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:
2026-07-08 10:50:53 +02:00
parent 9ca672d434
commit e6d9454251
6 changed files with 236 additions and 127 deletions
@@ -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);
}
+12
View File
@@ -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]