From 59fc820226fc7d2f8a823ac651fc6c11b1887de3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 14:14:28 +0200 Subject: [PATCH] perf(inject/host): dedup the DualSense HID-output feedback plane (G17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/inject/linux/dualsense.rs | 14 ++- .../src/inject/proto/dualsense_proto.rs | 109 ++++++++++++++++++ .../src/inject/windows/dualsense_windows.rs | 16 ++- 3 files changed, 134 insertions(+), 5 deletions(-) diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index 1b9a94bc..16c05761 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -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, /// 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, /// 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, @@ -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); + } } } } diff --git a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs index 1487ad7c..9fc189d6 100644 --- a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs @@ -445,10 +445,119 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) { } } +/// 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 +/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report. +/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the +/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by +/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire). +#[derive(Clone, Default)] +pub struct HidoutDedup { + led: Option<(u8, u8, u8)>, + player_leds: Option, + /// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2. + trigger: [Option>; 2], +} + +impl HidoutDedup { + /// Forget all remembered state — call when a pad is created or unplugged so the first feedback + /// after a (re)connect is always forwarded. + pub fn clear(&mut self) { + *self = HidoutDedup::default(); + } + + /// 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 { + match h { + HidOutput::Led { r, g, b, .. } => { + let v = Some((*r, *g, *b)); + if self.led == v { + false + } else { + self.led = v; + true + } + } + HidOutput::PlayerLeds { bits, .. } => { + let v = Some(*bits); + if self.player_leds == v { + false + } else { + self.player_leds = v; + true + } + } + HidOutput::Trigger { which, effect, .. } => { + let slot = (*which as usize).min(1); + if self.trigger[slot].as_deref() == Some(effect.as_slice()) { + false + } else { + self.trigger[slot] = Some(effect.clone()); + true + } + } + // One-shot haptic pulse (Steam voice-coil) — state-less, always fires. + HidOutput::TrackpadHaptic { .. } => true, + } + } +} + #[cfg(test)] mod tests { use super::*; + /// `HidoutDedup` forwards a value once, drops exact repeats, re-forwards a change, tracks the two + /// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`. + #[test] + fn hidout_dedup_forwards_only_changes() { + let mut d = HidoutDedup::default(); + let led = |r| HidOutput::Led { + pad: 0, + r, + g: 0, + 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))); + + // 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 + + // The two adaptive triggers (L2=0, R2=1) are tracked separately. + let trig = |which, byte| HidOutput::Trigger { + pad: 0, + 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 + + // One-shot haptic pulses are never deduped. + let haptic = HidOutput::TrackpadHaptic { + pad: 0, + side: 0, + amplitude: 1, + period: 2, + count: 3, + }; + assert!(d.should_forward(&haptic)); + assert!(d.should_forward(&haptic)); + + // `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))); + } + /// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0 /// on the left half, right pad (surface 2) contact 1 on the right half; y follows the /// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index 1b960c2b..47e356ed 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -18,8 +18,8 @@ //! must already be installed; the installer stages it.) use super::dualsense_proto::{ - parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H, - DS_TOUCH_W, + parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_INPUT_REPORT_LEN, + DS_TOUCH_H, DS_TOUCH_W, }; use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; @@ -341,6 +341,9 @@ pub struct DualSenseWindowsManager { pads: Vec>, state: Vec, 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, last_write: Vec, /// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of /// permanently disabling every pad for the session. @@ -362,6 +365,7 @@ impl DualSenseWindowsManager { 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(), @@ -386,6 +390,7 @@ impl DualSenseWindowsManager { *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 { @@ -466,6 +471,7 @@ impl DualSenseWindowsManager { 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(); } @@ -496,7 +502,11 @@ impl DualSenseWindowsManager { } } 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); + } } } }