Rumble envelopes (0xCA v2) + 0.9.2 #3

Merged
enricobuehler merged 5 commits from rumble-envelopes into main 2026-07-11 01:35:01 +00:00
Owner

Cuts 0.9.2. Makes "stuck rumble" inexpressible on the wire by moving rumble from level-triggered unbounded state to host-authoritative self-terminating envelopes, and folds in two pre-existing working-tree fixes.

Rumble envelopes (0xCA v2)

The 0xCA datagram grows a length-tolerant tail [u8 seq][u16 ttl_ms]: the host authorizes a level for at most ttl_ms and renews it (~120 ms) while it holds, letting an abandoned one lapse client-side. seq is a per-pad wrapping reorder gate (reusing GamepadSnapshot::seq_newer). Decoders read the first 7 bytes as a plain level and ignore the tail, so no wire-version bump — all four host/client generation pairings render correctly. Per-client staleness magic (SDL 1.5 s, Apple 1.6 s, Android 60 s) is demoted to a legacy-host-only fallback.

  • core: encode_rumble_datagram_v2 / decode_rumble_envelope; demux seq gate → (pad, low, high, Option<ttl>); next_rumble unchanged, next_rumble_ttl added; ABI punktfunk_connection_next_rumble2 + PUNKTFUNK_RUMBLE_NO_TTL, ABI_VERSION 4→5 (WIRE_VERSION unchanged).
  • host: flat 500 ms refresh → renewal loop (seq bump + fresh TTL on active pads, short post-stop zero burst, then quiet). Hatches PUNKTFUNK_RUMBLE_ENVELOPE=0, PUNKTFUNK_RUMBLE_TTL_MS (clamped [150, 5000]).
  • renderers honor the TTL, legacy fallback only for ttl=None: pf-client-core (Deck keep-alive now deadline-bounded), windows (SDL duration), android (JNI packs the lease out-of-band in bit 48), apple (RumbleRenderer.envelopeDeadline + nextRumble2).
  • tests: Rust codec round-trip + tail tolerance + seq-gate reorder; probe asserts the v2 tail under PUNKTFUNK_TEST_FEEDBACK; Apple loopback asserts ttlMs round-trips E2E; RumbleTuning lease cases.

Also folded in (pre-existing working-tree fixes)

  • fix(host/windows): stop a stale rumble residual a game abandoned (the game-facing companion to envelopes).
  • fix(flatpak): bind gamescope's Wayland socket so Deck Game-Mode HDR negotiates HDR10.

Design: punktfunk-planning/design/rumble-envelope-plan.md. Implementation reviewed by an adversarial multi-agent pass; the two confirmed findings (Android sentinel collision, probe assert-vs-log) are fixed in this branch.

🤖 Generated with Claude Code

Cuts **0.9.2**. Makes "stuck rumble" inexpressible on the wire by moving rumble from level-triggered unbounded state to host-authoritative **self-terminating envelopes**, and folds in two pre-existing working-tree fixes. ## Rumble envelopes (0xCA v2) The 0xCA datagram grows a length-tolerant tail `[u8 seq][u16 ttl_ms]`: the host authorizes a level for at most `ttl_ms` and renews it (~120 ms) while it holds, letting an abandoned one lapse client-side. `seq` is a per-pad wrapping reorder gate (reusing `GamepadSnapshot::seq_newer`). Decoders read the first 7 bytes as a plain level and ignore the tail, so **no wire-version bump** — all four host/client generation pairings render correctly. Per-client staleness magic (SDL 1.5 s, Apple 1.6 s, Android 60 s) is demoted to a legacy-host-only fallback. - **core**: `encode_rumble_datagram_v2` / `decode_rumble_envelope`; demux seq gate → `(pad, low, high, Option<ttl>)`; `next_rumble` unchanged, `next_rumble_ttl` added; ABI `punktfunk_connection_next_rumble2` + `PUNKTFUNK_RUMBLE_NO_TTL`, `ABI_VERSION` 4→5 (WIRE_VERSION unchanged). - **host**: flat 500 ms refresh → renewal loop (seq bump + fresh TTL on active pads, short post-stop zero burst, then quiet). Hatches `PUNKTFUNK_RUMBLE_ENVELOPE=0`, `PUNKTFUNK_RUMBLE_TTL_MS` (clamped `[150, 5000]`). - **renderers** honor the TTL, legacy fallback only for `ttl=None`: pf-client-core (Deck keep-alive now deadline-bounded), windows (SDL duration), android (JNI packs the lease out-of-band in bit 48), apple (`RumbleRenderer.envelopeDeadline` + `nextRumble2`). - **tests**: Rust codec round-trip + tail tolerance + seq-gate reorder; probe asserts the v2 tail under `PUNKTFUNK_TEST_FEEDBACK`; Apple loopback asserts `ttlMs` round-trips E2E; `RumbleTuning` lease cases. ## Also folded in (pre-existing working-tree fixes) - `fix(host/windows)`: stop a stale rumble residual a game abandoned (the game-facing companion to envelopes). - `fix(flatpak)`: bind gamescope's Wayland socket so Deck Game-Mode HDR negotiates HDR10. Design: `punktfunk-planning/design/rumble-envelope-plan.md`. Implementation reviewed by an adversarial multi-agent pass; the two confirmed findings (Android sentinel collision, probe assert-vs-log) are fixed in this branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
enricobuehler added 4 commits 2026-07-11 01:09:01 +00:00
The Vulkan-layer env vars alone left hdr10_format=None on a Deck OLED: the
FROG gamescope WSI layer loads but must open a Wayland connection to
gamescope's private socket ($GAMESCOPE_WAYLAND_DISPLAY = gamescope-0) to
negotiate HDR10 via the gamescope_swapchain protocol. The Deck runs games as
X11 clients, so --socket=wayland binds nothing and that socket never enters the
sandbox → the layer silently can't reach the compositor → PQ tone-mapped to
SDR, badge dark. Bind xdg-run/gamescope-0 (as chiaki-ng does); with the layer
path + ENABLE_GAMESCOPE_WSI + the socket, the surface offers HDR10 and the
presenter's existing HDR10 swapchain path engages — no client code change.
Harmless off-Deck (the layer no-ops with no gamescope socket to bind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
XInput vibration is level-triggered — it persists until the game sets it to
zero — so a game that latches a rumble and then stops calling XInputSetState (a
residual left at a menu/loading screen, or a plain forgotten stop) drones to the
client forever (measured: a stuck (0,512) resent every 500 ms for 5.5 minutes).
A real controller stops when the app stops driving it; mirror that. Keyed on
game ACTIVITY (any SET_STATE, even an unchanged one), so a rumble the game keeps
asserting is never cut — only an abandoned residual is; kept above SDL's ~2 s
resend so an SDL-driven host game refreshes the activity clock before it fires.

This is the game-facing half of the rumble-stop story; the wire-facing half is
the self-terminating envelope model in the following commit. They compose: this
bounds a game-abandoned rumble at the host, envelopes bound a host-abandoned
rumble at the client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rumble was level-triggered, unbounded state on a lossy channel: a non-zero
level meant "buzz until further notice", healed only by the host re-sending
state every 500 ms, and every client guessed when the host had died with its own
magic timeout (SDL 1.5 s, Apple 1.6 s, Android up to 60 s). A lost stop, a
reordered start, or a dead host could drone the motor for seconds.

Make "stuck rumble" inexpressible on the wire. The 0xCA datagram grows a
length-tolerant tail — [u8 seq][u16 ttl_ms] — so it self-terminates: the host
authorizes a level for at most ttl_ms and renews it (~120 ms) while it holds,
letting an abandoned one lapse client-side. seq is a per-pad wrapping reorder
gate (reusing GamepadSnapshot::seq_newer) so a reordered stale start can't
re-light a stopped motor. Decoders read the first 7 bytes as a plain level and
ignore the tail, so no wire-version bump: an old client renders a new host's
levels, and a new client falls back to its prior staleness heuristic against an
old host (ttl = None). All four generation pairings render correctly.

- core: encode_rumble_datagram_v2 / decode_rumble_envelope (datagram.rs); the
  client demux applies the seq gate then forwards (pad, low, high, Option<ttl>);
  next_rumble is unchanged (drops ttl), next_rumble_ttl keeps it; ABI adds
  punktfunk_connection_next_rumble2 + PUNKTFUNK_RUMBLE_NO_TTL, ABI_VERSION 4->5
  (WIRE_VERSION unchanged — the tail is backward-compatible).
- host (punktfunk1.rs): the flat 500 ms refresh becomes a renewal loop that bumps
  seq + stamps a fresh TTL on active pads and drains a short post-stop zero burst,
  then goes quiet. Hatches: PUNKTFUNK_RUMBLE_ENVELOPE=0 (legacy v1 + flat refresh,
  a bisect switch), PUNKTFUNK_RUMBLE_TTL_MS (clamped [150, 5000]).
- renderers honor the TTL as their playback duration/deadline and keep their old
  heuristic only for a legacy (ttl=None) update: pf-client-core (the Deck haptic
  keep-alive is now deadline-bounded so it can't sustain a host-stopped rumble),
  clients/windows (SDL duration), android (JNI packs the lease out-of-band in bit
  48 so any u16 ttl is unambiguous; Kotlin createOneShot(ttl)), apple
  (RumbleRenderer.envelopeDeadline + nextRumble2; sessionStaleSeconds demoted to
  the legacy fallback).
- tests: codec round-trip + tail tolerance + seq-gate reorder (Rust); the probe
  asserts the v2 tail arrived under PUNKTFUNK_TEST_FEEDBACK; the Apple loopback
  asserts ttlMs round-trips end to end; RumbleTuning lease-decision cases.

The host-side idle-timeout from the previous commit is defense in depth on the
game side; this is the guarantee on the client side. Design:
punktfunk-planning/design/rumble-envelope-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chore(release): bump workspace version to 0.9.2
ci / docs-site (pull_request) Successful in 53s
ci / web (pull_request) Successful in 57s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m17s
apple / swift (pull_request) Failing after 4m46s
apple / screenshots (pull_request) Has been skipped
ci / bench (pull_request) Successful in 5m26s
android / android (pull_request) Successful in 9m39s
ci / rust (pull_request) Successful in 18m50s
01d10f46ea
Release 0.9.2 — self-terminating rumble envelopes (0xCA v2) make "stuck rumble"
inexpressible on the wire and retire the per-client staleness guesses, plus the
Windows game-abandoned-residual stop and the Steam Deck Game-Mode HDR fix
(bind gamescope's Wayland socket).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
enricobuehler added 1 commit 2026-07-11 01:17:44 +00:00
fix(apple/test): unwrap optional leaseSeconds in RumbleTuningTests
ci / web (pull_request) Successful in 51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / docs-site (pull_request) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m17s
apple / swift (pull_request) Successful in 4m50s
apple / screenshots (pull_request) Has been skipped
ci / bench (pull_request) Successful in 6m18s
android / android (pull_request) Successful in 9m4s
ci / rust (pull_request) Successful in 18m21s
4873e8925d
RumbleTuning.leaseSeconds returns TimeInterval? (nil for the no-lease sentinel);
XCTAssertEqual(_, _, accuracy:) needs a non-optional Double. Coalesce with .nan
so a nil (which must not happen for a real ttl) still fails the assertion.
Test-only — the production Swift built clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
enricobuehler scheduled this pull request to auto merge when all checks succeed 2026-07-11 01:34:59 +00:00
enricobuehler merged commit 68a1ec41c2 into main 2026-07-11 01:35:01 +00:00
enricobuehler deleted branch rumble-envelopes 2026-07-11 01:35:02 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#3