fix(host/inject,drivers): rumble root fixes A-C — lossless report ring + rumble-keyed idle watchdogs
B: PadFeedback.game_drove -> rumble_drove, keyed on vibration-asserting reports — an LED/adaptive-trigger stream can no longer feed the abandoned-rumble force-off while a coalesced stop never re-asserts (the confirmed unbounded stuck-ON path). C: Linux parity — every UHID backend now arms the shared watchdog (Steam Input drives these pads over hidraw with Windows abandonment semantics) and the uinput mixer force-stops abandoned infinite-replay FF effects (FfState, unit-tested). Shared PUNKTFUNK_RUMBLE_IDLE_MS hatch (0 = off; non-zero floored above SDL's ~2 s rumble resend). A: PadShm v2.1 — a 1024 B tail extension carrying an 8-slot lossless output-report ring, feature-negotiated via zeroed reserved fields (out_ring_ver; deliberately NO GAMEPAD_PROTO_VERSION bump — mixed generations degrade to the legacy latest-report slot instead of failing closed). The pf-dualsense driver dual-writes both planes (publish_output); the host's shared OutputDrain drains oldest->newest with a torn-read recheck and an overflow->resync path (PadFeedback.resync force-stops + re-arms dedups). pf-umdf-util grows a min_data_size map fallback. Ds*Feedback.fresh removed (dead). design/rumble-root-fix.md par. A-C. Verified: pf-inject tests+clippy Linux+Windows (53/53 on winbox incl. the stop-coalesce repro); drivers ws check+clippy on the CI runner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -318,6 +318,32 @@ const OFF_DEVICE_TYPE: usize = core::mem::offset_of!(PadShm, device_type);
|
||||
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(PadShm, driver_proto);
|
||||
const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(PadShm, driver_heartbeat);
|
||||
const OFF_PAD_INDEX: usize = core::mem::offset_of!(PadShm, pad_index);
|
||||
// v2.1 output-report ring (see PadShm docs in pf_driver_proto).
|
||||
const OFF_OUT_RING_VER: usize = core::mem::offset_of!(PadShm, out_ring_ver);
|
||||
const OFF_RING_HEAD: usize = core::mem::offset_of!(PadShm, ring_head);
|
||||
const OFF_OUT_RING: usize = core::mem::offset_of!(PadShm, out_ring);
|
||||
const OUT_SLOT_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::OutSlot>();
|
||||
const OUT_RING_LEN: u32 = pf_driver_proto::gamepad::OUT_RING_LEN;
|
||||
|
||||
/// Publish one game output report to the host: the legacy latest-report slot + `out_seq` bump
|
||||
/// (every host generation reads this), and — when the host stamped `out_ring_ver` (it created the
|
||||
/// ring region) and our view maps it — the lossless report ring: slot bytes first, `ring_head`
|
||||
/// bump last, the same publish-then-bump store order the host's Acquire load pairs with on the
|
||||
/// legacy `out_seq`. The ring is what stops a rumble-STOP report from being coalesced away by a
|
||||
/// following LED/trigger report inside one host poll window (the confirmed stuck-rumble path).
|
||||
fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
|
||||
view.write_bytes(OFF_OUTPUT, bytes);
|
||||
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
|
||||
view.write_u32(OFF_OUT_SEQ, seq);
|
||||
if view.mapped_len() >= SHM_SIZE && view.read_u32(OFF_OUT_RING_VER) != 0 {
|
||||
let head = view.read_u32(OFF_RING_HEAD);
|
||||
let slot = OFF_OUT_RING + (head % OUT_RING_LEN) as usize * OUT_SLOT_SIZE;
|
||||
let n = bytes.len().min(64);
|
||||
view.write_u32(slot, n as u32);
|
||||
view.write_bytes(slot + 4, &bytes[..n]);
|
||||
view.write_u32(OFF_RING_HEAD, head.wrapping_add(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
|
||||
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
|
||||
@@ -336,6 +362,10 @@ fn channel_cfg() -> ChannelConfig {
|
||||
boot_name_prefix: "Global\\pfds-boot-",
|
||||
data_magic: SHM_MAGIC,
|
||||
data_size: SHM_SIZE,
|
||||
// The v2.1 layout grew by tail extension (the output-report ring); against an old host's
|
||||
// 256-byte section the full-size map still succeeds (sections are page-granular), but if
|
||||
// it is ever refused, mapping the legacy size keeps the pad alive with the ring disabled.
|
||||
min_data_size: pf_driver_proto::gamepad::PAD_SHM_LEGACY_SIZE,
|
||||
pad_index_off: OFF_PAD_INDEX,
|
||||
log,
|
||||
}
|
||||
@@ -375,10 +405,13 @@ fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
|
||||
if !file_log_enabled() {
|
||||
return None;
|
||||
}
|
||||
// WUDFHost's own (LocalService) temp dir — NOT world-writable/readable `C:\Users\Public`,
|
||||
// where the OUTPUT/feature-report hex dumps could leak per-pad identity/serial material to
|
||||
// any local reader (security-review 2026-07-17). Opt-in/debug only.
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("C:\\Users\\Public\\pfds-driver.log")
|
||||
.open(std::env::temp_dir().join("pfds-driver.log"))
|
||||
.ok()
|
||||
.map(std::sync::Mutex::new)
|
||||
})
|
||||
@@ -614,13 +647,11 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
|
||||
dbglog!("[pf-ds] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}");
|
||||
|
||||
// Publish the game's 0x02 output report to the sealed DATA section for the host (rumble /
|
||||
// lightbar / player-LEDs / adaptive triggers), then bump the host-polled output seq.
|
||||
// lightbar / player-LEDs / adaptive triggers): legacy slot + seq, plus the v2.1 ring.
|
||||
if !bytes.is_empty()
|
||||
&& let Some(view) = CHANNEL.data()
|
||||
{
|
||||
view.write_bytes(OFF_OUTPUT, &bytes);
|
||||
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
|
||||
view.write_u32(OFF_OUT_SEQ, seq);
|
||||
publish_output(view, &bytes);
|
||||
}
|
||||
|
||||
request.set_information(inlen as u64);
|
||||
@@ -662,9 +693,7 @@ fn on_set_feature(request: &Request) -> NTSTATUS {
|
||||
let mut out = [0u8; 64];
|
||||
let n = src.len().min(63);
|
||||
out[1..1 + n].copy_from_slice(&src[..n]);
|
||||
view.write_bytes(OFF_OUTPUT, &out);
|
||||
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
|
||||
view.write_u32(OFF_OUT_SEQ, seq);
|
||||
publish_output(view, &out);
|
||||
}
|
||||
}
|
||||
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
|
||||
|
||||
@@ -167,6 +167,7 @@ fn channel_cfg() -> ChannelConfig {
|
||||
boot_name_prefix: "Global\\pfmouse-boot-",
|
||||
data_magic: SHM_MAGIC,
|
||||
data_size: SHM_SIZE,
|
||||
min_data_size: SHM_SIZE, // layout never grew — no fallback size
|
||||
pad_index_off: OFF_PAD_INDEX,
|
||||
log,
|
||||
}
|
||||
|
||||
@@ -35,6 +35,13 @@ pub struct ChannelConfig {
|
||||
pub data_magic: u32,
|
||||
/// The DATA section's size (`size_of::<XusbShm>()` / `size_of::<PadShm>()`).
|
||||
pub data_size: usize,
|
||||
/// Fallback map length when the full `data_size` map is refused — the legacy section size of a
|
||||
/// layout that grew by tail extension (`PAD_SHM_LEGACY_SIZE` for the pad channel). Sections are
|
||||
/// pagefile-backed and page-granular, so the full-size map is expected to succeed against
|
||||
/// either host generation; this exists so a refused map can never fail the pad closed. Set
|
||||
/// equal to `data_size` for layouts that never grew. A caller gates tail-extension features on
|
||||
/// `MappedView::mapped_len()` (plus the layout's own capability field), never on assumption.
|
||||
pub min_data_size: usize,
|
||||
/// `offset_of!(…Shm, pad_index)` in the DATA section.
|
||||
pub pad_index_off: usize,
|
||||
/// The driver's logger (each driver tees to its own debug file).
|
||||
@@ -159,7 +166,9 @@ impl ChannelClient {
|
||||
/// close it — the view keeps the section alive. On validation failure the handle is
|
||||
/// deliberately NOT closed: a tampered value could name an unrelated handle in our own table.
|
||||
fn adopt(&self, cfg: &ChannelConfig, value: u64) {
|
||||
let Some(view) = MappedView::from_handle_value(value, cfg.data_size) else {
|
||||
let Some(view) = MappedView::from_handle_value(value, cfg.data_size)
|
||||
.or_else(|| MappedView::from_handle_value(value, cfg.min_data_size))
|
||||
else {
|
||||
if value != 0 {
|
||||
(cfg.log)(&format!(
|
||||
"[{}] delivered DATA handle 0x{value:x} did not map — ignoring",
|
||||
|
||||
@@ -80,6 +80,12 @@ impl MappedView {
|
||||
Some(MappedView { base, len })
|
||||
}
|
||||
|
||||
/// How many bytes this view maps — the gate for tail-extension features (a caller may only
|
||||
/// touch offsets `< mapped_len()`; see `ChannelConfig::min_data_size`).
|
||||
pub fn mapped_len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// Assert `off..off+n` is inside the view and, for atomics, `align`-aligned. The view base is
|
||||
/// page-aligned (`MapViewOfFile`), so field alignment reduces to offset alignment.
|
||||
#[inline]
|
||||
|
||||
@@ -99,6 +99,7 @@ fn channel_cfg() -> ChannelConfig {
|
||||
boot_name_prefix: "Global\\pfxusb-boot-",
|
||||
data_magic: SHM_MAGIC,
|
||||
data_size: SHM_SIZE,
|
||||
min_data_size: SHM_SIZE, // layout never grew — no fallback size
|
||||
pad_index_off: OFF_PAD_INDEX,
|
||||
log,
|
||||
}
|
||||
@@ -125,10 +126,14 @@ fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
|
||||
if !file_log_enabled() {
|
||||
return None;
|
||||
}
|
||||
// Write to the WUDFHost's own (LocalService) temp dir — NOT world-writable/readable
|
||||
// `C:\Users\Public`, where the per-event SET_STATE/report hex dumps could leak pad
|
||||
// identity to any local reader and a non-admin could pre-create/hold the file
|
||||
// (security-review 2026-07-17). Opt-in/debug only.
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("C:\\Users\\Public\\pfxusb-driver.log")
|
||||
.open(std::env::temp_dir().join("pfxusb-driver.log"))
|
||||
.ok()
|
||||
.map(std::sync::Mutex::new)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user