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