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.
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).
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.
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)
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>
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>
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 mostttl_msand renews it (~120 ms) while it holds, letting an abandoned one lapse client-side.seqis a per-pad wrapping reorder gate (reusingGamepadSnapshot::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.encode_rumble_datagram_v2/decode_rumble_envelope; demux seq gate →(pad, low, high, Option<ttl>);next_rumbleunchanged,next_rumble_ttladded; ABIpunktfunk_connection_next_rumble2+PUNKTFUNK_RUMBLE_NO_TTL,ABI_VERSION4→5 (WIRE_VERSION unchanged).PUNKTFUNK_RUMBLE_ENVELOPE=0,PUNKTFUNK_RUMBLE_TTL_MS(clamped[150, 5000]).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).PUNKTFUNK_TEST_FEEDBACK; Apple loopback assertsttlMsround-trips E2E;RumbleTuninglease 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