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
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:
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user