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

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;
}