feat(core,clients): one rumble policy engine for every platform (rumble root fix D)
punktfunk-core client/rumble.rs: a per-connection policy engine consumes seq-gated wire
updates and emits EFFECTIVE actuator commands — re-emits on renewals (duration APIs stay
re-armed), self-silences at the v2 lease, a UNIFORM 1 s legacy-host staleness replacing the
per-platform zoo (Apple 1.6 s / Android 60 s / SDL 1.5 s / Deck 1 s), quirk-declared
actuator keepalives (Deck 40 ms + LSB dedupe-defeat jitter), and one stop per buzzing pad
on connection close. Per-pad mailbox semantics: a stalled embedder wakes to ONE current
command, and a stop can structurally never be the update an overflowing queue drops.
New API/ABI: NativeClient::{next_rumble_command,set_rumble_quirks} +
punktfunk_connection_next_rumble_cmd/_set_rumble_quirks (next_rumble/next_rumble2 stay for
un-migrated embedders; both consumers are fed). Migrations DELETE the platform forks:
pf-client-core loses RumbleState + the Deck keepalive loop + LEGACY_RUMBLE_CEILING_MS and
physically silences a slot at close; Android loses the 60 s legacy one-shot (backstop
repack, cancel-on-zero); Apple loses envelopeDeadline + sessionStaleSeconds + both tick
watchdogs (CoreHaptics realization untouched; mac xcframework rebuilt locally).
design/rumble-root-fix.md par. D. Engine 10/10 unit tests; core tests 176 Linux / 175
Windows + clippy -D warnings; swift build + RumbleTuningTests; Kotlin + android-native
compile green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
//!
|
||||
//! This thread is also the single consumer of the rumble and HID-output pull planes.
|
||||
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::client::{ActuatorQuirks, NativeClient};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
@@ -61,24 +61,14 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
|
||||
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// Steam Deck built-in haptic keep-alive interval. The Deck's actuator decays inside SDL's
|
||||
/// ~2 s internal rumble resend (`SDL_RUMBLE_RESEND_MS`), and SDL short-circuits a repeated
|
||||
/// identical `set_rumble` value to a no-op device write — so a STEADY host value (which the
|
||||
/// host delivers only as unchanging 500 ms refreshes) never re-kicks the motor and is felt as
|
||||
/// a periodic pulse. We re-issue below the decay so the bursts fuse into a continuous buzz;
|
||||
/// 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. Deck-only (see
|
||||
/// [`Worker::issue_rumble`]); every other pad sustains rumble at the hardware level and is
|
||||
/// left untouched.
|
||||
const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40;
|
||||
|
||||
/// Ceiling on a *legacy* (no-TTL) host's Steam Deck rumble: silence the actuator once a real host
|
||||
/// update has been absent this long. A legacy host re-sends the held level as a flat 500 ms refresh,
|
||||
/// so a genuinely-held rumble refreshes the per-slot update clock (`RumbleState::updated_at`) every
|
||||
/// 500 ms and never approaches this — only a lost *stop* datagram (the host went quiet entirely)
|
||||
/// lets the 40 ms keep-alive drone on. 2× the 500 ms refresh bounds that lost stop to ~1 s,
|
||||
/// mirroring the Windows host's `RUMBLE_IDLE_TIMEOUT` residual cutoff. The v2 path is bounded by its
|
||||
/// lease `deadline` instead and never trips this (see [`Worker::render_feedback`]).
|
||||
const LEGACY_RUMBLE_CEILING_MS: u64 = 1_000;
|
||||
/// Steam Deck actuator-decay keepalive cadence, declared to the core's rumble policy engine as an
|
||||
/// [`ActuatorQuirks`] at slot open. The Deck's built-in actuator decays inside SDL's ~2 s internal
|
||||
/// rumble resend (`SDL_RUMBLE_RESEND_MS`) and SDL short-circuits an identical `set_rumble` value
|
||||
/// to a no-op device write — so a steady level is felt as a periodic pulse without sub-decay
|
||||
/// re-kicks; 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. The engine owns the
|
||||
/// re-kick timing, the 1-LSB dedupe-defeat jitter, and every staleness/lease bound — this worker
|
||||
/// only applies the commands it emits (`design/rumble-root-fix.md` §D).
|
||||
const DECK_RUMBLE_KEEPALIVE_MS: u16 = 40;
|
||||
|
||||
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
|
||||
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
|
||||
@@ -626,32 +616,6 @@ impl Ds5Feedback {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-controller rumble render state (the Steam Deck keep-alive + the host's v2 lease). Held
|
||||
/// per [`Slot`] so a rumble the host addressed to pad N drives only pad N's actuator.
|
||||
#[derive(Default)]
|
||||
struct RumbleState {
|
||||
/// Last rumble value handed to this pad (the logical host value, pre-jitter) and when —
|
||||
/// drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`].
|
||||
last: (u16, u16),
|
||||
last_at: Option<Instant>,
|
||||
/// When the last *real* host rumble datagram landed on this slot — set only in the feedback
|
||||
/// drain, never bumped by the Deck keep-alive re-kick (unlike `last_at`, which the keep-alive
|
||||
/// refreshes every ~40 ms). A legacy host carries no lease, so this per-slot clock is what
|
||||
/// bounds a lost stop-frame: once it is stale past `LEGACY_RUMBLE_CEILING_MS` the keep-alive
|
||||
/// stops and issues one (0, 0). See [`Worker::render_feedback`].
|
||||
updated_at: Option<Instant>,
|
||||
/// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a
|
||||
/// Deck keep-alive re-issue (see [`Worker::issue_rumble`]).
|
||||
jitter: bool,
|
||||
/// The host lease from a v2 rumble envelope: last non-zero level expires at this instant
|
||||
/// unless the host renews it. `None` outside a live rumble or against a legacy host (which
|
||||
/// sends no lease — the pad then relies on SDL's own duration expiry as before).
|
||||
deadline: Option<Instant>,
|
||||
/// The host-supplied TTL (ms) of the current envelope, handed to SDL as the `set_rumble`
|
||||
/// duration; `0` = legacy host (fall back to the proven 1.5 s duration).
|
||||
ttl_ms: u16,
|
||||
}
|
||||
|
||||
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
||||
/// pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)), and the per-pad wire/feedback
|
||||
/// state that used to be single-scalar on the Worker. Opening the device is what grabs the
|
||||
@@ -683,7 +647,6 @@ struct Slot {
|
||||
/// close lift a click held across detach/unplug.
|
||||
held_clicks: [bool; 2],
|
||||
last_accel: [i16; 3],
|
||||
rumble: RumbleState,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
@@ -699,7 +662,6 @@ impl Slot {
|
||||
surface_last: [(0, 0, false); 2],
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
rumble: RumbleState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -928,6 +890,20 @@ impl Worker {
|
||||
// uses the session-default kind.
|
||||
if let Some(c) = &self.attached {
|
||||
send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index);
|
||||
// Declare the actuator's quirks to the shared rumble policy engine. ALWAYS
|
||||
// set (defaults for a well-behaved pad): wire indices are reused within a
|
||||
// connection, so a Deck slot that closes must not leave its keepalive quirk
|
||||
// behind for the next pad on the same index.
|
||||
let quirks = if pref == GamepadPref::SteamDeck {
|
||||
ActuatorQuirks {
|
||||
keepalive_ms: DECK_RUMBLE_KEEPALIVE_MS,
|
||||
min_pulse_ms: 0,
|
||||
dedup_jitter: true,
|
||||
}
|
||||
} else {
|
||||
ActuatorQuirks::default()
|
||||
};
|
||||
c.set_rumble_quirks(index as u16, quirks);
|
||||
}
|
||||
tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)");
|
||||
self.slots.push(slot);
|
||||
@@ -940,6 +916,10 @@ impl Worker {
|
||||
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
||||
/// already gone (unplug).
|
||||
fn close_slot_at(&mut self, i: usize) {
|
||||
// Best-effort physical silence before the handle drops: a slot closed mid-buzz (detach /
|
||||
// unplug) must not depend on what SDL does to a rumbling device at close. Errors are
|
||||
// expected for an already-unplugged pad.
|
||||
let _ = self.slots[i].pad.set_rumble(0, 0, 100);
|
||||
if let Some(c) = self.attached.clone() {
|
||||
Self::flush_slot(&c, &mut self.slots[i]);
|
||||
// Signal the host to tear down this pad's virtual device (native hot-unplug). Sent
|
||||
@@ -1464,107 +1444,47 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a rumble value to SDL on one slot's pad, remembering it for the Deck keep-alive.
|
||||
/// SDL short-circuits an identical `(low, high)` with NO device write (it only re-arms its
|
||||
/// expiration), so on a Deck keep-alive re-issue of the same non-zero value we flip a single
|
||||
/// low-motor LSB — an imperceptible amplitude nudge — to force the write through and keep the
|
||||
/// actuator physically fed. The SDL duration is the host's envelope TTL (a lease continuously
|
||||
/// refreshed by renewals, so a sustained rumble never dies mid-effect and an abandoned one
|
||||
/// self-silences at the TTL); against a legacy host (`ttl_ms == 0`) it stays the proven 1.5 s.
|
||||
fn issue_rumble(slot: &mut Slot, low: u16, high: u16, deck: bool) {
|
||||
let dur_ms: u32 = if slot.rumble.ttl_ms == 0 {
|
||||
1_500 // legacy host: no lease — keep the proven duration
|
||||
/// Hand one policy-engine command to SDL on a slot's pad, verbatim. The core engine owns all
|
||||
/// rumble policy — leases, legacy-host staleness, the Deck keepalive + its dedupe-defeat
|
||||
/// jitter (declared as quirks at slot open) — so this worker keeps no rumble state at all.
|
||||
/// `backstop_ms` becomes the SDL duration: the hardware-level net under a stalled worker
|
||||
/// thread (the engine emits explicit zeros at every policy stop, so it is never the stop
|
||||
/// mechanism).
|
||||
fn issue_rumble(slot: &mut Slot, low: u16, high: u16, backstop_ms: u32) {
|
||||
let dur_ms: u32 = if (low, high) == (0, 0) {
|
||||
100 // a stop takes effect immediately; the duration is irrelevant
|
||||
} else {
|
||||
// Floor the lease so a jittered renewal (or the ~40 ms Deck re-kick) can never gap the
|
||||
// actuator between SDL writes.
|
||||
(slot.rumble.ttl_ms as u32).max(DECK_RUMBLE_KEEPALIVE_MS as u32 * 4)
|
||||
backstop_ms.max(160) // floor: a jittered renewal can never gap the actuator
|
||||
};
|
||||
let (out_low, out_high) =
|
||||
if deck && (low, high) == slot.rumble.last && (low, high) != (0, 0) {
|
||||
slot.rumble.jitter = !slot.rumble.jitter;
|
||||
(low ^ slot.rumble.jitter as u16, high)
|
||||
} else {
|
||||
(low, high)
|
||||
};
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the right
|
||||
// HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs the send side
|
||||
// on 0xCA with the pad index, so the two together pinpoint host-game vs client-render.
|
||||
match slot.pad.set_rumble(out_low, out_high, dur_ms) {
|
||||
match slot.pad.set_rumble(low, high, dur_ms) {
|
||||
Err(e) => {
|
||||
tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed")
|
||||
}
|
||||
Ok(()) => tracing::trace!(pad = slot.index, low, high, "rumble: rendered"),
|
||||
}
|
||||
slot.rumble.last = (low, high);
|
||||
slot.rumble.last_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar / player LEDs /
|
||||
/// adaptive triggers) — routing each update to the forwarded slot on its wire pad index; this
|
||||
/// thread is their single consumer. Rumble arrives as self-terminating v2 envelopes: each
|
||||
/// carries a TTL the host renews while the level holds and lets expire when it stops, so the
|
||||
/// actuator's divergence from the host's intent is bounded by the wire, not by a client guess.
|
||||
/// A legacy host (`ttl == None`) has no lease — the pad falls back to SDL's own 1.5 s duration
|
||||
/// expiry as before.
|
||||
/// thread is their single consumer. Rumble arrives as EFFECTIVE commands from the core's
|
||||
/// shared policy engine, which already applied every policy — v2 lease expiry, legacy-host
|
||||
/// staleness, the Deck actuator keepalive + jitter (via the quirks declared at slot open),
|
||||
/// and connection-close drain zeros — so this worker applies commands verbatim and keeps no
|
||||
/// rumble state of its own (`design/rumble-root-fix.md` §D).
|
||||
fn render_feedback(&mut self) {
|
||||
let Some(connector) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
// Rumble envelopes (0xCA) → the slot holding that wire pad index. An update for an index
|
||||
// with no live slot (a pad that just unplugged) is dropped.
|
||||
while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
|
||||
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == pad) {
|
||||
let deck = slot.pref == GamepadPref::SteamDeck;
|
||||
slot.rumble.ttl_ms = ttl.unwrap_or(0);
|
||||
// A v2 lease sets an explicit client-side deadline; a legacy update clears it and
|
||||
// leans on SDL's own duration expiry (unchanged behaviour).
|
||||
slot.rumble.deadline = match ttl {
|
||||
Some(ms) if (low, high) != (0, 0) => {
|
||||
Some(Instant::now() + Duration::from_millis(ms as u64))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// Mark this as a real host update. Unlike `last_at` (which the Deck keep-alive
|
||||
// re-kick refreshes every ~40 ms), this clock advances only here, so a legacy
|
||||
// lost-stop can be bounded by `LEGACY_RUMBLE_CEILING_MS` in the keep-alive below.
|
||||
slot.rumble.updated_at = Some(Instant::now());
|
||||
Self::issue_rumble(slot, low, high, deck);
|
||||
}
|
||||
}
|
||||
// Steam Deck keep-alive, per slot: the built-in actuator decays inside SDL's ~2 s internal
|
||||
// rumble resend, and SDL dedupes an unchanged `set_rumble` to a no-op device write — so a
|
||||
// steady host value is felt as a periodic pulse. Re-kick a Deck slot below the decay
|
||||
// (`DECK_RUMBLE_KEEPALIVE_MS`) so its discrete bursts fuse into a continuous buzz, but
|
||||
// silence it once the host's lease expires (the host stopped renewing — a lost stop, or the
|
||||
// host died). The per-slot timing guards make this idempotent with a fresh datagram this
|
||||
// tick (a just-set `last_at`/`deadline` fails both checks). Non-Deck slots sustain/expire at
|
||||
// the SDL/hardware level and never enter here.
|
||||
for slot in self.slots.iter_mut() {
|
||||
if slot.pref != GamepadPref::SteamDeck || slot.rumble.last == (0, 0) {
|
||||
continue;
|
||||
}
|
||||
if slot.rumble.deadline.is_some_and(|d| Instant::now() >= d) {
|
||||
slot.rumble.deadline = None;
|
||||
slot.rumble.ttl_ms = 0;
|
||||
Self::issue_rumble(slot, 0, 0, true);
|
||||
} else if slot.rumble.ttl_ms == 0
|
||||
&& slot
|
||||
.rumble
|
||||
.updated_at
|
||||
.is_some_and(|t| t.elapsed() >= Duration::from_millis(LEGACY_RUMBLE_CEILING_MS))
|
||||
{
|
||||
// Legacy host (no v2 lease): a held rumble refreshes `updated_at` every ~500 ms, so
|
||||
// this only trips on a lost stop-frame the host never followed up — silence the
|
||||
// actuator once instead of letting the 40 ms keep-alive drone forever. `issue_rumble`
|
||||
// sets `last` to (0, 0), so the top-of-loop guard skips this slot on later ticks.
|
||||
Self::issue_rumble(slot, 0, 0, true);
|
||||
} else if slot
|
||||
.rumble
|
||||
.last_at
|
||||
.is_none_or(|t| t.elapsed() >= Duration::from_millis(DECK_RUMBLE_KEEPALIVE_MS))
|
||||
{
|
||||
let (low, high) = slot.rumble.last;
|
||||
Self::issue_rumble(slot, low, high, true);
|
||||
// Engine commands → the slot holding that wire pad index. A command for an index with no
|
||||
// live slot (a pad that just unplugged) is dropped. The loop ends on NoFrame (drained
|
||||
// dry this tick) or Closed (session over — the engine delivered its close-drain zeros
|
||||
// first; the physical silence backstop is in `close_slot_at`).
|
||||
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
|
||||
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
|
||||
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
|
||||
}
|
||||
}
|
||||
// HID output (lightbar / player LEDs / adaptive triggers) → the slot on that wire index.
|
||||
|
||||
@@ -2015,6 +2015,105 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble2(
|
||||
})
|
||||
}
|
||||
|
||||
/// `flags` bit for [`punktfunk_connection_set_rumble_quirks`]: 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 Steam Deck's dedupe-defeat.
|
||||
pub const PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER: u32 = 1;
|
||||
|
||||
/// Pull the next EFFECTIVE rumble command from the shared policy engine — the uniform replacement
|
||||
/// for per-platform rumble policy. Unlike [`punktfunk_connection_next_rumble2`], the caller never
|
||||
/// sees a TTL and never owns a deadline: the engine emits the level on every wire update (renewals
|
||||
/// re-arm duration-parameterized APIs), an explicit zero at lease expiry / legacy-host staleness
|
||||
/// (a uniform 1 s) / connection close, and any keepalives declared via
|
||||
/// [`punktfunk_connection_set_rumble_quirks`]. Apply commands verbatim: `(0, 0)` = stop now;
|
||||
/// non-zero = run at this level, with `*backstop_ms` as the safety-net duration for platform APIs
|
||||
/// that take one (explicit-stop APIs ignore it; it is `0` on stop commands).
|
||||
/// [`PunktfunkStatus::NoFrame`] on timeout; [`PunktfunkStatus::Closed`] once the session ended AND
|
||||
/// every close-drain stop was delivered — silence all actuators on it.
|
||||
///
|
||||
/// An embedder uses EITHER this or `next_rumble`/`next_rumble2` for a connection's lifetime,
|
||||
/// never both (they consume the same wire plane).
|
||||
///
|
||||
/// # 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.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: *mut u16,
|
||||
low: *mut u16,
|
||||
high: *mut u16,
|
||||
backstop_ms: *mut u32,
|
||||
timeout_ms: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
match c
|
||||
.inner
|
||||
.next_rumble_command(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(cmd) => {
|
||||
unsafe {
|
||||
if !pad.is_null() {
|
||||
*pad = cmd.pad;
|
||||
}
|
||||
if !low.is_null() {
|
||||
*low = cmd.low;
|
||||
}
|
||||
if !high.is_null() {
|
||||
*high = cmd.high;
|
||||
}
|
||||
if !backstop_ms.is_null() {
|
||||
*backstop_ms = cmd.backstop_ms;
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the
|
||||
/// shared rumble policy engine instead of forking it (typically called at controller attach).
|
||||
/// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose
|
||||
/// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID
|
||||
/// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`:
|
||||
/// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved
|
||||
/// actuator.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle. Callable from any thread.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_set_rumble_quirks(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: u16,
|
||||
keepalive_ms: u16,
|
||||
min_pulse_ms: u16,
|
||||
flags: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
c.inner.set_rumble_quirks(
|
||||
pad,
|
||||
crate::client::ActuatorQuirks {
|
||||
keepalive_ms,
|
||||
min_pulse_ms,
|
||||
dedup_jitter: flags & PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER != 0,
|
||||
},
|
||||
);
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// timeout, [`PunktfunkStatus::Closed`] once the session ended. Only the DualSense host backend
|
||||
|
||||
@@ -28,10 +28,12 @@ mod planes;
|
||||
mod probe;
|
||||
mod pump;
|
||||
mod recovery;
|
||||
mod rumble;
|
||||
mod worker;
|
||||
|
||||
pub use self::planes::AudioPacket;
|
||||
pub use self::probe::ProbeOutcome;
|
||||
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
||||
|
||||
use self::control::{CtrlRequest, Negotiated};
|
||||
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
||||
@@ -75,6 +77,10 @@ pub struct NativeClient {
|
||||
frames: Arc<FrameChannel>,
|
||||
audio: Mutex<Receiver<AudioPacket>>,
|
||||
rumble: Mutex<Receiver<RumbleUpdate>>,
|
||||
/// The shared rumble policy engine ([`RumbleCommand`] API — the uniform per-platform-policy
|
||||
/// replacement). Fed by the datagram demux in parallel with the raw `rumble` queue; an
|
||||
/// embedder consumes ONE of the two APIs (documented on [`NativeClient::next_rumble_command`]).
|
||||
rumble_sched: Arc<rumble::RumbleShared>,
|
||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||
hidout: Mutex<Receiver<HidOutput>>,
|
||||
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
|
||||
@@ -299,6 +305,8 @@ impl NativeClient {
|
||||
let frame_chan = Arc::new(FrameChannel::new());
|
||||
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
||||
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<RumbleUpdate>(RUMBLE_QUEUE);
|
||||
let rumble_sched = Arc::new(rumble::RumbleShared::new());
|
||||
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
|
||||
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
||||
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
|
||||
let (host_timing_tx, host_timing_rx) =
|
||||
@@ -366,6 +374,7 @@ impl NativeClient {
|
||||
frames: frame_chan_w,
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
@@ -401,6 +410,7 @@ impl NativeClient {
|
||||
frames: frame_chan,
|
||||
audio: Mutex::new(audio_rx),
|
||||
rumble: Mutex::new(rumble_rx),
|
||||
rumble_sched,
|
||||
hidout: Mutex::new(hidout_rx),
|
||||
hdr_meta: Mutex::new(hdr_meta_rx),
|
||||
host_timing: Mutex::new(host_timing_rx),
|
||||
@@ -776,6 +786,35 @@ impl NativeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next EFFECTIVE rumble command from the shared policy engine — the uniform
|
||||
/// replacement for per-platform rumble policy (`design/rumble-root-fix.md` §D). Unlike
|
||||
/// [`NativeClient::next_rumble_ttl`], the caller never sees a TTL and never owns a deadline:
|
||||
/// the engine emits the level on every wire update (renewals re-arm duration-parameterized
|
||||
/// APIs), an explicit zero at lease expiry / legacy staleness / connection close, and
|
||||
/// quirk-declared keepalives ([`NativeClient::set_rumble_quirks`]). Apply commands verbatim:
|
||||
/// `(0, 0)` = stop now; non-zero = run at this level, with `backstop_ms` as the safety-net
|
||||
/// duration for APIs that take one. [`PunktfunkError::NoFrame`] on timeout;
|
||||
/// [`PunktfunkError::Closed`] once the session ended AND every close-drain stop was delivered.
|
||||
///
|
||||
/// One puller thread, and one API: an embedder uses EITHER this or
|
||||
/// `next_rumble`/`next_rumble_ttl` for a connection's lifetime, never both (both consume the
|
||||
/// same wire plane; the raw queue keeps filling harmlessly while this API is used).
|
||||
pub fn next_rumble_command(&self, timeout: Duration) -> Result<RumbleCommand> {
|
||||
match self.rumble_sched.next_command(timeout) {
|
||||
Ok(Some(c)) => Ok(c),
|
||||
Ok(None) => Err(PunktfunkError::NoFrame),
|
||||
Err(rumble::Closed) => Err(PunktfunkError::Closed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Declare a physical actuator's quirks for wire pad `pad` (see [`ActuatorQuirks`]) —
|
||||
/// typically at controller attach. All-default quirks (the initial state) describe a
|
||||
/// well-behaved actuator; only decaying actuators (Steam Deck, DualSense-over-BT raw HID)
|
||||
/// need a keepalive.
|
||||
pub fn set_rumble_quirks(&self, pad: u16, quirks: ActuatorQuirks) {
|
||||
self.rumble_sched.set_quirks(pad, quirks);
|
||||
}
|
||||
|
||||
/// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive
|
||||
/// trigger) the host's virtual pad received from a game; same timeout/closed semantics as
|
||||
/// [`NativeClient::next_rumble`]. Replay it on a real DualSense (e.g. via the platform's
|
||||
|
||||
@@ -40,6 +40,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
frames,
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
rumble_feed,
|
||||
hidout_tx,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
@@ -578,7 +579,10 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
};
|
||||
if fresh {
|
||||
let ttl = u.envelope.map(|e| e.ttl_ms);
|
||||
// Both consumers are fed; an embedder drains exactly one of them
|
||||
// (the legacy queue, or the policy engine's command API).
|
||||
let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl));
|
||||
rumble_feed.wire_update(u.pad, u.low, u.high, ttl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
//! 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;
|
||||
|
||||
/// 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 (Steam Deck ≈ 40, macOS DualSense-over-HID BT ≈ 900).
|
||||
/// `0` = no keepalive (the common case).
|
||||
pub keepalive_ms: u16,
|
||||
/// Floor for `backstop_ms` on non-zero commands (Android's `createOneShot` throws on 0).
|
||||
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>,
|
||||
/// Current jitter phase (see [`ActuatorQuirks::dedup_jitter`]).
|
||||
jitter: bool,
|
||||
quirks: ActuatorQuirks,
|
||||
}
|
||||
|
||||
impl PadState {
|
||||
const NEUTRAL: PadState = PadState {
|
||||
level: (0, 0),
|
||||
deadline: None,
|
||||
ttl_ms: 0,
|
||||
legacy_wire: None,
|
||||
dirty: false,
|
||||
next_keepalive: None,
|
||||
jitter: false,
|
||||
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;
|
||||
RumbleCommand {
|
||||
pad,
|
||||
low: 0,
|
||||
high: 0,
|
||||
backstop_ms: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) => {
|
||||
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) {
|
||||
return (Some(p.silence(pad)), None);
|
||||
}
|
||||
if p.quirks.keepalive_ms > 0 {
|
||||
p.next_keepalive =
|
||||
Some(now + Duration::from_millis(p.quirks.keepalive_ms as u64));
|
||||
}
|
||||
let (low, high) = p.level;
|
||||
return (
|
||||
Some(RumbleCommand {
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
backstop_ms: p.backstop(),
|
||||
}),
|
||||
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);
|
||||
let (mut low, high) = p.level;
|
||||
if p.quirks.dedup_jitter {
|
||||
p.jitter = !p.jitter;
|
||||
low ^= p.jitter as u16;
|
||||
}
|
||||
return (
|
||||
Some(RumbleCommand {
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
backstop_ms: p.backstop(),
|
||||
}),
|
||||
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::*;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ pub(crate) struct WorkerArgs {
|
||||
pub(crate) frames: Arc<FrameChannel>,
|
||||
pub(crate) audio_tx: SyncSender<AudioPacket>,
|
||||
pub(crate) rumble_tx: SyncSender<RumbleUpdate>,
|
||||
/// Feed half of the rumble policy engine — its `Drop` (demux task end) marks the engine
|
||||
/// closed, so the command API always observes connection teardown.
|
||||
pub(crate) rumble_feed: super::rumble::RumbleFeed,
|
||||
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
||||
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
||||
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
||||
|
||||
Reference in New Issue
Block a user