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
+60 -6
View File
@@ -690,10 +690,46 @@ pub mod gamepad {
pub _reserved1: [u8; 20], pub _reserved1: [u8; 20],
} }
/// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID /// The legacy (pre-ring) [`PadShm`] size. Old binaries on either side were built against a
/// input report into `input`; the driver feeds it to game `READ_REPORT`s and publishes a game's /// 256-byte layout; every field they know sits below this offset, and the ring extension keeps
/// `0x02` output (rumble / lightbar / player-LEDs / adaptive triggers) into `output`, bumping /// bytes `0..256` byte-identical. Pagefile-backed sections are page-granular, so a view of
/// `out_seq`. `device_type` selects the HID identity ([`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]). /// either generation's size maps against either generation's section — but a driver must
/// still be able to fall back to mapping this size if the full-size map is ever refused
/// (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.
pub const OUT_RING_LEN: u32 = 8;
pub const OUT_RING_LEN_USIZE: usize = OUT_RING_LEN as usize;
/// 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.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
pub struct OutSlot {
/// Valid bytes in `data` (0..=64). `0` = never written.
pub len: u32,
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
/// 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,
/// when the host stamped `out_ring_ver`, into the lossless `out_ring` (bumping `ring_head`).
/// The ring exists because the single slot COALESCES: a rumble-stop report overwritten by an
/// LED/trigger report inside one host poll window was gone forever — the confirmed stuck-rumble
/// path (`design/rumble-root-fix.md` §A). `device_type` selects the HID identity
/// ([`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]).
///
/// Version posture: this is a TAIL extension negotiated by zeroed-reserved capability fields,
/// deliberately NOT a [`GAMEPAD_PROTO_VERSION`] bump — the bootstrap fails CLOSED on a version
/// 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.
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug)] #[derive(Clone, Copy, Pod, Zeroable, Debug)]
pub struct PadShm { pub struct PadShm {
@@ -718,7 +754,20 @@ pub mod gamepad {
/// The pad index this section serves (host-stamped before the magic) — see /// The pad index this section serves (host-stamped before the magic) — see
/// [`XusbShm::pad_index`]. Carved from v1 reserved space (v2). /// [`XusbShm::pad_index`]. Carved from v1 reserved space (v2).
pub pad_index: u32, pub pad_index: u32,
pub _reserved1: [u8; 100], /// Host-stamped `1` at section creation ⇔ "this section carries the `out_ring` region and
/// 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
/// `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],
} }
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing // Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
@@ -744,7 +793,7 @@ pub mod gamepad {
assert!(offset_of!(XusbShm, driver_heartbeat) == 36); assert!(offset_of!(XusbShm, driver_heartbeat) == 36);
assert!(offset_of!(XusbShm, pad_index) == 40); assert!(offset_of!(XusbShm, pad_index) == 40);
assert!(size_of::<PadShm>() == 256); assert!(size_of::<PadShm>() == 1024);
assert!(offset_of!(PadShm, magic) == 0); assert!(offset_of!(PadShm, magic) == 0);
assert!(offset_of!(PadShm, input) == 8); assert!(offset_of!(PadShm, input) == 8);
assert!(offset_of!(PadShm, out_seq) == 72); assert!(offset_of!(PadShm, out_seq) == 72);
@@ -753,6 +802,11 @@ pub mod gamepad {
assert!(offset_of!(PadShm, driver_proto) == 144); assert!(offset_of!(PadShm, driver_proto) == 144);
assert!(offset_of!(PadShm, driver_heartbeat) == 148); assert!(offset_of!(PadShm, driver_heartbeat) == 148);
assert!(offset_of!(PadShm, pad_index) == 152); assert!(offset_of!(PadShm, pad_index) == 152);
// v2.1 ring extension — everything below PAD_SHM_LEGACY_SIZE is the v2 layout verbatim.
assert!(offset_of!(PadShm, out_ring_ver) == 156);
assert!(offset_of!(PadShm, ring_head) == 160);
assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE);
assert!(size_of::<OutSlot>() == 68);
assert!(size_of::<PadBootstrap>() == 32); assert!(size_of::<PadBootstrap>() == 32);
assert!(offset_of!(PadBootstrap, magic) == 0); assert!(offset_of!(PadBootstrap, magic) == 0);
+16 -6
View File
@@ -303,9 +303,14 @@ impl PadProto for DsLinuxProto {
PadFeedback { PadFeedback {
rumble: fb.rumble, rumble: fb.rumble,
hidout: fb.hidout, hidout: fb.hidout,
// Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does // Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games
// not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`). // going through hid-playstation get their stops surfaced reliably, but Steam Input
game_drove: None, // drives this pad over hidraw DIRECTLY — the same abandonment semantics as a Windows
// game, so the same watchdog applies. SDL-class writers re-assert a held level every
// ~2 s (inside the idle window), and a writer that goes silent on a latched level is
// cut exactly as real firmware decay would cut it on a physical pad.
rumble_drove: Some(fb.rumble.is_some()),
resync: false,
} }
} }
} }
@@ -392,9 +397,14 @@ impl PadProto for DsEdgeLinuxProto {
PadFeedback { PadFeedback {
rumble: fb.rumble, rumble: fb.rumble,
hidout: fb.hidout, hidout: fb.hidout,
// Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does // Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games
// not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`). // going through hid-playstation get their stops surfaced reliably, but Steam Input
game_drove: None, // drives this pad over hidraw DIRECTLY — the same abandonment semantics as a Windows
// game, so the same watchdog applies. SDL-class writers re-assert a held level every
// ~2 s (inside the idle window), and a writer that goes silent on a latched level is
// cut exactly as real firmware decay would cut it on a physical pad.
rumble_drove: Some(fb.rumble.is_some()),
resync: false,
} }
} }
} }
@@ -378,7 +378,11 @@ impl PadProto for Ds4LinuxProto {
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b }) .map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
.into_iter() .into_iter()
.collect(), .collect(),
game_drove: None, // Rumble-plane liveness (arms the shared abandoned-rumble force-off) — see the Linux
// DualSense backend for the hidraw-writer rationale; `parse_ds4_output` gates rumble
// on flag0 bit0 the same way.
rumble_drove: Some(fb.rumble.is_some()),
resync: false,
} }
} }
} }
+169 -34
View File
@@ -23,7 +23,7 @@ use anyhow::{bail, Result};
use punktfunk_core::input::{gamepad, GamepadFrame, MAX_PADS}; use punktfunk_core::input::{gamepad, GamepadFrame, MAX_PADS};
use std::collections::HashMap; use std::collections::HashMap;
use std::os::fd::{AsRawFd, OwnedFd}; use std::os::fd::{AsRawFd, OwnedFd};
use std::time::Instant; use std::time::{Duration, Instant};
// ioctls (x86_64). // ioctls (x86_64).
const UI_DEV_CREATE: libc::c_ulong = 0x5501; const UI_DEV_CREATE: libc::c_ulong = 0x5501;
@@ -263,14 +263,80 @@ struct Effect {
replay_ms: u16, replay_ms: u16,
} }
/// One virtual X-Box-360 pad backed by a uinput device. /// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy
pub struct VirtualPad { /// (finite-replay expiry + the abandoned-INFINITE-effect force-off), split from [`VirtualPad`] so
fd: OwnedFd, /// the policy is pure and unit-testable without a live uinput fd.
struct FfState {
effects: HashMap<i16, Effect>, effects: HashMap<i16, Effect>,
next_effect_id: i16, next_effect_id: i16,
gain: u32, gain: u32,
/// Last `(low, high)` reported, to dedup. /// Last `(low, high)` reported, to dedup.
last_mix: (u16, u16), last_mix: (u16, u16),
/// When a game last touched the FF plane (upload / erase / play / stop / gain). An
/// infinite-replay effect still playing past the shared idle window against this is a residual
/// the game abandoned (kernel auto-erase only covers a game whose fd CLOSED) — finite effects
/// are untouched: their declared replay deadline is the contract, exactly as a real pad honors
/// it. SDL-class writers re-play held rumble every ~2 s, refreshing this clock.
last_activity: Instant,
}
impl FfState {
fn new() -> FfState {
FfState {
effects: HashMap::new(),
next_effect_id: 0,
gain: 0xFFFF,
last_mix: (0, 0),
last_activity: Instant::now(),
}
}
/// The game touched the FF plane — refresh the abandoned-effect clock.
fn note_activity(&mut self) {
self.last_activity = Instant::now();
}
/// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones),
/// scale by gain. Returns the new `(low, high)` only when it changed since the last call.
fn mix(&mut self, now: Instant, idle: Option<Duration>) -> Option<(u16, u16)> {
let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t);
let (mut strong, mut weak) = (0u32, 0u32);
for e in self.effects.values_mut() {
let Some(deadline) = e.playing else { continue };
match deadline {
Some(d) if now >= d => e.playing = None,
// An infinite-replay effect the game stopped driving (no FF traffic for the whole
// idle window) — the alive-but-abandoned case the kernel's close-time auto-erase
// cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the
// clock). Mirrors the XUSB/UHID abandoned-rumble force-off.
None if stale => {
tracing::info!(
strong = e.strong,
weak = e.weak,
"rumble: stale infinite FF effect (game stopped driving the pad) — forcing off"
);
e.playing = None;
}
_ => {
strong = strong.saturating_add(e.strong as u32);
weak = weak.saturating_add(e.weak as u32);
}
}
}
// Linux FF: strong = low-frequency (big) motor, weak = high-frequency motor.
let low = ((strong.min(0xFFFF) * self.gain) >> 16) as u16;
let high = ((weak.min(0xFFFF) * self.gain) >> 16) as u16;
(self.last_mix != (low, high)).then(|| {
self.last_mix = (low, high);
(low, high)
})
}
}
/// One virtual X-Box-360 pad backed by a uinput device.
pub struct VirtualPad {
fd: OwnedFd,
ff: FfState,
} }
impl VirtualPad { impl VirtualPad {
@@ -369,10 +435,7 @@ impl VirtualPad {
Ok(VirtualPad { Ok(VirtualPad {
fd, fd,
effects: HashMap::new(), ff: FfState::new(),
next_effect_id: 0,
gain: 0xFFFF,
last_mix: (0, 0),
}) })
} }
@@ -460,6 +523,7 @@ impl VirtualPad {
unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) }; unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) };
match (ev.type_, ev.code) { match (ev.type_, ev.code) {
(EV_UINPUT, UI_FF_UPLOAD) => { (EV_UINPUT, UI_FF_UPLOAD) => {
self.ff.note_activity();
// SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two // SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two
// `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field // `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field
// (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid // (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid
@@ -469,13 +533,13 @@ impl VirtualPad {
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() { if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
let mut e = up.effect; let mut e = up.effect;
if e.id == -1 { if e.id == -1 {
e.id = self.next_effect_id; e.id = self.ff.next_effect_id;
self.next_effect_id = self.next_effect_id.wrapping_add(1); self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1);
} }
if e.type_ == FF_RUMBLE { if e.type_ == FF_RUMBLE {
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]); let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]); let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
let slot = self.effects.entry(e.id).or_insert(Effect { let slot = self.ff.effects.entry(e.id).or_insert(Effect {
strong: 0, strong: 0,
weak: 0, weak: 0,
playing: None, playing: None,
@@ -491,20 +555,25 @@ impl VirtualPad {
} }
} }
(EV_UINPUT, UI_FF_ERASE) => { (EV_UINPUT, UI_FF_ERASE) => {
self.ff.note_activity();
// SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`, // SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`,
// `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid // `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid
// initialized value — `request_id` is set below and `effect_id` filled by the ioctl. // initialized value — `request_id` is set below and `effect_id` filled by the ioctl.
let mut er: UinputFfErase = unsafe { std::mem::zeroed() }; let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
er.request_id = ev.value as u32; er.request_id = ev.value as u32;
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() { if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
self.effects.remove(&(er.effect_id as i16)); self.ff.effects.remove(&(er.effect_id as i16));
er.retval = 0; er.retval = 0;
let _ = ioctl_ptr(raw, UI_END_FF_ERASE, &mut er, "UI_END_FF_ERASE"); let _ = ioctl_ptr(raw, UI_END_FF_ERASE, &mut er, "UI_END_FF_ERASE");
} }
} }
(EV_FF, FF_GAIN) => self.gain = (ev.value as u32).min(0xFFFF), (EV_FF, FF_GAIN) => {
self.ff.note_activity();
self.ff.gain = (ev.value as u32).min(0xFFFF);
}
(EV_FF, code) => { (EV_FF, code) => {
if let Some(e) = self.effects.get_mut(&(code as i16)) { self.ff.note_activity();
if let Some(e) = self.ff.effects.get_mut(&(code as i16)) {
e.playing = if ev.value != 0 { e.playing = if ev.value != 0 {
Some((e.replay_ms > 0).then(|| { Some((e.replay_ms > 0).then(|| {
Instant::now() Instant::now()
@@ -519,26 +588,8 @@ impl VirtualPad {
} }
} }
// Mix: sum playing effects (expiring finished ones), scale by gain. self.ff
let now = Instant::now(); .mix(Instant::now(), crate::uhid_manager::rumble_idle_timeout())
let (mut strong, mut weak) = (0u32, 0u32);
for e in self.effects.values_mut() {
if let Some(deadline) = e.playing {
if deadline.is_some_and(|d| now >= d) {
e.playing = None;
} else {
strong = strong.saturating_add(e.strong as u32);
weak = weak.saturating_add(e.weak as u32);
}
}
}
// Linux FF: strong = low-frequency (big) motor, weak = high-frequency motor.
let low = ((strong.min(0xFFFF) * self.gain) >> 16) as u16;
let high = ((weak.min(0xFFFF) * self.gain) >> 16) as u16;
(self.last_mix != (low, high)).then(|| {
self.last_mix = (low, high);
(low, high)
})
} }
} }
@@ -727,3 +778,87 @@ mod tests {
); );
} }
} }
#[cfg(test)]
mod ff_state_tests {
use super::*;
/// The default idle window the shared hatch resolves to when the env is unset.
const IDLE: Option<Duration> = Some(Duration::from_millis(2500));
/// `gain` is 0xFFFF (not a true 1.0 multiplier), so a magnitude loses 1 LSB in the mixdown.
fn scaled(v: u16) -> u16 {
((v as u32 * 0xFFFF) >> 16) as u16
}
fn ff_with(effect: Effect) -> FfState {
let mut ff = FfState::new();
ff.effects.insert(0, effect);
ff
}
#[test]
fn abandoned_infinite_effect_is_forced_off_after_idle_window() {
let mut ff = ff_with(Effect {
strong: 0x8000,
weak: 0,
playing: Some(None),
replay_ms: 0,
});
let now = Instant::now();
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing
// The game goes silent on the FF plane past the idle window: cut, exactly once.
ff.last_activity = now - Duration::from_millis(2600);
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
assert_eq!(ff.mix(now, IDLE), None); // already off — no repeat
}
#[test]
fn finite_effect_honors_its_replay_deadline_not_the_idle_window() {
let now = Instant::now();
let mut ff = ff_with(Effect {
strong: 0x4000,
weak: 0,
playing: Some(Some(now + Duration::from_secs(10))),
replay_ms: 10_000,
});
// FF plane long stale, but the effect declared a finite replay — the declared duration is
// the contract (a real pad honors it too), so it keeps playing…
ff.last_activity = now - Duration::from_secs(60);
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x4000), 0)));
// …and expires at its own deadline.
assert_eq!(ff.mix(now + Duration::from_secs(11), IDLE), Some((0, 0)));
}
#[test]
fn replay_after_cut_rearms_the_effect() {
let now = Instant::now();
let mut ff = ff_with(Effect {
strong: 0x8000,
weak: 0,
playing: Some(None),
replay_ms: 0,
});
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
ff.last_activity = now - Duration::from_millis(3000);
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
// The game plays the effect again — an FF event refreshes the clock and re-arms playback.
ff.last_activity = now;
ff.effects.get_mut(&0).unwrap().playing = Some(None);
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
}
#[test]
fn disabled_watchdog_never_cuts() {
let now = Instant::now();
let mut ff = ff_with(Effect {
strong: 0x8000,
weak: 0,
playing: Some(None),
replay_ms: 0,
});
ff.last_activity = now - Duration::from_secs(600);
assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0)));
}
}
@@ -441,10 +441,15 @@ impl PadProto for SteamProto {
/// no rich host→client feedback plane (no lightbar / adaptive triggers), so `hidout` stays /// no rich host→client feedback plane (no lightbar / adaptive triggers), so `hidout` stays
/// empty. /// empty.
fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback { fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback {
let rumble = pad.service();
PadFeedback { PadFeedback {
rumble: pad.service(), rumble,
hidout: Vec::new(), hidout: Vec::new(),
game_drove: None, // Rumble-plane liveness: a `0xEB` rumble command this poll. Steam Input drives this
// pad over hidraw (the same abandonment semantics as the Windows Deck backend), so
// the shared abandoned-rumble force-off applies.
rumble_drove: Some(rumble.is_some()),
resync: false,
} }
} }
@@ -558,10 +563,15 @@ impl PadProto for ScProto {
/// registers no FF device for the classic SC, so rumble feedback can only arrive from a /// registers no FF device for the classic SC, so rumble feedback can only arrive from a
/// hidraw client (`0xEB`) — surfaced if it ever does. /// hidraw client (`0xEB`) — surfaced if it ever does.
fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback { fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback {
let rumble = pad.service();
PadFeedback { PadFeedback {
rumble: pad.service(), rumble,
hidout: Vec::new(), hidout: Vec::new(),
game_drove: None, // Rumble-plane liveness: the kernel registers no FF device for the classic SC, so
// rumble only ever arrives from a hidraw writer (`0xEB`) — which is exactly the
// writer class the shared abandoned-rumble force-off exists for.
rumble_drove: Some(rumble.is_some()),
resync: false,
} }
} }
} }
@@ -379,7 +379,10 @@ impl PadProto for TritonProto {
PadFeedback { PadFeedback {
rumble, rumble,
hidout, hidout,
game_drove: None, // Rumble-plane liveness: Steam is a hidraw writer here too, so the shared
// abandoned-rumble force-off applies (the raw 0xCD passthrough plane is unaffected).
rumble_drove: Some(rumble.is_some()),
resync: false,
} }
} }
} }
@@ -310,7 +310,13 @@ impl PadProto for SwitchProProto {
/// answered — call frequently) and surface a game's feedback: HD-rumble amplitude on the /// answered — call frequently) and surface a game's feedback: HD-rumble amplitude on the
/// universal 0xCA plane, player lights on the 0xCD plane. /// universal 0xCA plane, player lights on the 0xCD plane.
fn service(&self, pad: &mut SwitchProPad, idx: u8) -> PadFeedback { fn service(&self, pad: &mut SwitchProPad, idx: u8) -> PadFeedback {
pad.service(idx) let mut fb = pad.service(idx);
// Rumble-plane liveness: hid-nintendo embeds rumble data in every command it sends, so a
// poll that surfaced rumble is the activity signal; a writer that goes silent on a latched
// level is cut by the shared abandoned-rumble force-off (a physical Joy-Con/Pro's
// HD-rumble decays on its own far faster than the idle window).
fb.rumble_drove = Some(fb.rumble.is_some());
fb
} }
} }
@@ -469,10 +469,10 @@ pub struct DsFeedback {
pub hidout: Vec<HidOutput>, pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them. /// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
pub rumble: Option<(u16, u16)>, pub rumble: Option<(u16, u16)>,
/// Whether a fresh output report was seen this poll (set by the backend's section poll, not by /// The driver's output-report ring overflowed this poll — pending reports were DISCARDED and
/// the parser) — the game-activity signal the [`UhidManager`](crate::uhid_manager) /// feedback state is unknown; the [`UhidManager`](crate::uhid_manager) must resync (silence +
/// abandoned-rumble force-off keys on. /// re-armed dedups). Set by the backend's section drain, never by the parser.
pub fresh: bool, pub resync: bool,
} }
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is /// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
@@ -728,6 +728,17 @@ mod tests {
assert!(fb.rumble.is_none()); assert!(fb.rumble.is_none());
assert_eq!(fb.hidout.len(), 1); assert_eq!(fb.hidout.len(), 1);
assert!(matches!(fb.hidout[0], HidOutput::Led { r: 1, .. })); assert!(matches!(fb.hidout[0], HidOutput::Led { r: 1, .. }));
// An explicit stop — vibration flag SET, motors zero — parses as `Some((0, 0))`, never as
// absence: this is what distinguishes "the game stopped the motors" from "this report says
// nothing about rumble", and what `rumble_drove` (the abandoned-rumble watchdog's activity
// signal) keys on.
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[1] = 0x03; // compatible vibration + haptics select
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert_eq!(fb.rumble, Some((0, 0)));
} }
/// The input report's sensor/touch bytes must land exactly where the kernel's /// The input report's sensor/touch bytes must land exactly where the kernel's
@@ -80,10 +80,10 @@ pub struct Ds4Feedback {
pub rumble: Option<(u16, u16)>, pub rumble: Option<(u16, u16)>,
/// Lightbar RGB, if the report carried it (deduped by the manager). /// Lightbar RGB, if the report carried it (deduped by the manager).
pub led: Option<(u8, u8, u8)>, pub led: Option<(u8, u8, u8)>,
/// Whether a fresh output report was seen this poll (set by the backend's section poll, not by /// The driver's output-report ring overflowed this poll — pending reports were DISCARDED and
/// the parser) — the game-activity signal the [`UhidManager`](crate::uhid_manager) /// feedback state is unknown; the [`UhidManager`](crate::uhid_manager) must resync (silence +
/// abandoned-rumble force-off keys on. /// re-armed dedups). Set by the backend's section drain, never by the parser.
pub fresh: bool, pub resync: bool,
} }
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel /// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
@@ -182,5 +182,22 @@ mod tests {
parse_ds4_output(&motor_only, &mut fb2); parse_ds4_output(&motor_only, &mut fb2);
assert!(fb2.rumble.is_some()); assert!(fb2.rumble.is_some());
assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change
// LED-only write: rumble not asserted → stays `None` (this is what `rumble_drove` keys
// on — an LED stream must not read as rumble activity), and an explicit flagged zero
// parses as `Some((0, 0))`, never as absence.
let mut led_only = [0u8; 32];
led_only[0] = 0x05;
led_only[1] = 0x02; // LED only
led_only[6] = 0x11;
let mut fb3 = Ds4Feedback::default();
parse_ds4_output(&led_only, &mut fb3);
assert!(fb3.rumble.is_none());
let mut stop = [0u8; 32];
stop[0] = 0x05;
stop[1] = 0x01; // MOTOR flag, motors zero
let mut fb4 = Ds4Feedback::default();
parse_ds4_output(&stop, &mut fb4);
assert_eq!(fb4.rumble, Some((0, 0)));
} }
} }
+182 -37
View File
@@ -21,12 +21,21 @@ pub struct PadFeedback {
/// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report. /// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report.
pub rumble: Option<(u16, u16)>, pub rumble: Option<(u16, u16)>,
pub hidout: Vec<HidOutput>, pub hidout: Vec<HidOutput>,
/// Whether the game drove this pad's output channel this poll — a fresh output report landed, /// Whether the game drove this pad's RUMBLE plane this poll — at least one output report
/// regardless of whether it changed the rumble level. Drives the abandoned-rumble force-off in /// asserted the vibration fields (valid-flag set, including an explicit zero), not merely any
/// [`UhidManager::pump`] (the same game-ACTIVITY signal the XUSB path keys on). `None` means the /// report. Backends uphold `rumble_drove == Some(true)` ⇔ `rumble.is_some()`; the separate
/// backend does not track activity (every Linux backend): treated as always-active, so the /// field exists so `None` can mean "backend does not track activity" (force-off disabled).
/// force-off never fires there and Linux behaviour is unchanged. /// Keying the abandoned-rumble force-off in [`UhidManager::pump`] on the rumble plane
pub game_drove: Option<bool>, /// specifically — not on general output traffic — is what keeps a title that streams
/// LED/adaptive-trigger reports every frame from feeding the watchdog forever while a latched
/// rumble level never re-asserts (the lost-stop case).
pub rumble_drove: Option<bool>,
/// The backend's driver-channel drain OVERFLOWED and dropped reports this poll — downstream
/// feedback state is unknown. [`UhidManager::pump`] resyncs: it forwards a rumble stop
/// (bounded and imperceptible — a game still rumbling re-asserts the level within a poll or
/// two) and re-arms the rich-plane dedup so the next LED/trigger state re-forwards. Only the
/// Windows ring drains can set it; Linux kernel channels drain losslessly.
pub resync: bool,
} }
/// The per-controller half of a stateful virtual-pad backend — everything [`UhidManager`] cannot /// The per-controller half of a stateful virtual-pad backend — everything [`UhidManager`] cannot
@@ -88,22 +97,48 @@ pub struct UhidManager<B: PadProto> {
hidout_dedup: Vec<HidoutDedup>, hidout_dedup: Vec<HidoutDedup>,
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat). /// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
last_write: Vec<Instant>, last_write: Vec<Instant>,
/// When the game last drove each pad (a backend that reports `game_drove` saw a fresh output /// When the game last drove each pad's rumble plane (a backend that reports `rumble_drove`
/// report). A non-zero `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a /// saw a vibration-asserting output report). A non-zero `last_rumble` older than
/// residual the game abandoned — see [`pump`](Self::pump). /// [`RUMBLE_IDLE_TIMEOUT`] against this is a residual the game abandoned — see
/// [`pump`](Self::pump).
last_active: Vec<Instant>, last_active: Vec<Instant>,
} }
/// How long a latched, non-zero rumble may sit without the game driving the pad before it is forced /// How long a latched, non-zero rumble may sit without the game driving the RUMBLE plane before it
/// off. DualSense/DS4/Deck motors are level-triggered — they run until an output report sets them to /// is forced off. DualSense/DS4/Deck motors are level-triggered — they run until an output report
/// zero — so a game that latches a rumble and then stops writing output reports (a residual left at a /// sets them to zero — so a game that latches a rumble and then stops asserting the vibration
/// menu / loading screen, or a plain forgotten stop) would otherwise drone to the client forever: the /// fields (a residual left at a menu / loading screen, a forgotten stop, or a stop report lost to
/// resend loop in `native.rs` renews the latched level every ~120 ms and the client's envelope never /// the driver section's latest-report coalescing) would otherwise drone to the client forever: the
/// expires. This mirrors the XUSB path's identical guard, and is likewise keyed on game ACTIVITY (any /// resend loop in `native.rs` renews the latched level every ~120 ms and the client's envelope
/// fresh output report, even one that does not change the level), so a rumble the game keeps asserting /// never expires. Keyed on the rumble plane specifically ([`PadFeedback::rumble_drove`]), not on
/// is never cut — only an abandoned residual. Kept above SDL's ~2 s internal rumble resend. /// general output traffic, so an LED/adaptive-trigger stream cannot keep an abandoned rumble
/// alive — the same decay real DualSense-family firmware applies when vibration-flagged reports
/// cease.
///
/// INVARIANT: must stay comfortably above SDL's ~2 s internal rumble resend
/// (`SDL_RUMBLE_RESEND_MS`) — SDL-class writers re-assert a held level on that cadence *because*
/// real firmware decays, and that re-assert is what keeps a legitimately-held long rumble alive
/// here. The XUSB path shares this window via [`rumble_idle_timeout`] (every XUSB write IS a
/// rumble write, so its any-activity keying is already rumble-keyed by construction).
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500); const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
/// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides
/// [`RUMBLE_IDLE_TIMEOUT`]; `0` disables the watchdog entirely (the pre-watchdog behavior, for
/// bisecting field reports). Non-zero overrides are floored just above SDL's ~2 s resend so the
/// hatch cannot cut legitimately-held rumble (see the INVARIANT above). Shared by the UHID/UMDF
/// pump, the Windows XUSB manager, and the Linux uinput FF mixer.
pub(crate) fn rumble_idle_timeout() -> Option<Duration> {
static VAL: std::sync::OnceLock<Option<Duration>> = std::sync::OnceLock::new();
*VAL.get_or_init(|| match std::env::var("PUNKTFUNK_RUMBLE_IDLE_MS") {
Ok(v) => match v.trim().parse::<u64>() {
Ok(0) => None,
Ok(ms) => Some(Duration::from_millis(ms.max(2100))),
Err(_) => Some(RUMBLE_IDLE_TIMEOUT),
},
Err(_) => Some(RUMBLE_IDLE_TIMEOUT),
})
}
impl<B: PadProto + Default> UhidManager<B> { impl<B: PadProto + Default> UhidManager<B> {
pub fn new() -> UhidManager<B> { pub fn new() -> UhidManager<B> {
UhidManager::with_backend(B::default()) UhidManager::with_backend(B::default())
@@ -212,10 +247,27 @@ impl<B: PadProto> UhidManager<B> {
continue; continue;
}; };
let fb = self.backend.service(pad, i as u8); let fb = self.backend.service(pad, i as u8);
// Refresh the game-activity clock when the game drove the pad this poll (a fresh output if fb.resync {
// report, even at an unchanged level). `None` = a backend that does not track activity // The driver's output-report ring overflowed — reports were dropped and the
// (Linux): treated as always-active, so the force-off below never fires there. // feedback state is unknown. Conservatively silence the pad (a game still rumbling
if fb.game_drove != Some(false) { // 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"
);
if self.last_rumble[i] != (0, 0) {
self.last_rumble[i] = (0, 0);
rumble(i as u16, 0, 0);
}
self.hidout_dedup[i] = HidoutDedup::default();
}
// Refresh the game-activity clock when the game drove the pad's RUMBLE plane this poll
// (a vibration-asserting report, even at an unchanged level). LED/trigger-only traffic
// does NOT refresh — see `PadFeedback::rumble_drove`. `None` = a backend that does not
// track activity: treated as always-active, so the force-off below never fires there.
if fb.rumble_drove != Some(false) {
self.last_active[i] = now; self.last_active[i] = now;
} }
if let Some(r) = fb.rumble { if let Some(r) = fb.rumble {
@@ -224,12 +276,20 @@ impl<B: PadProto> UhidManager<B> {
rumble(i as u16, r.0, r.1); rumble(i as u16, r.0, r.1);
} }
} else if self.last_rumble[i] != (0, 0) } else if self.last_rumble[i] != (0, 0)
&& now.duration_since(self.last_active[i]) >= RUMBLE_IDLE_TIMEOUT && rumble_idle_timeout()
.is_some_and(|t| now.duration_since(self.last_active[i]) >= t)
{ {
// A non-zero rumble is latched but the game has not driven the pad for // A non-zero rumble is latched but the game has not driven the rumble plane for
// RUMBLE_IDLE_TIMEOUT — a residual it forgot to stop. Force it off (and forward the // the idle window — a residual it forgot to stop (or whose stop was lost). Force it
// zero) so `native.rs`'s resend loop stops droning it to the client. Mirrors the // off (and forward the zero) so `native.rs`'s resend loop stops droning it to the
// XUSB path's guard; see RUMBLE_IDLE_TIMEOUT. // client. Mirrors the XUSB path's guard; see RUMBLE_IDLE_TIMEOUT.
tracing::info!(
backend = B::LABEL,
index = i,
prev_low = self.last_rumble[i].0,
prev_high = self.last_rumble[i].1,
"rumble: stale residual (game stopped driving the rumble plane) — forcing off"
);
self.last_rumble[i] = (0, 0); self.last_rumble[i] = (0, 0);
rumble(i as u16, 0, 0); rumble(i as u16, 0, 0);
} }
@@ -440,7 +500,8 @@ mod tests {
let rumble = |r| PadFeedback { let rumble = |r| PadFeedback {
rumble: Some(r), rumble: Some(r),
hidout: Vec::new(), hidout: Vec::new(),
game_drove: Some(true), rumble_drove: Some(true),
resync: false,
}; };
*m.backend.feedback.borrow_mut() = vec![rumble((100, 0)), rumble((100, 0)), rumble((7, 7))]; *m.backend.feedback.borrow_mut() = vec![rumble((100, 0)), rumble((100, 0)), rumble((7, 7))];
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
@@ -466,16 +527,20 @@ mod tests {
*m.backend.feedback.borrow_mut() = vec![PadFeedback { *m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((200, 0)), rumble: Some((200, 0)),
hidout: Vec::new(), hidout: Vec::new(),
game_drove: Some(true), rumble_drove: Some(true),
resync: false,
}]; }];
assert_eq!(collect(&mut m), vec![(0, 200, 0)]); assert_eq!(collect(&mut m), vec![(0, 200, 0)]);
// The game stops driving the pad (no fresh output report) but never sent a stop. Before the // The game stops driving the RUMBLE plane — no output report at all, or (equivalently, the
// idle window elapses, nothing is forwarded — the latched level is left asserting. // confirmed stuck-ON case) a stream of LED/adaptive-trigger reports that never assert the
// vibration fields — and never sent a stop. Before the idle window elapses, nothing is
// forwarded — the latched level is left asserting.
let idle = || PadFeedback { let idle = || PadFeedback {
rumble: None, rumble: None,
hidout: Vec::new(), hidout: Vec::new(),
game_drove: Some(false), rumble_drove: Some(false),
resync: false,
}; };
*m.backend.feedback.borrow_mut() = vec![idle()]; *m.backend.feedback.borrow_mut() = vec![idle()];
assert_eq!(collect(&mut m), vec![]); assert_eq!(collect(&mut m), vec![]);
@@ -500,18 +565,29 @@ mod tests {
*m.backend.feedback.borrow_mut() = vec![PadFeedback { *m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((200, 0)), rumble: Some((200, 0)),
hidout: Vec::new(), hidout: Vec::new(),
game_drove: Some(true), rumble_drove: Some(true),
resync: false,
}]; }];
assert_eq!(collect(&mut m), vec![(0, 200, 0)]); assert_eq!(collect(&mut m), vec![(0, 200, 0)]);
// Even with a stale clock, a poll where the game drove the pad (fresh report, unchanged // Even with a stale clock, a poll where the game drove the rumble plane refreshes
// level → rumble None but game_drove Some(true)) refreshes activity, so the held rumble is // activity, so the held rumble is NOT cut. Backends report that as
// NOT cut. // `rumble: Some(level), rumble_drove: Some(true)` (an unchanged level dedups, no forward);
// the manager also honors the bare `rumble_drove: Some(true)` shape defensively.
m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50));
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((200, 0)),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
}];
assert_eq!(collect(&mut m), vec![]); // unchanged level dedups, clock refreshed
m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50)); m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50));
*m.backend.feedback.borrow_mut() = vec![PadFeedback { *m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: None, rumble: None,
hidout: Vec::new(), hidout: Vec::new(),
game_drove: Some(true), rumble_drove: Some(true),
resync: false,
}]; }];
assert_eq!(collect(&mut m), vec![]); assert_eq!(collect(&mut m), vec![]);
} }
@@ -529,7 +605,8 @@ mod tests {
*m.backend.feedback.borrow_mut() = vec![PadFeedback { *m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: None, rumble: None,
hidout: vec![led(10), led(10), led(20)], hidout: vec![led(10), led(10), led(20)],
game_drove: Some(true), rumble_drove: Some(true),
resync: false,
}]; }];
let out = RefCell::new(0u32); let out = RefCell::new(0u32);
m.pump( m.pump(
@@ -569,4 +646,72 @@ mod tests {
m.heartbeat(Duration::from_secs(3600)); m.heartbeat(Duration::from_secs(3600));
assert_eq!(writes(&m), after_frame + 2); assert_eq!(writes(&m), after_frame + 2);
} }
/// 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]
fn resync_forces_stop_and_rearms_dedup() {
let mut m = mgr();
m.handle(&frame(0, 0b1, 0));
let led = |r| HidOutput::Led {
pad: 0,
r,
g: 0,
b: 0,
};
let collect = |m: &mut UhidManager<MockProto>| {
let rumbles = RefCell::new(Vec::new());
let hidouts = RefCell::new(0u32);
m.pump(
|i, lo, hi| rumbles.borrow_mut().push((i, lo, hi)),
|_| *hidouts.borrow_mut() += 1,
);
(rumbles.into_inner(), hidouts.into_inner())
};
// Latch a rumble + an LED.
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((100, 0)),
hidout: vec![led(10)],
rumble_drove: Some(true),
resync: false,
}];
assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1));
// Overflow poll: no reports survived, resync flagged → forced stop, exactly once.
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: None,
hidout: Vec::new(),
rumble_drove: Some(false),
resync: true,
}];
assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0));
// The game re-asserts the SAME rumble + LED state: both must re-forward (the rumble
// because the forced stop reset `last_rumble`, the LED because the dedup was re-armed).
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((100, 0)),
hidout: vec![led(10)],
rumble_drove: Some(true),
resync: false,
}];
assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1));
// A resync with nothing latched forwards no spurious stop.
*m.backend.feedback.borrow_mut() = vec![
PadFeedback {
rumble: Some((0, 0)),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
},
PadFeedback {
rumble: None,
hidout: Vec::new(),
rumble_drove: Some(false),
resync: true,
},
];
assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0)); // the explicit stop
assert_eq!(collect(&mut m), (vec![], 0)); // resync at zero — silent
}
} }
@@ -79,7 +79,9 @@ impl PadProto for DsEdgeWinProto {
PadFeedback { PadFeedback {
rumble: fb.rumble, rumble: fb.rumble,
hidout: fb.hidout, hidout: fb.hidout,
game_drove: Some(fb.fresh), // Rumble-plane liveness, not any-report liveness — see the plain DualSense backend.
rumble_drove: Some(fb.rumble.is_some()),
resync: fb.resync,
} }
} }
} }
@@ -3,8 +3,9 @@
//! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and //! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and
//! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where //! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where
//! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`, //! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`,
//! the Windows backend talks to the UMDF driver over an **unnamed shared DATA section** (256 B `PadShm`: //! the Windows backend talks to the UMDF driver over an **unnamed shared DATA section** (`PadShm`:
//! magic `u32@0`, input report `@8`, output seq `u32@72`, output report `@76`) reached over the //! magic `u32@0`, input report `@8`, output seq `u32@72`, output report `@76`, and since v2.1 the
//! lossless output-report ring `@256` — see [`OutputDrain`]) reached over the
//! **sealed channel** ([`PadChannel`], `design/gamepad-channel-sealing.md`): the host duplicates the //! **sealed channel** ([`PadChannel`], `design/gamepad-channel-sealing.md`): the host duplicates the
//! section handle into the driver's WUDFHost, bootstrapped via the named `Global\pfds-boot-<idx>` //! section handle into the driver's WUDFHost, bootstrapped via the named `Global\pfds-boot-<idx>`
//! mailbox. The driver feeds game `READ_REPORT`s from the input bytes and publishes a game's `0x02` //! mailbox. The driver feeds game `READ_REPORT`s from the input bytes and publishes a game's `0x02`
@@ -56,6 +57,114 @@ pub(super) const OFF_PAD_INDEX: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index); 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_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
pub(super) const DEVTYPE_DUALSENSE_EDGE: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE; 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).
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: 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;
/// 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).
pub(super) struct OutputDrain {
/// Ring cursor: the driver's `ring_head` value up to which we have drained.
tail: u32,
/// Legacy cursor: the last `out_seq` consumed (single-slot path only).
last_out_seq: u32,
/// Latched on first ring activity; the legacy path never re-engages after it (the driver
/// dual-writes both planes, so consuming both would double-parse every report).
ring_live: bool,
}
impl OutputDrain {
pub(super) fn new() -> OutputDrain {
OutputDrain {
tail: 0,
last_out_seq: 0,
ring_live: false,
}
}
/// 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`).
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
// load orders the slot copies below — the same pairing the legacy `out_seq` idiom uses.
let head =
unsafe { (*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire) };
if self.ring_live || head != 0 {
self.ring_live = true;
if head == self.tail {
return false;
}
let pending = head.wrapping_sub(self.tail);
if pending <= OUT_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];
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 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).
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;
// `buf.0` is a live local 64-byte array.
unsafe {
std::ptr::copy_nonoverlapping(base.add(slot + 4), buf.0.as_mut_ptr(), buf.1)
};
}
// SAFETY: as the first `ring_head` load above.
let head2 = unsafe {
(*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire)
};
if head2.wrapping_sub(self.tail) <= OUT_RING_LEN {
for (data, len) in bufs.iter().take(n) {
if *len > 0 {
per_report(&data[..*len]);
}
}
self.tail = head;
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.
// SAFETY: as the first `ring_head` load above.
self.tail =
unsafe { (*(base.add(OFF_RING_HEAD) as *const AtomicU32)).load(Ordering::Acquire) };
return true;
}
// Legacy driver (never wrote the ring): the latest-report slot + seq — exactly the old
// single-slot semantics, coalescing and all; the rumble-keyed idle watchdog is the bound
// there until the driver package is updated.
// SAFETY: `OFF_OUT_SEQ` (== 72) is 4-aligned off the page-aligned base; Acquire pairs with
// the driver's publish-then-bump store order.
let seq = unsafe { (*(base.add(OFF_OUT_SEQ) as *const AtomicU32)).load(Ordering::Acquire) };
if seq != self.last_out_seq {
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(base.add(OFF_OUTPUT), out.as_mut_ptr(), 64) };
per_report(&out);
}
false
}
}
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver /// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel. /// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel.
@@ -72,7 +181,8 @@ pub struct DsWinPad {
attach: super::gamepad_raii::DriverAttach, attach: super::gamepad_raii::DriverAttach,
seq: u8, seq: u8,
ts: u32, ts: u32,
last_out_seq: u32, /// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver).
drain: OutputDrain,
} }
/// The PnP identity for a virtual controller devnode — varies by controller type so the same /// The PnP identity for a virtual controller devnode — varies by controller type so the same
@@ -292,6 +402,8 @@ impl DsWinPad {
unsafe { unsafe {
*base.add(OFF_DEVTYPE) = id.devtype; *base.add(OFF_DEVTYPE) = id.devtype;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); 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; DS_INPUT_REPORT_LEN], { std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
let mut r = [0u8; DS_INPUT_REPORT_LEN]; let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &DsState::neutral(), 0, 0); serialize_state(&mut r, &DsState::neutral(), 0, 0);
@@ -334,7 +446,7 @@ impl DsWinPad {
), ),
seq: 0, seq: 0,
ts: 0, ts: 0,
last_out_seq: 0, drain: OutputDrain::new(),
}) })
} }
@@ -365,10 +477,12 @@ impl DsWinPad {
}; };
} }
/// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a /// Drain the section's output plane; parse every new `0x02` report (rumble / LEDs / triggers)
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything /// into a [`DsFeedback`] for pad `pad`, oldest → newest — so a stop-then-LED burst yields the
/// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the /// stop AND the LED state, never just the latest report. Returns empty feedback if the driver
/// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped). /// hasn't published anything new. Also ticks the sealed-channel delivery and feeds the
/// driver-attach health watcher (the driver's ~125 Hz timer stamps `driver_proto` while it has
/// the section mapped).
pub(super) fn service(&mut self, pad: u8) -> DsFeedback { pub(super) fn service(&mut self, pad: u8) -> DsFeedback {
self.channel.pump(); self.channel.pump();
let mut fb = DsFeedback::default(); let mut fb = DsFeedback::default();
@@ -377,29 +491,10 @@ impl DsWinPad {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
}; };
self.attach.observe(proto); self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes; `OFF_OUT_SEQ` (== 72) is 4-aligned off the let base = self.channel.data_base();
// page-aligned base, so the `AtomicU32` view is valid. The driver bumps `out_seq` AFTER fb.resync = self
// writing the `output` report, so an `Acquire` load here orders the `output` copy below after .drain
// it — a fresh seq guarantees a coherent snapshot of the output bytes on a weakly-ordered core .drain(base, |bytes| parse_ds_output(pad, bytes, &mut fb));
// (ARM64). On x86-TSO it is a plain load.
let seq = unsafe {
(*(self.channel.data_base().add(OFF_OUT_SEQ) as *const AtomicU32))
.load(Ordering::Acquire)
};
if seq != self.last_out_seq {
self.last_out_seq = seq;
fb.fresh = true;
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_ds_output(pad, &out, &mut fb);
}
fb fb
} }
} }
@@ -479,9 +574,14 @@ impl PadProto for DsWinProto {
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback { fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
let fb = pad.service(idx); let fb = pad.service(idx);
PadFeedback { PadFeedback {
// Rumble-plane liveness: only a report that asserted the vibration fields counts
// (`parse_ds_output`'s valid-flag gate) — an LED/adaptive-trigger stream must never
// feed the abandoned-rumble force-off's activity clock (the historical unbounded
// stuck-ON path, now doubly closed by the lossless report ring).
rumble_drove: Some(fb.rumble.is_some()),
rumble: fb.rumble, rumble: fb.rumble,
hidout: fb.hidout, hidout: fb.hidout,
game_drove: Some(fb.fresh), resync: fb.resync,
} }
} }
} }
@@ -561,3 +661,129 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
/// keeps the section fresh (the driver's timer streams whatever's in it) — parity with the UHID /// keeps the section fresh (the driver's timer streams whatever's in it) — parity with the UHID
/// backend's silence heartbeat. /// backend's silence heartbeat.
pub type DualSenseWindowsManager = UhidManager<DsWinProto>; pub type DualSenseWindowsManager = UhidManager<DsWinProto>;
#[cfg(test)]
mod drain_tests {
use super::*;
/// A zeroed, 4-aligned stand-in for the pad section.
fn section() -> Vec<u32> {
vec![0u32; SHM_SIZE / 4]
}
fn base(buf: &mut [u32]) -> *mut u8 {
buf.as_mut_ptr() as *mut u8
}
/// Mimic the v2.1 driver's dual write: legacy slot + seq, then ring slot, then head.
fn publish(buf: &mut [u32], bytes: &[u8]) {
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;
write32(buf, slot, bytes.len() as u32);
let b = bytes_mut(buf);
b[slot + 4..slot + 4 + bytes.len()].copy_from_slice(bytes);
write32(buf, OFF_RING_HEAD, head.wrapping_add(1));
}
/// Mimic an OLD driver: latest-report slot + seq only, no ring.
fn legacy_publish(buf: &mut [u32], bytes: &[u8]) {
let b = bytes_mut(buf);
b[OFF_OUTPUT..OFF_OUTPUT + bytes.len()].copy_from_slice(bytes);
let seq = read32(buf, OFF_OUT_SEQ).wrapping_add(1);
write32(buf, OFF_OUT_SEQ, seq);
}
fn bytes_mut(buf: &mut [u32]) -> &mut [u8] {
// SAFETY: a u32 slice reinterpreted as bytes — same allocation, laxer alignment.
unsafe { std::slice::from_raw_parts_mut(buf.as_mut_ptr() as *mut u8, SHM_SIZE) }
}
fn read32(buf: &mut [u32], off: usize) -> u32 {
u32::from_ne_bytes(bytes_mut(buf)[off..off + 4].try_into().unwrap())
}
fn write32(buf: &mut [u32], off: usize, v: u32) {
bytes_mut(buf)[off..off + 4].copy_from_slice(&v.to_ne_bytes());
}
fn collect(d: &mut OutputDrain, buf: &mut [u32]) -> (Vec<Vec<u8>>, bool) {
let mut got = Vec::new();
let resync = d.drain(base(buf), |b| got.push(b.to_vec()));
(got, resync)
}
/// THE stop-coalesce repro (`design/rumble-root-fix.md` §A): a rumble-stop report followed by
/// an LED-only report inside one poll window must yield BOTH, oldest first — on the legacy
/// single slot the stop was overwritten and gone forever.
#[test]
fn ring_preserves_a_stop_followed_by_an_led_report() {
let mut buf = section();
let mut d = OutputDrain::new();
publish(&mut buf, &[0x02, 0x03, 0, 0xFF, 0xFF]); // rumble on
let (got, resync) = collect(&mut d, &mut buf);
assert!(!resync);
assert_eq!(got, vec![vec![0x02, 0x03, 0, 0xFF, 0xFF]]);
publish(&mut buf, &[0x02, 0x03, 0, 0, 0]); // explicit stop…
publish(&mut buf, &[0x02, 0, 0x04, 0, 0]); // …overwritten in-slot by an LED-only report
let (got, resync) = collect(&mut d, &mut buf);
assert!(!resync);
assert_eq!(
got,
vec![vec![0x02, 0x03, 0, 0, 0], vec![0x02, 0, 0x04, 0, 0]],
"the stop report must survive the burst, oldest first"
);
assert_eq!(collect(&mut d, &mut buf).0.len(), 0); // drained dry
}
#[test]
fn ring_wraps_across_polls() {
let mut buf = section();
let mut d = OutputDrain::new();
for i in 0..6u8 {
publish(&mut buf, &[0x02, i]);
}
assert_eq!(collect(&mut d, &mut buf).0.len(), 6);
for i in 6..12u8 {
// wraps past slot 8
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<_>>(),
vec![6, 7, 8, 9, 10, 11]
);
}
#[test]
fn overflow_discards_and_flags_resync_then_recovers() {
let mut buf = section();
let mut d = OutputDrain::new();
for i in 0..12u8 {
// 12 > OUT_RING_LEN pending — the oldest 4 were overwritten in-ring
publish(&mut buf, &[0x02, i]);
}
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");
publish(&mut buf, &[0x02, 99]);
let (got, resync) = collect(&mut d, &mut buf);
assert!(!resync);
assert_eq!(got, vec![vec![0x02, 99]]);
}
#[test]
fn legacy_driver_still_drains_the_latest_slot() {
let mut buf = section();
let mut d = OutputDrain::new();
legacy_publish(&mut buf, &[0x02, 1]);
legacy_publish(&mut buf, &[0x02, 2]); // coalesced — legacy semantics, latest wins
let (got, resync) = collect(&mut d, &mut buf);
assert!(!resync);
assert_eq!(got.len(), 1);
assert_eq!(&got[0][..2], &[0x02, 2]);
assert_eq!(collect(&mut d, &mut buf).0.len(), 0);
}
}
@@ -9,8 +9,8 @@
use super::dualsense_proto::DsState; use super::dualsense_proto::DsState;
use super::dualsense_windows::{ use super::dualsense_windows::{
create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, create_swdevice, OutputDrain, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE,
OFF_OUTPUT, OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
}; };
use super::dualshock4_proto::{ use super::dualshock4_proto::{
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W, parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
@@ -34,7 +34,8 @@ pub struct Ds4WinPad {
attach: super::gamepad_raii::DriverAttach, attach: super::gamepad_raii::DriverAttach,
counter: u8, counter: u8,
ts: u16, ts: u16,
last_out_seq: u32, /// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver).
drain: OutputDrain,
} }
impl Ds4WinPad { impl Ds4WinPad {
@@ -51,6 +52,8 @@ impl Ds4WinPad {
unsafe { unsafe {
*base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4; *base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); 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; DS4_INPUT_REPORT_LEN], { std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], {
let mut r = [0u8; DS4_INPUT_REPORT_LEN]; let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, &DsState::neutral(), 0, 0); serialize_state(&mut r, &DsState::neutral(), 0, 0);
@@ -91,7 +94,7 @@ impl Ds4WinPad {
), ),
counter: 0, counter: 0,
ts: 0, ts: 0,
last_out_seq: 0, drain: OutputDrain::new(),
}) })
} }
@@ -111,10 +114,11 @@ impl Ds4WinPad {
}; };
} }
/// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a /// Drain the section's output plane; parse every new `0x05` report (rumble / lightbar) into a
/// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. Also /// [`Ds4Feedback`], oldest → newest — so a stop-then-LED burst yields the stop AND the LED
/// ticks the sealed-channel delivery and feeds the driver-attach health watcher (the driver's /// state, never just the latest report. Returns empty feedback if the driver hasn't published
/// ~125 Hz timer stamps `driver_proto`). /// anything new. Also ticks the sealed-channel delivery and feeds the driver-attach health
/// watcher (the driver's ~125 Hz timer stamps `driver_proto`).
fn service(&mut self) -> Ds4Feedback { fn service(&mut self) -> Ds4Feedback {
self.channel.pump(); self.channel.pump();
let mut fb = Ds4Feedback::default(); let mut fb = Ds4Feedback::default();
@@ -123,24 +127,10 @@ impl Ds4WinPad {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
}; };
self.attach.observe(proto); self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes. let base = self.channel.data_base();
let seq = unsafe { fb.resync = self
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32) .drain
}; .drain(base, |bytes| parse_ds4_output(bytes, &mut fb));
if seq != self.last_out_seq {
self.last_out_seq = seq;
fb.fresh = true;
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_ds4_output(&out, &mut fb);
}
fb fb
} }
} }
@@ -229,7 +219,10 @@ impl PadProto for Ds4WinProto {
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b }) .map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
.into_iter() .into_iter()
.collect(), .collect(),
game_drove: Some(fb.fresh), // Rumble-plane liveness, not any-report liveness — see the DualSense backend
// (`parse_ds4_output` gates rumble on flag0 bit0 the same way).
rumble_drove: Some(fb.rumble.is_some()),
resync: fb.resync,
} }
} }
} }
@@ -240,26 +240,27 @@ impl XusbWinPad {
} }
} }
// The abandoned-rumble force-off window is shared with the UHID/UMDF pump —
// `uhid_manager::rumble_idle_timeout()` (`PUNKTFUNK_RUMBLE_IDLE_MS` hatch, default 2.5 s). XInput
// vibration is level-triggered — it persists until the game sets it to zero — so a game that
// latches a rumble and then stops calling `XInputSetState` (a residual left at a menu / loading
// screen, or a plain forgotten stop) would otherwise drone to the client forever (measured: a
// stuck `(0,512)` resent every 500 ms for 5.5 minutes). A real controller stops when the app
// stops driving it; the force-off mirrors that. Unlike the DualSense-family backends there are no
// flag semantics to key on — every `SET_STATE` IS a rumble write — so this path's any-activity
// keying is already rumble-keyed by construction. The shared window stays above SDL's ~2 s
// internal rumble resend so an SDL-driven host game (which re-issues the same level every ~2 s)
// refreshes the activity clock before it fires.
/// All virtual Xbox 360 pads of a session — the Windows analogue of the Linux uinput-xpad manager, /// All virtual Xbox 360 pads of a session — the Windows analogue of the Linux uinput-xpad manager,
/// now backed by the XUSB companion driver. Same method surface (`new`/`handle`/`pump_rumble`) the /// now backed by the XUSB companion driver. Same method surface (`new`/`handle`/`pump_rumble`) the
/// session input thread already drives. /// session input thread already drives.
/// How long a non-zero rumble may stay latched with the game NOT driving the pad (no `SET_STATE`)
/// before it is forced off. XInput vibration is level-triggered — it persists until the game sets
/// it to zero — so a game that latches a rumble and then stops calling `XInputSetState` (a residual
/// left at a menu / loading screen, or a plain forgotten stop) would otherwise drone to the client
/// forever (measured: a stuck `(0,512)` resent every 500 ms for 5.5 minutes). A real controller
/// stops when the app stops driving it; this mirrors that. It is keyed on game ACTIVITY (any
/// `SET_STATE`, even an unchanged one), so a rumble the game keeps asserting is never cut — only an
/// abandoned residual is. Kept above SDL's ~2 s internal rumble resend so an SDL-driven host game
/// (which re-issues the same level every ~2 s) refreshes the activity clock before this fires.
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
pub struct GamepadManager { pub struct GamepadManager {
slots: PadSlots<XusbWinPad>, slots: PadSlots<XusbWinPad>,
last_rumble: Vec<(u8, u8)>, last_rumble: Vec<(u8, u8)>,
/// When the game last drove each pad (bumped `rumble_seq` via `SET_STATE`). A non-zero /// When the game last drove each pad (bumped `rumble_seq` via `SET_STATE`). A non-zero
/// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the /// `last_rumble` older than the shared idle window
/// const's docs. /// ([`crate::uhid_manager::rumble_idle_timeout`]) against this is a stale residual — see the
/// comment above [`GamepadManager`].
last_active: Vec<Instant>, last_active: Vec<Instant>,
} }
@@ -346,11 +347,13 @@ impl GamepadManager {
send(i as u16, large as u16 * 257, small as u16 * 257); send(i as u16, large as u16 * 257, small as u16 * 257);
} }
} else if self.last_rumble[i] != (0, 0) } else if self.last_rumble[i] != (0, 0)
&& self.last_active[i].elapsed() >= RUMBLE_IDLE_TIMEOUT && crate::uhid_manager::rumble_idle_timeout()
.is_some_and(|t| self.last_active[i].elapsed() >= t)
{ {
// A non-zero rumble is latched but the game has not driven the pad for // A non-zero rumble is latched but the game has not driven the pad for the shared
// RUMBLE_IDLE_TIMEOUT — a residual it forgot to stop. Force it off (and forward // idle window — a residual it forgot to stop. Force it off (and forward the zero)
// the zero) so the resend loop stops droning it to the client. See the const docs. // so the resend loop stops droning it to the client. See the comment above
// `GamepadManager`.
tracing::info!( tracing::info!(
index = i, index = i,
prev_low = self.last_rumble[i].0 as u16 * 257, prev_low = self.last_rumble[i].0 as u16 * 257,
@@ -18,8 +18,8 @@
//! kernel's evdev parser; Steam-on-Windows reads the raw reports directly. //! kernel's evdev parser; Steam-on-Windows reads the raw reports directly.
use super::dualsense_windows::{ use super::dualsense_windows::{
create_swdevice, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUTPUT, create_swdevice, OutputDrain, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT,
OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
}; };
use super::gamepad_raii::PadChannel; use super::gamepad_raii::PadChannel;
use super::steam_proto::{ use super::steam_proto::{
@@ -41,7 +41,8 @@ pub struct DeckWinPad {
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach, attach: super::gamepad_raii::DriverAttach,
seq: u32, 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 { impl DeckWinPad {
@@ -56,6 +57,8 @@ impl DeckWinPad {
unsafe { unsafe {
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK; *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); 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( std::ptr::write_unaligned(
base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN], base.add(OFF_INPUT) as *mut [u8; STEAM_REPORT_LEN],
neutral_deck_report(), neutral_deck_report(),
@@ -96,7 +99,7 @@ impl DeckWinPad {
instance_id, instance_id,
), ),
seq: 0, 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 / /// 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 /// `0x8F` haptic pulse — republished by the driver off SET_FEATURE) into feedback. Also
/// ticks the sealed-channel delivery and the driver-attach health watcher. /// 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(); self.channel.pump();
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe { let proto = unsafe {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
}; };
self.attach.observe(proto); self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes. let mut rumble = None;
let seq = unsafe { let base = self.channel.data_base();
std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32) let resync = self.drain.drain(base, |bytes| {
}; // Oldest → newest: the last report that carried rumble wins (0x8F trackpad-haptic
if seq == self.last_out_seq { // reports carry none and pass through without disturbing it).
return None; if let Some(r) = parse_steam_output(bytes).rumble {
} rumble = Some(r);
self.last_out_seq = seq; }
let mut out = [0u8; 64]; });
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. (rumble, resync)
unsafe {
std::ptr::copy_nonoverlapping(
self.channel.data_base().add(OFF_OUTPUT),
out.as_mut_ptr(),
64,
)
};
parse_steam_output(&out).rumble
} }
} }
@@ -216,13 +211,14 @@ impl PadProto for DeckWinProto {
/// Deck has no rich host→client feedback plane (no lightbar / adaptive triggers), so /// Deck has no rich host→client feedback plane (no lightbar / adaptive triggers), so
/// `hidout` stays empty — parity with the Linux backend. /// `hidout` stays empty — parity with the Linux backend.
fn service(&self, pad: &mut DeckWinPad, _idx: u8) -> PadFeedback { 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 // The Deck drain surfaces `Some` exactly when a rumble-carrying report landed, so its
// its presence is the game-activity signal, even when the rumble level is unchanged. // presence is the rumble-plane activity signal, even at an unchanged level.
let rumble = pad.service(); let (rumble, resync) = pad.service();
PadFeedback { PadFeedback {
rumble, rumble,
hidout: Vec::new(), hidout: Vec::new(),
game_drove: Some(rumble.is_some()), rumble_drove: Some(rumble.is_some()),
resync,
} }
} }
} }
@@ -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_PROTO: usize = core::mem::offset_of!(PadShm, driver_proto);
const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(PadShm, driver_heartbeat); const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(PadShm, driver_heartbeat);
const OFF_PAD_INDEX: usize = core::mem::offset_of!(PadShm, pad_index); 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 /// 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`. /// 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-", boot_name_prefix: "Global\\pfds-boot-",
data_magic: SHM_MAGIC, data_magic: SHM_MAGIC,
data_size: SHM_SIZE, 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, pad_index_off: OFF_PAD_INDEX,
log, log,
} }
@@ -375,10 +405,13 @@ fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
if !file_log_enabled() { if !file_log_enabled() {
return None; 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() std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.open("C:\\Users\\Public\\pfds-driver.log") .open(std::env::temp_dir().join("pfds-driver.log"))
.ok() .ok()
.map(std::sync::Mutex::new) .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}"); 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 / // 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() if !bytes.is_empty()
&& let Some(view) = CHANNEL.data() && let Some(view) = CHANNEL.data()
{ {
view.write_bytes(OFF_OUTPUT, &bytes); publish_output(view, &bytes);
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
view.write_u32(OFF_OUT_SEQ, seq);
} }
request.set_information(inlen as u64); request.set_information(inlen as u64);
@@ -662,9 +693,7 @@ fn on_set_feature(request: &Request) -> NTSTATUS {
let mut out = [0u8; 64]; let mut out = [0u8; 64];
let n = src.len().min(63); let n = src.len().min(63);
out[1..1 + n].copy_from_slice(&src[..n]); out[1..1 + n].copy_from_slice(&src[..n]);
view.write_bytes(OFF_OUTPUT, &out); publish_output(view, &out);
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
view.write_u32(OFF_OUT_SEQ, seq);
} }
} }
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)"); dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
@@ -167,6 +167,7 @@ fn channel_cfg() -> ChannelConfig {
boot_name_prefix: "Global\\pfmouse-boot-", boot_name_prefix: "Global\\pfmouse-boot-",
data_magic: SHM_MAGIC, data_magic: SHM_MAGIC,
data_size: SHM_SIZE, data_size: SHM_SIZE,
min_data_size: SHM_SIZE, // layout never grew — no fallback size
pad_index_off: OFF_PAD_INDEX, pad_index_off: OFF_PAD_INDEX,
log, log,
} }
@@ -35,6 +35,13 @@ pub struct ChannelConfig {
pub data_magic: u32, pub data_magic: u32,
/// The DATA section's size (`size_of::<XusbShm>()` / `size_of::<PadShm>()`). /// The DATA section's size (`size_of::<XusbShm>()` / `size_of::<PadShm>()`).
pub data_size: usize, 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. /// `offset_of!(…Shm, pad_index)` in the DATA section.
pub pad_index_off: usize, pub pad_index_off: usize,
/// The driver's logger (each driver tees to its own debug file). /// 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 /// 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. /// deliberately NOT closed: a tampered value could name an unrelated handle in our own table.
fn adopt(&self, cfg: &ChannelConfig, value: u64) { 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 { if value != 0 {
(cfg.log)(&format!( (cfg.log)(&format!(
"[{}] delivered DATA handle 0x{value:x} did not map — ignoring", "[{}] delivered DATA handle 0x{value:x} did not map — ignoring",
@@ -80,6 +80,12 @@ impl MappedView {
Some(MappedView { base, len }) 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 /// 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. /// page-aligned (`MapViewOfFile`), so field alignment reduces to offset alignment.
#[inline] #[inline]
+6 -1
View File
@@ -99,6 +99,7 @@ fn channel_cfg() -> ChannelConfig {
boot_name_prefix: "Global\\pfxusb-boot-", boot_name_prefix: "Global\\pfxusb-boot-",
data_magic: SHM_MAGIC, data_magic: SHM_MAGIC,
data_size: SHM_SIZE, data_size: SHM_SIZE,
min_data_size: SHM_SIZE, // layout never grew — no fallback size
pad_index_off: OFF_PAD_INDEX, pad_index_off: OFF_PAD_INDEX,
log, log,
} }
@@ -125,10 +126,14 @@ fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
if !file_log_enabled() { if !file_log_enabled() {
return None; 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() std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.open("C:\\Users\\Public\\pfxusb-driver.log") .open(std::env::temp_dir().join("pfxusb-driver.log"))
.ok() .ok()
.map(std::sync::Mutex::new) .map(std::sync::Mutex::new)
}) })