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

+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()