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:
@@ -658,6 +658,14 @@ struct Worker {
|
||||
/// 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`]).
|
||||
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).
|
||||
rumble_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). Read by
|
||||
/// [`Worker::issue_rumble`].
|
||||
rumble_ttl_ms: u16,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
@@ -1246,9 +1254,18 @@ impl Worker {
|
||||
/// 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 1500 ms SDL duration is kept on every issue so SDL's logical
|
||||
/// expiration is continuously refreshed and a genuine sustained rumble never dies at 1.5 s.
|
||||
/// 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 (`rumble_ttl_ms == 0`) it stays the proven
|
||||
/// 1.5 s.
|
||||
fn issue_rumble(&mut self, low: u16, high: u16, deck: bool) {
|
||||
let dur_ms: u32 = if self.rumble_ttl_ms == 0 {
|
||||
1_500 // legacy host: no lease — keep the proven duration
|
||||
} else {
|
||||
// Floor the lease so a jittered renewal (or the ~40 ms Deck re-kick) can never gap the
|
||||
// actuator between SDL writes.
|
||||
(self.rumble_ttl_ms as u32).max(DECK_RUMBLE_KEEPALIVE_MS as u32 * 4)
|
||||
};
|
||||
let (out_low, out_high) =
|
||||
if deck && (low, high) == self.rumble_last && (low, high) != (0, 0) {
|
||||
self.rumble_jitter = !self.rumble_jitter;
|
||||
@@ -1259,7 +1276,7 @@ impl Worker {
|
||||
match self
|
||||
.open
|
||||
.as_mut()
|
||||
.map(|(_, p)| p.set_rumble(out_low, out_high, 1_500))
|
||||
.map(|(_, p)| p.set_rumble(out_low, out_high, dur_ms))
|
||||
{
|
||||
// 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
|
||||
@@ -1274,45 +1291,61 @@ impl Worker {
|
||||
|
||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
||||
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
|
||||
/// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only
|
||||
/// needs to outlive a couple of refresh periods: long enough that one or two lost
|
||||
/// refreshes don't gap a genuine long rumble, short enough that a stale nonzero state
|
||||
/// (a stop lost host-side, a session torn down mid-buzz) dies on its own instead of
|
||||
/// droning for seconds.
|
||||
/// 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.
|
||||
fn render_feedback(&mut self) {
|
||||
let Some(connector) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
// The Steam Deck's built-in haptic actuator decays inside SDL's ~2 s internal rumble
|
||||
// resend, and SDL dedupes an unchanged `set_rumble` value to a no-op device write — so a
|
||||
// steady host value (delivered only as identical 500 ms refreshes) is felt as a periodic
|
||||
// pulse rather than a continuous buzz. Detect the Deck pad here and keep it fed below the
|
||||
// decay (`DECK_RUMBLE_KEEPALIVE_MS`); every other pad sustains at the hardware level.
|
||||
// steady host value is felt as a periodic pulse rather than a continuous buzz. Detect the
|
||||
// Deck pad here and keep it fed below the decay (`DECK_RUMBLE_KEEPALIVE_MS`) — an actuator
|
||||
// limitation no wire lease can fix — but bound the re-kick by the host's TTL so it can no
|
||||
// longer sustain a value the host has stopped renewing. Every other pad sustains (and
|
||||
// expires) at the SDL/hardware level.
|
||||
let deck = self
|
||||
.open
|
||||
.as_ref()
|
||||
.and_then(|(id, _)| self.pad_info(*id))
|
||||
.is_some_and(|p| matches!(p.pref, GamepadPref::SteamDeck));
|
||||
let mut fresh = false;
|
||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||
while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
|
||||
if pad == 0 {
|
||||
fresh = true;
|
||||
self.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).
|
||||
self.rumble_deadline = match ttl {
|
||||
Some(ms) if (low, high) != (0, 0) => {
|
||||
Some(Instant::now() + Duration::from_millis(ms as u64))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
self.issue_rumble(low, high, deck);
|
||||
}
|
||||
}
|
||||
// Deck keep-alive: no fresh datagram this tick but a non-zero value is latched — re-kick
|
||||
// the actuator so its discrete haptic bursts fuse into a continuous buzz instead of a
|
||||
// ~2 s pulse. Zero is left alone (a real stop must stay stopped); non-Deck pads never
|
||||
// enter here (`deck` is false), so their behaviour is byte-for-byte unchanged.
|
||||
if deck
|
||||
&& !fresh
|
||||
&& self.rumble_last != (0, 0)
|
||||
&& self
|
||||
// Deck keep-alive: no fresh datagram this tick but a non-zero value is latched. If the
|
||||
// host lease has expired, silence the actuator (the host stopped renewing — the stop
|
||||
// datagram was lost, or the host died); otherwise re-kick it so its discrete haptic bursts
|
||||
// fuse into a continuous buzz. A legacy update leaves `rumble_deadline` None, so the
|
||||
// re-kick behaves exactly as before (SDL's duration is the only backstop). Non-Deck pads
|
||||
// never enter here (`deck` is false).
|
||||
if deck && !fresh && self.rumble_last != (0, 0) {
|
||||
if self.rumble_deadline.is_some_and(|d| Instant::now() >= d) {
|
||||
self.rumble_deadline = None;
|
||||
self.rumble_ttl_ms = 0;
|
||||
self.issue_rumble(0, 0, deck);
|
||||
} else if self
|
||||
.rumble_last_at
|
||||
.is_none_or(|t| t.elapsed() >= Duration::from_millis(DECK_RUMBLE_KEEPALIVE_MS))
|
||||
{
|
||||
let (low, high) = self.rumble_last;
|
||||
self.issue_rumble(low, high, deck);
|
||||
{
|
||||
let (low, high) = self.rumble_last;
|
||||
self.issue_rumble(low, high, deck);
|
||||
}
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let is_ds = self
|
||||
@@ -1382,6 +1415,8 @@ impl Worker {
|
||||
rumble_last: (0, 0),
|
||||
rumble_last_at: None,
|
||||
rumble_jitter: false,
|
||||
rumble_deadline: None,
|
||||
rumble_ttl_ms: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1752,6 +1752,10 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
/// 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.
|
||||
@@ -1792,6 +1796,62 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble(
|
||||
})
|
||||
}
|
||||
|
||||
/// `*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.
|
||||
pub const PUNKTFUNK_RUMBLE_NO_TTL: u32 = 0xFFFF_FFFF;
|
||||
|
||||
/// 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.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_rumble2(
|
||||
c: *mut PunktfunkConnection,
|
||||
pad: *mut u16,
|
||||
low: *mut u16,
|
||||
high: *mut u16,
|
||||
ttl_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_ttl(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok((p, l, h, ttl)) => {
|
||||
unsafe {
|
||||
if !pad.is_null() {
|
||||
*pad = p;
|
||||
}
|
||||
if !low.is_null() {
|
||||
*low = l;
|
||||
}
|
||||
if !high.is_null() {
|
||||
*high = h;
|
||||
}
|
||||
if !ttl_ms.is_null() {
|
||||
*ttl_ms = ttl.map_or(PUNKTFUNK_RUMBLE_NO_TTL, u32::from);
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -322,10 +322,17 @@ impl FrameChannel {
|
||||
const AUDIO_QUEUE: usize = 64;
|
||||
|
||||
/// Rumble updates buffered for the embedder. Overflow drops the NEWEST update (same
|
||||
/// `try_send` discipline as the other planes) — the host re-sends rumble state
|
||||
/// periodically, so a dropped transition (including a stop) heals within ~500 ms.
|
||||
/// `try_send` discipline as the other planes) — the host renews rumble state periodically
|
||||
/// (v2 envelopes) or re-sends it (legacy v1), so a dropped transition (including a stop) heals
|
||||
/// within one renewal/refresh period.
|
||||
const RUMBLE_QUEUE: usize = 16;
|
||||
|
||||
/// A rumble update handed to the embedder: `(pad, low, high, ttl_ms)`. `ttl_ms` is `Some(ms)` for
|
||||
/// a self-terminating v2 envelope (render for at most that long) and `None` for a legacy v1
|
||||
/// datagram (an old host — the renderer applies its own staleness policy). The seq from a v2
|
||||
/// envelope is consumed by the reorder gate in the datagram demux and is NOT forwarded.
|
||||
type RumbleUpdate = (u16, u16, u16, Option<u16>);
|
||||
|
||||
/// HID-output (DualSense lightbar / player LEDs / adaptive triggers) buffered for the embedder.
|
||||
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
|
||||
const HIDOUT_QUEUE: usize = 32;
|
||||
@@ -355,7 +362,7 @@ pub struct NativeClient {
|
||||
// and two threads racing one plane now serialize instead of being undefined).
|
||||
frames: Arc<FrameChannel>,
|
||||
audio: Mutex<Receiver<AudioPacket>>,
|
||||
rumble: Mutex<Receiver<(u16, u16, u16)>>,
|
||||
rumble: Mutex<Receiver<RumbleUpdate>>,
|
||||
/// 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.
|
||||
@@ -564,7 +571,7 @@ impl NativeClient {
|
||||
) -> Result<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::<(u16, u16, u16)>(RUMBLE_QUEUE);
|
||||
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<RumbleUpdate>(RUMBLE_QUEUE);
|
||||
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) =
|
||||
@@ -1024,8 +1031,20 @@ impl NativeClient {
|
||||
}
|
||||
|
||||
/// Pull the next rumble update `(pad, low, high)`; same semantics as
|
||||
/// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop.
|
||||
/// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop. The self-terminating
|
||||
/// TTL of a v2 envelope is dropped here — use [`NativeClient::next_rumble_ttl`] to honor it (a
|
||||
/// renderer that only sees `(pad, low, high)` keeps its own staleness policy exactly as before,
|
||||
/// which is what makes this back-compatible for un-updated embedders).
|
||||
pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> {
|
||||
self.next_rumble_ttl(timeout).map(|(p, l, h, _)| (p, l, h))
|
||||
}
|
||||
|
||||
/// Pull the next rumble update including its self-termination TTL: `(pad, low, high, ttl_ms)`.
|
||||
/// `ttl_ms` is `Some(ms)` for a v2 envelope — render the level for at most that long, then
|
||||
/// silence — and `None` for a legacy v1 datagram (an old host with no lease; fall back to the
|
||||
/// renderer's own staleness heuristic). The reorder gate (seq) is applied in the datagram demux
|
||||
/// before the update reaches this queue, so a stale/reordered envelope never surfaces here.
|
||||
pub fn next_rumble_ttl(&self, timeout: Duration) -> Result<RumbleUpdate> {
|
||||
match self.rumble.lock().unwrap().recv_timeout(timeout) {
|
||||
Ok(r) => Ok(r),
|
||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
||||
@@ -1168,7 +1187,7 @@ struct WorkerArgs {
|
||||
identity: Option<(String, String)>,
|
||||
frames: Arc<FrameChannel>,
|
||||
audio_tx: SyncSender<AudioPacket>,
|
||||
rumble_tx: SyncSender<(u16, u16, u16)>,
|
||||
rumble_tx: SyncSender<RumbleUpdate>,
|
||||
hidout_tx: SyncSender<HidOutput>,
|
||||
hdr_meta_tx: SyncSender<HdrMeta>,
|
||||
host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
||||
@@ -1593,6 +1612,10 @@ async fn worker_main(args: WorkerArgs) {
|
||||
// Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the
|
||||
// newest packet rather than backing up the QUIC receive path).
|
||||
let dgram_conn = conn.clone();
|
||||
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
|
||||
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
|
||||
// datagrams carry no seq and bypass it (an old host's own periodic re-send is the only heal).
|
||||
let mut rumble_last_seq: [Option<u8>; crate::input::MAX_PADS] = [None; crate::input::MAX_PADS];
|
||||
tokio::spawn(async move {
|
||||
while let Ok(d) = dgram_conn.read_datagram().await {
|
||||
match d.first() {
|
||||
@@ -1606,8 +1629,31 @@ async fn worker_main(args: WorkerArgs) {
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::RUMBLE_MAGIC) => {
|
||||
if let Some(r) = crate::quic::decode_rumble_datagram(&d) {
|
||||
let _ = rumble_tx.try_send(r);
|
||||
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
|
||||
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
|
||||
let fresh = match u.envelope {
|
||||
Some(env) => {
|
||||
let idx = u.pad as usize;
|
||||
if idx < crate::input::MAX_PADS {
|
||||
if crate::input::GamepadSnapshot::seq_newer(
|
||||
env.seq,
|
||||
rumble_last_seq[idx],
|
||||
) {
|
||||
rumble_last_seq[idx] = Some(env.seq);
|
||||
true
|
||||
} else {
|
||||
false // reordered/duplicate — drop, keep the newer state
|
||||
}
|
||||
} else {
|
||||
true // out-of-range pad (host never sends these): no gate
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
if fresh {
|
||||
let ttl = u.envelope.map(|e| e.ttl_ms);
|
||||
let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HIDOUT_MAGIC) => {
|
||||
|
||||
@@ -57,7 +57,11 @@ pub use stats::Stats;
|
||||
/// 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).
|
||||
pub const ABI_VERSION: u32 = 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.
|
||||
pub const ABI_VERSION: u32 = 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**
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -1388,7 +1388,9 @@ async fn serve_session(
|
||||
&& std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1")
|
||||
{
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
let d = punktfunk_core::quic::encode_rumble_datagram(0, 0x4000, 0x8000);
|
||||
// v2 envelope (seq 0, 400 ms TTL) so the loopback/probe assertion covers the self-
|
||||
// terminating tail, not just the level.
|
||||
let d = punktfunk_core::quic::encode_rumble_datagram_v2(0, 0x4000, 0x8000, 0, 400);
|
||||
let _ = conn.send_datagram(d.to_vec().into());
|
||||
for h in [
|
||||
HidOutput::Led {
|
||||
@@ -1826,6 +1828,49 @@ enum ClientInput {
|
||||
Rich(punktfunk_core::quic::RichInput),
|
||||
}
|
||||
|
||||
/// Default TTL stamped on a non-zero rumble envelope (0xCA v2): how long the client renders the
|
||||
/// level before silencing unless the host renews it. Tolerates 2–3 lost renewals (same loss
|
||||
/// margin the old flat 500 ms refresh gave) while capping a host-abandoned rumble at this on every
|
||||
/// client — versus the per-platform client heuristics it replaces (SDL 1.5 s, Apple 1.6 s, Android
|
||||
/// up to the QUIC idle-timeout). Overridable via `PUNKTFUNK_RUMBLE_TTL_MS` (floored at
|
||||
/// [`RUMBLE_TTL_FLOOR_MS`] so expiry jitter stays below the clients' tick granularity).
|
||||
const RUMBLE_TTL_MS: u16 = 400;
|
||||
/// Floor for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch — below this the ~50 ms client ticks make expiry
|
||||
/// audible (see `rumble-envelope-plan.md` §5).
|
||||
const RUMBLE_TTL_FLOOR_MS: u16 = 150;
|
||||
/// Ceiling for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch. A lease longer than a few seconds defeats the
|
||||
/// design's "an abandoned rumble stops promptly" goal, and keeping it well under `u16::MAX` means
|
||||
/// the wire never emits a TTL a narrower client-side slot could mistake for a sentinel.
|
||||
const RUMBLE_TTL_CEIL_MS: u16 = 5_000;
|
||||
/// Floor for the derived renewal interval (renew = ttl × 3/10) so an aggressive TTL hatch can't
|
||||
/// spin the renewal loop faster than this.
|
||||
const RUMBLE_RENEW_FLOOR_MS: u64 = 60;
|
||||
/// How many times a transition-to-zero (a stop) is re-sent on the renewal ticks after the
|
||||
/// immediate stop datagram, before the pad goes quiet. Covers stop-datagram loss for legacy
|
||||
/// clients (a v2 client also self-silences at TTL); even a fully lost burst heals via the client's
|
||||
/// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends.
|
||||
const RUMBLE_STOP_BURST: u8 = 2;
|
||||
|
||||
/// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating
|
||||
/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the
|
||||
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
|
||||
fn send_rumble(
|
||||
conn: &quinn::Connection,
|
||||
envelope_on: bool,
|
||||
pad: u16,
|
||||
low: u16,
|
||||
high: u16,
|
||||
seq: u8,
|
||||
ttl_ms: u16,
|
||||
) {
|
||||
let d: Vec<u8> = if envelope_on {
|
||||
punktfunk_core::quic::encode_rumble_datagram_v2(pad, low, high, seq, ttl_ms).to_vec()
|
||||
} else {
|
||||
punktfunk_core::quic::encode_rumble_datagram(pad, low, high).to_vec()
|
||||
};
|
||||
let _ = conn.send_datagram(d.into());
|
||||
}
|
||||
|
||||
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
|
||||
/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (`gamepad` — the
|
||||
/// resolved Hello preference: uinput X-Box pads or virtual DualSense pads), with rich
|
||||
@@ -1834,6 +1879,12 @@ enum ClientInput {
|
||||
/// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with
|
||||
/// the session; the pointer/keyboard injector (and its portal grant) lives in the service,
|
||||
/// across sessions.
|
||||
///
|
||||
/// Rumble is emitted as self-terminating 0xCA v2 envelopes (`[level][seq][ttl_ms]`): the host owns
|
||||
/// the timeline, renewing an active level every ~`RUMBLE_TTL_MS × 3/10` ms and letting an
|
||||
/// abandoned one expire client-side, so "stuck rumble" is inexpressible on the wire (see
|
||||
/// `punktfunk-planning/design/rumble-envelope-plan.md`). `PUNKTFUNK_RUMBLE_ENVELOPE=0` reverts to
|
||||
/// legacy v1 level datagrams + the flat 500 ms refresh (bisect hatch).
|
||||
fn input_thread(
|
||||
rx: std::sync::mpsc::Receiver<ClientInput>,
|
||||
conn: quinn::Connection,
|
||||
@@ -1852,12 +1903,31 @@ fn input_thread(
|
||||
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
|
||||
// `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back.
|
||||
let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS];
|
||||
// Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams),
|
||||
// so re-send the current state of every rumbling-capable pad every 500 ms — a dropped
|
||||
// transition (including a stop) heals on the next refresh.
|
||||
// Rumble self-terminating envelopes (0xCA v2). Each non-zero level is authorized for
|
||||
// `rumble_ttl_ms`; the host renews an active pad every `rumble_renew` and lets an abandoned
|
||||
// one expire on the client, so a dropped transition heals on the next renewal and a stop that
|
||||
// is lost heals via the stop burst (or the client's own TTL expiry). `rumble_seq` is the
|
||||
// per-pad wrapping reorder counter (bumped on changes AND renewals) the client gates on;
|
||||
// `rumble_stop_burst` counts the post-stop zero re-sends still owed. `PUNKTFUNK_RUMBLE_ENVELOPE=0`
|
||||
// reverts to legacy v1 datagrams re-sent flat every 500 ms.
|
||||
let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS];
|
||||
let mut rumble_seen = [false; MAX_WIRE_PADS];
|
||||
let mut rumble_seq = [0u8; MAX_WIRE_PADS];
|
||||
let mut rumble_stop_burst = [0u8; MAX_WIRE_PADS];
|
||||
let mut last_refresh = std::time::Instant::now();
|
||||
let rumble_envelope_on = std::env::var("PUNKTFUNK_RUMBLE_ENVELOPE").as_deref() != Ok("0");
|
||||
let rumble_ttl_ms: u16 = std::env::var("PUNKTFUNK_RUMBLE_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.map(|v| v.clamp(RUMBLE_TTL_FLOOR_MS, RUMBLE_TTL_CEIL_MS))
|
||||
.unwrap_or(RUMBLE_TTL_MS);
|
||||
// Renew at 30 % of the TTL (≈120 ms for the 400 ms default) so 2–3 renewals cover the lease;
|
||||
// in legacy mode the periodic block instead runs the old flat 500 ms full-state refresh.
|
||||
let rumble_refresh_interval = if rumble_envelope_on {
|
||||
std::time::Duration::from_millis((rumble_ttl_ms as u64 * 3 / 10).max(RUMBLE_RENEW_FLOOR_MS))
|
||||
} else {
|
||||
std::time::Duration::from_millis(500)
|
||||
};
|
||||
// Pointer buttons / keys the client currently holds down. The injector is host-lifetime, so a
|
||||
// press left dangling by an abrupt client disconnect stays latched in the compositor across the
|
||||
// reconnect (Mutter keeps the implicit pointer grab of the still-pressed button — a stuck
|
||||
@@ -1976,17 +2046,43 @@ fn input_thread(
|
||||
// plane; DualSense rich feedback (lightbar / player LEDs / adaptive triggers) → 0xCD.
|
||||
pads.pump(
|
||||
|pad, low, high| {
|
||||
if let Some(s) = rumble_state.get_mut(pad as usize) {
|
||||
let idx = pad as usize;
|
||||
if idx < MAX_WIRE_PADS {
|
||||
let prev = rumble_state[idx];
|
||||
// Log the silent→active transition (once per buzz) so a live test can tell
|
||||
// "host never gets rumble from the game" apart from "client doesn't render it".
|
||||
if *s == (0, 0) && (low != 0 || high != 0) {
|
||||
if prev == (0, 0) && (low != 0 || high != 0) {
|
||||
tracing::info!(pad, low, high, "rumble: forwarding to client (0xCA)");
|
||||
}
|
||||
*s = (low, high);
|
||||
rumble_seen[pad as usize] = true;
|
||||
rumble_state[idx] = (low, high);
|
||||
rumble_seen[idx] = true;
|
||||
// Bump the reorder counter on every change, then arm the stop burst on a
|
||||
// transition to zero (so a lost stop still reaches a legacy client) and clear
|
||||
// it when the game re-asserts a non-zero level.
|
||||
rumble_seq[idx] = rumble_seq[idx].wrapping_add(1);
|
||||
if (low, high) == (0, 0) {
|
||||
rumble_stop_burst[idx] = if prev != (0, 0) { RUMBLE_STOP_BURST } else { 0 };
|
||||
} else {
|
||||
rumble_stop_burst[idx] = 0;
|
||||
}
|
||||
let ttl = if (low, high) == (0, 0) {
|
||||
0
|
||||
} else {
|
||||
rumble_ttl_ms
|
||||
};
|
||||
send_rumble(
|
||||
&conn,
|
||||
rumble_envelope_on,
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
rumble_seq[idx],
|
||||
ttl,
|
||||
);
|
||||
} else {
|
||||
// Out-of-range pad (a backend never produces these) — forward without gating.
|
||||
send_rumble(&conn, rumble_envelope_on, pad, low, high, 0, rumble_ttl_ms);
|
||||
}
|
||||
let d = punktfunk_core::quic::encode_rumble_datagram(pad, low, high);
|
||||
let _ = conn.send_datagram(d.to_vec().into());
|
||||
},
|
||||
|h| {
|
||||
let _ = conn.send_datagram(h.encode().into());
|
||||
@@ -1996,12 +2092,40 @@ fn input_thread(
|
||||
// held-steady pad sends no wire events, so without a periodic re-emit the kernel/SDL drop
|
||||
// it as unplugged. The 8 ms gap inside heartbeat() governs the rate, not this ≤4 ms tick.
|
||||
pads.heartbeat();
|
||||
if last_refresh.elapsed() >= std::time::Duration::from_millis(500) {
|
||||
if last_refresh.elapsed() >= rumble_refresh_interval {
|
||||
last_refresh = std::time::Instant::now();
|
||||
for (i, &(low, high)) in rumble_state.iter().enumerate() {
|
||||
if rumble_seen[i] {
|
||||
let d = punktfunk_core::quic::encode_rumble_datagram(i as u16, low, high);
|
||||
let _ = conn.send_datagram(d.to_vec().into());
|
||||
if rumble_envelope_on {
|
||||
// Renewal: refresh an active pad's lease (bump seq, fresh TTL), and drain each
|
||||
// pad's post-stop zero burst, then let it go quiet — no perpetual zero refreshes.
|
||||
for i in 0..MAX_WIRE_PADS {
|
||||
if !rumble_seen[i] {
|
||||
continue;
|
||||
}
|
||||
let (low, high) = rumble_state[i];
|
||||
if (low, high) != (0, 0) {
|
||||
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
|
||||
send_rumble(
|
||||
&conn,
|
||||
true,
|
||||
i as u16,
|
||||
low,
|
||||
high,
|
||||
rumble_seq[i],
|
||||
rumble_ttl_ms,
|
||||
);
|
||||
} else if rumble_stop_burst[i] > 0 {
|
||||
rumble_stop_burst[i] -= 1;
|
||||
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
|
||||
send_rumble(&conn, true, i as u16, 0, 0, rumble_seq[i], 0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy: re-send the current level of every seen pad every 500 ms (v1).
|
||||
for (i, &(low, high)) in rumble_state.iter().enumerate() {
|
||||
if rumble_seen[i] {
|
||||
let d = punktfunk_core::quic::encode_rumble_datagram(i as u16, low, high);
|
||||
let _ = conn.send_datagram(d.to_vec().into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user