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
7 changed files with 95 additions and 501 deletions

No files matched your search

-59
View File
@@ -1,59 +0,0 @@
# Install with FrameDrop (proof of concept)
[FrameDrop](https://framedropvr.com) is a Windows app that sideloads onto a Steam Frame: it copies a build to the headset and adds it to the Steam library. Issue #25 asks for an "Install with FrameDrop" button. Frametop isn't an app FrameDrop can copy over as is: it builds in a container, installs user services and a SteamVR driver, and asks questions in a terminal. So FrameDrop installs a small installer instead. Playing "Frametop" from the library runs the one-line installer (`get.sh --yes`) and shows its progress.
Nothing here is published yet: no release asset, no manifest on Pages, no button.
## How FrameDrop installs a Linux zip
It uses Valve's SteamOS Devkit path: pair once with the headset's devkit service, then rsync the unpacked zip into `~/devkit-game/<name>` over SSH, and register it with Steam as a Devkit Game with a start command. `devkit.sh` here makes the same calls with Valve's devkit-utils, so all of this can be tested on the Frame without a PC.
## What the probe found (SteamOS 0.3.0, build 20260922.6101926)
`probe/probe.sh`, started as a Devkit Game, recorded:
- Devkit titles run on the host, not in a container, as user `steamos`, from a process tree that Steam's reaper owns. This held even with the compat tool set to `SteamLinuxRuntime_4-arm64`: Steam recorded the mapping and still ran it on the host.
- On the host, everything the installer needs works: git, curl to GitHub, podman (sees the `dev` container), `systemctl --user`, `systemd-run --user`, and GTK 4 with libadwaita.
- A GTK window opens in gamescope (an X11 window on `:1`, drawn through gamescope's Vulkan WSI).
- Steam puts its overlay in `LD_PRELOAD` and Steam runtime paths in `LD_LIBRARY_PATH` and `PATH`. Every host tool prints a preload error unless they're cleared.
- Inside the Steam Linux Runtime 4 container (started by hand with its `run` script), there's no git, podman, systemctl, or GTK, but `flatpak-spawn --host` runs commands on the host.
- Steam refuses Devkit Game names with a `-` ("missing/invalid arguments").
- Steam doesn't make the start command executable: without the exec bit, the title exits in a second and nothing runs.
## The installer
`installer/frametop-install.sh` is the start command.
1. In the container, it starts itself again on the host with `flatpak-spawn --host`.
2. It clears Steam's preload and library paths.
3. It runs `curl get.sh | bash -s -- --yes` in a transient user service, `frametop-framedrop-install`, unless one is already running. The service is used because Steam ends the title's whole process tree when it's quit, and starts it with an OOM score of 900.
4. `installer/progress.py` (GTK 4 and libadwaita) follows the service's log, shows `install.sh`'s steps as a progress bar, and reports the result. Closing it leaves the install running. Playing the title again reattaches.
`--yes` keeps the version that's installed, or installs stable. It skips the parts that need sudo (our eye tracker and the Bluetooth fixes) and the SteamVR restart. The window says to restart SteamVR.
`--dry-run` clones into `~/.cache/frametop-framedrop/dry-run` and stops there (`get.sh --clone-only`).
## Try it on the Frame
```
framedrop/devkit.sh add FrametopTest framedrop/installer "./frametop-install.sh --dry-run"
framedrop/devkit.sh run FrametopTest # or Play it from the Steam library
framedrop/devkit.sh remove FrametopTest
framedrop/devkit.sh add FrametopProbe framedrop/probe "./probe.sh native" # the probe
```
The probe writes `~/.cache/frametop-framedrop/probe-native.log`, and the installer writes `~/.cache/frametop-framedrop/install.log`.
## Build the download
```
framedrop/build.sh [ZIP_URL]
```
This writes `framedrop/build/Frametop.zip` (reproducible) and `frametop.framedrop.json`, FrameDrop's manifest with the zip's sha256. By default, `ZIP_URL` points at a `framedrop-installer` release asset. The button's link would be `https://framedropvr.com/install?manifest=https://deejanuz.github.io/frametop/frametop.framedrop.json`, with the manifest committed to main for Pages.
## Open questions, for a test with FrameDrop on a Windows PC
- Does FrameDrop keep or set the exec bit on `frametop-install.sh`? A zip unpacked on Windows loses it, and without it nothing runs.
- What start command does FrameDrop pick for this zip, and which runtime?
- Does the manifest's `name` become the Devkit Game name? It has to stay free of `-`.
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env bash
# Build the FrameDrop download: framedrop/build/Frametop.zip (the installer in a "Frametop"
# folder) and framedrop/build/frametop.framedrop.json, the manifest an "Install with
# FrameDrop" button points at. The zip is reproducible: same files, same sha256.
#
# Usage: framedrop/build.sh [ZIP_URL]
# ZIP_URL where the zip will be downloaded from (default: the framedrop-installer release)
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
url=${1:-https://github.com/DeeJanuz/frametop/releases/download/framedrop-installer/Frametop.zip}
out=$here/build
mkdir -p "$out"
python3 - "$here/installer" "$out/Frametop.zip" <<'EOF'
import os, sys, zipfile
src, dest = sys.argv[1:]
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
for name in ("frametop-install.sh", "progress.py"):
info = zipfile.ZipInfo(f"Frametop/{name}", date_time=(2026, 1, 1, 0, 0, 0))
info.create_system = 3 # unix, so the permissions below count
info.external_attr = (0o100755 if os.access(os.path.join(src, name), os.X_OK) else 0o100644) << 16
info.compress_type = zipfile.ZIP_DEFLATED
with open(os.path.join(src, name), "rb") as f:
z.writestr(info, f.read())
EOF
sha=$(sha256sum "$out/Frametop.zip" | cut -d' ' -f1)
python3 - "$url" "$sha" >"$out/frametop.framedrop.json" <<'EOF'
import json, sys
url, sha = sys.argv[1:]
print(json.dumps({
"schema": "framedrop.install/v1",
"name": "Frametop",
"files": [{"url": url, "sha256": sha}],
}, indent=2))
EOF
echo "built $out/Frametop.zip ($sha)"
echo " $out/frametop.framedrop.json"
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env bash
# Do on the Frame what FrameDrop does from a PC: copy a folder into ~/devkit-game/NAME and
# register it with Steam as a "Devkit Game" (Valve's devkit-utils, the same calls FrameDrop and
# the SteamOS Devkit Client make over SSH). For testing the FrameDrop installer and the probe
# without a PC. Steam must be running, and Developer Mode on.
#
# Usage: devkit.sh add NAME DIR COMMAND [--compat TOOL]
# devkit.sh run NAME # start it, as the library's Play button does
# devkit.sh remove NAME # delete the folder and the Steam entry
# devkit.sh list
#
# NAME can't contain "-": Steam answers "missing/invalid arguments". COMMAND is relative to
# the folder, like FrameDrop's start command ("./probe.sh native"). --compat sets the runtime
# (SteamLinuxRuntime_4-arm64, say); on SteamOS 0.3.0 Steam recorded it but still ran the
# title on the host. There's no environment option: this Steam ignores the devkit env file.
#
# devkit-utils is Valve's (LGPL, gitlab.steamos.cloud/devkit/steamos-devkit). FrameDrop and
# the devkit client copy it to ~/devkit-utils; if that isn't there, it's fetched at a pinned
# commit into ~/.cache/frametop-framedrop.
set -euo pipefail
devkit_rev=a00ceb7d91ea44a0c3e714a91a06417d6e5cdb33
cache=$HOME/.cache/frametop-framedrop
usage() { sed -n '7,10p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2; }
utils() {
if [ -e "$HOME/devkit-utils/steam-client-create-shortcut" ]; then
echo "$HOME/devkit-utils"; return
fi
if [ ! -e "$cache/devkit-utils/steam-client-create-shortcut" ]; then
echo "fetching devkit-utils ($devkit_rev)" >&2
local tmp
tmp=$(mktemp -d)
git -C "$tmp" init -q
git -C "$tmp" fetch -q --depth 1 https://gitlab.steamos.cloud/devkit/steamos-devkit.git "$devkit_rev"
git -C "$tmp" checkout -q FETCH_HEAD
mkdir -p "$cache"
rm -rf "$cache/devkit-utils"
cp -r "$tmp/client/devkit-utils" "$cache/devkit-utils"
rm -rf "$tmp"
fi
echo "$cache/devkit-utils"
}
[ $# -ge 1 ] || usage
cmd=$1; shift
case $cmd in
add)
[ $# -ge 3 ] || usage
name=$1 src=$2 start=$3; shift 3
case $name in *-*) echo "NAME can't contain '-' (Steam refuses it)" >&2; exit 2 ;; esac
compat=
while [ $# -gt 0 ]; do
case $1 in
--compat) compat=${2:?}; shift ;;
*) usage ;;
esac
shift
done
u=$(utils)
dir=$(python3 "$u/steamos-prepare-upload" --gameid "$name" | python3 -c 'import json,sys; print(json.load(sys.stdin)["directory"])')
# FrameDrop rsyncs the unpacked zip; --delete as a clean upload would.
rsync -a --delete "$src/" "$dir/"
parms=$(python3 - "$name" "$dir" "$start" "$compat" <<'EOF'
import json, sys
name, directory, start, compat = sys.argv[1:]
print(json.dumps({
"gameid": name, "directory": directory,
"argv": [start], "env": {},
"settings": {"steam_play": "0", "compat_tool": compat},
"force_appid": "", "lepton_args": "",
}))
EOF
)
res=$(python3 "$u/steam-client-create-shortcut" --parms "$parms" | tail -1)
echo "Steam: $res"
case $res in *'"error"'*) exit 1 ;; esac
echo "registered $name ($dir)"
;;
run)
[ $# -eq 1 ] || usage
python3 "$(utils)/steam-devkit-rpc" run-game "gameid=$1" | tail -1
echo
;;
remove)
[ $# -eq 1 ] || usage
python3 "$(utils)/steamos-delete" --delete-title "$1"
rm -f "$HOME/devkit-game/$1"-*.json
;;
list)
python3 "$(utils)/steam-devkit-rpc" list-shortcuts
;;
*) usage ;;
esac
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env bash
# Frametop's FrameDrop installer: the start command of the "Frametop" title that FrameDrop
# puts in the Steam library. Playing it installs Frametop (or updates it) with the one-line
# installer, get.sh --yes, and shows its progress in a window.
#
# Steam ends a title's whole process tree when it's quit, and starts it with a high OOM score,
# so the install runs in a user service of its own (frametop-framedrop-install) and the window
# only follows its log. Quitting or closing the window leaves the install running; playing the
# title again reattaches to it.
#
# Usage: frametop-install.sh [--dry-run]
# --dry-run clone into ~/.cache/frametop-framedrop/dry-run instead of ~/frametop, and
# don't run install.sh (get.sh --clone-only), for testing this flow
set -u
here=$(cd "$(dirname "$0")" && pwd)
self=$here/$(basename "$0")
# A Linux title can be started in the Steam Linux Runtime container, which has no git, podman,
# systemctl, or GTK. Start this again on the host. (SteamOS 0.3.0 runs devkit titles on the
# host anyway; this is for when FrameDrop or Steam picks the runtime.)
if [ -e /run/pressure-vessel ]; then
exec flatpak-spawn --host --watch-bus --env=DISPLAY="${DISPLAY-}" \
--env=XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR-}" --env=SteamAppId="${SteamAppId-}" \
--env=ENABLE_GAMESCOPE_WSI="${ENABLE_GAMESCOPE_WSI-}" /usr/bin/bash "$self" "$@"
fi
# Steam adds its overlay and runtime libraries to every child; host tools don't want them.
unset LD_PRELOAD LD_LIBRARY_PATH
export PATH=/usr/local/bin:/usr/bin:/bin
dry_run=0
[ "${1-}" = --dry-run ] && dry_run=1
unit=frametop-framedrop-install
state=$HOME/.cache/frametop-framedrop
log=$state/install.log
mkdir -p "$state"
get_args="--yes"
[ "$dry_run" = 1 ] && get_args="--yes --clone-only --dir $state/dry-run"
# Start the install unless one is running already. RemainAfterExit keeps its result for the
# window; the next start clears it.
if [ "$(systemctl --user show -P ActiveState "$unit" 2>/dev/null)" != activating ]; then
systemctl --user stop "$unit" 2>/dev/null
systemctl --user reset-failed "$unit" 2>/dev/null
: >"$log"
systemd-run --user --unit="$unit" --description="Frametop install (FrameDrop)" \
--property=Type=oneshot --property=RemainAfterExit=yes \
--property=StandardOutput="truncate:$log" --property=StandardError=inherit \
--setenv=HOME="$HOME" --setenv=PATH="$PATH" --setenv=TERM=dumb \
--working-directory="$HOME" --quiet --no-block \
/usr/bin/bash -c 'set -o pipefail; curl -fsSL https://deejanuz.github.io/frametop/get.sh | bash -s -- '"$get_args" ||
{ echo "couldn't start the install service" >&2; exit 1; }
fi
exec /usr/bin/python3 "$here/progress.py" "$unit" "$log"
-125
View File
@@ -1,125 +0,0 @@
#!/usr/bin/env python3
"""Progress window for the FrameDrop installer: follows the install service's log and state.
Usage: progress.py UNIT LOG
Closing the window doesn't stop the install; it keeps running in the user service UNIT.
"""
import re
import subprocess
import sys
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, Gtk, Pango # noqa: E402
UNIT, LOG = sys.argv[1], sys.argv[2]
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
# install.sh marks each step with "== N/10 what it is"
STEP = re.compile(r"^== (\d+)/(\d+) (.*)$")
DONE_TEXT = ("Frametop is installed. Restart SteamVR once, or reboot the headset, so it loads "
"Frametop's driver. Then open Launch a program, then Desktop.")
def unit_state():
out = subprocess.run(
["systemctl", "--user", "show", "-P", "ActiveState", UNIT],
capture_output=True, text=True).stdout.strip()
return out or "inactive"
class Window(Adw.ApplicationWindow):
def __init__(self, app):
super().__init__(application=app, title="Frametop")
self.set_default_size(900, 600)
self.offset = 0
self.partial = ""
self.status = Gtk.Label(label="Installing Frametop", xalign=0, wrap=True)
self.status.add_css_class("title-2")
self.detail = Gtk.Label(label="Starting...", xalign=0, wrap=True)
self.bar = Gtk.ProgressBar()
self.bar.pulse()
self.text = Gtk.TextView(editable=False, cursor_visible=False, monospace=True,
wrap_mode=Pango.WrapMode.WORD_CHAR)
self.text.set_left_margin(8)
self.text.set_right_margin(8)
scroll = Gtk.ScrolledWindow(vexpand=True, child=self.text)
self.scroll = scroll
self.note = note = Gtk.Label(
label="You can close this window: the install keeps going. Play Frametop again to "
"come back to it.", xalign=0, wrap=True)
note.add_css_class("dim-label")
close = Gtk.Button(label="Close", halign=Gtk.Align.END)
close.connect("clicked", lambda *_: self.close())
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12,
margin_top=18, margin_bottom=18, margin_start=18, margin_end=18)
for w in (self.status, self.detail, self.bar, scroll, note, close):
box.append(w)
view = Adw.ToolbarView(content=box)
view.add_top_bar(Adw.HeaderBar())
self.set_content(view)
self.finished = False
GLib.timeout_add(500, self.tick)
self.tick()
def add_lines(self, lines):
buf = self.text.get_buffer()
for line in lines:
m = STEP.match(line)
if m:
n, total, what = int(m[1]), int(m[2]), m[3]
self.bar.set_fraction((n - 1) / total)
self.detail.set_label(f"Step {n} of {total}: {what}")
buf.insert(buf.get_end_iter(), line + "\n")
# Keep the view at the newest line.
end = buf.create_mark(None, buf.get_end_iter(), False)
self.text.scroll_mark_onscreen(end)
buf.delete_mark(end)
def tick(self):
try:
with open(LOG, "rb") as f:
f.seek(self.offset)
data = f.read()
self.offset += len(data)
except FileNotFoundError:
data = b""
if data:
text = self.partial + ANSI.sub("", data.decode("utf-8", "replace")).replace("\r", "\n")
*lines, self.partial = text.split("\n")
self.add_lines(lines)
state = unit_state()
if state == "activating":
if self.bar.get_fraction() == 0:
self.bar.pulse()
return True
if self.partial:
self.add_lines([self.partial])
self.partial = ""
self.note.set_visible(False)
if state == "active":
self.status.set_label("Done")
self.detail.set_label(DONE_TEXT)
self.bar.set_fraction(1)
else:
self.status.set_label("The install stopped")
self.detail.set_label("The log above says why. Play Frametop again to try again.")
return False
def main():
app = Adw.Application(application_id="io.github.deejanuz.FrametopInstall")
app.connect("activate", lambda a: Window(a).present())
app.run([])
if __name__ == "__main__":
main()
-114
View File
@@ -1,114 +0,0 @@
#!/usr/bin/env bash
# FrameDrop proof of concept, step 1: run as a Steam "Devkit Game" (what FrameDrop installs a
# Linux zip as) and record what an installer started from Steam could use: host or Steam Linux
# Runtime container, podman, the user's systemd, git, the network, GTK, and a window on screen.
# Register and start it: framedrop/devkit.sh add FrametopProbe framedrop/probe "./probe.sh native"
# framedrop/devkit.sh run FrametopProbe
# Usage: probe.sh LABEL
# Writes ~/.cache/frametop-framedrop/probe-LABEL.log, replacing the previous one.
label=${1:-unlabeled}
out=$HOME/.cache/frametop-framedrop
mkdir -p "$out"
log=$out/probe-$label.log
exec >"$log" 2>&1
here=$(cd "$(dirname "$0")" && pwd)
section() { printf '\n== %s\n' "$1"; }
have() { command -v "$1" >/dev/null 2>&1; }
check() { # check NAME CMD...: one line, ok or failed with the exit status
local name=$1; shift
if out_=$(timeout 20 "$@" 2>&1); then echo "ok $name: $(echo "$out_" | head -1)"
else echo "FAILED $name (exit $?): $(echo "$out_" | head -2 | tr '\n' ' ')"; fi
}
section "when, who, where"
date -Is
echo "label=$label uid=$(id -u) user=$(id -un) pwd=$PWD here=$here"
echo "args: $*"
grep -E '^(ID|VARIANT_ID|VERSION_ID|BUILD_ID|PRETTY_NAME)=' /etc/os-release
section "container?"
for p in /run/pressure-vessel /.flatpak-info /run/host/os-release; do
[ -e "$p" ] && echo "present: $p" || echo "absent: $p"
done
echo "container=${container-} PRESSURE_VESSEL_RUNTIME=${PRESSURE_VESSEL_RUNTIME-}"
[ -e /run/host/os-release ] && grep -E '^(ID|VARIANT_ID)=' /run/host/os-release
section "environment"
env | grep -E '^(PATH|HOME|XDG_[A-Z_]+|DISPLAY|WAYLAND_DISPLAY|DBUS_SESSION_BUS_ADDRESS|SteamAppId|SteamGameId|STEAM_COMPAT_[A-Z_]+|LD_LIBRARY_PATH|LD_PRELOAD|SDL_[A-Z_]+|GDK_BACKEND|ENABLE_[A-Z_]+|PRESSURE_VESSEL_[A-Z_]+)=' | sort
echo "process tree:"
pid=$$
for _ in 1 2 3 4 5 6 7 8; do
[ "$pid" -le 1 ] 2>/dev/null && break
printf ' %s %s\n' "$pid" "$(tr '\0' ' ' </proc/"$pid"/cmdline 2>/dev/null | cut -c1-200)"
pid=$(awk '/^PPid:/{print $2}' /proc/"$pid"/status 2>/dev/null)
done
# Steam adds its overlay (LD_PRELOAD) and runtime libraries to every child. An installer has
# to drop them before running host tools, as the checks below do.
unset LD_PRELOAD
LD_LIBRARY_PATH=$(printf %s "${LD_LIBRARY_PATH-}" | tr ':' '\n' | grep -v -e '/Steam/' -e '^$' -e 'x86_64' -e 'i386' | paste -sd:)
[ -n "$LD_LIBRARY_PATH" ] && export LD_LIBRARY_PATH || unset LD_LIBRARY_PATH
PATH=$(printf %s "$PATH" | tr ':' '\n' | grep -v '/Steam/' | paste -sd:)
echo "cleaned: PATH=$PATH LD_LIBRARY_PATH=${LD_LIBRARY_PATH-}"
section "tools"
for t in bash git curl python3 podman distrobox systemctl systemd-run busctl gdbus flatpak-spawn \
steam-runtime-launch-client konsole zenity kdialog; do
printf '%-28s %s\n' "$t" "$(command -v "$t" || echo -)"
done
section "what an installer needs"
check "home writable" sh -c 'f=$HOME/.cache/frametop-framedrop/.w && : >"$f" && rm "$f" && echo yes'
check "~/frametop visible" sh -c 'ls -d "$HOME/frametop" && git -C "$HOME/frametop" log -1 --format=%h'
have git && check "git ls-remote github" git ls-remote --heads https://github.com/DeeJanuz/frametop.git experimental
have curl && check "curl get.sh" sh -c 'curl -fsSL https://deejanuz.github.io/frametop/get.sh | head -1'
have podman && check "podman ps" podman ps --format '{{.Names}}'
have distrobox && check "distrobox list" distrobox list
have systemctl && check "systemctl --user" systemctl --user is-system-running
have systemctl && check "user units visible" systemctl --user is-enabled frametop-input-relay.service
have python3 && check "python3 gi Gtk 4" python3 -c 'import gi; gi.require_version("Gtk","4.0"); gi.require_version("Adw","1"); from gi.repository import Gtk, Adw; print(Gtk.get_major_version(), Gtk.get_minor_version())'
section "escape to the host (needed if this is a container)"
# A transient user unit runs in the host's user manager, outside any container.
if have systemd-run; then
rm -f "$out/escape-$label"
check "systemd-run --user" systemd-run --user --wait --collect --quiet -- \
sh -c "grep -E '^(ID|VARIANT_ID)=' /etc/os-release > '$out/escape-$label'; command -v podman git >> '$out/escape-$label'"
[ -s "$out/escape-$label" ] && sed 's/^/ host says: /' "$out/escape-$label"
fi
if have busctl; then
check "busctl --user (systemd1)" busctl --user get-property org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager Version
fi
section "window"
# A plain GTK 4 window for 20 s. Watch for it on the Frame; "window: mapped" means GTK showed it.
if have python3; then
timeout 40 python3 - <<'EOF'
import sys
try:
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib
except Exception as e:
print("window: no GTK 4:", e); sys.exit(0)
app = Gtk.Application(application_id="io.github.deejanuz.FrametopProbe")
def on_activate(app):
w = Gtk.ApplicationWindow(application=app, title="Frametop installer probe")
w.set_default_size(640, 240)
w.set_child(Gtk.Label(label="Frametop installer probe\n\nIf you can read this, a FrameDrop install can show progress.\nThis window closes in 20 seconds."))
w.connect("map", lambda *_: print("window: mapped", flush=True))
w.present()
GLib.timeout_add_seconds(20, app.quit)
app.connect("activate", on_activate)
print("window: display", Gtk.Widget.get_display(Gtk.Label()) if hasattr(Gtk.Widget, "get_display") else "?", flush=True)
app.run([])
print("window: closed")
EOF
echo "window exit: $?"
else
echo "window: no python3"
fi
section "done"
date -Is
+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) {