perf(inject/host): dedup the DualSense HID-output feedback plane (G17)

A game's DualSense output report bundles rumble + lightbar + player-LEDs
+ adaptive-triggers into one report, so a pad that is merely rumbling
re-sends its unchanged lightbar / LED / trigger state on every output
report. The managers already dedup rumble, but forwarded every rich
`HidOutput` event verbatim — flooding the 0xCD feedback plane to the
client during continuous rumble.

Add a shared `HidoutDedup` (dualsense_proto, used by both the Linux UHID
and Windows UMDF managers) that forwards Led/PlayerLeds/Trigger only on a
value change (per side for the two triggers) and always forwards one-shot
TrackpadHaptic pulses — mirroring the rumble dedup two lines above and the
DS4 backend's lightbar dedup. Reset per pad on create/unplug.

Verified on Linux .21 (clippy -D warnings clean, new HidoutDedup unit
test + full suite green); Windows .173 with the rest of Phase 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:14:28 +02:00
parent d611645ffc
commit 59fc820226
3 changed files with 134 additions and 5 deletions
@@ -13,7 +13,7 @@
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION,
parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_FEATURE_CALIBRATION,
DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H,
DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC,
};
@@ -178,6 +178,9 @@ pub struct DualSenseManager {
state: Vec<DsState>,
/// Last rumble forwarded per pad, so a report that only changes the LED doesn't re-send it.
last_rumble: Vec<(u16, u16)>,
/// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an
/// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback.
hidout_dedup: Vec<HidoutDedup>,
/// When each pad last wrote an input report — drives [`DualSenseManager::heartbeat`], which
/// re-emits the current state during input silence so the kernel never sees the device go quiet.
last_write: Vec<Instant>,
@@ -201,6 +204,7 @@ impl DualSenseManager {
pads: (0..MAX_PADS).map(|_| None).collect(),
state: vec![DsState::neutral(); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
gate: PadGate::new(),
remap: crate::inject::steam_remap::RemapConfig::from_env(),
@@ -226,6 +230,7 @@ impl DualSenseManager {
*slot = None;
self.state[i] = DsState::neutral();
self.last_rumble[i] = (0, 0);
self.hidout_dedup[i].clear();
}
}
if f.active_mask & (1 << idx) == 0 {
@@ -314,6 +319,7 @@ impl DualSenseManager {
self.pads[idx] = Some(p);
self.state[idx] = DsState::neutral();
self.last_rumble[idx] = (0, 0);
self.hidout_dedup[idx].clear();
self.last_write[idx] = Instant::now();
self.gate.on_success();
}
@@ -346,7 +352,11 @@ impl DualSenseManager {
}
}
for h in fb.hidout {
hidout(h);
// Skip rich feedback that repeats the last-forwarded value (the game's output report
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
if self.hidout_dedup[i].should_forward(&h) {
hidout(h);
}
}
}
}