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
+16 -6
View File
@@ -303,9 +303,14 @@ impl PadProto for DsLinuxProto {
PadFeedback {
rumble: fb.rumble,
hidout: fb.hidout,
// Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does
// not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`).
game_drove: None,
// Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games
// going through hid-playstation get their stops surfaced reliably, but Steam Input
// 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 {
rumble: fb.rumble,
hidout: fb.hidout,
// Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does
// not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`).
game_drove: None,
// Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games
// going through hid-playstation get their stops surfaced reliably, but Steam Input
// 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 })
.into_iter()
.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 std::collections::HashMap;
use std::os::fd::{AsRawFd, OwnedFd};
use std::time::Instant;
use std::time::{Duration, Instant};
// ioctls (x86_64).
const UI_DEV_CREATE: libc::c_ulong = 0x5501;
@@ -263,14 +263,80 @@ struct Effect {
replay_ms: u16,
}
/// One virtual X-Box-360 pad backed by a uinput device.
pub struct VirtualPad {
fd: OwnedFd,
/// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy
/// (finite-replay expiry + the abandoned-INFINITE-effect force-off), split from [`VirtualPad`] so
/// the policy is pure and unit-testable without a live uinput fd.
struct FfState {
effects: HashMap<i16, Effect>,
next_effect_id: i16,
gain: u32,
/// Last `(low, high)` reported, to dedup.
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 {
@@ -369,10 +435,7 @@ impl VirtualPad {
Ok(VirtualPad {
fd,
effects: HashMap::new(),
next_effect_id: 0,
gain: 0xFFFF,
last_mix: (0, 0),
ff: FfState::new(),
})
}
@@ -460,6 +523,7 @@ impl VirtualPad {
unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) };
match (ev.type_, ev.code) {
(EV_UINPUT, UI_FF_UPLOAD) => {
self.ff.note_activity();
// 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
// (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() {
let mut e = up.effect;
if e.id == -1 {
e.id = self.next_effect_id;
self.next_effect_id = self.next_effect_id.wrapping_add(1);
e.id = self.ff.next_effect_id;
self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1);
}
if e.type_ == FF_RUMBLE {
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 slot = self.effects.entry(e.id).or_insert(Effect {
let slot = self.ff.effects.entry(e.id).or_insert(Effect {
strong: 0,
weak: 0,
playing: None,
@@ -491,20 +555,25 @@ impl VirtualPad {
}
}
(EV_UINPUT, UI_FF_ERASE) => {
self.ff.note_activity();
// 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
// initialized value — `request_id` is set below and `effect_id` filled by the ioctl.
let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
er.request_id = ev.value as u32;
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;
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) => {
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 {
Some((e.replay_ms > 0).then(|| {
Instant::now()
@@ -519,26 +588,8 @@ impl VirtualPad {
}
}
// Mix: sum playing effects (expiring finished ones), scale by gain.
let now = Instant::now();
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)
})
self.ff
.mix(Instant::now(), crate::uhid_manager::rumble_idle_timeout())
}
}
@@ -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
/// empty.
fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback {
let rumble = pad.service();
PadFeedback {
rumble: pad.service(),
rumble,
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
/// hidraw client (`0xEB`) — surfaced if it ever does.
fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback {
let rumble = pad.service();
PadFeedback {
rumble: pad.service(),
rumble,
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 {
rumble,
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
/// universal 0xCA plane, player lights on the 0xCD plane.
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
}
}