mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 08:00:09 +02:00
* Input relay: typing with the pointer helper down no longer ends the relay Typing on a pass-through keyboard tells the helper "typing". With the helper not running (SteamVR off), that send raised ConnectionRefusedError and the relay exited, dropping every grab until systemd restarted it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-steam: open Steam's menu through Steam's own UI steam/ft-steam menu opens the SteamVR dashboard on Steam's menu, or closes the dashboard if it's up, without pointer mode: it asks Steam's UI over its debugging port to show its dashboard overlay (ShowVROverlay, what Steam calls itself) and focus the Steam frame's left menu (MenuStore.OpenMainMenu). ft-steam check says whether those calls still exist, and update-check.py runs it, since a Steam client update can rename them. The CDP client moves from display-settings/steam_settings.py to steam/steamui.py, so both use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Shortcuts: Steam menu, commands, and modifier taps Key combinations (and mouse and controller buttons) get two new actions: - steam_menu: Open Steam menu / close dashboard (steam/ft-steam menu). - command:CMD: run CMD with sh -c, as the relay's service, with layout/, float/ and steam/ on its PATH. Input Settings offers it for key combinations as Run a command... Both work without pointer mode. A modifier on its own is now a key combination too: a tap, pressed and released with no other key, mouse button, or scroll in between. A bound tap sends the desktop F24 before the release, so Plasma's launcher stays shut. The defaults gain a Meta tap for the Steam menu; this replaces META_DASHBOARD, which only worked in pointer mode. input/test/keys-test.py runs the relay against fake devices with every outgoing socket renamed, so it's safe next to the live relay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Relay: share a key combination's Meta release with frame-voice A Meta+key combination (Meta+J for gaze_left, say) hides Meta's release from the desktop, and the relay skipped share_key for it too. frame-voice saw Meta go down on @frametop_keys and never come up, so it held all dictated text back, waiting for that release. Keys of grabbed keyboards are now shared as pressed, before key_binding() decides what the desktop gets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: ft-cutouts, the hand cutouts without pinches and grips hands/ft-cutouts on|off|status starts ft-camd and ft-hands as transient user units with ft-hands' new --no-gestures: hands are published for ft-screens' cutouts, but no pinch or grip is detected, so nothing clicks or drags and a closing hand doesn't raise the tracking rate. It needs a build and ft-camd's capabilities, not hands/run.sh install. Its units conflict with ft-handsctl's, so each stops the other, and they stop with SteamVR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-cutouts status: only the current run's tracker lines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: say why gaze mode can't work yet, and open the calibration whenever it's missing Turning gaze mode on without a calibration opened Calibrate only on the off-to-on change, and only if it could open right then. With the headset off, the eye tracker silent, or the panel not built, or with gaze mode already on when the gaze service started, nothing opened and nothing said why: the pointer just stayed a mouse. - The gaze service now checks every second: gaze mode on, no calibration for the tracker in use, eyes seen -> the full calibration opens. One that closes unfinished opens again only after the headset comes off and on, gaze mode off and on, or Calibrate, so it doesn't loop. A start that fails retries every 10 s. - Its status says why gaze mode can't work yet (checks.problem): not calibrated and opening, open, closed unfinished, or can't open and why. - Input Settings shows that under the Gaze pointer switch, along with the gaze service not installed or not running and our tracker missing its frame grabber. - ft-gazectl on notes a missing calibration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: install our eye tracker, prefer it, and say why a calibration dot wasn't taken A user on a fresh install got "Calibration failed: only 0 of 21 dots" with no reason. The installer never installed our tracker, so gaze used SteamVR's, and the only way SteamVR's tracker rejects a dot is losing an eye for most of the look. The panel just showed a red ring. - install.sh: step 9/10 installs our tracker (gaze/tracker/install.sh) after gaze mode, yes by default; it needs sudo, so --yes runs it only when sudo won't prompt. If it fails, gaze keeps SteamVR's tracker. Configs that still say GAZE_TRACKER=steam (the old template) are asked whether to switch. - GAZE_TRACKER=auto, the new default: ours when it's installed (the frame grabber, its unit, and ft-eyes' Python), else SteamVR's. ft-gazed rechecks every second, so installing it switches over. Input Settings lists Own tracker first as recommended, and says how to install it when it's missing (checking the host's /etc through /run/host from the dev container). - The calibration panel has a note line, orange over the instructions: why a dot wasn't taken (an eye lost, a blink, the eyes disagreeing for SteamVR's tracker, from steady_samples' new drop counts; ft-eyes' reply for ours), what a click is still waiting for after 1.5 s, and a failed calibration's most common reason, which the Gaze page shows too. steady_samples keeps the same samples as before (checked on 2037 windows of recordings); a lost eye is named before a blink, since its openness reads 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * README: link the Frametop Discord Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Wait for a new container to finish setting up before entering it container-up.sh starts the dev container in a scope of its own, so distrobox enter finds it running and skips its wait for distrobox-init. On a fresh install, init was still setting up passwordless sudo when dev-container.sh ran sudo dnf install, and sudo asked for a password with no terminal to read it from. container-up.sh now waits for container_setup_done itself, and the container's sudo calls use -n, so a password prompt fails at once with a clear message. Fixes #9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Prevent small desktop overlay pointer movements from starting a drag * Clear reported drag state on controller release * Click stability: only a hand controller's press starts it The 3D mouse drives SteamVR's laser through the ft_pointer virtual controller, so its events reach the screens the same way a controller's do. The filter held every press, which turned the mouse's short drags (selecting a character or two, nudging a slider) into clicks. Mark button events from hand controllers and start the filter only on those. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Input relay: retry a new device until udev gives it to the input group A new /dev/input node is root:root 0600 until udev applies GROUP=input. The scan probed each new node once and marked it seen even when the open failed, so a node caught in that gap was never opened. Behind a KVM, a switch brings back a hub of devices at once: on the Frame, four nodes failed with EACCES in one switch, the keyboard was never grabbed, and its keys went to gamescope instead of the desktop screens. A node that isn't readable yet now waits for the next scan. * Screens: take a screen's overlays from one copy in the catcher While a button pressed on a screen is held, UpdateCatcher checks every tick whether the laser is still on one of the screen's overlays. It built that list from s.All().begin() and s.All().end(), but All() returns a std::array by value: iterators into two different temporaries, which is undefined behaviour. A clang build of ft-screens got a garbage length, threw std::length_error, and aborted on the first click, taking KWin and the desktop with it. * Gaze: leave SteamVR's gaze action alone during VR games From curiousjtuber's PR #13: with the gaze service running, SteamVR restarted its eye tracker every 10 to 13 s of Beat Saber, as if the headset came off, and each restart took input focus from the game. The PR stopped every read in a game. Only the action path reaches SteamVR (UpdateActionState on the gaze set at overlay-global priority, then GetEyeTrackingDataRelativeToNow); the mmap and our tracker are read-only files. So only the action is skipped while a scene app runs, and gaze keeps moving the pointer over the dashboard in a game. The action source is only used with --source action. Co-Authored-By: CuriousJ <curious.j.tuber@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: --record-hz, and the hand recorder's design (hands/rec/DESIGN.md) ft-hands --record-hz N records at most N frame sets a second, for the hand recorder (10). DESIGN.md lays out the recorder: the headset panel, the session runner and its script, the files, review and export, consent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: ft-handpanel, the hand recorder's headset panel A head-locked SteamVR overlay for the hand recorder (hands/rec/DESIGN.md): 1.2 m ahead, 12 degrees up, 36 degrees wide, drawn with stb_truetype into three shared DMA-BUFs as ft-gazepanel does. It shows the title, step, wrapped instruction, note, countdown, hand chips, near/far bar and a "Paused" cover, driven over @ft_handpanel. It also places the touch target, a 2 cm dot in its own overlay fixed in the room where the head was at the first command for that point, and logs head and controller poses to poses.jsonl at 250 Hz from a thread of its own. Both threads take one lock around OpenVR calls. --no-vr prints each picture's state to stdout (and --dump writes the pictures), for testing without a headset. hands/rec/build.sh builds it in the dev container. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the recorder's worn check goes by the panel's backlight, as frame-job does Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the hand recorder's session runner and guided script hands/rec/session.py runs a recording session from script.json: it starts ft-camd and a tracking ft-hands as transient units only if they aren't running, records each section as one take (ft-hands --record-only at 10 sets/s, a new sets-N.bin after each pause), drives ft-handpanel, and writes session.json, calibration.json (identifying fields removed), prompts.jsonl and take.json. Feedback comes from the live hands file and, in the controller sections, from the panel's device poll. It also runs from the command line (--dry-run, --speed, --ring, --no-start). hands/rec/script.json: 11 sections, about 9 minutes without the object and controller sections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the hand recorder's window, review and export hands/rec/ft_handrec.py + main.qml (Kirigami, dev container; host launcher hands/rec/ft-handrec): consent (CONSENT.md, asked again when its version changes; profile.json with a random contributor id), the before-you-start checklist with the lighting and free-space checks, the session controls (Space pauses, Esc stops), review with a frame-set viewer that deletes ranges, takes and sessions, export with progress and cancel (warns while the headset is worn), and the upload page (UPLOAD.md, the huggingface-cli command; HF_DATASET is a placeholder). --dry-run runs sessions without processes, for testing. hands/rec/takes.py (standard library): indexes sets.bin and sets-N.bin without reading pixels, reads one set's cameras, keeps deleted ranges in take.json, and exports: deleted sets left out, zstd -10 -T2 at nice 19, manifest.json and SHA256SUMS, nothing left behind on cancel. CONSENT.md and UPLOAD.md are drafts pending a legal review; the window says contributions aren't open yet. The dev container gains zstd. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: upload from the hand recorder's window, export checks, a rehearsal hands/rec/validate.py (standard library; Linux and Windows, Python 3.12+) checks an export before upload and when it's received: SHA256SUMS, an allow-list of files, the manifest's schema and keys, the consent version, a uuid4 contributor, no identifying fields in calibration.json or device.json, every sets.bin.zst decompressed to its end as a stream with each FHSET01 header checked against the manifest, jsonl lines, the total size. It decompresses with compression.zstd, zstandard or the zstd program. validate.py DIR [--json]. hands/rec/hub.py uploads an export with huggingface_hub, as a pull request to contributions/<contributor>/<session>: validate first, refuse a repeat of the same export, check the login (whoami) and access (auth_check), upload_folder(create_pr=True) with the manifest summary as the description, then record the PR under "uploads" in session.json. Errors are explained (terms not accepted, not found, 401/403, network). --dry-run makes no network calls. FT_HANDREC_DATASET overrides HF_DATASET (DeeJanuz/frametop-hands); while the texts are drafts a real upload needs FT_HANDREC_ALLOW_UPLOAD=1. The Upload page shows the login with "Check again" and how to run hf auth login in a terminal (the token never enters the window), then Upload with a phase, progress and Cancel (hub.py as a child process), the PR link, and a warning for an export uploaded before. The manual command stays as the fallback. ft-handrec --hub-dry-run. session.py also saves device.json: cv.cad_from_cal and head from /persist/device_config.json, the labeller's shape, nothing identifying; export copies it. Session ids with a -N suffix are accepted everywhere. hands/rec/rehearse.sh runs it all without the headset: ft-ringplay plays 30 s of a capture into a ring, session.py records a short test script with ft-handpanel --no-vr and a tracker, then export, validate and a dry-run upload (--repo ID uploads for real). It runs in one frame-job scope, deletes its data and stops its processes, also on Ctrl+C. hands/rec/tests/test_validate.py covers good and broken exports and hub.py without the network. The dev container gains python3-huggingface-hub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * README: Frametop doesn't work on the SteamOS beta yet Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit072a294941) * Hands: step mode and pose pictures for the hand recorder The first real session moved on every 5 s with text only, too fast to follow. Each step now waits for Next (Space or the window's button), counts down 3-2-1 while recording, then holds. P pauses, R redoes a step, S skips a section; "Advance by itself" (--auto) keeps the old timed flow. Nothing records while a step waits: each step is its own recording part. The panel and the window show a picture of each pose (hands/rec/poses, generated by make_poses.py from a parametric hand, MIT) and a diagram of where to hold the hands and how far out. prompts.jsonl gains ready, wait and redo events; session.json gains mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the headset button as Next, clearer push steps The headset's right-side click button (KEY_SELECT on gpio-keys, read without a grab) now works the session: Next while a step waits, pause during a hold, resume while paused. With no mouse connected the hints lead with it. The push sections say plainly to push straight out from the headset and pull back, with a side-view picture of the head, the headset and the arrow, and the bar's ends read "At your chest" and "Arm out". The bar labels are sent as one field, so labels with spaces no longer split. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-floatd: a launch for a missing app no longer kills the control socket Gio.DesktopAppInfo.new() returns NULL for a desktop file that doesn't exist, and PyGObject raises TypeError ("constructor returned NULL") rather than returning None, so launch()'s `if info is None` never ran. The exception escaped the control socket's GLib callback, GLib dropped the watch, and ft-floatd stopped answering everything: the float key, dock, Launch as Standalone, and profiles, until the desktop restarted. Found on the Frame (2026-10-02): a profile saved with RustDesk's Flatpak open records its window's app id, com.carriez.flutter_hbb, which has no desktop file (the Flatpak's is com.rustdesk.RustDesk). `ft-layout use` on that profile asked ft-floatd to launch it, and ft-floatd went silent. With this, that launch replies "error no app com.carriez.flutter_hbb" and the profile's other apps open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-floatd: profiles keep and relaunch Flatpak apps whose window names another app id An X11 window in a Flatpak can give KWin an app id with no desktop file: RustDesk's says com.carriez.flutter_hbb (its GTK application id), and the Flatpak's desktop file is com.rustdesk.RustDesk. A profile recorded that id, so it couldn't relaunch the app (PR #16 keeps that from killing ft-floatd's socket). And the window's pid is the sandbox's own, so a launched window matched neither by process nor by app id, and didn't float. desktop_name() finds the desktop file whose StartupWMClass names the window's class (or app id) when the app id has none. Capture records that name, a profile claims open windows by it, and a launch's window matches by it. Profiles saved before this keep the old id; save them again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: check the tracking cameras before recording, and watch for losing them After the headset wakes, XRService sometimes fails to load the colour module's VCINT FPGA image; then only the two side cameras run, without the IR light, and the tracker finds no hands. hands/camcheck.py reads XRService's log, the video nodes it holds and ft-camd's ring, and says ok, degraded or unknown. The recorder won't start while degraded (--ignore-cameras overrides it), offers a confirmed SteamVR restart, and stops the first hand-size step when the tracker sees no hand at all. ft-camwatch (unit file only, not enabled) follows the log, notifies, and with CAMWATCH_AUTO_RESTART=1 restarts SteamVR when the headset isn't worn and nothing else uses VR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: shorter recording sessions with pose sweeps The second real session took 16 minutes, half of it 36 still poses. Labels come from the auto-labeller, so what matters is variety, not clean holds. A sweep step shows a strip of pose pictures and lights one every 4 s while the hands move slowly near and far; each cue is a prompt event with "cue": true. The core session is now 15 steps, about 5 minutes recorded. The pose groups, the one-hand sweeps' groups and the cue order are shuffled per session, seeded from its id and saved in session.json. A quick round (--quick, or the checklist's choice) is about 2 minutes for extra lighting. Touch the dot has 6 dots, the push sections two heights. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Input relay test: let the fake devices past the udev permission check Since the relay leaves a node it can't read yet for the next scan (12f2e84, PR #12), it checks os.access first, and the test's fake /dev/input paths don't exist, so the relay never opened them and every key check failed. The fake os now says they're readable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pause Frametop for VR games Frametop kept using the headset during games: with gaze mode off, our eye tracker still took about 60% of a core, remote desktop about 2 cores while on, and KWin kept drawing hidden screens because ft-screens sent their frame callbacks at 90 Hz. Pausing gives that back, and resuming brings back only what pausing stopped. It's also a way to keep the gaze service and our eye tracker off during games, which PR #13 asked for. Paused (input/game_pause.py, run by the input relay): - frametop-gaze stops (ft-eyegrab then idles by itself), and hand tracking and remote desktop stop if they run - the desktop hides and slows down: ft-screens "pause on" hides every panel and sends KWin a frame callback once a second; or, with pause_desktop "close", the desktop closes and starts again on resume - the relay lets go of the 3D mouse, typing goes to Steam, and mapped buttons and key combinations do only pause_toggle, steam_menu and commands Toggled by both thumbsticks clicked together twice (configurable), read passively from vrserver's web socket (input/vrws.py) so it works in games and takes nothing from them; by the new pause_toggle action; by input/ft-pause; and, with pause_auto (default on), by a VR game starting and ending, which the pointer helper now reports ("vrgame 1|0"). Frametop Input Settings has a Games page for it. update-check.py checks the web socket, and doesn't count a paused gaze service as failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: ft-hands works out which side camera is which ft-camd tells the side cameras apart by XRService's buffer allocation order, which some XRService starts reverse; both of 2026-10-02's starts did, so the cutouts missed the hands. HANDS_SWAP_SIDES=auto (the default) has ft-hands vote from hands seen in both side cameras: the landmark rays meet in front of both cameras only under the right naming. While undecided it probes the exchanged naming with the landmark model. It decides in about 2 s of hands (right on all 7 recordings replayed), swaps the views in place, and publishes sides.json. 0 and 1 still force it, with a warning when the hands disagree. Recordings carry each part's naming and the session's decision; review, export, validate and ft-handreplay put the names right, and takes.py sides records a decision by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: idle while the gaze isn't used The gaze service ran ft-gaze and our own eye tracker all the time: with gaze mode off, ft-eyes still took about 60% of a core, and ft-eyegrab, ft-gaze and ft-gazed 3 to 4% each. Now ft-gaze and our tracker run only while gaze mode is on and someone wears the headset, while a check or the calibration is open or asked for, or under a "wake" lease, which the Gaze page of Frametop Input Settings renews while it's open. 30 s after the last use they stop, and the frame grabber idles with our tracker. - The pointer helper answers "gaze ? headset" with worn|away (SteamVR's activity level for the headset); an older helper answers it as before, and the service then goes by gaze mode alone. - A quick check, calibration, or fit check asked for while idle wakes the tracker and opens once it sends; the automatic calibration waits quietly while it starts. - Status has "awake" and "idle" (why), and the Gaze page shows it. - gaze/test/idle-test.py runs the service with a fake helper and ft-gaze, offline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * update-check: a still controller isn't a broken web socket vrserver sends a controller's state only when something on it changes, and one lying still or asleep may not even send its first one. The check subscribed to one controller and failed after 3 s of silence. It now subscribes to all, and silence after a good handshake is a skip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the recorder measures the light itself The checklist page measures the light when it opens, starting ft-camd if nothing runs it (and stopping it on quit), instead of saying the cameras aren't running. The round's lighting defaults to what the cameras measure: daylight or indoor, from the mono cameras' ambient infrared. Lamps give off little infrared, so dim and normal rooms read alike; picking dim, room or daylight still overrides it. session.json gets source, measured and ambient_ir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the recorder's host commands run from the home folder host-spawn starts a host command in the caller's folder. Started from /tmp, the app's folder in the container is /run/host/tmp, which the host doesn't have, so starting ft-camd (and every other host command) exited 127. host_command now runs from home (env -C), and the launcher cds there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Input Settings: the Games page is Game optimization Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: push steps say to follow the hollow circle, not the blue dot The dot is the current tracker's distance guess, often wrong; the ring is where the hands should be. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Eye tracker: one thread for OpenCV and numpy Nothing called cv2.setNumThreads, so OpenCV kept a pool of one worker per core for pupil windows of 140 to 240 px. Live, its idle workers spun and yielded about 14,000 times a second each, about a quarter of a core, next to SteamVR's compositor. numpy's OpenBLAS also started 8 threads that never had work. eyes_pupil.py now sets OpenCV to one thread, and ft-eyes sets OPENBLAS_NUM_THREADS and OMP_NUM_THREADS to 1 before numpy loads (a value already in the environment wins). Replaying fit1 into a scratch share (ft-eyes-replay, 14 s measured, capped at one core with the replay): threads 13 -> 1, involuntary context switches 3,812/s -> 430/s, system time 6.9% -> 1.6% of a core. Under that cap the frames it kept up with went from 21-36 to 57-69 a second per eye. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer: read the overlay list every 20 s, not every second The helper ran `vrcmd --overlays` once a second while the pointer was awake, and in gaze mode the pointer never sleeps. Each run is a shell plus vrcmd, a new SteamVR client, about 26 to 30 ms of CPU, so about 3% of a core all the time. The list is now read every 20 seconds, and at once (at most once a second) when it may have changed: the pointer waking, the dashboard opening or closing or creating an overlay, the scene app changing, an "overlays" request, and a left click that hit nothing, which may be on a panel that came up since. The thread waits on a condition variable instead of waking every 100 ms, so it sleeps while paused. The main loop looks the keys up again as soon as a new list is in, rather than at its next 1 s tick. Overlays already on the list still show and hide within 50 ms, from the IsOverlayVisible poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: ft-eyes and ft-gaze below SteamVR's priority ft-eyes and ft-gaze run in the dev container through distrobox, so they live in podman's libpod scope: frametop-gaze.service's limits never reach them, and they ran at nice 0 next to vrcompositor and vrserver, also at nice 0. - ft-eyes sets itself to nice 10 and SCHED_BATCH at start, before its threads. Batch turns off wakeup preemption, so a frame ft-eyes wakes up for can wait out a running compositor's turn; a few ms late costs the gaze little. - ft-gaze sets nice 5 before its threads start, but stays SCHED_OTHER: each sample goes on to the pointer, and batch would add the same wait to every one of them. - Both only ever lower their priority (a higher nice already set wins), and a failure is logged and ignored. Checked in the dev container: nice 0 -> 10, policy 0 -> 3 (SCHED_BATCH) without any capability. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer: sleep on the command socket while the pointer is off The main loop slept a fixed 8 ms, about 116 wakeups a second, whether the pointer was awake or not, and every second it looked up every overlay's handle and read a string property from all 64 device slots to find its own device. With the pointer off and hand gestures off, the loop now waits in poll() on its command socket for up to 250 ms, or 20 ms while mapped Frame controller buttons are being read (SteamVR input has no event to wait for). A mouse command ends the wait at once. The headset's activity level, the game check, and the "vrgame" and "gazeawake" repeats keep going at that pace. The 50 ms visibility poll and the 1 s handle lookups run only while the pointer is awake, and waking forces both. The device index is looked for only while it's unknown, and again after SteamVR activates or deactivates a device. The HMD pose history is kept only with hand gestures on, its one user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remote desktop: connect FreeRDP only while a VNC viewer is connected vnc-bridge.sh kept FreeRDP connected to krdpserver from the moment remote desktop started, so krdp captured and H.264-encoded every KWin redraw in software (openh264) with nobody watching: krdpserver 55-78% of a core, xfreerdp 16-27%, Xvnc 6-11%, with 0 clients on :5900. krdp 6.7 creates its screencast session per RDP connection and drops it when the connection closes, so krdpserver itself idles without one and stays up. The bridge now counts established connections to Xvnc's port with ss, starts FreeRDP when a viewer appears (the desktop shows about 3 s later; the VNC screen is black until then) and stops it 45 s after the last one leaves (VNC_IDLE_SEC). Xvnc has no client hook, so its log output, which it writes for every connection, wakes the bridge early; otherwise it looks every 5 s while idle (0.1% of a core measured, against 0.9% for ss once a second) and every second while FreeRDP runs. The layout check runs only while FreeRDP runs. While a viewer is connected the bridge sends "watch 15" to ft-screens (@ft_screens) at once and every 5 s, so screens at a reduced frame rate (out of view, headset on a stand) stream at full rate; it lapses by itself if the bridge dies, and an ft-screens without the command just answers an error. The window search after starting FreeRDP now ends when FreeRDP exits instead of polling for 30 s. krdp on 127.0.0.1 with a fresh password, VNC on the tailnet address with VncAuth, and remote-ctl.sh start/stop (pause and resume) are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remote desktop: read the layout only after it changes While FreeRDP ran, vnc-bridge.sh called ft-layout remote-view every 5 s, which scans all of /proc for plasmashell and runs kscreen-doctor -j: about 4.4% of a core for a layout that rarely changes. It now stats the two files the answer depends on, the nested KWin's ~/.config/frametop/kwinoutputconfig.json (positions, scales, primary) and ~/.config/frametop-layout.json (screen sizes), once a second while FreeRDP runs. After either changes it reads the layout every 2 s for 10 s, since KWin's outputs follow the file a few seconds later; otherwise once a minute, in case a change touched neither. With no VNC viewer connected nothing runs (previous commit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Eye tracker: ft-eyes sleeps until the next frame is due ft-eyes looked for new frames about 1,000 times a second: each pass of its loop asked the control socket with a non-blocking recvfrom (a BlockingIOError nearly every time), read both cameras' counters, and slept 1 ms. The frames come every 11.1 ms per camera, and only as counters in ft-eyegrab's shared memory, so there's no fd to wait on. Now each pass ends in select() on the control socket, with a timeout until 2 ms before the next frame of either camera is due (from when its last one was seen), then every 1 ms until it comes. A command wakes it at once. A camera with no frame for 0.1 s (headset off, grabber idle) isn't waited for, and with both stopped it looks every 20 ms. Waiting for the frame grabber's file uses the same select, 0.2 s at a time, instead of sleeping through commands. A new frame is still seen within about 1 ms of when it lands. On a synthetic share at 90 Hz per camera, on a heavily loaded headset (load average 23, so ft-eyes rarely sat idle), its waits went from 178 to 81 a second; unloaded, the old loop's 1 ms sleeps add up to about 1,000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screens: frame rates by attention, ticks in step with the display ft-screens gave KWin a frame callback for every committed screen on each tick, and the tick was an 11 ms timer set again after each run, so it slid through the display's frame and came about 85 times a second at 90 Hz: the desktop repeated a frame several times a second (judder in scrolling and video), and KWin drew every screen in one burst at a random point of vrcompositor's frame. Hidden screens got the same 90 Hz unless Frametop was paused for a game. - Ticks run on a timerfd at absolute times, once per display frame, 1 ms after the vsync (IVRSystem::GetTimeSinceLastVsync and the HMD's display frequency, read once a second), so KWin gets its callbacks early in the frame. Measured with --no-vr: 91 wakeups a second instead of about 85. On the Frame the vsync times SteamVR reports lie on a 90 Hz grid. - Each screen's callbacks come at a rate for how much of it you see (vr.cpp, UpdateAttention): every frame while focused (within 12 degrees of where your head points, a laser or the mouse on it in the last 1.5 s, carried, or typed on), 15 a second for the rest of what you see (within 60 degrees), and 1 a second when hidden, behind you, or paused. Levels rise at once and fall after 1.5 s (focused) or 0.5 s (in view). KWin draws a screen only after its callback and its apps wait for theirs, so this throttles the apps too. A screen where nothing changes costs nothing at any rate, as before. - A video in view keeps every frame: 8 commits in a row that each redraw 6% or more of the screen, at 10 a second or more, count as one (from the surface's buffer damage). - "rates F V H" / --rates set the three rates (default 0 15 1, 0 = every frame), "rates?" shows them and each screen's level, "watch S" gives everything full rate for S seconds for a remote viewer (vnc-bridge.sh renews it), and "phase MS" moves the ticks for tuning. - ft-screens' main thread runs at nice -5 after the session starts: SteamOS allows down to -8 once the soft RLIMIT_NICE is raised, and KWin waits on these ticks. It had spent nearly 3 times as long waiting to run as running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer: skip unchanged work while the pointer is awake Every frame (about 116 a second) the helper tested the cursor ray against every visible overlay twice with ComputeOverlayIntersection, set the dot's alpha, width, transform and visibility (five calls into SteamVR), and sent the driver a pose datagram, even with the mouse and the head still. Now a frame reuses the last collision result when the mouse, the anchor (1 mm) and the eye (5 mm) haven't moved and no overlay showed, hid, or changed handle. The passes still run at least every 100 ms, since overlays move on their own (a floating window's controls follow it), and always while dragging. The dots' setters go to SteamVR only when their value changes: the placement when the dot moved 0.2 mm or the eye 5 mm, which turns or resizes it by well under 1%, and the width on a 0.5% change. The plain pose goes to the driver only when the laser's origin moved 0.2 mm or its direction 0.04 deg (0.1 mm where it lands, 15 cm on), and at least every 100 ms; the driver keeps the last pose and reports it every frame. A tilt's pose, a placement, or waking sends the next one regardless. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Session: blur, background contrast and animations off by default The nested kwinrc had no [Plugins] group, so KWin ran its default blur and background contrast effects, and kdeglobals had no AnimationDurationFactor, so animations ran at full length. KWin renders through zink on Turnip, on the GPU vrcompositor needs, and blur re-renders what's behind every translucent panel and menu; each animation frame is another frame for KWin and ft-screens. Before KWin starts, the session script now writes [Plugins] blurEnabled=false and contrastEnabled=false to $XDG_CONFIG_HOME/kwinrc and [KDE] AnimationDurationFactor=0 to its kdeglobals, each only if the desktop's own file has no value for it. It does this once and records that in $XDG_CONFIG_HOME/frametoprc ([Defaults] effects=1), because System Settings deletes a key put back to its default: without the marker, turning blur back on wouldn't survive a restart. The ids blur and contrast are the built-in effects of KWin 6.2.5 on SteamOS (both enabled by default in its plugin metadata). Tested against a temporary XDG_CONFIG_HOME: fresh config, an existing blurEnabled=true kept, and a deleted key not rewritten. README and docs/reference.md say how to turn them back on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Session: don't autostart Discover's notifier or IBus in the desktop The nested Plasma session runs the system's XDG autostart entries. Discover's update notifier (/etc/xdg/autostart/org.kde.discover.notifier.desktop) started plasma-discover --mode update inside it, 520-620 MB resident and about 9% of a core, with flatpak-system-helper and AppStream downloads behind it. IBus started a nested ibus-daemon with kimpanel and ibus-extension-gtk3, which no app in the desktop can use: KWin's input method is ft-textinput (zwp_input_method_v1, focus reports only; the VR keyboard types through ft-screens' seat), and the session already drops QT_IM_MODULE, GTK_IM_MODULE and XMODIFIERS. Nothing in Frametop talks to IBus. Before Plasma starts, the session script copies both entries into $XDG_CONFIG_HOME/autostart with Hidden=true, which plasma-session honours for that desktop only. It does this once ([Defaults] autostart=1 in frametoprc) and skips a name the user already has a file for, so deleting the copy brings the program back. The geoclue demo agent stays (it answers apps' location requests outside GNOME and idles at 0%), and orca's entry is OnlyShowIn GNOME-family desktops, so it never ran. Tested against a temporary XDG_CONFIG_HOME, including an existing user ibus.desktop left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Input relay: never block on the pointer helper's socket The relay sent to @ft_pointer_helper on a blocking socket. When the helper stalled, a layout placement or grabprobe holds it for seconds while ft-gazed keeps filling its socket at 90 Hz, the relay's one loop blocked with it: keyboards, the volume keys (which must never reach gamescope), and pausing all stopped until the helper read again. The socket is non-blocking now. A command the helper doesn't take (EAGAIN) waits in a queue, and everything after it queues behind it so the order holds; tick() sends what it can on each loop, and the select timeout drops to 20 ms while anything waits. Mouse moves add up into one queued move. A scroll notch is dropped rather than queued, since scrolling seconds late is no use; its release still goes. Presses, releases, show, hide, and the rest are kept, so no button stays down. The queue holds at most 512 commands. While paused, the configured pointer's queue still drains, so the releases and "hide" from standing down arrive. A "vrbind" that hits a full socket is sent again on the next loop instead of being lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer driver: parse outside the lock, report only changes Handle() held the state lock through a chain of up to a dozen sscanf calls per command, and RunFrame, which vrserver calls every frame, takes the same lock, so a burst of commands (about 116 poses a second, plus moves and buttons) could hold up vrserver's frame. Commands are now parsed into locals first, and the lock is held only to store the result. RunFrame also called UpdateBooleanComponent six times and UpdateScalarComponent twice every frame, and TrackedDevicePoseUpdated every frame even while disconnected. Components now go to SteamVR only when they change (all of them on the first frame). The pose still goes out every frame while the device is connected, as a tracked device's should; the disconnected pose goes out once. The helper now sends a pose only when it changes, so the comment says the driver keeps the last one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-powerd: ask SteamVR every 100 ms, not on every input event The loop polled the input devices with a 100 ms timeout and then, on every wake, did SteamVR's part: PollNextEvent, the headset's activity level and every device's pose, all IPC calls to vrserver. Input wakes it at once so the displays come on with the first key or motion, but a moving mouse sends hundreds of events a second, so moving the mouse meant hundreds of rounds of IPC a second instead of 10. Every wake still drains the input devices and the control socket and counts input as use straight away; SteamVR's part, and the backlight read that goes with it, now run only when 100 ms have passed since the last time, and poll sleeps until then. Built in the dev container (power/build.sh, no warnings); not run, since the live ft-powerd holds @ft_powerd. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Eye tracker: ft-eyegrab checks only the slot each camera writes next While copying, ft-eyegrab woke every 300 us (about 1,500 to 3,000 times a second) and fingerprinted all eight slots each time: 8 x 256 strided reads from DMA-BUF memory. - Each look now checks only the slot each camera writes next. The order is known (camera 0 3,0,1,2; camera 1 7,5,4,6,5,7,6,4), and the next slot follows from the last two; the table starts from those orders and learns from every frame, so a SteamVR update that changes them costs a few seconds of full scans, not frames. - A camera with nothing in its expected slot 1.5 frames after its last one, or with no order yet, gets all four slots checked, as before. A frame that turns up in an unexpected slot means full scans for that camera for 2 s. - A slot's fingerprint is taken again when it stops being one of the two in use, so a later check sees only a new frame. A frame is still passed on when its camera starts the frame after next. - Between frames it sleeps until 2.5 ms before the next is due, then looks every 1 ms, with 0.5 ms of timer slack (PR_SET_TIMERSLACK, --share only). With no frames from either camera for 0.5 s (headset off) it looks every 4 ms. - --rec keeps its 0.3 ms polls (and the expected-slot checks), for its timestamps. Tested offline by building poll_frames against simulated cameras that write each frame in four bursts, the last after the next frame starts (6 s, both cameras): 1,076 frames passed on, none torn or skipped, with the known orders and with camera 1 in a different order. Wakeups 1,486/s -> 207/s, the poller's CPU 3.6% -> 0.8% of a core (in plain memory; the real DMA-BUF reads cost more), and a frame's start is seen 1.35 ms after it begins on average instead of 0.76. With a camera stalling 15 ms every 2 s, the old poller passed on 22 torn frames and the new one 8 or fewer. Built (glibc 2.38 symbols at most, the host has 2.39), not installed: it runs as root from /etc/frametop, so it takes effect only after gaze/tracker/install.sh (sudo). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: upload from the headset, then plug in and leave it Export and upload are done in the headset now: the export page only notes that VR may stutter a little. Upload opens the pull request first (a draft) and shows its link, telling the person to plug in the headset and leave it until it says Uploaded; the files then go to refs/pr/N, and the pull request is marked open at the end. A retry of the same export goes on in the same pull request. While an export or upload runs, a host unit holds a logind sleep inhibitor so the Frame stays awake with the headset off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remote Access: check the status every 5 s instead of every 2 s While its window was open, Frametop Remote Access ran remote-ctl.sh status every 2 s, and each run spawns bash, curl (the tailnet name from tailscaled) and python3 to parse it. It now checks every 5 s, plus when the window comes to the front and once more 2 s after turning remote access on or off or changing the password, so a change still shows within a couple of seconds. A check doesn't start while one is still running. Doing the check in-process would duplicate remote-ctl.sh's idea of "running", which the session and the pause code share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * update-check: KWin's blur and contrast effect ids, retest hints The session now turns KWin's blur and contrast effects off by id (blurEnabled and contrastEnabled in the desktop's kwinrc), and a KWin that renamed them would quietly leave them on. The check looks for their built-in factories (KWin::blur_factory, KWin::contrast_factory) in kwin_wayland, which it already reads for --output-count, and warns if one is gone. The kwin and plasma-workspace retest hints gain the blur and the hidden autostart entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pause gesture: look the controllers up every 30 s, not every 3 s The gesture reader fetched vrserver's /input/getstate.json over HTTP every 3 seconds, the whole time the relay runs, to notice a controller's root path changing when the 3D mouse takes or gives back its hand role. It now looks them up when it connects, when a message comes from a device path it doesn't know (at most every 3 s; the device is read from the message with two string searches, not a JSON parse of all 160 a second), 1.5 s after the relay's 3D mouse connects or lets go (the relay tells it through GamePause.controllers_changed), and otherwise every 30 s. The keys test's pause stub gets the new method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Input relay: send mouse motion at most every 4 ms The relay sent the helper one "move" per SYN_REPORT, so a 1000 Hz mouse sent 1000 datagrams a second to a helper whose loop runs every 8 ms, and each one went through a dozen sscanf and strncmp tests in the helper before reaching the move handler. In a 200 ms test at 1000 Hz, 149 reports now make 45 moves with the same total. flush() on a report now sends only once 4 ms have passed since the last move; tick() sends the rest when due, and the select timeout shrinks to match. Buttons and the gaze keys still flush first, unconditionally, so a click lands where the pointer was. In the helper, "move" is now tested first in the command dispatch, and its handling is one lambda. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: ft-gaze prints and reads only the sources in use ft-gaze computed and printed all six sources for every sample: about 1.3 KB of JSON a line with our tracker (practice2), 120 KB a second through podman's stdio relay for ft-gazed to json.loads 90 times a second. It also read SteamVR's gaze action for every sample outside games (UpdateActionState and GetEyeTrackingDataRelativeToNow, two calls into vrserver, 180 a second), though with our tracker ft-gazed only uses own and mmap1. - ft-gaze takes --sources LIST (action, mmap1, mmap2, left, right, own, and eye for the EYE object; all by default, so the probe and ft-eyes-session are unchanged), and with --watch-stdin a line "sources LIST" on stdin switches them. A source left out isn't read and prints as {"ok":0} ("eye" as null), so every line keeps the same keys. An older ft-gaze ignores both, and prints everything as before. - ft-gazed asks for what it reads: own,mmap1 with our tracker; left,right,mmap1 with SteamVR's eyes; the source plus mmap1 and mmap2 on the older one-source path. While a check or the calibration runs or waits to open, all of them, since checks record every source (the calibration fits the action's correction too) and the fit check reads "eye". It switches as soon as that changes, well inside the check's 0.45 s settle. - So the action is read only during checks, or with --source action. On recorded samples, a line with own and mmap1 is about 700 bytes instead of 1,300 (practice2), and one with left, right and mmap1 about 550 instead of 940 (test1). gaze/test/idle-test.py now checks that ft-gaze starts with every source for a check and is then switched to those in use, without the action or own; all its checks pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer: don't put a vanished panel back in the visibility map The panel-edge test read visible[edgeKey], and when the last panel the cursor touched was gone from the overlay list, that added it back as hidden. The map then had more entries than there are handles, which made the 50 ms visibility poll run every frame, and since the last commit it also counted as a visibility change each time, so unchanged frames were never reused. The edge test now looks the key up without adding it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: ft-gaze's loop runs every 4 ms instead of 2 ft-gaze's loop slept 2 ms, so 500 times a second it read the head pose (GetDeviceToAbsoluteTrackingPose), checked the eye tracker's counter, and drained SteamVR's events, for samples that come 90 times a second. It now sleeps 4 ms. A new sample is printed within 4 ms of appearing, 2 on average (was 1), and the pose history still has a pose within 2 ms of any sample's time, which keeps the head-pose error under 0.2 degrees for a head turning 100 degrees a second. Sleeping until the next sample is due would have thinned the pose history to 11 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: the hidden panel waits for a command instead of waking every 50 ms The calibration panel runs for as long as the gaze service does, hidden nearly all the time, and it woke 20 to 30 times a second to look at its socket and SteamVR's events: about 0.9% of a core, the main cost left with gaze idle. It now waits in poll() on its command socket: up to a second while hidden, and up to 10 ms while shown, as before (it still drains SteamVR's events each pass, so a quit is acknowledged within a second while hidden). A command wakes it at once, so "show" draws sooner than before. With --watch-stdin, its stdin closing wakes it as well, so stopping it doesn't wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer: ft-screens announces new panels to the helper The helper now reads SteamVR's list of panels every 20 s instead of every second, so a panel made in between (a floating window's menu, frametop.float.N.sub.K, or the Frametop keyboard the first time it opens) couldn't be clicked with the mouse until the next read. ft-screens now sends "overlay <key>" to @ft_pointer_helper right after it makes one, and the helper adds it to its list at once (only frametop.* keys). An older helper ignores it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: fix Export doing nothing, and show it's busy at onceaf2ea7cput _stay_awake between exportSession and its @Slot, so the window's Export button called a method QML couldn't see. A new test checks every backend call in main.qml against Backend's slots and properties. Export and Upload now say Exporting…/Uploading… with a spinner the moment they're pressed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Lazy susan: Meta+Alt+Tab spins the panels around you - ft-screens "spin next|prev|<degrees>": every unpinned screen and floating window turns together about a vertical axis through your head (0.3 s, eased), so the next panel on the right or left comes to straight ahead; the arrangement stays as it is. Taps during a spin add to it, from where the panels are headed; grabbing a panel or placing it (ft-layout, ft-floatd) takes it out of the spin - when a spin settles, the panel in front gets the pointer (recenter), typing (as after a click), and KWin's active window: its floating window, or the top window on a screen (ft-floatd "front N", the KWin script's activate-output). KWin's outputs follow the screens' new places (ft-layout scale), as after a move - the input relay: spin_next and spin_prev actions, Meta+Alt+Tab and Meta+Alt+Shift+Tab by default; Frametop Input Settings lists them. Not Meta+Tab: that's Cmd+Tab on a Mac reached through a remote desktop like RustDesk, and the relay would take the Mac's app switcher. Meta+Alt+Tab (Cmd+Option+Tab) is unused on macOS, Windows, and KDE Used on the Frame (SteamOS 0.3.0 build 20260922) with one screen and three or four floating windows, through RustDesk to a Mac. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hand recorder: login command works from Frametop's Konsole Frametop's Konsole sets XDG_RUNTIME_DIR=/run/user/UID/frametop, where podman finds no container state, so 'distrobox enter dev -- hf auth login' failed with a crun error. The command the Upload page shows now sets the real runtime folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hand recorder: log in from the Upload page, three-step page, no terminal The Upload page is now three numbered steps: choose the export, log in to Hugging Face, upload. Log in runs hub.py login, huggingface_hub's browser login (OAuth device code, as hf auth login does): the link opens in the browser and the page shows the code to enter, with Copy code and Cancel. hub.py saves the token; the window never sees one, and nobody pastes one. The terminal upload and the login command are gone from the page, and UPLOAD.md is now a short 'About uploading' under the steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hand recorder: take.json keeps camera clock samples sets.bin's capture_ns is CLOCK_MONOTONIC_RAW; poses.jsonl and prompts.jsonl are CLOCK_MONOTONIC. On 2026-10-03 the two were 0.80 s apart during a session and 1.11 s apart five hours later, so images can't be paired with poses by capture_ns. take.json now samples RAW minus MONOTONIC as each recording part starts and stops (as ft-hands' raw_minus_mono_ns), so readers can put each exposure on the poses' clock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hand recorder export: no controller poses without controllers, nothing in deleted ranges When the checklist says no controllers, exported poses.jsonl has left and right null and feedback lines carry no controller state: controllers left switched on still get tracked (one wandered 2 m in a real session) and would read as the hands' ground truth. Poses and live-tracker feedback inside deleted ranges are left out too, as the images there are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hand recorder: final consent text (2026-10-03), residency check, installer Consent 2026-10-03, after a non-lawyer review: who runs this and how to reach them, the dataset is public (Hugging Face, possibly abroad), the Hugging Face username shows next to the contributor id, purposes (no identification), safety, the maintainer grant passes to whoever maintains Frametop next, withdrawal before and after merge, rights such as the GDPR's, and what a new version means. Residents of Illinois, Texas and Washington can't take part for now (biometric privacy laws): a third checkbox, profile consent.region_ok, checked by validate.py from this consent version on. The DRAFT banners are gone, so uploads no longer need FT_HANDREC_ALLOW_UPLOAD. hands/rec/install.sh installs the recorder on a Frame with Frametop: container packages, hand tracking and panel builds, ft-camd's capabilities, menu entry. test_qml_backend also checks each call's argument count against the slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screens: a reset button next to the grab bar, clickable in VR games Each desktop screen gets a reset button left of its bar (a reticle). It puts every screen back in its layout around where you are now, like Meta+Shift+R (ft-layout apply). In a VR game the screens leave the controllers to the game (the outside_games and dashboard modes), so a controller couldn't click any of their controls. Aiming a hand controller at the reset button now sets MakeOverlaysInteractiveIfVisible on that button's overlay alone, so the trigger clicks it; the flag clears half a second after the aim leaves a zone twice as wide, and the game gets the controllers back. The aim comes from the laser poses ft-screens already reads to show the controls. The ft-layout spawn is now RunLayout(cmd), shared with the arrange. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Click stability: 32 logical pixels by default, not 8 8 is about 0.2 degrees on a 3.4 m wide 3440-pixel screen 2 m away, so a trigger press turned into a drag unless the hand was very still. 32 (about 0.9 degrees) felt much better in the headset (2026-10-03). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screens: in games, pointing a controller at a panel turns its laser on SteamVR's own floating windows take the laser while a controller points at them in a game and give it back when it points away. Frametop's panels didn't: with the controllers left to the game (outside_games, the default, or dashboard), they couldn't be clicked without the dashboard. ft-screens now sets MakeOverlaysInteractiveIfVisible on a screen or floating window while a hand controller's laser pose meets it, its controls, or its popups (UpdateAim; curved screens hit on their cylinder), and clears it 0.3 s after the aim leaves a wider margin. A drag or a held button keeps it on. The keyboard, one overlay, uses ComputeOverlayIntersection and now follows the mode when a game starts or ends while it's open. This replaces the reset button's own aim zone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screens: find the controllers' laser tip during VR games too GetComponentStateForDevicePath with no input source handle fails for every render model component while a VR game runs (checked 2026-10-03 with a game up: all 21 components of frame_controller_right). TipOffset then fell back to the controller's pose, which aims 40 degrees above the Frame controller's laser. In games, pointing at a screen's middle missed it and pointing below it hit, so the new aim-to-laser only worked from the bottom; the controls' reveal and pin/roll aim were off the same way. GetComponentState still answers then, with the same tip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * input-relay: Add mute key as volume key Add KEY_MUTE as volume key. It will be mapped to KEY_MACRO28 and use wpctl to toggle the mute of the default audio sink. The toggle of the mute state will be done once when the key is pressed instead of continuously toggling it when it is held down. This allows the mute button on keyboards to work properly. Signed-off-by: SuperTuxii <123881249+SuperTuxii@users.noreply.github.com> * Session: bring back a taskbar saved on a screen the desktop doesn't have Plasma 6.2.5 keeps a panel on a screen number and never moves one whose number is past the screen count, so a taskbar saved on a spare output (#18, lastScreen=8 with three screens) or on a screen a smaller layout dropped stayed hidden. Before Plasma starts, session/fix-panels.py moves such a panel and its tray's containment to screen 0 (the primary), keeping its widgets, unless screen 0 already has a panel on that edge. doctor.sh checks the panels' screens, and report.sh lists them with the live outputs and panels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * keys-test: the spin bindings (Meta+Alt+Tab, Meta+Alt+Shift+Tab), not while paused Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Menu entries: own programs for Reset Screen Layout and Hide/Show Screens Reset Screen Layout and Hide/Show Screens both ran ft-layout. Steam lists entries by program, so Hide/Show launched Reset. Each gets a wrapper. From PR #17 (only this part of 9618be8; its host_command change is for the Nix packages). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screens: release a held button that can't come up on a screen Pausing, or hiding the screen a button went down on, took the laser off it mid-click; the pause gesture's second thumbstick click does that. SteamVR's laser mouse then forgot the button ("Mouse down count is 1 but states are all false"), no release came, and the catcher kept showing whenever the pressing laser was off the panels, even while paused. Being interactive, it kept the VR game's controllers from it until the desktop restarted (2026-10-04, Beat Saber). ft-screens now releases a held button when it's paused, when the screen it went down on is hidden, or, during VR games, when the pressing hand controller has held nothing for a second. GetControllerState answers overlay apps only while a game runs; outside games a hold is never cut. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Signed-off-by: SuperTuxii <123881249+SuperTuxii@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Codex <codex@localhost> Co-authored-by: CuriousJ <curious.j.tuber@gmail.com> Co-authored-by: Patrick McDavid <fusionjunky@gmail.com> Co-authored-by: SuperTuxii <123881249+SuperTuxii@users.noreply.github.com> Co-authored-by: John Murray <5672686+JRMurr@users.noreply.github.com>
231 lines
54 KiB
Markdown
231 lines
54 KiB
Markdown
# Frametop design
|
||
|
||
Why Frametop is built the way it is, and what we learned about SteamVR on the Steam Frame while building it. [reference.md](reference.md) describes the parts and how to run them. Read the SteamVR notes here before changing how the screens, the pointer, or the input relay talk to SteamVR.
|
||
|
||
## Goals
|
||
|
||
- Several desktop screens in SteamVR, each a real monitor with its own resolution and shape, placed anywhere around you.
|
||
- One physical mouse that drives a cursor through that 3D arrangement, and through the rest of SteamVR too: the dashboard, Steam, and other overlays.
|
||
- Controllers stay fully usable, and VR games aren't disturbed.
|
||
- Everything runs on the Frame itself, installed from a terminal on the headset.
|
||
|
||
## Screens
|
||
|
||
### Why our own compositor
|
||
|
||
SteamOS already shows a single-screen Plasma desktop in VR by running KWin nested inside gamescope. The first version of Frametop did the same with gamescope's `PerWindow` mode and `kwin --output-count N`, which gives each KWin output its own SteamVR overlay. It worked, but gamescope has two limits that ruled it out:
|
||
|
||
- It draws every window into one shared canvas of a single size, letterboxed, and never tells a window what size to be. So every screen had the same resolution and shape; portrait screens had to be rotated outputs on rolled panels.
|
||
- Its OpenVR backend allocates an upload buffer of exactly 1920 × 1080 × 4 bytes, so anything larger, such as 3440 × 1440, aborts it at start.
|
||
|
||
It also leaves the panels to SteamVR's dashboard, which places them and doesn't expose their position to other programs.
|
||
|
||
ft-screens replaces it. It's a small wlroots compositor that hosts the nested KWin. KWin's nested Wayland backend opens one window per output and resizes an output when the host configures its window, so ft-screens can give each screen any size, live. KWin needs `wl_compositor` v4 or later, `wl_shm`, `wl_seat`, `xdg_wm_base`, and `zwp_linux_dmabuf_v1` v4 with feedback from its host; the rest is optional.
|
||
|
||
Frames go to SteamVR the same way gamescope sends them: OpenVR's `IVRIPCResourceManagerClient` imports each DMA-BUF (`ImportDmabuf`), and the overlay shows it as a `TextureType_SharedTextureHandle` texture. There's no copy and no size limit. The Frame's SteamVR supports this interface, but the OpenVR header bundled with SteamVR's samples predates it, so the build fetches a pinned header from Valve's openvr repository.
|
||
|
||
Before settling on this, we tried KWin's screencast virtual outputs. KWin 6.2.5 crashed (`std::out_of_range` in `textureForOutput`) streaming one while nested in gamescope, and its headless backend doesn't implement virtual outputs at all.
|
||
|
||
A few wlroots details worth knowing: `wlr_shm_create` wants DRM format codes, not `wl_shm` ones, and KWin asks for server-side decorations before its first commit, when setting the mode would assert, so ft-screens answers on the first commit.
|
||
|
||
### Placing and sizing
|
||
|
||
Because the overlays are ours, ft-screens places them exactly with `SetOverlayTransformAbsolute` and draws its own controls under each screen. OpenVR has no overlay-relative transforms, so the controls are repositioned whenever their screen moves.
|
||
|
||
The curved layout chains screens edge to edge, like monitors on a desk: the middle screen (or the seam between two) straight ahead, and each neighbour hinged at the previous screen's outer edge and turned to face you. An earlier version spaced screens by angle, which assumes every screen sits on the circle; a flat 3.6 m screen's edges are much farther away than its centre, so its neighbours landed in front of it. Solving for the hinge angle needs a scan and bisection, because a simple fixed-point iteration diverges for screens nearly as wide as twice their distance.
|
||
|
||
`SetOverlayCurvature` takes the fraction of a full cylinder that the overlay's width covers, so the radius is width / (2π × curvature). The stated width is the arc length, and the cylinder bends toward the viewer: at a 2 m radius, a 3.57 m wide screen's corners sit about 75 cm closer to you and 23 cm further in than a flat screen's would. Controls on a curved screen are placed on that cylinder, and the bar gets the same curvature.
|
||
|
||
A resize handle has to be able to shrink a screen from any direction, so the dragged corner follows the laser along the screen's diagonal rather than taking the larger of its horizontal and vertical reach. Pushing and pulling a carried screen moves it along the line from your head, because the 3D mouse's virtual controller sits just in front of the bar, below the screen's centre, so the line from the device points mostly upward.
|
||
|
||
Wherever ft-screens needs to know where a laser points (showing the controls, the resize tab, the roll knob), it uses the laser's own pose, the render model's `tip` component, rather than the controller's pose. On the Frame's controllers the tip points 40° below the pose's forward axis, so rays from the pose missed what the laser was actually on. The 3D mouse's virtual controller has no tip, and its laser runs along its pose. ft-screens reads the tip with `GetComponentState`: `GetComponentStateForDevicePath` without an input source handle fails for every component while a VR game runs, so in games the rays came from the pose, 40° too high.
|
||
|
||
`ComputeOverlayIntersection` ignores `SetOverlayIntersectionMask`, and a control can't be allowed to cover part of its screen, so the resize tab sits entirely outside the corner.
|
||
|
||
### Pinning
|
||
|
||
Pinning started as "bring the screen to your wrist", which doesn't work for big screens, because their centre is far from the edge you bring close. It became aiming: while a screen is carried, the line from the carrying device to its bar is tested against the other hand controllers. Crossing a controller's 6 cm ring arms the pin (leaving past 9 cm, so it doesn't flicker), and crossing it again disarms it. The pin happens on release, with the screen's pose at that moment, so you can arm it and then turn the screen. An earlier version pinned the moment the laser touched the wrist, which left the screen at whatever angle the carrying hand had while pointing there.
|
||
|
||
A pinned screen's alpha follows the angle between its front and the direction to your head, fully visible inside the wrist angle and fading over the last 10°.
|
||
|
||
A head pin is the same pin on the headset (device index 0): the screen's transform is relative to the headset, so SteamVR keeps it rigidly in your view with no lag from us. It skips the facing rule, since a screen on your head always faces you the way it did when pinned. There's no aiming gesture for it: the line from the carrying device can't sensibly pass through your own head, and a ring in front of your face would be in the way. So it's set from Frametop Display Settings or `ft-layout`, and it pins the screen where it is. Carrying a head-pinned screen re-pins it on release, like a wrist pin, so it can be adjusted in VR.
|
||
|
||
### Named layouts
|
||
|
||
Named layouts are now profiles, which also hold which screens are hidden and which apps to open, with where their windows go. See [profiles.md](profiles.md).
|
||
|
||
A profile's screen part is the custom arrangement under a name: each screen's pose relative to your head, width, curve, and pin, but not its resolution or scale, which need a desktop restart or belong to KWin. Using one copies it into the custom arrangement, so everything that applies the layout (desktop start, Meta+Shift+R, Arrange now) works unchanged, and `active` remembers which name it came from. Saving without a name (`ft-layout capture`) clears `active`, because the screens have been placed by hand since. Layouts are kept per screen number, so one saved with a different screen count still applies: missing screens keep their last saved place or the preset's.
|
||
|
||
### Visibility and VR games
|
||
|
||
`VROverlayFlags_MakeOverlaysInteractiveIfVisible` keeps SteamVR's laser mouse on while an overlay with that flag is visible. Without it, the laser is off whenever the dashboard is closed: the first click on a panel only turns it on, and the laser turns off again as soon as it leaves every panel. With it, controllers work the screens normally, but the laser also takes the controllers away from a VR game.
|
||
|
||
`IVRApplications::GetCurrentSceneProcessId()` is 0 when no game is running (the Frame's home environment isn't a scene app) and the game's process ID while one is. ft-screens checks it twice a second, turns the flag off while a game runs, and by default hides the screens unless the dashboard is open. Flatscreen games run inside Steam's gamescope overlay and aren't scene apps, which is why "only with the dashboard open" is offered as a controller setting.
|
||
|
||
In a game, Frametop's panels work like SteamVR's own floating windows: point a controller at one and its laser comes on, point away and the game has the controllers again. ft-screens turns the flag on for a panel while a hand controller's laser pose meets it, its controls, or a floating window's popups. It finds that from the poses it already reads to show the controls, so SteamVR's laser doesn't have to be on first. Leaving takes a margin two control-sizes wide and 0.3 s, a drag or a held button keeps the flag on, and the keyboard, a single overlay, uses SteamVR's `ComputeOverlayIntersection`. The 3D mouse doesn't need any of this: it has its own laser mode.
|
||
|
||
## Floating windows
|
||
|
||
[floating-windows.md](floating-windows.md) describes the feature and its parts. Drag and drop and the clipboard only work between windows of one compositor, so a floating window stays a KWin window and gets a KWin output of its own: one of the spare outputs KWin opens after the screens, shown by ft-screens as a panel cropped to the window. What follows is how KWin 6.2.5 behaves underneath that, from its source (`src/backends/wayland/`) and from trying it on the Frametop desktop.
|
||
|
||
### KWin's nested outputs
|
||
|
||
- Disabling a nested output keeps its host window. `Output::applyChanges` only flips `enabled`, KWin stops rendering it (no more commits), and Plasma drops its desktop view. So ft-screens keeps the same toplevel, and its screen numbers stay put. Enabling the output again resumes on the same toplevel.
|
||
- Each output's host window is titled `KDE Wayland Compositor WL-<n>`, with `- Output disabled` appended while it's disabled (`WaylandOutput::updateWindowTitle`, on every `enabledChanged`). ft-screens reads the title to tell screens (`WL-0` to `WL-<SCREENS-1>`) from spares, and to see a spare turn on and off.
|
||
- A spare resized while it's disabled comes up at the new size on its first frame, so floating a window needn't blink. Outputs with gaps between them are accepted, so ft-floatd places spares apart from the screens and from each other, within Xwayland's 32767-pixel limit.
|
||
- KWin keeps a Wayland popup inside its parent's output (`XdgPopupWindow::updateRelativePlacement` uses the output's placement area), and X11 apps place their menus within the monitor. That's why a floating window's output has a margin around the window: menus and dropdowns open past the window's edges, into the margin.
|
||
- KWin makes a nested output the size it's configured to times its scale, rounded (at 1.5 it lays out 1067 × 667 on a 1600 × 1000 buffer), and gives the buffer a whole buffer scale (1.2 becomes 2). A buffer whose size isn't a multiple of that is a protocol error that disconnects KWin, so ft-floatd sizes spares in multiples of it. After a scale change, ft-floatd asks for the output's size again in the new scale's terms, or the next configure would make it the old size times the scale.
|
||
- **Virtual outputs don't work.** `createVirtualOutput` makes an output window but never adds it to the backend's `m_outputs`, so `findOutput()` returns null when the pointer enters it, and the next line dereferences it (`Q_ASSERT` is compiled out). A click on such a panel would crash KWin. This rules out virtual outputs (`stream_virtual_output`) for floating windows without a patched KWin.
|
||
|
||
### The pointer
|
||
|
||
- Pointer positions reach KWin only through motion events: the output's position in the layout plus the position on its window. When ft-screens stops sending motion, KWin's pointer stays put.
|
||
- KWin starts an interactive move on the press itself, before any motion. So ft-screens stops sending motion as soon as a press lands in a floating window's title bar (from the frame and client rectangles ft-floatd sends it), with no round trip, and carries the panel instead. KWin gets the release at the press point, and the window moves by nothing on its output.
|
||
- KWin's nested backend ignores the position in `wl_pointer.enter`, and wlroots drops a motion to the position it entered at, so the first click after crossing onto another panel landed where KWin's pointer had been. ft-screens enters one unit off.
|
||
|
||
### The KWin script
|
||
|
||
The KWin side is a script (`float/frametop-float.js`), not a C++ effect, because a script keeps working across KWin updates and an effect would have to match the host's exact KWin build. KWin scripts can call D-Bus but can't serve it, so ft-floatd's commands come back through a long poll: the script calls `NextCommand`, which answers when a command is ready, or empty after 20 seconds, under KWin's 25-second D-Bus timeout. A few things about KWin's script engine:
|
||
|
||
- `windowAdded` reports popups as windows of their own (`popupWindow` true, `transient` true) with their geometry.
|
||
- Setting `frameGeometry` applies asynchronously: the app has to answer the new size first.
|
||
- A script can't read a window's maximize mode, so the script counts a window as maximized when it fills its output's maximize area.
|
||
- `globalThis` isn't defined. `print` goes to the journal unless `QT_FORCE_STDERR_LOGGING=1`.
|
||
|
||
The title bar's float button is Frametop's own window decoration (`decoration/`), written in QML for KWin's Aurorae engine, which loads it without compiling. A C++ fork of Breeze would have to match SteamOS's exact KDecoration build. A decoration can only make the window requests KWin offers it, so the button toggles keep-below, which has no visible effect on a window alone on its own output, and the script treats keep-below as the floating flag.
|
||
|
||
### KWin's placement memory
|
||
|
||
KWin keeps each window's geometry, full screen, and maximized state for each layout of the outputs (its `PlacementTracker`, keyed by every enabled output's name and geometry). When the outputs come back to a layout it has seen, it puts every window back as it was in it. That's for plugging monitors in and out, and it does harm here. A spare output changes size after its window does, so what KWin keeps for a spare's size is the window's next size. Resizing a floating window back to an earlier size made the window and its output flip between two sizes for good (Dolphin went between 1187 and 1424 logical pixels wide, its output between 1687 and 1925). Full screen flipped the same way, and floating or docking one window could move others, even onto a spare or off one.
|
||
|
||
So the KWin script keeps where each window belongs: where ft-floatd put it, or where it went outside an output change. While KWin changes the outputs, it reports nothing to ft-floatd. Once KWin is done (`screensChanged` comes after its restore), it puts floating windows back, and the screens' windows too when only spares changed. KWin's resize request hasn't reached the app by then, so the app never sees it. A size asked for is held for a second, since an app can still answer an older request, and then the script takes the size the window has. If `screensChanged` doesn't come within 2 seconds, the script stops waiting for it.
|
||
|
||
KWin also ends an interactive move or resize whenever the outputs change. So during a resize by a floating window's edge, ft-floatd only crops the panel to the window, and resizes the output when the drag ends. The margin is the room to grow until then.
|
||
|
||
## The 3D mouse
|
||
|
||
The mouse works like the pointer on the Apple Vision Pro: a small cursor floats in the room, lands on whatever panel it meets, and acts on it like a controller's laser.
|
||
|
||
### A virtual controller
|
||
|
||
SteamVR's dashboard and every overlay it hosts are driven by the vrcompositor `lasermouse` action set: a pointer pose, left, right, and middle click, back, home, scrolling, and a system button that toggles the dashboard. So the 3D mouse is a virtual controller. The `ft_pointer` driver adds an invisible controller (its render model is a single transparent triangle) with its own controller type and default bindings for vrcompositor and the Steam client. Its `/input/a` button is bound to `lasermouse_secondary/switchlaserhand`, which moves the laser to it without clicking. `/pose/tip` didn't work for the laser, because tip is defined by a render model; `/pose/raw` does.
|
||
|
||
Driver poses are in SteamVR's raw tracking space, and client programs work in the standing universe, which on the Frame is about 1.6 m above raw. Mixing them up put the laser's origin 1.6 m above your head. The helper converts using the headset's pose in both spaces every frame.
|
||
|
||
Frametop's SteamVR clients (ft-pointer, ft-screens, ft-gaze) connect as a background app first and switch to an overlay app only once that works. `VR_Init` as an overlay app starts vrserver itself when none is running, and one started that way from the dev container never finds the headset. At a boot where the gamescope session timed out, systemd dropped `steamvr.service`'s start job, the pointer service (ordered only `After=` it) started anyway, and its vrserver made every SteamVR launch fail with `HmdNotFound`. SteamOS's health check then kept resetting the Steam client and tried to fall back to the previous OS slot. The units also say `Requisite=steamvr.service`, so they don't start at all when SteamVR's start fails.
|
||
|
||
The driver starts disconnected, because holding the right-hand role while SteamVR starts leaves the Steam UI stuck on its loading icon. It connects when the mouse is used and claims the right hand. SteamVR keeps a hand role reserved for a disconnected device that still asks for it, so the driver switches its role hint between right hand (connected) and opt-out (not connected).
|
||
|
||
### The cursor
|
||
|
||
Mouse motion turns into yaw and pitch around an anchor, the head position at the last recenter. A ray from the anchor is tested against every visible overlay with `ComputeOverlayIntersection`. On a hit, the cursor sits on that surface; otherwise it floats at `POINTER_DISTANCE`. Since the anchor isn't your current eye position, a second test runs along your line of sight to the cursor point, and anything nearer wins, so the cursor always lands on what you see under it. Overlays in `POINTER_IGNORE` are left out of both tests. A display-only panel, like a performance overlay locked to your view, has no input method, so SteamVR's laser passes through it, but `ComputeOverlayIntersection` still hits it, and the cursor stuck to it. The laser starts just before the cursor point, so an ignored panel nearer to you doesn't catch it either. Both tests ask SteamVR about every visible overlay, so a frame where nothing moved (the mouse, the anchor, the eye by more than 5 mm, which overlays show) reuses the last result, for up to 100 ms, since overlays can also move on their own. The dots' overlay settings go to SteamVR only when they change, and the pose goes to the driver, which keeps the last one, only when the laser would land 0.1 mm or more elsewhere, and at least every 100 ms.
|
||
|
||
OpenVR has no call to list other programs' overlays, so the helper runs `vrcmd --overlays` in the background. It includes hidden overlays, because a floating window's controls only appear while something hovers the window, and the cursor has to find them immediately. Each run is a shell and a new SteamVR client, about 30 ms of CPU, and it ran every second while the pointer was awake, which in gaze mode is all the time. Now it runs every 20 seconds, and at once when the pointer wakes, when the dashboard opens or closes, when a game starts or ends, and when a left click hits nothing (a panel that came up since). An overlay already on the list showing or hiding needs no new list: the helper checks the visibility of the ones it knows every 50 ms.
|
||
|
||
The laser starts partway along your line of sight to the cursor rather than at your eye. SteamVR sizes its hit dot by distance from the laser's origin, and a laser from the eye still shows a beam in each eye. Starting it close to the target makes the beam and the dot tiny, while `POINTER_ORIGIN_MARGIN` keeps the origin in front of the small window controls, which float a few centimetres in front of their panels. The helper's own white dot is the visible cursor. In empty space it's an interactive overlay that the laser lands on, so SteamVR never draws a laser into nothing.
|
||
|
||
A few overlays need special handling:
|
||
|
||
- The dashboard's dock and the floating windows' controls are scene-graph overlays with no texture (0 × 0) and a placeholder width, so `ComputeOverlayIntersection` never hits them. For those the helper tests the overlay's plane within `POINTER_SCENE_RADIUS` of its origin.
|
||
- SteamVR's Settings page is the one page `ComputeOverlayIntersection` can't find. Steam's pages, Library and the rest, are drawn in `valve.steam.gamepadui.main`, which it hits exactly. For SteamVR Settings that overlay is hidden, and the page is drawn by the dashboard's scene-graph panel, whose shape OpenVR doesn't give out, and whose transform's plane isn't the page's surface. The laser started behind the page, so most of it took no clicks (they went to a desktop screen behind it), and the page covered the dot. On that page only, the laser now starts near the eye so SteamVR's own hit test finds the page, the dot is drawn close in front of it, and the laser-catching dot sits far behind everything, invisible, with SteamVR's hit dot hidden on it. There the beam and SteamVR's hit dot look like a controller's; everywhere else nothing changes.
|
||
- Just off a panel, the cursor stays on that panel's plane within `POINTER_EDGE_REACH`, so resize margins and window controls just outside the panel are reachable.
|
||
- While the left button is held, the cursor keeps the distance it had at the press and stops re-testing collisions, so dragging past a panel's edge doesn't make it jump.
|
||
|
||
Head follow is experimental and off by default. It works, but it's only lightly tested, and the feel is mostly a matter of its settings; polishing it is left open. With it on (`POINTER_FOLLOW=1`, or a mouse button mapped to Head follow on/off), the cursor rides on a reference direction, where you were facing when your head last settled, and keeps its offset from it. The mouse can put the cursor anywhere up to `POINTER_FOLLOW_REACH` (70 degrees) from the reference, a corner of your view included. While your head stays within `POINTER_LEASH_DEG` of the reference, nothing moves on its own. Once your head has been past the leash for `POINTER_LEASH_DELAY` (0.2 s, so a glance out and back doesn't count), the reference eases to where you're facing (time constant `POINTER_LEASH_RETURN`, 0.2 s), never falling further behind than the leash, and the cursor ends up back where it was in your view. Then it waits for the leash again. Two earlier versions didn't work out. Moving the reference only while your head pulled at the end of the leash left it up to the leash off after you turned back, and getting it centred again meant overshooting with your head. Easing it toward your facing all the time moved the cursor on every small head movement. A leash of 0 makes the reference your facing direction, so the cursor is locked to your view, and mouse movement shifts it within the view. Head roll is ignored, so tilting your head doesn't swing the cursor around. While the left button is held the cursor stays put in the room, so your head can't nudge a click or a drag. When you let go, it carries on from where it is instead of jumping.
|
||
|
||
Gaze mode is experimental and off by default (`POINTER_GAZE=1`, the Gaze page of Frametop Input Settings, `gaze/ft-gazectl on`, or a mouse button or key combination mapped to Gaze pointer on/off). It's MAGIC pointing (Zhai, Morimoto and Ihde, 1999): the pointer goes where you look, and the mouse does the last bit. The gaze service (`gaze/ft-gazed`) sends the helper the corrected gaze at 90 Hz (from one eye while the tracker has lost the other), and while the gaze has the pointer, the cursor ray is that gaze from the eye. The pointer is aimed at the gaze each frame, not steered toward it, so nothing can pile up. An earlier try in the gaze probe steered the pointer with relative moves, and lost it when the pointer went idle or a controller had the laser. By default (`POINTER_GAZE_MOUSE_MOVE=held`, the Gaze page's Mouse movement switch) moving the mouse does nothing while the gaze has the pointer: it moves the pointer only while a button is held, as a correction. A bumped or drifting mouse can't pull the pointer off what you're looking at, and every mouse move is a correction, so the lessons aren't polluted by mouse moves to somewhere else (they used to be kept out by an 8 degree limit, which also dropped real corrections when the tracker was further off). With the gaze stale for a second, in a game, or with the headset off, the mouse moves the pointer as usual; with `free`, moving the mouse takes the pointer from the gaze. A left press while the gaze has the pointer isn't sent at once: the pointer stops where the gaze put it, you drag it onto what you meant with the button still down (panels only see it hover), and the release clicks there. Clicking at once clicked wherever the gaze was, often the wrong thing, before you could correct it. The drag is the correction. Snapping the pointer onto buttons and links is deferred: it needs accessibility (AT-SPI) on in the Frametop session, where it's off (no registry runs), plus app restarts, and it makes Chromium and Electron apps use more CPU. A press held still for `POINTER_GAZE_HOLD` (0.5 s) becomes a real press, so drags still work: hold, then move. The right button works the same way, with the right click on the release, and pressing it while the left press is held back starts a drag where the pointer is, like Meta+J then Meta+K. That drag lasts while either button (or key) is held, so a second right press, or a second Meta+K, is free to pan and tilt the panel being dragged; with the keyboard, the head turns it. Outside games the pointer then stays: the mouse going idle doesn't release it. A moving controller still releases it, as without gaze. Gaze mode is a mouse and keyboard feature: Steam reads the Frame controllers itself, outside SteamVR's bindings, so controller clicks at the gaze kept knocking SteamVR out of laser mode (see `docs/gaze-controllers.md`). Keyboard clicks (Meta+J, Meta+K) hold the dot still in your view while the keys are down, so the head, not the mouse, does the last bit; a quick tap clicks where the dot was at the press, since the head moves as you hit the keys. The relay hides Meta from the desktop as soon as such a combination fires, because KWin takes Meta with a mouse button as a window move or resize, which swallowed the clicks. The dot shows all the time by default. With `POINTER_GAZE_DOT=moving` it shows only while the mouse moves it (`POINTER_GAZE_SHOW`), while a press is held, and as a pulse for each click; otherwise it's transparent, so the laser still lands on it. Looking more than `POINTER_GAZE_RETAKE` (5 degrees) away from it, with the mouse still, gives it back, so small eye movements around the pointer don't pull it off what you're doing. A mouse nudge before a click whose correction is within `POINTER_GAZE_NUDGE_MAX` (55 degrees, half of what the headset shows across) is sent to the gaze service as a lesson: you were looking at where you clicked when the mouse took over, so the nudge is the eye tracker's error there. Using it is what calibrates it. A one-dot check in a panel fixed to the headset tops that up when the headset goes on, when our tracker thinks it moved, and when a correction is past that limit (the tracker is far off, so a click there isn't trusted as a lesson), and the full calibration and the headset fit check run in the same panel, so everything a user does to calibrate happens in one place in the headset; the gaze probe, a fullscreen GTK app, is the development tool. The limit was 8 degrees, which dropped every correction while our tracker was 12 off. Its dots sit at known directions from the headset, so the panel needs no screen geometry. The quick check's dot takes the gaze once it has held still, so what the tracker says doesn't have to be close for the capture to work. The full calibration's and the five-dot check's dots wait for a click while you look at the dot (a left click or Meta+J), because a steady gaze isn't always on the dot, and take the gaze held still up to the click; a right click or Meta+K stops. See `gaze/README.md` for the service, the calibration, and what was measured.
|
||
|
||
Replacing a loaded driver's files, as re-running the installer used to do, leaves SteamVR honoring the virtual controller's hand role but not its laser claim: the dashboard pointer stays unassigned until SteamVR restarts. The driver installer now leaves an unchanged driver in place.
|
||
|
||
`dashboard.laserRayWidthScale` controls the beam's width, but SteamVR only applies a change from its own settings screen or at restart, so it can't be switched per device while running.
|
||
|
||
### Handing the laser back and forth
|
||
|
||
The dashboard follows whichever device summoned it or last pressed its trigger. Frametop adds "last used wins": moving a real controller releases the pointer, and the next mouse movement takes the laser back. Moving means faster than 0.35 m/s or 2 rad/s (both times `POINTER_CONTROLLER_PICKUP`, 1 by default) for 100 ms in a row, while the controller is tracked normally. A single sample over the limit used to be enough, and controllers resting on a desk took the laser back on a knock or a tracking jump while the mouse was in use. Small movements don't count; waking needs `POINTER_WAKE_COUNTS` of mouse motion within a second, so desk jitter doesn't steal the laser. While the pointer is awake, a tiny transparent overlay with `MakeOverlaysInteractiveIfVisible` keeps SteamVR's laser mouse on, since otherwise the first click would only switch the laser on.
|
||
|
||
When the headset comes off, SteamVR reports its activity level as idle at once and turns the displays off 5 seconds later (`power.turnOffScreensTimeout`), unless something keeps it awake. An awake pointer did, and so did the helper's `vrcmd` runs: each is a new SteamVR client, and a new client every second kept SteamVR out of standby. The helper now releases the pointer as soon as the headset is idle, ignores the mouse until you're wearing it again, and pauses the overlay list whenever the pointer is off. With the pointer off it also stops running its loop every 8 ms, about 116 wakeups a second for nothing: it waits up to 250 ms for a command on its socket (20 ms while it reads mapped controller buttons, which SteamVR input only offers by polling), and leaves the overlay lookups until the pointer wakes.
|
||
|
||
### Moving floating windows
|
||
|
||
For SteamVR's own floating windows, the dashboard does the moving. A press on a window's grab bar, 7.5 cm below its bottom edge, parents the window to the pressing device with the relative transform at the press. The scroll wheel pushes it along its normal in steps of about 7 cm. The dashboard finishes the move up to 150 ms after the release and reads the device's pose again then, so the helper holds the drag pose for half a second after the button comes up. Tilting works by rotating the virtual controller around the grab point while both buttons are held.
|
||
|
||
## Input relay
|
||
|
||
SteamVR opens every input device only when it starts. When a Bluetooth mouse sleeps and reconnects, it gets new device nodes, and SteamVR keeps holding the old, deleted ones, so the mouse stops working until SteamVR restarts. The relay creates permanent virtual devices through uinput before SteamVR starts and forwards the real devices' events into them. systemd keeps the virtual devices' file descriptors across relay restarts, so SteamVR never sees them disappear.
|
||
|
||
Keyboards aren't grabbed by default, because a grabbed keyboard's keys went into a virtual keyboard nothing typed from; the relay forwards them to ft-screens instead.
|
||
|
||
The relay never waits on the pointer helper. Its socket to the helper used to block, so when the helper stalled (a layout placement or `grabprobe` holds it for seconds, and the gaze service fills its socket 90 times a second meanwhile), the whole relay stopped with it: keyboards, the volume keys, and pausing. Now what the helper doesn't take waits in order and goes out on the next loops. Mouse moves add up into one while they wait, and a scroll notch is dropped, since scrolling seconds late is no use; presses and releases are kept, so no button stays down. Mouse motion goes to the helper at most every 4 ms, rather than once per report, which from a 1000 Hz mouse was 1000 datagrams a second to a helper that runs every 8 ms; a button sends the motion before it first, so the click lands where the pointer was.
|
||
|
||
An ungrabbed keyboard reaches both sides at once. In VR, gamescope reads every input device itself (the SteamOS build's `InputStealer`, libinput with udev hotplug, so new devices too) and types into its focused app, and ft-screens types the same keys into the desktop. So Space in the desktop also paused Spotify on the dashboard. Typing now follows the last click. ft-screens sees clicks on its own screens, from the mouse or a controller. A click anywhere else is only visible for the mouse: overlay apps get SteamVR's `OverlayFocusChanged` (which panel the laser is on) but no controller button events, so the pointer helper reports the panel under the dot on each left press. ft-screens tells the relay where typing goes every second, from an unbound socket so the relay's replies can't loop back into its control socket, and the relay grabs pass-through keyboards while it's the desktop. A grab waits until the keyboard has no key down, so no key stays held on either side, and the relay lets go if ft-screens stops reporting. A program that reads every keyboard for a hotkey (a dictation tool, say) loses a grabbed keyboard. Repeating the keys on another input device doesn't work: gamescope reads that device too, whether it's the relay's virtual keyboard or one created later, and every Space, typed or dictated, paused Spotify again. So with `SHARE_KEYS=1` the relay sends a grabbed keyboard's keys to `@frametop_keys` as datagrams (`key <code> <value> <device name>`). It's off by default, because the relay can't tell who is listening: abstract sockets have no permissions, and any local process that binds the name first gets every key typed into the desktop, passwords included. A listener should accept only its own user (`SO_PASSCRED`) and skip any keyboard of its own that the relay grabs too.
|
||
|
||
Volume keys must never reach gamescope. With the openvr backend, gamescope sends volume up and down to Steam by moving keyboard focus to Steam for the key and then back to the previously focused surface. When nothing had focus, the one it moves back to is null, and wlroots aborts on a null focus surface (`wlr_seat_keyboard_notify_enter: Assertion 'surface' failed`), which ends the whole VR session. Keyboard focus is often empty while you work in VR, so one press of the headset's volume button could take everything down. gamescope reads the headset's buttons and every keyboard itself (`InputStealer`), as do SteamVR's processes, so the relay has to stop volume keys at the device. Grabbing `gpio-keys` would also take the headset's click button, so the relay remaps the volume entries in each device's keymap (`EVIOCSKEYCODE`) and handles the stand-in codes itself. That fix covers every device at once, including keyboards that aren't grabbed.
|
||
|
||
Frametop's keyboard opens by itself for a text field on the desktop. The apps run inside the nested KWin, so only KWin knows when a text field has focus, and the way it tells anyone is its input method protocol (`zwp_input_method_v1`): KWin starts one input method program and activates it whenever the focused app turns on text input. `input/ft-textinput` is that program, speaking the Wayland wire protocol directly so it needs nothing but Python on the host. It only reports focus. The gamescope session puts `QT_IM_MODULE=xim` and `GTK_IM_MODULE=xim` in the systemd user environment; with those, Qt and GTK apps use X input methods and never turn on Wayland text input, so the session script drops them.
|
||
|
||
The keyboard itself is ft-screens' own panel (`screens/keyboard.cpp`). We tried SteamVR's first (`ShowKeyboardForOverlay`), and on the Frame it doesn't fit a desktop. It's Steam's own panel (`valve.steam.gamepadui.keyboard`), which SteamVR mounts in the dashboard's scene, so with the dashboard closed it opened but wasn't drawn. Placing it in the room ourselves (`SetKeyboardTransformAbsolute`) made it show, but SteamVR moves it to whichever overlay the laser goes to and mounts it again, and while it's open the controllers switch to SteamVR's own laser. Our panel is an overlay like the screens' controls: any laser or the 3D mouse clicks it, nothing moves it, and its keys go out as key presses on ft-screens' seat rather than as text handed back to the input method. So nothing typed leaves ft-screens (a socket to the input method could be claimed by any local process, like `@frametop_keys`), apps without text input (X11, Electron) take the keys too, and they mean what the desktop's keyboard layout says. It's drawn on the CPU and uploaded with `SetOverlayRaw` when a key's look changes; the labels come from stb_truetype, so the container needs no text rendering stack.
|
||
|
||
The Frame controllers can be mapped like mouse buttons, but they aren't input devices on the host: they reach SteamVR over the headset's own radio, and no evdev or hidraw node exists for them. So only a SteamVR client can read them. Overlay apps normally get controller input only while they have input focus, which a background helper never has. SteamVR's experimental global action set priority (`steamvr/globalActionSetPriority`, "Enable global input from overlays") lets an overlay's action set receive input anyway, and takes the inputs it binds from the scene app. Binding every button would take them all from games, so the helper's action manifest puts each button in an action set of its own, and it activates only the sets of mapped buttons. The mapping itself stays in the relay, which does the action, so mice and controllers share one list of actions.
|
||
|
||
## The desktop session
|
||
|
||
The session is modeled on SteamOS's `steamos-nested-desktop` and runs beside it. It has its own runtime directory, config (`~/.config/frametop`), and state, so it never disturbs the stock desktop's layout or panels. It runs on a private D-Bus from `dbus-run-session`, which has two consequences. KDE only launches apps in systemd scopes when systemd is on the session bus, so everything started in the desktop lands in its systemd unit, and stopping the unit would kill all of it; `session/keep-apps.sh` moves those programs out first. And tools that need the real user bus, like podman and `distrobox-host-exec`, have to be pointed at it explicitly.
|
||
|
||
The VR launcher starts the session from the Steam client, and the client's environment came along: `LD_LIBRARY_PATH` pointing at Steam's own runtime, whose `libavcodec` has no H.264 decoder, so VLC in the desktop couldn't play most videos, plus the client's overlay and launch settings. The session script drops the client's variables before it starts anything. SteamOS's global Mesa settings (`/usr/share/deckard/mesavars.sh`) stay, and the gamescope session's Vulkan layer (`ENABLE_GAMESCOPE_WSI`) is only kept for the gamescope backend.
|
||
|
||
Steam, not systemd, suspends the Frame: after `system_idle_suspend_ac_sec` (an hour by default) without input on AC power, it logs `Switching to power state: k_ESystemPowerState_Sleep` and suspends, even while charging. It's a Steam setting (Settings → Power → When Plugged In and Idle → Sleep after), which the Stay awake while plugged in switch in Frametop Display Settings sets to Never. SteamVR's standby, which turns the displays off when the headset comes off, is separate; see below.
|
||
|
||
Flatpak apps need `XDG_DATA_DIRS` to include Flatpak's exports, or Plasma opens Discover instead of launching them, so the session sources `/etc/profile.d/flatpak.sh`.
|
||
|
||
The private runtime directory also moves the session's document portal to `$XDG_RUNTIME_DIR/frametop/doc`, and that broke saving and uploading in Flatpak apps. The file picker (xdg-desktop-portal 1.18.4 on SteamOS) gives a sandboxed app the host path of the file it picked, `/run/user/1000/frametop/doc/ID/NAME`. Inside the sandbox the portal is at `/run/flatpak/doc`, and `/run/user/1000` is a private per-app folder (`.flatpak/APP/xdg-run` in the runtime directory). So Brave created the missing folder there, "finished" the download into it, and the file vanished when the session cleaned up. The session script now links that path to `/run/flatpak/doc` in each installed app's folder before Plasma starts. Upstream xdg-desktop-portal fixed this after 1.22.1 (commit `69ba5e1`) by handing Flatpak apps `/run/flatpak/doc` paths, after which the links go unused.
|
||
|
||
A podman container's monitor process (conmon) stays in the cgroup of whatever started the container, and `distrobox enter` starts it on demand. When a Frametop service happened to start the `dev` container, stopping that service stopped the container and everything in it, including the desktop's compositor. `scripts/container-up.sh` starts the container in a systemd scope of its own before anything enters it. It then waits for distrobox-init to log `container_setup_done`, as `distrobox enter` does only for containers it starts itself. A new container's first start takes a minute or more (it installs distrobox's dependencies and sets up passwordless sudo), and an install that entered right away met a sudo password prompt with no terminal to answer it ([#9](https://github.com/DeeJanuz/frametop/issues/9)).
|
||
|
||
KWin renders with OpenGL through zink on Turnip, Vulkan on the same GPU vrcompositor needs to hit its frame time, and on the Frame that costs CPU too. The nested session started with KWin's defaults: blur and background contrast on (no `[Plugins]` group in its kwinrc) and animations at full length. Blur re-renders what's behind every translucent panel and menu each time it changes, and every animated frame is one more frame for KWin and ft-screens to draw and send. They're off by default in the Frametop desktop. The session script writes them before KWin starts, only where the desktop's own config has no value, once: System Settings deletes a setting put back to KDE's default rather than writing it, so without the marker in `frametoprc` a user who turned blur back on would lose it at the next start. The effect ids (`blur`, `contrast`) are the ones built into KWin 6.2.5 on SteamOS; KWin reads `<id>Enabled` from `[Plugins]`.
|
||
|
||
The nested session also runs the system's XDG autostart entries, being a KDE session. Discover's update notifier started `plasma-discover --mode update` in it (520 to 620 MB resident and about 9% of a core, plus `flatpak-system-helper` and AppStream downloads), and IBus started a daemon, the kimpanel panel and its GTK extension that nothing can use: KWin hands text input to the one input method it starts (`ft-textinput`), and the session drops `QT_IM_MODULE`, `GTK_IM_MODULE` and `XMODIFIERS`. The session hides both for this desktop only, with `Hidden=true` copies in its own autostart folder. The geoclue demo agent stays: it's what answers apps' location requests to Geoclue outside GNOME, and it costs nothing while idle. Orca's entry only starts in GNOME-family desktops.
|
||
|
||
Plasma 6.2.5 keeps each panel on a screen number (`lastScreen` in `plasma-org.kde.plasma.desktop-appletsrc`), and the numbers rank the enabled outputs by priority, so 0 is the primary screen. A panel whose number is past the screen count gets no view, and Plasma never moves it: the remap it runs at every start only moves a panel whose number has no desktop, and this desktop keeps a desktop for every output it has seen, spares included. So the taskbar was lost when the number of screens went down, and once it was found saved on a spare output, number 8 of a desktop with three screens ([#18](https://github.com/DeeJanuz/frametop/issues/18)). Before Plasma starts, the session runs `session/fix-panels.py`, which moves any panel numbered past the screen count, with its system tray's containment, to screen 0, keeping its widgets and settings. A panel stays put when screen 0 already has one on that edge, and comes back by itself if the screens do. The file is backed up to `<file>.ft-bak` first. Plasma's scripting can't do this while it runs (`panel.screen` is read-only in 6.2.5), so a lost taskbar comes back at the desktop's next start. `scripts/doctor.sh` and `scripts/report.sh` list the panels and their screens.
|
||
|
||
Remote desktop is a chain (krdp, then FreeRDP inside Xvnc) because nothing on SteamOS serves KWin over VNC directly. Kept connected all the time, it cost about a core with nobody watching: krdpserver 55 to 78% (it encodes H.264 in software with openh264: VA-API finds no driver for the Frame's GPU in the container), FreeRDP 16 to 27%, Xvnc 6 to 11%, and the bridge's layout check every 5 seconds another 4%. krdp creates its screencast session per RDP connection and drops it when the connection closes (`SessionController::onNewConnection` in krdp 6.7), so an idle krdpserver costs nothing and can stay up; only the RDP connection has to go. The bridge connects FreeRDP when a VNC client appears and disconnects 45 seconds after the last one leaves. Xvnc has no hook for its clients, so the bridge counts established connections to its port with `ss`, woken early by Xvnc's log output; looking with `ss` once a second cost about 0.9% of a core in bash, against about 0.1% this way. `Xvnc -inetd` from a systemd socket would start a server per connection and lose sharing between viewers. The layout check (`ft-layout remote-view`, which scans `/proc` for plasmashell and runs `kscreen-doctor -j`) now runs only while FreeRDP runs, and then only after `kwinoutputconfig.json` or `frametop-layout.json` changes, with one check a minute in case a change touched neither.
|
||
|
||
Program names stay within 15 characters, because Linux truncates process names there and the scripts find programs with `pgrep -x` and `pkill -x`. That's why the prefix is `ft-`.
|
||
|
||
## Displays off on a stand
|
||
|
||
SteamVR decides the headset is off from its proximity sensor, which the driver reads through the DSP, and turns the displays off 5 seconds later. On a display mount that covered the sensor, that never happened: SteamVR kept the headset in use all night (no `entering standby` for device 0 in vrserver.txt, and XRService's user presence stayed at 1), and Steam didn't sleep either, because its idle count treats a present user as active. The battery went from 100% to 12% overnight on a 5 V, 3 A charger, with the headset drawing about 17 W.
|
||
|
||
There's no client call that puts the headset in standby. The cv driver's `teststandby` debug request (`IVRDebug::DriverDebugRequest`) only answers "Standby unknown hmd" on the Frame. But what the driver does for the displays in standby is write `/sys/class/backlight/ae94000.dsi.0/brightness` ("cv: Set displays off" writes 0, "Set displays on" the old value), and the `video` group can write that file, from the container too. So `ft-powerd` goes by use instead of the sensor and turns the backlight off itself. Tracking and rendering keep running. Turning the backlight off moved the battery current by only about 75 mA (0.5 W), so they're most of the load, but they're also why the displays can wake the moment the headset moves.
|
||
|
||
Movement is judged within 10-second windows. On the mount, the head pose jittered within 0.5 mm and 0.1 degrees over 20 seconds, and its position drifted 1.7 mm (0.16 degrees) in 4 minutes. Compared with a fixed reference, that drift would count as movement sooner or later and keep the displays on; within 10 seconds it never reaches the 5 mm and 0.5 degree thresholds, and anyone wearing the headset passes them now and then.
|
||
|
||
Staying awake while charging uses Steam's own setting rather than a logind sleep inhibitor. Steam suspends with `dbus-send ... login1.Manager.Suspend boolean:true`, and a block inhibitor does stop that (`CanSuspend` answers "challenge" while one is held), but it stops the power button too. `system_idle_suspend_ac_sec` is field 24004 of Steam's CMsgClientSettings. In Steam's SharedJSContext, reachable over CDP on port 8080 because Steam runs with `-cef-enable-debugging`, `SteamClient.Settings.SetSetting` takes a change as a base64 protobuf, the way Steam's Power page sends it (0 is never), and `settingsStore.clientSettings` has the current values.
|
||
|
||
## Pausing for VR games
|
||
|
||
Hiding the screens during a game kept them out of view, but Frametop kept using the headset. Measured on 2026-10-02 with gaze mode off and no game running, in shares of one core: our eye tracker (ft-eyes) about 60%, ft-eyegrab, ft-gaze and ft-gazed about 3 to 4% each; remote desktop (krdpserver, FreeRDP, Xvnc) about 2 cores while it ran; KWin about 13%, ft-screens about 4%. The gaze service ran at full rate whether gaze mode was on or not; now it idles while the gaze isn't used (gaze/README.md), and pausing stops it outright. Reading SteamVR's eye tracking 90 times a second also made it restart every 10 to 13 seconds during Beat Saber, and each restart took input focus from the game, which paused it (PR #13; since then ft-gaze skips SteamVR's gaze action during games, but our own tracker kept running). So pausing stops what costs the most and leaves windows where they are.
|
||
|
||
- A hidden screen still cost as much as a visible one. ft-screens sent every committed screen its frame callback at 90 Hz whether its overlay showed or not (since then, a hidden screen always gets one a second; see the frame rates in reference.md), so KWin kept drawing, and its apps with it. Paused, ft-screens sends the callbacks once a second. A Wayland client draws again only after its last frame's callback, so KWin's output stalls, KWin's own clients stop getting theirs, and the whole desktop idles, without anything losing its connection. A second's pace, rather than none, keeps any client that waits on a callback from waiting forever. Stopping KWin or the apps with SIGSTOP would free the same, but a Wayland peer that stops reading overflows the other side's 4 KB socket buffer, which ends the connection: that's how the live desktop died once when its KWin stalled (`Data too big for buffer`). They also sit in different cgroups (KWin under steam.service when the VR launcher starts it, ft-screens in the dev container's), so no single freeze stops them together.
|
||
- The relay does the pausing because it's the one part that always runs, and the pointer helper keeps running because stopping it leaves its virtual controller connected with its last pose (the driver has no staleness timeout), maybe holding a hand role, with the 3D mouse dead. Releasing it does the job. The helper already checks for a scene app twice a second, so it's what tells the relay a game started.
|
||
- The gesture has to work during a game, but SteamVR input reaches only the app with input focus, and an overlay with global input (`steamvr/globalActionSetPriority`) takes the buttons it binds from the game. vrserver's web socket on 127.0.0.1:27062, which its controller binding page uses for the live view, reports every controller component whatever has focus, and reading it takes nothing. The game sees the clicks too, so the default is a gesture games hardly use: both thumbsticks, together, twice. "Together" means within 0.3 seconds of each other, so a stick held down to sprint while the other clicks doesn't count. The stream is about 160 messages a second, nearly all capacitive sensing, so the reader parses only the few that mention a gesture's button. A controller's root path changes while the 3D mouse holds its hand role (`/devices/cv/<serial>` instead of `/user/hand/right`), so the reader looks the controllers up again (an HTTP request to vrserver): when the relay's 3D mouse connects or lets go, when a message comes from a device it doesn't know, and every 30 seconds. It used to be every 3 seconds.
|
||
- Resuming starts remote desktop through `systemd-run --scope`: started straight from the relay, it would join the relay's cgroup and end with the next relay restart.
|
||
|
||
## SteamOS updates
|
||
|
||
On the Frame, SteamVR is part of the OS image (`/opt/steamvr`, the `deckard-steamvr-rel` package), next to KWin, gamescope, and the kernel, so every SteamOS update can bring a new SteamVR too. Frametop survives updates: it lives in the home folder and the `dev` container, the Bluetooth fixes are in `/etc`, which SteamOS keeps across updates, and nothing goes into `/usr`. What an update can break is what Frametop uses from the image. The public OpenVR API is versioned and stays put. The rest is less certain: `IVRIPCResourceManagerClient`, which is newer than the header SteamVR ships; the text `vrcmd --overlays` prints; the eye tracker's shared memory layout; XRService's camera buffers; KWin's nested backend; and behavior Frametop works around, such as the SteamVR Settings page that `ComputeOverlayIntersection` can't find or the scale KWin's nested backend doesn't undo.
|
||
|
||
`scripts/update-check.py`, which `scripts/doctor.sh` runs, checks what it can directly: that SteamVR still serves every OpenVR interface version the installed programs were built against (read from the binaries), that `vrcmd`'s format still parses, that the eye tracker's sample timestamp is still at the offset ft-gaze reads, and the host files, services, sockets, and driver registration. Behavior can't be checked without someone in the headset, so it records the versions of the packages that matter once things work (`--mark-good`), and after an update names what changed and what to try by hand.
|
||
|
||
## Approaches we dropped
|
||
|
||
- WayVR, an existing Wayland desktop for VR. It built and connected to SteamVR on the Frame, but nothing showed in the headset. It has no bindings for the Frame's controllers, and its KDE screen capture needs `xdg-desktop-portal-kde`, which SteamOS doesn't ship.
|
||
- gamescope in `PerWindow` mode, for the reasons above. Frametop still supports it as `BACKEND=gamescope`. In that mode SteamVR's dashboard owns the panels, so `ft-layout` floats each one with `vrcmd --dock-overlay` and the pointer helper carries it into place with the virtual controller, hovering first and then sliding at 0.5 m/s, because the dashboard exaggerates fast movements.
|
||
- A capture pipeline from a headless KWin through KWin's screencast protocol. It's workable, but ft-screens gets the frames directly with less code.
|
||
|
||
## Open questions
|
||
|
||
- A controller button that shows the screens during a game. Games own the controllers, so this needs SteamVR input actions for ft-screens.
|
||
- Drawing KWin's cursor on the screens.
|
||
- Frame pacing and GPU cost with several busy screens haven't been measured.
|
||
- Real standby on a stand, with rendering and tracking paused, not just the backlight off. SteamVR has no call for it, and its activity level follows the proximity sensor.
|