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:
2026-07-11 03:08:27 +02:00
parent 19e9828e8d
commit 73c911cae4
17 changed files with 734 additions and 91 deletions
+79 -4
View File
@@ -42,8 +42,12 @@ pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
Some((seq, pts_ns, &b[13..]))
}
/// Rumble datagram, host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop).
/// Legacy rumble datagram (v1), host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop) as *level-triggered* state
/// — it persists until superseded, which is why the host re-sends it periodically as its loss
/// heal. New hosts emit the self-terminating [`encode_rumble_datagram_v2`] instead; this is kept
/// for the loopback tests and as the wire an old host still speaks (a new client decodes both via
/// [`decode_rumble_envelope`]).
pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] {
let mut b = [0u8; 7];
b[0] = RUMBLE_MAGIC;
@@ -53,15 +57,86 @@ pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] {
b
}
/// Parse a rumble datagram → `(pad, low, high)`. `None` on bad tag/length.
/// Wire length of a v1 (legacy, level) rumble datagram.
pub const RUMBLE_V1_LEN: usize = 7;
/// 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.
pub const RUMBLE_V2_LEN: usize = 10;
/// Rumble envelope datagram (v2), host → client:
/// `[0xCA][u16 pad LE][u16 low LE][u16 high LE][u8 seq][u16 ttl_ms LE]`.
///
/// A *self-terminating* force-feedback command: the level is authorized for at most `ttl_ms`, so
/// a rumble the host stops renewing (or a host that dies) silences on its own — "stuck forever"
/// is inexpressible on the wire. `seq` is a per-pad wrapping counter (bumped on every send,
/// changes *and* renewals) compared with [`GamepadSnapshot::seq_newer`](crate::input::GamepadSnapshot::seq_newer)
/// so a reordered stale start can't re-light the motors after a stop. Renewals fully replace the
/// prior envelope's deadline; they never stack. An explicit stop is still `low == high == 0` sent
/// immediately (expiry is the safety net, never the stop mechanism).
pub fn encode_rumble_datagram_v2(pad: u16, low: u16, high: u16, seq: u8, ttl_ms: u16) -> [u8; 10] {
let mut b = [0u8; RUMBLE_V2_LEN];
b[0] = RUMBLE_MAGIC;
b[1..3].copy_from_slice(&pad.to_le_bytes());
b[3..5].copy_from_slice(&low.to_le_bytes());
b[5..7].copy_from_slice(&high.to_le_bytes());
b[7] = seq;
b[8..10].copy_from_slice(&ttl_ms.to_le_bytes());
b
}
/// The self-termination tail of a v2 rumble envelope (see [`encode_rumble_datagram_v2`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RumbleEnvelope {
/// Per-pad wrapping send counter — the reorder gate (see [`decode_rumble_envelope`]).
pub seq: u8,
/// How long, in ms, this envelope authorizes the stated level before the client must silence.
pub ttl_ms: u16,
}
/// A decoded rumble update. `envelope` is `None` for a legacy 7-byte datagram (an old host, which
/// has no seq/ttl — the client applies its own staleness policy), `Some` for a v2 envelope.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RumbleUpdate {
pub pad: u16,
pub low: u16,
pub high: u16,
pub envelope: Option<RumbleEnvelope>,
}
/// Parse a rumble datagram → `(pad, low, high)`, tolerating (and ignoring) a v2 envelope tail.
/// `None` on bad tag/length. Kept for callers that only need the level (the probe, the loopback
/// assertions); clients that honor TTL use [`decode_rumble_envelope`].
pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> {
if b.len() < 7 || b[0] != RUMBLE_MAGIC {
if b.len() < RUMBLE_V1_LEN || b[0] != RUMBLE_MAGIC {
return None;
}
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
Some((u16at(1), u16at(3), u16at(5)))
}
/// Parse a rumble datagram → [`RumbleUpdate`], detecting the v2 envelope tail by length. A
/// `>= RUMBLE_V2_LEN` buffer carries `seq`/`ttl_ms`; a 7..RUMBLE_V2_LEN buffer is a legacy level
/// (`envelope: None`) — the same tolerance as an old client would apply, so a torn/short tail
/// degrades to a level rather than dropping. `None` on bad tag/length.
pub fn decode_rumble_envelope(b: &[u8]) -> Option<RumbleUpdate> {
if b.len() < RUMBLE_V1_LEN || b[0] != RUMBLE_MAGIC {
return None;
}
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
let envelope = (b.len() >= RUMBLE_V2_LEN).then(|| RumbleEnvelope {
seq: b[7],
ttl_ms: u16::from_le_bytes([b[8], b[9]]),
});
Some(RumbleUpdate {
pad: u16at(1),
low: u16at(3),
high: u16at(5),
envelope,
})
}
/// Mic datagram, client → host: `[0xCB][u32 seq LE][u64 pts_ns LE][opus payload]` — the same
/// layout as [`encode_audio_datagram`] with [`MIC_MAGIC`], one Opus frame per datagram.
pub fn encode_mic_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
+73
View File
@@ -949,6 +949,79 @@ fn rumble_datagram_roundtrip() {
assert!(decode_rumble_datagram(&d[..6]).is_none());
}
#[test]
fn rumble_envelope_roundtrip_and_legacy_tolerance() {
// v2 envelope round-trips seq + ttl.
let d = encode_rumble_datagram_v2(2, 0x4000, 0x8000, 7, 400);
assert_eq!(d[0], RUMBLE_MAGIC);
assert_eq!(d.len(), RUMBLE_V2_LEN);
assert_eq!(
decode_rumble_envelope(&d),
Some(RumbleUpdate {
pad: 2,
low: 0x4000,
high: 0x8000,
envelope: Some(RumbleEnvelope {
seq: 7,
ttl_ms: 400
}),
})
);
// The legacy level decoder reads a v2 datagram as a plain level — the tail is ignored, so an
// old client running against a new host still renders the right amplitudes.
assert_eq!(decode_rumble_datagram(&d), Some((2, 0x4000, 0x8000)));
// A legacy 7-byte datagram (old host) decodes as a level with no envelope — a new client then
// applies its own staleness policy.
let v1 = encode_rumble_datagram(3, 0x1111, 0x2222);
assert_eq!(
decode_rumble_envelope(&v1),
Some(RumbleUpdate {
pad: 3,
low: 0x1111,
high: 0x2222,
envelope: None,
})
);
// A torn/short tail (8 or 9 bytes) is not a valid envelope — degrade to a level, never panic
// or drop. (The host never emits these; a truncating middlebox might.)
assert_eq!(
decode_rumble_envelope(&d[..8]).map(|u| u.envelope),
Some(None)
);
assert_eq!(
decode_rumble_envelope(&d[..9]).map(|u| u.envelope),
Some(None)
);
// Bad tag / too short → None on both decoders.
assert!(decode_rumble_envelope(&d[..6]).is_none());
let mut wrong_tag = d;
wrong_tag[0] = AUDIO_MAGIC;
assert!(decode_rumble_envelope(&wrong_tag).is_none());
}
#[test]
fn rumble_envelope_seq_gate_drops_reordered_stale_start() {
use crate::input::GamepadSnapshot;
// The client-side reorder gate (reused verbatim from gamepad snapshots): a stale start
// arriving after a stop must not re-light the motors.
let stop = decode_rumble_envelope(&encode_rumble_datagram_v2(0, 0, 0, 10, 0)).unwrap();
let stale_start =
decode_rumble_envelope(&encode_rumble_datagram_v2(0, 0x8000, 0x8000, 9, 400)).unwrap();
let stop_seq = stop.envelope.unwrap().seq;
let stale_seq = stale_start.envelope.unwrap().seq;
// Nothing applied yet → the first update always passes.
assert!(GamepadSnapshot::seq_newer(stop_seq, None));
// The reordered older start does NOT supersede the stop.
assert!(!GamepadSnapshot::seq_newer(stale_seq, Some(stop_seq)));
// A genuine later renewal does.
assert!(GamepadSnapshot::seq_newer(11, Some(stop_seq)));
// Wraps: seq 1 supersedes 254.
assert!(GamepadSnapshot::seq_newer(1, Some(254)));
}
#[test]
fn mic_datagram_roundtrip_and_disjoint_from_audio() {
let opus = [0x5Au8; 80];