fix(feedback): the pad stops keeping a game's trigger effect after the stream ends #44
@@ -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) {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user