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>
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>
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>
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>
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>