frame-autopass: automatic colour/IR passthrough for the Steam Frame

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bod09andClaude Opus 5.5 committed 2026-10-01 23:08:11 +01:00
commit 8c7dfc322f
39 files changed
+5656

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
# Build and test every push; publish a release for every v* tag.
# The release package is built the same way on every push, so a tag never
# ships something that has not been built before.
name: build
on:
push:
pull_request:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # git describe needs the tags for the version
- name: Fetch toolchain and SDK headers
run: scripts/fetch-deps.sh
- name: Host tests
run: make test
- name: Release package
run: make dist
- uses: actions/upload-artifact@v4
with:
name: frame-autopass-${{ github.sha }}
path: dist/
- name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes \
dist/frame-autopass-aarch64.tar.gz dist/frame-autopass-aarch64.tar.gz.sha256 dist/install.sh
+7
View File
@@ -0,0 +1,7 @@
ref/
build*/
dist/
third_party/openvr/
toolchain/
research/
__pycache__/
+1530
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 bod09
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+101
View File
@@ -0,0 +1,101 @@
# Host targets (tests) build with the system compiler. Device targets are
# cross-compiled for the headset (aarch64, glibc) with the Zig toolchain
# fetched by scripts/fetch-deps.sh.
CXX ?= c++
CXXFLAGS ?= -std=c++17 -O2 -Wall -Wextra -Wpedantic -Werror
BUILD := build/host
ZIG := toolchain/zig-x86_64-linux-0.16.0/zig
# The headset runs glibc 2.39; target a little lower so an OS update that
# holds glibc back does not strand the binaries.
TARGET := aarch64-linux-gnu.2.35
DEV := build/aarch64
DEVCXX := $(ZIG) c++ -target $(TARGET)
DEVFLAGS := -std=c++17 -O2 -g -Wall -Wextra
OPENVR := third_party/openvr
OPENVR_SRC := $(addprefix $(OPENVR)/src/,openvr_api_public.cpp jsoncpp.cpp \
vrcore/dirtools_public.cpp vrcore/envvartools_public.cpp vrcore/pathtools_public.cpp \
vrcore/sharedlibtools_public.cpp vrcore/hmderrors_public.cpp \
vrcore/vrpathregistry_public.cpp vrcore/strtools_public.cpp)
OPENVR_OBJ := $(patsubst $(OPENVR)/src/%.cpp,$(DEV)/openvr/%.o,$(OPENVR_SRC))
OPENVR_DEFS := -DVR_API_PUBLIC -DOPENVR_BUILD_STATIC -DLINUX -DPOSIX -DLINUXARM64 -DVRCORE_NO_PLATFORM
.PHONY: dist test device deploy clean
HOST_TESTS := test_light_policy test_sensors test_xrservice_log test_daemon_config test_passthrough_state
test: $(addprefix $(BUILD)/,$(HOST_TESTS))
@for t in $(HOST_TESTS); do $(BUILD)/$$t || exit 1; done
$(BUILD)/test_light_policy: tests/test_light_policy.cpp src/light_policy.cpp src/light_policy.hpp
@mkdir -p $(BUILD)
$(CXX) $(CXXFLAGS) -Isrc -o $@ tests/test_light_policy.cpp src/light_policy.cpp
$(BUILD)/test_sensors: tests/test_sensors.cpp src/sensors.cpp src/sensors.hpp src/xrservice_log.cpp src/xrservice_log.hpp src/light_policy.cpp src/light_policy.hpp
@mkdir -p $(BUILD)
$(CXX) $(CXXFLAGS) -Isrc -o $@ tests/test_sensors.cpp src/sensors.cpp src/xrservice_log.cpp src/light_policy.cpp
$(BUILD)/test_xrservice_log: tests/test_xrservice_log.cpp src/xrservice_log.cpp src/xrservice_log.hpp
@mkdir -p $(BUILD)
$(CXX) $(CXXFLAGS) -Isrc -o $@ tests/test_xrservice_log.cpp src/xrservice_log.cpp
$(BUILD)/test_daemon_config: tests/test_daemon_config.cpp src/daemon_config.cpp src/daemon_config.hpp src/light_policy.cpp src/sensors.hpp
@mkdir -p $(BUILD)
$(CXX) $(CXXFLAGS) -Isrc -o $@ tests/test_daemon_config.cpp src/daemon_config.cpp src/light_policy.cpp
$(BUILD)/test_passthrough_state: tests/test_passthrough_state.cpp src/passthrough_state.cpp src/passthrough_state.hpp
@mkdir -p $(BUILD)
$(CXX) $(CXXFLAGS) -Isrc -o $@ tests/test_passthrough_state.cpp src/passthrough_state.cpp
# Device builds. autopassd does not link SteamVR at all; only autopass does (for
# the brief background connection that performs a switch).
device: $(DEV)/autopassd $(DEV)/autopass
$(ZIG) $(OPENVR)/headers/openvr.h:
scripts/fetch-deps.sh
$(DEV)/openvr/%.o: $(OPENVR)/src/%.cpp | $(ZIG)
@mkdir -p $(dir $@)
$(DEVCXX) -std=c++17 -O2 -w -Wno-nullability-completeness $(OPENVR_DEFS) -I$(OPENVR)/headers -I$(OPENVR)/src -I$(OPENVR)/src/vrcore -c -o $@ $<
$(DEV)/libopenvr_loader.a: $(OPENVR_OBJ)
$(ZIG) ar rcs $@ $^
DAEMON_SRC := src/autopassd.cpp src/daemon_config.cpp src/app_paths.cpp src/passthrough_state.cpp \
src/light_policy.cpp src/sensors.cpp src/xrservice_log.cpp
CTL_SRC := tools/autopass.cpp src/app_paths.cpp src/passthrough_state.cpp src/private_camera.cpp
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
RELEASE_FLAGS := -DAUTOPASS_VERSION='"$(VERSION)"' -std=c++17 -Os -Wall -Wextra -Wno-nullability-completeness -ffunction-sections -fdata-sections -Wl,--gc-sections -s
$(DEV)/autopassd: $(DAEMON_SRC) $(wildcard src/*.hpp) | $(ZIG)
@mkdir -p $(DEV)
$(DEVCXX) $(RELEASE_FLAGS) -Isrc -o $@ $(DAEMON_SRC)
$(DEV)/autopass: $(CTL_SRC) $(wildcard src/*.hpp) $(DEV)/libopenvr_loader.a
$(DEVCXX) $(RELEASE_FLAGS) -DOPENVR_BUILD_STATIC -Isrc -isystem $(OPENVR)/headers -o $@ $(CTL_SRC) $(DEV)/libopenvr_loader.a -ldl
# Copies autopassd and autopass to the headset's install directory, replacing
# running binaries safely (rename). Does not install or start the unit:
# run `autopass install` on the headset for that.
deploy: $(DEV)/autopassd $(DEV)/autopass
ssh frame 'mkdir -p ~/.local/share/frame-autopass'
scp -q $(DEV)/autopassd frame:.local/share/frame-autopass/autopassd.new
scp -q $(DEV)/autopass frame:.local/share/frame-autopass/autopass.new
ssh frame 'cd ~/.local/share/frame-autopass && mv autopassd.new autopassd && mv autopass.new autopass'
# Release package: dist/frame-autopass-aarch64.tar.gz (+ .sha256) and
# dist/install.sh. Always a clean build, so VERSION is baked in.
dist:
rm -rf dist $(DEV)/autopassd $(DEV)/autopass
$(MAKE) $(DEV)/autopassd $(DEV)/autopass
mkdir -p dist/frame-autopass
cp $(DEV)/autopassd $(DEV)/autopass install.sh README.md LICENSE dist/frame-autopass/
tar -C dist -czf dist/frame-autopass-aarch64.tar.gz frame-autopass
cd dist && sha256sum frame-autopass-aarch64.tar.gz > frame-autopass-aarch64.tar.gz.sha256
cp install.sh dist/install.sh
rm -rf dist/frame-autopass
clean:
rm -rf build dist
+156
View File
@@ -0,0 +1,156 @@
# frame-autopass
> **AI disclaimer:** built with the help of AI and tested on a single
> headset. Treat it as experimental.
Automatic passthrough switching for the **Steam Frame with the Arcturus
Vision colour module**: colour passthrough in good light, the Frame's own
infrared (IR) passthrough in the dark. Turn the lights off and you can see
in IR about 1.5 s later; turn them on and colour is back in about a second.
There is nothing to press and nothing to configure.
It runs entirely on the headset, never talks to the network, and does
nothing at all while passthrough is not showing.
## Requirements
- A Steam Frame with the Arcturus Vision colour passthrough module attached.
- Tested on SteamOS 0.3.0 with SteamVR 2.17.10. Other versions may work; if
SteamVR changes the interface it uses, it stops switching safely (see
[After a SteamVR update](#after-a-steamvr-update)).
- Desktop Mode with a terminal (Konsole). Developer mode was enabled on the
test headset; it may not be required.
## Install
On the headset, switch to Desktop Mode, open **Konsole** and paste:
```sh
curl -fsSL https://github.com/bod09/frame-autopass/releases/latest/download/install.sh | bash
```
That's it. It starts automatically whenever SteamVR runs, from now on.
The installer checks that the Arcturus module is present, downloads the
latest release, verifies its checksum, puts two programs in
`~/.local/share/frame-autopass` and registers a systemd user service that
starts and stops with SteamVR. Everything stays in your home folder, so it
survives SteamOS updates and needs no root access.
Prefer not to pipe a script into bash? Download
`frame-autopass-aarch64.tar.gz` from the
[releases page](https://github.com/bod09/frame-autopass/releases), unpack
it, read `install.sh`, and run `./install.sh` from that folder.
## Use
Just use passthrough as normal. The commands below are optional (run them
in Konsole):
```sh
~/.local/share/frame-autopass/autopass status # running? what is it showing, and why
~/.local/share/frame-autopass/autopass update # install the latest release
~/.local/share/frame-autopass/autopass report # write ~/frame-autopass-report.txt for a bug report
~/.local/share/frame-autopass/autopass uninstall # remove the service, back to plain colour passthrough
```
`uninstall` stops the service and removes it; delete
`~/.local/share/frame-autopass` and `~/.config/frame-autopass` as well to
remove every trace.
## How it decides
It reads three signals, none of which needs an image or a camera device:
- **XRService's IR emitters.** Valve's tracking service turns the IR
emitters on when its tracking cameras run out of light (about 0.2 s after
the room goes dark) and off once there is light again (at least 5 s
later). It says so in its log, which this follows.
- **The colour camera's light value**, published by SteamVR for every
colour frame.
- **The IR camera's light value**, published for every IR frame: 0 in the
dark even with the emitters on, about 0.2 to 0.35 in a lit room.
From these:
- **To IR:** the emitters are on and the colour camera reads dim. The
emitters decide what "dark" is, so a dim room at dusk stays in colour.
- **To colour:** the IR camera sees light, or the emitters have turned off.
- **Mistakes are undone fast:** for 3 s after switching to colour, if the
emitters are on and the colour camera sees darkness, it goes straight back
to IR and ignores whatever misled it for a minute (doubling if it repeats).
At worst you see one sub-second blink.
- **No flicker:** after a switch it waits at least 2 s before switching
back; that wait only grows (up to 30 s) if the light keeps changing back
the moment a switch is allowed, as a flashing light would.
- Losing tracking (which happens in the dark if you stand still) does not
stop it working.
Known limits: in a dimly lit room, or with your face right up against a
wall, the IR camera may not see enough light, so colour comes back when the
emitters turn off, about 5 s after the light instead of 1 s. It never
leaves you in the dark for that: the slow direction is always back to
colour.
The research behind every rule, with measurements, is in
[FINDINGS.md](FINDINGS.md).
## After a SteamVR update
There is no public way to switch the passthrough camera, so frame-autopass
uses a private SteamVR interface. Before every switch it checks that
SteamVR's code still matches what it was built against. If an update
changes it, frame-autopass stops switching (passthrough keeps working
normally, just without automatic switching) and `autopass status` says so.
Run `autopass update`; if no fixed release exists yet, please open an issue
with the output of `autopass report`.
## Safety
- It never opens a camera, never writes outside your home folder, and does
not connect to the internet (except `update`, when you run it).
- It only reads SteamVR's shared memory and logs. To switch, it connects to
SteamVR for about 15 ms as a background client, which never wakes the
headset or starts SteamVR.
- Crash guard: if XRService (which also runs tracking) restarts within 10 s
of a switch twice, switching pauses until you run `autopass guard reset`.
This exists because XRService once crashed during early testing; the
crash was most likely unrelated, but switching should not continue if it
ever repeats.
- Measured cost on the headset: about 0.5 MB of memory, no CPU or wake-ups
while passthrough is hidden.
## Tuning
Optional: put `key = value` lines in `~/.config/frame-autopass/autopass.conf`
and restart SteamVR. The keys and defaults are listed at the top of
[src/daemon_config.hpp](src/daemon_config.hpp). Unknown keys are rejected
and logged, so a typo never silently does nothing.
## Building from source
On an x86_64 Linux PC:
```sh
scripts/fetch-deps.sh # Zig toolchain and OpenVR SDK headers, into gitignored folders
make test # host tests
make dist # cross-compiled release package in dist/
```
`make deploy` copies a build to a headset reachable as `ssh frame`.
GitHub Actions builds and tests every push and publishes a release for every
`v*` tag.
`tools/` holds the research tools used to find all of this: a passive
shared-memory recorder and sampler, and an annotated ARM64 disassembler for
SteamVR's binaries.
## Credits
The existence and name of SteamVR's private passthrough camera interface
were first learned from
[KominoVR/frame-passthrough-shortcuts](https://github.com/KominoVR/frame-passthrough-shortcuts),
which toggles the camera with controller gestures. No code from it is used;
everything here was re-derived from the headset's own binaries.
MIT licence, see [LICENSE](LICENSE).
Executable
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# frame-autopass installer and updater for the Steam Frame.
#
# curl -fsSL https://github.com/bod09/frame-autopass/releases/latest/download/install.sh | bash
#
# or, from an unpacked release folder: ./install.sh
#
# Installs two programs into ~/.local/share/frame-autopass and a systemd
# user unit that starts them with SteamVR. Nothing outside your home
# directory is touched, so it survives SteamOS updates. Running it again
# updates to the latest release. Remove with `autopass uninstall`.
set -euo pipefail
REPO="bod09/frame-autopass"
DEST="${XDG_DATA_HOME:-$HOME/.local/share}/frame-autopass"
TARBALL="frame-autopass-aarch64.tar.gz"
say() { printf '%s\n' "$*"; }
fail() { printf 'frame-autopass: %s\n' "$*" >&2; exit 1; }
[ "$(uname -m)" = "aarch64" ] || fail "this is for the Steam Frame (aarch64); this machine is $(uname -m)."
command -v systemctl >/dev/null || fail "systemd not found."
if ! grep -qs arcimx616 /sys/class/video4linux/*/name; then
fail "the Arcturus Vision colour module was not found. frame-autopass switches between that module
and the built-in IR cameras, so it needs the module attached."
fi
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
# Use the files next to this script when run from an unpacked release,
# otherwise download the latest release and check its checksum.
here="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || true)"
if [ -n "$here" ] && [ -x "$here/autopassd" ] && [ -x "$here/autopass" ]; then
src="$here"
else
base="https://github.com/$REPO/releases/latest/download"
say "Downloading the latest frame-autopass release..."
curl -fsSL -o "$work/$TARBALL" "$base/$TARBALL" || fail "download failed ($base/$TARBALL)."
curl -fsSL -o "$work/$TARBALL.sha256" "$base/$TARBALL.sha256" || fail "checksum download failed."
(cd "$work" && sha256sum -c --quiet "$TARBALL.sha256") || fail "checksum mismatch; not installing."
tar -C "$work" -xzf "$work/$TARBALL"
src="$work/frame-autopass"
fi
new_version="$("$src/autopass" version 2>/dev/null || echo unknown)"
old_version="$("$DEST/autopass" version 2>/dev/null || echo none)"
# Replace the programs while the service is stopped, then (re)install the
# unit, which starts it again if SteamVR is running.
systemctl --user stop frame-autopass.service 2>/dev/null || true
mkdir -p "$DEST"
for f in autopassd autopass; do
install -m 0755 "$src/$f" "$DEST/$f.new"
mv -f "$DEST/$f.new" "$DEST/$f"
done
"$DEST/autopass" install
if [ "$old_version" = "none" ]; then
say "frame-autopass $new_version installed. It runs whenever SteamVR runs; nothing else to do."
else
say "frame-autopass updated: $old_version -> $new_version."
fi
say "Check it any time with: $DEST/autopass status"
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# Fetch the build dependencies into gitignored directories. Nothing is
# installed system-wide.
set -eu
cd "$(dirname "$0")/.."
ZIG_VERSION=0.16.0
ZIG_SHA256=70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00
OPENVR_COMMIT=0924064316de3effbcd1acf1e309182a2deb1c05 # OpenVR SDK 2.15.6
if [ ! -x toolchain/zig-x86_64-linux-$ZIG_VERSION/zig ]; then
mkdir -p toolchain
tarball=zig-x86_64-linux-$ZIG_VERSION.tar.xz
curl -sSfL -o toolchain/$tarball https://ziglang.org/download/$ZIG_VERSION/$tarball
echo "$ZIG_SHA256 toolchain/$tarball" | sha256sum -c
tar -C toolchain -xf toolchain/$tarball
rm toolchain/$tarball
fi
if [ ! -f third_party/openvr/headers/openvr.h ]; then
mkdir -p third_party/openvr
git -C third_party/openvr init -q
git -C third_party/openvr fetch -q --depth 1 https://github.com/ValveSoftware/openvr $OPENVR_COMMIT
git -C third_party/openvr checkout -q FETCH_HEAD
# The SDK repo ships prebuilt binaries for every platform; only the
# headers and the loader source are needed.
rm -rf third_party/openvr/bin third_party/openvr/lib third_party/openvr/samples \
third_party/openvr/unity_package third_party/openvr/controller_callouts third_party/openvr/.git
fi
+85
View File
@@ -0,0 +1,85 @@
#include "app_paths.hpp"
#include <dirent.h>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <sys/stat.h>
#include <unistd.h>
namespace autopass {
namespace {
std::string home() {
const char* h = std::getenv("HOME");
return h && *h ? h : "/tmp";
}
std::string xdg(const char* var, const char* fallback) {
const char* v = std::getenv(var);
if (v && *v == '/') return v;
return home() + fallback;
}
} // namespace
std::string config_dir() { return xdg("XDG_CONFIG_HOME", "/.config") + "/frame-autopass"; }
std::string install_dir() { return xdg("XDG_DATA_HOME", "/.local/share") + "/frame-autopass"; }
bool make_dirs(const std::string& path) {
std::string partial;
std::stringstream ss(path);
std::string part;
if (!path.empty() && path[0] == '/') partial = "/";
while (std::getline(ss, part, '/')) {
if (part.empty()) continue;
partial += part + "/";
if (::mkdir(partial.c_str(), 0755) != 0 && errno != EEXIST) return false;
}
return true;
}
bool write_file_atomic(const std::string& path, const std::string& contents) {
const std::string tmp = path + ".tmp";
{
std::ofstream f(tmp, std::ios::binary | std::ios::trunc);
if (!(f << contents) || !f.flush()) return false;
}
return std::rename(tmp.c_str(), path.c_str()) == 0;
}
bool read_file(const std::string& path, std::string* contents) {
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::stringstream ss;
ss << f.rdbuf();
*contents = ss.str();
return true;
}
bool colour_module_present() {
const std::string base = "/sys/class/video4linux";
DIR* dir = ::opendir(base.c_str());
if (!dir) return false;
bool found = false;
while (const dirent* e = ::readdir(dir)) {
if (e->d_name[0] == '.') continue;
std::string name;
if (read_file(base + "/" + e->d_name + "/name", &name) && name.find("arcimx616") != std::string::npos) {
found = true;
break;
}
}
::closedir(dir);
return found;
}
#ifndef AUTOPASS_VERSION
#define AUTOPASS_VERSION "dev"
#endif
const char* version() { return AUTOPASS_VERSION; }
} // namespace autopass
+38
View File
@@ -0,0 +1,38 @@
#pragma once
// Where autopassd and autopass keep their files. Everything lives in the user's
// home directory; nothing is written to system locations.
#include <string>
namespace autopass {
// ~/.config/frame-autopass (honours XDG_CONFIG_HOME).
std::string config_dir();
// ~/.local/share/frame-autopass (honours XDG_DATA_HOME): the
// installed binaries (autopassd, autopass).
std::string install_dir();
inline std::string status_path() { return config_dir() + "/status.json"; }
inline std::string log_path() { return config_dir() + "/autopassd.log"; }
inline std::string conf_path() { return config_dir() + "/autopass.conf"; }
inline std::string lock_path() { return config_dir() + "/autopassd.lock"; }
// Crash guard: XRService restarts soon after an autopassd switch, one line each.
inline std::string crash_guard_path() { return config_dir() + "/crash_guard"; }
// Creates the directory and its parents. Returns false on failure.
bool make_dirs(const std::string& path);
// Writes a file atomically (temporary file, then rename).
bool write_file_atomic(const std::string& path, const std::string& contents);
// Reads a whole small file; returns false if it cannot be read.
bool read_file(const std::string& path, std::string* contents);
// Whether the Arcturus Vision colour module is attached: its sensors
// (arcimx616) are listed by name in /sys/class/video4linux. Only reads
// sysfs names; never opens a camera device.
bool colour_module_present();
// The release version, set at build time (Makefile VERSION).
const char* version();
} // namespace autopass
+429
View File
@@ -0,0 +1,429 @@
// autopassd: adaptive passthrough for the Steam Frame.
//
// Shows the Arcturus colour feed in good light and the built-in mono IR
// feed in low light, fully automatically: a fast switch, and an adaptive
// cooldown so it never flickers (light_policy.hpp).
//
// Designed to cost nothing when passthrough is not in use:
// - It does not connect to SteamVR. It follows XRService's session log
// with inotify (xrservice_log.hpp): passthrough shown/hidden, standby,
// IR emitters, tracking loss. XRService writes nothing while the headset
// is idle, so autopassd is not woken at all then.
// - While passthrough is shown in colour it reads the colour camera's light
// value from shared memory once a second (4 Hz while a decision is
// pending). While IR is shown with the IR emitters on, it reads the IR
// camera's light value from the same shared memory 4 times a second to
// recognise a lit room (sensors.hpp), and logs it every 10 s. It never
// captures images.
// - To switch, it runs `autopass set rgb|mono`, which connects to SteamVR as
// a background client for ~15 ms (it does not wake the headset), checks
// the private interface's fingerprint, switches and exits.
//
// Started and stopped with SteamVR by a systemd user unit (`autopass install`).
// Usage: autopassd [--observe] [--verbose]
#include "app_paths.hpp"
#include "daemon_config.hpp"
#include "light_policy.hpp"
#include "passthrough_state.hpp"
#include "sensors.hpp"
#include "xrservice_log.hpp"
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <fcntl.h>
#include <deque>
#include <optional>
#include <poll.h>
#include <spawn.h>
#include <string>
#include <sys/file.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <thread>
#include <unistd.h>
extern char** environ;
namespace {
using autopass::Evidence;
using autopass::Source;
volatile std::sig_atomic_t g_stop = 0;
void on_signal(int) { g_stop = 1; }
bool g_verbose = false;
std::FILE* g_log = nullptr;
std::uint64_t now_ms() {
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
}
std::string wall_time() {
char buf[32];
const std::time_t t = std::time(nullptr);
std::strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", std::localtime(&t));
return buf;
}
void log(const std::string& msg) {
const std::string line = wall_time() + " " + msg + "\n";
if (g_log) {
std::fputs(line.c_str(), g_log);
std::fflush(g_log);
struct stat st{};
if (::stat(autopass::log_path().c_str(), &st) == 0 && st.st_size > 512 * 1024) {
std::fclose(g_log);
std::rename(autopass::log_path().c_str(), (autopass::log_path() + ".1").c_str());
g_log = std::fopen(autopass::log_path().c_str(), "a");
}
} else {
std::fputs(line.c_str(), stderr);
}
}
std::string json_escape(const std::string& s) {
std::string out;
for (char c : s) {
if (c == '"' || c == '\\') out += '\\';
out += c;
}
return out;
}
const char* tri(const std::optional<bool>& v, const char* yes, const char* no) {
return !v ? "unknown" : *v ? yes : no;
}
// Exit codes of `autopass set` (tools/autopass.cpp).
constexpr int kSetOk = 0;
constexpr int kSetMismatch = 3; // SteamVR's private interface changed
constexpr int kSetNotEnabled = 4; // passthrough camera not enabled
constexpr int kSetNoModule = 5; // no Arcturus colour module
// Runs `autopass set rgb|mono --quiet` and waits up to 5 s. Returns its exit
// code, or -1 if it could not be run or did not finish.
int run_switch_helper(bool rgb) {
const std::string helper = autopass::install_dir() + "/autopass";
char arg0[] = "autopass", arg1[] = "set", arg_rgb[] = "rgb", arg_mono[] = "mono", arg3[] = "--quiet";
char* argv[] = {arg0, arg1, rgb ? arg_rgb : arg_mono, arg3, nullptr};
pid_t pid = 0;
if (::posix_spawn(&pid, helper.c_str(), nullptr, nullptr, argv, environ) != 0) return -1;
for (int i = 0; i < 100; ++i) {
int status = 0;
const pid_t r = ::waitpid(pid, &status, WNOHANG);
if (r == pid) return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
if (r < 0 && errno != EINTR) return -1;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
::kill(pid, SIGKILL);
::waitpid(pid, nullptr, 0);
return -1;
}
} // namespace
int main(int argc, char** argv) {
bool observe_flag = false;
for (int i = 1; i < argc; ++i) {
const std::string a = argv[i];
if (a == "--observe") observe_flag = true;
else if (a == "--verbose") g_verbose = true;
else if (a == "--help") {
std::printf("autopassd [--observe] [--verbose]\n");
return 0;
} else {
std::fprintf(stderr, "unknown argument %s\n", a.c_str());
return 2;
}
}
std::signal(SIGINT, on_signal);
std::signal(SIGTERM, on_signal);
if (!autopass::make_dirs(autopass::config_dir())) {
std::fprintf(stderr, "cannot create %s\n", autopass::config_dir().c_str());
return 1;
}
const int lock_fd = ::open(autopass::lock_path().c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0600);
if (lock_fd < 0 || ::flock(lock_fd, LOCK_EX | LOCK_NB) != 0) {
std::fprintf(stderr, "autopassd is already running\n");
return 1;
}
g_log = std::fopen(autopass::log_path().c_str(), "a");
autopass::DaemonConfig cfg;
std::string conf_text;
if (autopass::read_file(autopass::conf_path(), &conf_text)) {
const auto problem = autopass::parse_daemon_config(conf_text, &cfg);
if (!problem.empty()) {
log("autopass.conf rejected (" + problem + "); using defaults");
cfg = {};
}
}
// Crash guard (FINDINGS.md 31): two XRService restarts within 10 s of an
// autopassd switch stop switching until `autopass guard reset`.
std::string guard_text;
int guard_hits = 0;
if (autopass::read_file(autopass::crash_guard_path(), &guard_text))
for (char c : guard_text) guard_hits += c == '\n';
// Why switching is off, if it is (shown by `autopass status`): empty, or
// observe-only, crash-guard, steamvr-changed, no-colour-module.
std::string blocked = observe_flag || cfg.observe_only ? "observe-only" : guard_hits >= 2 ? "crash-guard" : "";
if (blocked == "crash-guard") log("crash guard: XRService restarted twice soon after switches; observe-only until `autopass guard reset`");
else if (!blocked.empty()) log("observe-only: decisions are logged, never applied");
// Follow XRService's log. SteamVR may still be starting: retry quietly.
autopass::XrServiceLog xr;
bool warned = false;
while (!g_stop && !xr.start()) {
if (!warned) log("waiting for XRService's log (" + xr.error() + ")");
warned = true;
std::this_thread::sleep_for(std::chrono::seconds(3));
}
if (g_stop) return 0;
autopass::PassthroughState state;
Source initial = Source::Color;
if (const auto snap = state.read(); snap && !snap->config.rgb) initial = Source::Ir;
autopass::LightPolicy policy(cfg.policy, initial);
autopass::EvidenceBuilder evidence(cfg.sensors);
log(std::string("autopassd ") + autopass::version() + " started: showing " + autopass::to_string(initial));
double last_colour_ts = -1;
double mono_ts_at_last_light = 0;
std::uint64_t last_ir_log_ms = 0;
// The last 10 s of IR light readings (4 a second), written to the log
// when autopassd returns to colour, so a slow return shows what it read.
struct LightNote {
std::uint64_t ms;
double light;
};
std::deque<LightNote> recent_ir;
// When the cameras first saw the room go dark or lit, to log how long
// XRService takes to turn its IR emitters on or off after that.
std::optional<std::uint64_t> dark_seen_ms, lit_seen_ms;
std::optional<bool> last_emitters;
long long last_switch_wall = 0;
std::uint64_t expect_source_until_ms = 0, last_state_retry_ms = 0;
bool was_active = false;
std::string last_reason, last_status;
autopass::Cause last_cause = autopass::Cause::None;
const auto apply = [&](Source target, const std::string& reason, std::uint64_t now) {
if (!blocked.empty()) {
log(std::string("would switch to ") + autopass::to_string(target) + " (" + reason + ") [" + blocked + "]");
return;
}
const int rc = run_switch_helper(target == Source::Color);
if (rc == kSetOk) {
log(std::string("switched to ") + autopass::to_string(target) + ": " + reason);
if (target == Source::Color && !recent_ir.empty()) {
std::string notes = "IR light before the switch (s ago: value):";
for (const auto& n : recent_ir) {
char item[32];
std::snprintf(item, sizeof item, " %.1f:%.2f", (now - n.ms) / 1000.0, n.light);
notes += item;
}
log(notes);
}
recent_ir.clear();
expect_source_until_ms = now + 3000;
last_switch_wall = static_cast<long long>(std::time(nullptr));
evidence.on_switched(now, target, true, last_cause);
} else if (rc == kSetMismatch) {
log("SteamVR's camera interface changed (SteamVR update?); switching disabled until frame-autopass "
"is updated (`autopass update`)");
blocked = "steamvr-changed";
} else if (rc == kSetNoModule) {
log("Arcturus colour module not detected: not switching");
blocked = "no-colour-module";
} else if (rc == kSetNotEnabled) {
log("passthrough camera not enabled; not switching");
} else {
log("switch helper failed (code " + std::to_string(rc) + ")");
}
// Whatever happened, follow what is actually shown.
if (const auto snap = state.read()) policy.adopt(snap->config.rgb ? Source::Color : Source::Ir);
if (rc != kSetOk) evidence.reset();
};
// First pass runs at once, so a passthrough already shown at start-up is
// handled without waiting for XRService's next log line. Afterwards -1
// means: sleep until XRService logs something.
int wait_ms = 0;
while (!g_stop) {
pollfd pfd{xr.fd(), POLLIN, 0};
if (::poll(&pfd, 1, wait_ms) < 0 && errno != EINTR) break;
if (g_stop) break;
const std::uint64_t now = now_ms();
const bool xr_changed = xr.update();
const autopass::XrState& xs = xr.state();
if (xr.take_restarted()) {
const long long restart = xr.session_start();
const long long since = last_switch_wall && restart ? restart - last_switch_wall : -1;
log("XRService restarted" + (since >= 0 ? " " + std::to_string(since) + " s after autopassd's last switch"
: std::string(" (no recent autopassd switch)")));
if (since >= 0 && since <= 10) {
std::string text;
autopass::read_file(autopass::crash_guard_path(), &text);
text += wall_time() + " XRService restart " + std::to_string(since) + " s after a switch\n";
autopass::write_file_atomic(autopass::crash_guard_path(), text);
log("crash guard: recorded; switching stops after two such restarts");
}
}
if (g_verbose && xr_changed)
log(std::string("xrservice: passthrough ") + tri(xs.passthrough, "shown", "hidden") + ", standby " +
tri(xs.standby, "yes", "no") + ", emitters " + tri(xs.emitters, "on", "off") + ", tracking " +
tri(xs.tracking_lost, "lost", "ok"));
if (xs.emitters != last_emitters) {
if (last_emitters && xs.emitters) {
const auto& seen = *xs.emitters ? dark_seen_ms : lit_seen_ms;
char msg[160];
if (seen)
std::snprintf(msg, sizeof msg, "IR emitters turned %s %.1f s after the cameras first saw the room %s",
*xs.emitters ? "on" : "off", (now - *seen) / 1000.0, *xs.emitters ? "dark" : "lit");
else
std::snprintf(msg, sizeof msg, "IR emitters turned %s (no light change seen before it)",
*xs.emitters ? "on" : "off");
log(msg);
}
last_emitters = xs.emitters;
}
const bool active = xs.passthrough == true && xs.standby != true;
if (active != was_active) {
log(active ? "passthrough shown: sensing" : "passthrough not shown: idle");
was_active = active;
// Without the Arcturus module there is no colour feed to switch to.
// Checked each time passthrough appears (a sysfs read).
if (active) {
const bool module = autopass::colour_module_present();
if (!module && blocked.empty()) {
log("Arcturus colour module not detected: not switching");
blocked = "no-colour-module";
} else if (module && blocked == "no-colour-module") {
log("Arcturus colour module detected: switching again");
blocked.clear();
}
}
evidence.reset();
last_colour_ts = -1;
dark_seen_ms.reset();
lit_seen_ms.reset();
}
Evidence ev = Evidence::Unknown;
Source shown = policy.source();
bool want_ir = false;
if (active) {
if (state.path().empty() && now - last_state_retry_ms > 5000) {
last_state_retry_ms = now;
state = autopass::PassthroughState();
}
std::optional<double> colour_light;
std::optional<double> ir_light;
if (const auto snap = state.read()) {
shown = snap->config.rgb ? Source::Color : Source::Ir;
// The IR camera's light value counts only from a new frame:
// a stalled XRService leaves the last one in place.
if (shown == Source::Ir && snap->mono_light && snap->mono_timestamp > mono_ts_at_last_light) {
mono_ts_at_last_light = snap->mono_timestamp;
ir_light = *snap->mono_light;
}
if (shown != policy.source() && now > expect_source_until_ms) {
log(std::string("source changed outside autopassd to ") + autopass::to_string(shown));
policy.adopt(shown);
evidence.reset();
}
if (snap->colour) {
const bool fresh = last_colour_ts >= 0 && snap->colour->timestamp != last_colour_ts;
last_colour_ts = snap->colour->timestamp;
if (shown == Source::Color && fresh) colour_light = snap->colour->light;
}
}
want_ir = shown == Source::Ir && evidence.wants_ir_light(now, xs.emitters);
if (want_ir && ir_light) {
recent_ir.push_back({now, *ir_light});
while (!recent_ir.empty() && now - recent_ir.front().ms > 10000) recent_ir.pop_front();
if (now - last_ir_log_ms >= 10000) {
last_ir_log_ms = now;
char msg[96];
std::snprintf(msg, sizeof msg, "IR light %.2f, emitters %s, tracking %s", *ir_light,
tri(xs.emitters, "on", "off"), tri(xs.tracking_lost, "lost", "ok"));
log(msg);
}
}
// Emitter timing (log only): the colour camera knows dark from lit;
// in IR, the IR light value is the first sign of light.
const auto saw = [&](bool lit) {
auto& mark = lit ? lit_seen_ms : dark_seen_ms;
if (!mark) mark = now;
(lit ? dark_seen_ms : lit_seen_ms).reset();
};
if (colour_light) {
if (*colour_light < cfg.sensors.colour_dark_light) saw(false);
else if (*colour_light >= cfg.sensors.colour_min_light) saw(true);
}
if (ir_light) {
if (*ir_light >= cfg.sensors.ir_min_light) saw(true);
else lit_seen_ms.reset(); // not lit (yet); "dark" is the colour camera's call
}
// Tracking loss does not hold anything: in a dark room tracking
// can drop the moment the light goes out and stay lost while the
// headset is still (2026-10-01 21:43-21:53), and neither the
// emitters nor the IR light value depend on it (FINDINGS.md 41).
const autopass::EvidenceResult er = evidence.evaluate(now, shown, xs.emitters, colour_light, ir_light);
ev = er.evidence;
last_cause = er.cause;
const auto d = policy.update(now, ev, er.why, er.urgent);
if (d.changed) apply(d.source, d.reason, now);
if (g_verbose && d.reason != last_reason && d.reason != "missing reading") log("decision: " + d.reason);
last_reason = d.reason;
}
// Next wake-up. Idle, or nothing that could change our mind: sleep
// until XRService logs something.
const bool pending = last_reason == "confirming" || last_reason == "confirmed, waiting for cooldown" ||
last_reason == "settling after switch" || last_reason == "missing reading";
// Colour shown: read the colour camera's light value once a second
// (XRService's "emitters on" line wakes autopassd at once when it gets
// dark). IR shown: read the IR camera's light value 4 times a second
// while it matters, otherwise wait for XRService's log.
if (!active) wait_ms = -1;
else if (pending) wait_ms = 250;
else if (want_ir) wait_ms = 250;
else if (shown == Source::Color) wait_ms = 1000;
else wait_ms = -1;
char buf[512];
std::snprintf(buf, sizeof buf,
"{\"pid\": %d, \"passthrough\": \"%s\", \"standby\": \"%s\", \"showing\": \"%s\", "
"\"ir_emitters\": \"%s\", \"tracking\": \"%s\", \"evidence\": \"%s\", \"cooldown_s\": %.0f, "
"\"can_switch\": %s, \"blocked\": \"%s\", \"reason\": \"%s\", \"version\": \"%s\"}\n",
static_cast<int>(::getpid()), tri(xs.passthrough, "shown", "hidden"), tri(xs.standby, "yes", "no"),
autopass::to_string(policy.source()), tri(xs.emitters, "on", "off"), tri(xs.tracking_lost, "lost", "ok"),
autopass::to_string(ev), policy.cooldown_ms() / 1000.0, blocked.empty() ? "true" : "false",
blocked.c_str(), json_escape(active ? last_reason : "").c_str(), autopass::version());
if (buf != last_status) {
autopass::write_file_atomic(autopass::status_path(), buf);
last_status = buf;
}
}
xr.stop();
log("autopassd stopped");
if (g_log) std::fclose(g_log);
return 0;
}
+92
View File
@@ -0,0 +1,92 @@
#include "daemon_config.hpp"
#include <cerrno>
#include <cmath>
#include <cstdlib>
#include <sstream>
namespace autopass {
namespace {
std::string trim(const std::string& s) {
const auto b = s.find_first_not_of(" \t\r");
if (b == std::string::npos) return {};
const auto e = s.find_last_not_of(" \t\r");
return s.substr(b, e - b + 1);
}
bool parse_number(const std::string& text, double* out) {
if (text.empty()) return false;
char* end = nullptr;
errno = 0;
const double v = std::strtod(text.c_str(), &end);
if (errno || !end || *end != '\0' || !std::isfinite(v)) return false;
*out = v;
return true;
}
} // namespace
std::string parse_daemon_config(const std::string& text, DaemonConfig* config) {
DaemonConfig result = *config;
std::istringstream in(text);
std::string line;
int number = 0;
while (std::getline(in, line)) {
++number;
const auto hash = line.find('#');
if (hash != std::string::npos) line.erase(hash);
line = trim(line);
if (line.empty()) continue;
const auto eq = line.find('=');
const std::string where = "line " + std::to_string(number) + ": ";
if (eq == std::string::npos) return where + "expected key = value";
const std::string key = trim(line.substr(0, eq));
const std::string value = trim(line.substr(eq + 1));
if (key == "observe_only") {
if (value == "true") result.observe_only = true;
else if (value == "false") result.observe_only = false;
else return where + "observe_only must be true or false";
continue;
}
double* target = nullptr;
if (key == "confirm_s") target = &result.policy.confirm_s;
else if (key == "urgent_confirm_s") target = &result.policy.urgent_confirm_s;
else if (key == "confirm_to_colour_s") target = &result.policy.confirm_to_colour_s;
else if (key == "cooldown_base_s") target = &result.policy.cooldown_base_s;
else if (key == "cooldown_max_s") target = &result.policy.cooldown_max_s;
else if (key == "cooldown_reset_s") target = &result.policy.cooldown_reset_s;
else if (key == "flicker_slack_s") target = &result.policy.flicker_slack_s;
else if (key == "settle_s") target = &result.policy.settle_s;
else if (key == "settle_to_colour_s") target = &result.policy.settle_to_colour_s;
else if (key == "stale_s") target = &result.policy.stale_s;
else if (key == "colour_min_light") target = &result.sensors.colour_min_light;
else if (key == "colour_dark_light") target = &result.sensors.colour_dark_light;
else if (key == "verify_s") target = &result.sensors.verify_s;
else if (key == "ir_min_light") target = &result.sensors.ir_min_light;
else if (key == "ir_light_hold_s") target = &result.sensors.ir_light_hold_s;
else if (key == "ir_light_lockout_s") target = &result.sensors.ir_light_lockout_s;
else if (key == "ir_light_lockout_max_s") target = &result.sensors.ir_light_lockout_max_s;
else if (key == "emitters_off_distrust_s") target = &result.sensors.emitters_off_distrust_s;
else if (key == "emitters_off_distrust_max_s") target = &result.sensors.emitters_off_distrust_max_s;
else if (key == "smoothing_s") target = &result.sensors.smoothing_s;
else return where + "unknown key '" + key + "'";
if (!parse_number(value, target)) return where + "'" + value + "' is not a number";
}
const std::string problem = validate(result.policy);
if (!problem.empty()) return problem;
const auto& s = result.sensors;
if (!(s.colour_min_light > 0 && s.colour_min_light <= 1) ||
!(s.colour_dark_light > 0 && s.colour_dark_light <= s.colour_min_light))
return "colour light thresholds must be in (0, 1], colour_dark_light <= colour_min_light";
if (!(s.verify_s >= 0) || !(s.smoothing_s >= 0) || !(s.ir_light_hold_s >= 0) ||
!(s.ir_min_light > 0 && s.ir_min_light <= 1) || !(s.ir_light_lockout_s >= 0) ||
!(s.ir_light_lockout_max_s >= s.ir_light_lockout_s) || !(s.emitters_off_distrust_s >= 0) ||
!(s.emitters_off_distrust_max_s >= s.emitters_off_distrust_s))
return "sensor durations must be non-negative (each _max_s at least its base)";
*config = result;
return {};
}
} // namespace autopass
+48
View File
@@ -0,0 +1,48 @@
#pragma once
// autopass.conf: optional `key = value` lines overriding the policy defaults.
// Blank lines and lines starting with '#' are ignored. Unknown keys and
// malformed values are errors, reported with their line number, so a typo
// never silently leaves a default in place.
//
// confirm_s = 0.5 # evidence must persist this long (to IR)
// confirm_to_colour_s = 0.25 # ...to colour (checked by the colour camera)
// urgent_confirm_s = 0.25 # ...when undoing a mistaken switch
// cooldown_base_s = 2 # anti-flicker cooldown: starts at base,
// cooldown_max_s = 30 # doubles for a switch that comes within
// flicker_slack_s = 1.5 # flicker_slack_s of being allowed,
// cooldown_reset_s = 30 # steps down for slower ones, clears after quiet
// settle_s = 1.5 # after a switch to IR
// settle_to_colour_s = 0.3 # after a switch to colour
// stale_s = 3.0
// colour_min_light = 0.6 # with IR emitters on: dark below this
// colour_dark_light = 0.35 # emitters on and colour this dark right after a
// # switch to colour: that switch was a mistake
// verify_s = 3 # check colour right after switching to it
// ir_min_light = 0.15 # IR shown: the IR camera's light value says lit
// ir_light_hold_s = 0 # for this long (on top of confirm_to_colour_s)
// ir_light_lockout_s = 60 # ignore the IR light value after it misled us, doubling
// ir_light_lockout_max_s = 600 # up to this
// emitters_off_distrust_s = 60 # ignore "emitters off" after it misled us, doubling
// emitters_off_distrust_max_s = 600 # up to this
// smoothing_s = 0 # extra smoothing of the colour light value (the camera already smooths it)
// observe_only = false # log decisions but never switch
#include "light_policy.hpp"
#include "sensors.hpp"
#include <string>
namespace autopass {
struct DaemonConfig {
PolicyConfig policy;
SensorConfig sensors;
bool observe_only = false;
};
// Parses `text` over `config` (so absent keys keep their current value).
// Returns an empty string on success, otherwise "line N: reason".
std::string parse_daemon_config(const std::string& text, DaemonConfig* config);
} // namespace autopass
+155
View File
@@ -0,0 +1,155 @@
#include "light_policy.hpp"
#include <algorithm>
#include <cmath>
#include <utility>
namespace autopass {
const char* to_string(Source source) {
return source == Source::Color ? "colour" : "ir";
}
const char* to_string(Mode mode) {
switch (mode) {
case Mode::Auto: return "auto";
case Mode::ForceColor: return "colour";
case Mode::ForceIr: return "ir";
}
return "unknown";
}
const char* to_string(Evidence e) {
switch (e) {
case Evidence::Unknown: return "unknown";
case Evidence::Neutral: return "neutral";
case Evidence::Dark: return "dark";
case Evidence::Bright: return "bright";
}
return "unknown";
}
bool parse_mode(const std::string& text, Mode* mode) {
if (text == "auto") *mode = Mode::Auto;
else if (text == "colour" || text == "color") *mode = Mode::ForceColor;
else if (text == "ir" || text == "mono") *mode = Mode::ForceIr;
else return false;
return true;
}
std::string validate(const PolicyConfig& c) {
for (double v : {c.confirm_s, c.confirm_to_colour_s, c.urgent_confirm_s, c.cooldown_base_s, c.cooldown_max_s, c.cooldown_reset_s, c.flicker_slack_s, c.settle_s,
c.settle_to_colour_s, c.stale_s})
if (!std::isfinite(v) || v < 0) return "durations must be finite and non-negative";
if (c.stale_s <= 0) return "stale_s must be positive";
if (c.cooldown_max_s < c.cooldown_base_s) return "cooldown_max_s must be at least cooldown_base_s";
return {};
}
namespace {
std::uint64_t to_ms(double seconds) {
return static_cast<std::uint64_t>(std::llround(seconds * 1000.0));
}
} // namespace
LightPolicy::LightPolicy(PolicyConfig config, Source initial)
: config_(config), source_(initial) {}
void LightPolicy::reset_confirmation() {
pending_ = false;
pending_since_ms_ = 0;
}
bool LightPolicy::settling(std::uint64_t now_ms) const {
const double settle = source_ == Source::Color ? config_.settle_to_colour_s : config_.settle_s;
return switched_ && now_ms - last_switch_ms_ < to_ms(settle);
}
std::uint64_t LightPolicy::cooldown_ms() const {
double s = config_.cooldown_base_s;
for (int i = 0; i < flicker_level_ && s < config_.cooldown_max_s; ++i) s *= 2;
return to_ms(std::min(s, config_.cooldown_max_s));
}
void LightPolicy::adopt(Source actual) {
if (actual == source_) return;
source_ = actual;
reset_confirmation();
}
Decision LightPolicy::decide(std::uint64_t now_ms, Source target, std::string reason, bool automatic) {
const bool changed = target != source_;
if (changed) {
source_ = target;
switched_ = true;
last_switch_ms_ = now_ms;
if (automatic) {
// Only a switch that came about as soon as the cooldown allowed
// it is flicker; slower ones step the cooldown back down.
if (auto_switched_) {
const std::uint64_t gap = now_ms - last_auto_switch_ms_, allowed = cooldown_ms();
if (gap < allowed + to_ms(config_.flicker_slack_s))
flicker_level_ = std::min(flicker_level_ + 1, 16);
else if (gap >= allowed + to_ms(config_.cooldown_reset_s))
flicker_level_ = 0;
else if (flicker_level_ > 0)
--flicker_level_;
}
auto_switched_ = true;
last_auto_switch_ms_ = now_ms;
}
reset_confirmation();
}
return {source_, changed, std::move(reason)};
}
Decision LightPolicy::set_mode(std::uint64_t now_ms, Mode mode) {
mode_ = mode;
reset_confirmation();
switch (mode) {
case Mode::ForceColor: return decide(now_ms, Source::Color, "manual: force colour", false);
case Mode::ForceIr: return decide(now_ms, Source::Ir, "manual: force IR", false);
case Mode::Auto: break;
}
return decide(now_ms, source_, "manual: auto, holding current source until light confirms a change", false);
}
Decision LightPolicy::update(std::uint64_t now_ms, Evidence evidence, const std::string& why, bool urgent) {
if (now_ms < last_tick_ms_) now_ms = last_tick_ms_; // never run time backwards
last_tick_ms_ = now_ms;
if (settling(now_ms)) return {source_, false, "settling after switch"};
if (evidence == Evidence::Unknown) {
if (last_known_ms_ == 0 || now_ms - last_known_ms_ >= to_ms(config_.stale_s)) {
reset_confirmation();
return {source_, false, "no reading, holding"};
}
return {source_, false, "missing reading"};
}
last_known_ms_ = now_ms;
if (mode_ != Mode::Auto) return {source_, false, std::string("manual: ") + to_string(mode_)};
const bool want_ir = source_ == Source::Color && evidence == Evidence::Dark;
const bool want_color = source_ == Source::Ir && evidence == Evidence::Bright;
if (!want_ir && !want_color) {
reset_confirmation();
return {source_, false, "no change needed"};
}
if (!pending_) {
pending_ = true;
pending_since_ms_ = now_ms;
}
const double confirm = urgent ? config_.urgent_confirm_s : want_color ? config_.confirm_to_colour_s : config_.confirm_s;
if (now_ms - pending_since_ms_ < to_ms(confirm)) return {source_, false, "confirming"};
if (!urgent && auto_switched_ && now_ms - last_auto_switch_ms_ < cooldown_ms())
return {source_, false, "confirmed, waiting for cooldown"};
const std::string suffix = why.empty() ? "" : " (" + why + ")";
return want_ir ? decide(now_ms, Source::Ir, "auto: too dark for colour" + suffix, true)
: decide(now_ms, Source::Color, "auto: bright enough for colour" + suffix, true);
}
} // namespace autopass
+123
View File
@@ -0,0 +1,123 @@
#pragma once
// Decides which passthrough source to show. Pure logic: no SteamVR, no
// clock, no I/O, so it can be tested on the host with synthetic signals.
//
// Sensors (see sensors.hpp) turn their readings into Evidence each tick:
// Dark (too dark for colour passthrough), Bright (bright enough for
// colour), Neutral (no reason to change), or Unknown (no reading). This
// class owns the timing rules: the first switch is fast (a short
// confirmation), and an adaptive cooldown stops flicker. The cooldown
// before the next automatic switch starts short and doubles each time
// switches follow each other quickly, resetting after a quiet period.
// There is also a settle window after any switch and holding when
// readings go stale.
#include <cstdint>
#include <string>
namespace autopass {
enum class Source { Color, Ir };
enum class Mode { Auto, ForceColor, ForceIr };
enum class Evidence { Unknown, Neutral, Dark, Bright };
const char* to_string(Source source);
const char* to_string(Mode mode);
const char* to_string(Evidence evidence);
bool parse_mode(const std::string& text, Mode* mode);
struct PolicyConfig {
// Evidence for a change must persist this long before a switch fires,
// so a hand passing over the cameras does not. Urgent evidence (undoing
// a switch that turned out wrong) needs only urgent_confirm_s.
// Switching to colour is checked by the colour camera straight away
// (sensors.hpp), so a mistake there costs under a second: it can be
// quicker (confirm_to_colour_s) than the switch to IR, where confirm_s
// keeps a hand over the cameras from counting. 0.5 s is enough there
// since switching to IR also needs XRService's emitters on, which a
// hand over the colour camera does not cause (FINDINGS.md 45).
double confirm_s = 0.5;
double confirm_to_colour_s = 0.25;
double urgent_confirm_s = 0.25;
// Cooldown after an automatic switch before the next automatic one. It
// only grows for flicker: a switch that comes within flicker_slack_s of
// the moment the cooldown allowed it (the light changing back as soon
// as it could, as a flashing light or a fooled sensor does) doubles it,
// up to cooldown_max_s. Anything slower, such as someone turning lights
// on and off, steps it back down one level per switch, and
// cooldown_reset_s of quiet clears it. Manual switches do not count:
// the cooldown exists to stop automatic flip-flopping, not to delay the
// user.
double cooldown_base_s = 2.0;
double cooldown_max_s = 30.0;
double cooldown_reset_s = 30.0;
double flicker_slack_s = 1.5;
// Evidence this soon after a switch is ignored while the other
// camera's pipeline settles. The colour camera's light value is valid
// on its first frame (FINDINGS.md 21), so after a switch to colour a
// much shorter settle lets a mistaken switch be caught quickly.
double settle_s = 1.5;
double settle_to_colour_s = 0.3;
// With no reading for this long, hold the current source and forget
// partial confirmation.
double stale_s = 3.0;
};
// Returns an empty string when the config is usable, otherwise the reason.
std::string validate(const PolicyConfig& config);
struct Decision {
Source source;
bool changed; // source differs from the previous decision
std::string reason; // human-readable, for logs and the status file
};
class LightPolicy {
public:
LightPolicy(PolicyConfig config, Source initial);
// Feed the evidence for this tick. Timestamps are monotonic
// milliseconds. `why` is appended to the reason of an automatic switch.
// `urgent` evidence (a switch that turned out to be a mistake) still
// needs confirmation but skips the cooldown; it counts towards the
// cooldown's escalation like any automatic switch.
Decision update(std::uint64_t now_ms, Evidence evidence, const std::string& why = {}, bool urgent = false);
// Manual override. Leaving a forced mode for Auto keeps the current
// source and requires fresh confirmation before any switch.
Decision set_mode(std::uint64_t now_ms, Mode mode);
// The source actually shown changed without us. Adopt it without
// counting it as a switch.
void adopt(Source actual);
Mode mode() const { return mode_; }
Source source() const { return source_; }
// True within settle_s of the last switch (sensors should reset).
bool settling(std::uint64_t now_ms) const;
// Current cooldown after the last automatic switch, in milliseconds.
std::uint64_t cooldown_ms() const;
private:
Decision decide(std::uint64_t now_ms, Source target, std::string reason, bool automatic);
void reset_confirmation();
PolicyConfig config_;
Mode mode_ = Mode::Auto;
Source source_;
std::uint64_t last_tick_ms_ = 0;
std::uint64_t last_known_ms_ = 0;
bool switched_ = false; // any switch (drives the settle window)
std::uint64_t last_switch_ms_ = 0;
bool auto_switched_ = false; // automatic switch (drives the cooldown)
std::uint64_t last_auto_switch_ms_ = 0;
int flicker_level_ = 0; // cooldown doublings in effect
bool pending_ = false;
std::uint64_t pending_since_ms_ = 0;
};
} // namespace autopass
+146
View File
@@ -0,0 +1,146 @@
#include "passthrough_state.hpp"
#include <cmath>
#include <cstring>
#include <dirent.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <vector>
namespace autopass {
namespace {
constexpr std::size_t kConfigOffset = 2;
// Stream blocks: mono at 0x14, colour at 0x20e8. Inside each, per-camera
// blocks 0x1040 apart hold per-frame records 0x104 apart starting at +0x38;
// a record's frame timestamp (float64) is at +0x10.
constexpr std::size_t kMonoStream = 0x14;
constexpr std::size_t kFirstRecord = 0x38;
constexpr std::size_t kCameraStride = 0x1040;
constexpr std::size_t kRecordStride = 0x104;
constexpr int kCameras = 2;
constexpr int kRecordsPerCamera = 4;
// The colour stream block starts here; everything before is the mono one.
constexpr std::size_t kColourStream = 0x20e8;
// Per-frame records are found by a float32 1/2.2 gamma marker.
constexpr std::size_t kRecordBeforeMarker = 0xd8;
constexpr std::size_t kTimestampInRecord = 0x10;
constexpr std::size_t kLightAfterMarker = 0x14;
// The same light value, by fixed offset: mono records carry no gamma marker.
constexpr std::size_t kLightInRecord = kRecordBeforeMarker + kLightAfterMarker;
float read_f32(const std::uint8_t* p) {
float v;
std::memcpy(&v, p, sizeof v);
return v;
}
double read_f64(const std::uint8_t* p) {
double v;
std::memcpy(&v, p, sizeof v);
return v;
}
double newest_timestamp(const std::uint8_t* buf, std::size_t stream) {
double newest = 0;
for (int cam = 0; cam < kCameras; ++cam)
for (int rec = 0; rec < kRecordsPerCamera; ++rec) {
const std::size_t off = stream + kFirstRecord + cam * kCameraStride + rec * kRecordStride + kTimestampInRecord;
const double ts = read_f64(buf + off);
if (std::isfinite(ts) && ts > newest) newest = ts;
}
return newest;
}
} // namespace
bool CameraConfig::valid() const {
for (auto b : bytes())
if (b > 1) return false;
return true;
}
std::string CameraConfig::describe() const {
std::string s;
s += "enabled=" + std::to_string(enabled);
s += " stereo=" + std::to_string(stereo);
s += " rgb=" + std::to_string(rgb);
s += " disable_devignetting=" + std::to_string(disable_devignetting);
s += " sharpening=" + std::to_string(sharpening);
return s;
}
std::string PassthroughState::locate() {
std::string found;
int matches = 0;
DIR* dir = ::opendir("/dev/shm");
if (!dir) return {};
while (auto* entry = ::readdir(dir)) {
const std::string name = entry->d_name;
if (name.rfind("u", 0) != 0 || name.find("-Shm_") == std::string::npos) continue;
const std::string path = "/dev/shm/" + name;
struct stat st{};
if (::stat(path.c_str(), &st) == 0 && static_cast<std::size_t>(st.st_size) == kSize) {
found = path;
++matches;
}
}
::closedir(dir);
return matches == 1 ? found : std::string();
}
PassthroughState::PassthroughState(std::string path) : path_(std::move(path)) {}
std::optional<PassthroughSnapshot> PassthroughState::read() const {
if (path_.empty()) return std::nullopt;
const int fd = ::open(path_.c_str(), O_RDONLY | O_CLOEXEC);
if (fd < 0) return std::nullopt;
std::vector<std::uint8_t> buf(kSize);
std::size_t got = 0;
while (got < kSize) {
const auto n = ::pread(fd, buf.data() + got, kSize - got, static_cast<off_t>(got));
if (n <= 0) break;
got += static_cast<std::size_t>(n);
}
::close(fd);
if (got != kSize) return std::nullopt;
return parse(buf.data());
}
PassthroughSnapshot PassthroughState::parse(const std::uint8_t* buf) {
PassthroughSnapshot snap;
snap.config = CameraConfig::from(buf + kConfigOffset);
snap.mono_timestamp = newest_timestamp(buf, kMonoStream);
snap.colour_timestamp = newest_timestamp(buf, kColourStream);
double newest_mono = 0;
for (int cam = 0; cam < kCameras; ++cam)
for (int rec = 0; rec < kRecordsPerCamera; ++rec) {
const std::uint8_t* record = buf + kMonoStream + kFirstRecord + cam * kCameraStride + rec * kRecordStride;
const double ts = read_f64(record + kTimestampInRecord);
const float light = read_f32(record + kLightInRecord);
if (std::isfinite(ts) && ts > newest_mono && light >= 0.0f && light <= 1.0f) {
newest_mono = ts;
snap.mono_light = light;
}
}
// 1/2.2 computed in double and rounded to float by the writer. A float
// division (1.0f / 2.2f) rounds to 0x3ee8ba2e and would never match.
const std::uint32_t gamma_bits = 0x3ee8ba2f;
std::uint8_t marker[4];
std::memcpy(marker, &gamma_bits, sizeof marker);
for (std::size_t pos = kColourStream + kRecordBeforeMarker; pos + kLightAfterMarker + 4 <= kSize; pos += 4) {
if (std::memcmp(buf + pos, marker, 4) != 0) continue;
const std::uint8_t* record = buf + pos - kRecordBeforeMarker;
ColourFrameInfo info;
info.timestamp = read_f64(record + kTimestampInRecord);
info.light = read_f32(buf + pos + kLightAfterMarker);
if (!(info.timestamp > 0) || !(info.light >= 0.0f && info.light <= 1.0f)) continue;
if (!snap.colour || info.timestamp > snap.colour->timestamp) snap.colour = info;
}
return snap;
}
} // namespace autopass
+73
View File
@@ -0,0 +1,73 @@
#pragma once
// Passive reader for SteamVR's VR_CameraPassthroughState shared memory
// (see FINDINGS.md sections 12 and 13). Reads a tmpfs file only: no
// SteamVR calls, no locks, no devices, so it cannot disturb passthrough.
#include <array>
#include <cstdint>
#include <optional>
#include <string>
namespace autopass {
// The 5-byte passthrough camera config, as stored at state offset 2.
struct CameraConfig {
std::uint8_t enabled = 0;
std::uint8_t stereo = 0;
std::uint8_t rgb = 0;
std::uint8_t disable_devignetting = 0;
std::uint8_t sharpening = 0;
std::array<std::uint8_t, 5> bytes() const {
return {enabled, stereo, rgb, disable_devignetting, sharpening};
}
static CameraConfig from(const std::uint8_t* p) { return {p[0], p[1], p[2], p[3], p[4]}; }
bool valid() const; // every byte is 0 or 1
std::string describe() const;
};
struct ColourFrameInfo {
double timestamp = 0; // seconds, SteamVR clock
float light = 0; // "g4": ~1 bright, 0 when too dark for colour
};
struct PassthroughSnapshot {
CameraConfig config;
// Newest colour-stream frame, if any record is present.
std::optional<ColourFrameInfo> colour;
// Newest frame timestamp of each stream (0 if none). Only the stream
// being displayed advances, so a timestamp that keeps moving means
// that passthrough source is live (FINDINGS.md section 26).
double mono_timestamp = 0;
double colour_timestamp = 0;
// Light value of the newest mono frame, at the record offset where the
// colour stream keeps g4 (FINDINGS.md 41): 0 in the dark even with the
// IR emitters on, about 0.2-0.35 in a lit room. Only advances while IR
// is shown.
std::optional<float> mono_light;
};
class PassthroughState {
public:
static constexpr std::size_t kSize = 0x6200;
// Finds the state file by its unique size under /dev/shm. Returns an
// empty string when there is not exactly one candidate.
static std::string locate();
explicit PassthroughState(std::string path = locate());
const std::string& path() const { return path_; }
// Reads and parses one snapshot. Returns nullopt if the file cannot be
// read or is not the expected size.
std::optional<PassthroughSnapshot> read() const;
// Exposed for tests: parse a raw buffer of kSize bytes.
static PassthroughSnapshot parse(const std::uint8_t* buf);
private:
std::string path_;
};
} // namespace autopass
+102
View File
@@ -0,0 +1,102 @@
#include "private_camera.hpp"
#include <openvr.h>
#include <array>
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace autopass {
namespace {
constexpr const char* kInterface = "IVRCameraPassthroughInternal_001";
constexpr std::size_t kGetConfig = 9;
constexpr std::size_t kSetConfig = 10;
// First instructions of CVRCameraPassthroughInternal's GetConfig and
// SetConfig in SteamVR 2.17.10 (vrclient.so 0x1a49d8 and 0x1a4bf8). Only
// position-independent words are included: the prologue, argument moves,
// the `add x1, x0, #0x18` that reaches the shared-state accessor, and a
// short relative branch. A call instruction would change whenever the
// library is relinked, so the fingerprints stop before the first `bl`.
constexpr std::array<std::uint32_t, 6> kGetFingerprint = {
0xa9bc7bfd, // stp x29, x30, [sp, #-0x40]!
0x910003fd, // mov x29, sp
0xa90153f3, // stp x19, x20, [sp, #0x10]
0xaa0103f3, // mov x19, x1 (cfg)
0x91006001, // add x1, x0, #0x18 (shared state)
0xb40002b3, // cbz x19, ... (null cfg returns only the flag)
};
constexpr std::array<std::uint32_t, 7> kSetFingerprint = {
0xa9ba7bfd, // stp x29, x30, [sp, #-0x60]!
0x910003fd, // mov x29, sp
0xa90153f3, // stp x19, x20, [sp, #0x10]
0xaa0103f3, // mov x19, x1 (cfg)
0x91006001, // add x1, x0, #0x18 (shared state)
0x910083f4, // add x20, sp, #0x20 (lock guard)
0xaa1403e0, // mov x0, x20
};
template <std::size_t N>
bool matches(const void* fn, const std::array<std::uint32_t, N>& expected) {
std::uint32_t words[N];
std::memcpy(words, fn, sizeof words);
return std::memcmp(words, expected.data(), sizeof words) == 0;
}
using GetConfigFn = bool (*)(void* self, std::uint8_t* cfg);
using SetConfigFn = void (*)(void* self, const std::uint8_t* cfg);
} // namespace
PrivateCamera::PrivateCamera() {
vr::EVRInitError err = vr::VRInitError_None;
void* instance = vr::VR_GetGenericInterface(kInterface, &err);
if (!instance || err != vr::VRInitError_None) {
error_ = std::string("interface unavailable: ") + vr::VR_GetVRInitErrorAsEnglishDescription(err);
return;
}
auto** methods = *static_cast<void***>(instance);
if (!methods || !methods[kGetConfig] || !methods[kSetConfig]) {
error_ = "interface has no config methods";
return;
}
if (!matches(methods[kGetConfig], kGetFingerprint) || !matches(methods[kSetConfig], kSetFingerprint)) {
error_ = "SteamVR's camera interface does not match the verified build (2.17.10); refusing to call it";
return;
}
instance_ = instance;
methods_ = methods;
}
std::optional<CameraConfig> PrivateCamera::get() {
if (!ok()) return std::nullopt;
std::array<std::uint8_t, 5> raw;
raw.fill(0xff);
reinterpret_cast<GetConfigFn>(methods_[kGetConfig])(instance_, raw.data());
const auto config = CameraConfig::from(raw.data());
if (!config.valid()) {
error_ = "config read back non-boolean bytes: " + config.describe();
return std::nullopt;
}
return config;
}
bool PrivateCamera::set(const CameraConfig& config) {
if (!ok()) return false;
if (!config.valid()) {
error_ = "refusing to write non-boolean config";
return false;
}
const auto raw = config.bytes();
reinterpret_cast<SetConfigFn>(methods_[kSetConfig])(instance_, raw.data());
const auto back = get();
if (!back || back->bytes() != raw) {
error_ = "config did not read back as written";
return false;
}
return true;
}
} // namespace autopass
+39
View File
@@ -0,0 +1,39 @@
#pragma once
// Access to SteamVR's private IVRCameraPassthroughInternal_001 interface,
// limited to reading and writing the 5-byte camera config. Requires an
// initialised OpenVR client (VR_Init) in the calling process.
//
// The interface has no public header. Its layout was mapped from the
// headset's own vrclient.so (FINDINGS.md sections 9 and 11). Before any
// call, the code of the two methods is compared with the instructions
// seen in that binary; if SteamVR has changed them, nothing is called.
#include "passthrough_state.hpp"
#include <optional>
#include <string>
namespace autopass {
class PrivateCamera {
public:
// Looks up the interface and verifies the method fingerprints. On
// failure, error() says why and every other call returns failure.
PrivateCamera();
bool ok() const { return methods_ != nullptr; }
const std::string& error() const { return error_; }
std::optional<CameraConfig> get();
// Writes the config and reads it back. Returns false (with error())
// on any mismatch.
bool set(const CameraConfig& config);
private:
void* instance_ = nullptr;
void** methods_ = nullptr;
std::string error_;
};
} // namespace autopass
+152
View File
@@ -0,0 +1,152 @@
#include "sensors.hpp"
#include <algorithm>
#include <cmath>
#include <cstdio>
namespace autopass {
namespace {
std::uint64_t to_ms(double seconds) { return static_cast<std::uint64_t>(std::llround(seconds * 1000.0)); }
} // namespace
double Ema::add(std::uint64_t now_ms, double value) {
if (!has_ || tau_s_ <= 0) {
value_ = value;
has_ = true;
} else {
const double dt = static_cast<double>(now_ms > last_ms_ ? now_ms - last_ms_ : 0) / 1000.0;
value_ += (1.0 - std::exp(-dt / tau_s_)) * (value - value_);
}
last_ms_ = now_ms;
return value_;
}
const char* to_string(Cause c) {
switch (c) {
case Cause::None: return "none";
case Cause::EmittersOff: return "IR emitters off";
case Cause::IrLight: return "IR camera sees light";
case Cause::EmittersOnDim: return "IR emitters on, colour dim";
case Cause::VerifyFailed: return "colour dark right after switching";
}
return "?";
}
void EvidenceBuilder::on_switched(std::uint64_t now_ms, Source to, bool automatic, Cause cause) {
reset();
if (to == Source::Color && automatic) {
verify_until_ms_ = now_ms + to_ms(config_.verify_s);
verify_cause_ = cause;
} else {
verify_until_ms_ = 0;
verify_cause_ = Cause::None;
}
}
EvidenceResult EvidenceBuilder::ir_evidence(std::uint64_t now_ms, std::optional<bool> emitters_on,
std::optional<double> ir_light) {
EvidenceResult r;
char buf[128];
if (!*emitters_on && now_ms >= distrust_until_ms_) {
r.evidence = Evidence::Bright;
r.cause = Cause::EmittersOff;
r.why = "IR emitters off";
return r;
}
r.why = *emitters_on ? "IR emitters on" : "IR emitters off, not trusted for a while after a failed colour check";
if (now_ms < ir_light_lockout_until_ms_) {
r.evidence = Evidence::Neutral;
return r;
}
if (!ir_light) return r; // Unknown between samples
if (*ir_light >= config_.ir_min_light) {
if (!ir_lit_since_ms_) ir_lit_since_ms_ = now_ms;
} else {
ir_lit_since_ms_.reset();
}
std::snprintf(buf, sizeof buf, "IR camera light %.2f", *ir_light);
r.why = buf;
if (ir_lit_since_ms_ && now_ms - *ir_lit_since_ms_ >= to_ms(config_.ir_light_hold_s)) {
r.evidence = Evidence::Bright;
r.cause = Cause::IrLight;
} else {
r.evidence = Evidence::Neutral;
}
return r;
}
EvidenceResult EvidenceBuilder::evaluate(std::uint64_t now_ms, Source shown, std::optional<bool> emitters_on,
std::optional<double> colour_light, std::optional<double> ir_light) {
EvidenceResult r;
char buf[128];
if (colour_light) colour_.add(now_ms, *colour_light);
// Without the emitter signal there is no reliable way back from IR, so
// nothing switches either way.
if (!emitters_on) {
r.evidence = Evidence::Neutral;
r.why = "IR emitter state unknown";
return r;
}
if (shown == Source::Ir) return ir_evidence(now_ms, emitters_on, ir_light);
if (!colour_.has()) {
if (*emitters_on) {
r.evidence = Evidence::Dark;
r.cause = Cause::EmittersOnDim;
r.why = "IR emitters on, no colour reading";
}
return r;
}
// No fresh colour sample this tick is "unknown", not "neutral": neutral
// would reset the policy's confirmation between samples.
if (!colour_light) return r;
const double v = colour_.value();
// XRService's emitters decide what is dark (FINDINGS.md 38, 39): its
// tracking cameras turn them on only at their longest exposure. With
// the emitters off, a low colour reading is a dim room (dusk), not a
// dark one, and the IR view would be no better.
if (!*emitters_on) {
std::snprintf(buf, sizeof buf, "IR emitters off, colour light %.2f", v);
r.evidence = Evidence::Neutral;
r.why = buf;
return r;
}
// Right after an automatic switch to colour, emitters on and colour
// clearly dark: that switch was a mistake. Go back at once and stop
// believing whatever misled it for a while (doubling each time).
if (now_ms < verify_until_ms_ && v < config_.colour_dark_light) {
std::snprintf(buf, sizeof buf, "colour light %.2f right after switching (was: %s)", v, to_string(verify_cause_));
if (verify_cause_ == Cause::EmittersOff) {
const double s = next_distrust_s_ > 0 ? next_distrust_s_ : config_.emitters_off_distrust_s;
distrust_until_ms_ = now_ms + to_ms(s);
next_distrust_s_ = std::min(s * 2, config_.emitters_off_distrust_max_s);
} else if (verify_cause_ == Cause::IrLight) {
const double s = next_ir_light_lockout_s_ > 0 ? next_ir_light_lockout_s_ : config_.ir_light_lockout_s;
ir_light_lockout_until_ms_ = now_ms + to_ms(s);
next_ir_light_lockout_s_ = std::min(s * 2, config_.ir_light_lockout_max_s);
}
verify_cause_ = Cause::None; // count this mistake once
r.evidence = Evidence::Dark;
r.cause = Cause::VerifyFailed;
r.urgent = true;
r.why = buf;
return r;
}
if (v < config_.colour_min_light) {
std::snprintf(buf, sizeof buf, "IR emitters on, colour light %.2f", v);
r.evidence = Evidence::Dark;
r.cause = Cause::EmittersOnDim;
r.why = buf;
return r;
}
std::snprintf(buf, sizeof buf, "colour light %.2f", v);
r.evidence = Evidence::Neutral;
r.why = buf;
return r;
}
} // namespace autopass
+137
View File
@@ -0,0 +1,137 @@
#pragma once
// Turn raw readings into Evidence for LightPolicy (FINDINGS.md 29, 34, 38-43).
//
// Three signals, none of them needing an image:
// - XRService's IR emitter state (from its log, xrservice_log.hpp). Its
// tracking cameras' auto-exposure turns the emitters on only at their
// longest exposure with gain to spare, within ~0.1 s of darkness, and off
// only after 5 s of comfortable exposure (often much later, as the
// emitters light the room themselves). So "on" means dark, and "off"
// means the room has light, even if a dim one.
// - The colour camera's light value g4 (shared memory, only while colour
// is shown). Low in a dark room, but also at dusk, when the room is
// still fine to see in (2026-10-01 18:41), so on its own it never
// switches.
// - The IR camera's light value (shared memory, the same record field as
// g4, only while IR is shown). 0 in the dark even with the emitters on,
// ~0.2-0.35 within half a second of a light coming on, also while
// tracking is lost (FINDINGS.md 41).
//
// Colour shown, Dark when the emitters are on and:
// - g4 < `colour_min_light`;
// - within `verify_s` of an automatic switch to colour, g4 <
// `colour_dark_light`: that switch was a mistake. Urgent (skips the
// cooldown), and what misled it is not believed for a while:
// "emitters off" for `emitters_off_distrust_s`, the IR light value for
// `ir_light_lockout_s`, each doubling per repeat up to its maximum.
// With the emitters off nothing in colour switches to IR. The cost: if the
// emitters are fooled off in a dark room (a wall centimetres away,
// 00:13:33), colour stays until they come back on, which they do as soon
// as the wall is no longer lit by them.
//
// IR shown, Bright when either:
// - the emitters are off (and not distrusted). XRService keeps them on for
// at least 5 s after the light returns, often longer;
// - the IR light value is at least `ir_min_light` for `ir_light_hold_s`:
// what normally brings colour back, about a second after the light. A
// wall lit by the emitters from close by read 0.10 (22:04:09), below the
// threshold; if one fools it anyway, the colour check above undoes it.
//
// Without the emitter signal (for example a SteamVR update changed the log
// line) nothing switches in either direction.
#include "light_policy.hpp"
#include <cstdint>
#include <optional>
#include <string>
namespace autopass {
// Exponential moving average over irregular samples.
class Ema {
public:
explicit Ema(double time_constant_s = 1.0) : tau_s_(time_constant_s) {}
double add(std::uint64_t now_ms, double value);
void reset() { has_ = false; }
bool has() const { return has_; }
double value() const { return value_; }
private:
double tau_s_;
bool has_ = false;
double value_ = 0;
std::uint64_t last_ms_ = 0;
};
struct SensorConfig {
double colour_min_light = 0.6; // with emitters on: dim for colour below this
double colour_dark_light = 0.35; // clearly dark: a switch to colour was a mistake
double verify_s = 3.0; // check colour right after switching to it
double ir_min_light = 0.15; // IR shown: the IR camera's light value says lit...
double ir_light_hold_s = 0.0; // ...for this long (on top of the policy's confirmation)
double ir_light_lockout_s = 60.0; // ignore the IR light value after it misled us, doubling...
double ir_light_lockout_max_s = 600.0;
double emitters_off_distrust_s = 60.0; // ignore "emitters off" after it misled us, doubling...
double emitters_off_distrust_max_s = 600.0;
// Extra smoothing of the colour light value. 0: the camera already
// smooths it (0.89 to 0.44 over ~1.5 s when the light goes out).
double smoothing_s = 0.0;
};
// Which signal produced a Bright or Dark.
enum class Cause { None, EmittersOff, IrLight, EmittersOnDim, VerifyFailed };
const char* to_string(Cause cause);
struct EvidenceResult {
Evidence evidence = Evidence::Unknown;
Cause cause = Cause::None;
bool urgent = false; // may skip the cooldown (a mistaken switch)
std::string why; // short description for logs
};
// Combines the signals into one Evidence for the source being shown.
class EvidenceBuilder {
public:
explicit EvidenceBuilder(SensorConfig config = {})
: config_(config), colour_(config.smoothing_s) {}
// Forget smoothed readings (passthrough hidden, source changed).
void reset() {
colour_.reset();
ir_lit_since_ms_.reset();
}
// Tell the builder about a switch that happened, so it can verify an
// automatic switch to colour and learn from a mistaken one.
void on_switched(std::uint64_t now_ms, Source to, bool automatic, Cause cause);
// `emitters_on`: known emitter state, or nullopt. `colour_light` /
// `ir_light`: fresh readings for this tick, if any.
EvidenceResult evaluate(std::uint64_t now_ms, Source shown, std::optional<bool> emitters_on,
std::optional<double> colour_light, std::optional<double> ir_light = std::nullopt);
bool distrusting_emitters_off(std::uint64_t now_ms) const { return now_ms < distrust_until_ms_; }
// Whether autopassd should poll the IR light value (IR shown, and it could
// matter right now).
bool wants_ir_light(std::uint64_t now_ms, std::optional<bool> emitters_on) const {
return now_ms >= ir_light_lockout_until_ms_ && (emitters_on != false || now_ms < distrust_until_ms_);
}
private:
EvidenceResult ir_evidence(std::uint64_t now_ms, std::optional<bool> emitters_on,
std::optional<double> ir_light);
SensorConfig config_;
Ema colour_;
std::optional<std::uint64_t> ir_lit_since_ms_;
std::uint64_t ir_light_lockout_until_ms_ = 0;
double next_ir_light_lockout_s_ = 0; // 0: use ir_light_lockout_s
std::uint64_t verify_until_ms_ = 0;
Cause verify_cause_ = Cause::None;
std::uint64_t distrust_until_ms_ = 0;
double next_distrust_s_ = 0; // 0: use emitters_off_distrust_s
};
} // namespace autopass
+190
View File
@@ -0,0 +1,190 @@
#include "xrservice_log.hpp"
#include <cerrno>
#include <climits>
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <fcntl.h>
#include <sys/inotify.h>
#include <sys/stat.h>
#include <unistd.h>
#include <vector>
namespace autopass {
namespace {
constexpr const char* kLink = "xrservice.txt";
// The session log is a few hundred KB; read at most this much of its tail
// when looking for the latest state.
constexpr long long kMaxInitialRead = 16LL * 1024 * 1024;
} // namespace
std::string XrServiceLog::default_logs_dir() {
const char* home = std::getenv("HOME");
return std::string(home && *home ? home : "/tmp") + "/.local/share/Steam/logs";
}
XrServiceLog::XrServiceLog(std::string logs_dir) : logs_dir_(std::move(logs_dir)) {}
XrServiceLog::~XrServiceLog() { stop(); }
bool apply_xrservice_line(const std::string& line, XrState* s) {
const auto has = [&line](const char* text) { return line.find(text) != std::string::npos; };
if (has("[DeckardCaptureSource] Passthrough cameras resumed")) s->passthrough = true;
else if (has("[DeckardCaptureSource] Passthrough cameras paused")) s->passthrough = false;
else if (has("[UserPresence] Received onEnterStandby")) s->standby = true;
else if (has("[UserPresence] Received onLeaveStandby")) s->standby = false;
else if (has("[IREmitters] IR Emitters Turned On")) s->emitters = true;
else if (has("[IREmitters] IR Emitters Turned Off")) s->emitters = false;
else if (has("[IREmitters] IR emitters mode changed to Auto")) s->emitters = false;
else if (has("Transition to IMUFallback")) s->tracking_lost = true;
else if (has("[IMUFallback] Disabled")) s->tracking_lost = false;
else return false;
return true;
}
void apply_xrservice_text(const std::string& text, XrState* state) {
std::size_t start = 0;
while (start < text.size()) {
std::size_t end = text.find('\n', start);
if (end == std::string::npos) end = text.size();
apply_xrservice_line(text.substr(start, end - start), state);
start = end + 1;
}
}
long long XrServiceLog::parse_session_start(const std::string& path) {
int y, mo, d, h, mi, s;
const auto slash = path.rfind("/XRService-");
if (slash == std::string::npos || slash < 11) return 0;
const auto dir = path.rfind("XRService-", slash - 1);
if (dir == std::string::npos) return 0;
if (std::sscanf(path.c_str() + dir, "XRService-%d.%d.%d/XRService-%d-%d-%d.log", &y, &mo, &d, &h, &mi, &s) != 6)
return 0;
std::tm tm{};
tm.tm_year = y - 1900;
tm.tm_mon = mo - 1;
tm.tm_mday = d;
tm.tm_hour = h;
tm.tm_min = mi;
tm.tm_sec = s;
tm.tm_isdst = -1;
return static_cast<long long>(std::mktime(&tm));
}
void XrServiceLog::stop() {
if (file_fd_ >= 0) ::close(file_fd_);
if (inotify_fd_ >= 0) ::close(inotify_fd_);
file_fd_ = inotify_fd_ = -1;
dir_watch_ = file_watch_ = -1;
partial_.clear();
}
bool XrServiceLog::start() {
stop();
inotify_fd_ = ::inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
if (inotify_fd_ < 0) {
error_ = std::string("inotify: ") + std::strerror(errno);
return false;
}
dir_watch_ = ::inotify_add_watch(inotify_fd_, logs_dir_.c_str(), IN_CREATE | IN_MOVED_TO);
if (!open_log()) {
stop();
return false;
}
return true;
}
bool XrServiceLog::open_log() {
if (file_fd_ >= 0) ::close(file_fd_);
if (file_watch_ >= 0) ::inotify_rm_watch(inotify_fd_, file_watch_);
file_fd_ = file_watch_ = -1;
partial_.clear();
char resolved[PATH_MAX];
const std::string link = logs_dir_ + "/" + kLink;
if (!::realpath(link.c_str(), resolved)) {
error_ = "cannot resolve " + link + ": " + std::strerror(errno);
return false;
}
// A different session log than the last one seen (even across a
// stop/start) means XRService restarted in between.
if (!log_path_.empty() && log_path_ != resolved) restarted_ = true;
log_path_ = resolved;
session_start_ = parse_session_start(log_path_);
file_fd_ = ::open(log_path_.c_str(), O_RDONLY | O_CLOEXEC);
if (file_fd_ < 0) {
error_ = "cannot open " + log_path_ + ": " + std::strerror(errno);
return false;
}
file_watch_ = ::inotify_add_watch(inotify_fd_, log_path_.c_str(), IN_MODIFY);
// Initial state from the tail of the log.
struct stat st{};
::fstat(file_fd_, &st);
const long long size = st.st_size;
const long long from = size > kMaxInitialRead ? size - kMaxInitialRead : 0;
std::string text(static_cast<std::size_t>(size - from), '\0');
long long got = 0;
while (got < size - from) {
const auto n = ::pread(file_fd_, text.data() + got, static_cast<std::size_t>(size - from - got), from + got);
if (n <= 0) break;
got += n;
}
text.resize(static_cast<std::size_t>(got));
// Everything we track comes from this session's log.
state_ = {};
apply_xrservice_text(text, &state_);
offset_ = from + got;
error_.clear();
return true;
}
void XrServiceLog::read_new_bytes() {
struct stat st{};
if (file_fd_ < 0 || ::fstat(file_fd_, &st) != 0) return;
if (st.st_size < offset_) offset_ = 0; // truncated
std::vector<char> buf(64 * 1024);
for (;;) {
const auto n = ::pread(file_fd_, buf.data(), buf.size(), offset_);
if (n <= 0) break;
offset_ += n;
partial_.append(buf.data(), static_cast<std::size_t>(n));
std::size_t start = 0, nl;
while ((nl = partial_.find('\n', start)) != std::string::npos) {
apply_xrservice_line(partial_.substr(start, nl - start), &state_);
start = nl + 1;
}
partial_.erase(0, start);
}
}
bool XrServiceLog::update() {
if (inotify_fd_ < 0) return false;
const XrState before = state_;
bool file_changed = false, relink = false;
alignas(inotify_event) char buf[4096];
for (;;) {
const auto n = ::read(inotify_fd_, buf, sizeof buf);
if (n <= 0) break;
for (char* p = buf; p < buf + n;) {
const auto* e = reinterpret_cast<const inotify_event*>(p);
if (e->wd == file_watch_) file_changed = true;
if (e->wd == dir_watch_ && e->len && std::strcmp(e->name, kLink) == 0) relink = true;
p += sizeof(inotify_event) + e->len;
}
}
if (relink) {
char resolved[PATH_MAX];
const std::string link = logs_dir_ + "/" + kLink;
if (::realpath(link.c_str(), resolved) && log_path_ != resolved) open_log();
}
if (file_changed) read_new_bytes();
return state_.passthrough != before.passthrough || state_.standby != before.standby ||
state_.emitters != before.emitters || state_.tracking_lost != before.tracking_lost;
}
} // namespace autopass
+86
View File
@@ -0,0 +1,86 @@
#pragma once
// Follows XRService's session log for the few facts autopassd needs
// (FINDINGS.md sections 29 and 33). Lines, as logged on the headset:
//
// [DeckardCaptureSource] Passthrough cameras resumed | paused
// passthrough is being shown | not shown (every toggle, and standby)
// [UserPresence] Received onEnterStandby | onLeaveStandby
// [IREmitters] IR Emitters Turned On | Off
// XRService's own "too dark for the cameras" judgement
// Transition to IMUFallback | [IMUFallback] Disabled
// visual tracking lost | back
//
// ~/.local/share/Steam/logs/xrservice.txt is a symlink to the current
// session log. start() reads the log once for the latest state, then
// inotify delivers only appended bytes. XRService writes nothing while the
// headset is idle, so an idle autopassd is never woken. A new session log (the
// symlink changes, also across stop/start) means XRService restarted.
#include <optional>
#include <string>
namespace autopass {
struct XrState {
std::optional<bool> passthrough; // cameras resumed (shown) / paused
std::optional<bool> standby; // headset in standby
std::optional<bool> emitters; // IR emitters on
std::optional<bool> tracking_lost; // IMU fallback active
};
// Applies one log line to `state`. Returns true if it was a line we track.
bool apply_xrservice_line(const std::string& line, XrState* state);
// Applies every complete or trailing line of `text` in order.
void apply_xrservice_text(const std::string& text, XrState* state);
class XrServiceLog {
public:
explicit XrServiceLog(std::string logs_dir = default_logs_dir());
~XrServiceLog();
XrServiceLog(const XrServiceLog&) = delete;
XrServiceLog& operator=(const XrServiceLog&) = delete;
static std::string default_logs_dir();
bool start(); // false with error() if the log cannot be opened
void stop();
bool running() const { return inotify_fd_ >= 0; }
int fd() const { return inotify_fd_; }
const std::string& error() const { return error_; }
// Handles pending inotify events without blocking. Returns true if any
// tracked state changed.
bool update();
const XrState& state() const { return state_; }
// True once after XRService started a new session log since the one
// seen before, including across stop()/start(). Cleared by reading.
bool take_restarted() {
const bool r = restarted_;
restarted_ = false;
return r;
}
// Session start (Unix time) parsed from the log path, or 0.
long long session_start() const { return session_start_; }
static long long parse_session_start(const std::string& path);
private:
bool open_log();
void read_new_bytes();
std::string logs_dir_;
std::string log_path_;
int inotify_fd_ = -1;
int dir_watch_ = -1;
int file_watch_ = -1;
int file_fd_ = -1;
long long offset_ = 0;
std::string partial_;
XrState state_;
bool restarted_ = false;
long long session_start_ = 0;
std::string error_;
};
} // namespace autopass
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+53
View File
@@ -0,0 +1,53 @@
// Host-side tests for the autopass.conf parser.
#include "daemon_config.hpp"
#include <cstdio>
using namespace autopass;
namespace {
int failures = 0;
#define CHECK(expr) do { if (!(expr)) { std::printf("FAIL line %d: %s\n", __LINE__, #expr); ++failures; } } while (0)
} // namespace
int main() {
DaemonConfig c;
CHECK(parse_daemon_config("", &c).empty());
CHECK(c.sensors.colour_min_light == 0.6 && c.sensors.colour_dark_light == 0.35 && !c.observe_only);
CHECK(c.policy.confirm_s == 0.5 && c.policy.urgent_confirm_s == 0.25 && c.policy.cooldown_base_s == 2.0);
CHECK(parse_daemon_config("# comment\n\n colour_min_light = 0.5 # inline\nverify_s=4\nobserve_only = true\n", &c).empty());
CHECK(c.sensors.colour_min_light == 0.5 && c.sensors.verify_s == 4.0 && c.observe_only);
CHECK(parse_daemon_config("cooldown_base_s = 1\ncooldown_max_s = 20\n", &c).empty());
CHECK(c.policy.cooldown_base_s == 1.0 && c.policy.cooldown_max_s == 20.0);
// Errors leave the config untouched and name the line.
DaemonConfig d;
const auto e1 = parse_daemon_config("confirm_s = 2\nverfy_s = 5\n", &d);
CHECK(e1.find("line 2") != std::string::npos && e1.find("unknown key") != std::string::npos);
CHECK(d.policy.confirm_s == 0.5);
// Removed keys are rejected, not silently ignored.
CHECK(parse_daemon_config("ir_max_grain = 7\n", &d).find("unknown key") != std::string::npos);
CHECK(parse_daemon_config("grain_lockout_s = 60\n", &d).find("unknown key") != std::string::npos);
CHECK(parse_daemon_config("ir_min_light = 0.2\nir_light_hold_s = 0.5\nconfirm_to_colour_s = 0.5\n", &d).empty());
CHECK(d.policy.confirm_to_colour_s == 0.5);
CHECK(d.sensors.ir_min_light == 0.2 && d.sensors.ir_light_hold_s == 0.5);
CHECK(!parse_daemon_config("ir_min_light = 0\n", &d).empty());
CHECK(!parse_daemon_config("ir_light_lockout_max_s = 10\n", &d).empty()); // below ir_light_lockout_s
// colour_dark_light above colour_min_light makes no sense.
CHECK(!parse_daemon_config("colour_dark_light = 0.7\n", &d).empty());
CHECK(parse_daemon_config("cooldown_base_s = ten\n", &d).find("not a number") != std::string::npos);
CHECK(parse_daemon_config("cooldown_base_s = 10s\n", &d).find("not a number") != std::string::npos);
CHECK(parse_daemon_config("min_dwell_s = 10\n", &d).find("unknown key") != std::string::npos);
CHECK(!parse_daemon_config("cooldown_base_s = 40\n", &d).empty()); // above cooldown_max_s
CHECK(parse_daemon_config("observe_only = yes\n", &d).find("true or false") != std::string::npos);
CHECK(parse_daemon_config("just words\n", &d).find("key = value") != std::string::npos);
// Values that parse but fail policy validation are rejected too.
CHECK(!parse_daemon_config("colour_min_light = 2\n", &d).empty());
CHECK(d.sensors.colour_min_light == 0.6);
if (failures) { std::printf("%d check(s) failed\n", failures); return 1; }
std::printf("all autopassd config tests passed\n");
return 0;
}
+273
View File
@@ -0,0 +1,273 @@
// Host-side tests for LightPolicy: evidence in, switching decisions out.
// Sensor behaviour is tested in test_sensors.cpp.
#include "light_policy.hpp"
#include <cstdio>
#include <functional>
#include <vector>
using namespace autopass;
namespace {
int failures = 0;
void check(bool ok, const char* what, int line) {
if (!ok) {
std::printf("FAIL line %d: %s\n", line, what);
++failures;
}
}
#define CHECK(expr) check((expr), #expr, __LINE__)
// Feeds evidence(t) every `step_ms` from `from_ms` to `to_ms`; returns the
// switch times.
std::vector<std::uint64_t> run(LightPolicy& p, std::uint64_t from_ms, std::uint64_t to_ms,
const std::function<Evidence(std::uint64_t)>& evidence, std::uint64_t step_ms = 250) {
std::vector<std::uint64_t> switches;
for (std::uint64_t t = from_ms; t <= to_ms; t += step_ms)
if (p.update(t, evidence(t)).changed) switches.push_back(t);
return switches;
}
// Evidence for the source currently shown in a room that is dark when
// `dark(t)`: Dark while colour is shown in the dark, Bright while IR is
// shown in the light, otherwise Neutral.
std::function<Evidence(std::uint64_t)> room(LightPolicy& p, const std::function<bool(std::uint64_t)>& dark) {
return [&p, dark](std::uint64_t t) {
const bool d = dark(t);
if (p.source() == Source::Color) return d ? Evidence::Dark : Evidence::Neutral;
return d ? Evidence::Neutral : Evidence::Bright;
};
}
// The timing rules are tested with a 0.5 s confirmation; the defaults are
// covered by test_sensors' end-to-end replays.
PolicyConfig cfg() {
PolicyConfig c;
c.confirm_s = 0.5;
return c;
}
void test_validate_and_strings() {
CHECK(validate(PolicyConfig{}).empty());
PolicyConfig c;
c.confirm_s = -1;
CHECK(!validate(c).empty());
c = {};
c.stale_s = 0;
CHECK(!validate(c).empty());
c = {};
c.cooldown_max_s = 1; // below the 2 s base
CHECK(!validate(c).empty());
Mode m;
CHECK(parse_mode("auto", &m) && m == Mode::Auto);
CHECK(parse_mode("colour", &m) && m == Mode::ForceColor);
CHECK(parse_mode("color", &m) && m == Mode::ForceColor);
CHECK(parse_mode("ir", &m) && m == Mode::ForceIr);
CHECK(!parse_mode("bright", &m));
}
void test_first_switch_is_fast() {
// Lights off in a room: the switch comes after confirm_s (0.5 s), not
// after any cooldown.
LightPolicy p(cfg(), Source::Color);
const auto s = run(p, 0, 20000, room(p, [](std::uint64_t t) { return t >= 5000; }));
CHECK(s.size() == 1);
if (!s.empty()) CHECK(s[0] >= 5500 && s[0] <= 5750);
}
void test_normal_off_then_on_is_fast_both_ways() {
LightPolicy p(cfg(), Source::Color);
const auto s = run(p, 0, 60000, room(p, [](std::uint64_t t) { return t >= 5000 && t < 30000; }));
CHECK(s.size() == 2);
if (s.size() == 2) {
CHECK(s[0] <= 5750);
CHECK(s[1] >= 30000 && s[1] <= 32250); // settle 1.5 s already long past; confirm 0.5 s
}
}
void test_quick_reversal_waits_for_base_cooldown() {
// Light off, then straight back on after 1 s: the reversal waits for
// the 2 s cooldown, not longer.
LightPolicy p(cfg(), Source::Color);
const auto s = run(p, 0, 20000, room(p, [](std::uint64_t t) { return t >= 5000 && t < 6000; }));
CHECK(s.size() == 2);
if (s.size() == 2) CHECK(s[1] - s[0] >= 2000 && s[1] - s[0] <= 2750);
}
void test_flickering_light_escalates_cooldown() {
// A light flickering on/off every second for two minutes. Without the
// cooldown this would switch ~120 times; with it, the gaps double.
LightPolicy p(cfg(), Source::Color);
const auto s = run(p, 0, 120000, room(p, [](std::uint64_t t) { return (t / 1000) % 2 == 1; }));
CHECK(s.size() >= 3 && s.size() <= 10);
for (std::size_t i = 2; i < s.size(); ++i) CHECK(s[i] - s[i - 1] >= s[i - 1] - s[i - 2] - 1000);
// Gaps reach the 30 s cap and never exceed cap + one flicker period.
if (s.size() >= 2) CHECK(s.back() - s[s.size() - 2] <= 32000);
std::printf("flicker: %zu switches in 120 s:", s.size());
for (auto t : s) std::printf(" %.1f", t / 1000.0);
std::printf("\n");
}
void test_lights_toggled_by_hand_stay_fast() {
// Someone switching the lights every 5 s for a minute (2026-10-01 18:55:
// the old rule made each switch slower than the last). Every change is
// followed within confirm time; the cooldown never grows.
LightPolicy p(cfg(), Source::Color);
const auto s = run(p, 0, 62000, room(p, [](std::uint64_t t) { return t >= 2000 && (t - 2000) / 5000 % 2 == 0; }));
CHECK(s.size() == 12);
for (std::size_t i = 0; i < s.size(); ++i) {
const std::uint64_t change = 2000 + 5000 * i;
CHECK(s[i] >= change && s[i] <= change + 1250);
}
CHECK(p.cooldown_ms() == 2000);
}
void test_cooldown_resets_after_quiet_period() {
LightPolicy p(cfg(), Source::Color);
// Four quick reversals escalate the cooldown...
run(p, 0, 30000, room(p, [](std::uint64_t t) { return (t / 1000) % 2 == 1; }));
CHECK(p.cooldown_ms() > 2000);
// ...then a quiet minute in steady light, and a normal off/on is fast
// again.
const bool steady_dark = p.source() == Source::Ir; // keep the light matching what is shown
run(p, 30250, 100000, room(p, [steady_dark](std::uint64_t) { return steady_dark; }));
const bool start_dark = p.source() == Source::Ir;
const auto s = run(p, 100250, 110000, room(p, [start_dark](std::uint64_t) { return !start_dark; }));
CHECK(s.size() == 1);
if (!s.empty()) CHECK(s[0] <= 101000);
}
void test_brief_evidence_ignored() {
// Dark evidence for less than confirm_s (a hand passing) never switches.
LightPolicy p(cfg(), Source::Color);
CHECK(run(p, 0, 30000, room(p, [](std::uint64_t t) { return t >= 5000 && t < 5250; })).empty());
// Evidence flickering faster than confirm_s never switches either.
LightPolicy q(cfg(), Source::Color);
int changes = 0;
for (std::uint64_t t = 0; t < 60000; t += 250)
if (q.update(t, (t / 250) % 2 ? Evidence::Dark : Evidence::Neutral).changed) ++changes;
CHECK(changes == 0);
}
void test_unknown_between_samples_keeps_confirming() {
// A sensor sampling slower than the loop produces Unknown in between;
// confirmation keeps accumulating across those ticks.
LightPolicy p(cfg(), Source::Color);
const auto s = run(p, 0, 5000, [](std::uint64_t t) { return (t / 250) % 2 ? Evidence::Unknown : Evidence::Dark; });
CHECK(s.size() == 1);
}
void test_stale_readings_hold_and_reset() {
LightPolicy p(cfg(), Source::Color);
run(p, 0, 2000, [](auto) { return Evidence::Neutral; });
// Dark for 0.25 s (not yet confirmed), then readings stop for 5 s.
CHECK(run(p, 2250, 2500, [](auto) { return Evidence::Dark; }).empty());
CHECK(run(p, 2750, 7750, [](auto) { return Evidence::Unknown; }).empty());
CHECK(p.source() == Source::Color);
// Dark again: confirmation restarts from zero.
const auto s = run(p, 8000, 12000, [](auto) { return Evidence::Dark; });
CHECK(s.size() == 1);
if (!s.empty()) CHECK(s[0] >= 8500);
}
void test_manual_override() {
LightPolicy p(cfg(), Source::Color);
auto d = p.set_mode(1000, Mode::ForceIr);
CHECK(d.changed && d.source == Source::Ir);
CHECK(run(p, 1250, 60000, [](auto) { return Evidence::Bright; }).empty());
d = p.set_mode(60000, Mode::Auto);
CHECK(!d.changed && d.source == Source::Ir);
d = p.set_mode(61000, Mode::ForceColor);
CHECK(d.changed && d.source == Source::Color);
CHECK(!p.set_mode(62000, Mode::ForceColor).changed);
}
void test_manual_switch_does_not_delay_auto() {
// Seen live 23:27: force IR in bright light, back to auto; colour
// returns after settle + confirm, with no cooldown. A long cooldown
// makes the difference visible past the settle window.
PolicyConfig c = cfg();
c.cooldown_base_s = 10;
LightPolicy p(c, Source::Color);
CHECK(p.set_mode(5000, Mode::ForceIr).changed);
p.set_mode(6000, Mode::Auto);
const auto s = run(p, 6250, 20000, [](auto) { return Evidence::Bright; });
CHECK(s.size() == 1);
if (!s.empty()) CHECK(s[0] <= 7250);
}
void test_settle_ignores_transition_evidence() {
LightPolicy p(cfg(), Source::Color);
CHECK(p.set_mode(1000, Mode::ForceIr).changed);
p.set_mode(1100, Mode::Auto);
for (std::uint64_t t = 1250; t < 2500; t += 250)
CHECK(p.update(t, Evidence::Bright).reason == "settling after switch");
CHECK(p.update(2600, Evidence::Bright).reason == "confirming");
}
void test_adopt_is_not_a_switch() {
LightPolicy p(cfg(), Source::Color);
p.adopt(Source::Ir);
CHECK(p.source() == Source::Ir && !p.settling(0));
const auto s = run(p, 0, 5000, [](auto) { return Evidence::Bright; });
CHECK(s.size() == 1);
if (!s.empty()) CHECK(s[0] <= 750);
}
void test_urgent_skips_cooldown_with_short_confirm() {
// A switch to colour turned out wrong (it is dark): undoing it must not
// wait for the cooldown, and needs only urgent_confirm_s (0.25 s).
PolicyConfig c = cfg();
c.cooldown_base_s = 10;
LightPolicy p(c, Source::Ir);
std::uint64_t to_colour = 0, back = 0;
for (std::uint64_t t = 0; t < 5000 && !to_colour; t += 250)
if (p.update(t, Evidence::Bright).changed) to_colour = t;
CHECK(to_colour > 0);
for (std::uint64_t t = to_colour + 50; t < 20000 && !back; t += 50)
if (p.update(t, Evidence::Dark, "", true).changed) back = t;
CHECK(back && back - to_colour <= 650); // settle 0.3 s + urgent confirm 0.25 s
// Non-urgent evidence afterwards respects the escalated cooldown (the
// quick reversal doubled it to 20 s).
CHECK(p.cooldown_ms() == 20000);
const auto s2 = run(p, back + 250, back + 40000, [](auto) { return Evidence::Bright; });
CHECK(s2.size() == 1);
if (!s2.empty()) CHECK(s2[0] - back >= 20000);
}
void test_time_going_backwards_is_safe() {
LightPolicy p(cfg(), Source::Color);
run(p, 0, 5000, [](auto) { return Evidence::Neutral; });
CHECK(!p.update(4000, Evidence::Neutral).changed);
CHECK(!p.update(100, Evidence::Dark).changed);
}
} // namespace
int main() {
test_validate_and_strings();
test_first_switch_is_fast();
test_normal_off_then_on_is_fast_both_ways();
test_quick_reversal_waits_for_base_cooldown();
test_flickering_light_escalates_cooldown();
test_lights_toggled_by_hand_stay_fast();
test_cooldown_resets_after_quiet_period();
test_brief_evidence_ignored();
test_unknown_between_samples_keeps_confirming();
test_stale_readings_hold_and_reset();
test_manual_override();
test_manual_switch_does_not_delay_auto();
test_settle_ignores_transition_evidence();
test_adopt_is_not_a_switch();
test_urgent_skips_cooldown_with_short_confirm();
test_time_going_backwards_is_safe();
if (failures) {
std::printf("%d check(s) failed\n", failures);
return 1;
}
std::printf("all light policy tests passed\n");
return 0;
}
+83
View File
@@ -0,0 +1,83 @@
// Parses real VR_CameraPassthroughState snapshots captured on the headset
// (tests/fixtures, taken from the 21:32 live capture: one lit, one dark).
#include "passthrough_state.hpp"
#include <cstdio>
#include <fstream>
#include <iterator>
#include <utility>
#include <vector>
using namespace autopass;
namespace {
int failures = 0;
#define CHECK(expr) do { if (!(expr)) { std::printf("FAIL line %d: %s\n", __LINE__, #expr); ++failures; } } while (0)
std::vector<std::uint8_t> load(const char* path) {
std::ifstream f(path, std::ios::binary);
return {std::istreambuf_iterator<char>(f), {}};
}
} // namespace
int main() {
const auto lit = load("tests/fixtures/state_lit.bin");
const auto dark = load("tests/fixtures/state_dark.bin");
CHECK(lit.size() == PassthroughState::kSize);
CHECK(dark.size() == PassthroughState::kSize);
if (failures) return 1;
const auto a = PassthroughState::parse(lit.data());
CHECK(a.config.valid());
CHECK(a.config.enabled == 1 && a.config.rgb == 1);
CHECK(a.colour.has_value());
if (a.colour) CHECK(a.colour->light > 0.7f && a.colour->timestamp > 0);
const auto b = PassthroughState::parse(dark.data());
CHECK(b.config.rgb == 1);
CHECK(b.colour.has_value());
if (b.colour) CHECK(b.colour->light < 0.05f);
// Liveness timestamps: in RGB mode the colour stream is the newest;
// in mono mode (capture 2, t=60 s) the mono stream is newer and the
// colour records are frozen at the moment of the switch.
CHECK(a.colour_timestamp > 0 && a.colour_timestamp > a.mono_timestamp);
if (a.colour) CHECK(a.colour_timestamp >= a.colour->timestamp);
const auto mono = load("tests/fixtures/state_mono.bin");
CHECK(mono.size() == PassthroughState::kSize);
if (mono.size() == PassthroughState::kSize) {
const auto m = PassthroughState::parse(mono.data());
CHECK(m.config.enabled == 1 && m.config.rgb == 0);
CHECK(m.mono_timestamp > m.colour_timestamp);
CHECK(m.mono_timestamp - m.colour_timestamp > 10.0);
}
// The IR camera's light value (FINDINGS.md 41), captured with IR shown
// in room B 2026-10-01: light on 0.219, light off (emitters on) 0.
for (const auto& [file, lit] : {std::pair{"tests/fixtures/state_mono_lit.bin", true},
std::pair{"tests/fixtures/state_mono_dark.bin", false}}) {
const auto raw = load(file);
CHECK(raw.size() == PassthroughState::kSize);
if (raw.size() != PassthroughState::kSize) continue;
const auto m = PassthroughState::parse(raw.data());
CHECK(m.config.rgb == 0 && m.mono_light.has_value());
if (m.mono_light) CHECK(lit ? *m.mono_light > 0.2f && *m.mono_light < 0.25f : *m.mono_light == 0.0f);
}
// An all-zero buffer has no records and an all-off config.
std::vector<std::uint8_t> zero(PassthroughState::kSize, 0);
const auto z = PassthroughState::parse(zero.data());
CHECK(!z.colour.has_value());
CHECK(z.config.valid() && !z.config.enabled);
CHECK(z.mono_timestamp == 0 && z.colour_timestamp == 0);
CHECK(!z.mono_light.has_value());
if (a.colour && b.colour)
std::printf("lit light=%.3f dark light=%.3f\n", a.colour->light, b.colour->light);
if (failures) { std::printf("%d check(s) failed\n", failures); return 1; }
std::printf("all passthrough state tests passed\n");
return 0;
}
+384
View File
@@ -0,0 +1,384 @@
// Host-side tests for the sensor layer: XRService log parsing and the
// evidence rules (FINDINGS.md sections 29, 34 and 39), plus an end-to-end
// replay of the scenarios seen live, run through LightPolicy.
#include "light_policy.hpp"
#include "sensors.hpp"
#include "xrservice_log.hpp"
#include <cstdio>
#include <optional>
#include <string>
#include <vector>
using namespace autopass;
namespace {
int failures = 0;
#define CHECK(expr) do { if (!(expr)) { std::printf("FAIL line %d: %s\n", __LINE__, #expr); ++failures; } } while (0)
// Real XRService lines from 2026-09-30.
const char* kModeAuto = "Wed Sep 30 2026 22:39:15.067651 INFO: SLAMConsole: [IREmitters] IR emitters mode changed to Auto";
const char* kOn = "Wed Sep 30 2026 22:40:49.267834 INFO: SLAMConsole: [IREmitters] IR Emitters Turned On";
const char* kOff = "Wed Sep 30 2026 22:41:24.230440 INFO: SLAMConsole: [IREmitters] IR Emitters Turned Off";
const char* kNoise = "Wed Sep 30 2026 22:39:23.152963 WARNING: [DeckardCaptureSource] Max number of iteration reached when estimating the CCT from grey world gains";
void test_parsing() {
XrState s;
CHECK(apply_xrservice_line(kOn, &s) && s.emitters == std::optional<bool>(true));
CHECK(apply_xrservice_line(kOff, &s) && s.emitters == std::optional<bool>(false));
CHECK(apply_xrservice_line(kModeAuto, &s) && s.emitters == std::optional<bool>(false));
CHECK(!apply_xrservice_line(kNoise, &s));
CHECK(!apply_xrservice_line("IR Emitters Turned On", &s)); // must carry the [IREmitters] tag
// Real lines from 2026-09-30 for the other tracked facts.
CHECK(apply_xrservice_line("Wed Sep 30 2026 22:39:15.083409 INFO: [DeckardCaptureSource] Passthrough cameras resumed", &s));
CHECK(s.passthrough == std::optional<bool>(true));
CHECK(apply_xrservice_line("Wed Sep 30 2026 22:44:35.826667 INFO: [DeckardCaptureSource] Passthrough cameras paused", &s));
CHECK(s.passthrough == std::optional<bool>(false));
CHECK(apply_xrservice_line("Wed Sep 30 2026 22:44:35.792844 INFO: [UserPresence] Received onEnterStandby from SteamVR. Setting UserPresenceDetected to 0.", &s));
CHECK(s.standby == std::optional<bool>(true));
CHECK(apply_xrservice_line("Wed Sep 30 2026 22:49:17.425068 INFO: [UserPresence] Received onLeaveStandby from SteamVR. Setting UserPresenceDetected to 1.", &s));
CHECK(s.standby == std::optional<bool>(false));
CHECK(apply_xrservice_line("Wed Sep 30 2026 22:44:35.852332 INFO: Transition to IMUFallback. Latest tracked pose: ref=Cam0", &s));
CHECK(s.tracking_lost == std::optional<bool>(true));
CHECK(apply_xrservice_line("Wed Sep 30 2026 22:39:15.336452 INFO: [DCU] [IMUFallback] Disabled with latest prediction: ", &s));
CHECK(s.tracking_lost == std::optional<bool>(false));
// "Tracking cameras streaming paused" is not the passthrough line.
XrState t;
CHECK(!apply_xrservice_line("Wed Sep 30 2026 22:44:35.885592 INFO: [DeckardCaptureSource] Tracking cameras streaming paused", &t));
CHECK(!t.passthrough);
// Text: last line of each kind wins; a trailing partial line counts.
const std::string nl = "\n";
XrState u;
apply_xrservice_text(std::string(kModeAuto) + nl + kOn + nl + kNoise + nl + kOff + nl + kOn, &u);
CHECK(u.emitters == std::optional<bool>(true));
}
void test_ema() {
Ema e(1.0);
CHECK(!e.has());
CHECK(e.add(0, 10) == 10);
const double v = e.add(1000, 0); // one time constant later: ~37% left
CHECK(v > 3.5 && v < 3.9);
e.reset();
CHECK(!e.has());
}
void test_colour_rules() {
EvidenceBuilder b;
// Emitters on and colour dim: dark.
auto r = b.evaluate(0, Source::Color, true, 0.435);
CHECK(r.evidence == Evidence::Dark && r.cause == Cause::EmittersOnDim && !r.urgent);
// Emitters on but colour bright (a hand just left the cameras; the
// emitters lag ~5 s): not dark.
b.reset();
CHECK(b.evaluate(0, Source::Color, true, 0.95).evidence == Evidence::Neutral);
// Emitters off, dim-looking view in good light (the 23:27:47 case): not dark.
b.reset();
CHECK(b.evaluate(0, Source::Color, false, 0.63).evidence == Evidence::Neutral);
// Emitters off and colour reads dark (dusk, 2026-10-01 18:41): XRService's
// cameras have light enough, so no switch however long it lasts.
b.reset();
for (std::uint64_t t = 0; t <= 60000; t += 1000)
CHECK(b.evaluate(t, Source::Color, false, 0.01).evidence == Evidence::Neutral);
// Emitters on and no colour reading ever: dark.
b.reset();
CHECK(b.evaluate(0, Source::Color, true, std::nullopt).evidence == Evidence::Dark);
// Colour seen before but no fresh sample this tick: unknown.
b.reset();
b.evaluate(0, Source::Color, true, 0.9);
CHECK(b.evaluate(250, Source::Color, true, std::nullopt).evidence == Evidence::Unknown);
// Emitter state unknown: never switch either way.
b.reset();
CHECK(b.evaluate(0, Source::Color, std::nullopt, 0.0).evidence == Evidence::Neutral);
CHECK(b.evaluate(0, Source::Ir, std::nullopt, std::nullopt).evidence == Evidence::Neutral);
}
void test_ir_rules() {
EvidenceBuilder b;
auto r = b.evaluate(0, Source::Ir, false, std::nullopt);
CHECK(r.evidence == Evidence::Bright && r.cause == Cause::EmittersOff);
CHECK(b.evaluate(0, Source::Ir, true, std::nullopt).evidence == Evidence::Unknown);
// Emitters on and a dark IR light reading: not bright.
CHECK(b.evaluate(500, Source::Ir, true, std::nullopt, 0.0).evidence == Evidence::Neutral);
}
void test_ir_light() {
// The IR camera's own light value (FINDINGS.md 41): a lit reading is
// bright at once (it steps 0 -> 0.33 within 0.1 s; the policy's
// confirmation does the rest).
EvidenceBuilder a;
auto r = a.evaluate(0, Source::Ir, true, std::nullopt, 0.3);
CHECK(r.evidence == Evidence::Bright && r.cause == Cause::IrLight);
CHECK(a.evaluate(250, Source::Ir, true, std::nullopt, 0.14).evidence == Evidence::Neutral);
// With a hold configured, it must last that long.
SensorConfig held;
held.ir_light_hold_s = 0.25;
EvidenceBuilder b(held);
CHECK(b.evaluate(0, Source::Ir, true, std::nullopt, 0.3).evidence == Evidence::Neutral);
r = b.evaluate(250, Source::Ir, true, std::nullopt, 0.3);
CHECK(r.evidence == Evidence::Bright && r.cause == Cause::IrLight);
// Dark (0 with the emitters on, 70 s in room B): never.
EvidenceBuilder c;
for (std::uint64_t t = 0; t <= 70000; t += 500)
CHECK(c.evaluate(t, Source::Ir, true, std::nullopt, 0.0).evidence != Evidence::Bright);
// A dark reading resets the hold.
EvidenceBuilder d(held);
d.evaluate(0, Source::Ir, true, std::nullopt, 0.3);
d.evaluate(125, Source::Ir, true, std::nullopt, 0.0);
CHECK(d.evaluate(250, Source::Ir, true, std::nullopt, 0.3).evidence != Evidence::Bright);
// If it misled us (colour dark right after), it is locked out for a while.
EvidenceBuilder e;
e.on_switched(0, Source::Color, true, Cause::IrLight);
CHECK(e.evaluate(300, Source::Color, true, 0.0).cause == Cause::VerifyFailed);
e.on_switched(600, Source::Ir, true, Cause::VerifyFailed);
for (std::uint64_t t = 1000; t < 60000; t += 250)
CHECK(e.evaluate(t, Source::Ir, true, std::nullopt, 0.3).evidence != Evidence::Bright);
e.evaluate(61000, Source::Ir, true, std::nullopt, 0.3);
CHECK(e.evaluate(61250, Source::Ir, true, std::nullopt, 0.3).evidence == Evidence::Bright);
}
void test_ir_light_lockout_escalates() {
// The IR light value misled us (assumed: a surface at medium distance,
// lit by the emitters, in the dark): after each failed colour check it
// is ignored for 60 s, then 120 s, ...
EvidenceBuilder b;
b.on_switched(10000, Source::Color, true, Cause::IrLight);
CHECK(b.evaluate(10300, Source::Color, true, 0.0).cause == Cause::VerifyFailed);
b.on_switched(10600, Source::Ir, true, Cause::VerifyFailed);
CHECK(!b.wants_ir_light(11000, true));
for (std::uint64_t t = 11000; t < 70000; t += 500)
CHECK(b.evaluate(t, Source::Ir, true, std::nullopt, 0.2).evidence != Evidence::Bright);
CHECK(b.wants_ir_light(70400, true));
b.on_switched(80000, Source::Color, true, Cause::IrLight);
b.evaluate(80300, Source::Color, true, 0.0);
b.on_switched(80600, Source::Ir, true, Cause::VerifyFailed);
CHECK(!b.wants_ir_light(80300 + 119000, true));
CHECK(b.wants_ir_light(80300 + 121000, true));
// Emitters off never needs it unless "off" is distrusted.
EvidenceBuilder c;
CHECK(!c.wants_ir_light(0, false));
CHECK(c.wants_ir_light(0, true));
}
void test_verify_and_distrust() {
// "Emitters off" took us to colour, but they are back on and colour is
// dark (a wall fooled XRService): back to IR at once, and "off" is not
// believed for 60 s, then 120 s, ...
EvidenceBuilder b;
b.on_switched(10000, Source::Color, true, Cause::EmittersOff);
auto r = b.evaluate(10300, Source::Color, true, 0.0);
CHECK(r.evidence == Evidence::Dark && r.cause == Cause::VerifyFailed && r.urgent);
CHECK(b.distrusting_emitters_off(10300));
b.on_switched(10800, Source::Ir, true, Cause::VerifyFailed);
CHECK(b.evaluate(11000, Source::Ir, false, std::nullopt).evidence != Evidence::Bright);
CHECK(b.evaluate(70000, Source::Ir, false, std::nullopt).evidence != Evidence::Bright);
CHECK(b.wants_ir_light(70000, false)); // the IR light value stands in meanwhile
// It expires by itself: never stuck in IR while the emitters stay off.
CHECK(!b.distrusting_emitters_off(70400));
r = b.evaluate(70400, Source::Ir, false, std::nullopt);
CHECK(r.evidence == Evidence::Bright && r.cause == Cause::EmittersOff);
// A second mistake doubles it.
b.on_switched(80000, Source::Color, true, Cause::EmittersOff);
b.evaluate(80300, Source::Color, true, 0.0);
CHECK(b.distrusting_emitters_off(80300 + 119000));
CHECK(!b.distrusting_emitters_off(80300 + 120000));
// Emitters off and colour dark right after switching (dusk): fine, no revert.
EvidenceBuilder f;
f.on_switched(0, Source::Color, true, Cause::EmittersOff);
r = f.evaluate(300, Source::Color, false, 0.01);
CHECK(r.evidence == Evidence::Neutral && !r.urgent && !f.distrusting_emitters_off(300));
// A correct switch to colour (it is light) passes the check quietly,
// also while the emitters lag behind.
EvidenceBuilder c;
c.on_switched(0, Source::Color, true, Cause::IrLight);
r = c.evaluate(300, Source::Color, true, 0.9);
CHECK(r.evidence == Evidence::Neutral && !r.urgent);
// After the verify window, emitters on and dark colour take the normal
// (not urgent) route.
EvidenceBuilder d;
d.on_switched(0, Source::Color, true, Cause::EmittersOff);
d.evaluate(3500, Source::Color, true, 0.9);
r = d.evaluate(4000, Source::Color, true, 0.0);
CHECK(r.evidence == Evidence::Dark && r.cause == Cause::EmittersOnDim && !r.urgent);
// A manual switch to colour is not verified.
EvidenceBuilder e;
e.on_switched(0, Source::Color, false, Cause::None);
CHECK(!e.evaluate(300, Source::Color, true, 0.0).urgent);
}
// End to end: a simulated room, XRService's emitters as measured (on ~1 s
// after darkness, off ~5 s after light returns, fooled off after ~3 s with
// a wall close to the headset, off at dusk), the colour camera, sensors,
// policy.
enum class Wall { None, Close, Medium };
struct World {
bool emitters = false;
bool sticky = false; // XRService keeps its emitters on once on (00:28-00:30)
std::uint64_t dark_since = 0, bright_since = 0, wall_since = 0;
void step(std::uint64_t t, double light, Wall wall_kind) {
const bool wall = wall_kind == Wall::Close;
if (wall) {
if (!wall_since) wall_since = t ? t : 1;
if (t - wall_since >= 3000) emitters = false; // reflected IR fools XRService
return;
}
wall_since = 0;
if (light < 0.3 && !dusk(light)) {
if (!dark_since) dark_since = t ? t : 1;
bright_since = 0;
if (t - dark_since >= 1000) emitters = true;
} else if (light >= 0.6 || dusk(light)) {
if (!bright_since) bright_since = t ? t : 1;
dark_since = 0;
if (t - bright_since >= 5000 && !sticky) emitters = false;
} else {
dark_since = bright_since = 0;
}
}
// Dusk (light 0.1..0.3): the colour camera reads ~0 like a dark room,
// but XRService's cameras manage without emitters.
static bool dusk(double light) { return light >= 0.1 && light < 0.3; }
static double colour(double light) { return light >= 0.6 ? 0.9 : light >= 0.3 ? 0.435 : 0.0; }
// The IR camera's light value (FINDINGS.md 41-42): 0 dark, ~0.3 lit,
// 0.10 at a close wall lit by the emitters (measured). A surface at
// medium distance is assumed to fool it (not measured).
static double ir_light(double light, Wall wall) {
if (wall == Wall::Close) return 0.10;
if (wall == Wall::Medium) return 0.2;
return light >= 0.6 ? 0.3 : 0.0;
}
};
struct Replay {
std::vector<std::uint64_t> switches;
std::vector<Source> to;
std::uint64_t colour_in_dark_ms = 0; // time spent showing colour while it was dark
};
Replay replay(double (*light)(std::uint64_t), Wall (*wall)(std::uint64_t), std::uint64_t end_ms,
bool emitters_stuck_off = false, bool sticky = false) {
LightPolicy p({}, Source::Color);
EvidenceBuilder b;
World w;
w.sticky = sticky;
Replay out;
for (std::uint64_t t = 0; t <= end_ms; t += 250) {
const double l = light(t);
const Wall wk = wall(t);
w.step(t, l, wk);
if (emitters_stuck_off) w.emitters = false;
const bool colour_shown = p.source() == Source::Color;
if (colour_shown && l < 0.1) out.colour_in_dark_ms += 250;
// autopassd reads the IR light value at 2 Hz, only when it could matter.
std::optional<double> ir;
if (!colour_shown && b.wants_ir_light(t, w.emitters) && t % 500 == 0) ir = World::ir_light(l, wk);
const auto r = b.evaluate(t, p.source(), w.emitters,
colour_shown ? std::optional<double>(World::colour(l)) : std::nullopt, ir);
const auto d = p.update(t, r.evidence, r.why, r.urgent);
if (d.changed) {
out.switches.push_back(t);
out.to.push_back(d.source);
b.on_switched(t, d.source, true, r.cause);
}
}
return out;
}
Wall no_wall(std::uint64_t) { return Wall::None; }
void print(const char* what, const Replay& r) {
std::printf("%s: switches at", what);
for (std::size_t i = 0; i < r.switches.size(); ++i)
std::printf(" %.2f(%s)", r.switches[i] / 1000.0, r.to[i] == Source::Color ? "colour" : "ir");
std::printf("; colour shown in the dark %.2f s\n", r.colour_in_dark_ms / 1000.0);
}
void test_end_to_end() {
// Lights off at 10 s, on at 40 s: IR within ~2 s; colour within ~1 s
// (the IR light value, held 0.25 s), before XRService's emitters go off.
auto r = replay([](std::uint64_t t) { return t >= 10000 && t < 40000 ? 0.0 : 1.0; }, no_wall, 80000);
print("off/on", r);
CHECK(r.switches.size() == 2);
if (r.switches.size() == 2) {
CHECK(r.switches[0] >= 11000 && r.switches[0] <= 12000);
CHECK(r.switches[1] >= 40500 && r.switches[1] <= 41750);
}
// The 00:28 walk-through: XRService keeps its emitters on through bright
// rooms. Colour must still come back.
r = replay([](std::uint64_t t) { return t >= 10000 && t < 40000 ? 0.0 : 1.0; }, no_wall, 80000, false, true);
print("bright room, emitters stay on", r);
CHECK(r.switches.size() == 2);
if (r.switches.size() == 2) CHECK(r.switches[1] >= 40500 && r.switches[1] <= 41750);
// Dark throughout with the emitters on: never back to colour.
r = replay([](std::uint64_t t) { return t >= 5000 ? 0.0 : 1.0; }, no_wall, 180000);
CHECK(r.switches.size() == 1);
// A hand over the cameras for 1.5 s in a lit room: no switch.
r = replay([](std::uint64_t t) { return t >= 10000 && t < 11500 ? 0.0 : 1.0; }, no_wall, 40000);
CHECK(r.switches.empty());
// Good light, changing views: never switches.
r = replay([](std::uint64_t t) { return (t / 7000) % 2 ? 1.0 : 0.7; }, no_wall, 120000);
CHECK(r.switches.empty());
// Dimmed to 45%: nothing changes.
r = replay([](std::uint64_t) { return 0.45; }, no_wall, 60000);
CHECK(r.switches.empty());
// Dusk (2026-10-01 18:41): the colour camera reads dark, XRService's
// emitters stay off. Never switches.
r = replay([](std::uint64_t t) { return t >= 10000 ? 0.2 : 1.0; }, no_wall, 180000);
print("dusk", r);
CHECK(r.switches.empty());
// Lights off (IR), then back to dusk level: the emitters go off, colour
// returns and stays (the dark colour reading is not a failed check).
r = replay([](std::uint64_t t) { return t >= 10000 && t < 40000 ? 0.0 : 0.2; }, no_wall, 180000);
print("dark then dusk", r);
CHECK(r.switches.size() == 2 && !r.to.empty() && r.to.back() == Source::Color);
// Dark room, face near a wall from 20 s to 40 s. XRService turns its
// emitters off at ~23 s: colour (dark) until they come back on after
// the wall, then IR stays. The accepted cost of trusting the emitters.
r = replay([](std::uint64_t) { return 0.0; },
[](std::uint64_t t) { return t >= 20000 && t < 40000 ? Wall::Close : Wall::None; }, 120000);
print("close wall in the dark", r);
CHECK(!r.to.empty() && r.to.back() == Source::Ir);
CHECK(r.switches.size() <= 3);
CHECK(r.colour_in_dark_ms <= 22000); // the initial ~1.5 s, plus the time at the wall
// A wall at medium distance in the dark for three minutes: the view
// is assumed to fool the IR light value. Each mistake is a sub-second
// colour flash, then it is ignored for 60 s, 120 s, ...: a few flashes.
r = replay([](std::uint64_t) { return 0.0; },
[](std::uint64_t t) { return t >= 20000 && t < 200000 ? Wall::Medium : Wall::None; }, 240000);
print("medium wall in the dark", r);
CHECK(!r.to.empty() && r.to.back() == Source::Ir);
CHECK(r.switches.size() <= 7);
CHECK(r.colour_in_dark_ms <= 5000);
}
} // namespace
int main() {
test_parsing();
test_ema();
test_colour_rules();
test_ir_rules();
test_ir_light();
test_ir_light_lockout_escalates();
test_verify_and_distrust();
test_end_to_end();
if (failures) { std::printf("%d check(s) failed\n", failures); return 1; }
std::printf("all sensor tests passed\n");
return 0;
}
+139
View File
@@ -0,0 +1,139 @@
// Host-side test for XrServiceLog against a fake Steam logs directory:
// initial state from an existing log, appended lines (including one split
// across two writes), unrelated noise, and an XRService restart that
// replaces the xrservice.txt symlink.
#include "xrservice_log.hpp"
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <ctime>
#include <string>
#include <unistd.h>
using autopass::XrServiceLog;
namespace {
int failures = 0;
#define CHECK(expr) do { if (!(expr)) { std::printf("FAIL line %d: %s\n", __LINE__, #expr); ++failures; } } while (0)
const char* kAuto = "Wed Sep 30 2026 22:39:15.067651 INFO: SLAMConsole: [IREmitters] IR emitters mode changed to Auto\n";
const char* kOn = "Wed Sep 30 2026 22:40:49.267834 INFO: SLAMConsole: [IREmitters] IR Emitters Turned On\n";
const char* kOff = "Wed Sep 30 2026 22:41:24.230440 INFO: SLAMConsole: [IREmitters] IR Emitters Turned Off\n";
const char* kNoise = "Wed Sep 30 2026 22:39:23.152963 WARNING: [DeckardCaptureSource] Max number of iteration reached\n";
void append(const std::string& path, const std::string& text) {
std::ofstream f(path, std::ios::app | std::ios::binary);
f << text;
}
} // namespace
int main() {
char tmpl[] = "/tmp/autopass_emitter_XXXXXX";
const std::string dir = ::mkdtemp(tmpl);
const std::string log1 = dir + "/XRService-1.log", log2 = dir + "/XRService-2.log", link = dir + "/xrservice.txt";
const std::string log3 = dir + "/XRService-3.log";
append(log1, std::string(kAuto) + kNoise + kOn + kNoise);
CHECK(::symlink(log1.c_str(), link.c_str()) == 0);
XrServiceLog w(dir);
CHECK(w.start());
CHECK(w.state().emitters == std::optional<bool>(true)); // last line in the existing log
CHECK(!w.update()); // nothing new
CHECK(!w.take_restarted());
append(log1, kNoise);
CHECK(!w.update());
CHECK(w.state().emitters == std::optional<bool>(true));
// A line written in two parts is only parsed once complete.
const std::string off = kOff;
append(log1, off.substr(0, 40));
w.update();
CHECK(w.state().emitters == std::optional<bool>(true));
append(log1, off.substr(40));
CHECK(w.update());
CHECK(w.state().emitters == std::optional<bool>(false));
// XRService restarts: a new session log, symlink replaced atomically.
append(log2, std::string(kAuto) + kNoise);
const std::string tmp_link = dir + "/xrservice.txt.new";
CHECK(::symlink(log2.c_str(), tmp_link.c_str()) == 0);
CHECK(std::rename(tmp_link.c_str(), link.c_str()) == 0);
w.update();
CHECK(w.state().emitters == std::optional<bool>(false)); // "mode changed to Auto" with no On: off
CHECK(w.take_restarted());
CHECK(!w.take_restarted());
append(log2, kOn);
CHECK(w.update());
CHECK(w.state().emitters == std::optional<bool>(true));
// The old log is no longer followed.
append(log1, kOff);
w.update();
CHECK(w.state().emitters == std::optional<bool>(true));
// A restart while the watcher is stopped (passthrough off) is still
// noticed on the next start.
w.stop();
CHECK(!w.running());
append(log3, kAuto);
const std::string tmp3 = dir + "/xrservice.txt.3";
CHECK(::symlink(log3.c_str(), tmp3.c_str()) == 0);
CHECK(std::rename(tmp3.c_str(), link.c_str()) == 0);
CHECK(w.start());
CHECK(w.take_restarted());
// ...but not when it is the same session.
w.stop();
CHECK(w.start());
CHECK(!w.take_restarted());
w.stop();
std::remove(log3.c_str());
std::remove(link.c_str());
std::remove(log1.c_str());
std::remove(log2.c_str());
::rmdir(dir.c_str());
// Session start from the real path layout.
const long long t0 = XrServiceLog::parse_session_start(
"/home/steamos/.local/share/Steam/logs/XRService-2026.09.30/XRService-23-49-58.log");
CHECK(t0 > 0);
{
const std::time_t tt = static_cast<std::time_t>(t0);
const std::tm* lt = std::localtime(&tt);
CHECK(lt->tm_year == 126 && lt->tm_mon == 8 && lt->tm_mday == 30 && lt->tm_hour == 23 && lt->tm_min == 49 &&
lt->tm_sec == 58);
}
CHECK(XrServiceLog::parse_session_start("/tmp/whatever.log") == 0);
// Passthrough and standby lines in the same stream.
{
char tmpl2[] = "/tmp/autopass_xrlog_XXXXXX";
const std::string d2 = ::mkdtemp(tmpl2);
const std::string lg = d2 + "/XRService-1.log", lk = d2 + "/xrservice.txt";
append(lg, "x INFO: [DeckardCaptureSource] Passthrough cameras resumed\n");
CHECK(::symlink(lg.c_str(), lk.c_str()) == 0);
XrServiceLog x(d2);
CHECK(x.start());
CHECK(x.state().passthrough == std::optional<bool>(true));
append(lg, "x INFO: [UserPresence] Received onEnterStandby from SteamVR.\nx INFO: [DeckardCaptureSource] Passthrough cameras paused\n");
CHECK(x.update());
CHECK(x.state().passthrough == std::optional<bool>(false) && x.state().standby == std::optional<bool>(true));
x.stop();
std::remove(lk.c_str());
std::remove(lg.c_str());
::rmdir(d2.c_str());
}
// No log at all: start() fails cleanly.
XrServiceLog missing("/nonexistent/autopass/logs");
CHECK(!missing.start());
CHECK(!missing.error().empty());
if (failures) { std::printf("%d check(s) failed\n", failures); return 1; }
std::printf("all XRService log tests passed\n");
return 0;
}
+310
View File
@@ -0,0 +1,310 @@
// autopass: install, inspect and support autopassd.
//
// autopass install install the systemd user unit that starts autopassd
// with SteamVR and stops it with SteamVR; start it
// autopass uninstall stop and remove the unit, switch back to colour
// autopass status autopassd's status and whether the unit is active
// autopass update download and install the latest release
// autopass report write ~/frame-autopass-report.txt for a bug report
// autopass version print the version
// autopass guard [reset] show or clear the crash guard (XRService restarts
// soon after autopassd switches)
// autopass state [SECONDS] passive: camera config and colour light value
// from shared memory, no SteamVR calls
// autopass get read the camera config (private interface)
// autopass set rgb|mono [--quiet]
// switch the camera source (used by autopassd). Exit
// codes: 0 done or already so, 1 error, 3 SteamVR's
// interface changed, 4 camera not enabled, 5 no
// Arcturus colour module.
//
// `get` and `set` connect to SteamVR as a background client for a few
// milliseconds; that never starts SteamVR and does not wake the headset.
#include "app_paths.hpp"
#include "passthrough_state.hpp"
#include "private_camera.hpp"
#include <openvr.h>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <sys/wait.h>
#include <thread>
#include <unistd.h>
namespace {
constexpr const char* kUnit = "frame-autopass.service";
constexpr const char* kInstallScript = "https://github.com/bod09/frame-autopass/releases/latest/download/install.sh";
int usage() {
std::fprintf(stderr,
"usage: autopass install | uninstall | status | update | report | version |\n"
" guard [reset] | state [SECONDS] | get | set rgb|mono [--quiet]\n");
return 2;
}
std::string unit_path() {
const char* xdg = std::getenv("XDG_CONFIG_HOME");
const char* home = std::getenv("HOME");
const std::string base = xdg && *xdg == '/' ? xdg : std::string(home ? home : "/tmp") + "/.config";
return base + "/systemd/user/" + kUnit;
}
// Runs `systemctl --user ARGS...`, returns its exit code.
int systemctl(std::initializer_list<const char*> args, bool quiet = false) {
std::string cmd = "systemctl --user";
for (const char* a : args) cmd += std::string(" ") + a;
if (quiet) cmd += " >/dev/null 2>&1";
const int rc = std::system(cmd.c_str());
return WIFEXITED(rc) ? WEXITSTATUS(rc) : 1;
}
std::string unit_text() {
return std::string(
"# Installed by `autopass install`. Starts autopassd with SteamVR and stops it\n"
"# with SteamVR. Remove with `autopass uninstall`.\n"
"[Unit]\n"
"Description=Adaptive passthrough (colour in good light, IR in the dark)\n"
"After=steamvr.service\n"
"BindsTo=steamvr.service\n"
"\n"
"[Service]\n"
"Type=simple\n"
"ExecStart=") + autopass::install_dir() + "/autopassd\n"
"Restart=on-failure\n"
"RestartSec=5\n"
"Slice=session.slice\n"
"Nice=10\n"
"\n"
"[Install]\n"
"WantedBy=steamvr.service\n";
}
int install() {
const std::string dir = autopass::install_dir();
if (::access((dir + "/autopassd").c_str(), X_OK) != 0 || ::access((dir + "/autopass").c_str(), X_OK) != 0) {
std::fprintf(stderr, "autopassd and autopass must be in %s first\n", dir.c_str());
return 1;
}
if (!autopass::colour_module_present()) {
std::fprintf(stderr,
"The Arcturus Vision colour module was not found (no arcimx616 camera in\n"
"/sys/class/video4linux). frame-autopass switches between that module and the\n"
"built-in IR cameras, so it needs the module attached. Not installing.\n");
return 1;
}
const std::string path = unit_path();
if (!autopass::make_dirs(path.substr(0, path.rfind('/'))) || !autopass::write_file_atomic(path, unit_text())) {
std::fprintf(stderr, "cannot write %s\n", path.c_str());
return 1;
}
if (systemctl({"daemon-reload"}) != 0 || systemctl({"enable", "--now", kUnit}) != 0) {
std::fprintf(stderr, "systemctl failed; see `systemctl --user status %s`\n", kUnit);
return 1;
}
std::printf("installed %s: autopassd now starts and stops with SteamVR\n", path.c_str());
return 0;
}
int switch_source(bool want_rgb, bool quiet);
int uninstall() {
systemctl({"disable", "--now", kUnit}, true);
std::remove(unit_path().c_str());
systemctl({"daemon-reload"}, true);
std::printf("removed %s\n", kUnit);
// Leave the headset showing colour, the stock behaviour with the module.
switch_source(true, true);
return 0;
}
int status() {
std::string text;
std::printf("unit: %s, %s\n", systemctl({"is-enabled", "--quiet", kUnit}, true) == 0 ? "installed" : "not installed",
systemctl({"is-active", "--quiet", kUnit}, true) == 0 ? "running" : "not running");
std::printf("version: %s\n", autopass::version());
if (autopass::read_file(autopass::status_path(), &text)) {
std::printf("status: %s", text.c_str());
if (text.find("\"blocked\": \"steamvr-changed\"") != std::string::npos)
std::printf("\nSteamVR has changed the camera interface frame-autopass uses, so it has\n"
"stopped switching (passthrough itself works normally). Run `autopass update`;\n"
"if that does not help, a new release is needed.\n");
else if (text.find("\"blocked\": \"no-colour-module\"") != std::string::npos)
std::printf("\nThe Arcturus Vision colour module is not attached, so there is nothing to\n"
"switch to; it resumes when the module is back.\n");
else if (text.find("\"blocked\": \"crash-guard\"") != std::string::npos)
std::printf("\nXRService restarted twice soon after a switch, so switching is paused as a\n"
"precaution. `autopass guard reset`, then restart SteamVR, to resume.\n");
}
if (autopass::read_file(autopass::crash_guard_path(), &text) && !text.empty())
std::printf("crash guard entries (switching stops at 2):\n%s", text.c_str());
return 0;
}
int guard(bool reset) {
std::string text;
const bool present = autopass::read_file(autopass::crash_guard_path(), &text) && !text.empty();
if (reset) {
std::remove(autopass::crash_guard_path().c_str());
std::printf("crash guard cleared; restart autopassd (or SteamVR) to resume switching\n");
return 0;
}
std::printf(present ? "crash guard entries:\n%s" : "crash guard: no entries\n", text.c_str());
return 0;
}
int state(double seconds) {
const autopass::PassthroughState st;
if (st.path().empty()) {
std::fprintf(stderr, "passthrough state file not found in /dev/shm\n");
return 1;
}
const auto end = std::chrono::steady_clock::now() + std::chrono::duration<double>(seconds);
do {
const auto s = st.read();
if (!s) {
std::fprintf(stderr, "cannot read %s\n", st.path().c_str());
return 1;
}
std::printf("config: %s", s->config.describe().c_str());
if (s->colour) std::printf(" | colour light %.3f", s->colour->light);
std::printf(" | newest mono %.3f colour %.3f\n", s->mono_timestamp, s->colour_timestamp);
if (seconds > 0) std::this_thread::sleep_for(std::chrono::milliseconds(500));
} while (std::chrono::steady_clock::now() < end);
return 0;
}
int update() {
std::printf("fetching %s\n", kInstallScript);
const std::string cmd = std::string("curl -fsSL ") + kInstallScript + " | bash";
const int rc = std::system(cmd.c_str());
return WIFEXITED(rc) ? WEXITSTATUS(rc) : 1;
}
// Appends a command's output to the report.
void section(std::string* out, const char* title, const std::string& cmd) {
*out += std::string("\n== ") + title + " ==\n";
if (FILE* p = ::popen((cmd + " 2>&1").c_str(), "r")) {
char buf[4096];
std::size_t n;
while ((n = std::fread(buf, 1, sizeof buf, p)) > 0) out->append(buf, n);
::pclose(p);
}
}
int report() {
const char* home = std::getenv("HOME");
const std::string path = std::string(home ? home : "/tmp") + "/frame-autopass-report.txt";
std::string out = std::string("frame-autopass report, version ") + autopass::version() + "\n";
out += std::string("Arcturus colour module: ") + (autopass::colour_module_present() ? "found" : "NOT found") + "\n";
section(&out, "date", "date");
section(&out, "SteamOS", "grep -E '^(VERSION_ID|BUILD_ID)=' /etc/os-release");
section(&out, "SteamVR", "cat /opt/steamvr/bin/version.txt; grep -m1 -E 'cv: version [0-9]' ~/.local/share/Steam/logs/vrserver.txt");
section(&out, "unit", std::string("systemctl --user status --no-pager ") + kUnit + " | head -5");
section(&out, "status", "cat " + autopass::status_path());
section(&out, "config", "cat " + autopass::conf_path() + " 2>/dev/null || echo '(defaults)'");
section(&out, "crash guard", "cat " + autopass::crash_guard_path() + " 2>/dev/null || echo '(empty)'");
section(&out, "autopassd log (last 300 lines)", "tail -n 300 " + autopass::log_path());
section(&out, "XRService (last 150 relevant lines)",
"grep -hE 'IREmitters|Passthrough cameras|UserPresence|IMUFallback\\] (En|Dis)|Transition to IMU' "
"~/.local/share/Steam/logs/xrservice.txt | cut -c1-140 | tail -n 150");
if (!autopass::write_file_atomic(path, out)) {
std::fprintf(stderr, "cannot write %s\n", path.c_str());
return 1;
}
std::printf("wrote %s\nIt contains your SteamOS and SteamVR versions and recent logs (no personal\n"
"data that I know of; have a look before sharing it). Attach it to a GitHub issue.\n",
path.c_str());
return 0;
}
struct Session {
bool ok = false;
Session() {
vr::EVRInitError err = vr::VRInitError_None;
vr::VR_Init(&err, vr::VRApplication_Background);
ok = err == vr::VRInitError_None;
if (!ok) std::fprintf(stderr, "SteamVR unavailable: %s\n", vr::VR_GetVRInitErrorAsEnglishDescription(err));
}
~Session() {
if (ok) vr::VR_Shutdown();
}
};
int get_config() {
Session session;
if (!session.ok) return 1;
autopass::PrivateCamera camera;
if (!camera.ok()) {
std::fprintf(stderr, "%s\n", camera.error().c_str());
return 3;
}
const auto c = camera.get();
if (!c) {
std::fprintf(stderr, "%s\n", camera.error().c_str());
return 1;
}
std::printf("%s\n", c->describe().c_str());
return 0;
}
int switch_source(bool want_rgb, bool quiet) {
if (!autopass::colour_module_present()) {
if (!quiet) std::fprintf(stderr, "Arcturus colour module not found; not changing anything\n");
return 5;
}
Session session;
if (!session.ok) return 1;
autopass::PrivateCamera camera;
if (!camera.ok()) {
std::fprintf(stderr, "%s\n", camera.error().c_str());
return 3;
}
auto c = camera.get();
if (!c) {
std::fprintf(stderr, "%s\n", camera.error().c_str());
return 1;
}
if (!c->enabled) {
if (!quiet) std::fprintf(stderr, "passthrough camera is not enabled; not changing anything\n");
return 4;
}
if (c->rgb == static_cast<std::uint8_t>(want_rgb)) {
if (!quiet) std::printf("already %s\n", want_rgb ? "colour" : "IR");
return 0;
}
c->rgb = want_rgb ? 1 : 0;
if (!camera.set(*c)) {
std::fprintf(stderr, "%s\n", camera.error().c_str());
return 1;
}
if (!quiet) std::printf("switched to %s\n", want_rgb ? "colour" : "IR");
return 0;
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) return usage();
const std::string cmd = argv[1];
if (cmd == "install" && argc == 2) return install();
if (cmd == "uninstall" && argc == 2) return uninstall();
if (cmd == "status" && argc == 2) return status();
if (cmd == "update" && argc == 2) return update();
if (cmd == "report" && argc == 2) return report();
if ((cmd == "version" || cmd == "--version") && argc == 2) return std::printf("%s\n", autopass::version()) < 0;
if (cmd == "guard" && argc == 2) return guard(false);
if (cmd == "guard" && argc == 3 && std::string(argv[2]) == "reset") return guard(true);
if (cmd == "state" && argc <= 3) return state(argc == 3 ? std::atof(argv[2]) : 0);
if (cmd == "get" && argc == 2) return get_config();
if (cmd == "set" && (argc == 3 || (argc == 4 && std::string(argv[3]) == "--quiet"))) {
const std::string which = argv[2];
if (which != "rgb" && which != "mono") return usage();
return switch_source(which == "rgb", argc == 4);
}
return usage();
}
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Annotated arm64 disassembly of functions in a stripped shared object.
Used to learn the signatures of SteamVR's private interfaces from the
headset's own binaries. Resolves adrp+add/ldr pairs to strings or data,
PLT stubs to imported symbol names, and RELATIVE relocations in data.
Usage: disasm.py LIB ADDR [ADDR ...] [--max N] [--until ADDR]
disasm.py --xref LIB TARGET [TARGET ...]
Addresses are hex. Disassembles linearly from ADDR until a `ret` that is
not skipped over by an earlier forward branch, or N instructions.
"""
import re
import struct
import subprocess
import sys
import capstone
from capstone import arm64_const as A
class Elf:
def __init__(self, path):
self.path = path
self.data = open(path, "rb").read()
self.sections = []
out = subprocess.run(["readelf", "-SW", path], capture_output=True, text=True).stdout
for m in re.finditer(r"\]\s+(\S+)\s+\S+\s+([0-9a-f]+)\s+([0-9a-f]+)\s+([0-9a-f]+)", out):
name, addr, off, size = m.group(1), int(m.group(2), 16), int(m.group(3), 16), int(m.group(4), 16)
self.sections.append((name, addr, off, size))
self.relative = {}
self.symbolic = {}
out = subprocess.run(["readelf", "-rW", path], capture_output=True, text=True).stdout
for line in out.splitlines():
m = re.match(r"\s*([0-9a-f]+)\s+[0-9a-f]+\s+(R_AARCH64_\w+)\s+(\S+)?\s*(.*)", line)
if not m:
continue
where, kind = int(m.group(1), 16), m.group(2)
if kind == "R_AARCH64_RELATIVE":
self.relative[where] = int(m.group(3), 16)
elif kind in ("R_AARCH64_JUMP_SLOT", "R_AARCH64_GLOB_DAT", "R_AARCH64_ABS64"):
rest = (m.group(4) or "").strip()
sym = rest.split("+")[0].strip() if rest else ""
self.symbolic[where] = sym.split("@")[0]
self.plt = self._map_plt()
def section_of(self, addr):
for name, a, off, size in self.sections:
if a and a <= addr < a + size:
return name, a, off
return None
def read(self, addr, n):
s = self.section_of(addr)
if not s or s[0] == ".bss":
return None
return self.data[addr - s[1] + s[2]: addr - s[1] + s[2] + n]
def cstring(self, addr, limit=160):
raw = self.read(addr, limit)
if not raw:
return None
end = raw.find(b"\0")
if end <= 0:
return None
text = raw[:end]
if all(32 <= c < 127 or c in (9, 10) for c in text):
return text.decode()
return None
def u64(self, addr):
raw = self.read(addr, 8)
return struct.unpack("<Q", raw)[0] if raw and len(raw) == 8 else None
def _map_plt(self):
plt = {}
s = [x for x in self.sections if x[0] == ".plt"]
if not s:
return plt
_, addr, off, size = s[0]
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = True
page = None
for ins in md.disasm(self.data[off:off + size], addr):
if ins.id == A.ARM64_INS_ADRP:
page = ins.operands[1].imm
elif ins.id == A.ARM64_INS_LDR and page is not None and len(ins.operands) > 1:
got = page + ins.operands[1].mem.disp
sym = self.symbolic.get(got)
if sym:
plt[ins.address - 4] = sym
page = None
return plt
def describe(self, addr):
"""Best-effort label for an address used as data."""
if addr in self.plt:
return "plt:" + self.plt[addr]
s = self.cstring(addr)
if s:
return repr(s)
if addr in self.relative:
target = self.relative[addr]
s = self.cstring(target)
return f"-> {target:#x}" + (f" {s!r}" if s else "")
if addr in self.symbolic:
return "got:" + self.symbolic[addr]
sec = self.section_of(addr)
return sec[0] if sec else None
def disassemble(elf, start, max_ins, until=None):
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = True
code = elf.read(start, max_ins * 4)
regs_page = {}
furthest = start
for ins in md.disasm(code, start):
note = ""
ops = ins.operands
if ins.id == A.ARM64_INS_ADRP:
regs_page[ops[0].reg] = ops[1].imm
elif ins.id == A.ARM64_INS_ADD and len(ops) == 3 and ops[1].reg in regs_page and ops[2].type == A.ARM64_OP_IMM:
target = regs_page.pop(ops[1].reg) + ops[2].imm
note = f"{target:#x} {elf.describe(target) or ''}"
elif ins.id in (A.ARM64_INS_LDR, A.ARM64_INS_STR) and len(ops) > 1 and ops[1].type == A.ARM64_OP_MEM \
and ops[1].mem.base in regs_page:
target = regs_page[ops[1].mem.base] + ops[1].mem.disp
note = f"[{target:#x}] {elf.describe(target) or ''}"
elif ins.id in (A.ARM64_INS_BL, A.ARM64_INS_B) and ops and ops[0].type == A.ARM64_OP_IMM:
target = ops[0].imm
label = elf.plt.get(target)
note = f"-> {label}" if label else ""
if ins.id == A.ARM64_INS_B and target > furthest:
furthest = target
elif ins.group(capstone.CS_GRP_JUMP) and ops and ops[-1].type == A.ARM64_OP_IMM:
if ops[-1].imm > furthest:
furthest = ops[-1].imm
print(f" {ins.address:#x}: {ins.mnemonic:8} {ins.op_str:40} {note}".rstrip())
if until is not None:
if ins.address + 4 >= until:
break
continue
if ins.id == A.ARM64_INS_RET and ins.address >= furthest:
break
if ins.id == A.ARM64_INS_B and ops and ops[0].type == A.ARM64_OP_IMM and ins.address >= furthest \
and ops[0].imm < ins.address:
break
def xrefs(elf, target):
"""Addresses in .text whose adrp+add (or adrp+ldr) pair forms `target`."""
name, addr, off, size = [s for s in elf.sections if s[0] == ".text"][0]
words = struct.unpack_from(f"<{size // 4}I", elf.data, off)
hits = []
page_of = {}
for i, w in enumerate(words):
pc = addr + i * 4
if (w & 0x9F000000) == 0x90000000: # adrp
rd = w & 0x1F
immlo = (w >> 29) & 3
immhi = (w >> 5) & 0x7FFFF
imm = (immhi << 2) | immlo
if imm & (1 << 20):
imm -= 1 << 21
page_of[rd] = ((pc & ~0xFFF) + (imm << 12), pc)
elif (w & 0xFF800000) == 0x91000000: # add (immediate, 64-bit, no shift)
rn = (w >> 5) & 0x1F
if rn in page_of and pc - page_of[rn][1] < 64:
if page_of[rn][0] + ((w >> 10) & 0xFFF) == target:
hits.append(pc)
return hits
def main():
args = sys.argv[1:]
if args and args[0] == "--xref":
elf = Elf(args[1])
for a in args[2:]:
print(a, [hex(h) for h in xrefs(elf, int(a, 16))])
return
max_ins = 400
until = None
if "--until" in args:
i = args.index("--until")
until = int(args[i + 1], 16)
del args[i:i + 2]
if "--max" in args:
i = args.index("--max")
max_ins = int(args[i + 1])
del args[i:i + 2]
elf = Elf(args[0])
for a in args[1:]:
start = int(a, 16)
print(f"== {start:#x}")
disassemble(elf, start, max_ins if until is None else (until - start) // 4 + 1, until)
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Offline analysis of a shm_capture.py recording (runs on the PC).
Ground truth is the colour light value (g4) read from the
VR_CameraPassthroughState file, available only while RGB is selected.
Every 32-bit word of every other captured buffer is correlated with it
during the RGB phase; the best candidates are then printed across the
whole run so we can see whether they still follow the lights while mono
is selected.
Usage: shm_analyse.py CAPTURE [--top N] [--min-r R]
"""
import argparse
import struct
import zlib
from collections import defaultdict
import numpy as np
STATE_SIZE = 0x6200
GAMMA = bytes.fromhex("2fbae83e")
def load(path):
series = defaultdict(list)
with open(path, "rb") as f:
data = f.read()
pos = 0
while pos < len(data):
(n,) = struct.unpack_from("<I", data, pos)
pos += 4
name = data[pos:pos + n].decode()
pos += n
t, raw_len, z_len = struct.unpack_from("<dII", data, pos)
pos += 16
buf = zlib.decompress(data[pos:pos + z_len])
pos += z_len
if len(buf) == raw_len:
series[name].append((t, buf))
return series
def colour_light(buf):
best = None
for pos in range(0x20E8 + 0xD8, STATE_SIZE - 0x18, 4):
if buf[pos:pos + 4] == GAMMA:
ts = struct.unpack_from("<d", buf, pos - 0xD8 + 0x10)[0]
light = struct.unpack_from("<f", buf, pos + 0x14)[0]
if best is None or ts > best[0]:
best = (ts, light)
return best
def main():
ap = argparse.ArgumentParser()
ap.add_argument("capture")
ap.add_argument("--top", type=int, default=25)
ap.add_argument("--min-r", type=float, default=0.8)
args = ap.parse_args()
series = load(args.capture)
state_name = next(n for n, s in series.items() if len(s[0][1]) == STATE_SIZE)
state = series.pop(state_name)
# Ground truth: g4 while RGB is selected and the colour record is fresh.
truth_t, truth_v, mono_t = [], [], []
last_ts = None
for t, buf in state:
rgb = buf[4]
if not rgb:
mono_t.append(t)
continue
cl = colour_light(buf)
if cl and cl[0] != last_ts:
truth_t.append(t)
truth_v.append(cl[1])
last_ts = cl[0]
truth_t = np.array(truth_t)
truth_v = np.array(truth_v)
print(f"state file {state_name}: {len(state)} snapshots, {len(truth_t)} fresh RGB light samples, "
f"mono from {min(mono_t, default=float('nan')):.1f} to {max(mono_t, default=float('nan')):.1f} s")
print("light over time (RGB phase):", " ".join(f"{t:.0f}s={v:.2f}" for t, v in zip(truth_t[::8], truth_v[::8])))
candidates = []
for name, snaps in series.items():
times = np.array([t for t, _ in snaps])
rgb_idx = [i for i, t in enumerate(times) if truth_t.size and truth_t[0] <= t <= truth_t[-1]
and not any(abs(t - m) < 0.5 for m in mono_t[:1] + mono_t[-1:])
and not (mono_t and mono_t[0] - 1 <= t <= mono_t[-1] + 1)]
if len(rgb_idx) < 6:
continue
size = len(snaps[0][1]) // 4 * 4
mat = np.frombuffer(b"".join(snaps[i][1][:size] for i in rgb_idx), dtype="<f4").reshape(len(rgb_idx), -1)
ref = np.interp(times[rgb_idx], truth_t, truth_v)
with np.errstate(all="ignore"):
finite = np.isfinite(mat).all(axis=0)
m = np.where(finite, mat, 0).astype(np.float64)
m -= m.mean(axis=0)
r0 = ref - ref.mean()
denom = np.sqrt((m ** 2).sum(axis=0) * (r0 ** 2).sum())
r = np.where((denom > 0) & finite, (m * r0[:, None]).sum(axis=0) / denom, 0)
for w in np.argsort(-np.abs(r))[:args.top]:
if abs(r[w]) >= args.min_r:
candidates.append((abs(r[w]), r[w], name, int(w) * 4))
candidates.sort(reverse=True)
print(f"\n{len(candidates)} words with |r| >= {args.min_r} against the RGB light value")
for absr, r, name, off in candidates[:args.top]:
snaps = series[name]
vals = [struct.unpack_from("<f", b, off)[0] for _, b in snaps]
rgb_vals = [v for (t, _), v in zip(snaps, vals) if not (mono_t and mono_t[0] <= t <= mono_t[-1])]
mono_vals = [v for (t, _), v in zip(snaps, vals) if mono_t and mono_t[0] <= t <= mono_t[-1]]
print(f"r={r:+.3f} {name.split('/')[-1]}+{off:#x} rgb range {min(rgb_vals):.4g}..{max(rgb_vals):.4g}"
f" | mono range {min(mono_vals, default=float('nan')):.4g}..{max(mono_vals, default=float('nan')):.4g}")
return candidates
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Passively capture every shared-memory file a process maps.
Reads tmpfs files only: no SteamVR calls, no locks, no devices. Used to
find which of XRService's shared buffers carry colour-camera statistics
while mono passthrough is displayed.
Each snapshot is appended to the output as a record:
u32 name_len, name, f64 t, u32 raw_len, u32 z_len, zlib(data)
Small files are read every --fast seconds, large ones every --slow.
Usage: shm_capture.py [--pid PID | --process NAME] [--seconds N]
[--fast S] [--slow S] [--large BYTES] [--also PATH] --out PATH
"""
import argparse
import os
import re
import struct
import subprocess
import time
import zlib
def find_pid(pattern):
out = subprocess.run(["pgrep", "-f", pattern], capture_output=True, text=True).stdout.split()
pids = [int(p) for p in out if int(p) != os.getpid()]
if not pids:
raise SystemExit(f"no process matches {pattern!r}")
return min(pids)
def mapped_shm(pid):
paths = set()
with open(f"/proc/{pid}/maps") as f:
for line in f:
m = re.search(r"(/dev/shm/\S+)", line)
if m:
paths.add(m.group(1))
for fd in os.listdir(f"/proc/{pid}/fd"):
try:
target = os.readlink(f"/proc/{pid}/fd/{fd}")
except OSError:
continue
if target.startswith("/dev/shm/"):
paths.add(target)
return sorted(p for p in paths if os.path.isfile(p))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--pid", type=int)
ap.add_argument("--process", default="XRService --documentsRoot")
ap.add_argument("--seconds", type=float, default=90)
ap.add_argument("--fast", type=float, default=0.25)
ap.add_argument("--slow", type=float, default=2.0)
ap.add_argument("--large", type=int, default=1 << 20)
ap.add_argument("--also", action="append", default=[],
help="extra file to capture on the fast schedule (repeatable)")
ap.add_argument("--out", required=True)
args = ap.parse_args()
pid = args.pid or find_pid(args.process)
paths = sorted(set(mapped_shm(pid)) | set(args.also))
sizes = {p: os.path.getsize(p) for p in paths}
print(f"pid {pid}: {len(paths)} shm files, {sum(sizes.values()) / 1e6:.1f} MB total")
t0 = time.monotonic()
next_slow = 0.0
with open(args.out, "wb") as out:
while True:
now = time.monotonic() - t0
if now >= args.seconds:
break
do_slow = now >= next_slow
if do_slow:
next_slow = now + args.slow
for p in paths:
if sizes[p] > args.large and not do_slow:
continue
try:
with open(p, "rb") as f:
data = f.read()
except OSError:
continue
z = zlib.compress(data, 1)
name = p.encode()
out.write(struct.pack("<I", len(name)) + name + struct.pack("<dII", now, len(data), len(z)) + z)
spent = time.monotonic() - t0 - now
time.sleep(max(0.0, args.fast - spent))
print(f"wrote {os.path.getsize(args.out) / 1e6:.1f} MB")
if __name__ == "__main__":
main()
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Record every new per-frame record of both passthrough streams.
Passive: only reads SteamVR's VR_CameraPassthroughState tmpfs file (the
25088-byte /dev/shm segment); no SteamVR calls, no devices. For each
stream and camera, whenever the newest record's timestamp changes, one
line is written: wall time, stream (mono/colour), camera, record index,
timestamp and the record's raw bytes as hex. Used to look for per-frame
fields that follow the IR emitters (FINDINGS.md 38).
Usage: shm_records.py --seconds N [--hz N] --out PATH
"""
import argparse
import glob
import os
import struct
import sys
import time
STATE_SIZE = 0x6200
STREAMS = {"mono": 0x14, "colour": 0x20E8}
CAMS, RECS, REC_SIZE, CAM_SIZE = 2, 16, 0x104, 0x1040
def find_state_file():
m = [p for p in glob.glob("/dev/shm/u1000-Shm_*") if os.path.getsize(p) == STATE_SIZE]
if len(m) != 1:
sys.exit(f"expected one {STATE_SIZE}-byte shm file, found {m}")
return m[0]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--seconds", type=float, required=True)
ap.add_argument("--hz", type=float, default=50)
ap.add_argument("--out", required=True)
a = ap.parse_args()
path = find_state_file()
last = {}
end = time.time() + a.seconds
with open(path, "rb") as f, open(a.out, "w") as out:
while time.time() < end:
f.seek(0)
buf = f.read(STATE_SIZE)
now = time.time()
out.write(f"{now:.3f} cfg {buf[2:7].hex()}\n") if last.get("cfg") != buf[2:7] else None
last["cfg"] = buf[2:7]
for name, base in STREAMS.items():
for cam in range(CAMS):
best = None
for rec in range(RECS):
off = base + 0x38 + cam * CAM_SIZE + rec * REC_SIZE
ts = struct.unpack_from("<d", buf, off + 0x10)[0]
if ts == ts and (best is None or ts > best[0]):
best = (ts, rec, off)
if best and last.get((name, cam)) != best[0]:
last[(name, cam)] = best[0]
ts, rec, off = best
out.write(f"{now:.3f} {name} {cam} {rec} {ts:.6f} {buf[off:off + REC_SIZE].hex()}\n")
time.sleep(1 / a.hz)
if __name__ == "__main__":
main()
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Passively sample SteamVR's VR_CameraPassthroughState shared memory.
Only reads a tmpfs file. It makes no SteamVR calls, takes no locks and
opens no devices, so it cannot disturb passthrough or tracking. Readings
may occasionally tear (the writer holds a mutex we deliberately ignore);
that is acceptable for exploration.
Per-frame records are found by their gamma marker (1/2.2 as float32).
For each record whose frame counter changed, one CSV row is written with
the raw fields around the marker, so we can see which ones track light.
With --raw PATH, every snapshot is also appended to PATH as
[float64 time][STATE_SIZE bytes] so it can be re-parsed offline.
Usage: shm_sampler.py [--seconds N] [--hz N] [--out PATH] [--raw PATH] [--shm PATH]
"""
import argparse
import glob
import os
import struct
import sys
import time
GAMMA = struct.pack("<f", 1 / 2.2)
STATE_SIZE = 0x6200
RECORD_BEFORE_GAMMA = 0xD8 # record start is this far before the marker
FIELDS_AFTER_GAMMA = 12 # float32 fields logged after the marker
def find_state_file():
matches = [p for p in glob.glob("/dev/shm/u1000-Shm_*") if os.path.getsize(p) == STATE_SIZE]
if len(matches) != 1:
sys.exit(f"expected exactly one {STATE_SIZE}-byte shm file, found {matches}")
return matches[0]
def records(buf):
pos = buf.find(GAMMA)
while pos != -1:
start = pos - RECORD_BEFORE_GAMMA
if start >= 0:
yield start, pos
pos = buf.find(GAMMA, pos + 4)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--seconds", type=float, default=120)
ap.add_argument("--hz", type=float, default=20)
ap.add_argument("--out", default="shm_samples.csv")
ap.add_argument("--raw")
ap.add_argument("--shm")
args = ap.parse_args()
path = args.shm or find_state_file()
header = ["t", "offset", "stream", "id", "frame", "ts0", "ts1", "fx", "fy", "cx", "cy"]
header += [f"g{i}" for i in range(FIELDS_AFTER_GAMMA)] + ["owner", "seq", "config"]
last_frame = {}
t0 = time.monotonic()
raw = open(args.raw, "wb") if args.raw else None
with open(args.out, "w") as out, open(path, "rb") as f:
out.write(",".join(header) + "\n")
while time.monotonic() - t0 < args.seconds:
f.seek(0)
buf = f.read(STATE_SIZE)
now = time.monotonic() - t0
if raw:
raw.write(struct.pack("<d", now) + buf)
config = buf[2:7].hex()
for start, g in records(buf):
ident, frame = struct.unpack_from("<II", buf, start)
owner, seq = struct.unpack_from("<II", buf, g + 4 + FIELDS_AFTER_GAMMA * 4)
key = start
if last_frame.get(key) == (frame, seq):
continue
last_frame[key] = (frame, seq)
ts0, ts1 = struct.unpack_from("<dd", buf, start + 0x10)
fx, fy, cx, cy = struct.unpack_from("<4f", buf, start + 0x20)
fields = struct.unpack_from(f"<{FIELDS_AFTER_GAMMA}f", buf, g + 4)
stream = 1 if start >= 0x20E8 else 0
row = [f"{now:.3f}", hex(start), str(stream), str(ident), str(frame), f"{ts0:.4f}", f"{ts1:.4f}",
f"{fx:.1f}", f"{fy:.1f}", f"{cx:.1f}", f"{cy:.1f}"]
row += [f"{v:.6g}" for v in fields] + [str(owner), str(seq), config]
out.write(",".join(row) + "\n")
time.sleep(1 / args.hz)
if raw:
raw.close()
if __name__ == "__main__":
main()