From e90c5d5bcd3d2084ad56c0671b5835de875cd5a0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 30 Jul 2026 18:27:27 +0200 Subject: [PATCH] fix(gamepad): the output-report ring absorbs haptics-rate writers (PadShm v2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field log (2026-07-30) showed a game driving the virtual DualSense's output endpoint at >2 kHz sustained for tens of seconds. The 8-slot ring — sized on the assumption that 2 kHz is double any real HID output rate — overflowed every 4 ms poll, which force-silenced rumble for each storm's whole duration and flooded the log at ~230 WARN lines/s (96 % of the user's 5000-line web-console export, evicting the session history it was needed to diagnose). Three legs, negotiated so every old/new host×driver pairing keeps working: - pf-driver-proto: the ring grows in place 8 -> 56 slots; PadShm becomes exactly one page (4096 B), the hard ceiling that keeps cross-generation section views mappable. A new out_ring_len field carries the driver's side of the length negotiation. Deliberately NOT a GAMEPAD_PROTO_VERSION bump (that fails closed - no pad at all). - pf-gamepad driver: picks its ring length from the host's out_ring_ver stamp (>= 2 + a full-size map -> 56) and echoes it before every ring_head bump (now a Release store), so an Acquire-observing drain always reads the modulo that indexed the slots it copies. - host drain: follows the echo (0 = old driver = 8); on genuine overflow it now salvages the legacy latest-report slot - the freshest coalesced state - instead of total silence, and the per-poll overflow WARN is rate-limited to 1 line/s per pad with a suppressed count. Verified on the Windows CI runner (drivers workspace build + clippy -D warnings against the WDK; 64 pf-inject tests incl. the new negotiation/ salvage/limiter tests; pf-inject clippy -D warnings) and on Linux via the CI docker image (82 pf-inject tests). DriverVer needs no manual bump - the installer stamps a strictly-increasing build timestamp per release. Co-Authored-By: Claude Fable 5 --- crates/pf-driver-proto/src/lib.rs | 74 ++++++-- crates/pf-inject/src/inject/uhid_manager.rs | 73 ++++++- .../src/inject/windows/dualsense_windows.rs | 179 +++++++++++++++--- .../src/inject/windows/dualshock4_windows.rs | 5 +- .../src/inject/windows/steam_deck_windows.rs | 5 +- .../windows/drivers/pf-gamepad/src/lib.rs | 47 ++++- 6 files changed, 325 insertions(+), 58 deletions(-) diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 23883c90..7f00b695 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -1094,11 +1094,35 @@ pub mod gamepad { /// (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. + /// The v2.1 output-report ring depth — the length every pre-v2.2 driver hardcodes in its + /// `% OUT_RING_LEN` slot math, kept as the drain length whenever [`PadShm::out_ring_len`] + /// reads 0. Its sizing assumption ("8 slots at a ~4 ms poll tolerates a sustained 2 kHz + /// writer — double any real HID output rate") was falsified in the field: DS5 compat-vibration + /// writers that re-send per audio quantum sustain >2 kHz for tens of seconds (field log + /// 2026-07-30), overflowing every poll. pub const OUT_RING_LEN: u32 = 8; pub const OUT_RING_LEN_USIZE: usize = OUT_RING_LEN as usize; + /// The v2.2 output-report ring depth — the same ring grown in place to every slot that fits + /// the one-page section ([`PAD_SHM_SIZE`] = 4096): 56 slots at the host's ~4 ms poll + /// tolerates a sustained 14 kHz writer, ~7× the worst rate observed in the field. Used only + /// when BOTH sides negotiated it (host stamped `out_ring_ver >= 2`, driver echoed the length + /// in [`PadShm::out_ring_len`]). + pub const OUT_RING_LEN_V22: u32 = 56; + pub const OUT_RING_LEN_V22_USIZE: usize = OUT_RING_LEN_V22 as usize; + + /// The v2.1 [`PadShm`] size. A v2.1 driver maps this much and gates its 8-slot ring writes on + /// `mapped_len() >= 1024`; the v2.2 growth keeps bytes `0..1024` layout-identical (the ring + /// slots 8.. extend over what was `_reserved2`). + pub const PAD_SHM_V21_SIZE: usize = 1024; + + /// The full v2.2 [`PadShm`] size — exactly one page. This is a hard ceiling, not a choice: + /// pagefile-backed sections round up to page granularity, which is what lets every binary + /// generation map its own idea of the size against any other generation's section. A layout + /// that grows past 4096 breaks that (an old section refuses the larger view) and must ride a + /// new negotiation, not this one. + pub const PAD_SHM_SIZE: usize = 4096; + /// 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. @@ -1110,8 +1134,9 @@ pub mod gamepad { 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 + /// Virtual DualSense / DualShock 4 shared section (4096 B — [`PAD_SHM_SIZE`]; bytes `0..256` + /// are the v2 legacy layout verbatim — [`PAD_SHM_LEGACY_SIZE`]; bytes `0..1024` are the v2.1 + /// layout verbatim — [`PAD_SHM_V21_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, @@ -1126,6 +1151,16 @@ pub mod gamepad { /// 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. + /// + /// v2.2 ring-length negotiation (the ring grown 8 → [`OUT_RING_LEN_V22`] in place): the two + /// sides must agree on the `% len` slot math, and neither the host binary nor the driver + /// binary can assume the other's generation, so each declares and the SHORTER understanding + /// wins. The host stamps `out_ring_ver = 2` at section creation ("I can drain the long + /// ring"); the driver picks its length from that stamp (`>= 2` and a full-size map → 56, + /// `1` → 8, `0` → no ring) and ECHOES the picked length into `out_ring_len` before every + /// `ring_head` bump. The host keys its drain off the echo alone (`0` = pre-v2.2 driver = 8), + /// and the slot-bytes → echo → head-bump store order means a drain that Acquire-observed a + /// head bump always reads the length that wrote those slots. #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable, Debug)] pub struct PadShm { @@ -1154,16 +1189,23 @@ pub mod gamepad { /// 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 + /// Driver-bumped (AFTER writing `out_ring[ring_head % 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 > 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], + /// The ring length the driver's slot math is USING — the driver's side of the v2.2 + /// negotiation (see the struct docs), (re-)stamped before every `ring_head` bump. `0` = + /// a pre-v2.2 driver that never writes it = [`OUT_RING_LEN`]. Carved from v2.1 reserved + /// space (v2.2). + pub out_ring_len: u32, + pub _reserved1: [u8; 88], + /// The lossless output-report ring — [`OUT_RING_LEN`] slots under a v2.1 negotiation, + /// [`OUT_RING_LEN_V22`] under v2.2 (slots 8.. overlay what v2.1 called `_reserved2`, + /// which no shipped binary ever read or wrote). See the struct docs and [`OutSlot`]. + pub out_ring: [OutSlot; OUT_RING_LEN_V22_USIZE], + pub _reserved2: [u8; 32], } // Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing @@ -1189,7 +1231,7 @@ pub mod gamepad { assert!(offset_of!(XusbShm, driver_heartbeat) == 36); assert!(offset_of!(XusbShm, pad_index) == 40); - assert!(size_of::() == 1024); + assert!(size_of::() == PAD_SHM_SIZE); assert!(offset_of!(PadShm, magic) == 0); assert!(offset_of!(PadShm, input) == 8); assert!(offset_of!(PadShm, out_seq) == 72); @@ -1203,6 +1245,14 @@ pub mod gamepad { assert!(offset_of!(PadShm, ring_head) == 160); assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE); assert!(size_of::() == 68); + // v2.2 in-place ring growth — the echo field sits in v2.1 reserved space, the grown ring + // stays within the v2.1 slots' historical offsets (slot k at 256 + k*68), and the whole + // struct is exactly the one page that keeps cross-generation views mappable. + assert!(offset_of!(PadShm, out_ring_len) == 164); + assert!( + PAD_SHM_LEGACY_SIZE + OUT_RING_LEN_USIZE * size_of::() <= PAD_SHM_V21_SIZE + ); + assert!(PAD_SHM_SIZE == 4096); assert!(size_of::() == 16); assert!(offset_of!(ChannelProof, magic) == 0); diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index fc77b338..3d029c3a 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -102,6 +102,45 @@ pub struct UhidManager { /// [`RUMBLE_IDLE_TIMEOUT`] against this is a residual the game abandoned — see /// [`pump`](Self::pump). last_active: Vec, + /// Per-pad rate limiter for the ring-overflow WARN — see [`OverflowWarn`]. + overflow_warn: Vec, +} + +/// Rate limiter for the per-poll ring-overflow WARN. A sustained >2 kHz output-report writer +/// against a pre-v2.2 8-slot driver ring overflows EVERY ~4 ms poll; unlimited, that is ~230 +/// WARN lines/s — a field log's 5000-line web-console ring was 96 % this one line, which evicted +/// the whole session history it was needed to diagnose (2026-07-30). One line per +/// [`Self::PERIOD`] with a `suppressed` count keeps the signal and the log. +#[derive(Default, Clone)] +struct OverflowWarn { + /// When the last line was emitted (`None` = never — the next overflow logs immediately). + last: Option, + /// Overflow polls swallowed since `last` — carried on the next emitted line. + suppressed: u32, +} + +impl OverflowWarn { + const PERIOD: Duration = Duration::from_secs(1); + + /// Record one overflow poll; emit (rate-limited) the WARN for it. + fn note(&mut self, now: Instant, backend: &'static str, index: usize) { + if self + .last + .is_none_or(|t| now.duration_since(t) >= Self::PERIOD) + { + tracing::warn!( + backend, + index, + suppressed = self.suppressed, + "output-report ring overflow — resyncing feedback state (repeats coalesced, 1 \ + line/s; `suppressed` = swallowed since the previous line)" + ); + self.last = Some(now); + self.suppressed = 0; + } else { + self.suppressed = self.suppressed.saturating_add(1); + } + } } /// How long a latched, non-zero rumble may sit without the game driving the RUMBLE plane before it @@ -162,6 +201,7 @@ impl UhidManager { hidout_dedup: vec![HidoutDedup::default(); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], last_active: vec![Instant::now(); MAX_PADS], + overflow_warn: vec![OverflowWarn::default(); MAX_PADS], } } @@ -249,14 +289,13 @@ impl UhidManager { let fb = self.backend.service(pad, i as u8); 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" - ); + // feedback state is unknown beyond what the drain salvaged from the legacy + // latest-report slot (delivered through `fb` like any report). Conservatively + // silence the pad FIRST (a plane the salvage didn't carry must not stay latched; + // the salvaged state re-applies right below) and re-arm the rich-plane dedup so + // the next LED/trigger state re-forwards. WARN through the per-pad rate limiter — + // a storm overflows every poll and the raw line once flooded a whole log export. + self.overflow_warn[i].note(now, B::LABEL, i); if self.last_rumble[i] != (0, 0) { self.last_rumble[i] = (0, 0); rumble(i as u16, 0, 0); @@ -646,6 +685,24 @@ mod tests { m.heartbeat(Duration::from_secs(3600)); assert_eq!(writes(&m), after_frame + 2); } + /// The overflow WARN limiter: a storm overflowing every ~4 ms poll must emit one line per + /// [`OverflowWarn::PERIOD`] and carry the swallowed count — the raw per-poll line once made + /// up 96 % of a field log export and evicted the session history around it. + #[test] + fn overflow_warn_coalesces_within_the_period() { + let mut w = OverflowWarn::default(); + let t0 = Instant::now(); + w.note(t0, "Mock", 0); // first overflow logs immediately + assert_eq!((w.last, w.suppressed), (Some(t0), 0)); + for _ in 0..250 { + w.note(t0 + Duration::from_millis(4), "Mock", 0); + } + assert_eq!((w.last, w.suppressed), (Some(t0), 250), "storm coalesced"); + let t1 = t0 + OverflowWarn::PERIOD; + w.note(t1, "Mock", 0); // period elapsed — logs (with suppressed=250) and re-arms + assert_eq!((w.last, w.suppressed), (Some(t1), 0)); + } + /// 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] diff --git a/crates/pf-inject/src/inject/windows/dualsense_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_windows.rs index 8079b587..232e0a1c 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_windows.rs @@ -57,22 +57,27 @@ 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). +// v2.1/v2.2 output-report ring (see `PadShm` in pf-driver-proto for the layout + version posture +// + the ring-length negotiation). 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_LEN: usize = + core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_ring_len); 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; +pub(super) const OUT_RING_LEN_V22: u32 = pf_driver_proto::gamepad::OUT_RING_LEN_V22; -/// 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). +/// Shared drain over a pad section's output plane — the lossless report ring when the driver +/// publishes one (8 slots from a v2.1 driver, [`OUT_RING_LEN_V22`] once both sides negotiated the +/// v2.2 growth — the driver's `out_ring_len` echo decides, see the PadShm docs), 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, @@ -94,9 +99,11 @@ impl OutputDrain { /// 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`). + /// the negotiated ring length landed since the last poll (or the driver lapped us mid-copy): + /// the pending window was DISCARDED as possibly torn, the legacy latest-report slot was + /// salvaged into ONE `per_report` call (the freshest coalesced state — the driver + /// dual-publishes every report there), and the caller must still treat its downstream + /// feedback state as unknown (`PadFeedback::resync`) for the planes that report didn't carry. 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 @@ -108,18 +115,35 @@ impl OutputDrain { if head == self.tail { return false; } + // The v2.2 length echo — the modulo the DRIVER's slot math used (0 = a pre-v2.2 + // driver that never stamps it and hardcodes 8). Loaded after the Acquire on + // `ring_head` and re-stamped by the driver before every bump, so any observed head + // comes with the length that indexed its slots. Out-of-range values (a torn or + // hostile section) clamp to the v2.1 length: the drain then at worst mis-slots and + // discards, never reads out of bounds (slot offsets stay ≤ the v2.2 ring's end). + // SAFETY: `OFF_OUT_RING_LEN` (== 164) is 4-aligned off the page-aligned base. + let echo = unsafe { + (*(base.add(OFF_OUT_RING_LEN) as *const AtomicU32)).load(Ordering::Relaxed) + }; + let ring_len = if (1..=OUT_RING_LEN_V22).contains(&echo) { + echo + } else { + OUT_RING_LEN + }; let pending = head.wrapping_sub(self.tail); - if pending <= OUT_RING_LEN { + if pending <= 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]; + let mut bufs = + [([0u8; 64], 0usize); pf_driver_proto::gamepad::OUT_RING_LEN_V22_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 idx = (self.tail.wrapping_add(k as u32) % 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). + // SAFETY: slot .. slot+OUT_SLOT_SIZE is inside the SHM_SIZE section (idx < + // `ring_len` ≤ OUT_RING_LEN_V22, whose last slot ends at 4064 ≤ SHM_SIZE); + // 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; @@ -132,7 +156,7 @@ impl OutputDrain { let head2 = unsafe { (*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire) }; - if head2.wrapping_sub(self.tail) <= OUT_RING_LEN { + if head2.wrapping_sub(self.tail) <= ring_len { for (data, len) in bufs.iter().take(n) { if *len > 0 { per_report(&data[..*len]); @@ -142,11 +166,23 @@ impl OutputDrain { 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. + // Overflow (or lapped mid-copy): skip to the freshest head and salvage the legacy + // latest-report slot — the driver dual-publishes every report there, so it holds the + // newest state the window carried. One salvaged report beats the old total silence: a + // rumble-heavy >2 kHz writer used to be force-muted for the storm's whole duration + // (field log 2026-07-30). The slot has no seqlock, so the copy can tear against a + // mid-write driver — the parser's id/flag gates drop most tears, a plausible-but-torn + // value lasts one poll at storm rates, and the caller's resync still silences every + // plane the salvaged report doesn't assert. A report that lands between the reload + // and the copy is salvaged now AND drained next poll — harmless, reports are + // valid-flag-gated state and the caller's dedup drops the repeat. // SAFETY: as the first `ring_head` load above. self.tail = unsafe { (*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire) }; + let mut out = [0u8; 64]; + // SAFETY: the legacy 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); return true; } // Legacy driver (never wrote the ring): the latest-report slot + seq — exactly the old @@ -422,8 +458,11 @@ 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); + // Ring capability, stamped before the magic so the driver sees it on attach: `2` = + // "this host drains the v2.2 long ring" (a v2.1 driver reads it as a boolean and + // stays on its 8-slot math — the drain follows the driver's `out_ring_len` echo, so + // both generations pair correctly; see the PadShm negotiation docs). + std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2); 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); @@ -709,14 +748,28 @@ mod drain_tests { buf.as_mut_ptr() as *mut u8 } - /// Mimic the v2.1 driver's dual write: legacy slot + seq, then ring slot, then head. + /// Mimic the v2.1 driver's dual write: legacy slot + seq, then ring slot (8-slot math, no + /// length echo), then head. fn publish(buf: &mut [u32], bytes: &[u8]) { + ring_publish(buf, bytes, OUT_RING_LEN, false); + } + + /// Mimic the v2.2 driver's dual write: same, with the long-ring slot math and the + /// `out_ring_len` echo stamped before the head bump. + fn v22_publish(buf: &mut [u32], bytes: &[u8]) { + ring_publish(buf, bytes, OUT_RING_LEN_V22, true); + } + + fn ring_publish(buf: &mut [u32], bytes: &[u8], len: u32, echo: bool) { 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; + let slot = OFF_OUT_RING + (head % 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); + if echo { + write32(buf, OFF_OUT_RING_LEN, len); + } write32(buf, OFF_RING_HEAD, head.wrapping_add(1)); } @@ -792,7 +845,7 @@ mod drain_tests { } #[test] - fn overflow_discards_and_flags_resync_then_recovers() { + fn overflow_salvages_the_latest_slot_and_flags_resync_then_recovers() { let mut buf = section(); let mut d = OutputDrain::new(); for i in 0..12u8 { @@ -801,13 +854,91 @@ mod drain_tests { } 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"); + assert_eq!( + got.len(), + 1, + "the possibly-torn ring window must not be parsed — only the legacy latest slot" + ); + assert_eq!( + &got[0][..2], + &[0x02, 11], + "the salvage must be the freshest coalesced state, not silence" + ); publish(&mut buf, &[0x02, 99]); let (got, resync) = collect(&mut d, &mut buf); assert!(!resync); assert_eq!(got, vec![vec![0x02, 99]]); } + /// The 2026-07-30 field storm: a >2 kHz writer lands more than 8 reports per poll window. A + /// v2.2 driver's echoed long ring must absorb it losslessly — this exact burst overflowed + /// EVERY poll against the 8-slot ring. + #[test] + fn v22_ring_absorbs_a_burst_the_v21_ring_could_not() { + let mut buf = section(); + let mut d = OutputDrain::new(); + for i in 0..40u8 { + v22_publish(&mut buf, &[0x02, i]); + } + let (got, resync) = collect(&mut d, &mut buf); + assert!(!resync, "40 pending ≤ 56 slots — no overflow"); + assert_eq!( + got.iter().map(|r| r[1]).collect::>(), + (0..40).collect::>() + ); + } + + #[test] + fn v22_ring_wraps_across_polls() { + let mut buf = section(); + let mut d = OutputDrain::new(); + for i in 0..50u8 { + v22_publish(&mut buf, &[0x02, i]); + } + assert_eq!(collect(&mut d, &mut buf).0.len(), 50); + for i in 50..100u8 { + // wraps past slot 56 + v22_publish(&mut buf, &[0x02, i]); + } + let (got, resync) = collect(&mut d, &mut buf); + assert!(!resync); + assert_eq!( + got.iter().map(|r| r[1]).collect::>(), + (50..100).collect::>() + ); + } + + #[test] + fn v22_overflow_still_salvages_and_recovers() { + let mut buf = section(); + let mut d = OutputDrain::new(); + for i in 0..60u8 { + // 60 > OUT_RING_LEN_V22 pending + v22_publish(&mut buf, &[0x02, i]); + } + let (got, resync) = collect(&mut d, &mut buf); + assert!(resync); + assert_eq!(got.len(), 1); + assert_eq!(&got[0][..2], &[0x02, 59]); + v22_publish(&mut buf, &[0x02, 99]); + let (got, resync) = collect(&mut d, &mut buf); + assert!(!resync); + assert_eq!(got, vec![vec![0x02, 99]]); + } + + /// An out-of-range `out_ring_len` (torn write / hostile section) must clamp to the v2.1 + /// length, not drive the slot math out of the ring. + #[test] + fn garbage_length_echo_clamps_to_the_v21_length() { + let mut buf = section(); + let mut d = OutputDrain::new(); + publish(&mut buf, &[0x02, 1]); // 8-slot math, like the driver the clamp falls back to + write32(&mut buf, OFF_OUT_RING_LEN, 9999); + let (got, resync) = collect(&mut d, &mut buf); + assert!(!resync); + assert_eq!(got, vec![vec![0x02, 1]]); + } + /// Every hardware id the host puts on a pad devnode must be one the shipped INF actually /// declares — otherwise PnP matches none of our models, falls through to the synthesized USB /// ids on the same devnode, and binds Microsoft's inbox `input.inf`/`HidUsb`, which cannot diff --git a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs index 29a9667f..27e16840 100644 --- a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs @@ -56,8 +56,9 @@ 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); + // Ring capability `2` = "this host drains the v2.2 long ring", stamped before the + // magic so the driver sees it on attach (see the DualSense open path + PadShm docs). + std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2); 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); 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 544a7eb9..d8e85a73 100644 --- a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs +++ b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs @@ -61,8 +61,9 @@ 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); + // Ring capability `2` = "this host drains the v2.2 long ring", stamped before the + // magic so the driver sees it on attach (see the DualSense open path + PadShm docs). + std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2); std::ptr::write_unaligned( base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN], neutral_deck_report(), diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index fca373a4..d8b59b8f 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -302,8 +302,9 @@ static INPUT_REPORT: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new(NEUTRAL_ // ---- the sealed pad channel: layouts + offsets from pf_driver_proto (drift = compile error) ---- // UMDF runs in WUDFHost.exe (user-mode) and hidclass blocks a control channel on the device stack // (custom interface CreateFile → err 31; custom IOCTL on the HID handle → err 1) and UMDF has no -// control device. So the DATA section (`PadShm`, 256 B — input report @8, output seq @72, output -// report @76, device_type @140, health marks @144/@148, pad_index @152) is UNNAMED and reached only +// control device. So the DATA section (`PadShm` — input report @8, output seq @72, output +// report @76, device_type @140, health marks @144/@148, pad_index @152, output-report ring +// @156..) is UNNAMED and reached only // through a handle the SYSTEM host duplicated into this WUDFHost, bootstrapped over the named mailbox // `Global\pfds-boot-`. The handshake + all shared-memory access live in `pf_umdf_util`. const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS" @@ -318,30 +319,56 @@ 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). +// v2.1/v2.2 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_LEN: usize = core::mem::offset_of!(PadShm, out_ring_len); 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; +const OUT_RING_LEN_V22: u32 = pf_driver_proto::gamepad::OUT_RING_LEN_V22; + +/// The output-ring length this side's slot math uses against the attached section — the driver's +/// half of the v2.2 negotiation (PadShm docs): the host's `out_ring_ver` stamp declares what it +/// can drain, the mapped length proves the slots exist in OUR view, and the shorter understanding +/// wins. `0` = no ring (pre-v2.1 host, or a fallback-size map too small to hold one) — legacy +/// latest-slot only. Constant per attachment (both inputs are fixed once the view exists). +fn ring_len(view: &pf_umdf_util::section::MappedView) -> u32 { + if view.read_u32(OFF_OUT_RING_VER) == 0 + || view.mapped_len() < pf_driver_proto::gamepad::PAD_SHM_V21_SIZE + { + return 0; + } + if view.read_u32(OFF_OUT_RING_VER) >= 2 + && view.mapped_len() >= pf_driver_proto::gamepad::PAD_SHM_SIZE + { + OUT_RING_LEN_V22 + } else { + 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). +/// ring region) and our view maps it — the lossless report ring: slot bytes first, then the +/// [`ring_len`] echo (the v2.2 length negotiation — a pre-v2.2 host never reads it), then the +/// `ring_head` bump with `Release` so the host's Acquire load can never observe the bump without +/// the slot bytes and the length that indexed them. 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 len = ring_len(view); + if len != 0 { let head = view.read_u32(OFF_RING_HEAD); - let slot = OFF_OUT_RING + (head % OUT_RING_LEN) as usize * OUT_SLOT_SIZE; + let slot = OFF_OUT_RING + (head % 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)); + view.write_u32(OFF_OUT_RING_LEN, len); + view.store_u32(OFF_RING_HEAD, head.wrapping_add(1), Ordering::Release); } }