Compare commits

...
1 Commits
Author SHA1 Message Date
0x1f6 66160aed55 Gaze: detect the eye-server.mmap layout at runtime (SteamOS beta, +5 shift)
SteamOS 0.4.x beta (SteamVR 2.18.2) moved every field of
/dev/shm/eye-server.mmap from the timestamp on by 5 bytes; the counter at
0x38 kept its place. With the old constants ft-gaze read neighboring fields
as floats, and ft-gazed discarded every sample as broken JSON. Measured on
2026-10-04 (SteamOS 0.4.3 beta, SteamVR 2.18.2, ftdiag scan with an active
gaze session):

  counter (u32)  0x38  -> 0x38   (unmoved)
  time (f64)     0x157 -> 0x15c
  left1/right1   0x15f/0x16b -> 0x164/0x170
  fix1           0x18f -> 0x194
  left2/right2   0x19b/0x1a7 -> 0x1a0/0x1ac
  var1/var2      0x177/0x1b3 -> 0x17c/0x1b8
  open           0x1cb -> 0x1d0
  meas           0x1d3 -> 0x1d8

While eye data flowed, ft-gazed logged ~100% bad samples (whole beta boots:
336k of 337k lines on Oct 3); gaze mode silently fell back to head-only
steering. With this change: 0 bad samples at a steady 15 Hz, and the full
calibration completes (21 of 21 dots) where it previously took none.

Instead of hardcoding either layout (which would break the other generation),
the layout is detected at runtime (EyeFile::Detect): a candidate (shift 0 or
5) is accepted when, at base+shift, the f64 timestamp is within +/-2 s of
CLOCK_MONOTONIC_RAW and advances across ~60 ms, and the set-1 eye directions
are unit vectors (|v| in 0.9..1.1). Both checks together also detect a
co-moved block reliably; a shift of only some unstructured fields (var/open)
would not be locally detectable.

While no candidate fits, ft-gaze emits no samples at all (an unknown layout
still passes the counter/timestamp consistency check, but yields garbage)
and logs "eye-server.mmap has eye data, but its layout is not recognized -
eye tracking unavailable", rate-limited to one line per 60 s. Detection
retries only while the eye server actually writes (the unshifted counter
ticks per sample), so a silent server (headset off, gaze idle) causes
neither retries nor journal noise. It re-detects on its own after the
headset goes back on or the eye server restarts, without ft-gaze
restarting. kNeed grows to hold either layout.

Verified on the beta: layout reported as "beta (+5)" within a second of the
eye server writing, self-healing after idle periods, 0 bad samples with eye
data flowing, full calibration 21/21.
2026-10-04 20:13:48 +02:00
+95 -11
View File
@@ -98,6 +98,10 @@ double NowRaw() {
} }
// --- eye-server.mmap (packed, unaligned: read with memcpy) --- // --- eye-server.mmap (packed, unaligned: read with memcpy) ---
// The SteamOS 0.4.x beta (SteamVR 2.18.2) moved every field from the timestamp on by 5
// bytes (measured 2026-10-04 with the ftdiag scan: timestamp 0x157 -> 0x15c, the vectors
// moved with it; the counter at 0x38 kept its place). Which layout is live is detected at
// runtime (EyeFile::Detect), so one binary serves both generations.
constexpr size_t kCounter = 0x38; // u32, one per sample constexpr size_t kCounter = 0x38; // u32, one per sample
constexpr size_t kTime = 0x157; // f64, CLOCK_MONOTONIC_RAW seconds constexpr size_t kTime = 0x157; // f64, CLOCK_MONOTONIC_RAW seconds
constexpr size_t kLeft1 = 0x15f, kRight1 = 0x16b; // set 1: unit vectors, head space constexpr size_t kLeft1 = 0x15f, kRight1 = 0x16b; // set 1: unit vectors, head space
@@ -110,11 +114,18 @@ constexpr size_t kVar1 = 0x177, kVar2 = 0x1b3;
// The measurements the filter is fed: left x, y, right x, y, then the variance of each (left // The measurements the filter is fed: left x, y, right x, y, then the variance of each (left
// x, y, right x, y). An eye's pair stops changing while the tracker can't see it. // x, y, right x, y). An eye's pair stops changing while the tracker can't see it.
constexpr size_t kMeas = 0x1d3; constexpr size_t kMeas = 0x1d3;
constexpr size_t kNeed = 0x1f3; constexpr size_t kNeed = 0x1f3 + 5; // enough for either layout
struct EyeFile { struct EyeFile {
// Everything from the timestamp on is read at base + shift: 0 on Stable, 5 on the
// 0.4.x beta (see the constants above). known only once a ticking timestamp was found
// for the current shift; before that, reading would yield garbage that still passes
// the seqlock check below.
size_t shift = 0;
bool known = false;
const uint8_t *p = nullptr; const uint8_t *p = nullptr;
size_t size = 0; size_t size = 0;
size_t At(size_t base) const { return base + shift; }
bool Open() { bool Open() {
const int fd = open("/dev/shm/eye-server.mmap", O_RDONLY | O_CLOEXEC); const int fd = open("/dev/shm/eye-server.mmap", O_RDONLY | O_CLOEXEC);
if (fd < 0) return false; if (fd < 0) return false;
@@ -140,6 +151,41 @@ struct EyeFile {
std::memcpy(f, p + off, sizeof f); std::memcpy(f, p + off, sizeof f);
return {f[0], f[1], f[2]}; return {f[0], f[1], f[2]};
} }
// A timestamp that is neither near the monotonic-raw clock nor advancing is not the
// live timestamp field.
bool TimePlausible(double t, double now) const {
return t > now - 2.0 && t <= now + 2.0;
}
// The eye directions are unit vectors, so their length is a second, independent check
// next to the timestamp: a mere coincidence in one field does not confirm a layout.
static bool UnitVec(Vec3 v) {
const float n = v.x * v.x + v.y * v.y + v.z * v.z;
return n > 0.81f && n < 1.21f; // |v| within 0.9 .. 1.1
}
// Try both layouts and keep the one whose timestamp actually ticks and whose eye
// directions are plausible. Safe to retry while the eye server is silent (headset off,
// no samples for a while): it only succeeds once data flows. Cheap: two reads ~60 ms
// apart per candidate. If neither candidate fits, known stays false and callers must
// treat eye tracking as unavailable (never emit samples from an unknown layout).
bool Detect(double now) {
for (const size_t cand : {size_t(0), size_t(5)}) {
if (size < kTime + cand + 8 || size < kFix1 + cand + 12) continue;
const double t0 = Get<double>(kTime + cand);
if (!TimePlausible(t0, now)) continue;
if (!UnitVec(V(kLeft1 + cand)) || !UnitVec(V(kRight1 + cand))) continue;
usleep(60000);
const double t1 = Get<double>(kTime + cand);
const double now2 = NowRaw();
if (t1 > t0 && TimePlausible(t1, now2)) {
shift = cand;
known = true;
return true;
}
}
shift = 0;
known = false;
return false;
}
}; };
struct EyeSample { struct EyeSample {
@@ -151,20 +197,22 @@ struct EyeSample {
}; };
// A consistent copy: the writer has no seqlock we can use, so read until the counter and // A consistent copy: the writer has no seqlock we can use, so read until the counter and
// timestamp are the same before and after. // timestamp are the same before and after. Only call this once the layout is known
// (EyeFile::known): on an unknown layout these reads still pass the consistency check,
// but yield garbage.
bool ReadSample(const EyeFile &f, EyeSample &s) { bool ReadSample(const EyeFile &f, EyeSample &s) {
for (int attempt = 0; attempt < 4; ++attempt) { for (int attempt = 0; attempt < 4; ++attempt) {
const uint32_t n0 = f.Get<uint32_t>(kCounter); const uint32_t n0 = f.Get<uint32_t>(kCounter);
const double t0 = f.Get<double>(kTime); const double t0 = f.Get<double>(f.At(kTime));
std::atomic_thread_fence(std::memory_order_acquire); std::atomic_thread_fence(std::memory_order_acquire);
s.left1 = f.V(kLeft1), s.right1 = f.V(kRight1), s.fix1 = f.V(kFix1); s.left1 = f.V(f.At(kLeft1)), s.right1 = f.V(f.At(kRight1)), s.fix1 = f.V(f.At(kFix1));
s.left2 = f.V(kLeft2), s.right2 = f.V(kRight2); s.left2 = f.V(f.At(kLeft2)), s.right2 = f.V(f.At(kRight2));
std::memcpy(s.open, f.p + kOpen, sizeof s.open); std::memcpy(s.open, f.p + f.At(kOpen), sizeof s.open);
std::memcpy(s.var1, f.p + kVar1, sizeof s.var1); std::memcpy(s.var1, f.p + f.At(kVar1), sizeof s.var1);
std::memcpy(s.var2, f.p + kVar2, sizeof s.var2); std::memcpy(s.var2, f.p + f.At(kVar2), sizeof s.var2);
std::memcpy(s.meas, f.p + kMeas, sizeof s.meas); std::memcpy(s.meas, f.p + f.At(kMeas), sizeof s.meas);
std::atomic_thread_fence(std::memory_order_acquire); std::atomic_thread_fence(std::memory_order_acquire);
if (f.Get<uint32_t>(kCounter) == n0 && f.Get<double>(kTime) == t0) { if (f.Get<uint32_t>(kCounter) == n0 && f.Get<double>(f.At(kTime)) == t0) {
s.n = n0, s.t = t0; s.n = n0, s.t = t0;
return true; return true;
} }
@@ -527,6 +575,13 @@ int main(int argc, char **argv) {
EyeFile eyes; EyeFile eyes;
const bool haveMmap = eyes.Open(); const bool haveMmap = eyes.Open();
std::fprintf(stderr, "ft-gaze: eye-server.mmap %s\n", haveMmap ? "open" : "not available"); std::fprintf(stderr, "ft-gaze: eye-server.mmap %s\n", haveMmap ? "open" : "not available");
// The mmap layout (the shift from the timestamp field on, see above) is detected at
// startup, and retried whenever fresh eye data arrives without a known layout.
// Until then, eye tracking counts as unavailable.
if (haveMmap) eyes.Detect(NowRaw());
if (haveMmap && eyes.known)
std::fprintf(stderr, "ft-gaze: eye-server.mmap layout: %s\n",
eyes.shift ? "beta (+5)" : "stable");
OwnFile ownFile; OwnFile ownFile;
Screens screens; Screens screens;
@@ -544,6 +599,12 @@ int main(int argc, char **argv) {
// over the dashboard. Games are told apart the way ft-screens does it, by the scene app. // over the dashboard. Games are told apart the way ft-screens does it, by the scene app.
bool inGame = false; bool inGame = false;
double nextGameCheck = 0; double nextGameCheck = 0;
// Layout detection runs only while the eye server is actually writing: the counter
// (0x38, unshifted in both layouts) ticks once per sample, so a silent server (headset
// off, gaze idle) causes neither retries nor journal noise. The "not recognized" line
// is rate-limited so an unrecognized but writing server doesn't flood the journal.
double nextLayoutCheck = 0, nextLayoutLog = 0, lastNewSample = 0;
uint32_t lastCounterSeen = 0;
while (true) { while (true) {
const double now = NowRaw(); const double now = NowRaw();
@@ -562,7 +623,30 @@ int main(int argc, char **argv) {
// One line per new eye sample, or at 90 Hz without the mmap. // One line per new eye sample, or at 90 Hz without the mmap.
EyeSample s; EyeSample s;
bool fresh = false; bool fresh = false;
if (haveMmap && ReadSample(eyes, s) && s.n != lastN) fresh = true, lastN = s.n; // Retry the layout when the eye server writes but we have no known layout, or
// when samples that used to flow have stopped: the layout may change under us
// (a SteamVR restart replaces the mmap), and detection only needs the writer
// alive, never our samples.
const bool writing = eyes.Get<uint32_t>(kCounter) != lastCounterSeen;
if (writing) lastCounterSeen = eyes.Get<uint32_t>(kCounter);
if (haveMmap && writing && (!eyes.known || now - lastNewSample > 2.0) &&
now >= nextLayoutCheck) {
nextLayoutCheck = now + 1.0;
const bool was = eyes.known;
if (eyes.Detect(now)) {
lastN = 0; // let the next sample count as new even if n repeated
if (!was)
std::fprintf(stderr, "ft-gaze: eye-server.mmap layout: %s\n",
eyes.shift ? "beta (+5)" : "stable");
} else if (now >= nextLayoutLog) {
nextLayoutLog = now + 60.0;
std::fprintf(stderr,
"ft-gaze: eye-server.mmap has eye data, but its layout is not recognized — eye tracking unavailable\n");
}
}
if (haveMmap && eyes.known && ReadSample(eyes, s) && s.n != lastN) {
fresh = true, lastN = s.n, lastNewSample = now;
}
if (!haveMmap && now - lastEmit >= 1.0 / 90) fresh = true, s.t = now; if (!haveMmap && now - lastEmit >= 1.0 / 90) fresh = true, s.t = now;
if (fresh && hp.bPoseIsValid) { if (fresh && hp.bPoseIsValid) {