From 9e6fc6e071f2ae7a5a9bf80703a12808f43dfd6d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 14:07:32 +0200 Subject: [PATCH] =?UTF-8?q?fix(host/inject,drivers):=20rumble=20root=20fix?= =?UTF-8?q?es=20A-C=20=E2=80=94=20lossless=20report=20ring=20+=20rumble-ke?= =?UTF-8?q?yed=20idle=20watchdogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B: PadFeedback.game_drove -> rumble_drove, keyed on vibration-asserting reports — an LED/adaptive-trigger stream can no longer feed the abandoned-rumble force-off while a coalesced stop never re-asserts (the confirmed unbounded stuck-ON path). C: Linux parity — every UHID backend now arms the shared watchdog (Steam Input drives these pads over hidraw with Windows abandonment semantics) and the uinput mixer force-stops abandoned infinite-replay FF effects (FfState, unit-tested). Shared PUNKTFUNK_RUMBLE_IDLE_MS hatch (0 = off; non-zero floored above SDL's ~2 s rumble resend). A: PadShm v2.1 — a 1024 B tail extension carrying an 8-slot lossless output-report ring, feature-negotiated via zeroed reserved fields (out_ring_ver; deliberately NO GAMEPAD_PROTO_VERSION bump — mixed generations degrade to the legacy latest-report slot instead of failing closed). The pf-dualsense driver dual-writes both planes (publish_output); the host's shared OutputDrain drains oldest->newest with a torn-read recheck and an overflow->resync path (PadFeedback.resync force-stops + re-arms dedups). pf-umdf-util grows a min_data_size map fallback. Ds*Feedback.fresh removed (dead). design/rumble-root-fix.md par. A-C. Verified: pf-inject tests+clippy Linux+Windows (53/53 on winbox incl. the stop-coalesce repro); drivers ws check+clippy on the CI runner. Co-Authored-By: Claude Fable 5 --- crates/pf-driver-proto/src/lib.rs | 66 +++- .../pf-inject/src/inject/linux/dualsense.rs | 22 +- .../pf-inject/src/inject/linux/dualshock4.rs | 6 +- crates/pf-inject/src/inject/linux/gamepad.rs | 203 ++++++++++-- .../src/inject/linux/steam_controller.rs | 18 +- .../src/inject/linux/steam_controller2.rs | 5 +- .../pf-inject/src/inject/linux/switch_pro.rs | 8 +- .../src/inject/proto/dualsense_proto.rs | 19 +- .../src/inject/proto/dualshock4_proto.rs | 25 +- crates/pf-inject/src/inject/uhid_manager.rs | 219 ++++++++++--- .../inject/windows/dualsense_edge_windows.rs | 4 +- .../src/inject/windows/dualsense_windows.rs | 290 ++++++++++++++++-- .../src/inject/windows/dualshock4_windows.rs | 47 ++- .../src/inject/windows/gamepad_windows.rs | 37 ++- .../src/inject/windows/steam_deck_windows.rs | 50 ++- .../windows/drivers/pf-dualsense/src/lib.rs | 45 ++- packaging/windows/drivers/pf-mouse/src/lib.rs | 1 + .../drivers/pf-umdf-util/src/channel.rs | 11 +- .../drivers/pf-umdf-util/src/section.rs | 6 + packaging/windows/drivers/pf-xusb/src/lib.rs | 7 +- 20 files changed, 877 insertions(+), 212 deletions(-) diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 53245f39..6aab89c9 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -690,10 +690,46 @@ pub mod gamepad { pub _reserved1: [u8; 20], } - /// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID - /// input report into `input`; the driver feeds it to game `READ_REPORT`s and publishes a game's - /// `0x02` output (rumble / lightbar / player-LEDs / adaptive triggers) into `output`, bumping - /// `out_seq`. `device_type` selects the HID identity ([`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]). + /// The legacy (pre-ring) [`PadShm`] size. Old binaries on either side were built against a + /// 256-byte layout; every field they know sits below this offset, and the ring extension keeps + /// bytes `0..256` byte-identical. Pagefile-backed sections are page-granular, so a view of + /// either generation's size maps against either generation's section — but a driver must + /// still be able to fall back to mapping this size if the full-size map is ever refused + /// (see `pf_umdf_util::ChannelConfig::min_data_size`). + pub const PAD_SHM_LEGACY_SIZE: usize = 256; + + /// Output-report ring depth. 8 slots at the host's ~4 ms poll tolerates a sustained 2 kHz + /// writer — double any real HID output rate. + pub const OUT_RING_LEN: u32 = 8; + pub const OUT_RING_LEN_USIZE: usize = OUT_RING_LEN as usize; + + /// One slot of the lossless output-report ring: the report bytes as the game wrote them + /// (report id first), with the exact length — unlike the legacy latest-report slot, whose + /// fixed 64-byte copy can carry a stale tail from a previous longer report. + #[repr(C)] + #[derive(Clone, Copy, Pod, Zeroable, Debug)] + pub struct OutSlot { + /// Valid bytes in `data` (0..=64). `0` = never written. + pub len: u32, + pub data: [u8; 64], + } + + /// Virtual DualSense / DualShock 4 shared section (1024 B; bytes `0..256` are the v2 legacy + /// layout verbatim — [`PAD_SHM_LEGACY_SIZE`]). The host writes the `0x01`-style HID input + /// report into `input`; the driver feeds it to game `READ_REPORT`s and publishes a game's + /// `0x02` output (rumble / lightbar / player-LEDs / adaptive triggers) twice: into the legacy + /// latest-report `output` slot (bumping `out_seq` — every host generation reads this), and, + /// when the host stamped `out_ring_ver`, into the lossless `out_ring` (bumping `ring_head`). + /// The ring exists because the single slot COALESCES: a rumble-stop report overwritten by an + /// LED/trigger report inside one host poll window was gone forever — the confirmed stuck-rumble + /// path (`design/rumble-root-fix.md` §A). `device_type` selects the HID identity + /// ([`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]). + /// + /// Version posture: this is a TAIL extension negotiated by zeroed-reserved capability fields, + /// deliberately NOT a [`GAMEPAD_PROTO_VERSION`] bump — the bootstrap fails CLOSED on a version + /// mismatch (no pad at all), which is the wrong failure mode for a feedback-quality fix. An + /// old driver never reads the new fields; an old host never stamps `out_ring_ver`, so a new + /// driver stays on the legacy slot against it. #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable, Debug)] pub struct PadShm { @@ -718,7 +754,20 @@ pub mod gamepad { /// The pad index this section serves (host-stamped before the magic) — see /// [`XusbShm::pad_index`]. Carved from v1 reserved space (v2). pub pad_index: u32, - pub _reserved1: [u8; 100], + /// Host-stamped `1` at section creation ⇔ "this section carries the `out_ring` region and + /// the host drains it". The section starts zeroed and an old host never writes it, so `0` + /// tells a new driver to stay legacy-only. Carved from v2 reserved space (v2.1). + pub out_ring_ver: u32, + /// Driver-bumped (AFTER writing `out_ring[ring_head % OUT_RING_LEN]`) count of reports + /// ever published to the ring — the host's drain cursor compares against its own tail and + /// detects overflow by `head - tail > OUT_RING_LEN`. Same publish-then-bump store order as + /// `out_seq` (the host's Acquire load orders the reads). Carved from v2 reserved space + /// (v2.1). + pub ring_head: u32, + pub _reserved1: [u8; 92], + /// The lossless output-report ring (v2.1) — see the struct docs and [`OutSlot`]. + pub out_ring: [OutSlot; OUT_RING_LEN_USIZE], + pub _reserved2: [u8; 224], } // Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing @@ -744,7 +793,7 @@ pub mod gamepad { assert!(offset_of!(XusbShm, driver_heartbeat) == 36); assert!(offset_of!(XusbShm, pad_index) == 40); - assert!(size_of::() == 256); + assert!(size_of::() == 1024); assert!(offset_of!(PadShm, magic) == 0); assert!(offset_of!(PadShm, input) == 8); assert!(offset_of!(PadShm, out_seq) == 72); @@ -753,6 +802,11 @@ pub mod gamepad { assert!(offset_of!(PadShm, driver_proto) == 144); assert!(offset_of!(PadShm, driver_heartbeat) == 148); assert!(offset_of!(PadShm, pad_index) == 152); + // v2.1 ring extension — everything below PAD_SHM_LEGACY_SIZE is the v2 layout verbatim. + assert!(offset_of!(PadShm, out_ring_ver) == 156); + assert!(offset_of!(PadShm, ring_head) == 160); + assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE); + assert!(size_of::() == 68); assert!(size_of::() == 32); assert!(offset_of!(PadBootstrap, magic) == 0); diff --git a/crates/pf-inject/src/inject/linux/dualsense.rs b/crates/pf-inject/src/inject/linux/dualsense.rs index 63ba5d22..121388e4 100644 --- a/crates/pf-inject/src/inject/linux/dualsense.rs +++ b/crates/pf-inject/src/inject/linux/dualsense.rs @@ -303,9 +303,14 @@ impl PadProto for DsLinuxProto { PadFeedback { rumble: fb.rumble, hidout: fb.hidout, - // Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does - // not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`). - game_drove: None, + // Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games + // going through hid-playstation get their stops surfaced reliably, but Steam Input + // drives this pad over hidraw DIRECTLY — the same abandonment semantics as a Windows + // game, so the same watchdog applies. SDL-class writers re-assert a held level every + // ~2 s (inside the idle window), and a writer that goes silent on a latched level is + // cut exactly as real firmware decay would cut it on a physical pad. + rumble_drove: Some(fb.rumble.is_some()), + resync: false, } } } @@ -392,9 +397,14 @@ impl PadProto for DsEdgeLinuxProto { PadFeedback { rumble: fb.rumble, hidout: fb.hidout, - // Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does - // not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`). - game_drove: None, + // Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games + // going through hid-playstation get their stops surfaced reliably, but Steam Input + // drives this pad over hidraw DIRECTLY — the same abandonment semantics as a Windows + // game, so the same watchdog applies. SDL-class writers re-assert a held level every + // ~2 s (inside the idle window), and a writer that goes silent on a latched level is + // cut exactly as real firmware decay would cut it on a physical pad. + rumble_drove: Some(fb.rumble.is_some()), + resync: false, } } } diff --git a/crates/pf-inject/src/inject/linux/dualshock4.rs b/crates/pf-inject/src/inject/linux/dualshock4.rs index 23f4fcf0..1f9555d2 100644 --- a/crates/pf-inject/src/inject/linux/dualshock4.rs +++ b/crates/pf-inject/src/inject/linux/dualshock4.rs @@ -378,7 +378,11 @@ impl PadProto for Ds4LinuxProto { .map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b }) .into_iter() .collect(), - game_drove: None, + // Rumble-plane liveness (arms the shared abandoned-rumble force-off) — see the Linux + // DualSense backend for the hidraw-writer rationale; `parse_ds4_output` gates rumble + // on flag0 bit0 the same way. + rumble_drove: Some(fb.rumble.is_some()), + resync: false, } } } diff --git a/crates/pf-inject/src/inject/linux/gamepad.rs b/crates/pf-inject/src/inject/linux/gamepad.rs index 6ed7dacc..cac39a69 100644 --- a/crates/pf-inject/src/inject/linux/gamepad.rs +++ b/crates/pf-inject/src/inject/linux/gamepad.rs @@ -23,7 +23,7 @@ use anyhow::{bail, Result}; use punktfunk_core::input::{gamepad, GamepadFrame, MAX_PADS}; use std::collections::HashMap; use std::os::fd::{AsRawFd, OwnedFd}; -use std::time::Instant; +use std::time::{Duration, Instant}; // ioctls (x86_64). const UI_DEV_CREATE: libc::c_ulong = 0x5501; @@ -263,14 +263,80 @@ struct Effect { replay_ms: u16, } -/// One virtual X-Box-360 pad backed by a uinput device. -pub struct VirtualPad { - fd: OwnedFd, +/// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy +/// (finite-replay expiry + the abandoned-INFINITE-effect force-off), split from [`VirtualPad`] so +/// the policy is pure and unit-testable without a live uinput fd. +struct FfState { effects: HashMap, next_effect_id: i16, gain: u32, /// Last `(low, high)` reported, to dedup. last_mix: (u16, u16), + /// When a game last touched the FF plane (upload / erase / play / stop / gain). An + /// infinite-replay effect still playing past the shared idle window against this is a residual + /// the game abandoned (kernel auto-erase only covers a game whose fd CLOSED) — finite effects + /// are untouched: their declared replay deadline is the contract, exactly as a real pad honors + /// it. SDL-class writers re-play held rumble every ~2 s, refreshing this clock. + last_activity: Instant, +} + +impl FfState { + fn new() -> FfState { + FfState { + effects: HashMap::new(), + next_effect_id: 0, + gain: 0xFFFF, + last_mix: (0, 0), + last_activity: Instant::now(), + } + } + + /// The game touched the FF plane — refresh the abandoned-effect clock. + fn note_activity(&mut self) { + self.last_activity = Instant::now(); + } + + /// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones), + /// scale by gain. Returns the new `(low, high)` only when it changed since the last call. + fn mix(&mut self, now: Instant, idle: Option) -> Option<(u16, u16)> { + let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t); + let (mut strong, mut weak) = (0u32, 0u32); + for e in self.effects.values_mut() { + let Some(deadline) = e.playing else { continue }; + match deadline { + Some(d) if now >= d => e.playing = None, + // An infinite-replay effect the game stopped driving (no FF traffic for the whole + // idle window) — the alive-but-abandoned case the kernel's close-time auto-erase + // cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the + // clock). Mirrors the XUSB/UHID abandoned-rumble force-off. + None if stale => { + tracing::info!( + strong = e.strong, + weak = e.weak, + "rumble: stale infinite FF effect (game stopped driving the pad) — forcing off" + ); + e.playing = None; + } + _ => { + strong = strong.saturating_add(e.strong as u32); + weak = weak.saturating_add(e.weak as u32); + } + } + } + // Linux FF: strong = low-frequency (big) motor, weak = high-frequency motor. + let low = ((strong.min(0xFFFF) * self.gain) >> 16) as u16; + let high = ((weak.min(0xFFFF) * self.gain) >> 16) as u16; + (self.last_mix != (low, high)).then(|| { + self.last_mix = (low, high); + (low, high) + }) + } +} + +/// One virtual X-Box-360 pad backed by a uinput device. +pub struct VirtualPad { + fd: OwnedFd, + ff: FfState, } impl VirtualPad { @@ -369,10 +435,7 @@ impl VirtualPad { Ok(VirtualPad { fd, - effects: HashMap::new(), - next_effect_id: 0, - gain: 0xFFFF, - last_mix: (0, 0), + ff: FfState::new(), }) } @@ -460,6 +523,7 @@ impl VirtualPad { unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) }; match (ev.type_, ev.code) { (EV_UINPUT, UI_FF_UPLOAD) => { + self.ff.note_activity(); // SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two // `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field // (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid @@ -469,13 +533,13 @@ impl VirtualPad { if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() { let mut e = up.effect; if e.id == -1 { - e.id = self.next_effect_id; - self.next_effect_id = self.next_effect_id.wrapping_add(1); + e.id = self.ff.next_effect_id; + self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1); } if e.type_ == FF_RUMBLE { let strong = u16::from_ne_bytes([e.u[0], e.u[1]]); let weak = u16::from_ne_bytes([e.u[2], e.u[3]]); - let slot = self.effects.entry(e.id).or_insert(Effect { + let slot = self.ff.effects.entry(e.id).or_insert(Effect { strong: 0, weak: 0, playing: None, @@ -491,20 +555,25 @@ impl VirtualPad { } } (EV_UINPUT, UI_FF_ERASE) => { + self.ff.note_activity(); // SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`, // `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid // initialized value — `request_id` is set below and `effect_id` filled by the ioctl. let mut er: UinputFfErase = unsafe { std::mem::zeroed() }; er.request_id = ev.value as u32; if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() { - self.effects.remove(&(er.effect_id as i16)); + self.ff.effects.remove(&(er.effect_id as i16)); er.retval = 0; let _ = ioctl_ptr(raw, UI_END_FF_ERASE, &mut er, "UI_END_FF_ERASE"); } } - (EV_FF, FF_GAIN) => self.gain = (ev.value as u32).min(0xFFFF), + (EV_FF, FF_GAIN) => { + self.ff.note_activity(); + self.ff.gain = (ev.value as u32).min(0xFFFF); + } (EV_FF, code) => { - if let Some(e) = self.effects.get_mut(&(code as i16)) { + self.ff.note_activity(); + if let Some(e) = self.ff.effects.get_mut(&(code as i16)) { e.playing = if ev.value != 0 { Some((e.replay_ms > 0).then(|| { Instant::now() @@ -519,26 +588,8 @@ impl VirtualPad { } } - // Mix: sum playing effects (expiring finished ones), scale by gain. - let now = Instant::now(); - let (mut strong, mut weak) = (0u32, 0u32); - for e in self.effects.values_mut() { - if let Some(deadline) = e.playing { - if deadline.is_some_and(|d| now >= d) { - e.playing = None; - } else { - strong = strong.saturating_add(e.strong as u32); - weak = weak.saturating_add(e.weak as u32); - } - } - } - // Linux FF: strong = low-frequency (big) motor, weak = high-frequency motor. - let low = ((strong.min(0xFFFF) * self.gain) >> 16) as u16; - let high = ((weak.min(0xFFFF) * self.gain) >> 16) as u16; - (self.last_mix != (low, high)).then(|| { - self.last_mix = (low, high); - (low, high) - }) + self.ff + .mix(Instant::now(), crate::uhid_manager::rumble_idle_timeout()) } } @@ -727,3 +778,87 @@ mod tests { ); } } + +#[cfg(test)] +mod ff_state_tests { + use super::*; + + /// The default idle window the shared hatch resolves to when the env is unset. + const IDLE: Option = Some(Duration::from_millis(2500)); + + /// `gain` is 0xFFFF (not a true 1.0 multiplier), so a magnitude loses 1 LSB in the mixdown. + fn scaled(v: u16) -> u16 { + ((v as u32 * 0xFFFF) >> 16) as u16 + } + + fn ff_with(effect: Effect) -> FfState { + let mut ff = FfState::new(); + ff.effects.insert(0, effect); + ff + } + + #[test] + fn abandoned_infinite_effect_is_forced_off_after_idle_window() { + let mut ff = ff_with(Effect { + strong: 0x8000, + weak: 0, + playing: Some(None), + replay_ms: 0, + }); + let now = Instant::now(); + assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); + assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing + // The game goes silent on the FF plane past the idle window: cut, exactly once. + ff.last_activity = now - Duration::from_millis(2600); + assert_eq!(ff.mix(now, IDLE), Some((0, 0))); + assert_eq!(ff.mix(now, IDLE), None); // already off — no repeat + } + + #[test] + fn finite_effect_honors_its_replay_deadline_not_the_idle_window() { + let now = Instant::now(); + let mut ff = ff_with(Effect { + strong: 0x4000, + weak: 0, + playing: Some(Some(now + Duration::from_secs(10))), + replay_ms: 10_000, + }); + // FF plane long stale, but the effect declared a finite replay — the declared duration is + // the contract (a real pad honors it too), so it keeps playing… + ff.last_activity = now - Duration::from_secs(60); + assert_eq!(ff.mix(now, IDLE), Some((scaled(0x4000), 0))); + // …and expires at its own deadline. + assert_eq!(ff.mix(now + Duration::from_secs(11), IDLE), Some((0, 0))); + } + + #[test] + fn replay_after_cut_rearms_the_effect() { + let now = Instant::now(); + let mut ff = ff_with(Effect { + strong: 0x8000, + weak: 0, + playing: Some(None), + replay_ms: 0, + }); + assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); + ff.last_activity = now - Duration::from_millis(3000); + assert_eq!(ff.mix(now, IDLE), Some((0, 0))); + // The game plays the effect again — an FF event refreshes the clock and re-arms playback. + ff.last_activity = now; + ff.effects.get_mut(&0).unwrap().playing = Some(None); + assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); + } + + #[test] + fn disabled_watchdog_never_cuts() { + let now = Instant::now(); + let mut ff = ff_with(Effect { + strong: 0x8000, + weak: 0, + playing: Some(None), + replay_ms: 0, + }); + ff.last_activity = now - Duration::from_secs(600); + assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0))); + } +} diff --git a/crates/pf-inject/src/inject/linux/steam_controller.rs b/crates/pf-inject/src/inject/linux/steam_controller.rs index 687cb5a8..73d6f1ec 100644 --- a/crates/pf-inject/src/inject/linux/steam_controller.rs +++ b/crates/pf-inject/src/inject/linux/steam_controller.rs @@ -441,10 +441,15 @@ impl PadProto for SteamProto { /// no rich host→client feedback plane (no lightbar / adaptive triggers), so `hidout` stays /// empty. fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback { + let rumble = pad.service(); PadFeedback { - rumble: pad.service(), + rumble, hidout: Vec::new(), - game_drove: None, + // Rumble-plane liveness: a `0xEB` rumble command this poll. Steam Input drives this + // pad over hidraw (the same abandonment semantics as the Windows Deck backend), so + // the shared abandoned-rumble force-off applies. + rumble_drove: Some(rumble.is_some()), + resync: false, } } @@ -558,10 +563,15 @@ impl PadProto for ScProto { /// registers no FF device for the classic SC, so rumble feedback can only arrive from a /// hidraw client (`0xEB`) — surfaced if it ever does. fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback { + let rumble = pad.service(); PadFeedback { - rumble: pad.service(), + rumble, hidout: Vec::new(), - game_drove: None, + // Rumble-plane liveness: the kernel registers no FF device for the classic SC, so + // rumble only ever arrives from a hidraw writer (`0xEB`) — which is exactly the + // writer class the shared abandoned-rumble force-off exists for. + rumble_drove: Some(rumble.is_some()), + resync: false, } } } diff --git a/crates/pf-inject/src/inject/linux/steam_controller2.rs b/crates/pf-inject/src/inject/linux/steam_controller2.rs index e3757580..4dc9a18a 100644 --- a/crates/pf-inject/src/inject/linux/steam_controller2.rs +++ b/crates/pf-inject/src/inject/linux/steam_controller2.rs @@ -379,7 +379,10 @@ impl PadProto for TritonProto { PadFeedback { rumble, hidout, - game_drove: None, + // Rumble-plane liveness: Steam is a hidraw writer here too, so the shared + // abandoned-rumble force-off applies (the raw 0xCD passthrough plane is unaffected). + rumble_drove: Some(rumble.is_some()), + resync: false, } } } diff --git a/crates/pf-inject/src/inject/linux/switch_pro.rs b/crates/pf-inject/src/inject/linux/switch_pro.rs index 086d5be8..c4f31f94 100644 --- a/crates/pf-inject/src/inject/linux/switch_pro.rs +++ b/crates/pf-inject/src/inject/linux/switch_pro.rs @@ -310,7 +310,13 @@ impl PadProto for SwitchProProto { /// answered — call frequently) and surface a game's feedback: HD-rumble amplitude on the /// universal 0xCA plane, player lights on the 0xCD plane. fn service(&self, pad: &mut SwitchProPad, idx: u8) -> PadFeedback { - pad.service(idx) + let mut fb = pad.service(idx); + // Rumble-plane liveness: hid-nintendo embeds rumble data in every command it sends, so a + // poll that surfaced rumble is the activity signal; a writer that goes silent on a latched + // level is cut by the shared abandoned-rumble force-off (a physical Joy-Con/Pro's + // HD-rumble decays on its own far faster than the idle window). + fb.rumble_drove = Some(fb.rumble.is_some()); + fb } } diff --git a/crates/pf-inject/src/inject/proto/dualsense_proto.rs b/crates/pf-inject/src/inject/proto/dualsense_proto.rs index 74e5d4ce..9393e0c6 100644 --- a/crates/pf-inject/src/inject/proto/dualsense_proto.rs +++ b/crates/pf-inject/src/inject/proto/dualsense_proto.rs @@ -469,10 +469,10 @@ pub struct DsFeedback { pub hidout: Vec, /// `(low, high)` motor levels (0..=0xFFFF), if a report carried them. pub rumble: Option<(u16, u16)>, - /// Whether a fresh output report was seen this poll (set by the backend's section poll, not by - /// the parser) — the game-activity signal the [`UhidManager`](crate::uhid_manager) - /// abandoned-rumble force-off keys on. - pub fresh: bool, + /// The driver's output-report ring overflowed this poll — pending reports were DISCARDED and + /// feedback state is unknown; the [`UhidManager`](crate::uhid_manager) must resync (silence + + /// re-armed dedups). Set by the backend's section drain, never by the parser. + pub resync: bool, } /// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is @@ -728,6 +728,17 @@ mod tests { assert!(fb.rumble.is_none()); assert_eq!(fb.hidout.len(), 1); assert!(matches!(fb.hidout[0], HidOutput::Led { r: 1, .. })); + + // An explicit stop — vibration flag SET, motors zero — parses as `Some((0, 0))`, never as + // absence: this is what distinguishes "the game stopped the motors" from "this report says + // nothing about rumble", and what `rumble_drove` (the abandoned-rumble watchdog's activity + // signal) keys on. + let mut data = vec![0u8; 48]; + data[0] = 0x02; + data[1] = 0x03; // compatible vibration + haptics select + let mut fb = DsFeedback::default(); + parse_ds_output(0, &data, &mut fb); + assert_eq!(fb.rumble, Some((0, 0))); } /// The input report's sensor/touch bytes must land exactly where the kernel's diff --git a/crates/pf-inject/src/inject/proto/dualshock4_proto.rs b/crates/pf-inject/src/inject/proto/dualshock4_proto.rs index 9752c386..d3955dcb 100644 --- a/crates/pf-inject/src/inject/proto/dualshock4_proto.rs +++ b/crates/pf-inject/src/inject/proto/dualshock4_proto.rs @@ -80,10 +80,10 @@ pub struct Ds4Feedback { pub rumble: Option<(u16, u16)>, /// Lightbar RGB, if the report carried it (deduped by the manager). pub led: Option<(u8, u8, u8)>, - /// Whether a fresh output report was seen this poll (set by the backend's section poll, not by - /// the parser) — the game-activity signal the [`UhidManager`](crate::uhid_manager) - /// abandoned-rumble force-off keys on. - pub fresh: bool, + /// The driver's output-report ring overflowed this poll — pending reports were DISCARDED and + /// feedback state is unknown; the [`UhidManager`](crate::uhid_manager) must resync (silence + + /// re-armed dedups). Set by the backend's section drain, never by the parser. + pub resync: bool, } /// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel @@ -182,5 +182,22 @@ mod tests { parse_ds4_output(&motor_only, &mut fb2); assert!(fb2.rumble.is_some()); assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change + + // LED-only write: rumble not asserted → stays `None` (this is what `rumble_drove` keys + // on — an LED stream must not read as rumble activity), and an explicit flagged zero + // parses as `Some((0, 0))`, never as absence. + let mut led_only = [0u8; 32]; + led_only[0] = 0x05; + led_only[1] = 0x02; // LED only + led_only[6] = 0x11; + let mut fb3 = Ds4Feedback::default(); + parse_ds4_output(&led_only, &mut fb3); + assert!(fb3.rumble.is_none()); + let mut stop = [0u8; 32]; + stop[0] = 0x05; + stop[1] = 0x01; // MOTOR flag, motors zero + let mut fb4 = Ds4Feedback::default(); + parse_ds4_output(&stop, &mut fb4); + assert_eq!(fb4.rumble, Some((0, 0))); } } diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index 6a25ef24..fc77b338 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -21,12 +21,21 @@ pub struct PadFeedback { /// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report. pub rumble: Option<(u16, u16)>, pub hidout: Vec, - /// Whether the game drove this pad's output channel this poll — a fresh output report landed, - /// regardless of whether it changed the rumble level. Drives the abandoned-rumble force-off in - /// [`UhidManager::pump`] (the same game-ACTIVITY signal the XUSB path keys on). `None` means the - /// backend does not track activity (every Linux backend): treated as always-active, so the - /// force-off never fires there and Linux behaviour is unchanged. - pub game_drove: Option, + /// Whether the game drove this pad's RUMBLE plane this poll — at least one output report + /// asserted the vibration fields (valid-flag set, including an explicit zero), not merely any + /// report. Backends uphold `rumble_drove == Some(true)` ⇔ `rumble.is_some()`; the separate + /// field exists so `None` can mean "backend does not track activity" (force-off disabled). + /// Keying the abandoned-rumble force-off in [`UhidManager::pump`] on the rumble plane + /// specifically — not on general output traffic — is what keeps a title that streams + /// LED/adaptive-trigger reports every frame from feeding the watchdog forever while a latched + /// rumble level never re-asserts (the lost-stop case). + pub rumble_drove: Option, + /// The backend's driver-channel drain OVERFLOWED and dropped reports this poll — downstream + /// feedback state is unknown. [`UhidManager::pump`] resyncs: it forwards a rumble stop + /// (bounded and imperceptible — a game still rumbling re-asserts the level within a poll or + /// two) and re-arms the rich-plane dedup so the next LED/trigger state re-forwards. Only the + /// Windows ring drains can set it; Linux kernel channels drain losslessly. + pub resync: bool, } /// The per-controller half of a stateful virtual-pad backend — everything [`UhidManager`] cannot @@ -88,22 +97,48 @@ pub struct UhidManager { hidout_dedup: Vec, /// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat). last_write: Vec, - /// When the game last drove each pad (a backend that reports `game_drove` saw a fresh output - /// report). A non-zero `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a - /// residual the game abandoned — see [`pump`](Self::pump). + /// When the game last drove each pad's rumble plane (a backend that reports `rumble_drove` + /// saw a vibration-asserting output report). A non-zero `last_rumble` older than + /// [`RUMBLE_IDLE_TIMEOUT`] against this is a residual the game abandoned — see + /// [`pump`](Self::pump). last_active: Vec, } -/// How long a latched, non-zero rumble may sit without the game driving the pad before it is forced -/// off. DualSense/DS4/Deck motors are level-triggered — they run until an output report sets them to -/// zero — so a game that latches a rumble and then stops writing output reports (a residual left at a -/// menu / loading screen, or a plain forgotten stop) would otherwise drone to the client forever: the -/// resend loop in `native.rs` renews the latched level every ~120 ms and the client's envelope never -/// expires. This mirrors the XUSB path's identical guard, and is likewise keyed on game ACTIVITY (any -/// fresh output report, even one that does not change the level), so a rumble the game keeps asserting -/// is never cut — only an abandoned residual. Kept above SDL's ~2 s internal rumble resend. +/// How long a latched, non-zero rumble may sit without the game driving the RUMBLE plane before it +/// is forced off. DualSense/DS4/Deck motors are level-triggered — they run until an output report +/// sets them to zero — so a game that latches a rumble and then stops asserting the vibration +/// fields (a residual left at a menu / loading screen, a forgotten stop, or a stop report lost to +/// the driver section's latest-report coalescing) would otherwise drone to the client forever: the +/// resend loop in `native.rs` renews the latched level every ~120 ms and the client's envelope +/// never expires. Keyed on the rumble plane specifically ([`PadFeedback::rumble_drove`]), not on +/// general output traffic, so an LED/adaptive-trigger stream cannot keep an abandoned rumble +/// alive — the same decay real DualSense-family firmware applies when vibration-flagged reports +/// cease. +/// +/// INVARIANT: must stay comfortably above SDL's ~2 s internal rumble resend +/// (`SDL_RUMBLE_RESEND_MS`) — SDL-class writers re-assert a held level on that cadence *because* +/// real firmware decays, and that re-assert is what keeps a legitimately-held long rumble alive +/// here. The XUSB path shares this window via [`rumble_idle_timeout`] (every XUSB write IS a +/// rumble write, so its any-activity keying is already rumble-keyed by construction). const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500); +/// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides +/// [`RUMBLE_IDLE_TIMEOUT`]; `0` disables the watchdog entirely (the pre-watchdog behavior, for +/// bisecting field reports). Non-zero overrides are floored just above SDL's ~2 s resend so the +/// hatch cannot cut legitimately-held rumble (see the INVARIANT above). Shared by the UHID/UMDF +/// pump, the Windows XUSB manager, and the Linux uinput FF mixer. +pub(crate) fn rumble_idle_timeout() -> Option { + static VAL: std::sync::OnceLock> = std::sync::OnceLock::new(); + *VAL.get_or_init(|| match std::env::var("PUNKTFUNK_RUMBLE_IDLE_MS") { + Ok(v) => match v.trim().parse::() { + Ok(0) => None, + Ok(ms) => Some(Duration::from_millis(ms.max(2100))), + Err(_) => Some(RUMBLE_IDLE_TIMEOUT), + }, + Err(_) => Some(RUMBLE_IDLE_TIMEOUT), + }) +} + impl UhidManager { pub fn new() -> UhidManager { UhidManager::with_backend(B::default()) @@ -212,10 +247,27 @@ impl UhidManager { continue; }; let fb = self.backend.service(pad, i as u8); - // Refresh the game-activity clock when the game drove the pad this poll (a fresh output - // report, even at an unchanged level). `None` = a backend that does not track activity - // (Linux): treated as always-active, so the force-off below never fires there. - if fb.game_drove != Some(false) { + if fb.resync { + // The driver's output-report ring overflowed — reports were dropped and the + // feedback state is unknown. Conservatively silence the pad (a game still rumbling + // re-asserts the level within a poll or two) and re-arm the rich-plane dedup so + // the next LED/trigger state re-forwards. + tracing::warn!( + backend = B::LABEL, + index = i, + "output-report ring overflow — resyncing feedback state" + ); + if self.last_rumble[i] != (0, 0) { + self.last_rumble[i] = (0, 0); + rumble(i as u16, 0, 0); + } + self.hidout_dedup[i] = HidoutDedup::default(); + } + // Refresh the game-activity clock when the game drove the pad's RUMBLE plane this poll + // (a vibration-asserting report, even at an unchanged level). LED/trigger-only traffic + // does NOT refresh — see `PadFeedback::rumble_drove`. `None` = a backend that does not + // track activity: treated as always-active, so the force-off below never fires there. + if fb.rumble_drove != Some(false) { self.last_active[i] = now; } if let Some(r) = fb.rumble { @@ -224,12 +276,20 @@ impl UhidManager { rumble(i as u16, r.0, r.1); } } else if self.last_rumble[i] != (0, 0) - && now.duration_since(self.last_active[i]) >= RUMBLE_IDLE_TIMEOUT + && rumble_idle_timeout() + .is_some_and(|t| now.duration_since(self.last_active[i]) >= t) { - // 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 `native.rs`'s resend loop stops droning it to the client. Mirrors the - // XUSB path's guard; see RUMBLE_IDLE_TIMEOUT. + // A non-zero rumble is latched but the game has not driven the rumble plane for + // the idle window — a residual it forgot to stop (or whose stop was lost). Force it + // off (and forward the zero) so `native.rs`'s resend loop stops droning it to the + // client. Mirrors the XUSB path's guard; see RUMBLE_IDLE_TIMEOUT. + tracing::info!( + backend = B::LABEL, + index = i, + prev_low = self.last_rumble[i].0, + prev_high = self.last_rumble[i].1, + "rumble: stale residual (game stopped driving the rumble plane) — forcing off" + ); self.last_rumble[i] = (0, 0); rumble(i as u16, 0, 0); } @@ -440,7 +500,8 @@ mod tests { let rumble = |r| PadFeedback { rumble: Some(r), hidout: Vec::new(), - game_drove: Some(true), + rumble_drove: Some(true), + resync: false, }; *m.backend.feedback.borrow_mut() = vec![rumble((100, 0)), rumble((100, 0)), rumble((7, 7))]; assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards @@ -466,16 +527,20 @@ mod tests { *m.backend.feedback.borrow_mut() = vec![PadFeedback { rumble: Some((200, 0)), hidout: Vec::new(), - game_drove: Some(true), + rumble_drove: Some(true), + resync: false, }]; assert_eq!(collect(&mut m), vec![(0, 200, 0)]); - // The game stops driving the pad (no fresh output report) but never sent a stop. Before the - // idle window elapses, nothing is forwarded — the latched level is left asserting. + // The game stops driving the RUMBLE plane — no output report at all, or (equivalently, the + // confirmed stuck-ON case) a stream of LED/adaptive-trigger reports that never assert the + // vibration fields — and never sent a stop. Before the idle window elapses, nothing is + // forwarded — the latched level is left asserting. let idle = || PadFeedback { rumble: None, hidout: Vec::new(), - game_drove: Some(false), + rumble_drove: Some(false), + resync: false, }; *m.backend.feedback.borrow_mut() = vec![idle()]; assert_eq!(collect(&mut m), vec![]); @@ -500,18 +565,29 @@ mod tests { *m.backend.feedback.borrow_mut() = vec![PadFeedback { rumble: Some((200, 0)), hidout: Vec::new(), - game_drove: Some(true), + rumble_drove: Some(true), + resync: false, }]; assert_eq!(collect(&mut m), vec![(0, 200, 0)]); - // Even with a stale clock, a poll where the game drove the pad (fresh report, unchanged - // level → rumble None but game_drove Some(true)) refreshes activity, so the held rumble is - // NOT cut. + // Even with a stale clock, a poll where the game drove the rumble plane refreshes + // activity, so the held rumble is NOT cut. Backends report that as + // `rumble: Some(level), rumble_drove: Some(true)` (an unchanged level dedups, no forward); + // the manager also honors the bare `rumble_drove: Some(true)` shape defensively. + m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50)); + *m.backend.feedback.borrow_mut() = vec![PadFeedback { + rumble: Some((200, 0)), + hidout: Vec::new(), + rumble_drove: Some(true), + resync: false, + }]; + assert_eq!(collect(&mut m), vec![]); // unchanged level dedups, clock refreshed m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50)); *m.backend.feedback.borrow_mut() = vec![PadFeedback { rumble: None, hidout: Vec::new(), - game_drove: Some(true), + rumble_drove: Some(true), + resync: false, }]; assert_eq!(collect(&mut m), vec![]); } @@ -529,7 +605,8 @@ mod tests { *m.backend.feedback.borrow_mut() = vec![PadFeedback { rumble: None, hidout: vec![led(10), led(10), led(20)], - game_drove: Some(true), + rumble_drove: Some(true), + resync: false, }]; let out = RefCell::new(0u32); m.pump( @@ -569,4 +646,72 @@ mod tests { m.heartbeat(Duration::from_secs(3600)); assert_eq!(writes(&m), after_frame + 2); } + /// A ring-overflow resync must silence a latched rumble once and re-arm the rich-plane dedup, + /// so the game's next asserted state re-forwards even when it equals the pre-overflow state. + #[test] + fn resync_forces_stop_and_rearms_dedup() { + let mut m = mgr(); + m.handle(&frame(0, 0b1, 0)); + let led = |r| HidOutput::Led { + pad: 0, + r, + g: 0, + b: 0, + }; + let collect = |m: &mut UhidManager| { + let rumbles = RefCell::new(Vec::new()); + let hidouts = RefCell::new(0u32); + m.pump( + |i, lo, hi| rumbles.borrow_mut().push((i, lo, hi)), + |_| *hidouts.borrow_mut() += 1, + ); + (rumbles.into_inner(), hidouts.into_inner()) + }; + + // Latch a rumble + an LED. + *m.backend.feedback.borrow_mut() = vec![PadFeedback { + rumble: Some((100, 0)), + hidout: vec![led(10)], + rumble_drove: Some(true), + resync: false, + }]; + assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1)); + + // Overflow poll: no reports survived, resync flagged → forced stop, exactly once. + *m.backend.feedback.borrow_mut() = vec![PadFeedback { + rumble: None, + hidout: Vec::new(), + rumble_drove: Some(false), + resync: true, + }]; + assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0)); + + // The game re-asserts the SAME rumble + LED state: both must re-forward (the rumble + // because the forced stop reset `last_rumble`, the LED because the dedup was re-armed). + *m.backend.feedback.borrow_mut() = vec![PadFeedback { + rumble: Some((100, 0)), + hidout: vec![led(10)], + rumble_drove: Some(true), + resync: false, + }]; + assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1)); + + // A resync with nothing latched forwards no spurious stop. + *m.backend.feedback.borrow_mut() = vec![ + PadFeedback { + rumble: Some((0, 0)), + hidout: Vec::new(), + rumble_drove: Some(true), + resync: false, + }, + PadFeedback { + rumble: None, + hidout: Vec::new(), + rumble_drove: Some(false), + resync: true, + }, + ]; + assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0)); // the explicit stop + assert_eq!(collect(&mut m), (vec![], 0)); // resync at zero — silent + } } diff --git a/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs index 0b860bae..f640c4bd 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs @@ -79,7 +79,9 @@ impl PadProto for DsEdgeWinProto { PadFeedback { rumble: fb.rumble, hidout: fb.hidout, - game_drove: Some(fb.fresh), + // Rumble-plane liveness, not any-report liveness — see the plain DualSense backend. + rumble_drove: Some(fb.rumble.is_some()), + resync: fb.resync, } } } diff --git a/crates/pf-inject/src/inject/windows/dualsense_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_windows.rs index 69334500..60c7d9d9 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_windows.rs @@ -3,8 +3,9 @@ //! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and //! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where //! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`, -//! the Windows backend talks to the UMDF driver over an **unnamed shared DATA section** (256 B `PadShm`: -//! magic `u32@0`, input report `@8`, output seq `u32@72`, output report `@76`) reached over the +//! the Windows backend talks to the UMDF driver over an **unnamed shared DATA section** (`PadShm`: +//! magic `u32@0`, input report `@8`, output seq `u32@72`, output report `@76`, and since v2.1 the +//! lossless output-report ring `@256` — see [`OutputDrain`]) reached over the //! **sealed channel** ([`PadChannel`], `design/gamepad-channel-sealing.md`): the host duplicates the //! section handle into the driver's WUDFHost, bootstrapped via the named `Global\pfds-boot-` //! mailbox. The driver feeds game `READ_REPORT`s from the input bytes and publishes a game's `0x02` @@ -56,6 +57,114 @@ pub(super) const OFF_PAD_INDEX: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index); pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4; pub(super) const DEVTYPE_DUALSENSE_EDGE: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE; +// v2.1 output-report ring (see `PadShm` in pf-driver-proto for the layout + version posture). +pub(super) const OFF_OUT_RING_VER: usize = + core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_ring_ver); +pub(super) const OFF_RING_HEAD: usize = + core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, ring_head); +pub(super) const OFF_OUT_RING: usize = + core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_ring); +pub(super) const OUT_SLOT_SIZE: usize = core::mem::size_of::(); +pub(super) const OUT_RING_LEN: u32 = pf_driver_proto::gamepad::OUT_RING_LEN; + +/// Shared drain over a pad section's output plane — the lossless v2.1 report ring when the driver +/// publishes one, the legacy latest-report slot otherwise (an old driver package). One per pad; +/// owns the cursors that used to live as bare `last_out_seq` fields on each backend. The ring is +/// what guarantees a rumble-STOP report can never be coalesced away by a following LED/trigger +/// report inside one ~4 ms poll window — the confirmed unbounded stuck-rumble path +/// (`design/rumble-root-fix.md` §A). +pub(super) struct OutputDrain { + /// Ring cursor: the driver's `ring_head` value up to which we have drained. + tail: u32, + /// Legacy cursor: the last `out_seq` consumed (single-slot path only). + last_out_seq: u32, + /// Latched on first ring activity; the legacy path never re-engages after it (the driver + /// dual-writes both planes, so consuming both would double-parse every report). + ring_live: bool, +} + +impl OutputDrain { + pub(super) fn new() -> OutputDrain { + OutputDrain { + tail: 0, + last_out_seq: 0, + ring_live: false, + } + } + + /// Drain every output report published since the last call, oldest → newest, invoking + /// `per_report` with each report's exact bytes. Returns `true` on ring OVERFLOW — more than + /// [`OUT_RING_LEN`] reports landed since the last poll (or the driver lapped us mid-copy): the + /// pending reports were DISCARDED as possibly torn and the caller must treat its downstream + /// feedback state as unknown (`PadFeedback::resync`). + pub(super) fn drain(&mut self, base: *mut u8, mut per_report: impl FnMut(&[u8])) -> bool { + // SAFETY: base points at SHM_SIZE bytes; `OFF_RING_HEAD` (== 160) is 4-aligned off the + // page-aligned base. The driver bumps `ring_head` AFTER writing the slot, so an Acquire + // load orders the slot copies below — the same pairing the legacy `out_seq` idiom uses. + let head = + unsafe { (*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire) }; + if self.ring_live || head != 0 { + self.ring_live = true; + if head == self.tail { + return false; + } + let pending = head.wrapping_sub(self.tail); + if pending <= OUT_RING_LEN { + // Copy the pending slots out FIRST, then re-check the head: a writer that lapped + // past our window during the copy may have overwritten what we read, so parse only + // when the window provably stayed inside the ring. + let n = pending as usize; + let mut bufs = [([0u8; 64], 0usize); pf_driver_proto::gamepad::OUT_RING_LEN_USIZE]; + for (k, buf) in bufs.iter_mut().enumerate().take(n) { + let idx = (self.tail.wrapping_add(k as u32) % OUT_RING_LEN) as usize; + let slot = OFF_OUT_RING + idx * OUT_SLOT_SIZE; + // SAFETY: slot .. slot+OUT_SLOT_SIZE is inside the SHM_SIZE section; the len + // field is 4-aligned (`OFF_OUT_RING` == 256, `OUT_SLOT_SIZE` == 68). + let len = unsafe { std::ptr::read_unaligned(base.add(slot) as *const u32) }; + buf.1 = (len as usize).min(64); + // SAFETY: the slot's data region is slot+4 .. slot+4+64, inside the section; + // `buf.0` is a live local 64-byte array. + unsafe { + std::ptr::copy_nonoverlapping(base.add(slot + 4), buf.0.as_mut_ptr(), buf.1) + }; + } + // SAFETY: as the first `ring_head` load above. + let head2 = unsafe { + (*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire) + }; + if head2.wrapping_sub(self.tail) <= OUT_RING_LEN { + for (data, len) in bufs.iter().take(n) { + if *len > 0 { + per_report(&data[..*len]); + } + } + self.tail = head; + return false; + } + } + // Overflow (or lapped mid-copy): skip to the freshest head, deliver nothing, and + // report the resync — parsing possibly-torn reports is worse than a bounded silence. + // SAFETY: as the first `ring_head` load above. + self.tail = + unsafe { (*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire) }; + return true; + } + // Legacy driver (never wrote the ring): the latest-report slot + seq — exactly the old + // single-slot semantics, coalescing and all; the rumble-keyed idle watchdog is the bound + // there until the driver package is updated. + // SAFETY: `OFF_OUT_SEQ` (== 72) is 4-aligned off the page-aligned base; Acquire pairs with + // the driver's publish-then-bump store order. + let seq = unsafe { (*(base.add(OFF_OUT_SEQ) as *const AtomicU32)).load(Ordering::Acquire) }; + if seq != self.last_out_seq { + self.last_out_seq = seq; + let mut out = [0u8; 64]; + // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. + unsafe { std::ptr::copy_nonoverlapping(base.add(OFF_OUTPUT), out.as_mut_ptr(), 64) }; + per_report(&out); + } + false + } +} /// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_` software devnode (the driver /// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel. @@ -72,7 +181,8 @@ pub struct DsWinPad { attach: super::gamepad_raii::DriverAttach, seq: u8, ts: u32, - last_out_seq: u32, + /// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver). + drain: OutputDrain, } /// The PnP identity for a virtual controller devnode — varies by controller type so the same @@ -292,6 +402,8 @@ impl DsWinPad { unsafe { *base.add(OFF_DEVTYPE) = id.devtype; std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); + // Ring capability (v2.1), stamped before the magic so the driver sees it on attach. + std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 1); std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], { let mut r = [0u8; DS_INPUT_REPORT_LEN]; serialize_state(&mut r, &DsState::neutral(), 0, 0); @@ -334,7 +446,7 @@ impl DsWinPad { ), seq: 0, ts: 0, - last_out_seq: 0, + drain: OutputDrain::new(), }) } @@ -365,10 +477,12 @@ impl DsWinPad { }; } - /// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a - /// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything - /// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the - /// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped). + /// Drain the section's output plane; parse every new `0x02` report (rumble / LEDs / triggers) + /// into a [`DsFeedback`] for pad `pad`, oldest → newest — so a stop-then-LED burst yields the + /// stop AND the LED state, never just the latest report. Returns empty feedback if the driver + /// hasn't published anything new. Also ticks the sealed-channel delivery and feeds the + /// driver-attach health watcher (the driver's ~125 Hz timer stamps `driver_proto` while it has + /// the section mapped). pub(super) fn service(&mut self, pad: u8) -> DsFeedback { self.channel.pump(); let mut fb = DsFeedback::default(); @@ -377,29 +491,10 @@ impl DsWinPad { std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) }; self.attach.observe(proto); - // SAFETY: base points at SHM_SIZE bytes; `OFF_OUT_SEQ` (== 72) is 4-aligned off the - // page-aligned base, so the `AtomicU32` view is valid. The driver bumps `out_seq` AFTER - // writing the `output` report, so an `Acquire` load here orders the `output` copy below after - // it — a fresh seq guarantees a coherent snapshot of the output bytes on a weakly-ordered core - // (ARM64). On x86-TSO it is a plain load. - let seq = unsafe { - (*(self.channel.data_base().add(OFF_OUT_SEQ) as *const AtomicU32)) - .load(Ordering::Acquire) - }; - if seq != self.last_out_seq { - self.last_out_seq = seq; - fb.fresh = true; - let mut out = [0u8; 64]; - // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. - unsafe { - std::ptr::copy_nonoverlapping( - self.channel.data_base().add(OFF_OUTPUT), - out.as_mut_ptr(), - 64, - ) - }; - parse_ds_output(pad, &out, &mut fb); - } + let base = self.channel.data_base(); + fb.resync = self + .drain + .drain(base, |bytes| parse_ds_output(pad, bytes, &mut fb)); fb } } @@ -479,9 +574,14 @@ impl PadProto for DsWinProto { fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback { let fb = pad.service(idx); PadFeedback { + // Rumble-plane liveness: only a report that asserted the vibration fields counts + // (`parse_ds_output`'s valid-flag gate) — an LED/adaptive-trigger stream must never + // feed the abandoned-rumble force-off's activity clock (the historical unbounded + // stuck-ON path, now doubly closed by the lossless report ring). + rumble_drove: Some(fb.rumble.is_some()), rumble: fb.rumble, hidout: fb.hidout, - game_drove: Some(fb.fresh), + resync: fb.resync, } } } @@ -561,3 +661,129 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> { /// keeps the section fresh (the driver's timer streams whatever's in it) — parity with the UHID /// backend's silence heartbeat. pub type DualSenseWindowsManager = UhidManager; + +#[cfg(test)] +mod drain_tests { + use super::*; + + /// A zeroed, 4-aligned stand-in for the pad section. + fn section() -> Vec { + vec![0u32; SHM_SIZE / 4] + } + + fn base(buf: &mut [u32]) -> *mut u8 { + buf.as_mut_ptr() as *mut u8 + } + + /// Mimic the v2.1 driver's dual write: legacy slot + seq, then ring slot, then head. + fn publish(buf: &mut [u32], bytes: &[u8]) { + legacy_publish(buf, bytes); + let head = read32(buf, OFF_RING_HEAD); + let slot = OFF_OUT_RING + (head % OUT_RING_LEN) as usize * OUT_SLOT_SIZE; + write32(buf, slot, bytes.len() as u32); + let b = bytes_mut(buf); + b[slot + 4..slot + 4 + bytes.len()].copy_from_slice(bytes); + write32(buf, OFF_RING_HEAD, head.wrapping_add(1)); + } + + /// Mimic an OLD driver: latest-report slot + seq only, no ring. + fn legacy_publish(buf: &mut [u32], bytes: &[u8]) { + let b = bytes_mut(buf); + b[OFF_OUTPUT..OFF_OUTPUT + bytes.len()].copy_from_slice(bytes); + let seq = read32(buf, OFF_OUT_SEQ).wrapping_add(1); + write32(buf, OFF_OUT_SEQ, seq); + } + + fn bytes_mut(buf: &mut [u32]) -> &mut [u8] { + // SAFETY: a u32 slice reinterpreted as bytes — same allocation, laxer alignment. + unsafe { std::slice::from_raw_parts_mut(buf.as_mut_ptr() as *mut u8, SHM_SIZE) } + } + + fn read32(buf: &mut [u32], off: usize) -> u32 { + u32::from_ne_bytes(bytes_mut(buf)[off..off + 4].try_into().unwrap()) + } + + fn write32(buf: &mut [u32], off: usize, v: u32) { + bytes_mut(buf)[off..off + 4].copy_from_slice(&v.to_ne_bytes()); + } + + fn collect(d: &mut OutputDrain, buf: &mut [u32]) -> (Vec>, bool) { + let mut got = Vec::new(); + let resync = d.drain(base(buf), |b| got.push(b.to_vec())); + (got, resync) + } + + /// THE stop-coalesce repro (`design/rumble-root-fix.md` §A): a rumble-stop report followed by + /// an LED-only report inside one poll window must yield BOTH, oldest first — on the legacy + /// single slot the stop was overwritten and gone forever. + #[test] + fn ring_preserves_a_stop_followed_by_an_led_report() { + let mut buf = section(); + let mut d = OutputDrain::new(); + publish(&mut buf, &[0x02, 0x03, 0, 0xFF, 0xFF]); // rumble on + let (got, resync) = collect(&mut d, &mut buf); + assert!(!resync); + assert_eq!(got, vec![vec![0x02, 0x03, 0, 0xFF, 0xFF]]); + + publish(&mut buf, &[0x02, 0x03, 0, 0, 0]); // explicit stop… + publish(&mut buf, &[0x02, 0, 0x04, 0, 0]); // …overwritten in-slot by an LED-only report + let (got, resync) = collect(&mut d, &mut buf); + assert!(!resync); + assert_eq!( + got, + vec![vec![0x02, 0x03, 0, 0, 0], vec![0x02, 0, 0x04, 0, 0]], + "the stop report must survive the burst, oldest first" + ); + assert_eq!(collect(&mut d, &mut buf).0.len(), 0); // drained dry + } + + #[test] + fn ring_wraps_across_polls() { + let mut buf = section(); + let mut d = OutputDrain::new(); + for i in 0..6u8 { + publish(&mut buf, &[0x02, i]); + } + assert_eq!(collect(&mut d, &mut buf).0.len(), 6); + for i in 6..12u8 { + // wraps past slot 8 + publish(&mut buf, &[0x02, i]); + } + let (got, resync) = collect(&mut d, &mut buf); + assert!(!resync); + assert_eq!( + got.iter().map(|r| r[1]).collect::>(), + vec![6, 7, 8, 9, 10, 11] + ); + } + + #[test] + fn overflow_discards_and_flags_resync_then_recovers() { + let mut buf = section(); + let mut d = OutputDrain::new(); + for i in 0..12u8 { + // 12 > OUT_RING_LEN pending — the oldest 4 were overwritten in-ring + publish(&mut buf, &[0x02, i]); + } + let (got, resync) = collect(&mut d, &mut buf); + assert!(resync, "an overflowed window must be reported"); + assert!(got.is_empty(), "possibly-torn reports must not be parsed"); + publish(&mut buf, &[0x02, 99]); + let (got, resync) = collect(&mut d, &mut buf); + assert!(!resync); + assert_eq!(got, vec![vec![0x02, 99]]); + } + + #[test] + fn legacy_driver_still_drains_the_latest_slot() { + let mut buf = section(); + let mut d = OutputDrain::new(); + legacy_publish(&mut buf, &[0x02, 1]); + legacy_publish(&mut buf, &[0x02, 2]); // coalesced — legacy semantics, latest wins + let (got, resync) = collect(&mut d, &mut buf); + assert!(!resync); + assert_eq!(got.len(), 1); + assert_eq!(&got[0][..2], &[0x02, 2]); + assert_eq!(collect(&mut d, &mut buf).0.len(), 0); + } +} diff --git a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs index 4325b00a..84c65e46 100644 --- a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs @@ -9,8 +9,8 @@ use super::dualsense_proto::DsState; use super::dualsense_windows::{ - create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, - OFF_OUTPUT, OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, + create_swdevice, OutputDrain, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, + OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, }; use super::dualshock4_proto::{ parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W, @@ -34,7 +34,8 @@ pub struct Ds4WinPad { attach: super::gamepad_raii::DriverAttach, counter: u8, ts: u16, - last_out_seq: u32, + /// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver). + drain: OutputDrain, } impl Ds4WinPad { @@ -51,6 +52,8 @@ impl Ds4WinPad { unsafe { *base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4; std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); + // Ring capability (v2.1), stamped before the magic so the driver sees it on attach. + std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 1); std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], { let mut r = [0u8; DS4_INPUT_REPORT_LEN]; serialize_state(&mut r, &DsState::neutral(), 0, 0); @@ -91,7 +94,7 @@ impl Ds4WinPad { ), counter: 0, ts: 0, - last_out_seq: 0, + drain: OutputDrain::new(), }) } @@ -111,10 +114,11 @@ impl Ds4WinPad { }; } - /// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a - /// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. Also - /// ticks the sealed-channel delivery and feeds the driver-attach health watcher (the driver's - /// ~125 Hz timer stamps `driver_proto`). + /// Drain the section's output plane; parse every new `0x05` report (rumble / lightbar) into a + /// [`Ds4Feedback`], oldest → newest — so a stop-then-LED burst yields the stop AND the LED + /// state, never just the latest report. Returns empty feedback if the driver hasn't published + /// anything new. Also ticks the sealed-channel delivery and feeds the driver-attach health + /// watcher (the driver's ~125 Hz timer stamps `driver_proto`). fn service(&mut self) -> Ds4Feedback { self.channel.pump(); let mut fb = Ds4Feedback::default(); @@ -123,24 +127,10 @@ impl Ds4WinPad { std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) }; self.attach.observe(proto); - // SAFETY: base points at SHM_SIZE bytes. - let seq = unsafe { - std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32) - }; - if seq != self.last_out_seq { - self.last_out_seq = seq; - fb.fresh = true; - let mut out = [0u8; 64]; - // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. - unsafe { - std::ptr::copy_nonoverlapping( - self.channel.data_base().add(OFF_OUTPUT), - out.as_mut_ptr(), - 64, - ) - }; - parse_ds4_output(&out, &mut fb); - } + let base = self.channel.data_base(); + fb.resync = self + .drain + .drain(base, |bytes| parse_ds4_output(bytes, &mut fb)); fb } } @@ -229,7 +219,10 @@ impl PadProto for Ds4WinProto { .map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b }) .into_iter() .collect(), - game_drove: Some(fb.fresh), + // Rumble-plane liveness, not any-report liveness — see the DualSense backend + // (`parse_ds4_output` gates rumble on flag0 bit0 the same way). + rumble_drove: Some(fb.rumble.is_some()), + resync: fb.resync, } } } diff --git a/crates/pf-inject/src/inject/windows/gamepad_windows.rs b/crates/pf-inject/src/inject/windows/gamepad_windows.rs index 2b404426..b30d017e 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_windows.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_windows.rs @@ -240,26 +240,27 @@ impl XusbWinPad { } } +// The abandoned-rumble force-off window is shared with the UHID/UMDF pump — +// `uhid_manager::rumble_idle_timeout()` (`PUNKTFUNK_RUMBLE_IDLE_MS` hatch, default 2.5 s). 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; the force-off mirrors that. Unlike the DualSense-family backends there are no +// flag semantics to key on — every `SET_STATE` IS a rumble write — so this path's any-activity +// keying is already rumble-keyed by construction. The shared window stays 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 it fires. /// 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 { slots: PadSlots, 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_rumble` older than the shared idle window + /// ([`crate::uhid_manager::rumble_idle_timeout`]) against this is a stale residual — see the + /// comment above [`GamepadManager`]. last_active: Vec, } @@ -346,11 +347,13 @@ impl GamepadManager { 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 + && crate::uhid_manager::rumble_idle_timeout() + .is_some_and(|t| self.last_active[i].elapsed() >= t) { - // 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. + // A non-zero rumble is latched but the game has not driven the pad for the shared + // idle window — 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 comment above + // `GamepadManager`. tracing::info!( index = i, prev_low = self.last_rumble[i].0 as u16 * 257, diff --git a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs index 183f7990..14e215e1 100644 --- a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs +++ b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs @@ -18,8 +18,8 @@ //! kernel's evdev parser; Steam-on-Windows reads the raw reports directly. use super::dualsense_windows::{ - create_swdevice, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUTPUT, - OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, + create_swdevice, OutputDrain, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, + OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, }; use super::gamepad_raii::PadChannel; use super::steam_proto::{ @@ -41,7 +41,8 @@ pub struct DeckWinPad { /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. attach: super::gamepad_raii::DriverAttach, seq: u32, - last_out_seq: u32, + /// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver). + drain: OutputDrain, } impl DeckWinPad { @@ -56,6 +57,8 @@ impl DeckWinPad { unsafe { *base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK; std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); + // Ring capability (v2.1), stamped before the magic so the driver sees it on attach. + std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 1); std::ptr::write_unaligned( base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN], neutral_deck_report(), @@ -96,7 +99,7 @@ impl DeckWinPad { instance_id, ), seq: 0, - last_out_seq: 0, + drain: OutputDrain::new(), }) } @@ -118,31 +121,23 @@ impl DeckWinPad { /// Poll the section's output slot; parse a newly-published Steam command (`0xEB` rumble / /// `0x8F` haptic pulse — republished by the driver off SET_FEATURE) into feedback. Also /// ticks the sealed-channel delivery and the driver-attach health watcher. - fn service(&mut self) -> Option<(u16, u16)> { + fn service(&mut self) -> (Option<(u16, u16)>, bool) { self.channel.pump(); // SAFETY: base points at SHM_SIZE bytes. let proto = unsafe { std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) }; self.attach.observe(proto); - // SAFETY: base points at SHM_SIZE bytes. - let seq = unsafe { - std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32) - }; - if seq == self.last_out_seq { - return None; - } - self.last_out_seq = seq; - let mut out = [0u8; 64]; - // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. - unsafe { - std::ptr::copy_nonoverlapping( - self.channel.data_base().add(OFF_OUTPUT), - out.as_mut_ptr(), - 64, - ) - }; - parse_steam_output(&out).rumble + let mut rumble = None; + let base = self.channel.data_base(); + let resync = self.drain.drain(base, |bytes| { + // Oldest → newest: the last report that carried rumble wins (0x8F trackpad-haptic + // reports carry none and pass through without disturbing it). + if let Some(r) = parse_steam_output(bytes).rumble { + rumble = Some(r); + } + }); + (rumble, resync) } } @@ -216,13 +211,14 @@ impl PadProto for DeckWinProto { /// Deck has no rich host→client feedback plane (no lightbar / adaptive triggers), so /// `hidout` stays empty — parity with the Linux backend. fn service(&self, pad: &mut DeckWinPad, _idx: u8) -> PadFeedback { - // The Deck poll returns `Some` exactly when a fresh output report landed (a seq bump), so - // its presence is the game-activity signal, even when the rumble level is unchanged. - let rumble = pad.service(); + // The Deck drain surfaces `Some` exactly when a rumble-carrying report landed, so its + // presence is the rumble-plane activity signal, even at an unchanged level. + let (rumble, resync) = pad.service(); PadFeedback { rumble, hidout: Vec::new(), - game_drove: Some(rumble.is_some()), + rumble_drove: Some(rumble.is_some()), + resync, } } } diff --git a/packaging/windows/drivers/pf-dualsense/src/lib.rs b/packaging/windows/drivers/pf-dualsense/src/lib.rs index 59473140..9c9b607c 100644 --- a/packaging/windows/drivers/pf-dualsense/src/lib.rs +++ b/packaging/windows/drivers/pf-dualsense/src/lib.rs @@ -318,6 +318,32 @@ const OFF_DEVICE_TYPE: usize = core::mem::offset_of!(PadShm, device_type); const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(PadShm, driver_proto); const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(PadShm, driver_heartbeat); const OFF_PAD_INDEX: usize = core::mem::offset_of!(PadShm, pad_index); +// v2.1 output-report ring (see PadShm docs in pf_driver_proto). +const OFF_OUT_RING_VER: usize = core::mem::offset_of!(PadShm, out_ring_ver); +const OFF_RING_HEAD: usize = core::mem::offset_of!(PadShm, ring_head); +const OFF_OUT_RING: usize = core::mem::offset_of!(PadShm, out_ring); +const OUT_SLOT_SIZE: usize = core::mem::size_of::(); +const OUT_RING_LEN: u32 = pf_driver_proto::gamepad::OUT_RING_LEN; + +/// Publish one game output report to the host: the legacy latest-report slot + `out_seq` bump +/// (every host generation reads this), and — when the host stamped `out_ring_ver` (it created the +/// ring region) and our view maps it — the lossless report ring: slot bytes first, `ring_head` +/// bump last, the same publish-then-bump store order the host's Acquire load pairs with on the +/// legacy `out_seq`. The ring is what stops a rumble-STOP report from being coalesced away by a +/// following LED/trigger report inside one host poll window (the confirmed stuck-rumble path). +fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) { + view.write_bytes(OFF_OUTPUT, bytes); + let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1); + view.write_u32(OFF_OUT_SEQ, seq); + if view.mapped_len() >= SHM_SIZE && view.read_u32(OFF_OUT_RING_VER) != 0 { + let head = view.read_u32(OFF_RING_HEAD); + let slot = OFF_OUT_RING + (head % OUT_RING_LEN) as usize * OUT_SLOT_SIZE; + let n = bytes.len().min(64); + view.write_u32(slot, n as u32); + view.write_bytes(slot + 4, &bytes[..n]); + view.write_u32(OFF_RING_HEAD, head.wrapping_add(1)); + } +} /// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so /// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`. @@ -336,6 +362,10 @@ fn channel_cfg() -> ChannelConfig { boot_name_prefix: "Global\\pfds-boot-", data_magic: SHM_MAGIC, data_size: SHM_SIZE, + // The v2.1 layout grew by tail extension (the output-report ring); against an old host's + // 256-byte section the full-size map still succeeds (sections are page-granular), but if + // it is ever refused, mapping the legacy size keeps the pad alive with the ring disabled. + min_data_size: pf_driver_proto::gamepad::PAD_SHM_LEGACY_SIZE, pad_index_off: OFF_PAD_INDEX, log, } @@ -375,10 +405,13 @@ fn file_appender() -> Option<&'static std::sync::Mutex> { if !file_log_enabled() { return None; } + // WUDFHost's own (LocalService) temp dir — NOT world-writable/readable `C:\Users\Public`, + // where the OUTPUT/feature-report hex dumps could leak per-pad identity/serial material to + // any local reader (security-review 2026-07-17). Opt-in/debug only. std::fs::OpenOptions::new() .create(true) .append(true) - .open("C:\\Users\\Public\\pfds-driver.log") + .open(std::env::temp_dir().join("pfds-driver.log")) .ok() .map(std::sync::Mutex::new) }) @@ -614,13 +647,11 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS { dbglog!("[pf-ds] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}"); // Publish the game's 0x02 output report to the sealed DATA section for the host (rumble / - // lightbar / player-LEDs / adaptive triggers), then bump the host-polled output seq. + // lightbar / player-LEDs / adaptive triggers): legacy slot + seq, plus the v2.1 ring. if !bytes.is_empty() && let Some(view) = CHANNEL.data() { - view.write_bytes(OFF_OUTPUT, &bytes); - let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1); - view.write_u32(OFF_OUT_SEQ, seq); + publish_output(view, &bytes); } request.set_information(inlen as u64); @@ -662,9 +693,7 @@ fn on_set_feature(request: &Request) -> NTSTATUS { let mut out = [0u8; 64]; let n = src.len().min(63); out[1..1 + n].copy_from_slice(&src[..n]); - view.write_bytes(OFF_OUTPUT, &out); - let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1); - view.write_u32(OFF_OUT_SEQ, seq); + publish_output(view, &out); } } dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)"); diff --git a/packaging/windows/drivers/pf-mouse/src/lib.rs b/packaging/windows/drivers/pf-mouse/src/lib.rs index 83d7e6da..dfb17656 100644 --- a/packaging/windows/drivers/pf-mouse/src/lib.rs +++ b/packaging/windows/drivers/pf-mouse/src/lib.rs @@ -167,6 +167,7 @@ fn channel_cfg() -> ChannelConfig { boot_name_prefix: "Global\\pfmouse-boot-", data_magic: SHM_MAGIC, data_size: SHM_SIZE, + min_data_size: SHM_SIZE, // layout never grew — no fallback size pad_index_off: OFF_PAD_INDEX, log, } diff --git a/packaging/windows/drivers/pf-umdf-util/src/channel.rs b/packaging/windows/drivers/pf-umdf-util/src/channel.rs index faac5ac0..03d9e984 100644 --- a/packaging/windows/drivers/pf-umdf-util/src/channel.rs +++ b/packaging/windows/drivers/pf-umdf-util/src/channel.rs @@ -35,6 +35,13 @@ pub struct ChannelConfig { pub data_magic: u32, /// The DATA section's size (`size_of::()` / `size_of::()`). pub data_size: usize, + /// Fallback map length when the full `data_size` map is refused — the legacy section size of a + /// layout that grew by tail extension (`PAD_SHM_LEGACY_SIZE` for the pad channel). Sections are + /// pagefile-backed and page-granular, so the full-size map is expected to succeed against + /// either host generation; this exists so a refused map can never fail the pad closed. Set + /// equal to `data_size` for layouts that never grew. A caller gates tail-extension features on + /// `MappedView::mapped_len()` (plus the layout's own capability field), never on assumption. + pub min_data_size: usize, /// `offset_of!(…Shm, pad_index)` in the DATA section. pub pad_index_off: usize, /// The driver's logger (each driver tees to its own debug file). @@ -159,7 +166,9 @@ impl ChannelClient { /// close it — the view keeps the section alive. On validation failure the handle is /// deliberately NOT closed: a tampered value could name an unrelated handle in our own table. fn adopt(&self, cfg: &ChannelConfig, value: u64) { - let Some(view) = MappedView::from_handle_value(value, cfg.data_size) else { + let Some(view) = MappedView::from_handle_value(value, cfg.data_size) + .or_else(|| MappedView::from_handle_value(value, cfg.min_data_size)) + else { if value != 0 { (cfg.log)(&format!( "[{}] delivered DATA handle 0x{value:x} did not map — ignoring", diff --git a/packaging/windows/drivers/pf-umdf-util/src/section.rs b/packaging/windows/drivers/pf-umdf-util/src/section.rs index 4b67e05a..da786530 100644 --- a/packaging/windows/drivers/pf-umdf-util/src/section.rs +++ b/packaging/windows/drivers/pf-umdf-util/src/section.rs @@ -80,6 +80,12 @@ impl MappedView { Some(MappedView { base, len }) } + /// How many bytes this view maps — the gate for tail-extension features (a caller may only + /// touch offsets `< mapped_len()`; see `ChannelConfig::min_data_size`). + pub fn mapped_len(&self) -> usize { + self.len + } + /// Assert `off..off+n` is inside the view and, for atomics, `align`-aligned. The view base is /// page-aligned (`MapViewOfFile`), so field alignment reduces to offset alignment. #[inline] diff --git a/packaging/windows/drivers/pf-xusb/src/lib.rs b/packaging/windows/drivers/pf-xusb/src/lib.rs index fbda48b0..f9fbc410 100644 --- a/packaging/windows/drivers/pf-xusb/src/lib.rs +++ b/packaging/windows/drivers/pf-xusb/src/lib.rs @@ -99,6 +99,7 @@ fn channel_cfg() -> ChannelConfig { boot_name_prefix: "Global\\pfxusb-boot-", data_magic: SHM_MAGIC, data_size: SHM_SIZE, + min_data_size: SHM_SIZE, // layout never grew — no fallback size pad_index_off: OFF_PAD_INDEX, log, } @@ -125,10 +126,14 @@ fn file_appender() -> Option<&'static std::sync::Mutex> { if !file_log_enabled() { return None; } + // Write to the WUDFHost's own (LocalService) temp dir — NOT world-writable/readable + // `C:\Users\Public`, where the per-event SET_STATE/report hex dumps could leak pad + // identity to any local reader and a non-admin could pre-create/hold the file + // (security-review 2026-07-17). Opt-in/debug only. std::fs::OpenOptions::new() .create(true) .append(true) - .open("C:\\Users\\Public\\pfxusb-driver.log") + .open(std::env::temp_dir().join("pfxusb-driver.log")) .ok() .map(std::sync::Mutex::new) })