feat(rumble): host-authoritative self-terminating envelopes (0xCA v2)
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>
This commit is contained in:
@@ -21,7 +21,11 @@
|
||||
// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
|
||||
// v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake —
|
||||
// the display-side companion to dial-first, so saved-host "online" pips reflect real reachability).
|
||||
#define ABI_VERSION 4
|
||||
// v5: added `punktfunk_connection_next_rumble2` (rumble pull that also yields the self-terminating
|
||||
// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
|
||||
// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
|
||||
// [`WIRE_VERSION`] is unchanged.
|
||||
#define ABI_VERSION 5
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
@@ -155,6 +159,11 @@
|
||||
// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
|
||||
#define PUNKTFUNK_CODEC_AV1 4
|
||||
|
||||
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
|
||||
// datagram — an old host that sent no self-termination lease. The client then falls back to its
|
||||
// own staleness heuristic for that update instead of a host-supplied deadline.
|
||||
#define PUNKTFUNK_RUMBLE_NO_TTL 4294967295
|
||||
|
||||
// 16-byte AEAD authentication tag appended by GCM.
|
||||
#define TAG_LEN 16
|
||||
|
||||
@@ -289,6 +298,19 @@
|
||||
#define HIDOUT_MAGIC 205
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Wire length of a v1 (legacy, level) rumble datagram.
|
||||
#define RUMBLE_V1_LEN 7
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Wire length of a v2 (envelope) rumble datagram — the v1 body plus a `[u8 seq][u16 ttl_ms LE]`
|
||||
// tail. Decoders are length-tolerant (see [`decode_rumble_envelope`]): an old client reads the
|
||||
// first 7 bytes as a plain level and ignores the tail, so no wire-version bump is needed — the
|
||||
// same dual-size idiom the HDR-luminance `AddRequest` tail uses.
|
||||
#define RUMBLE_V2_LEN 10
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
|
||||
// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
|
||||
@@ -1291,6 +1313,10 @@ PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c,
|
||||
// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
||||
//
|
||||
// This drops the self-terminating TTL of a v2 rumble envelope — an embedder that only calls this
|
||||
// keeps its own staleness policy, exactly as before. Use [`punktfunk_connection_next_rumble2`] to
|
||||
// honor the host-supplied lease and delete the client-side timeout heuristics.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At
|
||||
// most one thread pulls rumble — it may run concurrently with the video/audio pullers.
|
||||
@@ -1301,6 +1327,25 @@ PunktfunkStatus punktfunk_connection_next_rumble(PunktfunkConnection *c,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next rumble update *including its self-termination TTL* (v2 envelopes), waiting up to
|
||||
// `timeout_ms`. Same `pad`/`low`/`high` semantics as [`punktfunk_connection_next_rumble`], plus
|
||||
// `*ttl_ms`: how long (milliseconds) to render this level before silencing unless the host renews
|
||||
// it. [`PUNKTFUNK_RUMBLE_NO_TTL`] means "no lease" — a legacy host; fall back to a client-side
|
||||
// timeout. The reorder gate (seq) is applied inside the core before the update surfaces here, so a
|
||||
// stale/reordered envelope never reaches the caller.
|
||||
//
|
||||
// # Safety
|
||||
// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
|
||||
// thread pulls rumble — it may run concurrently with the video/audio pullers.
|
||||
PunktfunkStatus punktfunk_connection_next_rumble2(PunktfunkConnection *c,
|
||||
uint16_t *pad,
|
||||
uint16_t *low,
|
||||
uint16_t *high,
|
||||
uint32_t *ttl_ms,
|
||||
uint32_t timeout_ms);
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger)
|
||||
// the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on
|
||||
|
||||
Reference in New Issue
Block a user