feat(host): fold Steam Deck to DualSense on Windows; split-pad touch + pad clicks
A Deck client asking for 'steamdeck' on a Windows host resolved to Xbox360, whose apply_rich is a no-op — gyro and both trackpads silently discarded. Windows already ships a full DualSense backend (UMDF driver, touchpad + motion, wire units 1:1), so pick_gamepad now folds SteamDeck -> DualSense there. The three near-identical DualSense-family appliers (Linux UHID, Windows DualSense, Windows DS4) are hoisted into one shared dualsense_proto::DsState::apply_rich, with two mapping upgrades for Steam dual-pad clients everywhere: * the Deck's two pads SPLIT the single DualSense touchpad — left pad -> contact 0 on the left half, right pad -> contact 1 on the right half — mirroring the physical thumb layout and the split-pad zones games and Steam Input already use (the left pad was previously dropped outright) * TouchpadEx pad clicks now press the touchpad-click button (persisted in DsState::touch_click, OR-ed in by both serializers; previously dropped by every DualSense-family backend, Linux included) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -249,6 +249,7 @@ impl DualSenseManager {
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
@@ -267,50 +268,9 @@ impl DualSenseManager {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// The DualSense touchpad carries two contacts; clamp to a valid slot and keep the
|
||||
// reported contact id consistent with it (the wire `finger` is untrusted).
|
||||
let slot = (finger as usize).min(1);
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = active;
|
||||
t.id = slot as u8;
|
||||
// Normalized 0..=65535 → the touchpad's coordinate range (0..=W-1 / 0..=H-1,
|
||||
// what the kernel advertises as the ABS_MT extents).
|
||||
t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
|
||||
//! same as a real USB DualSense.
|
||||
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
|
||||
// Feature reports the host stack GET_REPORTs during init — without these replies the kernel
|
||||
// (`hid-playstation`) never finishes calibration and creates no input devices. Verbatim from
|
||||
@@ -125,6 +125,12 @@ pub struct DsState {
|
||||
pub gyro: [i16; 3],
|
||||
pub accel: [i16; 3],
|
||||
pub touch: [Touch; 2],
|
||||
/// Per-contact-slot click state from the rich plane (`TouchpadEx.click` — a Steam pad's
|
||||
/// physical pad-click). The serializers OR any held slot into the touchpad-click button
|
||||
/// bit: the DualSense has ONE clickable pad, so either Deck pad clicking counts. Lives
|
||||
/// outside `buttons` because `from_gamepad` rebuilds those from every button frame —
|
||||
/// managers must persist this across rebuilds like `touch`/`gyro`/`accel`.
|
||||
pub touch_click: [bool; 2],
|
||||
}
|
||||
|
||||
impl DsState {
|
||||
@@ -235,6 +241,88 @@ impl DsState {
|
||||
_ => 8,
|
||||
};
|
||||
}
|
||||
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) into this state —
|
||||
/// the ONE mapping shared by every DualSense-family backend (Linux UHID, Windows UMDF,
|
||||
/// DS4 both ways; `touch_w`/`touch_h` are the pad's advertised extents, 1920×1080 vs
|
||||
/// 1920×942).
|
||||
///
|
||||
/// Wire touch coordinates are screen convention (+x right, +y down) — same as the
|
||||
/// DualSense pad's own (top-left origin), so no flip here.
|
||||
///
|
||||
/// A Steam Deck / Steam Controller client sends TWO pads as `TouchpadEx` surfaces; the
|
||||
/// DualSense has one pad with two contact slots, so the surfaces SPLIT it — left pad →
|
||||
/// contact 0 on the left half, right pad → contact 1 on the right half. That mirrors the
|
||||
/// physical thumb layout and lands exactly on the split-pad zones games and Steam Input
|
||||
/// already use for the DS4/DualSense touchpad. Pad clicks ride `touch_click` (the
|
||||
/// serializer ORs them into the touchpad-click button — one clickable pad, either
|
||||
/// surface counts); dropping them was the "Deck pad click does nothing on a DualSense
|
||||
/// host" gap.
|
||||
pub fn apply_rich(&mut self, rich: RichInput, touch_w: u16, touch_h: u16) {
|
||||
// Normalized position → pad extents. The kernel/driver advertises 0..=W-1 / 0..=H-1.
|
||||
let scale = |n: u32, extent: u16| ((n * (extent - 1) as u32) / u16::MAX as u32) as u16;
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// The DualSense touchpad carries two contacts; clamp to a valid slot and keep
|
||||
// the reported contact id consistent with it (the wire `finger` is untrusted).
|
||||
let slot = (finger as usize).min(1);
|
||||
self.touch[slot] = Touch {
|
||||
active,
|
||||
id: slot as u8,
|
||||
x: scale(x as u32, touch_w),
|
||||
y: scale(y as u32, touch_h),
|
||||
};
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
// The wire is already DualSense-convention units (20 LSB/°·s, 10000 LSB/g).
|
||||
self.gyro = gyro;
|
||||
self.accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
click,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32; // signed centre-0 → 0..=65535
|
||||
let half = touch_w / 2;
|
||||
let (slot, tx) = match surface {
|
||||
// The single / DualSense pad: full extent, slot by finger.
|
||||
0 => ((finger as usize).min(1), scale(n(x), touch_w)),
|
||||
// Steam LEFT pad → contact 0 on the left half.
|
||||
1 => (0, scale(n(x), half)),
|
||||
// Steam RIGHT pad (or anything newer) → contact 1 on the right half.
|
||||
_ => (1, half + scale(n(x), half)),
|
||||
};
|
||||
self.touch[slot] = Touch {
|
||||
active: touch,
|
||||
id: slot as u8,
|
||||
x: tx,
|
||||
y: scale(n(y), touch_h),
|
||||
};
|
||||
self.touch_click[slot] = click;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `buttons[2]` as serialized: the live button frame plus the touchpad-click bit when a
|
||||
/// rich-plane pad click is held (see [`DsState::touch_click`]).
|
||||
pub fn buttons2_with_click(&self) -> u8 {
|
||||
let mut b = self.buttons[2];
|
||||
if self.touch_click.iter().any(|c| *c) {
|
||||
b |= btn2::TOUCHPAD;
|
||||
}
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a full input report `0x01` (pure — unit-testable without a transport). Field
|
||||
@@ -253,7 +341,7 @@ pub fn serialize_state(r: &mut [u8; DS_INPUT_REPORT_LEN], st: &DsState, seq: u8,
|
||||
r[7] = seq; // seq_number (struct off 6)
|
||||
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
|
||||
r[9] = st.buttons[1]; // off 8
|
||||
r[10] = st.buttons[2]; // off 9
|
||||
r[10] = st.buttons2_with_click(); // off 9 (PS/touchpad-click/mute; rich pad clicks OR in)
|
||||
r[11] = st.buttons[3]; // off 10
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15
|
||||
@@ -350,6 +438,127 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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
|
||||
/// button bit in the serialized report.
|
||||
#[test]
|
||||
fn steam_surfaces_split_the_touchpad() {
|
||||
let mut s = DsState::neutral();
|
||||
// Left pad, centre → middle of the LEFT half.
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 1,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert!(s.touch[0].active);
|
||||
assert_eq!(s.touch[0].id, 0);
|
||||
assert_eq!(s.touch[0].x, (DS_TOUCH_W / 2 - 1) / 2); // centre of 0..=959
|
||||
assert_eq!(s.touch[0].y, (DS_TOUCH_H - 1) / 2);
|
||||
// Right pad, top-right corner → right edge of the RIGHT half, y = 0 (screen top).
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 2,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: true,
|
||||
x: i16::MAX,
|
||||
y: i16::MIN,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert!(s.touch[1].active);
|
||||
assert_eq!(s.touch[1].id, 1);
|
||||
assert_eq!(s.touch[1].x, DS_TOUCH_W - 1);
|
||||
assert_eq!(s.touch[1].y, 0);
|
||||
// The right pad's click reaches the (single) touchpad-click button bit.
|
||||
assert!(s.touch_click[1]);
|
||||
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, btn2::TOUCHPAD);
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &s, 0, 0);
|
||||
assert_eq!(r[10] & btn2::TOUCHPAD, btn2::TOUCHPAD);
|
||||
// Releasing the click clears the bit again.
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 2,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, 0);
|
||||
}
|
||||
|
||||
/// The single-surface forms keep their full-pad mapping: unsigned `Touchpad` and
|
||||
/// `TouchpadEx` surface 0 both span the whole touchpad, slot picked by finger.
|
||||
#[test]
|
||||
fn single_surface_spans_full_pad() {
|
||||
let mut s = DsState::neutral();
|
||||
s.apply_rich(
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: 0,
|
||||
active: true,
|
||||
x: 65535,
|
||||
y: 65535,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(
|
||||
(s.touch[0].x, s.touch[0].y),
|
||||
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
|
||||
);
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 0,
|
||||
finger: 1,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: i16::MAX,
|
||||
y: i16::MAX,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(
|
||||
(s.touch[1].x, s.touch[1].y),
|
||||
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
|
||||
);
|
||||
// Motion is unit-passthrough (wire is already DualSense convention).
|
||||
s.apply_rich(
|
||||
RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro: [100, -200, 300],
|
||||
accel: [-1000, 2000, -3000],
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(s.gyro, [100, -200, 300]);
|
||||
assert_eq!(s.accel, [-1000, 2000, -3000]);
|
||||
}
|
||||
|
||||
/// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor
|
||||
/// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with
|
||||
/// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2).
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter
|
||||
r[4] = st.ry;
|
||||
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
|
||||
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
|
||||
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
|
||||
r[7] = (st.buttons2_with_click() & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click (incl. rich pad clicks) + report counter
|
||||
r[8] = st.l2; // L2 analog (z)
|
||||
r[9] = st.r2; // R2 analog (rz)
|
||||
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
|
||||
|
||||
@@ -442,6 +442,7 @@ impl DualSenseWindowsManager {
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
@@ -458,46 +459,9 @@ impl DualSenseWindowsManager {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
let slot = (finger as usize).min(1);
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = active;
|
||||
t.id = slot as u8;
|
||||
t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||
self.state[idx].apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ impl DualShock4WindowsManager {
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
@@ -224,46 +225,9 @@ impl DualShock4WindowsManager {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
let slot = (finger as usize).min(1);
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = active;
|
||||
t.id = slot as u8;
|
||||
t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// A Steam right/single pad maps onto the one DS4 touchpad (signed centre-0 →
|
||||
// 0..=65535); surface 1 (the Steam left pad) has no DS4 equivalent.
|
||||
if surface != 1 {
|
||||
let slot = (finger as usize).min(1);
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32;
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = touch;
|
||||
t.id = slot as u8;
|
||||
t.x = (n(x) * (DS4_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
|
||||
t.y = (n(y) * (DS4_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam
|
||||
// dual pads split the one touchpad left/right, pad clicks ride touch_click.
|
||||
self.state[idx].apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
|
||||
@@ -2091,6 +2091,11 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
||||
// Steam Deck: Linux UHID hid-steam. The classic Steam Controller's backend isn't built yet,
|
||||
// so it folds to Xbox360 for now (Windows Steam devices are M7).
|
||||
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
||||
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its
|
||||
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the
|
||||
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
|
||||
// all of that silently.
|
||||
GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
@@ -4180,6 +4185,13 @@ mod tests {
|
||||
assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne);
|
||||
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
|
||||
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
|
||||
|
||||
// Steam Deck: native on Linux; folds to DualSense on Windows (keeps gyro + trackpads
|
||||
// via the UMDF backend — Xbox360 would drop the whole rich plane); Xbox360 elsewhere.
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense);
|
||||
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user