WIP: markerless full-body pose input (deck-pose daemon + deck-slice game) #915

Draft
lytedev wants to merge 29 commits from deck-pose-wip into main
Owner

WIP — not for merge. Consolidates the whole markerless-pose-input program into one branch so it can be reviewed and shipped as a chunk rather than as seven stacked PRs. Supersedes #906, #907, #908, #909, #910, #911 and #913, all now closed.

15 commits, deliberately not squashed — the messages carry the root-cause writeups (the unlink-a-live-socket mechanism, the dt-collapse analysis, the One Euro unit error) that a squash would destroy.

Nothing is deployed and nothing is enabled on any host.

What this is

Camera-only full-body tracking as a game input platform for the steamdeck, modelled on the Nex Playground: a webcam, no controllers, up to six people. A daemon owns the camera and the model and publishes over Unix sockets; games are separate processes that link no ML runtime. Plus one game, deck-slice, that proves the seam works.

  • Daemon: packages/rust/deck-pose/ — docs lib/doc/deck-pose.md
  • Game: packages/games/deck-slice/ — docs lib/doc/deck-slice.md
  • Plan and open questions: issues/open/deck-pose.md
  • NixOS module: lib/modules/nixos/deck-pose.nix, wired into steamdeck but disabled

The stack, in order

  1. Daemon spike — v4l2 capture → MoveNet MultiPose → IoU tracking → One Euro smoothing → debug overlay with live latency instrumentation.
  2. NixOS module + steamdeck wiring, off by default.
  3. Gesture recognition + Unix socket publisher — jump, squat, punch, swipe, dwell; NDJSON pose stream and events.
  4. Slice game — Godot 4, reads the socket via StreamPeerUDS.
  5. Spatial framing — letterboxed play rect, self-view skeleton, edge cues.
  6. Cut threshold made body-relative and forgiving.
  7. Two speed-measurement bug fixes and the camera-feed background.

Findings worth keeping

ONNX Runtime, not TFLite. TFLite was the intended runtime and is not reachable here: nixpkgs.tensorflow-lite is marked broken, and every Rust binding either builds TensorFlow with bazel or downloads a prebuilt runtime in a build script — neither survives the nix sandbox. Consumed as an ONNX re-export of the same Apache-2.0 MoveNet weights, pinned by commit and hash. Ultralytics YOLO-pose was rejected on licensing (AGPL-3.0).

int8 is ~12x SLOWER than float32, measured, not assumed — 100.4ms vs 8.4ms at 256x256. ORT's CPU kernels leave dynamic-quantisation QDQ nodes unfused. Quantisation only pays when the runtime has kernels for it.

The One Euro defaults were on the wrong scale. The paper's beta = 0.007 is tuned for pixel-valued mouse input; these keypoints are normalised 0..1, so the adaptive term contributed ~0.02Hz against a 1.0Hz baseline and the filter collapsed into a fixed 1Hz low-pass. The skeleton visibly lerped. Regression-tested.

A daemon that unlinks a live socket lies about its client count. Unlinking does not disturb the process listening on it: the first daemon stays up, never accepts another client, and reports zero forever while a second serves everyone. Ownership is now an flock sidecar, and the telemetry reports published beside clients so the two can never again be reconciled only in hindsight.

Hand speed was fabricated two different ways. (a) Measured against the game's receive time, where batched poses share a millisecond and dt collapses to zero — a gentle wave read as 4 body-heights/sec and sliced. (b) A teleported keypoint at an honest 16.7ms interval — MoveNet swapping wrists as arms cross, occlusion reacquisition, tracker identity swaps — computing ~94 body-heights/sec and slicing. Both fed is_cutting(), so both caused phantom slices. Fixed by timing against the daemon's capture clock and rejecting physically impossible samples.

Video is forwarded, never re-encoded. There is no cheap downscale of a JPEG: any pixel change means decode + scale + encode on the latency-critical path. Resolution is chosen at the camera, where it is free.

Measured (dragon, OBSBOT at 1280x720 MJPEG 60fps)

pipeline 60fps sustained, 0 frames dropped
in-process latency P50 10.4ms / P95 10.7ms (budget 80ms)
inference 7.7ms
gesture recognition + publish 0.02ms/frame
video forwarding, when active +0.7ms
game JPEG decode (720p) 4–7ms, game holds 120fps
peak real hand speed 13.89 body-heights/sec (cut threshold 0.8)

The latency figure is dequeue→present-returned. It excludes sensor exposure and present-to-photons, both real and neither visible from inside the process — so it is not glass-to-glass.

Verified

nix build green for .#deck-pose and .#deck-slice. 50 daemon tests and 13 Godot framing checks, all sandbox-safe (no camera, network, tzdata). Daniel has play-tested the game live on a real camera and scored steadily.

Untested / open

  • Nothing has ever run on the steamdeck. All numbers are from a desktop. Van Gogh is 4-core Zen 2 at handheld power, and a handheld doing inference and a game and a 30fps JPEG decode on four cores is a different proposition.
  • The camera is unchosen — wants wide FOV, 60fps, MJPEG, ideally global shutter. Several candidates are monochrome, which is handled but untested for accuracy.
  • Gamescope is unattempted. What Game Mode needs is written down in lib/doc/deck-slice.md, not solved.
  • Only ever one player and one client. Multi-player and multi-client paths are written and unit-tested but never exercised for real.
  • Nobody has seen the camera feed background. Dim level and skeleton alpha are guesses.
  • Real glass-to-glass has never been measured — needs a physical high-speed capture.

Camera lifecycle

Socket-activated, not always-on. systemd holds the listening sockets; the first connection starts the daemon; an idle timeout exits it when the last client leaves, releasing the camera. Always-on was the first design and was wrong: it would have held the camera open — LED on, watching the room — and run inference on a handheld's battery for the whole session, benefiting nobody while nobody plays. It also reintroduced by the back door exactly the exposure that made --publish-video opt-in.

Verified on dragon: daemon opened the camera, exited 4s after the last client disconnected, and fuser /dev/video0 afterwards reported no holder.

The two modes differ in who owns the socket path, which is modelled explicitly rather than inferred. Self-bound we clean up and guard with an flock; activated, systemd owns the path — unlinking it on idle exit would make activation work exactly once, with every later connection finding nothing listening.

Hotplug is what makes activation coherent rather than merely deferred: a daemon started by a connection still waits patiently when no camera is plugged in, and survives a mid-session unplug.

Two findings worth keeping

Probing beats reading the capability flag, and the flag genuinely cannot do the job. VIDIOC_QUERYCAP reports capabilities — the union across every node the device owns — alongside per-node device_caps, and the v4l crate exposes only the union. So /dev/video0 (video capture) and /dev/video1 (metadata capture) both claim VIDEO_CAPTURE and the flag cannot distinguish them. Negotiating a capture format can: the metadata node refuses. That is a test whose success implies the thing we actually care about, rather than one that correlates with it. This trap had bitten the project twice.

Polling beats a udev monitor here, for the same reason. A video4linux add event names a node but not which sibling it is — the metadata node fires an identical event — so the probe decides either way and udev could only ever be a hint to probe sooner. Polling on the existing reopen backoff does the same work with no libudev in the closure and no extra descriptor to poll, which is a real benefit on a handheld. Removal is noticed immediately regardless, because the capture thread starts erroring on dequeue. If the 2s worst case ever proves too slow, udev drops in to trigger the probe early rather than replacing anything.

Known upstream defects — filed, not fixed

Written up in issues/open/deck-pose-phantom-gestures.md (included in this branch) rather than left in a PR description, since that is not somewhere a defect survives.

The daemon emits phantom gestures on tracking discontinuities, today. It computes gesture speeds from raw keypoint deltas with no plausibility check, so a jumping keypoint produces a fictional speed that clears every threshold and publishes swipe/punch events to every consumer. Checking the code rather than trusting the original report turned up two corrections: jump is exposed too (recognise.rs:270, threshold 1.2), and body_scale — the denominator of all of them — is the vertical span alone floored at 0.05, so a lean or a turn foreshortens it toward zero. Conversely the daemon does not share the interval bug fixed game-side; it times off capture Instants with nanosecond resolution. Recorded so nobody re-fixes that.

PlayerId does not keep its promise. It is documented as stable across occlusion and never recycled, and games are told to bind to it, but association is bbox IoU alone — which cannot separate two people crossing, since that is exactly when boxes overlap and only the poses differ. Adding keypoint similarity (OKS) to the cost function in track.rs is the fix.

The game-side guard in this branch protects the slice game's cut test only. It does nothing for the events the daemon publishes, and nothing for the next game.

**WIP — not for merge.** Consolidates the whole markerless-pose-input program into one branch so it can be reviewed and shipped as a chunk rather than as seven stacked PRs. Supersedes #906, #907, #908, #909, #910, #911 and #913, all now closed. **15 commits, deliberately not squashed** — the messages carry the root-cause writeups (the unlink-a-live-socket mechanism, the dt-collapse analysis, the One Euro unit error) that a squash would destroy. **Nothing is deployed and nothing is enabled on any host.** ## What this is Camera-only full-body tracking as a game input platform for the `steamdeck`, modelled on the Nex Playground: a webcam, no controllers, up to six people. A **daemon** owns the camera and the model and publishes over Unix sockets; **games are separate processes** that link no ML runtime. Plus one game, `deck-slice`, that proves the seam works. - Daemon: `packages/rust/deck-pose/` — docs `lib/doc/deck-pose.md` - Game: `packages/games/deck-slice/` — docs `lib/doc/deck-slice.md` - Plan and open questions: `issues/open/deck-pose.md` - NixOS module: `lib/modules/nixos/deck-pose.nix`, wired into `steamdeck` but **disabled** ## The stack, in order 1. **Daemon spike** — v4l2 capture → MoveNet MultiPose → IoU tracking → One Euro smoothing → debug overlay with live latency instrumentation. 2. **NixOS module** + steamdeck wiring, off by default. 3. **Gesture recognition + Unix socket publisher** — jump, squat, punch, swipe, dwell; NDJSON pose stream and events. 4. **Slice game** — Godot 4, reads the socket via `StreamPeerUDS`. 5. **Spatial framing** — letterboxed play rect, self-view skeleton, edge cues. 6. **Cut threshold** made body-relative and forgiving. 7. **Two speed-measurement bug fixes** and the camera-feed background. ## Findings worth keeping **ONNX Runtime, not TFLite.** TFLite was the intended runtime and is not reachable here: `nixpkgs.tensorflow-lite` is marked broken, and every Rust binding either builds TensorFlow with bazel or downloads a prebuilt runtime in a build script — neither survives the nix sandbox. Consumed as an ONNX re-export of the same Apache-2.0 MoveNet weights, pinned by commit and hash. Ultralytics YOLO-pose was rejected on licensing (AGPL-3.0). **int8 is ~12x SLOWER than float32**, measured, not assumed — 100.4ms vs 8.4ms at 256x256. ORT's CPU kernels leave dynamic-quantisation QDQ nodes unfused. Quantisation only pays when the runtime has kernels for it. **The One Euro defaults were on the wrong scale.** The paper's `beta = 0.007` is tuned for pixel-valued mouse input; these keypoints are normalised 0..1, so the adaptive term contributed ~0.02Hz against a 1.0Hz baseline and the filter collapsed into a fixed 1Hz low-pass. The skeleton visibly lerped. Regression-tested. **A daemon that unlinks a live socket lies about its client count.** Unlinking does not disturb the process listening on it: the first daemon stays up, never accepts another client, and reports zero forever while a second serves everyone. Ownership is now an `flock` sidecar, and the telemetry reports `published` beside `clients` so the two can never again be reconciled only in hindsight. **Hand speed was fabricated two different ways.** (a) Measured against the game's receive time, where batched poses share a millisecond and dt collapses to zero — a gentle wave read as 4 body-heights/sec and sliced. (b) A teleported keypoint at an *honest* 16.7ms interval — MoveNet swapping wrists as arms cross, occlusion reacquisition, tracker identity swaps — computing ~94 body-heights/sec and slicing. Both fed `is_cutting()`, so both caused phantom slices. Fixed by timing against the daemon's capture clock and rejecting physically impossible samples. **Video is forwarded, never re-encoded.** There is no cheap downscale of a JPEG: any pixel change means decode + scale + encode on the latency-critical path. Resolution is chosen at the camera, where it is free. ## Measured (dragon, OBSBOT at 1280x720 MJPEG 60fps) | | | | --- | --- | | pipeline | **60fps sustained, 0 frames dropped** | | in-process latency | **P50 10.4ms / P95 10.7ms** (budget 80ms) | | inference | 7.7ms | | gesture recognition + publish | 0.02ms/frame | | video forwarding, when active | +0.7ms | | game JPEG decode (720p) | 4–7ms, game holds 120fps | | peak real hand speed | 13.89 body-heights/sec (cut threshold 0.8) | The latency figure is dequeue→present-returned. It excludes sensor exposure and present-to-photons, both real and neither visible from inside the process — so it is **not** glass-to-glass. ## Verified `nix build` green for `.#deck-pose` and `.#deck-slice`. **50 daemon tests** and 13 Godot framing checks, all sandbox-safe (no camera, network, tzdata). Daniel has play-tested the game live on a real camera and scored steadily. ## Untested / open - **Nothing has ever run on the steamdeck.** All numbers are from a desktop. Van Gogh is 4-core Zen 2 at handheld power, and a handheld doing inference *and* a game *and* a 30fps JPEG decode on four cores is a different proposition. - **The camera is unchosen** — wants wide FOV, 60fps, MJPEG, ideally global shutter. Several candidates are monochrome, which is handled but untested for accuracy. - **Gamescope is unattempted.** What Game Mode needs is written down in `lib/doc/deck-slice.md`, not solved. - **Only ever one player and one client.** Multi-player and multi-client paths are written and unit-tested but never exercised for real. - **Nobody has seen the camera feed background.** Dim level and skeleton alpha are guesses. - **Real glass-to-glass has never been measured** — needs a physical high-speed capture. ## Camera lifecycle **Socket-activated, not always-on.** systemd holds the listening sockets; the first connection starts the daemon; an idle timeout exits it when the last client leaves, **releasing the camera**. Always-on was the first design and was wrong: it would have held the camera open — LED on, watching the room — and run inference on a handheld's battery for the whole session, benefiting nobody while nobody plays. It also reintroduced by the back door exactly the exposure that made `--publish-video` opt-in. Verified on dragon: daemon opened the camera, exited 4s after the last client disconnected, and `fuser /dev/video0` afterwards reported **no holder**. The two modes differ in **who owns the socket path**, which is modelled explicitly rather than inferred. Self-bound we clean up and guard with an flock; activated, systemd owns the path — unlinking it on idle exit would make activation work *exactly once*, with every later connection finding nothing listening. **Hotplug is what makes activation coherent** rather than merely deferred: a daemon started by a connection still waits patiently when no camera is plugged in, and survives a mid-session unplug. ### Two findings worth keeping **Probing beats reading the capability flag, and the flag genuinely cannot do the job.** `VIDIOC_QUERYCAP` reports `capabilities` — the union across every node the *device* owns — alongside per-node `device_caps`, and the `v4l` crate exposes only the union. So `/dev/video0` (video capture) and `/dev/video1` (**metadata** capture) both claim `VIDEO_CAPTURE` and the flag cannot distinguish them. Negotiating a capture format can: the metadata node refuses. That is a test whose success implies the thing we actually care about, rather than one that correlates with it. This trap had bitten the project twice. **Polling beats a udev monitor here, for the same reason.** A `video4linux` add event names a node but not *which sibling* it is — the metadata node fires an identical event — so the probe decides either way and udev could only ever be a hint to probe sooner. Polling on the existing reopen backoff does the same work with no `libudev` in the closure and no extra descriptor to poll, which is a real benefit on a handheld. Removal is noticed immediately regardless, because the capture thread starts erroring on dequeue. If the 2s worst case ever proves too slow, udev drops in to trigger the probe early rather than replacing anything. ## Known upstream defects — filed, not fixed Written up in **`issues/open/deck-pose-phantom-gestures.md`** (included in this branch) rather than left in a PR description, since that is not somewhere a defect survives. **The daemon emits phantom gestures on tracking discontinuities, today.** It computes gesture speeds from raw keypoint deltas with no plausibility check, so a jumping keypoint produces a fictional speed that clears every threshold and publishes `swipe`/`punch` events to every consumer. Checking the code rather than trusting the original report turned up two corrections: **`jump` is exposed too** (`recognise.rs:270`, threshold 1.2), and `body_scale` — the denominator of all of them — is the **vertical span alone** floored at 0.05, so a lean or a turn foreshortens it toward zero. Conversely the daemon does **not** share the interval bug fixed game-side; it times off capture `Instant`s with nanosecond resolution. Recorded so nobody re-fixes that. **`PlayerId` does not keep its promise.** It is documented as stable across occlusion and never recycled, and games are told to bind to it, but association is bbox IoU alone — which cannot separate two people crossing, since that is exactly when boxes overlap and only the poses differ. Adding keypoint similarity (OKS) to the cost function in `track.rs` is the fix. The game-side guard in this branch protects the slice game's cut test only. It does nothing for the events the daemon publishes, and nothing for the next game.
Phase 1 of a markerless full-body pose-input system for the steamdeck host
(camera-only body tracking as a game input platform, no controllers). This
change is the latency spike only: no gesture recognition, no publishing API,
no games. Those are phase 2, and are only worth building if the numbers here
hold — the point of the spike is to find out.

Pipeline: v4l2 capture -> MJPEG/YUYV/GREY decode -> MoveNet MultiPose
Lightning -> IoU tracking -> One Euro smoothing -> overlay with a live
per-stage latency readout.

Three decisions are load-bearing and were forced by measurement rather than
plan:

* ONNX Runtime, not TFLite+XNNPACK. `nixpkgs.tensorflow-lite` is marked
  broken, and every Rust TFLite binding either builds TensorFlow with bazel or
  downloads a prebuilt runtime from a build script — none of which survives
  the nix sandbox. `onnxruntime` is packaged and substitutes, so the model is
  consumed as an ONNX re-export of the same Apache-2.0 MoveNet weights.

* float32, not int8. Under ONNX Runtime`s CPU provider the int8 export of this
  model measures ~12x SLOWER than float32 (100ms vs 8.4ms per 256x256 pass) —
  its QDQ nodes fall back to unfused integer convolutions. Quantisation only
  pays when the runtime has kernels for it.

* Capture drops stale frames rather than queueing them. A FIFO handoff between
  a 60fps camera and a slower consumer makes the acted-on pose steadily older
  without bound; a one-slot mailbox that new frames overwrite keeps latency
  flat and makes the loss visible as a counter.

Measured on dragon (not the target hardware) against a real UVC camera at
1280x720: 60fps sustained, in-process latency P50 10.4ms / P95 10.7ms, zero
frames dropped. Budget is 80ms end-to-end.
The module installs the daemon and grants camera access; it does not start
anything. `service.enable` is a second, separate opt-in that runs it headless
at login — off until there is an event API worth having up before a game does.

`users` is empty by default rather than defaulting to the primary user:
handing a process the camera is a decision, not a convenience.

The steamdeck host sets `enable = false`. Nothing here has run on that
hardware, and the camera has not been chosen — `/dev/video0` is a guess on a
machine with no built-in camera, so the device must be set before enabling.

The module records, in comments, the two things phase 1 deliberately does not
solve: the overlay cannot appear over a gamescope session (the eventual answer
is that the daemon has no window and games read its socket), and continuous
CPU inference on a handheld has an unbudgeted power cost.
docs(deck-pose): runbook, latency budget and model rationale
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m8s
cc2845127b
Records the measurements and the two findings that contradict the plan, so
neither has to be rediscovered:

* TFLite is unreachable here (nixpkgs.tensorflow-lite is broken; every Rust
  binding needs bazel or a downloading build script), hence ONNX Runtime.
* int8 is ~12x SLOWER than float32 under ORT`s CPU provider, with the numbers
  behind that claim.

Also states plainly what the reported latency does NOT include — sensor
exposure before dequeue, and present-to-photons after — so the in-process
figure is not mistaken for glass-to-glass.

The issue file carries the phased plan and the open questions, each marked with
what it needs: hardware, a purchase decision, or physical testing.
fix(deck-pose): scale the One Euro defaults to normalised coordinates
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m9s
ffe5a6435b
The defaults were the Casiez et al. paper values (min_cutoff 1.0, beta 0.007),
which are wrong here — and wrong in a way that looks right, since anyone who
recognises them will assume they are correct.

The adaptive term is `cutoff = min_cutoff + beta * |velocity|`, so beta`s
correct magnitude depends on the units velocity is measured in. The paper tunes
for PIXEL-valued mouse input moving at hundreds of units/second. These
keypoints are normalised to 0..1, so a fast hand crossing half the frame in
150ms moves at ~3.3 units/second. With beta = 0.007 the adaptive term is worth
~0.02Hz against a 1.0Hz baseline: the filter degenerates into a fixed 1Hz
low-pass (tau 159ms, alpha 0.095 at 60fps) and the skeleton visibly lerps
toward the body rather than tracking it. Observed live on dragon.

min_cutoff 1.5 / beta 3.0 restores the intended adaptive behaviour at this
scale, confirmed on real motion. Both are now real CLI flags, along with
derivative_cutoff: these are tuned by feel, and requiring a TOML file to try a
value is the wrong ergonomics for that.

smooth.rs gains a regression test that simulates the swipe and asserts the
shipped defaults both track it closely and beat the paper values, so the
degenerate case cannot come back silently. The smoothing tests now use
SmoothingConfig::default() rather than a private copy of the numbers, so they
cannot drift away from what actually ships.
feat(deck-pose): gesture recognition and a Unix socket publisher
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m18s
df546a5734
Phase 2: the seam sketched in events.rs is now implemented. The daemon
recognises gestures and publishes both a continuous per-frame pose stream and
discrete events as newline-delimited JSON, so games can be separate processes
that link no ML runtime.

Recognisers (recognise.rs) — jump, squat, punch, swipe, dwell, plus
player joined/left. Three properties matter more than the individual
thresholds:

* Thresholds are BODY-RELATIVE, scaled by shoulder-to-hip distance, so a
  gesture works at any distance from the camera rather than at exactly one.
* Every gesture has a REFRACTORY PERIOD. Without one a single physical motion
  spanning several frames fires an event on each of them and a game sees one
  punch as six.
* Low-confidence keypoints are treated as ABSENT, not as positions. MoveNet
  places uncertain joints somewhere plausible-looking but frequently wrong,
  and believing them produces phantom gestures — the worst failure mode for an
  input device, because the player did nothing and the game reacted.

Velocities are per-second, derived from frame timestamps rather than per-frame
deltas, so no threshold silently changes meaning when the frame rate does.

Swipe carries a unit direction vector, speed AND the wrist position at
recognition time: a slice game has to hit-test the path, and by the time the
event arrives the hand has moved on.

Dwell carries a position rather than the region id the phase-1 sketch
imagined. Regions are a game`s notion of its own UI; the daemon cannot know
them without being configured with a game`s layout, so hit-testing belongs on
the game`s side of the socket.

Publisher (publish.rs) — the rule that shapes it is that a slow or dead client
must NEVER slow the daemon down. The publish path does no I/O: it serialises
once, fans the same Arc out to each client`s writer thread through a bounded
queue with try_send, and moves on. A full queue drops the message and counts
it; a client behind for a sustained run is disconnected rather than
accumulating a backlog of stale poses it could not use anyway. Same newest-wins
reasoning as the capture stage. Tested by publishing 2000 frames at a client
that never reads.

Publishing is OFF unless asked for. A pose stream is a camera feed by another
name and should not appear on a socket because someone ran the binary.

Measured on dragon with a live client: 60.1fps, P50 11.7ms / P95 13.2ms,
recognition + publish 0.02ms/frame, 602 messages in 10s, zero drops. A real
dwell event fired from a hand held still.
fix(deck-pose): refuse to steal a socket another daemon is publishing on
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m13s
416567481f
The reported symptom was a client counter reading zero while a client was
demonstrably receiving 60 messages a second. The counter was not the bug — it
was accurately reporting that THAT daemon had no clients.

`bind` unlinked whatever was at the socket path before binding. Unlinking a
live Unix socket does not disturb the process listening on it: its listener
keeps working on the now-unnamed inode, so the first daemon stays up, never
accepts another client, and reports zero forever, while a second daemon
started on the same path quietly serves everyone. Two daemons then disagree
about reality and the only visible symptom is the count. Reproduced exactly in
a test before changing anything: two publishers on one path gave first=0,
second=1, with the client receiving from the second.

Ownership is now an flock on a `<socket>.lock` sidecar. Deliberately not a
connect-probe of the socket: probing means connecting, which registers a
phantom client on the daemon being probed and reintroduces a smaller version
of the same lying counter — the first attempt at this fix did exactly that and
the test caught it. The kernel drops an flock when the holder dies by any
means, including SIGKILL, so an unclean shutdown still leaves the path usable.

Three tests: a second bind on a live socket fails and the incumbent keeps
serving; a stale socket file left by an unclean shutdown is still cleared; and
the client count reads 1 across sustained traffic rather than only at the
moment of connection.
feat(deck-pose): report published messages beside the client count
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m37s
2c27221192
The client-count telemetry became the subject of a disagreement that could not
be settled after the fact: one observer saw `clients=0` while another saw a
client receiving 60 messages a second, and the log carried nothing to
reconcile them. Both observations were about different moments, but nothing on
the line said so.

The periodic line now reports `published`, the number of messages accepted by
at least one client`s queue. Read together the two numbers are no longer
ambiguous: zero clients beside a RISING published total is a genuine
contradiction worth investigating, and zero clients beside a flat total is
simply nobody listening — which is what a daemon logs for every cycle before a
game connects, and is almost certainly what was seen.

Also adds a test that checks the underlying invariant DIRECTLY instead of
arguing it from the code. "publish_frame early-returns at zero clients, so no
bytes can flow" is only as strong as the claim that every delivery path is
gated, which was the thing in doubt. The test publishes continuously with a
client attached and asserts, on every iteration where a message was actually
delivered, that the count was not zero at that instant — plus that nothing is
delivered at all before a client attaches.
feat(deck-slice): Fruit-Ninja-style slice game driven by deck-pose
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m30s
8232c55017
The first real consumer of the pose daemon`s socket, and as much a test that
the protocol is usable as it is a game. Targets are flung up; you slice them by
swiping a wrist through them. Up to six players, each with their own blade
colour and score.

Godot 4.6 has StreamPeerUDS, so the game reads the Unix socket directly. No
bridge process, and no reason for the daemon to grow a TCP transport just to be
playable. (Gotcha worth the comment it carries: FileAccess.file_exists returns
FALSE for a socket — it is not a regular file — so the "is the daemon up yet"
check goes through DirAccess instead.)

Two decisions shape how it feels:

* Slicing is driven by the POSE STREAM, not by swipe events. A swipe event is
  one discrete notification per motion by design, but one swing can pass
  through three targets and the player expects all three to fall. So every
  blade`s swept segment is tested against every target every frame; swipe
  events only drive "you swung" feedback, which should fire even on a miss.

* Hits test the SEGMENT the hand travelled, not where it is now. At 60fps a
  fast swipe moves a hand hundreds of pixels between frames, so a
  point-in-circle test tunnels straight through and the player would swear they
  hit it.

The view is mirrored, which is not cosmetic: without it the game is unplayable
in a way people struggle to articulate.

The game is defensive about the daemon`s lifetime — it polls for the socket,
connects whenever it appears, survives a restart, and says on screen that it is
waiting and how to start the daemon. A pose game with no daemon looks identical
to one that cannot see you, and "nothing is moving" is not a diagnostic.

Packaged as the project directory plus a wrapper rather than an exported
binary: export would pull in hundreds of MB of engine templates to produce a
.pck of the same six GDScript files. The build does run --import twice, and
that is load-bearing — Godot writes its cache into .godot/ inside the project,
which a store path cannot allow at runtime.

Validated end to end against a synthetic pose stream (a wrist swept at a known
speed): connects, tracks blades, slices, applies the bomb penalty, and
reconnects when the daemon exits. Not driven by a real camera yet — the
development machine`s camera was in use — and never run on the steamdeck.
lib/doc/deck-slice.md records what Game Mode will need.
Daniel: "I need a sense of space, and I think the window versus camera`s
viewport makes the ratios feel confusing? and showing the skeleton at least a
tad might help provide that." Three changes, addressing the two separate
problems in that.

LETTERBOX THE CAMERA ASPECT. Pose coordinates are normalised against the
camera frame; mapping 0..1 onto a viewport of a different aspect scales x and
y by different factors, so identical physical hand movement covered different
screen distance horizontally and vertically. The camera aspect is now
letterboxed inside the viewport and EVERY pose->screen mapping goes through
that rect. The aspect comes from the `w`/`h` on each pose message rather than a
constant, because the daemon negotiates the capture format at runtime and does
not always get what it asked for.

The rect is visible — everything outside it dimmed, a faint border on it — so
"where the camera can see me" is a place rather than an inference. Targets
spawn inside it, so nothing is thrown where a hand cannot reach.

Consequence worth noting: the cut-speed threshold is now expressed in
play-rect widths per second rather than pixels. A fixed pixel threshold
silently demanded a faster physical swing on a larger window.

SELF-VIEW. Each player`s skeleton drawn faintly behind the gameplay — low
alpha, thin bones, deliberately subordinate to the targets. Blades say where
your hands are but nothing about where YOU are, and a player who has drifted
sideways or stood too close otherwise cannot tell. Toggleable with
`--no-self-view` or `S`.

EDGE GLOW. When a hand leaves frame or drops below the confidence threshold,
the edge it was last seen near glows; a hand merely approaching an edge glows
it weakly. Without this, "my hand left the frame", "the model lost my hand" and
"the game has hung" all look identical — a blade that stopped moving.

MIRRORING was already correct (MIRROR_X) and is unchanged; there is now a test
pinning it so it cannot regress silently.

The mapping arithmetic is extracted into framing.gd as pure static functions
and covered by tests/framing_test.gd, which the nix build runs — including the
uniform-scaling property this change exists to establish. Godot propagates the
script`s exit code, verified by deliberately failing a check.

Scene gained an Entities node so runtime targets and blades draw between the
self-view and the HUD; previously they were appended after the HUD and drew
over the score.
feat(deck-slice): log the resolved play rect
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m31s
23ab92880f
"The ratios feel wrong" can only be diagnosed against the rect actually in
use, and nothing was reporting it. Logged on every change — startup, window
resize, camera renegotiation — which is rare enough to be worth a line.

This also closed a real gap in how the letterbox was verified. The unit tests
cover the arithmetic, but every end-to-end run so far used a 16:9 viewport
where letterboxing is a no-op, so the runtime path was effectively untested.
With the rect logged, a headless run turns out to have a SQUARE 1280x1280
viewport, which exercises it properly: camera 1280x720 gives a play rect at
0,280 sized 1280x720 — inset 280px top and bottom, as it should be — and
gameplay (spawning inside the rect, slicing, scoring) works against it.
fix(deck-slice): make the cut threshold body-relative and forgiving
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
78c0c116ca
Daniel, playing #909: "feels much less responsive than it did before and `fast
enough to count as a cut` seems to be a bit unforgiving ... sometimes I move
fast but still not enough for the game?"

The unit has now been wrong twice, which is the argument for the third one.

Pixels/second demanded a faster physical swing on a larger window.
Play-rect widths/second fixed that but still moved whenever the framing did —
and it moved badly. Letterboxing corrected the vertical axis from
stretched-to-window-height back to its true 16:9 proportion, which roughly
HALVED the measured speed of a vertical swing while the threshold stayed put.
That is the specific mechanism behind the report.

Body heights (shoulder-to-hip) per second is the invariant that holds. A swing
is a physical motion of a physical arm, so measuring it against the player`s
own apparent size makes it independent of the window, the letterbox, the
capture resolution, and how far from the camera they stand. It is also the unit
the daemon`s gesture thresholds already use, so the two now agree instead of
each having their own idea of "fast".

Retuned down and biased forgiving: 0.8, against a measured 7-10
body-heights/sec for a deliberate swing and the daemon`s 1.5 for a `swipe`.
Roughly a quarter of the old effective threshold at mid distance. A missed
slice is far more frustrating than an accidental one and the only cost of
generosity here is the occasional unintended bomb.

`--cut-speed` exposes it, for the same reason the One Euro parameters are
flags: tuned by feel, so a rebuild per attempt makes tuning impractical. No
config file — one tunable, and live tuning is what a flag is for.

Diagnostics, so this stops being unfalsifiable: DECK_SLICE_DEBUG=1 now reports
peak observed hand speed against the current threshold, and logs any swing that
passed THROUGH a target while judged too slow, with its speed — which is
exactly the number the threshold should sit below.

It also reports the game`s own frame rate, which answers the other half of the
report: 1200-frame runs measure 129fps before the framing work and 128fps
after, so "less responsive" was the threshold and not a render regression.
docs(deck-pose): record the real-camera measurements
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m30s
1ea0cc719a
The cut threshold and the framing had only ever been exercised against a
synthetic stream or a 16:9 viewport. Both have now run against the real camera:

* Peak hand speed with a real person reaches ~14 body-heights/second, against
  a 0.8 threshold and with no near-miss events logged — so the retune is
  forgiving by more than an order of magnitude rather than by estimate.
* The letterbox ran with an actively non-16:9 window
  (1280x1413 -> play rect 0,346 1280x720), which is the first time the
  runtime path has been exercised outside a viewport where it does nothing.

Also documents how to read the publish telemetry, since the two numbers only
mean anything as a pair.
fix(deck-slice): stop fabricating hand speed from a collapsed interval
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
7d09abb06d
The peak-speed diagnostic reported 727 body-heights/sec. That is not a
diagnostic bug — the same number feeds the cut test, so it was silently
bypassing the threshold and causing accidental slices.

Cause: speed was measured against the GAME`s receive time. `Time.get_ticks_msec`
has millisecond resolution and the client drains every buffered line in one
poll, so a batch of poses that arrived together all shared one timestamp. The
interval collapsed to zero and was clamped to 0.1ms, turning a normalised
movement of 0.0001 into 4 body-heights/sec.

Reproduced before fixing: a deliberately gentle wave (~1 body-height/sec) sent
in batches reported 4.00 and registered a slice. After the fix the same stream
reports 0.63 and slices nothing, while a real swing still slices and now
measures 7.55 against an analytic prediction of 7.54 — the old code reported
10.57 for that same stream, inflated by the same mechanism.

Fixes:

* Speed is timed against the DAEMON`s `ts_ms`, stamped at capture, so batched
  frames keep their true ~16ms spacing.
* Intervals too short to divide by are DROPPED, not clamped. Positions still
  advance for drawing; the next valid interval measures across the gap.
  Fabricating a dt is what caused this.
* Body scale — the denominator of every speed — is now the full 2D torso
  length rather than the vertical span alone, which collapses toward zero when
  a player leans or turns while the torso is plainly still there. Floored at
  0.10, with a deliberately non-small fallback when the torso keypoints are not
  confident, consistent with treating low-confidence keypoints as absent.
* The reported peak is now a decaying 3-second window. A run-forever maximum
  pins itself to the first spike and is useless for tuning afterwards, which is
  exactly how the 727 reading presented.
fix(deck-slice): reject physically impossible hand speeds
All checks were successful
/ check-format (push) Successful in 11s
/ build (push) Successful in 6m31s
17e5e3349e
The interval fix in the previous commit removes one way a speed can be
fictional. It does not remove the other, and the remaining one is the better
match for the intermittency actually observed.

Correct timing does not help when the POSITION jumps. MoveNet swaps left and
right wrists as arms cross, loses and reacquires joints through occlusion, and
the tracker can hand an existing player id to a different body when two people
pass each other. Each teleports a wrist. A third of the frame in one entirely
honest 16.7ms interval computes ~79-94 body-heights/sec — nearly 120x the cut
threshold — so it slices targets the player never touched. Tracking
discontinuities are sporadic by construction, which fits an intermittent
symptom in a way a steady timing bug does not.

Demonstrated rather than argued. Against a synthetic stream whose wrist jumps a
third of the frame twice a second:

  without this guard: peak 94.29 body-heights/sec, targets sliced
  with it:            peak 0.00, 14 samples rejected, nothing sliced

and the earlier batched-message reproduction is unchanged at 0.62 peak with
zero rejections, so the guard costs nothing it should not.

Samples above 30 body-heights/sec are rejected. Measured reality is 7-14, so
the ceiling is more than twice the fastest motion ever observed and refuses
nothing a person can do.

REJECTED, not clamped: clamping to the ceiling would still leave a sample ~37x
the cut threshold, which still slices — the trap this is avoiding. The position
is kept, since it is probably where the hand now is, and re-anchored so the
next frame measures from there rather than spiking a second time off the same
jump.

Rejections are counted and printed in the debug readout. This class of fault
was hard to pin down precisely because it left no trace.
feat(deck-pose): forward camera video on a second socket
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
904ba26599
Daniel: "I think we should draw the actual camera feed as the background so
users can feel how the tracking relates to their bodies."

The game cannot open the camera — V4L2 gives exclusive access to one opener and
the daemon holds it, the same constraint that made every stray daemon block the
next one during development. So video comes through the daemon or not at all.

FORWARDED, NEVER RE-ENCODED. The camera already delivers MJPEG and the daemon
already holds that buffer before decoding for inference, so forwarding costs a
memcpy. Measured on dragon: inference 8.0ms with video off, 8.1ms with
--publish-video and no consumer attached (a true no-op), 8.7ms while actively
forwarding. The pose path is unaffected — 60.1fps, P95 15.4ms end to end with
a game attached, against an 80ms budget.

NO DAEMON-SIDE DOWNSCALE, which is a deliberate rejection of the obvious
feature rather than an omission. Any change to the pixels — including making
them smaller — means decode, scale and JPEG ENCODE on the latency-critical
path, to produce a worse image than the one already in hand. Resolution is
chosen where it is free, at the camera via --width/--height, which shrinks the
capture, the forward and the consumer`s decode together. --video-fps caps the
rate independently of inference. Non-MJPEG formats are skipped, not converted.

Separate socket, not the NDJSON pose stream: video is binary and wants
length-prefixed framing, and base64 in a text protocol would be waste on the
input-critical channel. 12-byte header (magic, length, width, height) so a
consumer reconnecting mid-stream can tell it is synchronised rather than
guessing.

Opt-in and off by default, wired through the NixOS module as a decision
separate from pose publishing. The pose stream is coordinates; this is video of
someone`s living room.

Same drop-never-queue discipline as capture and the pose publisher, with a
one-frame queue: a stalled video consumer loses frames rather than adding a
millisecond to the input path. Tested by offering 500 frames at a consumer that
never reads.
docs(deck-pose): file the phantom-gesture and player-identity defects
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
f22fb5e2a4
Both were found while building the slice game and were recorded only in a PR
description, which is not somewhere a defect survives.

The daemon computes gesture speeds from raw keypoint deltas with no
plausibility check, so a keypoint that jumps — MoveNet swapping wrists as arms
cross, a joint reacquired after occlusion, the tracker reassigning an identity —
produces a fictional speed that clears every threshold. It then publishes swipe
and punch events, to every consumer, for motions nobody made.

Two things the original report understated, both verified against the code
rather than recalled:

* JUMP is exposed too, not only swipe and punch: recognise.rs:270 divides hip
  velocity by the same scale against a 1.2 threshold.
* body_scale — the denominator of every one of those speeds — is computed from
  the VERTICAL span alone and floored at 0.05, so a player leaning or turning
  foreshortens it toward zero while the torso is plainly still there. The
  game-side equivalent was fixed to 2D distance with a 0.10 floor; the daemon
  still has the weaker version.

And one thing it overstated: the daemon does NOT share the interval bug. That
came from a millisecond-resolution receive clock and batched messages; the
daemon times off capture Instants with nanosecond resolution and already
rejects dt <= 0. Recorded so nobody re-fixes it.

The tracker half is separable and arguably more important. PlayerId is
documented as stable across occlusion and never recycled, and games are told to
bind to it, but association is by bounding-box IoU alone — which cannot
distinguish two people crossing, since that is exactly when their boxes overlap
and only the poses inside differ. Adding keypoint similarity (OKS) to the cost
function is the fix.

No code changed; the issue is the deliverable.
docs(deck-pose): name the exact site and the game-side guard
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m22s
c8a9d5e5cd
Quotes recognise.rs:325 and its formula literally rather than leaving the line
number in a table cell, and names the game-side guard by file
(packages/games/deck-slice/project/blade.gd) rather than referring to it as
"in deck-slice".

Both so the issue can be acted on without first re-deriving where the code is —
which is the difference between a filed defect and a note that a defect exists.
fix(deck-slice): draw the camera feed with a transform, not a negative rect
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
1193b19d58
Daniel: "I see no cam feed". The transport was fine throughout — the client was
reporting 2.85-3.6ms decodes the whole time it was invisible — because the bug
was in the drawing, three lines from the pixels.

Mirroring used a negative-width Rect2. Godot 3 flipped on that; Godot 4 draws
NOTHING, silently, with no error or warning. Now mirrored with a draw transform
instead.

The geometry moves into framing.gd as `mirror_transform`, which returns a rect
that is positive-size by construction. That is the point of it being a
function: the invariant that was violated becomes something a test can assert
rather than something rediscovered from a black screen.

Audited every other Rect2 and draw call in the game for the same assumption. It
was made exactly once. The vignette bands can compute a negative size at the
edges but already skip on an explicit positive-size guard, so they were never
exposed — worth recording, since "it was made once" is only reassuring if
someone checked.

Two layers of test:

* tests/framing_test.gd (runs in the nix build) asserts the mirror rect stays
  positive and that the transform genuinely mirrors — the source`s left edge
  must land on the destination`s right. A well-formed transform that did not
  flip would pass the first check and still be wrong.

* tests/render_test.gd renders actual pixels and reads them back, checking the
  feed appears AND is mirrored the right way round. It renders the old
  negative-rect approach alongside and asserts that one produces nothing, so it
  demonstrates the catch rather than claiming it.

The pixel test is deliberately NOT in the nix build. Godot`s headless mode uses
a dummy renderer that produces no pixels at all, so it needs a real GL context,
which in a sandbox means Xvfb plus software Mesa — a lot of build surface for
one test. It is documented to run by hand under xvfb-run, and it passes.
feat(deck-slice): extend the blades past the wrist along the forearm
Some checks failed
/ check-format (push) Successful in 11s
/ build (push) Has been cancelled
8ef2d99a65
Daniel: "it seems like since the weapons terminate at the wrists, it feels a
little off. So I need to be holding knives or something so that it extends a
little bit past the wrists."

Direction comes from the elbow->wrist vector, which MoveNet already provides in
the standard 17 keypoints, so no protocol change was needed. Length is a
fraction of the FOREARM rather than a pixel count — a fixed length would be a
dagger up close and a needle across the room, which is the mistake the cut
threshold already made once and should not make again. `--blade-length`
(default 0.45) tunes it by feel; 0 reduces exactly to the previous wrist-only
behaviour, which is a useful property for bisecting.

The blade is now a SEGMENT, so the swept region is a quadrilateral rather than
a line: from (previous wrist, previous tip) to (current wrist, current tip),
sampled along the blade and connected frame to frame. Testing only the wrist
path would have left the new reach visible but inert — the worst possible
outcome, because the player aims with the tip. Sampling reuses the existing
segment-circle primitive, and its failure mode with too few samples is a missed
slice rather than a phantom one.

A/B against a synthetic swing: 4 slices at length 0, 5 at 0.45, 8 at 0.9.

A missing or low-confidence elbow keeps the last known direction and length
instead of whipping the blade to an arbitrary angle — the same "absent, not a
position" rule the daemon applies to keypoints.

Drawn as a tapered edge with a bright core and a knuckle at the hilt, so which
way it points is readable against the camera feed now that the feed renders.

tests/blade_test.gd covers the geometry headlessly and runs in the nix build.
The load-bearing pair asserts that a target beyond the wrist is NOT hit by the
wrist path alone but IS hit by the extended blade.
feat(steamdeck): install deck-pose and deck-slice on the steamdeck
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m16s
8f24e20b5a
Enables the pose-input daemon and the slice game on the Deck. Nothing
starts automatically: service.enable stays off, so the daemon is run by
hand from a terminal, which is what the interactive spike wants.

The Deck has no built-in camera, so device is a placeholder until a USB
camera is attached.
feat(deck-pose): survive a camera arriving and leaving
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
ddc09ccdd6
The Deck has no built-in camera, so the normal case is a USB webcam plugged
into an already-running system and unplugged again later. The daemon resolved
one path at startup and exited on failure, which makes it unusable there:
enabling it as a service on a Deck with nothing plugged in would crash-loop.

Absence is now a STATE, not an error. The daemon starts without a camera, waits,
picks one up when it appears, keeps running when it vanishes, and reacquires it
— sockets bound and clients connected throughout. Reopen attempts back off to a
2s cap so an idle machine polls quietly instead of spinning.

`--device auto` is the DEFAULT. /dev/video0 is not reliably the camera: the
development OBSBOT presents /dev/video0 (video capture) and /dev/video1
(METADATA capture), and opening the wrong one fails with "querying current
format" — a trap that has bitten this project twice.

The capability flag cannot settle it, which is worth recording because it looks
like it should. VIDIOC_QUERYCAP reports `capabilities` (the union across every
node the DEVICE owns) and `device_caps` (this node only); the v4l crate exposes
only the union, so both nodes claim VIDEO_CAPTURE. What distinguishes them is
asking the node to do the job — query and set a capture format. The metadata
node refuses. That is both the honest test and the one whose success implies
the thing we actually care about.

Identities come from /dev/v4l/by-id where available, since /dev/videoN
renumbers across replug and across cameras; "the camera came back" should be a
judgement about identity, not about a number that may now mean something else.

Consumers are told explicitly rather than left to infer absence from silence:
a `{"t":"camera","present":false,"device":...}` message on transitions, AND
kept as sticky state replayed to every client the moment it connects. A game
that starts during an outage would otherwise sit in a silence indistinguishable
from a wedged daemon.

POLLING, NOT UDEV, and the reasoning is in supervisor.rs. A video4linux add
event names a node but not whether it is the capture node — the metadata
sibling fires an identical event — so telling them apart means probing anyway.
A udev event could only ever be a hint to run the probe sooner. Polling the
probe on the existing backoff does the same work with no libudev in the closure
and no extra descriptor, and removal is noticed immediately regardless because
the capture thread starts erroring on dequeue. If a 2s worst case ever proves
too slow, a udev monitor drops in to trigger attempt_open early rather than
replacing any of this.

Verified against the real camera on dragon: auto-select found
usb-..._OBSBOT_Meet_2-video-index0 and ran at 60fps; starting with no camera
kept the daemon alive with its socket bound and handed a late-joining client
`present:false` as its first line; pointing explicitly at the metadata node
reported absence instead of crashing.
feat(deck-slice): launch from the SteamOS menu, and survive camera absence
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
d84524853e
Daniel: "make sure the game can start the daemon and retries appropriately so
we just launch it from the steam os menu".

I evaluated your architecture rather than just building it, and agree with it.
Hotplug is what unlocks it: the daemon no longer exits without a camera, so an
always-up user service stops being a crash-loop and "launch from the menu"
collapses into "launch the game".

Service policy is now Restart=always rather than on-failure, which is only
reasonable BECAUSE absence is no longer a failure.

deck-slice-launch runs `systemctl --user start deck-pose.service` and then
EXECS the game, so Steam`s process tree holds the game rather than a shell
babysitting a child. Three things about it are deliberate:

* systemctl rather than spawning a daemon ourselves — supervision, restart and
  logging already exist and a game that forks a daemon owns a lifecycle problem
  it should not. Starting an already-running unit is a no-op, and the flock
  makes even a real double-start safe.
* Failing to start the service is NOT fatal. The game already retries the
  socket indefinitely and says on screen what it is waiting for, so a Deck
  without the unit still launches into something that explains itself.
* The game is referenced by absolute store path, not via PATH. A gamescope
  session need not inherit a login shell`s PATH, and "works from a terminal,
  fails from the menu" is the exact trap the launcher exists to avoid. I had
  written the PATH version first.

Game side handles the camera coming and going without a restart: the explicit
`{"t":"camera"}` state clears the frozen video frame and stops drawing blades
at positions nobody occupies, and the HUD distinguishes "no camera" from "no
daemon" — different problems with different things for the player to do.

Verified end to end: the game against a daemon with no camera connects, reports
"camera absent" from the sticky protocol message rather than inferring it from
silence, and keeps running.

A .desktop entry is installed, which is what SteamOS`s Add a Non-Steam Game
browser lists. Adding it to Steam stays manual — Steam owns that library and a
nix build has no supported way in.
feat(deck-pose): socket-activate the daemon and exit when idle
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
1768526604
Daniel: "but we also don`t need always-on camera-people-tracking, right?" He is
right and the always-on service was the wrong design. It would have held the
camera open — LED on, watching the room — and run inference on a handheld`s
battery for the whole session, benefiting nobody while nobody plays. It also
quietly undid the privacy care taken elsewhere: --publish-video is opt-in
precisely because video of a room is a bigger deal than coordinates, and an
always-open camera reintroduces that by the back door.

systemd now holds the listening sockets and starts the daemon on first
connection. The idle timeout is the half that delivers the benefit: without it
activation would merely defer the always-on daemon until the first launch.
Default 15s after the last client disconnects; --idle-timeout 0 disables it.

The part that is not just "also accept an fd" is OWNERSHIP, and getting it
wrong breaks activation in a way that looks like it works. Self-bound, we
created the path: clear a stale one, hold an flock against a second daemon,
unlink on exit. Activated, systemd created and owns it: do not unlink, do not
treat anything as stale, and do not take a lock — exclusivity is systemd`s job
and a lock we do not need is only a new way to fail. Unlinking systemd`s socket
on idle exit would be the subtle one: activation would work exactly once and
every later connection would find nothing listening. So Origin is modelled
explicitly and the cleanup path asks which mode it is in.

Both sockets are activated; video is the more sensitive of the two. Two socket
units rather than one with two ListenStreams, because a socket unit carries a
single FileDescriptorName and the daemon asks for descriptors by name — sharing
one name would leave it guessing by position. LISTEN_PID is checked so a child
cannot adopt a parent`s descriptors, and each fd is claimed once so a duplicate
name cannot hand the same descriptor to two owners.

Restart=no: exiting when idle is success, and restarting would reopen the
camera immediately and defeat the whole point.

VERIFIED on dragon with the real camera, which is the check that matters here
given how often a daemon has silently held /dev/video0: the daemon opened
usb-..._OBSBOT_Meet_2-video-index0, exited 4s after the last client
disconnected, and `fuser /dev/video0` afterwards reported NO holder. The camera
is genuinely released, not merely unreferenced.

The launcher now starts the SOCKET rather than the service — starting the
service directly would bypass activation and open the camera before anything
asked for poses. Strictly it need not start anything at all, since connecting
is what starts the daemon; it is kept as belt and braces for a session where
sockets.target did not pull the unit in.
feat(steamdeck): enable socket-activated deck-pose
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
ee7a669608
Turns on the socket-activated user service now that connecting is what
starts the daemon: nothing opens the camera until a game asks for poses,
and the daemon exits and releases it once nothing is connected. An
always-on service would have watched the room whenever the Deck was on.

Leaves device at its default, since auto-selection now probes for a node
that can negotiate a format rather than trusting the capability flag.
docs(deck-pose): correct comments that still described the always-on service
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
51ef1de3f1
The module was converted to socket activation but a comment block at the bottom
still described the design it replaced — including "the graphical-session.target
binding above", which no longer exists. That block was the only mention of
graphical-session left in the file, and reading it would reasonably lead
someone to believe the daemon still starts at login. It did exactly that.

A stale comment next to correct code is worse than no comment: it is evidence
for the wrong conclusion, and it survives review because the code beside it
looks fine.

Autostart ordering against Steam is now recorded as NO LONGER A CONCERN rather
than quietly dropped, because it used to be one — activation means the first
connection starts the daemon, so there is nothing to order against Steam.
Thermals are re-scoped to what is actually unbudgeted: the cost while playing,
not the cost at idle, which is what exiting when idle already answers.
fix(steamdeck): use %t for deck-pose sockets, not a hardcoded uid path
Some checks failed
/ check-format (push) Has been cancelled
/ build (push) Has been cancelled
59706baa0c
systemd.user units are installed for every user, so an absolute
/run/user/1000 path in a user unit makes root's user manager bind the
same file as daniel's. Observed on the Deck: both sockets ended up
root:root 0600 inside daniel's runtime dir, and connecting as daniel
failed with EACCES, so activation could never fire.
fix(deck-pose): wrap the whole config document in writeText
Some checks failed
/ check-format (push) Successful in 10s
/ build (push) Has been cancelled
6954599391
writeText was called on the [camera] chunk alone and the remaining
sections were concatenated onto the result. A derivation coerces to its
store path, so the [publish] body was glued onto the path itself and
environment.etc produced a symlink whose target was a path with TOML
stuck to the end.

Found on the steamdeck: socket activation fired, the daemon started, and
it died with 'reading config /etc/deck-pose/config.toml: No such file or
directory' — the file it was pointed at could not exist. Latent until
service.enable made something read the file.
fix(deck-pose): make socket activation actually work, proven against systemd
All checks were successful
/ check-format (push) Successful in 9s
/ build (push) Successful in 6m11s
7f0b400427
Testing this against a real systemd user unit rather than only unit tests found
three defects, two of which no unit test could have caught.

CONFIG.TOML MUST NOT CARRY A PER-USER SOCKET PATH. It is one system-wide file
shared by every user, so neither representation is correct: `%t` is systemd
unit syntax and reaches the daemon as a literal directory, and an expanded
/run/user/1000 is simply wrong for everyone else. Both options offered were
traps. The config now expresses INTENT — publish, publish video — and the path
comes from whoever places the socket: systemd binds %t/... and hands over the
descriptor, or the daemon falls back to $XDG_RUNTIME_DIR. %t expands to exactly
$XDG_RUNTIME_DIR, so the two agree BY CONSTRUCTION rather than by keeping two
strings in step. A `%`-prefixed value is now recognised as unit-only syntax and
never written to config.

THE DAEMON IGNORED SYSTEMD`S SOCKET. Publishing required a config flag, so an
activated daemon adopted nothing, served nothing, and then idled out while the
triggering connection was still queued — so systemd re-activated it
immediately. A hot restart loop, every 5 seconds, visible only against a real
unit. Being handed a listening socket IS the request to publish on it.

A DEPARTED CLIENT WAS NEVER REAPED WITHOUT TRAFFIC. Liveness was only noticed
on write failure and pruning only happened during broadcast — but with no
camera there are no writes and no broadcasts, so a client that had left still
counted forever, the idle timeout never fired, and the daemon never released
the camera. On a Deck, which has no built-in camera, that is the DEFAULT case.
Writer threads now bound their wait and probe for EOF, and the count includes
only live clients.

Also: the module`s device option defaulted to /dev/video0, silently overriding
the auto-selection the host config believed was in effect. Now `auto`.

And a warning if a per-uid runtime path is configured: systemd.user units are
installed for every user, so an absolute /run/user/<uid> path makes each user`s
manager — root`s included — bind the same file. It looks correct until a second
user logs in, which is exactly how this surfaced.

VERIFIED end to end against a real systemd user socket unit on dragon:
socket armed with the service inactive and no camera open; connecting started
the service and the client`s FIRST line was
{"t":"camera","seq":0,"present":false} — activation, hotplug absence and the
protocol in one; 12s later the service was inactive again, having released the
camera; the socket stayed armed and a second connection re-activated cleanly,
proving the socket had not been unlinked.
feat(deck-slice): expose every feel-tuned value as a flag, and raise defaults
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m7s
1bf8a5d2fd
From live play on the Deck: "knives are a little small and more fruits".

BLADE LENGTH 0.45 -> 0.8. Daniel is testing 0.9 and reports 0.45 reads as too
small at arm`s length on a handheld screen. 0.8 sits inside the range he is
actively validating rather than being a fresh guess in a different direction,
and it matches the thing being imitated: a held knife is roughly comparable to
a forearm in length, so most of a forearm past the wrist is the honest shape.

SPAWN RATE is now `--spawn-rate` in targets per second, default raised from an
effective ~0.87/sec to 1.6/sec. ONE number rather than two interval bounds,
which was the better interface: a rate is what a person actually has an opinion
about, and two bounds are two values you must keep ordered to say it. The
jitter that stops spawning feeling metronomic is derived from the rate (±35%)
and the mean interval is exactly 1/rate, so the configured number stays honest
instead of quietly drifting slower than it claims.

Rather than wait to be asked a fourth time, I audited every constant in the
game and split them by KIND. Judged by feel, so now flags: bomb chance,
gravity (hang time), target size (how forgiving a hit is), camera-feed
brightness, and self-view alpha — the last two being values I had already
flagged as guesses nobody had judged.

Deliberately NOT flags, because they are correctness or safety limits rather
than taste: the plausibility ceiling on hand speed, the body-scale floor, the
minimum measurable frame interval, and mirroring. Exposing those would only
offer a way to reintroduce the bugs they exist to prevent.

Verified: --spawn-rate 0.87 / 1.6 / 3.0 produced 2 / 4 / 5 slices over the same
synthetic run, so the knob does what the number says. Blade tests still pass.
lytedev force-pushed deck-pose-wip from 1bf8a5d2fd
All checks were successful
/ check-format (push) Successful in 10s
/ build (push) Successful in 6m7s
to f5e3d66454
All checks were successful
/ check-format (push) Successful in 12s
/ build (push) Successful in 6m0s
2026-08-04 15:35:30 -05:00
Compare
All checks were successful
/ check-format (push) Successful in 12s
Required
Details
/ build (push) Successful in 6m0s
Required
Details
This pull request has changes conflicting with the target branch.
  • issues/open/deck-pose.md
  • lib/host.nix
  • packages/hosts/steamdeck.nix
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin deck-pose-wip:deck-pose-wip
git switch deck-pose-wip
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lytedev/nix!915
No description provided.