fix(feedback): the pad stops keeping a game's trigger effect after the stream ends #44

Merged
enricobuehler merged 2 commits from worktree-haptics-m9-richfb into main 2026-08-04 21:07:37 +00:00
4 changed files with 308 additions and 17 deletions
@@ -119,6 +119,9 @@ class DsCapture(
// Nothing can retry after this point, so a failure is worth saying out loud: it is
// the difference between a quiet pad and one that buzzes until it is unplugged.
if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written")
// Motors silenced above; this hands back the lightbar, player LEDs and adaptive
// triggers the game was holding, which outlive the link just as stubbornly.
resetRichFeedback(m)
}
disarmBackstop()
usb.stop()
@@ -275,6 +278,35 @@ class DsCapture(
OutReportQueue.KEY_RUMBLE,
)
/**
* 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) {
+73
View File
@@ -1210,6 +1210,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
@@ -1225,6 +1226,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);
@@ -2474,6 +2504,49 @@ 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);
}
}
#[cfg(test)]
mod player_led_tests {
use super::*;
+195 -16
View File
@@ -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<u8>,
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
trigger: [Option<Vec<u8>>; 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<Instant>,
}
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<HidOutput> {
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());
}
}
+8 -1
View File
@@ -354,10 +354,17 @@ impl<B: PadProto> UhidManager<B> {
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);
}
}
}