10 Commits
Author SHA1 Message Date
lbgos 8009f79310 Arch packages: release r31 2026-10-18 21:33:32 +03:00
lbgos d157d0ab51 roomsetup: seed universe 2 for a first Room Setup
Room Setup starts in the session universe 3 and the driver switches to
universe 2 once tracking reports setup. On a first setup the chaperone
file has no universe 2, so SteamVR logs "Failed to update shared data"
on every switch and has no calibration for the frame being drawn in:
the floor step shows no floor and the draw step registers no strokes.
Reported logs show this on all five switches; VM 124 always had a
universe 2 and never logged it.

When the file has no universe 2, write it with the same plain origin as
universe 3, so the switch keeps the frame. Accept replaces it with the
drawn room. A seeded entry has no play area or bounds, so the world map
treats it like a missing boundary. An existing universe 2 is untouched.
2026-10-18 21:21:47 +03:00
lbgos 1fb075c174 basalt: stop growing execution stats in live trackers
Basalt's VIO estimators append every optimize() timing sample to
stats_all_ and stats_sums_. Only debug_finalize() reads them, and VIT
never calls it, so the history grows for as long as tracking runs
(about 1.6 MB/min in a 30 Hz stereo + 500 Hz IMU harness). Patch 0004
disables the stats for VIT trackers and skips the per-iteration
/proc/self/statm read that only fed them.
2026-10-18 21:21:47 +03:00
lbgos e16f571e57 steamvr_drv: name the RLIMIT_RTTIME compositor kill in vrserver.txt
vrcompositor inherits vrserver's zero RLIMIT_RTTIME. With CAP_SYS_NICE on
vrcompositor-launcher its realtime threads are SIGKILLed by the kernel and
SteamVR only reports a compositor crash. Log the cause and the setcap -r
fix once at driver start.
2026-10-18 21:21:47 +03:00
lbgos 5fb003aaab u_linux: keep normal priority under a zero RLIMIT_RTTIME
With a zero RLIMIT_RTTIME the kernel SIGKILLs the whole process at the
first scheduler tick that lands while a realtime thread runs, even if the
thread sleeps every iteration. SteamVR's vrserver runs drivers with this
limit, so the Rift S USB thread's realtime request would take vrserver
down wherever it succeeds (CAP_SYS_NICE or RLIMIT_RTPRIO 99).
2026-10-18 21:21:47 +03:00
lbgos 2d0c513209 README: Flatpak and Snap Steam are not supported 2026-10-18 19:23:06 +03:00
lbgos 5caba039b7 Arch packages: release r30 2026-10-17 14:47:43 +03:00
lbgos b23b5e24d0 README: universal install, controller pairing; install.sh WebKitGTK hint 2026-10-17 14:30:54 +03:00
lbgos 92d892f7ec Add Rift S controller pairing to Settings 2026-10-17 14:25:21 +03:00
lbgos 2036af9170 Universal x86_64 build for non-Arch distributions
Build the driver in the Steam Runtime sniper SDK and install it under
~/.local/share, which SteamVR sees inside its container, so no launch
option is needed. glibc 2.31 and old libwayland compatibility, a
relocatable overlay manifest, and the overlay in its own systemd unit.
2026-10-17 14:25:21 +03:00
34 changed files with 1579 additions and 45 deletions

No files matched your search

+4
View File
@@ -136,6 +136,10 @@ if(NOT WIN32)
find_package(Systemd MODULE)
find_package(OpenGLES MODULE COMPONENTS V3)
find_library(RT_LIBRARY rt)
# glibc before 2.34 (the Steam Runtime SDK) keeps shm_open in librt.
if(RT_LIBRARY)
link_libraries(${RT_LIBRARY})
endif()
endif()
if(NOT ANDROID AND NOT MSVC)
find_package(PkgConfig MODULE)
+18 -6
View File
@@ -15,14 +15,14 @@ Free and open source, built on Monado and Basalt. Steam and SteamVR are installe
- In-headset Room Setup with floor calibration, a persistent boundary and saved-room recognition.
- A stable dashboard and a held position with IMU rotation when headset tracking is lost.
- Headset and both Touch controller tracking, with controller haptics.
- Rift S Settings for device state, software IPD, recentering and Room Setup, with an 80 fps zero-copy headset preview and fullscreen. The preview drops to 1 fps in standby.
- Rift S Settings for device state, controller pairing, software IPD, recentering and Room Setup, with an 80 fps zero-copy headset preview and fullscreen. The preview drops to 1 fps in standby.
- An in-headset Desktop overlay with mouse, scrolling and keyboard input on KDE Plasma Wayland.
## Requirements
- Oculus Rift S, its Touch controllers, USB 3 and DisplayPort.
- An x86-64 Linux desktop with a systemd user session and local USB seat access.
- Native Steam with SteamVR installed from the Steam library.
- Native Steam with SteamVR installed from the Steam library. Flatpak and Snap Steam are not supported: Valve does not support SteamVR in them because they break DRM leasing and asynchronous reprojection.
- A Vulkan-capable GPU and drivers supported by SteamVR's direct display mode. Current testing uses an NVIDIA RTX 3090.
- The patched Basalt build. The Arch packages include it; an unpatched Basalt lacks the required reconnect support.
- KDE Plasma Wayland for the Desktop overlay.
@@ -34,17 +34,27 @@ Close SteamVR and Settings before installing or upgrading.
On Arch Linux and derivatives, install the prebuilt package from the [latest release](https://github.com/lbgos/rift-s-linux/releases/latest):
```sh
curl -LO https://github.com/lbgos/rift-s-linux/releases/download/r29/rift-s-linux-bin-29-1-x86_64.pkg.tar.zst
sudo pacman -U rift-s-linux-bin-29-1-x86_64.pkg.tar.zst
curl -LO https://github.com/lbgos/rift-s-linux/releases/download/r30/rift-s-linux-bin-30-1-x86_64.pkg.tar.zst
sudo pacman -U rift-s-linux-bin-30-1-x86_64.pkg.tar.zst
```
To build the latest `main` instead, run `makepkg -si` in [packaging/aur/basalt-monado-rifts](packaging/aur/basalt-monado-rifts), then in [packaging/aur/rift-s-linux-git](packaging/aur/rift-s-linux-git). AUR submission is pending. Other distributions build from source.
To build the latest `main` instead, run `makepkg -si` in [packaging/aur/basalt-monado-rifts](packaging/aur/basalt-monado-rifts), then in [packaging/aur/rift-s-linux-git](packaging/aur/rift-s-linux-git). AUR submission is pending.
On other distributions, install the universal build into your home directory. It needs no SteamVR launch option; the udev rule asks for your password once. Settings needs glibc 2.35 or newer and WebKitGTK 4.1 (Debian/Ubuntu `libwebkit2gtk-4.1-0`, Fedora `webkit2gtk4.1`).
```sh
curl -LO https://github.com/lbgos/rift-s-linux/releases/download/r30/rift-s-linux-r30-x86_64.tar.xz
tar -xf rift-s-linux-r30-x86_64.tar.xz
./rift-s-linux-r30-x86_64/install.sh
```
`install.sh --uninstall` removes it. Remove the universal build before installing a system package, and the reverse. [packaging/universal](packaging/universal) describes how it is built.
## Build from source
Install a C/C++ toolchain, CMake, Ninja, Eigen, TBB, fmt, OpenCV, Vulkan headers and loader, glslang, libusb, SDL2, cJSON, libcap, systemd and the X11, Wayland and DRM development libraries. The Desktop overlay also needs PipeWire development files, wayland-scanner, wayland-protocols and plasma-wayland-protocols. Settings needs Rust/Cargo, Node.js, pnpm, GTK3, WebKitGTK 4.1, shaderc's `glslc` and desktop-file-utils.
Build Basalt at the supported revision, applying all three patches in order:
Build Basalt at the supported revision, applying all four patches in order:
```sh
git clone https://github.com/lbgos/rift-s-linux.git
@@ -111,6 +121,8 @@ pnpm tauri build --no-bundle -- --locked
5. Open Rift S Settings from the desktop launcher. For the full-resolution preview, enable its next-start preference and relaunch SteamVR once. On KDE, the native VR View opens minimized to supply the preview.
6. Open the dashboard's Desktop tab to use the KDE desktop inside the headset.
To pair a new or replacement Touch controller, close SteamVR and use Pair controller in Settings. Hold Menu + Y (left) or Oculus + B (right) until the controller light flashes, then select it.
## Known issues
- USB or DisplayPort loss can crash SteamVR's compositor. Reconnect the headset and relaunch SteamVR manually. Recovery does not launch SteamVR automatically.
+4 -1
View File
@@ -53,6 +53,8 @@ foreach(_xml ${_protocols})
endforeach()
set(RIFTS_DESKTOP_MANIFEST "${CMAKE_INSTALL_FULL_DATADIR}/rifts-desktop/rifts-desktop.vrmanifest")
# Relocated installs find the manifest relative to the binary.
file(RELATIVE_PATH RIFTS_DESKTOP_MANIFEST_FROM_BIN "${CMAKE_INSTALL_FULL_BINDIR}" "${RIFTS_DESKTOP_MANIFEST}")
add_executable(rifts-desktop
src/capture.cpp
src/gpu.cpp
@@ -65,7 +67,8 @@ set_target_properties(rifts-desktop PROPERTIES CXX_STANDARD 20 CXX_STANDARD_REQU
target_include_directories(rifts-desktop PRIVATE
"${CMAKE_CURRENT_BINARY_DIR}"
"${CMAKE_CURRENT_SOURCE_DIR}/../src/external/openvr_includes")
target_compile_definitions(rifts-desktop PRIVATE RIFTS_DESKTOP_MANIFEST="${RIFTS_DESKTOP_MANIFEST}")
target_compile_definitions(rifts-desktop PRIVATE RIFTS_DESKTOP_MANIFEST="${RIFTS_DESKTOP_MANIFEST}"
RIFTS_DESKTOP_MANIFEST_FROM_BIN="${RIFTS_DESKTOP_MANIFEST_FROM_BIN}")
target_link_libraries(rifts-desktop PRIVATE PkgConfig::RIFTS_DESKTOP_DEPS Vulkan::Vulkan ${CMAKE_DL_LIBS})
configure_file(rifts-desktop.desktop.in rifts-desktop.desktop @ONLY)
+14 -2
View File
@@ -289,7 +289,7 @@ private:
auto *self = static_cast<KWinDesktop *>(data);
if (strcmp(interface, wl_output_interface.name) == 0 && version >= 4) {
auto o = std::make_unique<Output>(Output{self, id});
o->output = static_cast<wl_output *>(wl_registry_bind(registry, id, &wl_output_interface, 4));
o->output = static_cast<wl_output *>(wl_registry_bind(registry, id, &wl_output_interface, kOutputVersion));
wl_output_add_listener(o->output, &kOutput, o.get());
self->add_xdg(o.get());
self->outputs_.push_back(std::move(o));
@@ -425,14 +425,26 @@ private:
.mode = [](void *, wl_output *, uint32_t, int32_t, int32_t, int32_t) {},
.done = output_done,
.scale = [](void *, wl_output *, int32_t) {},
#ifdef WL_OUTPUT_NAME_SINCE_VERSION
.name = output_name,
.description = [](void *, wl_output *, const char *) {},
#endif
};
// Older libwayland headers (the Steam Runtime SDK) stop at version 3; xdg-output
// then supplies the same connector name.
#ifdef WL_OUTPUT_NAME_SINCE_VERSION
static constexpr uint32_t kOutputVersion = 4;
#else
static constexpr uint32_t kOutputVersion = 3;
#endif
static constexpr zxdg_output_v1_listener kXdgOutput = {
.logical_position = xdg_position,
.logical_size = xdg_size,
.done = [](void *, zxdg_output_v1 *) {},
.name = [](void *, zxdg_output_v1 *, const char *) {},
.name = [](void *data, zxdg_output_v1 *, const char *name) {
auto *o = static_cast<Output *>(data);
if (o->name.empty()) o->name = name;
},
.description = [](void *, zxdg_output_v1 *, const char *) {},
};
static constexpr kde_output_order_v1_listener kOrder = {order_output, order_done};
+20 -2
View File
@@ -27,6 +27,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
@@ -36,6 +37,9 @@
#ifndef RIFTS_DESKTOP_MANIFEST
#define RIFTS_DESKTOP_MANIFEST "/usr/share/rifts-desktop/rifts-desktop.vrmanifest"
#endif
#ifndef RIFTS_DESKTOP_MANIFEST_FROM_BIN
#define RIFTS_DESKTOP_MANIFEST_FROM_BIN "../share/rifts-desktop/rifts-desktop.vrmanifest"
#endif
namespace rd {
namespace {
@@ -193,11 +197,25 @@ write_ppm(const std::string &path, const std::vector<uint8_t> &bgra, uint32_t w,
return fclose(f) == 0;
}
// The installed manifest, next to this binary when the install was moved to another prefix.
const std::string &
manifest_path()
{
static const std::string path = [] {
std::error_code error;
auto exe = std::filesystem::read_symlink("/proc/self/exe", error);
auto local = (exe.parent_path() / RIFTS_DESKTOP_MANIFEST_FROM_BIN).lexically_normal();
return !error && std::filesystem::is_regular_file(local, error) ? local.string()
: std::string(RIFTS_DESKTOP_MANIFEST);
}();
return path;
}
bool
ensure_registered(const Vr &vr)
{
bool installed = vr.apps->IsApplicationInstalled(kAppKey);
auto result = vr.apps->AddApplicationManifest(RIFTS_DESKTOP_MANIFEST, false);
auto result = vr.apps->AddApplicationManifest(manifest_path().c_str(), false);
if (result != vr::VRApplicationError_None) {
fprintf(stderr, "rifts-desktop: manifest registration: %s\n", vr.apps->GetApplicationsErrorNameFromEnum(result));
return false;
@@ -223,7 +241,7 @@ run_registration(bool remove)
// Not LaunchDashboardOverlay: SteamVR identifies this process as the app by its path,
// so the overlay would count as running. Callers start it themselves.
if (remove) {
vr.apps->RemoveApplicationManifest(RIFTS_DESKTOP_MANIFEST);
vr.apps->RemoveApplicationManifest(manifest_path().c_str());
} else {
if (!ensure_registered(vr)) { vr_disconnect(); return 1; }
}
+3 -1
View File
@@ -1,7 +1,7 @@
pkgbase = basalt-monado-rifts
pkgdesc = Basalt tracking library with Rift S lifecycle and worker defaults
pkgver = 0.1.r544.gdf6e970
pkgrel = 1
pkgrel = 2
url = https://gitlab.freedesktop.org/mateosss/basalt
arch = x86_64
license = BSD-2-Clause
@@ -35,6 +35,7 @@ pkgbase = basalt-monado-rifts
source = 0001-keep-imu-frequency-per-tracker.patch
source = 0002-limit-workers-with-driver-calibration.patch
source = 0003-safe-recreation-and-realtime-worker-budget.patch
source = 0004-skip-unread-execution-stats.patch
sha256sums = SKIP
sha256sums = SKIP
sha256sums = SKIP
@@ -45,5 +46,6 @@ pkgbase = basalt-monado-rifts
sha256sums = a0bc2a766ad6ae5fdfa33cd80d45d12798d43efa4c34605ee6cf20bae157d18d
sha256sums = 607d53dadbd8ca5b0978d10378e631539bc8047ca0fce317b90d68a52f785a52
sha256sums = a434e76c66c129aa3a900ed151dd2dd9bc98b671451dda26855a438cff08b2ac
sha256sums = 1f8848384b376addb471add3b9efea8c6effbfe7fe615c2beb3ad5ebdf432290
pkgname = basalt-monado-rifts
@@ -0,0 +1,135 @@
--- a/include/basalt/utils/time_utils.hpp
+++ b/include/basalt/utils/time_utils.hpp
@@ -124,6 +124,9 @@ class ExecutionStats {
std::string format_;
};
+ //! Live trackers never read the history, so a disabled instance keeps no values.
+ bool enabled = true;
+
Meta& add(const std::string& name, double value);
Meta& add(const std::string& name, const Eigen::VectorXd& value);
Meta& add(const std::string& name, const Eigen::VectorXf& value);
@@ -139,6 +142,7 @@ class ExecutionStats {
private:
std::unordered_map<std::string, Meta> stats_;
std::vector<std::string> order_;
+ Meta discarded_; // returned by add() while disabled
};
} // namespace basalt
--- a/include/basalt/vi_estimator/sqrt_keypoint_vio.h
+++ b/include/basalt/vi_estimator/sqrt_keypoint_vio.h
@@ -130,6 +130,7 @@ class SqrtKeypointVioEstimator : public VioEstimatorBase, public SqrtBundleAdjus
bool show_uimat(UIMAT m) const;
void debug_finalize() override;
+ void disable_stats() override { stats_all_.enabled = stats_sums_.enabled = false; }
void logMargNullspace();
Eigen::VectorXd checkMargNullspace() const;
--- a/include/basalt/vi_estimator/sqrt_keypoint_vo.h
+++ b/include/basalt/vi_estimator/sqrt_keypoint_vo.h
@@ -176,6 +176,7 @@ class SqrtKeypointVoEstimator : public VioEstimatorBase, public SqrtBundleAdjust
Sophus::SE3d getT_w_i_init() override { return T_w_i_init.template cast<double>(); }
void debug_finalize() override;
+ void disable_stats() override { stats_all_.enabled = stats_sums_.enabled = false; }
EIGEN_MAKE_ALIGNED_OPERATOR_NEW
--- a/include/basalt/vi_estimator/vio_estimator.h
+++ b/include/basalt/vi_estimator/vio_estimator.h
@@ -136,6 +136,9 @@ class VioEstimatorBase {
virtual inline void debug_finalize() {}
+ //! Stop keeping the per-frame timing stats that only debug_finalize() reads.
+ virtual void disable_stats() {}
+
virtual Sophus::SE3d getT_w_i_init() = 0;
// Legacy functions. Should not be used in the new code.
--- a/src/utils/time_utils.cpp
+++ b/src/utils/time_utils.cpp
@@ -31,6 +31,7 @@ Scalar variance(const Eigen::Matrix<Scalar, N, 1>& vec) {
}
ExecutionStats::Meta& ExecutionStats::add(const std::string& name, double value) {
+ if (!enabled) return discarded_;
auto [it, new_item] = stats_.try_emplace(name);
if (new_item) {
order_.push_back(name);
@@ -41,6 +42,7 @@ ExecutionStats::Meta& ExecutionStats::add(const std::string& name, double value)
}
ExecutionStats::Meta& ExecutionStats::add(const std::string& name, const Eigen::VectorXd& value) {
+ if (!enabled) return discarded_;
auto [it, new_item] = stats_.try_emplace(name);
if (new_item) {
order_.push_back(name);
@@ -56,6 +58,7 @@ ExecutionStats::Meta& ExecutionStats::add(const std::string& name, const Eigen::
}
void ExecutionStats::merge_all(const ExecutionStats& other) {
+ if (!enabled) return;
for (const auto& name : other.order_) {
const auto& meta = other.stats_.at(name);
std::visit(
@@ -80,6 +83,7 @@ overload(Ts...) -> overload<Ts...>;
} // namespace
void ExecutionStats::merge_sums(const ExecutionStats& other) {
+ if (!enabled) return;
for (const auto& name : other.order_) {
const auto& meta = other.stats_.at(name);
std::visit(overload{[&](const std::vector<double>& data) {
--- a/src/vi_estimator/sqrt_keypoint_vio.cpp
+++ b/src/vi_estimator/sqrt_keypoint_vio.cpp
@@ -1224,6 +1224,7 @@ bool SqrtKeypointVioEstimator<Scalar_>::optimize() {
// timing
ExecutionStats stats;
+ stats.enabled = stats_all_.enabled;
Timer timer_total;
Timer timer_iteration;
@@ -1544,7 +1545,7 @@ bool SqrtKeypointVioEstimator<Scalar_>::optimize() {
double cumulative_time = timer_total.elapsed();
stats.add("iteration", iteration_time).format("ms");
- {
+ if (stats.enabled) {
basalt::MemoryInfo mi;
if (get_memory_info(mi)) {
stats.add("resident_memory", mi.resident_memory);
--- a/src/vi_estimator/sqrt_keypoint_vo.cpp
+++ b/src/vi_estimator/sqrt_keypoint_vo.cpp
@@ -1108,6 +1108,7 @@ bool SqrtKeypointVoEstimator<Scalar_>::optimize() {
// timing
ExecutionStats stats;
+ stats.enabled = stats_all_.enabled;
Timer timer_total;
Timer timer_iteration;
@@ -1395,7 +1396,7 @@ bool SqrtKeypointVoEstimator<Scalar_>::optimize() {
double cumulative_time = timer_total.elapsed();
stats.add("iteration", iteration_time).format("ms");
- {
+ if (stats.enabled) {
basalt::MemoryInfo mi;
if (get_memory_info(mi)) {
stats.add("resident_memory", mi.resident_memory);
--- a/src/vit/vit_tracker.cpp
+++ b/src/vit/vit_tracker.cpp
@@ -398,6 +398,8 @@ struct Tracker::Implementation {
ASSERT_(image_data_queue != nullptr);
vio = VioEstimatorFactory::getVioEstimator(vio_config, calib, constants::g, true, use_double);
+ // Nothing reads these stats in a live session; they would grow for as long as it runs.
+ vio->disable_stats();
imu_data_queue = &vio->imu_data_queue;
ASSERT_(imu_data_queue != nullptr);
+4 -3
View File
@@ -1,7 +1,7 @@
# Maintainer: lbgos <285507185+lbgos@users.noreply.github.com>
pkgname=basalt-monado-rifts
pkgver=0.1.r544.gdf6e970
pkgrel=1
pkgrel=2
pkgdesc='Basalt tracking library with Rift S lifecycle and worker defaults'
arch=('x86_64')
url='https://gitlab.freedesktop.org/mateosss/basalt'
@@ -20,9 +20,10 @@ source=('basalt::git+https://gitlab.freedesktop.org/mateosss/basalt.git#commit=d
'cereal::git+https://github.com/USCiLab/cereal.git#commit=ebef1e929807629befafbb2918ea1a08c7194554'
'0001-keep-imu-frequency-per-tracker.patch'
'0002-limit-workers-with-driver-calibration.patch'
'0003-safe-recreation-and-realtime-worker-budget.patch')
'0003-safe-recreation-and-realtime-worker-budget.patch'
'0004-skip-unread-execution-stats.patch')
sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP'
'a0bc2a766ad6ae5fdfa33cd80d45d12798d43efa4c34605ee6cf20bae157d18d' '607d53dadbd8ca5b0978d10378e631539bc8047ca0fce317b90d68a52f785a52' 'a434e76c66c129aa3a900ed151dd2dd9bc98b671451dda26855a438cff08b2ac')
'a0bc2a766ad6ae5fdfa33cd80d45d12798d43efa4c34605ee6cf20bae157d18d' '607d53dadbd8ca5b0978d10378e631539bc8047ca0fce317b90d68a52f785a52' 'a434e76c66c129aa3a900ed151dd2dd9bc98b671451dda26855a438cff08b2ac' '1f8848384b376addb471add3b9efea8c6effbfe7fe615c2beb3ad5ebdf432290')
prepare() {
cd basalt
+3 -3
View File
@@ -1,6 +1,6 @@
pkgbase = rift-s-linux-bin
pkgdesc = Oculus Rift S on Linux SteamVR: driver, Room Setup, Settings app and in-headset desktop (prebuilt)
pkgver = 29
pkgver = 31
pkgrel = 1
url = https://github.com/lbgos/rift-s-linux
install = rift-s-linux.install
@@ -72,7 +72,7 @@ pkgbase = rift-s-linux-bin
conflicts = monado-rifts-git
options = !strip
options = !debug
source = https://github.com/lbgos/rift-s-linux/releases/download/r29/rift-s-linux-r29-x86_64.tar.zst
sha256sums = c0833f3f3ffa0537be444bfd52ea5f731e764420bd0249b505891a387b64c754
source = https://github.com/lbgos/rift-s-linux/releases/download/r31/rift-s-linux-r31-x86_64.tar.zst
sha256sums = b466138ac5f2bfa38d78335e4fdbee3d1b4c8efd3891cb0639d1eb28df86f47b
pkgname = rift-s-linux-bin
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: lbgos <285507185+lbgos@users.noreply.github.com>
pkgname=rift-s-linux-bin
pkgver=29
pkgver=31
pkgrel=1
pkgdesc='Oculus Rift S on Linux SteamVR: driver, Room Setup, Settings app and in-headset desktop (prebuilt)'
arch=('x86_64')
@@ -24,7 +24,7 @@ conflicts=('rift-s-linux' 'monado' 'monado-git' 'rifts-settings' 'basalt-monado-
install=rift-s-linux.install
options=('!strip' '!debug')
source=("https://github.com/lbgos/rift-s-linux/releases/download/r$pkgver/rift-s-linux-r$pkgver-x86_64.tar.zst")
sha256sums=('c0833f3f3ffa0537be444bfd52ea5f731e764420bd0249b505891a387b64c754')
sha256sums=('b466138ac5f2bfa38d78335e4fdbee3d1b4c8efd3891cb0639d1eb28df86f47b')
package() {
cp -a usr "$pkgdir/"
+43
View File
@@ -0,0 +1,43 @@
# Universal x86_64 build
A tarball for non-Arch distributions. `install.sh` puts everything under
`~/.local/share/rift-s-linux`, which SteamVR also sees inside the Steam Linux
Runtime container, so the driver needs no SteamVR launch option. Only the udev
rule needs root.
The driver, Basalt and their libraries are built in the Steam Runtime sniper SDK
(Debian 11, glibc 2.31) and bundle Basalt, oneTBB and a trimmed OpenCV. The
Settings app needs webkit2gtk 4.1, which the SDK lacks, so it is built in an
Ubuntu 22.04 root (glibc 2.35).
## Layout
`work/` is bind-mounted at `/work` in both roots:
- `src/monado` this repository
- `src/basalt` Basalt prepared by `packaging/aur/basalt-monado-rifts` (`makepkg -o`)
- `src/{opencv-4.12.0,oneTBB-2022.2.0,fmt-11.2.0}` upstream release tarballs
- `hostdeps/usr` header-only build deps extracted from the Arch packages
`boost`, `eigen`, `vulkan-headers`, `plasma-wayland-protocols`
`sysroot/` is `com.valvesoftware.SteamRuntime.Sdk-amd64,i386-sniper-sysroot.tar.gz`
from `repo.steampowered.com/steamrt-images-sniper/snapshots/`. `sdk` runs a
command in it with bwrap.
The Ubuntu root needs `build-essential pkg-config libwebkit2gtk-4.1-dev
libgtk-3-dev libsoup-3.0-dev libvulkan-dev libwayland-dev glslang-tools
python3`, rustup, Node 22 and pnpm 10. Ubuntu 22.04 has no `glslc` package; a
wrapper around `glslangValidator -V -x` that adds the surrounding braces is enough
for `settings-app/src-tauri/build.rs`.
## Build
```sh
./sdk /work/src/monado/packaging/universal/deps.sh # once
./sdk /work/src/monado/packaging/universal/build.sh
<ubuntu root> /work/src/monado/packaging/universal/settings.sh
packaging/universal/package.sh work <version> # host, needs patchelf
```
The result is `work/dist/rift-s-linux-<version>-x86_64.tar.xz`. Users extract it
and run `./install.sh`; `./install.sh --uninstall` removes it.
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# Build Basalt and rift-s-linux against the bundled deps; stage the install under /work/stage.
set -eu
export CC=gcc-14 CXX=g++-14
P=/work/prefix H=/work/hostdeps/usr J=${J:-8}
export CMAKE_PREFIX_PATH="$P:$H"
cmake -S /work/src/basalt -B /work/build/basalt -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=$P -DCMAKE_INSTALL_LIBDIR=lib -DBUILD_TESTS=OFF -DBUILD_TESTING=OFF \
-DBASALT_INSTANTIATIONS_DOUBLE=OFF -DBASALT_BUILTIN_EIGEN=OFF -DEIGEN_ROOT=$H/include/eigen3 \
-DBoost_INCLUDE_DIR=$H/include -DBASALT_BUILD_SHARED_LIBRARY_ONLY=ON -DBASALT_ENABLE_ROSBAG2=OFF \
-DBASALT_BUILD_VISUALIZATION=OFF -DCXX_MARCH=x86-64 -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCMAKE_INSTALL_RPATH="\$ORIGIN"
cmake --build /work/build/basalt -j${BJ:-4} && cmake --install /work/build/basalt
nm -D /work/build/basalt/libbasalt.so | grep -q " T vit_tracker_supports_safe_recreation"
drivers=$(sed -n "s/.*(\(XRT_BUILD_DRIVER_[A-Z0-9_]*\) .*/-D\1=OFF/p" /work/src/monado/CMakeLists.txt | tr "\n" " ")
# bin/ and share/steamvr-monado/bin/linux64/ both reach the bundled lib/.
cmake -S /work/src/monado -B /work/build/monado -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/opt/rift-s-linux -DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_INSTALL_LIBEXECDIR=lib \
-DCMAKE_INSTALL_RPATH="\$ORIGIN/../lib:\$ORIGIN/../../../../lib" \
-DBUILD_TESTING=ON -DXRT_BUILD_SAMPLES=OFF $drivers \
-DXRT_BUILD_DRIVER_RIFT_S=ON -DXRT_BUILD_DRIVER_SIMULATED=ON \
-DXRT_FEATURE_STEAMVR_PLUGIN=ON -DXRT_FEATURE_SLAM=ON \
-DXRT_HAVE_SYSTEM_CJSON=OFF -DXRT_HAVE_GST=OFF -DXRT_HAVE_BLUETOOTH=OFF \
-DXRT_HAVE_DBUS=OFF -DXRT_HAVE_HIDAPI=OFF -DXRT_HAVE_JPEG=OFF -DXRT_HAVE_LIBBSD=OFF \
-DXRT_HAVE_LIBUVC=OFF -DXRT_HAVE_OPENVR=OFF -DXRT_MODULE_MERCURY_HANDTRACKING=OFF \
-DRIFTS_DESKTOP_OVERLAY=ON -DOpenCV_DIR=$P/lib/cmake/opencv4 -DEigen3_DIR=$H/share/eigen3/cmake
cmake --build /work/build/monado -j$J
VIT_SYSTEM_LIBRARY_PATH=$P/lib/libbasalt.so ctest --test-dir /work/build/monado --output-on-failure -j4
rm -rf /work/stage && DESTDIR=/work/stage cmake --install /work/build/monado
echo build-ok
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Build the bundled third-party libraries into /work/prefix inside the sniper SDK.
set -eu
export CC=gcc-14 CXX=g++-14
P=/work/prefix
J=${J:-8}
common="-G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=$P -DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_POSITION_INDEPENDENT_CODE=ON"
cd /work/src
cmake -S fmt-11.2.0 -B /work/build/fmt $common -DFMT_TEST=OFF -DFMT_DOC=OFF -DBUILD_SHARED_LIBS=OFF
cmake --build /work/build/fmt -j$J && cmake --install /work/build/fmt
cmake -S oneTBB-2022.2.0 -B /work/build/tbb $common -DTBB_TEST=OFF -DTBB_EXAMPLES=OFF -DTBB_STRICT=OFF -DTBBMALLOC_BUILD=OFF
cmake --build /work/build/tbb -j$J && cmake --install /work/build/tbb
cmake -S opencv-4.12.0 -B /work/build/opencv $common \
-DBUILD_LIST=core,imgproc,imgcodecs,highgui,video,calib3d,features2d,flann \
-DWITH_QT=OFF -DWITH_GTK=OFF -DWITH_FFMPEG=OFF -DWITH_GSTREAMER=OFF -DWITH_V4L=OFF -DWITH_OPENCL=OFF \
-DWITH_IPP=OFF -DWITH_ITT=OFF -DWITH_PROTOBUF=OFF -DWITH_TBB=OFF -DWITH_EIGEN=OFF -DWITH_LAPACK=OFF \
-DWITH_TIFF=OFF -DWITH_WEBP=OFF -DWITH_OPENEXR=OFF -DWITH_OPENJPEG=OFF -DWITH_JASPER=OFF -DWITH_AVIF=OFF \
-DWITH_IMGCODEC_HDR=OFF -DWITH_IMGCODEC_SUNRASTER=OFF -DWITH_IMGCODEC_PXM=OFF -DWITH_IMGCODEC_PFM=OFF \
-DBUILD_PNG=ON -DBUILD_JPEG=ON -DBUILD_ZLIB=ON -DWITH_ADE=OFF -DWITH_VA=OFF -DWITH_1394=OFF \
-DBUILD_TESTS=OFF -DBUILD_PERF_TESTS=OFF -DBUILD_EXAMPLES=OFF -DBUILD_opencv_apps=OFF -DBUILD_JAVA=OFF \
-DBUILD_opencv_python3=OFF -DOPENCV_GENERATE_PKGCONFIG=OFF -DCPU_BASELINE=SSE3 -DOPENCV_SKIP_PYTHON_LOADER=ON
cmake --build /work/build/opencv -j$J && cmake --install /work/build/opencv
echo deps-ok
+84
View File
@@ -0,0 +1,84 @@
#!/bin/sh
# Install or remove the universal rift-s-linux build for the current user.
#
# ./install.sh install into ~/.local/share/rift-s-linux
# ./install.sh --uninstall remove it again
#
# Everything lands in the user's home, which SteamVR can also see inside the Steam
# Linux Runtime container. Only the udev rule for headset access needs root.
set -eu
BUILD_PREFIX=/opt/rift-s-linux
here=$(cd "$(dirname "$0")" && pwd)
data=${XDG_DATA_HOME:-$HOME/.local/share}
config=${XDG_CONFIG_HOME:-$HOME/.config}
prefix=$data/rift-s-linux
units=$config/systemd/user
service=rifts-steamvr-registration.service
layer=$data/vulkan/implicit_layer.d/monado_roomsetup_vulkan.json
rule=/etc/udev/rules.d/70-monado-rifts.rules
desktop_files="rifts-steamvr.desktop rifts-desktop.desktop rifts-settings.desktop"
[ "$(id -u)" -ne 0 ] || { echo 'Run as your normal user, not root.' >&2; exit 1; }
as_root() {
if command -v sudo >/dev/null; then sudo "$@"; else pkexec "$@"; fi
}
uninstall() {
if [ -x "$prefix/bin/rifts-setup" ]; then
systemctl --user disable --now "$service" 2>/dev/null || true
"$prefix/bin/rifts-setup" uninstall || true
fi
rm -f "$units/$service" "$layer"
for file in $desktop_files; do rm -f "$data/applications/$file"; done
rm -rf "$prefix"
systemctl --user daemon-reload 2>/dev/null || true
if [ -e "$rule" ]; then
as_root rm -f "$rule" && as_root udevadm control --reload-rules || true
fi
echo 'rift-s-linux removed.'
}
if [ "${1:-}" = --uninstall ]; then
uninstall
exit
fi
if [ -d /usr/share/steamvr-monado ]; then
echo 'A system-wide rift-s-linux package is installed. Remove it first.' >&2
exit 1
fi
# Stop the running watcher before replacing its files.
systemctl --user stop "$service" 2>/dev/null || true
rm -rf "$prefix.new"
mkdir -p "$prefix.new"
cp -a "$here/files/." "$prefix.new/"
# Text files carry the build prefix; binaries locate their files at run time.
grep -rlI --null "$BUILD_PREFIX" "$prefix.new" | xargs -0 -r sed -i "s|$BUILD_PREFIX|$prefix|g"
rm -rf "$prefix"
mv "$prefix.new" "$prefix"
mkdir -p "$units" "$(dirname "$layer")" "$data/applications"
cp "$prefix/lib/systemd/user/$service" "$units/$service"
cp "$prefix/share/vulkan/implicit_layer.d/monado_roomsetup_vulkan.json" "$layer"
for file in $desktop_files; do
if [ -f "$prefix/share/applications/$file" ]; then cp "$prefix/share/applications/$file" "$data/applications/$file"; fi
done
if ! cmp -s "$prefix/share/udev/70-monado-rifts.rules" "$rule" 2>/dev/null; then
echo 'Installing the udev rule for headset access (needs your password once).'
as_root install -Dm644 "$prefix/share/udev/70-monado-rifts.rules" "$rule"
as_root udevadm control --reload-rules
as_root udevadm trigger --subsystem-match=usb --attr-match=idVendor=2833 || true
as_root udevadm trigger --subsystem-match=hidraw || true
fi
systemctl --user daemon-reload
systemctl --user enable --now "$service"
echo 'rift-s-linux installed. Reconnect the Rift S, then start SteamVR from Steam.'
if ldd "$prefix/bin/rifts-settings" 2>/dev/null | grep -q 'not found'; then
echo 'The Settings app needs WebKitGTK 4.1 and glibc 2.35 or newer'
echo '(Debian/Ubuntu: libwebkit2gtk-4.1-0, Fedora: webkit2gtk4.1, openSUSE: libwebkit2gtk-4_1-0).'
fi
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
# Assemble the universal tarball from a finished SDK build (see build.sh).
#
# package.sh <work dir> <version>
#
# <work dir> holds src/{monado,basalt,...}, prefix/ (bundled libraries) and
# stage/opt/rift-s-linux (the installed tree). Needs patchelf and xz on the host.
set -eu
work=$(cd "$1" && pwd)
version=$2
name=rift-s-linux-$version-x86_64
out=$work/dist/$name
files=$out/files
src=$work/src
licenses=$files/share/licenses/rift-s-linux
rm -rf "$out"
mkdir -p "$files"
cp -a "$work/stage/opt/rift-s-linux/." "$files/"
# The universal install never registers itself as the system OpenXR runtime.
rm -rf "$files/share/openxr" "$files/lib/libopenxr_monado.so"
# Bundled libraries: Basalt and its OpenCV/oneTBB. Everything else comes from the
# host or, for the SteamVR driver, from the Steam Linux Runtime.
for lib in libbasalt.so.2 libtbb.so.12 $(cd "$work/prefix/lib" && ls libopencv_*.so.412); do
cp -L "$work/prefix/lib/$lib" "$files/lib/$lib"
done
ln -s libbasalt.so.2 "$files/lib/libbasalt.so"
# Relocatable lookup from each install location to the bundled lib/.
set_rpath() {
for f in "$@"; do
if [ ! -L "$f" ] && file -b "$f" | grep -q ELF; then patchelf --set-rpath "$rpath" "$f"; fi
done
}
rpath='$ORIGIN/../lib' set_rpath "$files"/bin/*
rpath='$ORIGIN' set_rpath "$files"/lib/*.so*
rpath='$ORIGIN/../../../../lib' set_rpath "$files"/share/steamvr-monado/bin/linux64/*
install -Dm644 "$src/monado/packaging/aur/rift-s-linux-git/70-monado-rifts.rules" \
"$files/share/udev/70-monado-rifts.rules"
mkdir -p "$licenses"
install -m644 "$src/monado/LICENSE" "$licenses/LICENSE"
cp -a "$src/monado/LICENSES" "$licenses/Monado"
(cd "$src/monado" && find src/external -type f \( -iname LICENSE -o -iname LICENSE.txt -o -iname COPYING \
-o -iname '*.license' \) -exec install -Dm644 {} "$licenses/{}" \;)
for item in LICENSE thirdparty/basalt-headers/LICENSE thirdparty/basalt-headers/thirdparty/Sophus/LICENSE.txt \
thirdparty/basalt-headers/thirdparty/cereal/LICENSE thirdparty/CLI11/LICENSE thirdparty/magic_enum/LICENSE \
thirdparty/opengv/License.txt thirdparty/vit/LICENSE; do
install -Dm644 "$src/basalt/$item" "$licenses/Basalt/$item"
done
cp -a "$work/prefix/share/licenses/opencv4" "$licenses/OpenCV"
install -m644 "$src"/opencv-*/LICENSE "$licenses/OpenCV/LICENSE"
install -Dm644 "$src"/oneTBB-*/LICENSE.txt "$licenses/oneTBB/LICENSE.txt"
install -Dm644 "$src"/fmt-*/LICENSE "$licenses/fmt/LICENSE"
install -m755 "$src/monado/packaging/universal/install.sh" "$out/install.sh"
tar -C "$work/dist" --sort=name --owner=0 --group=0 --numeric-owner -cJf "$work/dist/$name.tar.xz" "$name"
echo "$work/dist/$name.tar.xz"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# Run a command inside the Steam Runtime sniper SDK sysroot, with ~/src/universal/work at /work.
here=$(dirname "$(readlink -f "$0")")
exec bwrap --bind "$here/sysroot" / --dev /dev --proc /proc --tmpfs /tmp \
--bind "$here/work" /work --ro-bind /etc/resolv.conf /etc/resolv.conf \
--setenv HOME /work/home --setenv PATH /usr/local/bin:/usr/bin:/bin --chdir /work "$@"
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
# Build the Settings app and add it to /work/stage. Runs in an Ubuntu 22.04 root
# (webkit2gtk 4.1, glibc 2.35) after build.sh; needs cargo, pnpm and glslc.
set -eu
app=/work/src/monado/settings-app
aur=/work/src/monado/packaging/aur/rift-s-linux-git
dest=/work/stage/opt/rift-s-linux
licenses=$dest/share/licenses/rift-s-linux
meta=/work/build/settings
mkdir -p "$meta"
cd "$app"
pnpm install --frozen-lockfile
pnpm build
# custom-protocol embeds the built frontend instead of using the dev server.
cargo build --manifest-path src-tauri/Cargo.toml --release --locked --features custom-protocol -j${J:-8}
cargo test --manifest-path src-tauri/Cargo.toml --release --locked --features custom-protocol -j${J:-8}
cargo metadata --manifest-path src-tauri/Cargo.toml --locked --features custom-protocol --format-version 1 \
--filter-platform x86_64-unknown-linux-gnu > "$meta/rust.json"
pnpm licenses list --prod --json > "$meta/js.json"
install -Dm755 src-tauri/target/release/rifts-settings "$dest/bin/rifts-settings"
install -Dm644 rifts-settings.desktop "$dest/share/applications/rifts-settings.desktop"
sed -i -e "s|^Exec=.*|Exec=/opt/rift-s-linux/bin/rifts-settings|" \
-e "s|^Icon=.*|Icon=/opt/rift-s-linux/share/icons/hicolor/128x128/apps/rifts-settings.png|" \
"$dest/share/applications/rifts-settings.desktop"
install -Dm644 src-tauri/icons/icon.png "$dest/share/icons/hicolor/128x128/apps/rifts-settings.png"
install -Dm755 install-vrview-rule.sh "$dest/share/rifts-settings/install-vrview-rule.sh"
install -Dm644 vrview.kwinrule "$dest/share/rifts-settings/vrview.kwinrule"
install -Dm644 LICENSE "$licenses/Settings-MIT"
install -Dm644 src/fonts/OFL.txt "$licenses/OpenSans-OFL"
install -Dm644 src-tauri/native/OpenVR-LICENSE "$licenses/Settings-OpenVR"
python3 "$aur/settings-licenses.py" "$meta/rust.json" "$meta/js.json" "$aur/settings-license-fallbacks.json" \
> "$licenses/Settings-third-party-notices.txt"
echo settings-ok
@@ -0,0 +1,135 @@
--- a/include/basalt/utils/time_utils.hpp
+++ b/include/basalt/utils/time_utils.hpp
@@ -124,6 +124,9 @@ class ExecutionStats {
std::string format_;
};
+ //! Live trackers never read the history, so a disabled instance keeps no values.
+ bool enabled = true;
+
Meta& add(const std::string& name, double value);
Meta& add(const std::string& name, const Eigen::VectorXd& value);
Meta& add(const std::string& name, const Eigen::VectorXf& value);
@@ -139,6 +142,7 @@ class ExecutionStats {
private:
std::unordered_map<std::string, Meta> stats_;
std::vector<std::string> order_;
+ Meta discarded_; // returned by add() while disabled
};
} // namespace basalt
--- a/include/basalt/vi_estimator/sqrt_keypoint_vio.h
+++ b/include/basalt/vi_estimator/sqrt_keypoint_vio.h
@@ -130,6 +130,7 @@ class SqrtKeypointVioEstimator : public VioEstimatorBase, public SqrtBundleAdjus
bool show_uimat(UIMAT m) const;
void debug_finalize() override;
+ void disable_stats() override { stats_all_.enabled = stats_sums_.enabled = false; }
void logMargNullspace();
Eigen::VectorXd checkMargNullspace() const;
--- a/include/basalt/vi_estimator/sqrt_keypoint_vo.h
+++ b/include/basalt/vi_estimator/sqrt_keypoint_vo.h
@@ -176,6 +176,7 @@ class SqrtKeypointVoEstimator : public VioEstimatorBase, public SqrtBundleAdjust
Sophus::SE3d getT_w_i_init() override { return T_w_i_init.template cast<double>(); }
void debug_finalize() override;
+ void disable_stats() override { stats_all_.enabled = stats_sums_.enabled = false; }
EIGEN_MAKE_ALIGNED_OPERATOR_NEW
--- a/include/basalt/vi_estimator/vio_estimator.h
+++ b/include/basalt/vi_estimator/vio_estimator.h
@@ -136,6 +136,9 @@ class VioEstimatorBase {
virtual inline void debug_finalize() {}
+ //! Stop keeping the per-frame timing stats that only debug_finalize() reads.
+ virtual void disable_stats() {}
+
virtual Sophus::SE3d getT_w_i_init() = 0;
// Legacy functions. Should not be used in the new code.
--- a/src/utils/time_utils.cpp
+++ b/src/utils/time_utils.cpp
@@ -31,6 +31,7 @@ Scalar variance(const Eigen::Matrix<Scalar, N, 1>& vec) {
}
ExecutionStats::Meta& ExecutionStats::add(const std::string& name, double value) {
+ if (!enabled) return discarded_;
auto [it, new_item] = stats_.try_emplace(name);
if (new_item) {
order_.push_back(name);
@@ -41,6 +42,7 @@ ExecutionStats::Meta& ExecutionStats::add(const std::string& name, double value)
}
ExecutionStats::Meta& ExecutionStats::add(const std::string& name, const Eigen::VectorXd& value) {
+ if (!enabled) return discarded_;
auto [it, new_item] = stats_.try_emplace(name);
if (new_item) {
order_.push_back(name);
@@ -56,6 +58,7 @@ ExecutionStats::Meta& ExecutionStats::add(const std::string& name, const Eigen::
}
void ExecutionStats::merge_all(const ExecutionStats& other) {
+ if (!enabled) return;
for (const auto& name : other.order_) {
const auto& meta = other.stats_.at(name);
std::visit(
@@ -80,6 +83,7 @@ overload(Ts...) -> overload<Ts...>;
} // namespace
void ExecutionStats::merge_sums(const ExecutionStats& other) {
+ if (!enabled) return;
for (const auto& name : other.order_) {
const auto& meta = other.stats_.at(name);
std::visit(overload{[&](const std::vector<double>& data) {
--- a/src/vi_estimator/sqrt_keypoint_vio.cpp
+++ b/src/vi_estimator/sqrt_keypoint_vio.cpp
@@ -1224,6 +1224,7 @@ bool SqrtKeypointVioEstimator<Scalar_>::optimize() {
// timing
ExecutionStats stats;
+ stats.enabled = stats_all_.enabled;
Timer timer_total;
Timer timer_iteration;
@@ -1544,7 +1545,7 @@ bool SqrtKeypointVioEstimator<Scalar_>::optimize() {
double cumulative_time = timer_total.elapsed();
stats.add("iteration", iteration_time).format("ms");
- {
+ if (stats.enabled) {
basalt::MemoryInfo mi;
if (get_memory_info(mi)) {
stats.add("resident_memory", mi.resident_memory);
--- a/src/vi_estimator/sqrt_keypoint_vo.cpp
+++ b/src/vi_estimator/sqrt_keypoint_vo.cpp
@@ -1108,6 +1108,7 @@ bool SqrtKeypointVoEstimator<Scalar_>::optimize() {
// timing
ExecutionStats stats;
+ stats.enabled = stats_all_.enabled;
Timer timer_total;
Timer timer_iteration;
@@ -1395,7 +1396,7 @@ bool SqrtKeypointVoEstimator<Scalar_>::optimize() {
double cumulative_time = timer_total.elapsed();
stats.add("iteration", iteration_time).format("ms");
- {
+ if (stats.enabled) {
basalt::MemoryInfo mi;
if (get_memory_info(mi)) {
stats.add("resident_memory", mi.resident_memory);
--- a/src/vit/vit_tracker.cpp
+++ b/src/vit/vit_tracker.cpp
@@ -398,6 +398,8 @@ struct Tracker::Implementation {
ASSERT_(image_data_queue != nullptr);
vio = VioEstimatorFactory::getVioEstimator(vio_config, calib, constants::g, true, use_double);
+ // Nothing reads these stats in a live session; they would grow for as long as it runs.
+ vio->disable_stats();
imu_data_queue = &vio->imu_data_queue;
ASSERT_(imu_data_queue != nullptr);
+13 -5
View File
@@ -8,6 +8,7 @@ import os
from pathlib import Path
import re
import select
import shutil
import subprocess
import sys
import time
@@ -16,8 +17,9 @@ DRIVER = Path('@CMAKE_INSTALL_FULL_DATADIR@/steamvr-monado')
DESKTOP = Path('@CMAKE_INSTALL_FULL_BINDIR@/rifts-desktop')
STEAMVR_APP = '250820'
# Steam starts SteamVR inside the Steam Linux Runtime container, which cannot see the
# host's /usr: the driver and its libraries would be missing. vrmonitor.sh starts
# SteamVR on the host instead.
# host's /usr: a system-wide driver and its libraries would be missing. vrmonitor.sh
# starts SteamVR on the host instead. The universal build installs into the home
# directory, which the container shares, and needs no launch option.
LAUNCH_SUFFIX = '/bin/vrmonitor.sh %command%'
LAUNCH_PATH = ['UserLocalConfigStore', 'Software', 'Valve', 'Steam', 'apps', STEAMVR_APP]
@@ -91,7 +93,8 @@ def register(home, registry_path, uninstall=False):
if not registry.get('log'):
pathreg(tool, 'setlog', str(root / 'logs'))
pathreg(tool, 'adddriver', str(DRIVER))
return True, roots, libraries, host_launch(roots, runtime)
# A home install drops the option a former system-wide install may have set.
return True, roots, libraries, host_launch(roots, runtime, uninstall=home in DRIVER.parents)
def vdf_tokens(text):
@@ -244,8 +247,13 @@ def host_launch(roots, runtime, uninstall=False):
def launch_desktop():
# The client registers its manifest and preserves the user's autolaunch choice.
# It also locks out duplicates when SteamVR has already started it.
subprocess.Popen([str(DESKTOP), '--autolaunch'], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
command = [str(DESKTOP), '--autolaunch']
# Run it in its own unit: this service's memory limit only covers the watcher.
if shutil.which('systemd-run'):
subprocess.run(['systemd-run', '--user', '--quiet', '--collect', '--', *command],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20)
else:
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
class DirectoryEvents:
+8
View File
@@ -1,5 +1,8 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod pairing;
mod pairing_protocol;
use chrono::{DateTime, Local};
use serde::Serialize;
use serde_json::{json, Value};
@@ -856,6 +859,10 @@ fn main() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
pairing::controller_pairing_status,
pairing::controller_pairing_start,
pairing::controller_pairing_select,
pairing::controller_pairing_cancel,
snapshot,
preview_layout,
preview_status,
@@ -869,6 +876,7 @@ fn main() {
.expect("Unable to start Rift S settings")
.run(|_, event| {
if matches!(event, tauri::RunEvent::Exit) {
pairing::shutdown();
if let Ok(_lock) = VR.lock() {
unsafe { rifts_shutdown() };
}
+328
View File
@@ -0,0 +1,328 @@
// Copyright 2026, lbgos
// SPDX-License-Identifier: BSL-1.0
use crate::pairing_protocol::{Device, Radio, Transport, LEFT, RIGHT};
use serde::Serialize;
use std::{
fs::{self, File, OpenOptions},
os::fd::AsRawFd,
sync::{mpsc, Mutex},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
#[derive(Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Status {
stage: String,
message: String,
candidates: Vec<Candidate>,
hand: String,
}
#[derive(Clone, Serialize)]
struct Candidate {
id: String,
}
enum Request {
Pair(u64),
Cancel,
}
#[derive(Default)]
struct Session {
status: Status,
sender: Option<mpsc::Sender<Request>>,
worker: Option<JoinHandle<()>>,
}
static SESSION: Mutex<Session> = Mutex::new(Session {
status: Status {
stage: String::new(),
message: String::new(),
candidates: Vec::new(),
hand: String::new(),
},
sender: None,
worker: None,
});
fn publish(stage: &str, message: impl Into<String>, devices: &[Device]) {
if let Ok(mut session) = SESSION.lock() {
session.status = Status {
stage: stage.into(),
message: message.into(),
candidates: devices
.iter()
.map(|device| Candidate {
id: format!("{:016x}", device.id),
})
.collect(),
hand: session.status.hand.clone(),
};
}
}
struct Hid(File);
impl Hid {
fn open() -> Result<Self, String> {
if radio_in_use() {
return Err("Close SteamVR and Monado before pairing a controller.".into());
}
let mut paths = Vec::new();
for entry in fs::read_dir("/sys/class/hidraw")
.map_err(|e| e.to_string())?
.flatten()
{
let Ok(device) = fs::canonicalize(entry.path().join("device")) else {
continue;
};
let interface = device.ancestors().any(|path| {
fs::read_to_string(path.join("bInterfaceNumber")).is_ok_and(|s| s.trim() == "06")
});
let headset = device.ancestors().any(|path| {
fs::read_to_string(path.join("idVendor")).is_ok_and(|s| s.trim() == "2833")
&& fs::read_to_string(path.join("idProduct")).is_ok_and(|s| s.trim() == "0051")
});
if interface && headset {
paths.push(std::path::Path::new("/dev").join(entry.file_name()));
}
}
if paths.len() != 1 {
return Err(if paths.is_empty() {
"Rift S headset is not connected."
} else {
"Connect only one Rift S headset before pairing."
}
.into());
}
let file = OpenOptions::new()
.read(true)
.write(true)
.open(&paths[0])
.map_err(|e| {
format!("Cannot open headset radio. Check Rift S device permissions: {e}")
})?;
unsafe extern "C" {
fn flock(fd: i32, operation: i32) -> i32;
}
if unsafe { flock(file.as_raw_fd(), 2 | 4) } < 0 {
return Err("Another pairing session owns the headset radio.".into());
}
Ok(Self(file))
}
}
impl Transport for Hid {
fn get(&mut self, report: u8) -> Result<Vec<u8>, String> {
let mut data = vec![0u8; 200];
data[0] = report;
let size = feature(&self.0, 0x07, &mut data)?;
data.truncate(size);
Ok(data)
}
fn set(&mut self, data: &[u8]) -> Result<(), String> {
// hidraw locks are advisory; the driver does not participate in them.
// Check again before every write if SteamVR was started during the scan.
if radio_in_use() {
return Err("A VR runtime started during pairing. Close it before retrying.".into());
}
let mut data = data.to_vec();
let size = feature(&self.0, 0x06, &mut data)?;
if size != data.len() {
return Err("Incomplete headset radio write. Pairing state may have changed.".into());
}
Ok(())
}
}
fn feature(file: &File, operation: u32, data: &mut [u8]) -> Result<usize, String> {
unsafe extern "C" {
fn ioctl(fd: i32, request: std::ffi::c_ulong, ...) -> i32;
}
// Linux HIDIOCGFEATURE/HIDIOCSFEATURE, both _IOC_READ | _IOC_WRITE.
let request = (3u32 << 30) | ((data.len() as u32) << 16) | (u32::from(b'H') << 8) | operation;
let result = unsafe {
ioctl(
file.as_raw_fd(),
request as std::ffi::c_ulong,
data.as_mut_ptr(),
)
};
if result < 0 {
Err(format!(
"Headset USB operation failed: {}",
std::io::Error::last_os_error()
))
} else {
Ok(result as usize)
}
}
fn run(kind: u32, receiver: mpsc::Receiver<Request>) {
let result = (|| {
let mut radio = Radio(Hid::open()?);
let previous = radio.mode()?;
// Restore the original mode even when entering discovery only partly succeeds.
let outcome = (|| {
radio.set_mode(1)?;
let deadline = Instant::now() + Duration::from_secs(90);
loop {
let paired = radio.list(0x11)?;
let candidates: Vec<_> = radio
.list(0x0c)?
.into_iter()
.filter(|device| {
device.kind == kind
&& device.id != 0
&& !device.corrupt
&& !paired.iter().any(|old| old.id == device.id)
})
.collect();
publish(
if candidates.is_empty() {
"searching"
} else {
"ready"
},
if candidates.is_empty() {
"Waiting for a controller."
} else {
"Select the controller to pair."
},
&candidates,
);
match receiver.recv_timeout(Duration::from_millis(500)) {
Ok(Request::Cancel) | Err(mpsc::RecvTimeoutError::Disconnected) => {
return Ok(false)
}
Ok(Request::Pair(id)) => {
let selected = candidates
.iter()
.find(|device| device.id == id)
.ok_or("Controller is no longer available. Scan again.")?;
publish(
"pairing",
"Pairing controller. Keep Settings open and SteamVR closed.",
&[],
);
radio.pair(selected).map_err(|error| format!("{error}. Pairing did not finish; the saved pairing list may have changed."))?;
return Ok(true);
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if Instant::now() >= deadline {
return Err("No controller selected within 90 seconds. Try again.".into());
}
}
})();
let restore = radio.set_mode(previous);
match (outcome, restore) {
(Ok(paired), Ok(())) => Ok(paired),
(Err(error), Ok(())) => Err(error),
(outcome, Err(error)) => Err(format!("{} Radio mode could not be restored: {error}. Reconnect the headset before starting SteamVR.",
match outcome { Ok(true) => "Controller was added to the pairing list.".into(), Ok(false) => "Scan cancelled.".into(), Err(error) => error })),
}
})();
match result {
Ok(true) => publish(
"complete",
"Controller added to the headset pairing list. You can start SteamVR.",
&[],
),
Ok(false) => publish("cancelled", "Pairing scan cancelled.", &[]),
Err(error) => publish("failed", error, &[]),
}
}
#[tauri::command]
pub fn controller_pairing_status() -> Result<Status, String> {
Ok(SESSION.lock().map_err(|e| e.to_string())?.status.clone())
}
#[tauri::command]
pub fn controller_pairing_start(hand: String) -> Result<(), String> {
let kind = match hand.as_str() {
"left" => LEFT,
"right" => RIGHT,
_ => return Err("Invalid controller hand".into()),
};
if radio_in_use() {
return Err("Close SteamVR and Monado before pairing a controller.".into());
}
let mut session = SESSION.lock().map_err(|e| e.to_string())?;
if session
.worker
.as_ref()
.is_some_and(|worker| !worker.is_finished())
{
return Err("A controller pairing session is already running.".into());
}
let (sender, receiver) = mpsc::channel();
session.sender = Some(sender);
session.status = Status {
stage: "searching".into(),
message: "Opening headset radio.".into(),
candidates: vec![],
hand,
};
session.worker = Some(thread::spawn(move || run(kind, receiver)));
Ok(())
}
fn radio_in_use() -> bool {
["vrserver", "monado-service", "monado-cli"]
.iter()
.any(|name| super::process_pid(name).is_some())
}
#[tauri::command]
pub fn controller_pairing_select(id: String) -> Result<(), String> {
let mut session = SESSION.lock().map_err(|e| e.to_string())?;
if session.status.stage != "ready"
|| !session
.status
.candidates
.iter()
.any(|candidate| candidate.id == id)
{
return Err("Controller is no longer available.".into());
}
let id = u64::from_str_radix(&id, 16).map_err(|e| e.to_string())?;
session
.sender
.as_ref()
.ok_or("No pairing session")?
.send(Request::Pair(id))
.map_err(|e| e.to_string())?;
session.status.stage = "pairing".into();
session.status.message = "Pairing controller.".into();
Ok(())
}
#[tauri::command]
pub fn controller_pairing_cancel() -> Result<(), String> {
let session = SESSION.lock().map_err(|e| e.to_string())?;
if !["searching", "ready"].contains(&session.status.stage.as_str()) {
return Err("Wait for the pairing transaction to finish.".into());
}
session
.sender
.as_ref()
.ok_or("No pairing session")?
.send(Request::Cancel)
.map_err(|e| e.to_string())
}
pub fn shutdown() {
let worker = if let Ok(mut session) = SESSION.lock() {
if let Some(sender) = session.sender.take() {
let _ = sender.send(Request::Cancel);
}
session.worker.take()
} else {
None
};
if let Some(worker) = worker {
let _ = worker.join();
}
}
@@ -0,0 +1,369 @@
// Copyright 2026, lbgos
// SPDX-License-Identifier: BSL-1.0
//! Rift S wireless-host HID protocol. Lengths include the report ID.
//! Pairing uses discovery mode, a temporary connection, then an app restart.
//! This module has no firmware-update or unpair commands.
use std::time::{Duration, Instant};
pub const LEFT: u32 = 0x13001101;
pub const RIGHT: u32 = 0x13011101;
#[derive(Clone, Debug, PartialEq)]
pub struct Device {
pub id: u64,
pub kind: u32,
pub corrupt: bool,
}
pub trait Transport {
fn get(&mut self, report: u8) -> Result<Vec<u8>, String>;
fn set(&mut self, data: &[u8]) -> Result<(), String>;
}
pub fn devices(data: &[u8], report: u8) -> Result<Vec<Device>, String> {
let count_offset = if report == 0x11 { 1 } else { 2 };
if data.first() != Some(&report) || data.len() <= count_offset {
return Err("Invalid controller list report".into());
}
let count = usize::from(data[count_offset]);
let start = count_offset + 1;
if count > 4 || data.len() < start + count * 28 {
return Err("Truncated or oversized controller list".into());
}
data[start..start + count * 28]
.chunks_exact(28)
.map(|record| {
Ok(Device {
id: u64::from_le_bytes(record[..8].try_into().unwrap()),
kind: u32::from_le_bytes(record[8..12].try_into().unwrap()),
corrupt: record[27] & 1 != 0,
})
})
.collect()
}
pub struct Radio<T>(pub T);
impl<T: Transport> Radio<T> {
fn idle(&mut self) -> Result<Vec<u8>, String> {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let data = self.0.get(0x0b)?;
if data.len() < 7 || data[0] != 0x0b {
return Err("Truncated wireless status".into());
}
if data[2] == 0 {
return Ok(data);
}
if Instant::now() >= deadline {
return Err("Headset radio is busy".into());
}
std::thread::sleep(Duration::from_millis(10));
}
}
pub fn command(&mut self, data: &[u8]) -> Result<Vec<u8>, String> {
let sequence = self.idle()?[1];
// Never retry a write: an ambiguous USB failure may have committed it.
self.0.set(data)?;
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let reply = self.idle()?;
if reply[1] != sequence {
let status = u32::from_le_bytes(reply[3..7].try_into().unwrap());
return if status == 0 {
Ok(reply)
} else {
Err(format!(
"Headset radio rejected command {:#04x}: {status:#x}",
data[0]
))
};
}
if Instant::now() >= deadline {
return Err("Headset radio did not acknowledge the command".into());
}
std::thread::sleep(Duration::from_millis(10));
}
}
pub fn mode(&mut self) -> Result<u8, String> {
let data = self.0.get(0x0a)?;
match data.as_slice() {
[0x0a, mode @ 0..=2, ..] => Ok(*mode),
_ => Err("Invalid headset radio mode".into()),
}
}
pub fn set_mode(&mut self, mode: u8) -> Result<(), String> {
self.command(&[0x0a, mode])?;
if self.mode()? != mode {
return Err("Headset radio mode did not change".into());
}
Ok(())
}
pub fn list(&mut self, report: u8) -> Result<Vec<Device>, String> {
devices(&self.0.get(report)?, report)
}
pub fn pair(&mut self, selected: &Device) -> Result<(), String> {
if ![LEFT, RIGHT].contains(&selected.kind) || selected.id == 0 || selected.corrupt {
return Err("Controller type or app firmware is unsupported".into());
}
let whitelist = self.list(0x11)?;
if whitelist.iter().any(|device| device.id == selected.id) {
return Err("This controller is already paired".into());
}
if whitelist.len() == 4 {
return Err("Headset pairing list is full. No existing pairing was removed.".into());
}
if !self.list(0x0c)?.contains(selected) {
return Err(
"Controller stopped advertising. Hold its pairing buttons and try again.".into(),
);
}
let mut connect = vec![0x0d];
connect.extend(selected.id.to_le_bytes());
connect.extend(4000u16.to_le_bytes());
if let Err(error) = self.command(&connect) {
let cleanup = self.command(&[0x0e, 0]);
return Err(match cleanup {
Ok(_) => error,
Err(cleanup) => format!("{error}. Temporary connection cleanup failed: {cleanup}"),
});
}
let result: Result<(), String> = (|| {
let mut pair = vec![0x0f];
pair.extend(selected.kind.to_le_bytes());
self.command(&pair)?;
// Register 0x15 restarts the existing app, with no flash data.
let mut restart = vec![0u8; 61];
restart[0] = 0x13;
restart[1..9].copy_from_slice(&selected.id.to_le_bytes());
restart[9] = 0x15;
restart[11..13].copy_from_slice(&1000u16.to_le_bytes());
self.command(&restart)?;
Ok(())
})();
// End the temporary radio connection on success and failure.
// The disconnect report carries only the low byte of the target ID.
let disconnect = self.command(&[0x0e, selected.id as u8]);
match (result, disconnect) {
(Err(error), Err(cleanup)) => {
return Err(format!(
"{error}. Temporary connection cleanup failed: {cleanup}"
))
}
(Err(error), _) => return Err(error),
(_, Err(error)) => return Err(error),
_ => {}
}
let deadline = Instant::now() + Duration::from_secs(2);
loop {
let saved = self.list(0x11)?;
if whitelist.iter().any(|old| {
!saved
.iter()
.any(|device| device.id == old.id && device.kind == old.kind)
}) {
return Err("An existing controller is missing from the pairing list. Stop and check the headset.".into());
}
if saved
.iter()
.any(|device| device.id == selected.id && device.kind == selected.kind)
{
return Ok(());
}
if Instant::now() >= deadline {
return Err(
"Pair command completed but the controller is absent from the pairing list"
.into(),
);
}
std::thread::sleep(Duration::from_millis(100));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
#[test]
fn rejects_malformed_lists() {
for data in [vec![], vec![0x0c, 0], vec![0x0c, 0, 5], vec![0x0c, 0, 1]] {
assert!(devices(&data, 0x0c).is_err());
}
let mut data = vec![0x11, 1];
data.extend(42u64.to_le_bytes());
data.extend(LEFT.to_le_bytes());
data.extend([0; 16]);
assert_eq!(
devices(&data, 0x11).unwrap()[0],
Device {
id: 42,
kind: LEFT,
corrupt: false
}
);
}
struct Fake {
reads: VecDeque<Vec<u8>>,
writes: Vec<Vec<u8>>,
fail_write: bool,
}
impl Transport for Fake {
fn get(&mut self, report: u8) -> Result<Vec<u8>, String> {
let data = self.reads.pop_front().expect("unexpected read");
assert_eq!(data[0], report);
Ok(data)
}
fn set(&mut self, data: &[u8]) -> Result<(), String> {
self.writes.push(data.to_vec());
if self.fail_write {
Err("USB disconnected".into())
} else {
Ok(())
}
}
}
#[test]
fn command_requires_new_sequence_and_checks_result() {
let mut radio = Radio(Fake {
reads: [vec![11, 255, 0, 0, 0, 0, 0], vec![11, 0, 0, 3, 0, 0, 0]].into(),
writes: vec![],
fail_write: false,
});
assert!(radio.command(&[10, 1]).unwrap_err().contains("rejected"));
assert_eq!(radio.0.writes, [vec![10, 1]]);
}
#[test]
fn ambiguous_write_failure_is_never_retried() {
let mut radio = Radio(Fake {
reads: [vec![11, 1, 0, 0, 0, 0, 0]].into(),
writes: vec![],
fail_write: true,
});
assert!(radio.command(&[10, 1]).is_err());
assert_eq!(radio.0.writes.len(), 1);
}
fn list(report: u8, entries: &[Device]) -> Vec<u8> {
let mut data = if report == 0x11 {
vec![report, entries.len() as u8]
} else {
vec![report, 1, entries.len() as u8]
};
for device in entries {
data.extend(device.id.to_le_bytes());
data.extend(device.kind.to_le_bytes());
data.extend([0; 15]);
data.push(u8::from(device.corrupt));
}
data
}
#[test]
fn pair_preserves_existing_entries_and_verifies_new_identity() {
let old = Device {
id: 3,
kind: RIGHT,
corrupt: false,
};
let new = Device {
id: 0x12345678abcdef01,
kind: LEFT,
corrupt: false,
};
let mut reads = VecDeque::from([list(0x11, &[old.clone()]), list(0x0c, &[new.clone()])]);
for seq in 0..4 {
reads.push_back(vec![11, seq, 0, 0, 0, 0, 0]);
reads.push_back(vec![11, seq + 1, 0, 0, 0, 0, 0]);
}
reads.push_back(list(0x11, &[old, new.clone()]));
let mut radio = Radio(Fake {
reads,
writes: vec![],
fail_write: false,
});
radio.pair(&new).unwrap();
assert!(radio.0.reads.is_empty());
let writes = &radio.0.writes;
assert_eq!(
writes.iter().map(|data| data[0]).collect::<Vec<_>>(),
[0x0d, 0x0f, 0x13, 0x0e]
);
assert_eq!(&writes[0][1..9], &new.id.to_le_bytes());
assert_eq!(&writes[1][1..], &LEFT.to_le_bytes());
assert_eq!(writes[2].len(), 61);
assert_eq!(writes[2][9], 0x15);
assert_eq!(writes[2][10], 0);
assert_eq!(writes[3], [0x0e, new.id as u8]);
}
#[test]
fn full_whitelist_never_removes_existing_pairing() {
let entries: Vec<_> = (1..=4)
.map(|id| Device {
id,
kind: LEFT,
corrupt: false,
})
.collect();
let mut radio = Radio(Fake {
reads: [list(0x11, &entries)].into(),
writes: vec![],
fail_write: false,
});
assert!(radio
.pair(&Device {
id: 5,
kind: LEFT,
corrupt: false
})
.unwrap_err()
.contains("full"));
assert!(radio.0.writes.is_empty());
}
#[test]
fn rejected_pair_still_disconnects_without_restarting_controller() {
let new = Device {
id: 42,
kind: RIGHT,
corrupt: false,
};
let mut radio = Radio(Fake {
reads: [
list(0x11, &[]),
list(0x0c, &[new.clone()]),
vec![11, 0, 0, 0, 0, 0, 0],
vec![11, 1, 0, 0, 0, 0, 0],
vec![11, 1, 0, 0, 0, 0, 0],
vec![11, 2, 0, 1, 0, 0, 0],
vec![11, 2, 0, 0, 0, 0, 0],
vec![11, 3, 0, 0, 0, 0, 0],
]
.into(),
writes: vec![],
fail_write: false,
});
assert!(radio.pair(&new).unwrap_err().contains("rejected"));
assert_eq!(
radio
.0
.writes
.iter()
.map(|data| data[0])
.collect::<Vec<_>>(),
[0x0d, 0x0f, 0x0e]
);
assert!(radio.0.reads.is_empty());
}
}
+48
View File
@@ -79,6 +79,53 @@ function KeyValues({ rows }: { rows: [string, string][] }) {
interface PreviewStatus { presented: number; width: number; height: number; error: string | null }
interface PairingStatus { stage: string; message: string; candidates: { id: string }[]; hand?: 'left' | 'right' }
function ControllerPairing() {
const [hand, setHand] = useState<'left' | 'right'>('left');
const [status, setStatus] = useState<PairingStatus>({ stage: '', message: '', candidates: [] });
const [error, setError] = useState('');
const [pollError, setPollError] = useState('');
const [pending, setPending] = useState(false);
const active = ['searching', 'ready', 'pairing'].includes(status.stage);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout>;
async function poll() {
try {
const next = await invoke<PairingStatus>('controller_pairing_status');
if (!cancelled) {
setStatus(next);
if (['searching', 'ready', 'pairing'].includes(next.stage) && next.hand) setHand(next.hand);
setPollError('');
}
} catch (failure) { if (!cancelled) setPollError(String(failure)); }
if (!cancelled) timer = setTimeout(poll, 500);
}
void poll();
return () => { cancelled = true; clearTimeout(timer); };
}, []);
async function command(name: string, args?: Record<string, string>) {
setPending(true); setError('');
try {
await invoke(name, args);
setStatus(await invoke<PairingStatus>('controller_pairing_status'));
} catch (failure) { setError(String(failure)); }
finally { setPending(false); }
}
return <div className="sec pairing">
<h2>Pair controller</h2>
<p>Close SteamVR. Put a battery in the controller, then hold {hand === 'left' ? 'Menu + Y' : 'Oculus + B'} until its light flashes. Remove the old controller's battery when replacing it.</p>
<div className="pairing-controls">
<label>Hand <select aria-label="Controller hand" value={hand} disabled={active || pending} onChange={event => setHand(event.target.value as 'left' | 'right')}><option value="left">Left</option><option value="right">Right</option></select></label>
<button className="btn" disabled={active || pending} onClick={() => void command('controller_pairing_start', { hand })}>Find controller</button>
{active && status.stage !== 'pairing' && <button className="btn" disabled={pending} onClick={() => void command('controller_pairing_cancel')}>Cancel</button>}
</div>
{status.stage === 'ready' && status.candidates.map(candidate => <div className="pairing-candidate" key={candidate.id}><code>{candidate.id}</code><button className="btn sm" disabled={pending} onClick={() => void command('controller_pairing_select', { id: candidate.id })}>Pair {hand} controller</button></div>)}
<output className="fb" role="status">{error || pollError || status.message}</output>
<p className="pairing-note">Existing pairings stay saved. Firmware is never updated.</p>
</div>;
}
// The headset view is drawn natively: a Wayland subsurface owned by the Rust
// side covers the preview box and receives GPU frames at the headset refresh
// rate. This component only reports where the box is and shows status text
@@ -234,6 +281,7 @@ function App() {
return <div key={name}><b>{name}</b><span>{device?.tracking ?? missing}</span>{i === 0 ? <span className="r">{device?.tracking ? 'Wired' : missing}</span> : <span className="bat">{battery != null && <i style={{ '--v': `${battery * 100}%` } as React.CSSProperties}/>} {format(battery == null ? null : battery * 100, '%')}</span>}</div>;
})}
</div></div>
<ControllerPairing/>
<div className="sec"><h2>Cameras</h2><div className="t cam">
<div className="h"><span>Position</span><span>State</span><span className="r">Rate</span></div>
{['Front left', 'Front right', 'Top', 'Left', 'Right'].map((name, i) => <div key={name}><b>{name}</b><span>{i < 2 && data.cameraExport ? data.cameraActive ? 'Active' : 'No frames' : missing}</span><span className="r">{format(i < 2 ? data.cameraRate : null, ' Hz', 1)}</span></div>)}
+7
View File
@@ -27,6 +27,13 @@ main{flex:1;min-height:0;padding:1.5rem 2rem;display:flex}
h2{margin:0 0 .75rem;font-size:.8125rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;line-height:2.25rem;display:flex;align-items:center;justify-content:space-between}
.sec+.sec{margin-top:1.75rem}
.pairing p{font-size:.8125rem;margin:0 0 .75rem}
.pairing-controls,.pairing-candidate{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
.pairing-controls label{display:flex;align-items:center;gap:.5rem;font-size:.8125rem}
.pairing select{background:#000;border:1px solid var(--line);padding:.375rem}
.pairing-candidate{justify-content:space-between;margin-top:.5rem}
.pairing .pairing-note{margin:.5rem 0 0;color:var(--mut)}
#headset>.col:last-child{overflow-y:auto}
/* controls */
.btn{height:2.25rem;padding:0 1rem;border:0;border-radius:3px;background:var(--ctl);font-weight:600;font-size:.9rem;cursor:pointer;display:inline-flex;align-items:center;gap:.5rem;white-space:nowrap}
+12
View File
@@ -12,6 +12,7 @@
#include "util/u_pretty_print.h"
#include <pthread.h>
#include <sys/resource.h>
#include <assert.h>
#include <unistd.h>
#include <sched.h>
@@ -104,6 +105,17 @@ u_linux_try_to_set_realtime_priority_on_thread(enum u_logging_level log_level, c
name = str;
}
/*
* With a zero RLIMIT_RTTIME the kernel kills the whole process at the first
* scheduler tick that lands while a realtime thread runs, even one that sleeps
* every iteration. SteamVR's vrserver runs drivers with this limit.
*/
struct rlimit rttime;
if (getrlimit(RLIMIT_RTTIME, &rttime) == 0 && (rttime.rlim_cur == 0 || rttime.rlim_max == 0)) {
LOG_W("Keeping normal priority for thread '%s': RLIMIT_RTTIME is 0", name);
return;
}
if (log_level <= U_LOGGING_DEBUG) {
u_pp(dg, "Trying to raise priority on thread '%s'\n\t", name);
u_pp(dg, "before: ");
@@ -42,23 +42,42 @@ bool rift_s_prepare_session_origin(const std::string &path)
auto universes = cJSON_GetObjectItemCaseSensitive(document.get(), "universes");
if (!cJSON_IsString(id) || std::strcmp(id->valuestring, "chaperone_info") || !cJSON_IsArray(universes))
return false;
Json origin(cJSON_Parse("{\"universeID\":\"3\",\"seated\":{\"translation\":[0,0,0],\"yaw\":0},\"standing\":{\"translation\":[0,0,0],\"yaw\":0}}"), cJSON_Delete);
if (!origin) return false;
// Each session re-bases tracking, so offsets saved in universe 3 (for example by
// Quick Calibrate) would misplace the floor next time. Reset it to the plain origin.
int index = 0;
cJSON *entry;
auto plain = [](const char *id) {
return Json(cJSON_Parse((std::string("{\"universeID\":\"") + id +
"\",\"seated\":{\"translation\":[0,0,0],\"yaw\":0},"
"\"standing\":{\"translation\":[0,0,0],\"yaw\":0}}").c_str()),
cJSON_Delete);
};
cJSON *session = nullptr, *room = nullptr, *entry;
cJSON_ArrayForEach(entry, universes) {
auto universe = cJSON_GetObjectItemCaseSensitive(entry, "universeID");
if (cJSON_IsString(universe) && std::strcmp(universe->valuestring, "3") == 0) {
if (cJSON_Compare(entry, origin.get(), true)) return true;
if (!cJSON_ReplaceItemInArray(universes, index, origin.get())) return false;
break;
}
++index;
if (!cJSON_IsString(universe)) continue;
if (std::strcmp(universe->valuestring, "3") == 0) session = entry;
if (std::strcmp(universe->valuestring, "2") == 0) room = entry;
}
if (!entry && !cJSON_AddItemToArray(universes, origin.get())) return false;
origin.release();
bool changed = false;
// Each session re-bases tracking, so offsets saved in universe 3 (for example by
// Quick Calibrate) would misplace the floor next time. Reset it to the plain origin.
auto origin = plain("3");
if (!origin) return false;
if (!session || !cJSON_Compare(session, origin.get(), true)) {
if (session ? !cJSON_ReplaceItemViaPointer(universes, session, origin.get())
: !cJSON_AddItemToArray(universes, origin.get()))
return false;
origin.release();
changed = true;
}
// Room Setup moves to universe 2 once tracking is in setup. Without an entry SteamVR has no
// calibration for the frame being set up ("Failed to update shared data"), and a first setup
// shows no floor and takes no strokes. Seed the same plain origin so the switch keeps the
// frame; Accept replaces it with the drawn room. A saved room is never touched.
if (!room) {
auto seed = plain("2");
if (!seed || !cJSON_AddItemToArray(universes, seed.get())) return false;
seed.release();
changed = true;
}
if (!changed) return true;
std::unique_ptr<char, decltype(&cJSON_free)> bytes(cJSON_PrintUnformatted(document.get()), cJSON_free);
if (!bytes) return false;
std::string temporary = path + ".monado-XXXXXX";
@@ -4,6 +4,7 @@
#include <string>
// Write a parseable, unbounded session origin before publishing universe 3, replacing any
// offsets left in universe 3 by an earlier session. Other universes remain unchanged.
// offsets left in universe 3 by an earlier session. A missing universe 2 gets the same origin
// so a first Room Setup has a calibrated frame; an existing universe 2 remains unchanged.
bool rift_s_prepare_session_origin(const std::string &path);
bool rift_s_prepare_session_origin();
@@ -21,6 +21,8 @@
#include <cmath>
#include <algorithm>
#include <fstream>
#include <filesystem>
#include <unistd.h>
#include "math/m_api.h"
#include "ovrd_log.hpp"
@@ -28,6 +30,7 @@
#include "ovrd_display.hpp"
#include "ovrd_camera.hpp"
#include "ovrd_roomsetup_guard_process.hpp"
#include "ovrd_realtime.hpp"
#include "ovrd_world_universe.hpp"
#include "ovrd_chaperone_origin.hpp"
#include "../../drivers/rift_s/rift_s_world_control.h"
@@ -1948,6 +1951,23 @@ CServerDriver_Monado g_serverDriverMonado;
#define NUM_XDEVS 16
#ifdef __linux__
static void
warn_realtime_compositor()
{
char server[4096];
ssize_t length = readlink("/proc/self/exe", server, sizeof(server) - 1);
if (length <= 0)
return;
server[length] = 0;
std::string launcher = (std::filesystem::path(server).parent_path() / "vrcompositor-launcher").string();
if (ovrd_realtime_compositor_killed(launcher.c_str()))
ovrd_log("WARNING: %s has CAP_SYS_NICE while vrserver's RLIMIT_RTTIME is 0. The kernel kills the "
"compositor as soon as its realtime threads run. Remove it: sudo setcap -r %s\n",
launcher.c_str(), launcher.c_str());
}
#endif
vr::EVRInitError
CServerDriver_Monado::Init(vr::IVRDriverContext *pDriverContext)
{
@@ -1956,6 +1976,9 @@ CServerDriver_Monado::Init(vr::IVRDriverContext *pDriverContext)
ovrd_log_init(vr::VRDriverLog());
ovrd_log("Initializing Monado driver\n");
#ifdef __linux__
warn_realtime_compositor();
#endif
if (debug_get_bool_option_steamvr_bench()) {
vr::EVRSettingsError error = vr::VRSettingsError_None;
m_savedBenchPause = vr::VRSettings()->GetBool("power", "pauseCompositorOnStandby", &error);
@@ -0,0 +1,28 @@
// Copyright 2026, lbgos
// SPDX-License-Identifier: BSL-1.0
#pragma once
#ifdef __linux__
#include <endian.h>
#include <linux/capability.h>
#include <sys/resource.h>
#include <sys/xattr.h>
#include <sys/types.h>
/*!
* vrcompositor inherits vrserver's RLIMIT_RTTIME. When this process has a zero limit and the
* launcher at @p launcher carries CAP_SYS_NICE, the compositor's realtime threads are SIGKILLed
* by the kernel at the first scheduler tick. SteamVR then only reports a compositor crash.
*/
static inline bool
ovrd_realtime_compositor_killed(const char *launcher)
{
struct rlimit rttime;
if (getrlimit(RLIMIT_RTTIME, &rttime) != 0 || (rttime.rlim_cur != 0 && rttime.rlim_max != 0))
return false;
struct vfs_ns_cap_data caps = {};
ssize_t size = getxattr(launcher, "security.capability", &caps, sizeof(caps));
if (size < (ssize_t)(sizeof(caps.magic_etc) + sizeof(caps.data[0])))
return false;
return (le32toh(caps.data[0].permitted) & (1u << CAP_SYS_NICE)) != 0;
}
#endif
@@ -92,8 +92,9 @@ public:
if (!transforms(standing, setup))
return false;
// An untrusted saved plane belongs to another tracking session. Offer this
// session's nominal floor instead (raw Y=0, where the unanchored universe
// puts it): without any result the native step shows no floor to adjust.
// session's nominal floor instead (raw Y=0, the plain origin of universe 3 and
// of a first-time universe 2): without any result the native step shows no
// floor to adjust.
const vr::HmdMatrix34_t session_origin{{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}}};
auto y = saved_floor(saved_plane_trusted ? standing : session_origin, setup);
if (!y)
@@ -3,6 +3,7 @@
// Keep the pre-setup calibration if SteamVR's compositor dies during native drawing.
#include "openvr.h"
#include "ovrd_roomsetup_floor.hpp"
#include "ovrd_spawn_close.hpp"
#include "../../drivers/rift_s/rift_s_world_control.h"
#include <cjson/cJSON.h>
#include <chrono>
@@ -357,7 +358,7 @@ public:
posix_spawn_file_actions_adddup2(&actions, server_child, 3);
posix_spawn_file_actions_adddup2(&actions, monitor_child, 4);
posix_spawn_file_actions_adddup2(&actions, compositor_child, 5);
posix_spawn_file_actions_addclosefrom_np(&actions, 6);
roomsetup_spawn_close_from(&actions, 6);
int spawned = posix_spawn(&requester, executable, &actions, nullptr, arguments, environ);
posix_spawn_file_actions_destroy(&actions);
close(server_child); close(monitor_child); close(compositor_child);
@@ -460,7 +461,10 @@ static void log_coordinates(vr::IVRSystem &system, vr::IVRChaperoneSetup &setup,
int main(int argc, char **argv)
{
if (argc == 2 && strcmp(argv[1], "--recover-display") == 0) return recover_display();
bool recover = argc == 2 && strcmp(argv[1], "--recover-display") == 0;
// Spawners pass descriptors 3 (guard) or 3-5 (display recovery); drop the rest.
roomsetup_close_inherited(recover ? 6 : 4);
if (recover) return recover_display();
if (argc != 4) return 1;
int parent = static_cast<int>(syscall(SYS_pidfd_open, std::atoi(argv[2]), 0));
int ready = std::atoi(argv[3]);
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: BSL-1.0
#pragma once
#ifdef __linux__
#include "ovrd_spawn_close.hpp"
#include <dlfcn.h>
#include <cerrno>
#include <cstdio>
@@ -64,7 +66,7 @@ public:
posix_spawn_file_actions_adddup2(&actions, descriptors[1], child_acknowledgement);
// SteamVR also owns IPC descriptors without CLOEXEC. The guard must not
// retain those connections or shared buffers after a compositor exits.
posix_spawn_file_actions_addclosefrom_np(&actions, child_acknowledgement + 1);
roomsetup_spawn_close_from(&actions, child_acknowledgement + 1);
char destination[] = "3";
char *arguments[] = {executable.data(), library.data(), parent.data(), destination, nullptr};
int error = posix_spawn(&pid, executable.c_str(), &actions, nullptr, arguments, environ);
@@ -0,0 +1,39 @@
// Copyright 2026, lbgos
// SPDX-License-Identifier: BSL-1.0
/*!
* @file
* @brief Keep SteamVR's inherited descriptors out of spawned guard processes.
*
* The spawner calls roomsetup_spawn_close_from() on its spawn actions. glibc before
* 2.34 (the Steam Runtime) has no close-from spawn action, so the guard also calls
* roomsetup_close_inherited() first thing in main().
*/
#pragma once
#include <spawn.h>
#include <sys/resource.h>
#include <sys/syscall.h>
#include <unistd.h>
inline void
roomsetup_spawn_close_from(posix_spawn_file_actions_t *actions, int first)
{
#if defined(__GLIBC__) && (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 34))
posix_spawn_file_actions_addclosefrom_np(actions, first);
#else
(void)actions;
(void)first;
#endif
}
inline void
roomsetup_close_inherited(int first)
{
#ifdef SYS_close_range
if (syscall(SYS_close_range, first, ~0U, 0) == 0) return;
#endif
// Kernels before 5.9.
rlimit limit{};
int last = getrlimit(RLIMIT_NOFILE, &limit) == 0 && limit.rlim_cur != RLIM_INFINITY ? int(limit.rlim_cur) : 65536;
for (int descriptor = first; descriptor < last; ++descriptor) close(descriptor);
}
+32
View File
@@ -240,6 +240,38 @@ TEST_CASE("Native session origin has no geometry and preserves the saved room")
CHECK(read_file(fixture.path) == "broken json");
}
TEST_CASE("First Room Setup switches into a universe 2 with the session origin")
{
LogFixture fixture;
// An earlier release left only the session universe; a fresh install has no file at all.
const std::string session = R"({"universeID":"3","seated":{"translation":[0,0,0],"yaw":0},"standing":{"translation":[0,0,0],"yaw":0}})";
for (bool existing : {true, false}) {
if (existing) fixture.write(R"({"jsonid":"chaperone_info","version":5,"universes":[)" + session + "]}");
else fs::remove(fixture.path);
REQUIRE(rift_s_prepare_session_origin(fixture.path.string()));
auto bytes = read_file(fixture.path);
std::unique_ptr<cJSON, decltype(&cJSON_Delete)> document(cJSON_Parse(bytes.c_str()), cJSON_Delete);
auto universes = cJSON_GetObjectItemCaseSensitive(document.get(), "universes");
REQUIRE(cJSON_GetArraySize(universes) == 2);
cJSON *room = nullptr, *origin = nullptr, *entry;
cJSON_ArrayForEach(entry, universes) {
auto id = cJSON_GetObjectItemCaseSensitive(entry, "universeID")->valuestring;
(std::string(id) == "2" ? room : origin) = entry;
}
REQUIRE(room);
REQUIRE(origin);
// Same frame on both sides of the switch, and no room geometry to bind a world map to.
CHECK(cJSON_Compare(cJSON_GetObjectItemCaseSensitive(room, "standing"),
cJSON_GetObjectItemCaseSensitive(origin, "standing"), true));
CHECK(cJSON_Compare(cJSON_GetObjectItemCaseSensitive(room, "seated"),
cJSON_GetObjectItemCaseSensitive(origin, "seated"), true));
CHECK(cJSON_GetObjectItemCaseSensitive(room, "play_area") == nullptr);
CHECK(cJSON_GetObjectItemCaseSensitive(room, "collision_bounds") == nullptr);
REQUIRE(rift_s_prepare_session_origin(fixture.path.string()));
CHECK(read_file(fixture.path) == bytes);
}
}
TEST_CASE("Display recovery requires one exact runtime process and the expected server PID")
{
auto executable = fs::read_symlink("/proc/self/exe");