From e6d9454251b967c68f83d228e2ddca9df5390cad Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 8 Jul 2026 10:50:53 +0200 Subject: [PATCH] feat(host): fold Steam Deck to DualSense on Windows; split-pad touch + pad clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/inject/linux/dualsense.rs | 48 +--- .../src/inject/proto/dualsense_proto.rs | 213 +++++++++++++++++- .../src/inject/proto/dualshock4_proto.rs | 2 +- .../src/inject/windows/dualsense_windows.rs | 44 +--- .../src/inject/windows/dualshock4_windows.rs | 44 +--- crates/punktfunk-host/src/punktfunk1.rs | 12 + 6 files changed, 236 insertions(+), 127 deletions(-) diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index 2a798ddf..d71fe414 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -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); } diff --git a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs index c488db7b..955623c7 100644 --- a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs @@ -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). diff --git a/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs b/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs index e3be66d3..f1a80f04 100644 --- a/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualshock4_proto.rs @@ -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) diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index 5e481d6f..cbca4599 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -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); } diff --git a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs index 5cb5b3dc..6f24a994 100644 --- a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs @@ -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); } diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 8c2ff379..9ec2883e 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -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]