fix(gamepad): the output-report ring absorbs haptics-rate writers (PadShm v2.2)
ci / web (push) Successful in 1m1s
windows-drivers / probe-and-proto (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m16s
windows-drivers / driver-build (push) Successful in 2m15s
ci / rust-arm64 (push) Successful in 3m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Successful in 2m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
apple / swift (push) Successful in 4m36s
deb / build-publish-host (push) Successful in 4m2s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 29s
arch / build-publish (push) Failing after 6m11s
deb / build-publish (push) Successful in 6m6s
android / android (push) Successful in 8m29s
ci / rust (push) Successful in 10m32s
windows-host / package (push) Successful in 16m18s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m42s
apple / screenshots (push) Successful in 20m33s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:36:47 +02:00
co-authored by Claude Fable 5
parent ec675261fc
commit e90c5d5bcd
6 changed files with 325 additions and 58 deletions
+62 -12
View File
@@ -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::<PadShm>() == 1024);
assert!(size_of::<PadShm>() == 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::<OutSlot>() == 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::<OutSlot>() <= PAD_SHM_V21_SIZE
);
assert!(PAD_SHM_SIZE == 4096);
assert!(size_of::<ChannelProof>() == 16);
assert!(offset_of!(ChannelProof, magic) == 0);
+65 -8
View File
@@ -102,6 +102,45 @@ pub struct UhidManager<B: PadProto> {
/// [`RUMBLE_IDLE_TIMEOUT`] against this is a residual the game abandoned — see
/// [`pump`](Self::pump).
last_active: Vec<Instant>,
/// Per-pad rate limiter for the ring-overflow WARN — see [`OverflowWarn`].
overflow_warn: Vec<OverflowWarn>,
}
/// 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<Instant>,
/// 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<B: PadProto> UhidManager<B> {
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<B: PadProto> UhidManager<B> {
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]
@@ -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::<pf_driver_proto::gamepad::OutSlot>();
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::<Vec<_>>(),
(0..40).collect::<Vec<_>>()
);
}
#[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::<Vec<_>>(),
(50..100).collect::<Vec<_>>()
);
}
#[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
@@ -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);
@@ -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(),
+37 -10
View File
@@ -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-<index>`. 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::<pf_driver_proto::gamepad::OutSlot>();
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);
}
}