fix(host/windows): force off a game-abandoned rumble on the UMDF virtual pads
The DualSense / DualSense Edge / DualShock 4 / Switch Pro / Steam Deck backends
all run through UhidManager, whose pump() forwarded rumble only on a level
CHANGE and had no idle watchdog. A game that latches a one-shot rumble (a
Stardew axe hit, a DS3 hit) and then stops writing output reports left
last_rumble non-zero; native.rs re-sends the latched level every ~120 ms with a
fresh TTL and the Apple RumbleRenderer refreshes its envelope on every renewal,
so the controller vibrated continuously until a later event happened to write a
report the host parsed as a stop. The XUSB path already guards against this
(RUMBLE_IDLE_TIMEOUT force-off, 19e9828e); that guard was never ported here, so
every UMDF pad regressed for game-abandoned rumble once clients began
negotiating first-class virtual DualSense/DS4/etc. on Windows.
Port the guard into UhidManager::pump, keyed on game ACTIVITY (a fresh output
report, even at an unchanged level) so a rumble the game keeps asserting is
never cut — only an abandoned residual. The activity signal rides a new
PadFeedback.game_drove: Option<bool>; the Windows backends set it from a fresh
out_seq (via a `fresh` flag on DsFeedback/Ds4Feedback; the Deck uses is_some()).
Linux backends leave it None (untracked → always-active → the force-off never
fires there), so their behaviour is unchanged. +2 deterministic unit tests.
Verified: cargo check -p punktfunk-host --tests green on both Windows (.173) and
Linux (home-worker-5); the 10 inject::uhid_manager tests pass on Linux.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -303,6 +303,9 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,6 +392,9 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +378,7 @@ impl PadProto for Ds4LinuxProto {
|
||||
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
||||
.into_iter()
|
||||
.collect(),
|
||||
game_drove: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +444,7 @@ impl PadProto for SteamProto {
|
||||
PadFeedback {
|
||||
rumble: pad.service(),
|
||||
hidout: Vec::new(),
|
||||
game_drove: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,6 +561,7 @@ impl PadProto for ScProto {
|
||||
PadFeedback {
|
||||
rumble: pad.service(),
|
||||
hidout: Vec::new(),
|
||||
game_drove: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +376,11 @@ impl PadProto for TritonProto {
|
||||
data,
|
||||
})
|
||||
.collect();
|
||||
PadFeedback { rumble, hidout }
|
||||
PadFeedback {
|
||||
rumble,
|
||||
hidout,
|
||||
game_drove: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -469,6 +469,10 @@ pub struct DsFeedback {
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Whether a fresh output report was seen this poll (set by the backend's section poll, not by
|
||||
/// the parser) — the game-activity signal the [`UhidManager`](crate::inject::uhid_manager)
|
||||
/// abandoned-rumble force-off keys on.
|
||||
pub fresh: bool,
|
||||
}
|
||||
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
|
||||
|
||||
@@ -80,6 +80,10 @@ pub struct Ds4Feedback {
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Lightbar RGB, if the report carried it (deduped by the manager).
|
||||
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 parser) — the game-activity signal the [`UhidManager`](crate::inject::uhid_manager)
|
||||
/// abandoned-rumble force-off keys on.
|
||||
pub fresh: bool,
|
||||
}
|
||||
|
||||
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
|
||||
|
||||
@@ -21,6 +21,12 @@ pub struct PadFeedback {
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// Whether the game drove this pad's output channel this poll — a fresh output report landed,
|
||||
/// regardless of whether it changed the rumble level. Drives the abandoned-rumble force-off in
|
||||
/// [`UhidManager::pump`] (the same game-ACTIVITY signal the XUSB path keys on). `None` means the
|
||||
/// backend does not track activity (every Linux backend): treated as always-active, so the
|
||||
/// force-off never fires there and Linux behaviour is unchanged.
|
||||
pub game_drove: Option<bool>,
|
||||
}
|
||||
|
||||
/// The per-controller half of a stateful virtual-pad backend — everything [`UhidManager`] cannot
|
||||
@@ -82,8 +88,22 @@ pub struct UhidManager<B: PadProto> {
|
||||
hidout_dedup: Vec<HidoutDedup>,
|
||||
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
|
||||
last_write: Vec<Instant>,
|
||||
/// When the game last drove each pad (a backend that reports `game_drove` saw a fresh output
|
||||
/// report). A non-zero `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a
|
||||
/// residual the game abandoned — see [`pump`](Self::pump).
|
||||
last_active: Vec<Instant>,
|
||||
}
|
||||
|
||||
/// How long a latched, non-zero rumble may sit without the game driving the pad before it is forced
|
||||
/// off. DualSense/DS4/Deck motors are level-triggered — they run until an output report sets them to
|
||||
/// zero — so a game that latches a rumble and then stops writing output reports (a residual left at a
|
||||
/// menu / loading screen, or a plain forgotten stop) would otherwise drone to the client forever: the
|
||||
/// resend loop in `native.rs` renews the latched level every ~120 ms and the client's envelope never
|
||||
/// expires. This mirrors the XUSB path's identical guard, and is likewise keyed on game ACTIVITY (any
|
||||
/// fresh output report, even one that does not change the level), so a rumble the game keeps asserting
|
||||
/// is never cut — only an abandoned residual. Kept above SDL's ~2 s internal rumble resend.
|
||||
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
impl<B: PadProto + Default> UhidManager<B> {
|
||||
pub fn new() -> UhidManager<B> {
|
||||
UhidManager::with_backend(B::default())
|
||||
@@ -106,6 +126,7 @@ impl<B: PadProto> UhidManager<B> {
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
last_active: vec![Instant::now(); MAX_PADS],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,16 +206,32 @@ impl<B: PadProto> UhidManager<B> {
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
let now = Instant::now();
|
||||
for i in 0..MAX_PADS {
|
||||
let Some(pad) = self.slots.get_mut(i) else {
|
||||
continue;
|
||||
};
|
||||
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
|
||||
// report, even at an unchanged level). `None` = a backend that does not track activity
|
||||
// (Linux): treated as always-active, so the force-off below never fires there.
|
||||
if fb.game_drove != Some(false) {
|
||||
self.last_active[i] = now;
|
||||
}
|
||||
if let Some(r) = fb.rumble {
|
||||
if self.last_rumble[i] != r {
|
||||
self.last_rumble[i] = r;
|
||||
rumble(i as u16, r.0, r.1);
|
||||
}
|
||||
} else if self.last_rumble[i] != (0, 0)
|
||||
&& now.duration_since(self.last_active[i]) >= RUMBLE_IDLE_TIMEOUT
|
||||
{
|
||||
// A non-zero rumble is latched but the game has not driven the pad for
|
||||
// RUMBLE_IDLE_TIMEOUT — a residual it forgot to stop. Force it off (and forward the
|
||||
// zero) so `native.rs`'s resend loop stops droning it to the client. Mirrors the
|
||||
// XUSB path's guard; see RUMBLE_IDLE_TIMEOUT.
|
||||
self.last_rumble[i] = (0, 0);
|
||||
rumble(i as u16, 0, 0);
|
||||
}
|
||||
for h in fb.hidout {
|
||||
// Skip rich feedback that repeats the last-forwarded value (a game's output report
|
||||
@@ -231,6 +268,7 @@ impl<B: PadProto> UhidManager<B> {
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.hidout_dedup[idx].clear();
|
||||
self.last_write[idx] = Instant::now();
|
||||
self.last_active[idx] = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +440,7 @@ mod tests {
|
||||
let rumble = |r| PadFeedback {
|
||||
rumble: Some(r),
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(true),
|
||||
};
|
||||
*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
|
||||
@@ -414,6 +453,69 @@ mod tests {
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoned_rumble_is_forced_off_after_idle_timeout() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
let collect = |m: &mut UhidManager<MockProto>| {
|
||||
let out = RefCell::new(Vec::new());
|
||||
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
|
||||
out.into_inner()
|
||||
};
|
||||
// The game latches a non-zero rumble (a fresh report drove the pad).
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
rumble: Some((200, 0)),
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(true),
|
||||
}];
|
||||
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
|
||||
// idle window elapses, nothing is forwarded — the latched level is left asserting.
|
||||
let idle = || PadFeedback {
|
||||
rumble: None,
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(false),
|
||||
};
|
||||
*m.backend.feedback.borrow_mut() = vec![idle()];
|
||||
assert_eq!(collect(&mut m), vec![]);
|
||||
|
||||
// Simulate the game having abandoned the pad past the timeout: the residual is forced off
|
||||
// exactly once, then stays off (no repeated zero spam).
|
||||
m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50));
|
||||
*m.backend.feedback.borrow_mut() = vec![idle(), idle()];
|
||||
assert_eq!(collect(&mut m), vec![(0, 0, 0)]); // forced off
|
||||
assert_eq!(collect(&mut m), vec![]); // already zero — no repeat
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asserted_rumble_survives_idle_timeout_while_game_drives() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
let collect = |m: &mut UhidManager<MockProto>| {
|
||||
let out = RefCell::new(Vec::new());
|
||||
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
|
||||
out.into_inner()
|
||||
};
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
rumble: Some((200, 0)),
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(true),
|
||||
}];
|
||||
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
|
||||
// level → rumble None but game_drove Some(true)) refreshes activity, so the held rumble is
|
||||
// NOT cut.
|
||||
m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50));
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
rumble: None,
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(true),
|
||||
}];
|
||||
assert_eq!(collect(&mut m), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidout_dedup_drops_exact_repeats() {
|
||||
let mut m = mgr();
|
||||
@@ -427,6 +529,7 @@ mod tests {
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
rumble: None,
|
||||
hidout: vec![led(10), led(10), led(20)],
|
||||
game_drove: Some(true),
|
||||
}];
|
||||
let out = RefCell::new(0u32);
|
||||
m.pump(
|
||||
|
||||
@@ -79,6 +79,7 @@ impl PadProto for DsEdgeWinProto {
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
game_drove: Some(fb.fresh),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,6 +382,7 @@ impl DsWinPad {
|
||||
};
|
||||
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 {
|
||||
@@ -474,6 +475,7 @@ impl PadProto for DsWinProto {
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
game_drove: Some(fb.fresh),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ impl Ds4WinPad {
|
||||
};
|
||||
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 {
|
||||
@@ -227,6 +228,7 @@ impl PadProto for Ds4WinProto {
|
||||
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
||||
.into_iter()
|
||||
.collect(),
|
||||
game_drove: Some(fb.fresh),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,9 +215,13 @@ impl PadProto for DeckWinProto {
|
||||
/// Deck has no rich host→client feedback plane (no lightbar / adaptive triggers), so
|
||||
/// `hidout` stays empty — parity with the Linux backend.
|
||||
fn service(&self, pad: &mut DeckWinPad, _idx: u8) -> PadFeedback {
|
||||
// The Deck poll returns `Some` exactly when a fresh output report landed (a seq bump), so
|
||||
// its presence is the game-activity signal, even when the rumble level is unchanged.
|
||||
let rumble = pad.service();
|
||||
PadFeedback {
|
||||
rumble: pad.service(),
|
||||
rumble,
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(rumble.is_some()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user