diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index 47c2eaa3..cc14af2d 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -117,6 +117,7 @@ class DsCapture( // mid-rumble teardown would leave the motors running with nobody to stop them. // EP0-direct (the reader thread is stopping; the queue would never drain). usb.writeControl(stopReport(m)) + resetRichFeedback(m) } disarmBackstop() usb.stop() @@ -263,6 +264,35 @@ class DsCapture( ), ) + /** + * Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear. + * + * Rumble stops the moment nothing renews it, but these are LATCHED in the controller's + * firmware — they outlive the stream, the app, and being unplugged. Ending a session while a + * game held a weapon's trigger resistance left the physical trigger stiff afterwards, with + * nothing to release it but another game that happens to set one. + * + * EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT + * queue would never drain. Writes are best-effort — the pad may already be gone. + */ + private fun resetRichFeedback(m: DsDevice.Model) { + if (m == DsDevice.Model.DUALSHOCK4) { + // No adaptive triggers or player LEDs on a DS4, and its write is full-state, so + // blacking the lightbar is a single composed report. + ds4Rgb = 0 + usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0)) + return + } + // An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger. + for (which in 0..1) { + usb.writeControl( + DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)), + ) + } + usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0)) + usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0)) + } + /** The report that stops the motors. The DS4's is a full-state write, so it zeroes the * composed motor state and carries the current lightbar rather than blacking it out. */ private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) { diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 3eb51d7b..c470a9cb 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -1003,6 +1003,7 @@ impl Worker { // unplug) must not depend on what SDL does to a rumbling device at close. Errors are // expected for an already-unplugged pad. let _ = self.slots[i].pad.set_rumble(0, 0, 100); + Self::reset_slot_feedback(&mut self.slots[i]); if let Some(c) = self.attached.clone() { Self::flush_slot(&c, &mut self.slots[i]); // Signal the host to tear down this pad's virtual device (native hot-unplug). Sent @@ -1018,6 +1019,35 @@ impl Worker { ); } + /// Hand the physical controller back in a neutral state before its handle closes. + /// + /// Rumble stops on its own the moment nothing renews it, but the rich planes do not: an + /// adaptive-trigger effect and a lightbar colour are LATCHED in the pad's firmware and survive + /// the stream, the app, and being unplugged. Ending a session on a weapon's trigger resistance + /// left the physical trigger stiff on the desktop afterwards, with nothing to clear it but + /// another game. Apple's client already resets on teardown; this is the desktop half. + /// + /// Best-effort throughout: the pad may already be gone (that is one of the ways we get here). + fn reset_slot_feedback(slot: &mut Slot) { + if matches!( + slot.pref, + GamepadPref::DualSense | GamepadPref::DualSenseEdge + ) { + // An all-zero trigger block is mode 0x00 — no effect — which is what releases the + // trigger. Both sides, then the lightbar dark and the player indicator clear. + for which in [0u8, 1] { + let _ = slot + .pad + .send_effect(&Ds5Feedback::trigger_packet(which, &[0u8; 11])); + } + let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(0, 0, 0)); + let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(0)); + } else { + // Anything else with an LED goes dark through SDL, which owns the per-device details. + let _ = slot.pad.set_led(0, 0, 0); + } + } + fn close_all_slots(&mut self) { while !self.slots.is_empty() { self.close_slot_at(0); @@ -2008,3 +2038,46 @@ mod slot_tests { ); } } + +#[cfg(test)] +mod reset_packet_tests { + use super::*; + + /// The exact bytes a teardown sends to hand a DualSense back neutral. The *timing* of this + /// (slot close) needs a live SDL handle and stays untestable, so pin the payloads: a wrong + /// enable flag or a non-zero mode byte would silently leave the effect latched, which is the + /// bug this reset exists to prevent. + #[test] + fn reset_packets_release_the_triggers_and_darken_the_lights() { + // Trigger release: mode 0x00 with no parameters, on the side's own enable bit. + let l = Ds5Feedback::trigger_packet(0, &[0u8; 11]); + assert_eq!(l[0], 0x08, "left-trigger enable bit"); + assert!( + l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11] + .iter() + .all(|&b| b == 0), + "an all-zero block is mode 0x00 = no effect" + ); + let r = Ds5Feedback::trigger_packet(1, &[0u8; 11]); + assert_eq!(r[0], 0x04, "right-trigger enable bit"); + assert!( + r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11] + .iter() + .all(|&b| b == 0) + ); + + // Lightbar off: enable bit set, RGB all zero. The enable bit matters — without it the pad + // ignores the payload and keeps the game's last colour. + let bar = Ds5Feedback::lightbar_packet(0, 0, 0); + assert_eq!(bar[1], 0x04, "lightbar enable bit"); + assert_eq!( + &bar[Ds5Feedback::LED_RGB..Ds5Feedback::LED_RGB + 3], + &[0, 0, 0] + ); + + // Player indicator cleared. + let pl = Ds5Feedback::player_packet(0); + assert_eq!(pl[1], 0x10, "player-LED enable bit"); + assert_eq!(pl[Ds5Feedback::PAD_LIGHTS], 0); + } +} diff --git a/crates/pf-inject/src/inject/hidout_dedup.rs b/crates/pf-inject/src/inject/hidout_dedup.rs index 5e87a80b..e4a3d42c 100644 --- a/crates/pf-inject/src/inject/hidout_dedup.rs +++ b/crates/pf-inject/src/inject/hidout_dedup.rs @@ -5,6 +5,20 @@ //! rich state every report; this forwards only genuine changes (one-shot pulses always fire). use punktfunk_core::quic::HidOutput; +use std::time::{Duration, Instant}; + +/// How often the latched rich state is re-emitted even though nothing changed. +/// +/// The 0xCD plane is deduped AND rides unreliable datagrams, which is a bad pairing: a change is +/// forwarded exactly once, so if that datagram is dropped the game will never produce it again — +/// it keeps re-sending the same value and the dedup swallows every copy. The pad is then left +/// holding the PREVIOUS value: the last weapon's trigger effect, the last lightbar colour, for as +/// long as the game keeps that setting. For a trigger effect that can be the rest of a level. +/// +/// Slow on purpose. This is a repair mechanism, not a transport — at one second a lost update +/// costs a noticeable but bounded wrong-feel window, while the steady-state cost is at most four +/// small datagrams per second per pad, against a rumble plane that already resends at ~120 ms. +const RENEW_EVERY: Duration = Duration::from_millis(1000); /// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report /// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is @@ -18,6 +32,9 @@ pub struct HidoutDedup { player_leds: Option, /// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2. trigger: [Option>; 2], + /// When anything was last put on the wire for this pad. `None` = nothing latched yet, so + /// there is nothing to renew. See [`RENEW_EVERY`]. + last_sent: Option, } impl HidoutDedup { @@ -29,7 +46,53 @@ impl HidoutDedup { /// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a /// one-shot pulse; `false` if it repeats the last-forwarded value for its kind. - pub fn should_forward(&mut self, h: &HidOutput) -> bool { + /// + /// `now` only stamps the renewal clock ([`Self::renewals`]) — forwarding a change resets it, so + /// a plane the game is actively changing never pays for a renewal it does not need. + pub fn should_forward(&mut self, h: &HidOutput, now: Instant) -> bool { + let fwd = self.decide(h); + if fwd { + self.last_sent = Some(now); + } + fwd + } + + /// Re-emit the latched rich state, so one lost datagram cannot strand the pad on the previous + /// value. Returns the reports to send (empty until [`RENEW_EVERY`] has passed since anything + /// last went out); every one is idempotent, so a client that DID receive the original simply + /// re-applies it. + /// + /// One-shots are deliberately absent: replaying a `TrackpadHaptic` pulse would be a *new* + /// pulse, not a repair, and `HidRaw` is already re-sent verbatim by the device's own refresh + /// cadence (see the note in [`Self::decide`]). + pub fn renewals(&mut self, pad: u8, now: Instant) -> Vec { + if self + .last_sent + .is_none_or(|t| now.duration_since(t) < RENEW_EVERY) + { + return Vec::new(); + } + self.last_sent = Some(now); + let mut out = Vec::new(); + if let Some((r, g, b)) = self.led { + out.push(HidOutput::Led { pad, r, g, b }); + } + if let Some(bits) = self.player_leds { + out.push(HidOutput::PlayerLeds { pad, bits }); + } + for (which, effect) in self.trigger.iter().enumerate() { + if let Some(effect) = effect { + out.push(HidOutput::Trigger { + pad, + which: which as u8, + effect: effect.clone(), + }); + } + } + out + } + + fn decide(&mut self, h: &HidOutput) -> bool { match h { HidOutput::Led { r, g, b, .. } => { let v = Some((*r, *g, *b)); @@ -77,6 +140,7 @@ mod tests { /// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`. #[test] fn hidout_dedup_forwards_only_changes() { + let t = Instant::now(); let mut d = HidoutDedup::default(); let led = |r| HidOutput::Led { pad: 0, @@ -85,15 +149,15 @@ mod tests { b: 0, }; // First value forwards; an exact repeat is dropped; a change forwards again. - assert!(d.should_forward(&led(10))); - assert!(!d.should_forward(&led(10))); - assert!(d.should_forward(&led(20))); + assert!(d.should_forward(&led(10), t)); + assert!(!d.should_forward(&led(10), t)); + assert!(d.should_forward(&led(20), t)); // Player LEDs dedup on their own field, independent of the lightbar. let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits }; - assert!(d.should_forward(&pl(0b101))); - assert!(!d.should_forward(&pl(0b101))); - assert!(!d.should_forward(&led(20))); // lightbar still unchanged + assert!(d.should_forward(&pl(0b101), t)); + assert!(!d.should_forward(&pl(0b101), t)); + assert!(!d.should_forward(&led(20), t)); // lightbar still unchanged // The two adaptive triggers (L2=0, R2=1) are tracked separately. let trig = |which, byte| HidOutput::Trigger { @@ -101,10 +165,10 @@ mod tests { which, effect: vec![byte, 0, 0], }; - assert!(d.should_forward(&trig(0, 1))); - assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards - assert!(!d.should_forward(&trig(0, 1))); - assert!(d.should_forward(&trig(0, 2))); // L2 effect changed + assert!(d.should_forward(&trig(0, 1), t)); + assert!(d.should_forward(&trig(1, 1), t)); // same bytes, other side → still forwards + assert!(!d.should_forward(&trig(0, 1), t)); + assert!(d.should_forward(&trig(0, 2), t)); // L2 effect changed // One-shot haptic pulses are never deduped. let haptic = HidOutput::TrackpadHaptic { @@ -114,13 +178,128 @@ mod tests { period: 2, count: 3, }; - assert!(d.should_forward(&haptic)); - assert!(d.should_forward(&haptic)); + assert!(d.should_forward(&haptic, t)); + assert!(d.should_forward(&haptic, t)); // `clear` re-arms every kind. d.clear(); - assert!(d.should_forward(&led(20))); - assert!(d.should_forward(&pl(0b101))); - assert!(d.should_forward(&trig(0, 2))); + assert!(d.should_forward(&led(20), t)); + assert!(d.should_forward(&pl(0b101), t)); + assert!(d.should_forward(&trig(0, 2), t)); + } + + /// A change is forwarded once and then deduped — so if that one datagram is lost, nothing else + /// would ever carry it. The renewal is what repairs that. + #[test] + fn latched_state_is_renewed_so_a_lost_datagram_is_not_permanent() { + let t = Instant::now(); + let mut d = HidoutDedup::default(); + let trig = HidOutput::Trigger { + pad: 3, + which: 1, + effect: vec![0x02, 0x90, 0xA0], + }; + assert!(d.should_forward(&trig, t)); + assert!( + !d.should_forward(&trig, t), + "the game re-sends it; the dedup swallows it" + ); + + // Nothing due yet. + assert!(d.renewals(3, t + Duration::from_millis(999)).is_empty()); + + // Past the window: the latched state goes out again, addressed to the right pad. + let out = d.renewals(3, t + Duration::from_millis(1000)); + assert_eq!(out.len(), 1); + assert!(matches!( + &out[0], + HidOutput::Trigger { pad: 3, which: 1, effect } if effect == &vec![0x02, 0x90, 0xA0] + )); + + // And it keeps repairing on the same cadence, not just once. + assert!(d.renewals(3, t + Duration::from_millis(1500)).is_empty()); + assert_eq!(d.renewals(3, t + Duration::from_millis(2000)).len(), 1); + } + + /// Every latched plane is renewed together, and a plane the game is actively driving does not + /// pay for renewals it does not need (a forward resets the clock). + #[test] + fn renewal_covers_every_latched_plane_and_an_active_plane_defers_it() { + let t = Instant::now(); + let mut d = HidoutDedup::default(); + assert!(d.should_forward( + &HidOutput::Led { + pad: 0, + r: 9, + g: 8, + b: 7 + }, + t + )); + assert!(d.should_forward( + &HidOutput::PlayerLeds { + pad: 0, + bits: 0b100 + }, + t + )); + assert!(d.should_forward( + &HidOutput::Trigger { + pad: 0, + which: 0, + effect: vec![1] + }, + t + )); + assert!(d.should_forward( + &HidOutput::Trigger { + pad: 0, + which: 1, + effect: vec![2] + }, + t + )); + + let out = d.renewals(0, t + Duration::from_millis(1000)); + assert_eq!( + out.len(), + 4, + "lightbar + player LEDs + both triggers, got {out:?}" + ); + + // A genuine change re-stamps the clock, so the next renewal is a full window away. + let later = t + Duration::from_millis(1500); + assert!(d.should_forward( + &HidOutput::Led { + pad: 0, + r: 1, + g: 2, + b: 3 + }, + later + )); + assert!(d.renewals(0, later + Duration::from_millis(999)).is_empty()); + assert!(!d + .renewals(0, later + Duration::from_millis(1000)) + .is_empty()); + } + + /// Nothing latched = nothing to renew; a one-shot pulse must never be replayed as a "repair". + #[test] + fn renewal_is_silent_with_nothing_latched_and_never_replays_a_pulse() { + let t = Instant::now(); + let mut d = HidoutDedup::default(); + assert!(d.renewals(0, t + Duration::from_secs(60)).is_empty()); + + let pulse = HidOutput::TrackpadHaptic { + pad: 0, + side: 0, + amplitude: 1, + period: 2, + count: 3, + }; + assert!(d.should_forward(&pulse, t)); + // The pulse stamped the clock but latched no state, so the renewal has nothing to repeat. + assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty()); } } diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index 8f91f0a9..3e8b97ef 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -338,10 +338,17 @@ impl UhidManager { for h in fb.hidout { // Skip rich feedback that repeats the last-forwarded value (a game's output report // re-sends unchanged lightbar/LED/trigger state alongside every rumble update). - if self.hidout_dedup[i].should_forward(&h) { + if self.hidout_dedup[i].should_forward(&h, now) { hidout(h); } } + // Re-assert the latched rich state on a slow cadence. Deduping a plane that rides + // unreliable datagrams means a dropped update is never re-derived from the game — it + // keeps sending the same value and the dedup eats every copy — so without this one + // lost datagram leaves the pad on the previous weapon's trigger effect indefinitely. + for h in self.hidout_dedup[i].renewals(i as u8, now) { + hidout(h); + } } }