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:
@@ -13,7 +13,7 @@
|
|||||||
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
|
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
|
||||||
|
|
||||||
use super::dualsense_proto::{
|
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_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H,
|
||||||
DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC,
|
DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC,
|
||||||
};
|
};
|
||||||
@@ -178,6 +178,9 @@ pub struct DualSenseManager {
|
|||||||
state: Vec<DsState>,
|
state: Vec<DsState>,
|
||||||
/// Last rumble forwarded per pad, so a report that only changes the LED doesn't re-send it.
|
/// Last rumble forwarded per pad, so a report that only changes the LED doesn't re-send it.
|
||||||
last_rumble: Vec<(u16, u16)>,
|
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
|
/// 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.
|
/// re-emits the current state during input silence so the kernel never sees the device go quiet.
|
||||||
last_write: Vec<Instant>,
|
last_write: Vec<Instant>,
|
||||||
@@ -201,6 +204,7 @@ impl DualSenseManager {
|
|||||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||||
state: vec![DsState::neutral(); MAX_PADS],
|
state: vec![DsState::neutral(); MAX_PADS],
|
||||||
last_rumble: vec![(0, 0); MAX_PADS],
|
last_rumble: vec![(0, 0); MAX_PADS],
|
||||||
|
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
|
||||||
last_write: vec![Instant::now(); MAX_PADS],
|
last_write: vec![Instant::now(); MAX_PADS],
|
||||||
gate: PadGate::new(),
|
gate: PadGate::new(),
|
||||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||||
@@ -226,6 +230,7 @@ impl DualSenseManager {
|
|||||||
*slot = None;
|
*slot = None;
|
||||||
self.state[i] = DsState::neutral();
|
self.state[i] = DsState::neutral();
|
||||||
self.last_rumble[i] = (0, 0);
|
self.last_rumble[i] = (0, 0);
|
||||||
|
self.hidout_dedup[i].clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if f.active_mask & (1 << idx) == 0 {
|
if f.active_mask & (1 << idx) == 0 {
|
||||||
@@ -314,6 +319,7 @@ impl DualSenseManager {
|
|||||||
self.pads[idx] = Some(p);
|
self.pads[idx] = Some(p);
|
||||||
self.state[idx] = DsState::neutral();
|
self.state[idx] = DsState::neutral();
|
||||||
self.last_rumble[idx] = (0, 0);
|
self.last_rumble[idx] = (0, 0);
|
||||||
|
self.hidout_dedup[idx].clear();
|
||||||
self.last_write[idx] = Instant::now();
|
self.last_write[idx] = Instant::now();
|
||||||
self.gate.on_success();
|
self.gate.on_success();
|
||||||
}
|
}
|
||||||
@@ -346,8 +352,12 @@ impl DualSenseManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for h in fb.hidout {
|
for h in fb.hidout {
|
||||||
|
// 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);
|
hidout(h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<u8>,
|
||||||
|
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
||||||
|
trigger: [Option<Vec<u8>>; 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// 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
|
/// 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
|
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
//! must already be installed; the installer stages it.)
|
//! must already be installed; the installer stages it.)
|
||||||
|
|
||||||
use super::dualsense_proto::{
|
use super::dualsense_proto::{
|
||||||
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
|
parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_INPUT_REPORT_LEN,
|
||||||
DS_TOUCH_W,
|
DS_TOUCH_H, DS_TOUCH_W,
|
||||||
};
|
};
|
||||||
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
|
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
|
||||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||||
@@ -341,6 +341,9 @@ pub struct DualSenseWindowsManager {
|
|||||||
pads: Vec<Option<DsWinPad>>,
|
pads: Vec<Option<DsWinPad>>,
|
||||||
state: Vec<DsState>,
|
state: Vec<DsState>,
|
||||||
last_rumble: Vec<(u16, u16)>,
|
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>,
|
||||||
last_write: Vec<Instant>,
|
last_write: Vec<Instant>,
|
||||||
/// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
|
/// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of
|
||||||
/// permanently disabling every pad for the session.
|
/// permanently disabling every pad for the session.
|
||||||
@@ -362,6 +365,7 @@ impl DualSenseWindowsManager {
|
|||||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||||
state: vec![DsState::neutral(); MAX_PADS],
|
state: vec![DsState::neutral(); MAX_PADS],
|
||||||
last_rumble: vec![(0, 0); MAX_PADS],
|
last_rumble: vec![(0, 0); MAX_PADS],
|
||||||
|
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
|
||||||
last_write: vec![Instant::now(); MAX_PADS],
|
last_write: vec![Instant::now(); MAX_PADS],
|
||||||
gate: PadGate::new(),
|
gate: PadGate::new(),
|
||||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||||
@@ -386,6 +390,7 @@ impl DualSenseWindowsManager {
|
|||||||
*slot = None;
|
*slot = None;
|
||||||
self.state[i] = DsState::neutral();
|
self.state[i] = DsState::neutral();
|
||||||
self.last_rumble[i] = (0, 0);
|
self.last_rumble[i] = (0, 0);
|
||||||
|
self.hidout_dedup[i].clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if f.active_mask & (1 << idx) == 0 {
|
if f.active_mask & (1 << idx) == 0 {
|
||||||
@@ -466,6 +471,7 @@ impl DualSenseWindowsManager {
|
|||||||
self.pads[idx] = Some(p);
|
self.pads[idx] = Some(p);
|
||||||
self.state[idx] = DsState::neutral();
|
self.state[idx] = DsState::neutral();
|
||||||
self.last_rumble[idx] = (0, 0);
|
self.last_rumble[idx] = (0, 0);
|
||||||
|
self.hidout_dedup[idx].clear();
|
||||||
self.last_write[idx] = Instant::now();
|
self.last_write[idx] = Instant::now();
|
||||||
self.gate.on_success();
|
self.gate.on_success();
|
||||||
}
|
}
|
||||||
@@ -496,8 +502,12 @@ impl DualSenseWindowsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for h in fb.hidout {
|
for h in fb.hidout {
|
||||||
|
// 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);
|
hidout(h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user