From a1e79a0d69d84ab663e9546145cf2a383f27549a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 11 Jul 2026 03:07:50 +0200 Subject: [PATCH 1/5] fix(flatpak): bind gamescope's Wayland socket for Deck Game-Mode HDR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vulkan-layer env vars alone left hdr10_format=None on a Deck OLED: the FROG gamescope WSI layer loads but must open a Wayland connection to gamescope's private socket ($GAMESCOPE_WAYLAND_DISPLAY = gamescope-0) to negotiate HDR10 via the gamescope_swapchain protocol. The Deck runs games as X11 clients, so --socket=wayland binds nothing and that socket never enters the sandbox → the layer silently can't reach the compositor → PQ tone-mapped to SDR, badge dark. Bind xdg-run/gamescope-0 (as chiaki-ng does); with the layer path + ENABLE_GAMESCOPE_WSI + the socket, the surface offers HDR10 and the presenter's existing HDR10 swapchain path engages — no client code change. Harmless off-Deck (the layer no-ops with no gamescope socket to bind). Co-Authored-By: Claude Fable 5 --- packaging/flatpak/io.unom.Punktfunk.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packaging/flatpak/io.unom.Punktfunk.yml b/packaging/flatpak/io.unom.Punktfunk.yml index f16188d0..58545074 100644 --- a/packaging/flatpak/io.unom.Punktfunk.yml +++ b/packaging/flatpak/io.unom.Punktfunk.yml @@ -93,13 +93,21 @@ finish-args: # /usr/lib/extensions/vulkan/gamescope once installed (a one-time, per-Deck step; keep it in the # Decky plugin's setup / docs): # flatpak install --user -y flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08 - # These two env vars are all the app itself needs: the first puts the layer's implicit-layer JSON - # on the Vulkan loader's search path (the runtime point mounts the files but not onto the path), - # the second flips the layer's own `enable_environment` gate. With both, the surface starts - # offering HDR10 and the presenter's existing HDR10 swapchain path engages automatically — no - # client code change. Harmless off-Deck: the layer no-ops when there's no gamescope socket. + # THREE things are needed, not two (verified live on a Deck OLED — the env vars alone left + # hdr10_format=None). (1) VK_ADD_IMPLICIT_LAYER_PATH puts the layer's implicit-layer JSON on the + # Vulkan loader's search path (the runtime point mounts the files but not onto the path). (2) + # ENABLE_GAMESCOPE_WSI flips the layer's own `enable_environment` gate. (3) The layer, once + # loaded, must open a *Wayland* connection to gamescope's private socket ($GAMESCOPE_WAYLAND_DISPLAY + # = gamescope-0) to negotiate the HDR10 colorspace via the gamescope_swapchain protocol — but the + # Deck runs games as X11 clients (DISPLAY=:1, no WAYLAND_DISPLAY exported), so --socket=wayland + # binds nothing and that socket never enters the sandbox. Without it the layer loads, maps, and + # silently can't reach the compositor → no HDR10 offered → PQ tone-mapped to SDR, badge dark. + # Binding xdg-run/gamescope-0 is the missing half (chiaki-ng does the same). With all three the + # surface offers HDR10 and the presenter's existing HDR10 swapchain path engages — no client code + # change. Harmless off-Deck: the layer no-ops when there's no gamescope socket to bind. - --env=VK_ADD_IMPLICIT_LAYER_PATH=/usr/lib/extensions/vulkan/gamescope/share/vulkan/implicit_layer.d - --env=ENABLE_GAMESCOPE_WSI=1 + - --filesystem=xdg-run/gamescope-0 # gamescope's private Wayland socket (HDR negotiation) build-options: append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm20/bin From 19e9828e8d3ff3eeaf9cd4cd2a7cb430808ad184 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 11 Jul 2026 03:07:58 +0200 Subject: [PATCH 2/5] fix(host/windows): force off a stale rumble residual the game abandoned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XInput vibration is level-triggered — it persists until the game sets it to zero — so a game that latches a rumble and then stops calling XInputSetState (a residual left at a menu/loading screen, or a plain forgotten stop) drones to the client forever (measured: a stuck (0,512) resent every 500 ms for 5.5 minutes). A real controller stops when the app stops driving it; mirror that. Keyed on game ACTIVITY (any SET_STATE, even an unchanged one), so a rumble the game keeps asserting is never cut — only an abandoned residual is; kept above SDL's ~2 s resend so an SDL-driven host game refreshes the activity clock before it fires. This is the game-facing half of the rumble-stop story; the wire-facing half is the self-terminating envelope model in the following commit. They compose: this bounds a game-abandoned rumble at the host, envelopes bound a host-abandoned rumble at the client. Co-Authored-By: Claude Fable 5 --- .../src/inject/windows/gamepad_windows.rs | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs index 6b9cd29b..dd245194 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs @@ -16,7 +16,7 @@ use super::gamepad_raii::PadChannel; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use anyhow::{anyhow, Result}; use std::ffi::c_void; -use std::time::Duration; +use std::time::{Duration, Instant}; use windows::core::{w, GUID, HRESULT, PCWSTR}; use windows::Win32::Devices::Enumeration::Pnp::{ SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, @@ -273,9 +273,24 @@ impl XusbWinPad { /// All virtual Xbox 360 pads of a session — the Windows analogue of the Linux uinput-xpad manager, /// now backed by the XUSB companion driver. Same method surface (`new`/`handle`/`pump_rumble`) the /// session input thread already drives. +/// How long a non-zero rumble may stay latched with the game NOT driving the pad (no `SET_STATE`) +/// before it is forced off. XInput vibration is level-triggered — it persists until the game sets +/// it to zero — so a game that latches a rumble and then stops calling `XInputSetState` (a residual +/// left at a menu / loading screen, or a plain forgotten stop) would otherwise drone to the client +/// forever (measured: a stuck `(0,512)` resent every 500 ms for 5.5 minutes). A real controller +/// stops when the app stops driving it; this mirrors that. It is keyed on game ACTIVITY (any +/// `SET_STATE`, even an unchanged one), so a rumble the game keeps asserting is never cut — only an +/// abandoned residual is. Kept above SDL's ~2 s internal rumble resend so an SDL-driven host game +/// (which re-issues the same level every ~2 s) refreshes the activity clock before this fires. +const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500); + pub struct GamepadManager { pads: Vec>, last_rumble: Vec<(u8, u8)>, + /// When the game last drove each pad (bumped `rumble_seq` via `SET_STATE`). A non-zero + /// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the + /// const's docs. + last_active: Vec, broken: bool, } @@ -290,6 +305,7 @@ impl GamepadManager { GamepadManager { pads: (0..MAX_PADS).map(|_| None).collect(), last_rumble: vec![(0, 0); MAX_PADS], + last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(), broken: false, } } @@ -356,10 +372,28 @@ impl GamepadManager { continue; }; if let Some((large, small)) = pad.service() { + // The game drove the pad this poll (SET_STATE bumped the seq) — refresh the + // activity clock even when the level is unchanged, so a rumble it keeps asserting + // never trips the stale-residual timeout below. + self.last_active[i] = Instant::now(); if self.last_rumble[i] != (large, small) { self.last_rumble[i] = (large, small); send(i as u16, large as u16 * 257, small as u16 * 257); } + } else if self.last_rumble[i] != (0, 0) + && self.last_active[i].elapsed() >= RUMBLE_IDLE_TIMEOUT + { + // A non-zero rumble is latched but the game has not driven the pad for + // RUMBLE_IDLE_TIMEOUT — a residual it forgot to stop. Force it off (and forward + // the zero) so the resend loop stops droning it to the client. See the const docs. + tracing::info!( + index = i, + prev_low = self.last_rumble[i].0 as u16 * 257, + prev_high = self.last_rumble[i].1 as u16 * 257, + "rumble: stale residual (game stopped driving the pad) — forcing off" + ); + self.last_rumble[i] = (0, 0); + send(i as u16, 0, 0); } } } From 73c911cae4d068e7a47eb37fa6be8ab0a7ac3733 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 11 Jul 2026 03:08:27 +0200 Subject: [PATCH 3/5] feat(rumble): host-authoritative self-terminating envelopes (0xCA v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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); 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 --- .../io/unom/punktfunk/kit/GamepadFeedback.kt | 43 +++-- clients/android/native/src/feedback.rs | 24 ++- .../Connection/PunktfunkConnection.swift | 30 +++- .../Gamepad/GamepadFeedback.swift | 8 +- .../PunktfunkKit/Gamepad/RumbleRenderer.swift | 57 +++++-- .../LoopbackIntegrationTests.swift | 7 +- .../PunktfunkKitTests/RumbleTuningTests.swift | 34 ++++ clients/probe/src/main.rs | 42 ++++- clients/windows/src/gamepad.rs | 14 +- crates/pf-client-core/src/gamepad.rs | 81 ++++++--- crates/punktfunk-core/src/abi.rs | 60 +++++++ crates/punktfunk-core/src/client.rs | 62 ++++++- crates/punktfunk-core/src/lib.rs | 6 +- crates/punktfunk-core/src/quic/datagram.rs | 83 +++++++++- crates/punktfunk-core/src/quic/tests.rs | 73 +++++++++ crates/punktfunk-host/src/punktfunk1.rs | 154 ++++++++++++++++-- include/punktfunk_core.h | 47 +++++- 17 files changed, 734 insertions(+), 91 deletions(-) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index 2c37cf7a..46f2b122 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -35,6 +35,9 @@ class GamepadFeedback(private val handle: Long) { const val TAG_LED: Byte = 0x01 const val TAG_PLAYER_LEDS: Byte = 0x02 const val TAG_TRIGGER: Byte = 0x03 + // Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value. + // A new host renews far below this, so it never actually holds this long there. + const val LEGACY_RUMBLE_MS = 60_000L } @Volatile private var running = false @@ -66,7 +69,17 @@ class GamepadFeedback(private val handle: Long) { while (running) { val ev = NativeBridge.nativeNextRumble(handle) if (ev < 0L) continue // timeout / closed - renderRumble(((ev ushr 16) and 0xFFFF).toInt(), (ev and 0xFFFF).toInt()) + // ev bit 48 = has a v2 lease; bits 32..47 = ttl_ms; 16..31 = low; 0..15 = high. The + // lease flag is out-of-band, so any ttl_ms (incl. 0xFFFF) is a real lease — no + // in-band sentinel. No lease (legacy host) → the prior long one-shot. + val hasLease = ((ev ushr 48) and 0x1L) == 0x1L + val ttl = ((ev ushr 32) and 0xFFFF).toInt() + val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS + renderRumble( + ((ev ushr 16) and 0xFFFF).toInt(), + (ev and 0xFFFF).toInt(), + durationMs, + ) } }, "pf-rumble").apply { isDaemon = true; start() } @@ -143,9 +156,14 @@ class GamepadFeedback(private val handle: Long) { } } - /** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */ - private fun renderRumble(low: Int, high: Int) { - Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return + /** + * low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). + * `durationMs` is the host's v2 envelope TTL — the one-shot self-terminates after it unless the + * host renews, so a lost stop (or a dead host) silences at the lease instead of the old fixed + * 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS] (the prior fixed duration). + */ + private fun renderRumble(low: Int, high: Int, durationMs: Long) { + Log.i(TAG, "rumble low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return val lo = toAmplitude(low) val hi = toAmplitude(high) val m = vm @@ -157,12 +175,12 @@ class GamepadFeedback(private val handle: Long) { val combo = CombinedVibration.startParallel() if (amplitudeControlled && vibratorIds.size >= 2) { // ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention). - if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi)) - if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo)) + if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi, durationMs)) + if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo, durationMs)) } else { // Single motor or no amplitude control: blend both into one effect. val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) - for (id in vibratorIds) combo.addVibrator(id, oneShot(a)) + for (id in vibratorIds) combo.addVibrator(id, oneShot(a, durationMs)) } runCatching { m.vibrate(combo.combine()) } return @@ -175,7 +193,10 @@ class GamepadFeedback(private val handle: Long) { } val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) runCatching { - lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE)) + lv.vibrate( + if (amplitudeControlled) oneShot(a, durationMs) + else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs) + ) } } @@ -185,8 +206,10 @@ class GamepadFeedback(private val handle: Long) { return if (v16 != 0 && a == 0) 1 else a } - // Long one-shot held until the next packet (the host re-sends ~periodically); cancel on zero. - private fun oneShot(amp: Int): VibrationEffect = VibrationEffect.createOneShot(60_000L, amp) + // One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it + // self-terminates on a lost stop; cancel on zero. + private fun oneShot(amp: Int, durationMs: Long): VibrationEffect = + VibrationEffect.createOneShot(durationMs, amp) // ---- HID output ---- diff --git a/clients/android/native/src/feedback.rs b/clients/android/native/src/feedback.rs index f87eeecb..f5782276 100644 --- a/clients/android/native/src/feedback.rs +++ b/clients/android/native/src/feedback.rs @@ -24,8 +24,12 @@ const TAG_PLAYER_LEDS: u8 = 0x02; const TAG_TRIGGER: u8 = 0x03; /// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update. -/// Returns `(low << 16) | high` (each 0..=0xFFFF; `0` = stop), or `-1` on timeout / session closed. -/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread. +/// Returns a packed positive long: bit 48 = "has a v2 lease", bits 32..47 = `ttl_ms`, bits 16..31 = +/// `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` = stop). The lease flag is +/// out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no in-band sentinel to +/// collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and Kotlin falls +/// back to its long one-shot. `-1` on timeout / session closed (all packed values are positive, so +/// `-1` stays unambiguous). Pad index is dropped (single-pad model). Run from a Kotlin poll thread. #[no_mangle] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( _env: JNIEnv, @@ -37,12 +41,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( if handle == 0 { return -1; } - // SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the - // Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll + // SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_ttl is &self on + // the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll // threads (and joins them — unbounded) before nativeClose frees the handle. let h = unsafe { &*(handle as *const SessionHandle) }; - match h.client.next_rumble(PULL_TIMEOUT) { - Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high), + match h.client.next_rumble_ttl(PULL_TIMEOUT) { + Ok((_pad, low, high, ttl)) => { + // The reorder gate already ran in the core, so this update is fresh. Encode the + // Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. + let (lease_flag, ttl_bits) = match ttl { + Some(ms) => (1i64 << 48, jlong::from(ms) << 32), + None => (0, 0), + }; + lease_flag | ttl_bits | (jlong::from(low) << 16) | jlong::from(high) + } Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag } }) diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 7f57f675..627f8169 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -570,7 +570,8 @@ public final class PunktfunkConnection { /// Pull the next force-feedback update for the GCController haptics engine: /// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop. - /// Drain from the (single) feedback thread, alongside `nextHidOutput`. + /// Drain from the (single) feedback thread, alongside `nextHidOutput`. Drops the v2 + /// self-termination TTL — use `nextRumble2` to honor the host lease. public func nextRumble(timeoutMs: UInt32 = 0) throws -> (pad: UInt16, low: UInt16, high: UInt16)? { feedbackLock.lock() defer { feedbackLock.unlock() } @@ -590,6 +591,33 @@ public final class PunktfunkConnection { } } + /// Pull the next force-feedback update *including its self-termination TTL* (v2 envelopes): + /// `(pad, low, high, ttlMs)`. `ttlMs` is how long to render this level before silencing unless + /// the host renews it; `RumbleTuning.noTTL` (`UInt32.max`) means "no lease" — a legacy host, so + /// fall back to a client-side staleness timeout. The reorder gate (seq) already ran in the + /// core, so a stale/reordered envelope never surfaces here. Drain from the (single) feedback + /// thread, alongside `nextHidOutput`. + public func nextRumble2(timeoutMs: UInt32 = 0) throws + -> (pad: UInt16, low: UInt16, high: UInt16, ttlMs: UInt32)? + { + feedbackLock.lock() + defer { feedbackLock.unlock() } + guard let h = liveHandle() else { throw PunktfunkClientError.closed } + + var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, ttl: UInt32 = .max + let rc = punktfunk_connection_next_rumble2(h, &pad, &low, &high, &ttl, timeoutMs) + switch rc { + case statusOK: + return (pad, low, high, ttl) + case statusNoFrame: + return nil + case statusClosed: + throw PunktfunkClientError.closed + default: + throw PunktfunkClientError.status(rc) + } + } + /// One DualSense feedback event a game wrote to the host's virtual pad — replay it on /// the real controller (GCDeviceLight, GCControllerPlayerIndex, /// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these. diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift index a0ecc877..ddd38d90 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift @@ -92,15 +92,15 @@ public final class GamepadFeedback { // drain: levels rendered up to ~130 ms late through the core's 16-deep queue, // and its drop-newest overflow could shed a stop while stale nonzero states // queued ahead of it — buzzing until the host's next 500 ms refresh. - var newest: (low: UInt16, high: UInt16)? + var newest: (low: UInt16, high: UInt16, ttl: UInt32)? var rumbleBurst = 0 while rumbleBurst < 64, !flag.isStopped, - let r = try connection.nextRumble(timeoutMs: 0) { - if r.pad == 0 { newest = (r.low, r.high) } + let r = try connection.nextRumble2(timeoutMs: 0) { + if r.pad == 0 { newest = (r.low, r.high, r.ttlMs) } rumbleBurst += 1 } if let n = newest { - self?.rumble.apply(low: n.low, high: n.high) + self?.rumble.apply(low: n.low, high: n.high, ttlMs: n.ttl) } // Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing // per-frame LED/trigger reports) can't spin here or block stop() past one cycle. diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift index 772ac5ea..3da4af5e 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift @@ -23,10 +23,23 @@ enum RumbleTuning { /// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens; /// zero is never throttled. static let minRebakeSeconds: TimeInterval = 0.025 - /// Session watchdog: silence the motors when no wire command arrived for this long. The - /// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only - /// after 3 consecutive refreshes vanished — i.e. the channel or host died while audible. + /// Session watchdog: silence the motors when no wire command arrived for this long. This is + /// the **legacy-host fallback only** — an old host sends no self-termination lease, so its + /// periodic re-send (every 500 ms) is the sole liveness signal and 3 vanished refreshes means + /// the channel or host died while audible. A v2 host instead supplies a per-command TTL (see + /// [`leaseSeconds`]); that deadline supersedes this watchdog. static let sessionStaleSeconds: TimeInterval = 1.6 + + /// The legacy no-lease sentinel a v2 `ttl_ms` carries for an old host (mirrors the C ABI's + /// `PUNKTFUNK_RUMBLE_NO_TTL`). `UInt32.max` by construction. + static let noTTL: UInt32 = .max + + /// Interpret a wire TTL (ms) from a rumble update: `nil` for the legacy no-lease sentinel + /// ([`noTTL`]) — the renderer falls back to [`sessionStaleSeconds`] — else the self-termination + /// lease in seconds (render the level for at most this long unless the host renews it). + static func leaseSeconds(ttlMs: UInt32) -> TimeInterval? { + ttlMs == noTTL ? nil : TimeInterval(ttlMs) / 1000 + } /// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host /// refresh must never rebuild a player. static let levelEpsilon: Float = 1.0 / 256.0 @@ -139,6 +152,10 @@ final class RumbleRenderer: @unchecked Sendable { /// Wire-truth target (raw wire units) and when it was last confirmed by any command. private var target: (low: UInt16, high: UInt16) = (0, 0) private var lastCommand = DispatchTime(uptimeNanoseconds: 0) + /// The v2 envelope lease: the active level is authorized until here unless the host renews it + /// (`tick` silences at the deadline). `nil` against a legacy host (no lease — the + /// `sessionStaleSeconds` watchdog is the backstop) and while silent. + private var envelopeDeadline: DispatchTime? /// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm, /// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent, /// so an idle controller costs no timer wakeups and no radio traffic. @@ -212,13 +229,23 @@ final class RumbleRenderer: @unchecked Sendable { } } - /// Set the wire-truth target. Called with every 0xCA state the host sends — level changes - /// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise - /// free (invariant 2). - func apply(low lowAmp: UInt16, high highAmp: UInt16) { + /// Set the wire-truth target. Called with every 0xCA state the host sends — level changes AND + /// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the + /// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`] + /// against a legacy host (no lease → the staleness watchdog is the backstop). Renewals at an + /// unchanged level extend the deadline before the idempotence guard, so a held rumble never + /// lapses mid-effect. + func apply(low lowAmp: UInt16, high highAmp: UInt16, ttlMs: UInt32 = RumbleTuning.noTTL) { queue.async { self.lastCommand = .now() let active = lowAmp != 0 || highAmp != 0 + // v2 lease: a nonzero level gets an explicit deadline; a stop or a legacy update clears + // it. Set BEFORE the idempotence guard so an identical renewal still extends the lease. + if let lease = RumbleTuning.leaseSeconds(ttlMs: ttlMs), active { + self.envelopeDeadline = .now() + lease + } else { + self.envelopeDeadline = nil + } if active != self.wasActive { self.wasActive = active log.debug( @@ -236,6 +263,7 @@ final class RumbleRenderer: @unchecked Sendable { self.ticker?.cancel() self.ticker = nil self.target = (0, 0) + self.envelopeDeadline = nil self.wasActive = false self.teardown() self.closeHID() @@ -293,9 +321,18 @@ final class RumbleRenderer: @unchecked Sendable { /// Watchdog + housekeeping heartbeat while audible. private func tick() { - if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after { - // The host refreshes rumble state every 500 ms; this much silence means the channel - // (or host) died while a motor was on. A direct-connected pad would have been + if let deadline = envelopeDeadline { + // v2 host lease: silence the moment it lapses unrenewed. This firing in the wild is the + // observable signature of a host that stopped renewing (a dropped stop, or a dead host) + // — the whole point of the envelope model: the motor can't outlive the host's intent. + if target != (0, 0), DispatchTime.now() >= deadline { + log.warning("rumble: envelope expired unrenewed — silencing") + target = (0, 0) + envelopeDeadline = nil + } + } else if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after { + // Legacy host (no lease): it re-sends state every 500 ms, so this much silence means the + // channel (or host) died while a motor was on. A direct-connected pad would have been // stopped by its game long ago — force the same outcome. log.warning( "rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing") diff --git a/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift b/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift index f61120d3..680ca422 100644 --- a/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift @@ -88,16 +88,19 @@ final class LoopbackIntegrationTests: XCTestCase { // one feedback burst on the host→client planes — drain both and verify, end to // end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD). if ProcessInfo.processInfo.environment["PUNKTFUNK_TEST_FEEDBACK"] == "1" { - var rumble: (pad: UInt16, low: UInt16, high: UInt16)? + var rumble: (pad: UInt16, low: UInt16, high: UInt16, ttlMs: UInt32)? var hidout: [PunktfunkConnection.HidOutputEvent] = [] let feedbackDeadline = Date().addingTimeInterval(10) while (rumble == nil || hidout.count < 3), Date() < feedbackDeadline { - if rumble == nil, let r = try conn.nextRumble(timeoutMs: 100) { rumble = r } + if rumble == nil, let r = try conn.nextRumble2(timeoutMs: 100) { rumble = r } if let ev = try conn.nextHidOutput(timeoutMs: 100) { hidout.append(ev) } } XCTAssertEqual(rumble?.pad, 0) XCTAssertEqual(rumble?.low, 0x4000) XCTAssertEqual(rumble?.high, 0x8000) + // The synthetic host emits a v2 envelope (400 ms TTL) — assert the self-terminating tail + // survived the full wire → C ABI → Swift path, not just the level. + XCTAssertEqual(rumble?.ttlMs, 400) XCTAssertTrue( hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)), "missing the scripted lightbar event: \(hidout)") diff --git a/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift b/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift index d5562e0c..d947bff3 100644 --- a/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift @@ -75,6 +75,40 @@ final class RumbleTuningTests: XCTestCase { renderer.stop() } + func testLeaseSecondsInterpretsWireTTL() { + // The legacy no-lease sentinel → nil (fall back to the staleness watchdog). + XCTAssertNil(RumbleTuning.leaseSeconds(ttlMs: RumbleTuning.noTTL)) + XCTAssertEqual(RumbleTuning.noTTL, UInt32.max) + // A real lease → its duration in seconds. + XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 400), 0.4, accuracy: 1e-9) + XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 0), 0, accuracy: 1e-9) + XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 150), 0.15, accuracy: 1e-9) + } + + func testEnvelopeLeaseBoundsMotorLifeTighterThanTheLegacyWatchdog() { + // The whole point of v2: a host-supplied lease silences the motor faster than the + // legacy staleness watchdog ever could (which needs sessionStaleSeconds of silence). The + // default 400 ms TTL is well under that, on every platform. + let defaultTTL = RumbleTuning.leaseSeconds(ttlMs: 400) + XCTAssertNotNil(defaultTTL) + XCTAssertLessThan(defaultTTL!, RumbleTuning.sessionStaleSeconds) + // The ticker must be able to observe an expired lease promptly (well within one TTL). + XCTAssertLessThan(RumbleTuning.tickSeconds, defaultTTL!) + } + + /// A v2 envelope with a short TTL, left unrenewed, must self-silence — the renderer's core + /// promise. Drive the real queue/ticker (no physical pad) and confirm it doesn't wedge. + func testEnvelopeExpiresWhenUnrenewed() { + let renderer = RumbleRenderer(policy: .session) + renderer.retarget(nil) + // A 100 ms lease, then no renewal — the ticker (50 ms) must silence it on its own. + renderer.apply(low: 0x8000, high: 0x8000, ttlMs: 100) + Thread.sleep(forTimeInterval: 0.3) + // No assertion on private state; this exercises the expiry path + serial-queue teardown + // without deadlock (the ticker fires on the same queue stop() sync-hops onto). + renderer.stop() + } + func testTuningRelationsTheDesignDependsOn() { // The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps) // but trip well before a stuck rumble reads as "still going". diff --git a/clients/probe/src/main.rs b/clients/probe/src/main.rs index 5d213e3b..a5b372ff 100644 --- a/clients/probe/src/main.rs +++ b/clients/probe/src/main.rs @@ -1007,6 +1007,10 @@ async fn session(args: Args) -> Result<()> { let audio_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); let rumble_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); let hidout_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + // Set when a self-terminating v2 rumble envelope (0xCA with the seq+ttl tail) arrives — the + // Rust-side contract check for `PUNKTFUNK_TEST_FEEDBACK` (asserted at report time). A legacy v1 + // datagram leaves it false, so this only ever fails when a v2 tail we EXPECTED went missing. + let saw_v2_rumble = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); // Per-AU host timings (0xCF) → the stream loop, which matches them to received AUs by pts // and reports the host/network split. try_send: overflow drops samples, never blocks QUIC. let (host_timing_tx, host_timing_rx) = @@ -1018,6 +1022,7 @@ async fn session(args: Args) -> Result<()> { rumble_pkts.clone(), hidout_pkts.clone(), ); + let saw_v2 = saw_v2_rumble.clone(); let ht_tx = host_timing_tx; let conn2 = conn.clone(); // Build a multistream decoder for the host-RESOLVED layout so the probe actually decodes @@ -1026,6 +1031,7 @@ async fn session(args: Args) -> Result<()> { tokio::spawn(async move { use std::sync::atomic::Ordering::Relaxed; let mut hdr_logged = false; + let mut rumble_logged = false; let layout = punktfunk_core::audio::layout_for(audio_channels, false); let mut audio_dec = opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping).ok(); @@ -1051,7 +1057,24 @@ async fn session(args: Args) -> Result<()> { Err(e) => tracing::debug!(error = %e, "probe audio decode"), } } - } else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() { + } else if let Some(u) = punktfunk_core::quic::decode_rumble_envelope(&d) { + // Log the first rumble so a loopback test can see the self-terminating v2 + // envelope tail (seq + TTL) arrived, not just the level. + if !rumble_logged { + rumble_logged = true; + tracing::info!( + pad = u.pad, + low = u.low, + high = u.high, + envelope = ?u.envelope, + "rumble (0xCA)" + ); + } + // Record that a v2 tail was present — the Rust-side seq/ttl contract check for + // PUNKTFUNK_TEST_FEEDBACK (asserted at report time). + if u.envelope.is_some() { + saw_v2.store(true, Relaxed); + } r.fetch_add(1, Relaxed); } else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) { // HDR static metadata (0xCE). Log the first receipt so a loopback test can @@ -1292,6 +1315,23 @@ async fn session(args: Args) -> Result<()> { } } + // Rust-side rumble-envelope contract check: when the host was told to script a feedback burst + // (PUNKTFUNK_TEST_FEEDBACK, shared by a loopback harness), fail if no self-terminating v2 tail + // (seq + TTL) arrived — a regression that reverted the host to v1 level datagrams increments the + // rumble counter identically and would otherwise pass silently. Only tightens an otherwise-OK run + // (a video failure stays the primary error). The level + hidout planes are asserted end-to-end by + // the Apple loopback; this covers the seq/ttl tail on the Rust/Linux path. + let result = if std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1") + && result.is_ok() + && !saw_v2_rumble.load(std::sync::atomic::Ordering::Relaxed) + { + Err(anyhow::anyhow!( + "PUNKTFUNK_TEST_FEEDBACK: expected a v2 rumble envelope (0xCA seq+ttl tail), received none" + )) + } else { + result + }; + // `--quit` closes with the deliberate-quit code so the host skips the keep-alive linger; a normal // exit uses code 0 (an unwanted-disconnect close → the host lingers for a reconnect). let close_code = if args.quit { diff --git a/clients/windows/src/gamepad.rs b/clients/windows/src/gamepad.rs index 816282a2..3fb1c5b6 100644 --- a/clients/windows/src/gamepad.rs +++ b/clients/windows/src/gamepad.rs @@ -607,18 +607,22 @@ fn run( } } - // Feedback planes (this thread is their single consumer). The host re-sends rumble state - // periodically, so a generous duration with refresh-on-update is safe — a dropped stop - // heals within ~500 ms. + // Feedback planes (this thread is their single consumer). Rumble arrives as + // self-terminating v2 envelopes: the host renews an active level and lets an abandoned one + // lapse, so the SDL duration is the host's TTL — a lost stop (or a dead host) self-silences + // at the lease instead of droning. A legacy host (`ttl == None`) sends no lease → keep the + // proven 5 s duration and rely on its periodic re-send as before. if let Some(connector) = w.attached.clone() { - 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 { + // Floor the lease so a jittered renewal can't gap the actuator between writes. + let dur_ms = ttl.map_or(5_000, |ms| (ms as u32).max(240)); if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) { // 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, so the two together pinpoint host-game vs // client-render. - if let Err(e) = p.set_rumble(low, high, 5_000) { + if let Err(e) = p.set_rumble(low, high, dur_ms) { tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"); } else { tracing::debug!(low, high, "rumble: rendered"); diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index e8a44577..71fcc0b3 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -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, + /// 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, } } } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index a99d99c6..d0dd8448 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -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 diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index 82641ea1..0ea67267 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -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); + /// 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, audio: Mutex>, - rumble: Mutex>, + rumble: Mutex>, /// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams. hidout: Mutex>, /// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams. @@ -564,7 +571,7 @@ impl NativeClient { ) -> Result { let frame_chan = Arc::new(FrameChannel::new()); let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::(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::(RUMBLE_QUEUE); let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::(HIDOUT_QUEUE); let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::(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 { 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, audio_tx: SyncSender, - rumble_tx: SyncSender<(u16, u16, u16)>, + rumble_tx: SyncSender, hidout_tx: SyncSender, hdr_meta_tx: SyncSender, host_timing_tx: SyncSender, @@ -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; 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) => { diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index f4c77936..b3fe949e 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -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** diff --git a/crates/punktfunk-core/src/quic/datagram.rs b/crates/punktfunk-core/src/quic/datagram.rs index 4f7789fa..17cf1ecc 100644 --- a/crates/punktfunk-core/src/quic/datagram.rs +++ b/crates/punktfunk-core/src/quic/datagram.rs @@ -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, +} + +/// 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 { + 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 { diff --git a/crates/punktfunk-core/src/quic/tests.rs b/crates/punktfunk-core/src/quic/tests.rs index 889a2b92..dbc36240 100644 --- a/crates/punktfunk-core/src/quic/tests.rs +++ b/crates/punktfunk-core/src/quic/tests.rs @@ -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]; diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index b8978a19..48972d45 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -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 = 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, 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; 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::().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()); + } } } } diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 366f93cd..52faa565 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -21,7 +21,11 @@ // clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake). // v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake — // the display-side companion to dial-first, so saved-host "online" pips reflect real reachability). -#define ABI_VERSION 4 +// v5: added `punktfunk_connection_next_rumble2` (rumble pull that also yields the self-terminating +// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive — +// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so +// [`WIRE_VERSION`] is unchanged. +#define ABI_VERSION 5 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -155,6 +159,11 @@ // Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.) #define PUNKTFUNK_CODEC_AV1 4 +// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble +// datagram — an old host that sent no self-termination lease. The client then falls back to its +// own staleness heuristic for that update instead of a host-supplied deadline. +#define PUNKTFUNK_RUMBLE_NO_TTL 4294967295 + // 16-byte AEAD authentication tag appended by GCM. #define TAG_LEN 16 @@ -289,6 +298,19 @@ #define HIDOUT_MAGIC 205 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Wire length of a v1 (legacy, level) rumble datagram. +#define RUMBLE_V1_LEN 7 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Wire length of a v2 (envelope) rumble datagram — the v1 body plus a `[u8 seq][u16 ttl_ms LE]` +// tail. Decoders are length-tolerant (see [`decode_rumble_envelope`]): an old client reads the +// first 7 bytes as a plain level and ignores the tail, so no wire-version bump is needed — the +// same dual-size idiom the HDR-luminance `AddRequest` tail uses. +#define RUMBLE_V2_LEN 10 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI; // see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`]. @@ -1291,6 +1313,10 @@ PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c, // are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop. // Same timeout/closed semantics as [`punktfunk_connection_next_audio`]. // +// This drops the self-terminating TTL of a v2 rumble envelope — an embedder that only calls this +// keeps its own staleness policy, exactly as before. Use [`punktfunk_connection_next_rumble2`] to +// honor the host-supplied lease and delete the client-side timeout heuristics. +// // # Safety // `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At // most one thread pulls rumble — it may run concurrently with the video/audio pullers. @@ -1301,6 +1327,25 @@ PunktfunkStatus punktfunk_connection_next_rumble(PunktfunkConnection *c, uint32_t timeout_ms); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Pull the next rumble update *including its self-termination TTL* (v2 envelopes), waiting up to +// `timeout_ms`. Same `pad`/`low`/`high` semantics as [`punktfunk_connection_next_rumble`], plus +// `*ttl_ms`: how long (milliseconds) to render this level before silencing unless the host renews +// it. [`PUNKTFUNK_RUMBLE_NO_TTL`] means "no lease" — a legacy host; fall back to a client-side +// timeout. The reorder gate (seq) is applied inside the core before the update surfaces here, so a +// stale/reordered envelope never reaches the caller. +// +// # Safety +// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one +// thread pulls rumble — it may run concurrently with the video/audio pullers. +PunktfunkStatus punktfunk_connection_next_rumble2(PunktfunkConnection *c, + uint16_t *pad, + uint16_t *low, + uint16_t *high, + uint32_t *ttl_ms, + uint32_t timeout_ms); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger) // the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on From 01d10f46eab773af1d45db5b88fcc06de7b65658 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 11 Jul 2026 03:08:33 +0200 Subject: [PATCH 4/5] chore(release): bump workspace version to 0.9.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release 0.9.2 — self-terminating rumble envelopes (0xCA v2) make "stuck rumble" inexpressible on the wire and retire the per-client staleness guesses, plus the Windows game-abandoned-residual stop and the Steam Deck Game-Mode HDR fix (bind gamescope's Wayland socket). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 28 ++++++++++++++-------------- Cargo.toml | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 239e3500..83aa526c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2154,7 +2154,7 @@ dependencies = [ [[package]] name = "latency-probe" -version = "0.9.1" +version = "0.9.2" [[package]] name = "lazy_static" @@ -2286,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loss-harness" -version = "0.9.1" +version = "0.9.2" dependencies = [ "punktfunk-core", ] @@ -2765,7 +2765,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pf-client-core" -version = "0.9.1" +version = "0.9.2" dependencies = [ "anyhow", "async-channel", @@ -2787,7 +2787,7 @@ dependencies = [ [[package]] name = "pf-console-ui" -version = "0.9.1" +version = "0.9.2" dependencies = [ "anyhow", "ash", @@ -2808,7 +2808,7 @@ dependencies = [ [[package]] name = "pf-ffvk" -version = "0.9.1" +version = "0.9.2" dependencies = [ "ash", "bindgen", @@ -2817,7 +2817,7 @@ dependencies = [ [[package]] name = "pf-presenter" -version = "0.9.1" +version = "0.9.2" dependencies = [ "anyhow", "ash", @@ -3001,7 +3001,7 @@ dependencies = [ [[package]] name = "punktfunk-client-android" -version = "0.9.1" +version = "0.9.2" dependencies = [ "android_logger", "jni", @@ -3017,7 +3017,7 @@ dependencies = [ [[package]] name = "punktfunk-client-linux" -version = "0.9.1" +version = "0.9.2" dependencies = [ "anyhow", "async-channel", @@ -3033,7 +3033,7 @@ dependencies = [ [[package]] name = "punktfunk-client-session" -version = "0.9.1" +version = "0.9.2" dependencies = [ "anyhow", "pf-client-core", @@ -3048,7 +3048,7 @@ dependencies = [ [[package]] name = "punktfunk-client-windows" -version = "0.9.1" +version = "0.9.2" dependencies = [ "anyhow", "async-channel", @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "punktfunk-core" -version = "0.9.1" +version = "0.9.2" dependencies = [ "aes-gcm", "bytes", @@ -3103,7 +3103,7 @@ dependencies = [ [[package]] name = "punktfunk-host" -version = "0.9.1" +version = "0.9.2" dependencies = [ "aes", "aes-gcm", @@ -3175,7 +3175,7 @@ dependencies = [ [[package]] name = "punktfunk-probe" -version = "0.9.1" +version = "0.9.2" dependencies = [ "anyhow", "mdns-sd", @@ -3189,7 +3189,7 @@ dependencies = [ [[package]] name = "punktfunk-tray" -version = "0.9.1" +version = "0.9.2" dependencies = [ "anyhow", "ksni", diff --git a/Cargo.toml b/Cargo.toml index bac2cd1d..58a39f4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ exclude = [ ndk = { path = "clients/android/native/vendor/ndk" } [workspace.package] -version = "0.9.1" +version = "0.9.2" edition = "2021" rust-version = "1.82" license = "MIT OR Apache-2.0" From 4873e8925d4d9645e2fc2c583d1e799b7a7dade5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 11 Jul 2026 03:16:56 +0200 Subject: [PATCH 5/5] fix(apple/test): unwrap optional leaseSeconds in RumbleTuningTests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RumbleTuning.leaseSeconds returns TimeInterval? (nil for the no-lease sentinel); XCTAssertEqual(_, _, accuracy:) needs a non-optional Double. Coalesce with .nan so a nil (which must not happen for a real ttl) still fails the assertion. Test-only — the production Swift built clean. Co-Authored-By: Claude Fable 5 --- .../apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift b/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift index d947bff3..36b072bd 100644 --- a/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift @@ -79,10 +79,10 @@ final class RumbleTuningTests: XCTestCase { // The legacy no-lease sentinel → nil (fall back to the staleness watchdog). XCTAssertNil(RumbleTuning.leaseSeconds(ttlMs: RumbleTuning.noTTL)) XCTAssertEqual(RumbleTuning.noTTL, UInt32.max) - // A real lease → its duration in seconds. - XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 400), 0.4, accuracy: 1e-9) - XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 0), 0, accuracy: 1e-9) - XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 150), 0.15, accuracy: 1e-9) + // A real lease → its duration in seconds (non-nil for any ttl != noTTL). + XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 400) ?? .nan, 0.4, accuracy: 1e-9) + XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 0) ?? .nan, 0, accuracy: 1e-9) + XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 150) ?? .nan, 0.15, accuracy: 1e-9) } func testEnvelopeLeaseBoundsMotorLifeTighterThanTheLegacyWatchdog() {