Files
punktfunk/crates/punktfunk-core/src/client/rumble.rs
T
enricobuehler 92f617a989
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m28s
ci / docs-site (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 3m58s
ci / rust-arm64 (pull_request) Successful in 5m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / rust (pull_request) Successful in 8m47s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m10s
Merge remote-tracking branch 'origin/main' into worktree-haptics-m12-dry
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt
#	crates/pf-client-core/src/gamepad.rs
2026-08-04 23:11:22 +02:00

737 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The rumble policy engine — one place, all platforms (`design/rumble-root-fix.md` §D).
//!
//! Historically every client re-implemented the *when/what-level* decisions (lease deadlines,
//! legacy staleness, actuator keepalives, stop-on-close), each with its own magic numbers
//! (Apple 1.6 s, Android 60 s, SDL 1.5 s, Deck 1 s + 40 ms) — five parallel policy forks, five
//! places for a stuck-rumble bug. The engine consumes seq-gated wire updates and emits
//! **effective actuator commands**: embedders never see a TTL, never own a deadline, never invent
//! a staleness constant. A platform keeps only *how to make this actuator vibrate* plus a small
//! [`ActuatorQuirks`] declaration (decay keepalive, duration floor) that parameterizes the shared
//! engine instead of forking it.
//!
//! Command sources, in priority order: an expiry zero (v2 lease ran out unrenewed — the host-died
//! safety net), a legacy-staleness zero (no-TTL host went quiet past [`LEGACY_STALE_MS`]), the
//! current level on every wire update (renewals re-emit so duration-parameterized platform APIs
//! keep getting re-armed — exactly the pre-engine cadence), and quirk keepalives (an actuator
//! whose hardware decays between renewals, e.g. the Deck's, gets sub-renewal re-kicks with an
//! optional 1-LSB jitter to defeat SDL's identical-value dedupe). On connection close the engine
//! drains one zero per still-buzzing pad before reporting closed, so every platform silences on
//! detach by contract.
//!
//! The engine replaces the bounded `RUMBLE_QUEUE` ring for embedders on the command API: state is
//! a per-pad mailbox and commands are generated on demand, so a stalled embedder wakes to ONE
//! current-level command instead of a backlog — and a stop can never be the update that an
//! overflowing queue drops.
use crate::input::MAX_PADS;
use std::sync::{Condvar, Mutex};
use std::time::{Duration, Instant};
/// The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two
/// missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s /
/// 1 s), and matches the ratio the Steam Deck ceiling shipped with.
pub const LEGACY_STALE_MS: u64 = 1000;
/// Backstop duration handed to duration-parameterized platform APIs against a legacy host (the
/// engine's staleness zero lands at 1 s; this is the hardware-level net under an engine stall).
const BACKSTOP_LEGACY_MS: u32 = 2000;
/// The longest lease the engine honours, whatever the envelope claims — the receiver-side mirror of
/// the host's own `RUMBLE_TTL_CEIL_MS`.
///
/// No host built from this tree can exceed it (the `PUNKTFUNK_RUMBLE_TTL_MS` hatch is clamped to
/// `[150, 5000]` before it reaches the wire), so this is defence in depth against a third-party or
/// modified sender that stamps a long TTL and then wedges its renewal pump while the connection
/// stays up. It matters on exactly the platforms that sustain a level for the whole lease: Apple,
/// whose renderer deliberately keeps no staleness policy of its own, and a Deck slot, whose
/// keepalive re-kicks the actuator until the lease ends. Duration-parameterized embedders (SDL,
/// Android) already self-terminate at the clamped backstop.
///
/// Deliberately NOT `pub`: an embedder has no use for it, and every `pub` const in this crate is
/// emitted into `include/punktfunk_core.h` as an UNPREFIXED `#define` — a collision hazard the
/// header already has ~170 instances of, and one this has no reason to add to.
const MAX_LEASE_MS: u16 = 5_000;
/// One effective actuator command. `(0, 0)` means stop now. `backstop_ms` is a safety-net
/// duration for platform APIs that take one (SDL rumble, Android one-shots): the engine emits
/// explicit zeros at every policy stop, so the backstop only matters if the embedder thread itself
/// stalls; platforms with explicit-stop APIs ignore it. Zero commands carry `backstop_ms == 0`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RumbleCommand {
pub pad: u16,
pub low: u16,
pub high: u16,
pub backstop_ms: u32,
}
/// A physical actuator's declared quirks — how a platform parameterizes the shared policy instead
/// of forking it. Defaults (all zero/false) describe a well-behaved actuator.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ActuatorQuirks {
/// Re-emit an unchanged non-zero level every this many ms — for actuators whose hardware
/// output decays between wire renewals. `0` = no keepalive (the common case).
///
/// The one in-tree producer is the Steam Deck's ≈ 40 ms (`pf-client-core`'s slot open, paired
/// with `dedup_jitter`). The macOS DualSense-over-HID Bluetooth decay is NOT served by this
/// quirk, though it reads like the obvious second example: the Apple client keeps its own
/// ≈ 900 ms keepalive down in `RumbleRenderer` (`RumbleTuning.hidKeepaliveSeconds`) because
/// the re-emit has to happen BELOW the command layer. An engine keepalive arrives as a
/// command carrying the same levels, and that renderer skips a HID write whose levels are
/// unchanged — so the re-emit would be swallowed by the very dedupe it exists to defeat
/// (`dedup_jitter` is the Deck's answer to the same problem one layer up).
pub keepalive_ms: u16,
/// Floor for `backstop_ms` on non-zero commands.
///
/// **No in-tree producer sets this non-zero** — it is reachable only through the C ABI
/// (`punktfunk_connection_set_rumble_quirks`), for embedders whose duration-taking API
/// rejects short values. The case it was written for is handled elsewhere: Android's
/// `createOneShot` does throw on a non-positive duration, but the Kotlin renderer floors the
/// duration itself at the call, and that path never declares quirks at all. Kept because it
/// is exported ABI, and because a floor belongs here rather than re-invented per embedder.
pub min_pulse_ms: u16,
/// Alternate the low motor's LSB on keepalive re-emits (imperceptible) so an SDL-class layer
/// that no-ops identical values still writes the device — the Deck's dedupe-defeat.
pub dedup_jitter: bool,
}
#[derive(Clone, Copy)]
struct PadState {
level: (u16, u16),
/// v2 lease expiry — `None` for a zero level or a legacy pad.
deadline: Option<Instant>,
/// Last v2 TTL (drives the backstop); 0 ⇔ legacy.
ttl_ms: u16,
/// Last wire arrival iff the pad is driven by a legacy (no-TTL) host — the staleness clock.
legacy_wire: Option<Instant>,
/// A wire update landed since the last emit (level change OR renewal — renewals re-emit).
dirty: bool,
next_keepalive: Option<Instant>,
/// The exact value last handed to an embedder. `(0, 0)` ⇔ the engine believes this actuator is
/// silent. It replaces a free-running jitter phase because one field answers all three live
/// questions: would re-sending this be a no-op device write (the dedupe nudge), is a stop
/// redundant, and would the nudge synthesize the reserved stop.
last_emit: (u16, u16),
quirks: ActuatorQuirks,
}
impl PadState {
const NEUTRAL: PadState = PadState {
level: (0, 0),
deadline: None,
ttl_ms: 0,
legacy_wire: None,
dirty: false,
next_keepalive: None,
last_emit: (0, 0),
quirks: ActuatorQuirks {
keepalive_ms: 0,
min_pulse_ms: 0,
dedup_jitter: false,
},
};
fn backstop(&self) -> u32 {
let b = if self.ttl_ms > 0 {
(2 * self.ttl_ms as u32).clamp(500, 5000)
} else {
BACKSTOP_LEGACY_MS
};
b.max(self.quirks.min_pulse_ms as u32)
}
/// Zero the pad's level + timers and produce the stop command.
fn silence(&mut self, pad: u16) -> RumbleCommand {
self.level = (0, 0);
self.deadline = None;
self.legacy_wire = None;
self.next_keepalive = None;
self.dirty = false;
self.last_emit = (0, 0);
RumbleCommand {
pad,
low: 0,
high: 0,
backstop_ms: 0,
}
}
/// Build the command for the pad's current level, and record what we handed out.
///
/// On a `dedup_jitter` actuator, re-emitting the value the device last took is a no-op write on
/// an SDL-class layer, so the low motor's LSB is nudged. Keying that on `last_emit` rather than
/// on a free-running phase is what makes it work on EVERY emit path. Previously the nudge lived
/// only in the keepalive branch, so a host renewal — which arrives every `ttl*3/10` ms, 120 ms
/// at the 400 ms default and 60 ms at the hatch floor — re-emitted the raw level, collided with
/// the last jittered write, was swallowed, AND re-anchored the keepalive. That stretched the
/// gap between *distinct* device writes to 80 ms at the default cadence and 100 ms at the
/// floor, on an actuator whose quirk declares 40.
///
/// The nudge is refused when it would synthesize the reserved `(0, 0)` stop. That is level
/// `(1, 0)` and only that: `high` must already be 0, and `low ^ 1 == 0` implies `low == 1`.
/// There the LSB steps up instead, so the phase still alternates (1 ↔ 3, two parts in 65535)
/// and the pad never receives a stop the policy did not order.
fn emit(&mut self, pad: u16) -> RumbleCommand {
let (mut low, high) = self.level;
if self.quirks.dedup_jitter && (low, high) == self.last_emit {
let alt = low ^ 1;
low = if (alt, high) == (0, 0) {
low | 0b10
} else {
alt
};
}
self.last_emit = (low, high);
RumbleCommand {
pad,
low,
high,
backstop_ms: self.backstop(),
}
}
}
/// The pure per-connection policy state machine. Time is always passed in (`now`) so the policy
/// is deterministic and table-testable — the [`RumbleShared`] wrapper owns the real clock.
pub(crate) struct RumbleEngine {
pads: [PadState; MAX_PADS],
}
fn merge_wake(wake: &mut Option<Instant>, t: Instant) {
*wake = Some(wake.map_or(t, |w| w.min(t)));
}
impl RumbleEngine {
pub(crate) fn new() -> RumbleEngine {
RumbleEngine {
pads: [PadState::NEUTRAL; MAX_PADS],
}
}
/// Fold one seq-gated wire update in. Every update dirties the pad (renewals re-emit so
/// platform duration timers re-arm); a v2 update replaces the lease deadline, a legacy update
/// refreshes the staleness clock.
pub(crate) fn wire_update(
&mut self,
now: Instant,
pad: u16,
low: u16,
high: u16,
ttl_ms: Option<u16>,
) {
let Some(p) = self.pads.get_mut(pad as usize) else {
return;
};
p.level = (low, high);
p.dirty = true;
match ttl_ms {
Some(t) => {
// Never honour a lease longer than [`MAX_LEASE_MS`], whatever the sender claims.
let t = t.min(MAX_LEASE_MS);
p.ttl_ms = t;
p.legacy_wire = None;
p.deadline = if (low, high) != (0, 0) {
Some(now + Duration::from_millis(t as u64))
} else {
None
};
}
None => {
p.ttl_ms = 0;
p.deadline = None;
p.legacy_wire = Some(now);
}
}
}
pub(crate) fn set_quirks(&mut self, pad: u16, q: ActuatorQuirks) {
if let Some(p) = self.pads.get_mut(pad as usize) {
p.quirks = q;
// A changed cadence re-derives from the next emit/poll; a stale scheduled kick from
// the old cadence is harmless, a cleared quirk must cancel it.
if q.keepalive_ms == 0 {
p.next_keepalive = None;
}
}
}
/// Produce the next due command at `now`, if any, plus the earliest future instant something
/// becomes due (the caller's wake deadline; `None` = nothing scheduled, wait for wire input).
pub(crate) fn poll(&mut self, now: Instant) -> (Option<RumbleCommand>, Option<Instant>) {
let mut wake: Option<Instant> = None;
for i in 0..MAX_PADS {
let p = &mut self.pads[i];
let pad = i as u16;
if p.level != (0, 0) {
// 1) v2 lease expiry — the host stopped renewing (died / stopped caring). This
// firing in the wild is the signature of a host-side bug: worth a log line.
if let Some(d) = p.deadline {
if now >= d {
tracing::info!(pad, "rumble: envelope expired unrenewed — silencing");
return (Some(p.silence(pad)), None);
}
merge_wake(&mut wake, d);
}
// 2) legacy-host staleness — the uniform replacement for every per-platform bound.
if let Some(lw) = p.legacy_wire {
let stale_at = lw + Duration::from_millis(LEGACY_STALE_MS);
if now >= stale_at {
tracing::debug!(pad, "rumble: legacy host went quiet — silencing");
return (Some(p.silence(pad)), None);
}
merge_wake(&mut wake, stale_at);
}
}
// 3) a wire update to relay (level change or renewal re-arm).
if p.dirty {
p.dirty = false;
if p.level == (0, 0) {
// Relay a stop only if the actuator is, as far as the engine knows, still
// buzzing. A zero on an already-silent pad heals nothing and costs every
// embedder a command — Android an unconditional log line plus a binder
// `cancel()`. Two senders produce them: the host's deliberate
// `RUMBLE_STOP_BURST` re-sends after the first stop already landed, and (behind
// `PUNKTFUNK_RUMBLE_ENVELOPE=0`) the legacy flat 500 ms refresh, which re-sends
// zeros for every latched pad for the rest of the session. The burst still
// heals the case it exists for: a LOST first stop leaves the pad buzzing, so
// `last_emit != (0, 0)` and the re-send does emit.
if p.last_emit != (0, 0) {
return (Some(p.silence(pad)), None);
}
continue;
}
if p.quirks.keepalive_ms > 0 {
p.next_keepalive =
Some(now + Duration::from_millis(p.quirks.keepalive_ms as u64));
}
return (Some(p.emit(pad)), None);
}
// 4) actuator-decay keepalive, bounded by (1)/(2) above by construction: an expired
// or stale pad was silenced before reaching here, so a keepalive can never sustain a
// level the policy has ended.
if p.level != (0, 0) && p.quirks.keepalive_ms > 0 {
let ka = Duration::from_millis(p.quirks.keepalive_ms as u64);
let due = *p.next_keepalive.get_or_insert(now + ka);
if now >= due {
p.next_keepalive = Some(now + ka);
return (Some(p.emit(pad)), None);
}
merge_wake(&mut wake, due);
}
}
(None, wake)
}
/// Close drain: one stop per still-buzzing pad (call until `None`), so a session drop mid-buzz
/// silences every platform by contract instead of by per-client accident.
pub(crate) fn close_drain(&mut self) -> Option<RumbleCommand> {
for i in 0..MAX_PADS {
if self.pads[i].level != (0, 0) {
return Some(self.pads[i].silence(i as u16));
}
}
None
}
}
/// The engine behind a lock + condvar — the demux thread feeds it, one embedder thread polls it.
pub(crate) struct RumbleShared {
inner: Mutex<SharedState>,
cv: Condvar,
}
struct SharedState {
engine: RumbleEngine,
closed: bool,
}
/// Sets `closed` (and wakes the poller) when the owner — the datagram demux task — ends for any
/// reason, so the embedder's command poll always observes connection teardown.
pub(crate) struct RumbleFeed(pub(crate) std::sync::Arc<RumbleShared>);
impl RumbleFeed {
pub(crate) fn wire_update(&self, pad: u16, low: u16, high: u16, ttl_ms: Option<u16>) {
let mut g = self.0.inner.lock().unwrap();
g.engine.wire_update(Instant::now(), pad, low, high, ttl_ms);
drop(g);
self.0.cv.notify_all();
}
}
impl Drop for RumbleFeed {
fn drop(&mut self) {
self.0.inner.lock().unwrap().closed = true;
self.0.cv.notify_all();
}
}
impl RumbleShared {
pub(crate) fn new() -> RumbleShared {
RumbleShared {
inner: Mutex::new(SharedState {
engine: RumbleEngine::new(),
closed: false,
}),
cv: Condvar::new(),
}
}
pub(crate) fn set_quirks(&self, pad: u16, q: ActuatorQuirks) {
self.inner.lock().unwrap().engine.set_quirks(pad, q);
self.cv.notify_all();
}
/// Block up to `timeout` for the next effective command. `Ok(Some)` = a command, `Ok(None)` =
/// timeout, `Err(Closed)` = the connection ended AND the close-drain zeros were all delivered.
pub(crate) fn next_command(&self, timeout: Duration) -> Result<Option<RumbleCommand>, Closed> {
let overall = Instant::now() + timeout;
let mut g = self.inner.lock().unwrap();
loop {
let now = Instant::now();
let (cmd, wake) = g.engine.poll(now);
if let Some(c) = cmd {
return Ok(Some(c));
}
if g.closed {
return match g.engine.close_drain() {
Some(c) => Ok(Some(c)),
None => Err(Closed),
};
}
let until = wake.map_or(overall, |w| w.min(overall));
if until <= now {
if now >= overall {
return Ok(None);
}
continue;
}
let (guard, _) = self.cv.wait_timeout(g, until - now).unwrap();
g = guard;
}
}
}
/// The connection ended and every close-drain stop has been delivered.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Closed;
#[cfg(test)]
mod tests {
use super::*;
/// The Steam Deck's declared quirks — the only shipping actuator with `dedup_jitter`.
const DECK: ActuatorQuirks = ActuatorQuirks {
keepalive_ms: 40,
min_pulse_ms: 0,
dedup_jitter: true,
};
/// Drain the engine the way an embedder does: poll until nothing is due.
fn drain(e: &mut RumbleEngine, t: Instant) -> Vec<(u16, u16)> {
let mut out = Vec::new();
while let (Some(c), _) = e.poll(t) {
out.push((c.low, c.high));
}
out
}
fn ms(v: u64) -> Duration {
Duration::from_millis(v)
}
fn cmd(pad: u16, low: u16, high: u16, backstop_ms: u32) -> RumbleCommand {
RumbleCommand {
pad,
low,
high,
backstop_ms,
}
}
#[test]
fn v2_level_emits_and_expires_at_the_lease() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 0x4000, 0x8000, Some(400));
assert_eq!(e.poll(t0).0, Some(cmd(0, 0x4000, 0x8000, 800))); // backstop = 2×ttl
// No renewal: at the deadline the engine self-silences — the host-died safety net.
let (c, wake) = e.poll(t0 + ms(200));
assert_eq!(c, None);
assert_eq!(wake, Some(t0 + ms(400)));
assert_eq!(e.poll(t0 + ms(400)).0, Some(cmd(0, 0, 0, 0)));
assert_eq!(e.poll(t0 + ms(500)), (None, None)); // silenced — nothing scheduled
}
#[test]
fn renewal_re_emits_and_extends_the_deadline() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 0, Some(400));
assert!(e.poll(t0).0.is_some());
// A same-level renewal at t+300 re-emits (platform duration timers re-arm) and pushes the
// deadline to t+700 — so t+500 (past the ORIGINAL deadline) still rumbles.
e.wire_update(t0 + ms(300), 0, 100, 0, Some(400));
assert_eq!(e.poll(t0 + ms(300)).0, Some(cmd(0, 100, 0, 800)));
assert_eq!(e.poll(t0 + ms(500)).0, None);
assert_eq!(e.poll(t0 + ms(700)).0, Some(cmd(0, 0, 0, 0)));
}
#[test]
fn explicit_stop_is_immediate_and_cancels_the_lease() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 2, 500, 500, Some(400));
assert!(e.poll(t0).0.is_some());
e.wire_update(t0 + ms(50), 2, 0, 0, Some(0));
assert_eq!(e.poll(t0 + ms(50)).0, Some(cmd(2, 0, 0, 0)));
assert_eq!(e.poll(t0 + ms(600)), (None, None)); // no phantom expiry later
}
#[test]
fn legacy_host_gets_the_uniform_staleness_bound() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 300, 0, None); // legacy: no TTL
assert_eq!(e.poll(t0).0, Some(cmd(0, 300, 0, 2000)));
// The legacy 500 ms refresh keeps it alive…
e.wire_update(t0 + ms(500), 0, 300, 0, None);
assert_eq!(e.poll(t0 + ms(500)).0, Some(cmd(0, 300, 0, 2000)));
assert_eq!(e.poll(t0 + ms(1400)).0, None); // 900 ms since last wire — inside the bound
// …and one second of silence cuts it, on every platform alike.
assert_eq!(e.poll(t0 + ms(1500)).0, Some(cmd(0, 0, 0, 0)));
}
#[test]
fn keepalive_rekicks_with_jitter_and_never_outlives_the_lease() {
let mut e = RumbleEngine::new();
e.set_quirks(
0,
ActuatorQuirks {
keepalive_ms: 40,
min_pulse_ms: 0,
dedup_jitter: true,
},
);
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(400));
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800)));
// Keepalives at the quirk cadence, alternating the low LSB to defeat SDL's dedupe.
assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 101, 200, 800)));
assert_eq!(e.poll(t0 + ms(80)).0, Some(cmd(0, 100, 200, 800)));
// At the lease deadline the EXPIRY wins — a keepalive can never sustain an ended level.
assert_eq!(e.poll(t0 + ms(400)).0, Some(cmd(0, 0, 0, 0)));
assert_eq!(e.poll(t0 + ms(440)), (None, None));
}
#[test]
fn quirk_registered_mid_rumble_starts_keepalives() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 0, Some(400));
assert!(e.poll(t0).0.is_some());
e.set_quirks(
0,
ActuatorQuirks {
keepalive_ms: 40,
min_pulse_ms: 0,
dedup_jitter: false,
},
);
// First poll schedules from `now`; the next cadence tick emits.
let (c, wake) = e.poll(t0 + ms(10));
assert_eq!(c, None);
assert_eq!(wake, Some(t0 + ms(50)));
assert_eq!(e.poll(t0 + ms(50)).0, Some(cmd(0, 100, 0, 800)));
}
#[test]
fn min_pulse_floors_the_backstop() {
let mut e = RumbleEngine::new();
e.set_quirks(
0,
ActuatorQuirks {
keepalive_ms: 0,
min_pulse_ms: 5000,
dedup_jitter: false,
},
);
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 0, Some(100));
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 0, 5000)));
}
#[test]
fn close_drain_silences_every_buzzing_pad_once() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 0, Some(400));
e.wire_update(t0, 3, 0, 900, Some(400));
let _ = e.poll(t0);
let _ = e.poll(t0);
let a = e.close_drain().unwrap();
let b = e.close_drain().unwrap();
assert_eq!((a.pad, a.low, a.high), (0, 0, 0));
assert_eq!((b.pad, b.low, b.high), (3, 0, 0));
assert_eq!(e.close_drain(), None);
}
#[test]
fn stalled_embedder_wakes_to_one_current_command_not_a_backlog() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
// 20 renewals landed while the embedder was stalled — state, not a queue: exactly one
// command comes out, carrying the latest level.
for k in 0..20u64 {
e.wire_update(t0 + ms(k * 120), 0, 100 + k as u16, 0, Some(400));
}
let t = t0 + ms(20 * 120);
assert_eq!(e.poll(t).0, Some(cmd(0, 119, 0, 800)));
assert_eq!(e.poll(t).0, None);
}
#[test]
fn shared_close_delivers_drain_zero_then_closed() {
let shared = std::sync::Arc::new(RumbleShared::new());
let feed = RumbleFeed(shared.clone());
feed.wire_update(1, 100, 0, Some(400));
assert_eq!(
shared.next_command(ms(100)).unwrap().unwrap(),
cmd(1, 100, 0, 800)
);
drop(feed); // demux ended
assert_eq!(
shared.next_command(ms(100)).unwrap().unwrap(),
cmd(1, 0, 0, 0)
);
assert_eq!(shared.next_command(ms(10)), Err(Closed));
}
/// A host renewal must not repeat the value the device last took, or an SDL-class layer
/// swallows the write. Before the jitter moved onto every emit path it lived only in the
/// keepalive branch, so each renewal collided with the last jittered write and was deduped.
#[test]
fn renewal_keeps_the_dedupe_jitter_alternating() {
let mut e = RumbleEngine::new();
e.set_quirks(0, DECK);
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(400));
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(101, 200)]);
assert_eq!(drain(&mut e, t0 + ms(80)), vec![(100, 200)]);
// The renewal at the 120 ms default cadence: same level, must still be a distinct write.
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
assert_eq!(drain(&mut e, t0 + ms(120)), vec![(101, 200)]);
assert_eq!(drain(&mut e, t0 + ms(160)), vec![(100, 200)]);
}
/// Phase-robust version of the same property, at the TTL hatch's 60 ms renewal floor: no two
/// consecutive DISTINCT device writes may be further apart than the declared 40 ms cadence.
#[test]
fn renewal_never_gaps_distinct_writes_at_the_60ms_floor() {
let mut e = RumbleEngine::new();
e.set_quirks(0, DECK);
let t0 = Instant::now();
let (mut last, mut last_write, mut worst) = ((0u16, 0u16), 0u64, 0u64);
for tick in 0..=360u64 {
let t = t0 + ms(tick);
if tick % 60 == 0 {
e.wire_update(t, 0, 100, 200, Some(400));
}
for v in drain(&mut e, t) {
assert_ne!(v, (0, 0), "a live lease must never emit the stop sentinel");
if v != last {
worst = worst.max(tick - last_write);
last_write = tick;
last = v;
}
}
}
assert!(
worst <= 41,
"worst distinct-write gap {worst} ms exceeds the 40 ms declared cadence"
);
}
/// The nudge must stay behind `dedup_jitter`: an off-by-one amplitude on a default-quirks pad
/// would land in Apple's identical-target comparison and Android's one-shot amplitudes.
#[test]
fn default_quirks_pads_get_the_level_verbatim_on_every_renewal() {
let mut e = RumbleEngine::new(); // Apple / Android / plain SDL
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(400));
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800)));
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
assert_eq!(e.poll(t0 + ms(120)).0, Some(cmd(0, 100, 200, 800)));
}
/// Level `(1, 0)` is the one value whose LSB flip is the reserved stop. The nudge steps up
/// instead, so the phase still alternates and no stop is invented under a live lease.
#[test]
fn jitter_never_synthesizes_the_stop_sentinel() {
let mut e = RumbleEngine::new();
e.set_quirks(0, DECK);
let t0 = Instant::now();
e.wire_update(t0, 0, 1, 0, Some(400));
assert_eq!(e.poll(t0).0, Some(cmd(0, 1, 0, 800)));
assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 3, 0, 800)));
assert_eq!(e.poll(t0 + ms(80)).0, Some(cmd(0, 1, 0, 800)));
}
/// A zero for a pad the engine already believes is silent is dropped: it heals nothing and
/// costs every embedder a command. The deliberate stop-burst heal is unaffected, because a
/// LOST stop leaves the pad buzzing and the re-send therefore does emit.
#[test]
fn a_redundant_stop_is_dropped_but_the_burst_still_heals_a_lost_one() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(400));
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
// First stop reaches the embedder…
e.wire_update(t0 + ms(10), 0, 0, 0, Some(0));
assert_eq!(drain(&mut e, t0 + ms(10)), vec![(0, 0)]);
// …and the burst re-sends behind it are now silent.
e.wire_update(t0 + ms(20), 0, 0, 0, Some(0));
e.wire_update(t0 + ms(30), 0, 0, 0, Some(0));
assert_eq!(drain(&mut e, t0 + ms(30)), Vec::new());
// But if the pad is buzzing (the stop that mattered was lost), a re-send still emits.
e.wire_update(t0 + ms(40), 0, 100, 200, Some(400));
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(100, 200)]);
e.wire_update(t0 + ms(50), 0, 0, 0, Some(0));
assert_eq!(drain(&mut e, t0 + ms(50)), vec![(0, 0)]);
}
/// The client bounds the host's lease. `RUMBLE_TTL_CEIL_MS` is sender-side only, so a modified
/// or third-party host could otherwise stamp a huge TTL and wedge its pump, leaving Apple and
/// the Deck buzzing for the whole of it.
#[test]
fn an_overlong_lease_is_clamped_to_the_ceiling() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(u16::MAX));
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 5000)));
// Silenced at the ceiling, not at the 65 s the sender asked for.
assert!(e.poll(t0 + ms(MAX_LEASE_MS as u64 - 1)).0.is_none());
assert_eq!(
e.poll(t0 + ms(MAX_LEASE_MS as u64)).0,
Some(cmd(0, 0, 0, 0)),
"the lease must end at the ceiling"
);
}
/// A v2 envelope carrying `ttl_ms == 0` on a LIVE level. The audit suspected the zero would be
/// mistaken for the legacy sentinel in `backstop()`; it cannot, because the expiry check
/// preempts the relay branch — the pad silences on the same poll and never reaches a backstop.
/// Pinned so that ordering stays load-bearing rather than incidental.
#[test]
fn a_zero_ttl_envelope_silences_rather_than_taking_the_legacy_backstop() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(0));
assert_eq!(
e.poll(t0).0,
Some(cmd(0, 0, 0, 0)),
"a zero-length lease must expire immediately, not emit with a legacy backstop"
);
}
}