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:
2026-07-17 14:07:32 +02:00
parent 570ff504ad
commit 9e6fc6e071
20 changed files with 877 additions and 212 deletions
@@ -18,8 +18,8 @@
//! kernel's evdev parser; Steam-on-Windows reads the raw reports directly.
use super::dualsense_windows::{
create_swdevice, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUTPUT,
OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
create_swdevice, OutputDrain, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT,
OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
};
use super::gamepad_raii::PadChannel;
use super::steam_proto::{
@@ -41,7 +41,8 @@ pub struct DeckWinPad {
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach,
seq: u32,
last_out_seq: u32,
/// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver).
drain: OutputDrain,
}
impl DeckWinPad {
@@ -56,6 +57,8 @@ impl DeckWinPad {
unsafe {
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
// Ring capability (v2.1), stamped before the magic so the driver sees it on attach.
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 1);
std::ptr::write_unaligned(
base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN],
neutral_deck_report(),
@@ -96,7 +99,7 @@ impl DeckWinPad {
instance_id,
),
seq: 0,
last_out_seq: 0,
drain: OutputDrain::new(),
})
}
@@ -118,31 +121,23 @@ impl DeckWinPad {
/// Poll the section's output slot; parse a newly-published Steam command (`0xEB` rumble /
/// `0x8F` haptic pulse — republished by the driver off SET_FEATURE) into feedback. Also
/// ticks the sealed-channel delivery and the driver-attach health watcher.
fn service(&mut self) -> Option<(u16, u16)> {
fn service(&mut self) -> (Option<(u16, u16)>, bool) {
self.channel.pump();
// SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
};
self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes.
let seq = unsafe {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
};
if seq == self.last_out_seq {
return None;
}
self.last_out_seq = seq;
let mut out = [0u8; 64];
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe {
std::ptr::copy_nonoverlapping(
self.channel.data_base().add(OFF_OUTPUT),
out.as_mut_ptr(),
64,
)
};
parse_steam_output(&out).rumble
let mut rumble = None;
let base = self.channel.data_base();
let resync = self.drain.drain(base, |bytes| {
// Oldest → newest: the last report that carried rumble wins (0x8F trackpad-haptic
// reports carry none and pass through without disturbing it).
if let Some(r) = parse_steam_output(bytes).rumble {
rumble = Some(r);
}
});
(rumble, resync)
}
}
@@ -216,13 +211,14 @@ impl PadProto for DeckWinProto {
/// Deck has no rich host→client feedback plane (no lightbar / adaptive triggers), so
/// `hidout` stays empty — parity with the Linux backend.
fn service(&self, pad: &mut DeckWinPad, _idx: u8) -> PadFeedback {
// The Deck poll returns `Some` exactly when a fresh output report landed (a seq bump), so
// its presence is the game-activity signal, even when the rumble level is unchanged.
let rumble = pad.service();
// The Deck drain surfaces `Some` exactly when a rumble-carrying report landed, so its
// presence is the rumble-plane activity signal, even at an unchanged level.
let (rumble, resync) = pad.service();
PadFeedback {
rumble,
hidout: Vec::new(),
game_drove: Some(rumble.is_some()),
rumble_drove: Some(rumble.is_some()),
resync,
}
}
}