fix(gamepad/host): keep Steam Deck trackpad clicks across a button frame (G2)
SteamControllerManager::handle rebuilds `SteamState.buttons` from the gamepad
frame every tick via from_gamepad, preserving only the rich-plane TOUCH bits —
so a held trackpad CLICK (set on the rich plane by apply_rich, stored in
`buttons`) was wiped on the very next button/stick frame and only flickered
back on the next rich event. This is the exact trap the DualSense backend
already dodges by keeping click in a separate `touch_click` field.
Mirror that: add persisted `lpad_click`/`rpad_click` bools to SteamState set by
apply_rich (instead of pressing LPAD_CLICK/RPAD_CLICK into `buttons`), OR them
into the report's click bits in serialize_deck_state, and preserve them across
the rebuild in handle() like touch/coords/motion. RPAD_CLICK's other owner —
the DualSense touchpad-click wire button via from_gamepad — stays in `buttons`
and is OR'd at serialize, so the two sources release independently (a released
BTN_TOUCHPAD can't strand a rich click, and vice-versa).
Adds a regression test (rich_click_survives_a_buttons_rebuild). All 17
inject::{steam,dualsense,dualshock4}_proto tests pass on Linux (.21).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -415,6 +415,12 @@ impl SteamControllerManager {
|
|||||||
s.gyro = prev.gyro;
|
s.gyro = prev.gyro;
|
||||||
s.accel = prev.accel;
|
s.accel = prev.accel;
|
||||||
s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH);
|
s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH);
|
||||||
|
// Trackpad CLICK arrives on the rich plane too and must survive a button-only frame,
|
||||||
|
// exactly like touch/coords/motion above. It lives in its own fields (not `buttons`,
|
||||||
|
// which `from_gamepad` just rebuilt) so preserving it can't strand the BTN_TOUCHPAD
|
||||||
|
// wire-button's RPAD_CLICK — the two are OR'd only at serialize.
|
||||||
|
s.lpad_click = prev.lpad_click;
|
||||||
|
s.rpad_click = prev.rpad_click;
|
||||||
self.state[idx] = s;
|
self.state[idx] = s;
|
||||||
self.write(idx);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,15 @@ pub struct SteamState {
|
|||||||
/// (with Z/RZ negated) on the separate sensors evdev.
|
/// (with Z/RZ negated) on the separate sensors evdev.
|
||||||
pub accel: [i16; 3],
|
pub accel: [i16; 3],
|
||||||
pub gyro: [i16; 3],
|
pub gyro: [i16; 3],
|
||||||
|
/// Trackpad CLICK from the rich plane ([`apply_rich`]), kept OUTSIDE `buttons` because
|
||||||
|
/// [`SteamControllerManager::handle`](super::super::linux::steam_controller::SteamControllerManager)
|
||||||
|
/// rebuilds `buttons` from the gamepad frame every tick — exactly why DualSense keeps
|
||||||
|
/// `touch_click` separate. Merged into the report's click bits in [`serialize_deck_state`]. The
|
||||||
|
/// DualSense touchpad-click WIRE button still sets `RPAD_CLICK` in `buttons` via
|
||||||
|
/// [`from_gamepad`](Self::from_gamepad); the two sources are OR'd at serialize, so each releases
|
||||||
|
/// independently (a released `BTN_TOUCHPAD` can't strand a rich click, and vice-versa).
|
||||||
|
pub lpad_click: bool,
|
||||||
|
pub rpad_click: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SteamState {
|
impl SteamState {
|
||||||
@@ -273,12 +282,14 @@ impl SteamState {
|
|||||||
// left pad, anything else (0 single / 2 right) = right pad.
|
// left pad, anything else (0 single / 2 right) = right pad.
|
||||||
if surface == 1 {
|
if surface == 1 {
|
||||||
self.press(btn::LPAD_TOUCH, touch);
|
self.press(btn::LPAD_TOUCH, touch);
|
||||||
self.press(btn::LPAD_CLICK, click);
|
// Click lives in its own field, NOT `buttons` — `handle()` rebuilds `buttons`
|
||||||
|
// every gamepad frame and would otherwise wipe a held click (the bug this fixes).
|
||||||
|
self.lpad_click = click;
|
||||||
self.lpad_x = x;
|
self.lpad_x = x;
|
||||||
self.lpad_y = flip_y(y);
|
self.lpad_y = flip_y(y);
|
||||||
} else {
|
} else {
|
||||||
self.press(btn::RPAD_TOUCH, touch);
|
self.press(btn::RPAD_TOUCH, touch);
|
||||||
self.press(btn::RPAD_CLICK, click);
|
self.rpad_click = click;
|
||||||
self.rpad_x = x;
|
self.rpad_x = x;
|
||||||
self.rpad_y = flip_y(y);
|
self.rpad_y = flip_y(y);
|
||||||
}
|
}
|
||||||
@@ -297,7 +308,18 @@ pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq
|
|||||||
r[2] = ID_CONTROLLER_DECK_STATE;
|
r[2] = ID_CONTROLLER_DECK_STATE;
|
||||||
r[3] = 0x3C; // payload length; the kernel ignores it
|
r[3] = 0x3C; // payload length; the kernel ignores it
|
||||||
r[4..8].copy_from_slice(&seq.to_le_bytes());
|
r[4..8].copy_from_slice(&seq.to_le_bytes());
|
||||||
r[8..16].copy_from_slice(&st.buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0)
|
// Rich-plane trackpad clicks live in their own fields (see `SteamState`) so a button-only frame
|
||||||
|
// can't wipe them; merge them into the report's click bits here. RPAD_CLICK may ALSO come from
|
||||||
|
// the DualSense touchpad-click wire button via `from_gamepad` — OR both, so either source lights
|
||||||
|
// it and each releases independently.
|
||||||
|
let mut buttons = st.buttons;
|
||||||
|
if st.lpad_click {
|
||||||
|
buttons |= btn::LPAD_CLICK;
|
||||||
|
}
|
||||||
|
if st.rpad_click {
|
||||||
|
buttons |= btn::RPAD_CLICK;
|
||||||
|
}
|
||||||
|
r[8..16].copy_from_slice(&buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0)
|
||||||
r[16..18].copy_from_slice(&st.lpad_x.to_le_bytes());
|
r[16..18].copy_from_slice(&st.lpad_x.to_le_bytes());
|
||||||
r[18..20].copy_from_slice(&st.lpad_y.to_le_bytes());
|
r[18..20].copy_from_slice(&st.lpad_y.to_le_bytes());
|
||||||
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
|
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
|
||||||
@@ -611,7 +633,9 @@ mod tests {
|
|||||||
pressure: 100,
|
pressure: 100,
|
||||||
});
|
});
|
||||||
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0);
|
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0);
|
||||||
assert_ne!(s.buttons & btn::LPAD_CLICK, 0);
|
// Click now rides its own field (kept OUT of `buttons`, which handle() rebuilds each frame).
|
||||||
|
assert!(s.lpad_click);
|
||||||
|
assert_eq!(s.buttons & btn::LPAD_CLICK, 0);
|
||||||
assert_eq!((s.lpad_x, s.lpad_y), (-5000, -6000));
|
assert_eq!((s.lpad_x, s.lpad_y), (-5000, -6000));
|
||||||
s.apply_rich(RichInput::TouchpadEx {
|
s.apply_rich(RichInput::TouchpadEx {
|
||||||
pad: 0,
|
pad: 0,
|
||||||
@@ -624,6 +648,7 @@ mod tests {
|
|||||||
pressure: 0,
|
pressure: 0,
|
||||||
});
|
});
|
||||||
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
|
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
|
||||||
|
assert!(!s.rpad_click); // click:false → field cleared
|
||||||
assert_eq!((s.rpad_x, s.rpad_y), (7000, 8000));
|
assert_eq!((s.rpad_x, s.rpad_y), (7000, 8000));
|
||||||
|
|
||||||
// The i16 edge: wire y = -32768 (top-most) must clamp, not overflow.
|
// The i16 edge: wire y = -32768 (top-most) must clamp, not overflow.
|
||||||
@@ -640,6 +665,34 @@ mod tests {
|
|||||||
assert_eq!(s.rpad_y, 32767);
|
assert_eq!(s.rpad_y, 32767);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression (G2): a held trackpad click set on the rich plane must survive the per-frame
|
||||||
|
/// `buttons` rebuild that `SteamControllerManager::handle` performs via `from_gamepad`. Before
|
||||||
|
/// the fix, click lived in `buttons` and the rebuild wiped it every gamepad frame.
|
||||||
|
#[test]
|
||||||
|
fn rich_click_survives_a_buttons_rebuild() {
|
||||||
|
let mut held = SteamState::neutral();
|
||||||
|
held.apply_rich(RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface: 1,
|
||||||
|
finger: 0,
|
||||||
|
touch: true,
|
||||||
|
click: true,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
pressure: 0,
|
||||||
|
});
|
||||||
|
assert!(held.lpad_click);
|
||||||
|
// A following button-only frame: from_gamepad rebuilds buttons (dropping the click bit),
|
||||||
|
// then handle() carries the rich fields over — the click must still reach the report.
|
||||||
|
let mut merged = SteamState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
|
||||||
|
assert_eq!(merged.buttons & btn::LPAD_CLICK, 0); // the rebuild alone loses it (the old bug)
|
||||||
|
merged.lpad_click = held.lpad_click; // what handle() now preserves
|
||||||
|
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||||
|
serialize_deck_state(&mut r, &merged, 0);
|
||||||
|
let serialized = u64::from_le_bytes(r[8..16].try_into().unwrap());
|
||||||
|
assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild
|
||||||
|
}
|
||||||
|
|
||||||
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
|
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
|
||||||
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
|
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user