diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index 57881b93..de22f0e3 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -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, } } } diff --git a/crates/punktfunk-host/src/inject/linux/dualshock4.rs b/crates/punktfunk-host/src/inject/linux/dualshock4.rs index d24b7cbe..adf31c8b 100644 --- a/crates/punktfunk-host/src/inject/linux/dualshock4.rs +++ b/crates/punktfunk-host/src/inject/linux/dualshock4.rs @@ -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, } } } diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller.rs b/crates/punktfunk-host/src/inject/linux/steam_controller.rs index ab645fad..0c7ac4ad 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller.rs @@ -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, } } } diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller2.rs b/crates/punktfunk-host/src/inject/linux/steam_controller2.rs index 3b40c358..cbdd983f 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller2.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller2.rs @@ -376,7 +376,11 @@ impl PadProto for TritonProto { data, }) .collect(); - PadFeedback { rumble, hidout } + PadFeedback { + rumble, + hidout, + game_drove: None, + } } } diff --git a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs index 6117475d..ef143cd9 100644 --- a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs @@ -469,6 +469,10 @@ pub struct DsFeedback { pub hidout: Vec, /// `(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 diff --git a/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs b/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs index 152e98e8..2f2747f5 100644 --- a/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs @@ -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 diff --git a/crates/punktfunk-host/src/inject/uhid_manager.rs b/crates/punktfunk-host/src/inject/uhid_manager.rs index db741bd7..29e7a05a 100644 --- a/crates/punktfunk-host/src/inject/uhid_manager.rs +++ b/crates/punktfunk-host/src/inject/uhid_manager.rs @@ -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, + /// 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, } /// The per-controller half of a stateful virtual-pad backend — everything [`UhidManager`] cannot @@ -82,8 +88,22 @@ pub struct UhidManager { hidout_dedup: Vec, /// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat). last_write: Vec, + /// 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, } +/// 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 UhidManager { pub fn new() -> UhidManager { UhidManager::with_backend(B::default()) @@ -106,6 +126,7 @@ impl UhidManager { 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 UhidManager { 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 UhidManager { 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| { + 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| { + 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( diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_edge_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_edge_windows.rs index a4d0e914..b206b550 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_edge_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_edge_windows.rs @@ -79,6 +79,7 @@ impl PadProto for DsEdgeWinProto { PadFeedback { rumble: fb.rumble, hidout: fb.hidout, + game_drove: Some(fb.fresh), } } } diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index 7d01ec5b..3ed0f33b 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -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), } } } diff --git a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs index 110d53e0..29252838 100644 --- a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs @@ -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), } } } diff --git a/crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs b/crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs index 682f6e91..134f611b 100644 --- a/crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs @@ -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()), } } }