From 8c854e0a19df8000a37054b3f1ef46cb46166e4a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 11:15:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(gamepad):=20classic=20Steam=20Controller?= =?UTF-8?q?=20backend=20=E2=80=94=20Linux=20UHID=20via=20hid-steam=20(N3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reserved GamepadPref::SteamController = 5 slot goes live: the same hid-steam driver under the wired-SC identity (28DE:1102, ID_CONTROLLER_STATE), UHID-only in v1 (no captured SC USB interface layout, so no Steam-Input promotion — the pre-usbip Deck state; acceptable for discontinued hardware). Layout pinned against the kernel's ID_CONTROLLER_STATE table: 24-bit buttons at 8..11 (low bits shared with the Deck; grips at 9.7/10.0 = the Deck's L5/R5 positions; right-pad click 10.2; joystick click 10.6), u8 triggers at 11/12, the joystick/left-pad MULTIPLEX at 16..20 (a left-pad contact shadows the stick, like real hardware's lpad_touched flag), right pad at 20..24. Mapping: wire left stick -> SC stick; wire right stick -> right-pad coords + touched bit (the SC's camera surface — the second-stick loss is inherent); PADDLE1/2 -> the two grips (natively, masked out of the fold input); PADDLE3/4 + MISC1 -> the remap policy. The SC parser has NO gamepad_mode gate, so no mode-entry pulse. SteamDeckPad grew a SteamModel (open_model); ScProto/SteamCtrlManager; pick_gamepad flips SteamController -> itself on Linux (replacing the Xbox360 fold); SDL picker splits Valve PIDs (Deck 1205 stays SteamDeck, SC 1102/1142 now declare SteamController). Verified: .21 clippy -D warnings + 304/0 tests + on-box UHID smoke (hid-steam binds 1102, BTN_A + right-pad ABS_RX land on evdev, no mode pulse); .133 clippy -D warnings green. Co-Authored-By: Claude Fable 5 --- crates/pf-client-core/src/gamepad.rs | 10 +- crates/punktfunk-core/src/abi.rs | 4 +- crates/punktfunk-core/src/config.rs | 6 +- .../src/inject/linux/steam_controller.rs | 196 ++++++++++++++++-- .../src/inject/proto/steam_proto.rs | 189 +++++++++++++++++ crates/punktfunk-host/src/punktfunk1.rs | 37 +++- 6 files changed, 417 insertions(+), 25 deletions(-) diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index d49a9222..a8e9c643 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -268,6 +268,7 @@ impl PadInfo { GamepadPref::DualShock4 => "DualShock 4", GamepadPref::XboxOne => "Xbox One", GamepadPref::SteamDeck => "Steam Deck", + GamepadPref::SteamController => "Steam Controller", GamepadPref::SwitchPro => "Switch Pro", _ => "", } @@ -783,11 +784,14 @@ impl Worker { self.subsystem.product_for_id(jid).unwrap_or(0), ); // There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by - // VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual - // hid-steam pad with the back grips + dual trackpads and the right glyph identity. - if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) { + // VID/PID — the host then builds the matching virtual hid-steam pad (grips + trackpads + + // the right glyph identity): Deck 0x1205; classic SC wired 0x1102 / dongle 0x1142. + if vid == 0x28DE && pid == 0x1205 { pref = GamepadPref::SteamDeck; } + if vid == 0x28DE && matches!(pid, 0x1102 | 0x1142) { + pref = GamepadPref::SteamController; + } // The DualSense Edge has no distinct SDL gamepad type either (it reports PS5) — detect by // VID/PID so the host builds the virtual Edge and this pad's back paddles land on native // slots instead of the fold/drop policy. diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 243d76a7..4ac86e0a 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -882,8 +882,8 @@ pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3; /// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux /// hosts); otherwise the host falls back to X-Box 360. pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4; -/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro, -/// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands. +/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual +/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360. pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5; /// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the /// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 9d1680d0..673f69f0 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -156,9 +156,9 @@ pub enum GamepadPref { /// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like /// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host. DualShock4, - /// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — dual trackpads, gyro, - /// two grip paddles, trackpad-only haptics. Needs Linux UHID. *(Reserved; its backend is not yet - /// built — currently folds to `Xbox360`; the Deck identity below is the implemented one.)* + /// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — one stick + dual + /// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact + /// shadows the stick (hardware multiplex). Needs Linux UHID. SteamController, /// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl. /// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller.rs b/crates/punktfunk-host/src/inject/linux/steam_controller.rs index 0a42a448..ab645fad 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller.rs @@ -20,8 +20,8 @@ //! command (the DualSense backend only services GET_REPORT + OUTPUT). use super::steam_proto::{ - btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT, - STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR, + btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state, + serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR, }; use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{Context, Result}; @@ -77,10 +77,12 @@ fn try_clear_lizard_mode() { } } -/// A virtual Steam Deck backed by `/dev/uhid`. Dropping it destroys the device (the kernel tears -/// down the bound `hid-steam` interface + both evdevs). +/// A virtual Steam Deck **or classic Steam Controller** backed by `/dev/uhid` (same driver, two +/// identities/report layouts — see [`SteamModel`]). Dropping it destroys the device (the kernel +/// tears down the bound `hid-steam` interface + its evdevs). pub struct SteamDeckPad { fd: File, + model: SteamModel, seq: u32, created: Instant, /// When `b9.6` started being continuously held in our OUTPUT (anti-toggle guard); `None` = not. @@ -89,7 +91,16 @@ pub struct SteamDeckPad { impl SteamDeckPad { pub fn open(index: u8) -> Result { - try_clear_lizard_mode(); + SteamDeckPad::open_model(index, SteamModel::Deck) + } + + /// Open under a specific Steam identity. The classic Controller's `ID_CONTROLLER_STATE` path + /// has NO `gamepad_mode` gate in the kernel (only the Deck's parser early-returns under + /// lizard mode), so the SC skips the whole mode-entry machinery. + pub fn open_model(index: u8, model: SteamModel) -> Result { + if model == SteamModel::Deck { + try_clear_lizard_mode(); + } let fd = OpenOptions::new() .read(true) .write(true) @@ -100,24 +111,29 @@ impl SteamDeckPad { })?; let mut pad = SteamDeckPad { fd, + model, seq: 0, created: Instant::now(), menu_hold_since: None, }; - pad.send_create2(index).context("UHID_CREATE2 Steam Deck")?; + pad.send_create2(index).context("UHID_CREATE2 Steam pad")?; Ok(pad) } fn send_create2(&mut self, index: u8) -> Result<()> { + let (name, phys, uniq) = match self.model { + SteamModel::Deck => ("Steam Deck", "steam", "steam"), + SteamModel::Controller => ("Steam Controller", "steamctrl", "steamctrl"), + }; let mut ev = [0u8; UHID_EVENT_SIZE]; ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); - put_cstr(&mut ev, 4, 128, &format!("Punktfunk Steam Deck {index}")); // name[128] - put_cstr(&mut ev, 132, 64, &format!("punktfunk/steam/{index}")); // phys[64] - put_cstr(&mut ev, 196, 64, &format!("punktfunk-steam-{index}")); // uniq[64] + put_cstr(&mut ev, 4, 128, &format!("Punktfunk {name} {index}")); // name[128] + put_cstr(&mut ev, 132, 64, &format!("punktfunk/{phys}/{index}")); // phys[64] + put_cstr(&mut ev, 196, 64, &format!("punktfunk-{uniq}-{index}")); // uniq[64] ev[260..262].copy_from_slice(&(STEAMDECK_RDESC.len() as u16).to_ne_bytes()); // rd_size ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus ev[264..268].copy_from_slice(&STEAM_VENDOR.to_ne_bytes()); - ev[268..272].copy_from_slice(&STEAMDECK_PRODUCT.to_ne_bytes()); + ev[268..272].copy_from_slice(&self.model.product().to_ne_bytes()); ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country ev[280..280 + STEAMDECK_RDESC.len()].copy_from_slice(STEAMDECK_RDESC); @@ -125,13 +141,19 @@ impl SteamDeckPad { Ok(()) } - /// Serialize `st` (with the gamepad-mode entry overlay + anti-toggle guard applied) and write it. + /// Serialize `st` under this pad's model (Deck reports get the gamepad-mode entry overlay + + /// anti-toggle guard applied) and write it. pub fn write_state(&mut self, st: &SteamState) -> Result<()> { self.seq = self.seq.wrapping_add(1); - let mut s = *st; - s.buttons = self.effective_buttons(st.buttons); let mut r = [0u8; STEAM_REPORT_LEN]; - serialize_deck_state(&mut r, &s, self.seq); + match self.model { + SteamModel::Deck => { + let mut s = *st; + s.buttons = self.effective_buttons(st.buttons); + serialize_deck_state(&mut r, &s, self.seq); + } + SteamModel::Controller => serialize_sc_state(&mut r, st, self.seq), + } let mut ev = [0u8; UHID_EVENT_SIZE]; ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes()); @@ -142,8 +164,9 @@ impl SteamDeckPad { } /// True while still pulsing the mode-switch at creation (the caller force-writes during this). + /// Deck-only — the SC's kernel parser has no mode gate. fn in_mode_entry(&self) -> bool { - self.created.elapsed() < MODE_ENTER + self.model == SteamModel::Deck && self.created.elapsed() < MODE_ENTER } /// During mode entry, force `b9.6` held (override). Afterwards, pass the real buttons through but @@ -438,6 +461,113 @@ impl PadProto for SteamProto { /// the pad alive (and drives the mode-entry pulse) — all from the shared [`UhidManager`]. pub type SteamControllerManager = UhidManager; +/// The **classic Steam Controller** half of the shared stateful manager: the same `hid-steam` +/// driver under the wired-SC identity (`28DE:1102`, `ID_CONTROLLER_STATE`), UHID-only in v1 — +/// the usbip/gadget transports present the Deck's captured 3-interface USB device, and the SC's +/// wired interface layout hasn't been captured, so there is no Steam-Input promotion (the same +/// degraded-but-working state the Deck had pre-usbip; acceptable for discontinued hardware). +/// +/// Deltas vs the Deck (see [`sc_from_gamepad`]/[`serialize_sc_state`]): one stick + two pads + +/// two grips — the wire right stick drives the right pad, a left-pad contact shadows the left +/// stick, wire PADDLE1/2 land on the two grips (3/4 fold via the remap policy), and the kernel +/// registers neither FF rumble nor a sensors evdev for this model (feedback stays empty). +pub struct ScProto { + /// Fallback policy for the wire paddles beyond the SC's two grips (PADDLE3/4). + remap: crate::inject::steam_remap::RemapConfig, +} + +impl Default for ScProto { + fn default() -> ScProto { + ScProto { + remap: crate::inject::steam_remap::RemapConfig::from_env(), + } + } +} + +impl PadProto for ScProto { + type Pad = SteamDeckPad; + type State = SteamState; + const LABEL: &'static str = "Steam Controller"; + const DEVICE: &'static str = "Steam Controller"; + const CREATE_HINT: &'static str = ""; + + fn open(&mut self, idx: u8) -> Result { + let p = SteamDeckPad::open_model(idx, SteamModel::Controller)?; + tracing::info!( + index = idx, + "virtual Steam Controller created (UHID hid-steam)" + ); + Ok(p) + } + + fn neutral(&self) -> SteamState { + SteamState::neutral() + } + + /// Merge buttons/sticks/triggers, preserving the rich-plane fields. PADDLE1/2 map natively to + /// the SC's two grips inside [`sc_from_gamepad`]; only 3/4 go through the fold policy — mask + /// the native pair out of the fold input so the policy can't double-fire them. + fn merge_frame( + &self, + prev: &SteamState, + f: &crate::gamestream::gamepad::GamepadFrame, + ) -> SteamState { + use punktfunk_core::input::gamepad as gs; + let native = f.buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE2); + let folded = crate::inject::steam_remap::fold_paddles( + f.buttons & !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2), + self.remap.paddles, + ); + let mut s = sc_from_gamepad( + folded | native, + f.ls_x, + f.ls_y, + f.rs_x, + f.rs_y, + f.left_trigger, + f.right_trigger, + ); + s.lpad_x = prev.lpad_x; + s.lpad_y = prev.lpad_y; + s.gyro = prev.gyro; + s.accel = prev.accel; + s.buttons |= prev.buttons & btn::LPAD_TOUCH; + s.lpad_click = prev.lpad_click; + // The right pad carries the wire right stick each frame; a rich right-pad contact + // (TouchpadEx surface 2) overrides it only while the stick is centered — the stick is + // the primary camera surface on this mapping. + if f.rs_x == 0 && f.rs_y == 0 { + s.rpad_x = prev.rpad_x; + s.rpad_y = prev.rpad_y; + s.buttons |= prev.buttons & btn::RPAD_TOUCH; + s.rpad_click = prev.rpad_click; + } + s + } + + fn apply_rich(&self, st: &mut SteamState, rich: RichInput) { + st.apply_rich(rich); + } + + fn write_state(&self, pad: &mut SteamDeckPad, st: &SteamState) { + let _ = pad.write_state(st); + } + + /// Answer the kernel handshake (serial GET_REPORT + settings SET_REPORTs). The kernel + /// registers no FF device for the classic SC, so rumble feedback can only arrive from a + /// hidraw client (`0xEB`) — surfaced if it ever does. + fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback { + PadFeedback { + rumble: pad.service(), + hidout: Vec::new(), + } + } +} + +/// All virtual classic Steam Controllers of a session — `PUNKTFUNK_GAMEPAD=steamcontroller`, or +/// the per-pad kind a client declares for a physical SC. +pub type SteamCtrlManager = UhidManager; + #[cfg(test)] mod tests { use super::*; @@ -547,4 +677,40 @@ mod tests { "device not torn down on drop" ); } + + /// On-box smoke for the classic-SC identity: binds `hid-steam` as `28DE:1102`, input flows + /// with NO mode-entry pulse (the SC parser has no gamepad_mode gate), a held A + right-stick + /// deflection land on the evdev (BTN_A + ABS_RX — the right PAD surface), and a grip lands + /// on BTN_GRIPR (0x2c5? — kernel BTN_GRIPR = 0x2c5 on new kernels / check via bitmap). + #[test] + #[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"] + fn sc_backend_binds_and_input_flows() { + use punktfunk_core::input::gamepad as gs; + const BTN_A: u16 = 0x130; + const ABS_RX: u16 = 0x03; + let mut pad = SteamDeckPad::open_model(0, SteamModel::Controller) + .expect("open SC pad (/dev/uhid + input group?)"); + let st = sc_from_gamepad(gs::BTN_A | gs::BTN_PADDLE1, 0, 0, 9000, 0, 0, 0); + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(900) { + let _ = pad.service(); + pad.write_state(&st).expect("write_state"); + std::thread::sleep(Duration::from_millis(4)); + } + let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default(); + assert!( + devs.contains("Steam Controller"), + "SC gamepad evdev not created" + ); + let node = find_node("Steam Controller").expect("SC evdev node"); + assert!( + key_is_down(&node, BTN_A), + "BTN_A not down — SC serialize failed (no mode gate should apply)" + ); + assert_eq!( + abs_value(&node, ABS_RX), + Some(9000), + "wire right stick did not land on the right pad (ABS_RX)" + ); + } } diff --git a/crates/punktfunk-host/src/inject/proto/steam_proto.rs b/crates/punktfunk-host/src/inject/proto/steam_proto.rs index 2e18b0fb..a42bd1f4 100644 --- a/crates/punktfunk-host/src/inject/proto/steam_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/steam_proto.rs @@ -341,6 +341,129 @@ pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes()); } +/// Map an `XInput`/GameStream pad frame into **classic Steam Controller** state. The SC's 24-bit +/// button field (report bytes 8..10) shares its low-bit layout with the Deck's (face/shoulder/ +/// trigger-full byte 8; dpad/View/Steam/Menu byte 9 bits 0–6), so this reuses the [`btn`] masks — +/// with the SC-specific tail per the kernel's `ID_CONTROLLER_STATE` table: +/// - `9.7`/`10.0` are the SC's TWO grips (the bit positions the Deck calls L5/R5): wire +/// `BTN_PADDLE2`/`BTN_PADDLE1` (L4/R4, the primary pair) land there; fold PADDLE3/4 via +/// [`super::steam_remap`] BEFORE calling this. +/// - `10.2` = right-pad clicked (the SC has no right stick): wire `BTN_RS_CLICK` and the +/// DualSense `BTN_TOUCHPAD` click both land there. +/// - `10.6` = joystick clicked = wire `BTN_LS_CLICK` (the same bit the Deck calls L3). +/// - No QAM/misc slot — `BTN_MISC1` is dropped (fold it upstream if a policy wants it). +/// +/// The wire right STICK drives the right-pad coordinates (`rpad_x/y` + the `10.4` touched bit +/// while deflected) — the SC's camera surface; the loss of a true second stick is inherent to +/// the hardware. The left stick rides the joystick fields; a left-pad `TouchpadEx` contact +/// (via [`SteamState::apply_rich`]) SHADOWS the joystick while touched (the report multiplexes +/// them at bytes 16..20, exactly like real hardware's `lpad_touched` flag). +pub fn sc_from_gamepad( + buttons: u32, + lx: i16, + ly: i16, + rx: i16, + ry: i16, + lt: u8, + rt: u8, +) -> SteamState { + let on = |bit: u32| buttons & bit != 0; + let mut s = SteamState { + lx, + ly, + rx: 0, + ry: 0, + lt: (lt as u16) * 128, + rt: (rt as u16) * 128, + // The wire right stick becomes a right-pad contact (see the doc above). + rpad_x: rx, + rpad_y: ry, + ..SteamState::neutral() + }; + let mut b = 0u64; + let set = |b: &mut u64, on: bool, m: u64| { + if on { + *b |= m; + } + }; + set(&mut b, on(gs::BTN_A), btn::A); + set(&mut b, on(gs::BTN_B), btn::B); + set(&mut b, on(gs::BTN_X), btn::X); + set(&mut b, on(gs::BTN_Y), btn::Y); + set(&mut b, on(gs::BTN_LB), btn::LB); + set(&mut b, on(gs::BTN_RB), btn::RB); + set(&mut b, lt > 0, btn::LT_FULL); + set(&mut b, rt > 0, btn::RT_FULL); + set(&mut b, on(gs::BTN_BACK), btn::VIEW); + set(&mut b, on(gs::BTN_START), btn::MENU); + set(&mut b, on(gs::BTN_GUIDE), btn::STEAM); + set(&mut b, on(gs::BTN_DPAD_UP), btn::DPAD_UP); + set(&mut b, on(gs::BTN_DPAD_DOWN), btn::DPAD_DOWN); + set(&mut b, on(gs::BTN_DPAD_LEFT), btn::DPAD_LEFT); + set(&mut b, on(gs::BTN_DPAD_RIGHT), btn::DPAD_RIGHT); + // SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4. + set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip + set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip + // Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2). + set(&mut b, on(gs::BTN_LS_CLICK), btn::L3); + set( + &mut b, + on(gs::BTN_RS_CLICK) || on(gs::BTN_TOUCHPAD), + btn::RPAD_CLICK, + ); + // Right-pad touched (10.4) while the wire stick is deflected — the coords are live then. + set(&mut b, rx != 0 || ry != 0, btn::RPAD_TOUCH); + s.buttons = b; + s +} + +/// Serialize the classic Steam Controller input report (`ID_CONTROLLER_STATE`) into the 64-byte +/// unnumbered frame `steam_do_input_event` parses. Byte-exact against the kernel's message +/// table: 24-bit buttons at 8..11, **u8** triggers at 11/12 (the Deck uses u16 at 44/46), +/// the joystick/left-pad MULTIPLEX at 16..20 (left-pad coords shadow the joystick while the +/// `10.3` touched bit is set), the right pad at 20..24, and the (kernel-ignored, hidraw-visible) +/// accel/gyro at 28..39. The kernel negates both Y axes on top of these raw values. +pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) { + r.fill(0); + r[0] = 0x01; + r[1] = 0x00; + r[2] = ID_CONTROLLER_STATE; + r[3] = 0x3C; + r[4..8].copy_from_slice(&seq.to_le_bytes()); + // Rich-plane pad clicks merge like the Deck path: left-pad clicked = 10.1 (hidraw-only — + // the kernel maps no key to it), right-pad clicked = 10.2. + let mut buttons = st.buttons; + if st.lpad_click { + buttons |= btn::LPAD_CLICK; + } + if st.rpad_click { + buttons |= btn::RPAD_CLICK; + } + r[8] = (buttons & 0xFF) as u8; + r[9] = ((buttons >> 8) & 0xFF) as u8; + r[10] = ((buttons >> 16) & 0xFF) as u8; + r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8 + r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8 + // Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit. + let (x, y) = if buttons & btn::LPAD_TOUCH != 0 { + (st.lpad_x, st.lpad_y) + } else { + (st.lx, st.ly) + }; + r[16..18].copy_from_slice(&x.to_le_bytes()); + r[18..20].copy_from_slice(&y.to_le_bytes()); + r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes()); + r[22..24].copy_from_slice(&st.rpad_y.to_le_bytes()); + // IMU: present in the frame (28..39) for hidraw readers, but the kernel maps none of it + // ("accelerator/gyro is disabled by default" — no sensors evdev for the SC). + r[28..30].copy_from_slice(&st.accel[0].to_le_bytes()); + r[30..32].copy_from_slice(&st.accel[1].to_le_bytes()); + r[32..34].copy_from_slice(&st.accel[2].to_le_bytes()); + r[34..36].copy_from_slice(&st.gyro[0].to_le_bytes()); + r[36..38].copy_from_slice(&st.gyro[1].to_le_bytes()); + r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes()); +} + /// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a /// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so /// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`, @@ -693,6 +816,72 @@ mod tests { assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild } + /// The classic-SC frame, byte-exact against the kernel's `ID_CONTROLLER_STATE` table: 24-bit + /// buttons at 8..11, u8 triggers at 11/12, the joystick/left-pad multiplex at 16..20, right + /// pad at 20..24 — and the SC-specific button tail (grips at 9.7/10.0, right-pad click at + /// 10.2, joystick click at 10.6). + #[test] + fn sc_serialize_and_mapping() { + // Full mapping: face + grips + clicks + a deflected right stick. + let s = sc_from_gamepad( + gs::BTN_A + | gs::BTN_PADDLE1 + | gs::BTN_PADDLE2 + | gs::BTN_LS_CLICK + | gs::BTN_RS_CLICK, + 1000, + -2000, + 3000, + -4000, + 255, + 0, + ); + assert_ne!(s.buttons & btn::A, 0); + assert_ne!(s.buttons & btn::R5, 0); // PADDLE1 → right grip (10.0) + assert_ne!(s.buttons & btn::L5, 0); // PADDLE2 → left grip (9.7) + assert_ne!(s.buttons & btn::L3, 0); // LS click → joystick clicked (10.6) + assert_ne!(s.buttons & btn::RPAD_CLICK, 0); // RS click → right-pad clicked (10.2) + assert_ne!(s.buttons & btn::RPAD_TOUCH, 0); // deflected stick = touched pad (10.4) + assert_eq!((s.rpad_x, s.rpad_y), (3000, -4000)); // right stick rides the right pad + assert_eq!((s.rx, s.ry), (0, 0)); + + let mut r = [0u8; STEAM_REPORT_LEN]; + serialize_sc_state(&mut r, &s, 0x0102_0304); + assert_eq!(&r[0..4], &[0x01, 0x00, 0x01, 0x3C]); // ID_CONTROLLER_STATE + assert_eq!(&r[4..8], &[0x04, 0x03, 0x02, 0x01]); + assert_eq!(r[8] & 0x80, 0x80); // A = 8.7 + assert_eq!(r[9] & 0x80, 0x80); // left grip = 9.7 + assert_eq!(r[10] & 0x01, 0x01); // right grip = 10.0 + assert_eq!(r[10] & 0x04, 0x04); // right-pad clicked = 10.2 + assert_eq!(r[10] & 0x40, 0x40); // joystick clicked = 10.6 + assert_eq!(r[11], 255); // left trigger u8 + assert_eq!(r[12], 0); // right trigger u8 + assert_eq!(&r[16..18], &1000i16.to_le_bytes()); // joystick X (lpad untouched) + assert_eq!(&r[18..20], &(-2000i16).to_le_bytes()); + assert_eq!(&r[20..22], &3000i16.to_le_bytes()); // right pad X + assert_eq!(&r[22..24], &(-4000i16).to_le_bytes()); + + // Left-pad multiplex: a TouchpadEx surface-1 contact shadows the joystick at 16..20 + // and sets the 10.3 touched bit (+ the 10.1 click bit from the rich field). + let mut s = sc_from_gamepad(0, 1234, 0, 0, 0, 0, 0); + s.apply_rich(RichInput::TouchpadEx { + pad: 0, + surface: 1, + finger: 0, + touch: true, + click: true, + x: -5000, + y: 6000, + pressure: 0, + }); + let mut r = [0u8; STEAM_REPORT_LEN]; + serialize_sc_state(&mut r, &s, 0); + assert_eq!(r[10] & 0x08, 0x08); // left-pad touched = 10.3 + assert_eq!(r[10] & 0x02, 0x02); // left-pad clicked = 10.1 (rich click merged) + assert_eq!(&r[16..18], &(-5000i16).to_le_bytes()); // lpad coords shadow the joystick + assert_eq!(&r[18..20], &(-6000i16).to_le_bytes()); // screen +down → raw +up (flip) + } + /// 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…]`. #[test] diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 60ff16e7..e75d2399 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1779,6 +1779,8 @@ struct Pads { steamdeck: Option, #[cfg(target_os = "linux")] switchpro: Option, + #[cfg(target_os = "linux")] + steamctrl: Option, #[cfg(target_os = "windows")] dualsense_win: Option, #[cfg(target_os = "windows")] @@ -1812,6 +1814,8 @@ impl Pads { steamdeck: None, #[cfg(target_os = "linux")] switchpro: None, + #[cfg(target_os = "linux")] + steamctrl: None, #[cfg(target_os = "windows")] dualsense_win: None, #[cfg(target_os = "windows")] @@ -1888,6 +1892,11 @@ impl Pads { .get_or_insert_with(crate::inject::switch_pro::SwitchProManager::new) .handle(ev), #[cfg(target_os = "linux")] + GamepadPref::SteamController => self + .steamctrl + .get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new) + .handle(ev), + #[cfg(target_os = "linux")] GamepadPref::XboxOne => self .xboxone .get_or_insert_with(|| { @@ -1973,6 +1982,12 @@ impl Pads { m.apply_rich(rich) } } + #[cfg(target_os = "linux")] + GamepadPref::SteamController => { + if let Some(m) = &mut self.steamctrl { + m.apply_rich(rich) + } + } #[cfg(target_os = "windows")] GamepadPref::DualSense => { if let Some(m) = &mut self.dualsense_win { @@ -2027,6 +2042,9 @@ impl Pads { if let Some(m) = &mut self.switchpro { m.pump(&mut rumble, &mut hidout); } + if let Some(m) = &mut self.steamctrl { + m.pump(&mut rumble, &mut hidout); + } } #[cfg(target_os = "windows")] { @@ -2065,6 +2083,9 @@ impl Pads { if let Some(m) = &mut self.switchpro { m.heartbeat(gap); } + if let Some(m) = &mut self.steamctrl { + m.heartbeat(gap); + } } #[cfg(target_os = "windows")] { @@ -2758,9 +2779,10 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool // One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on // Windows (XInput can't tell them apart anyway). GamepadPref::XboxOne if linux => GamepadPref::XboxOne, - // 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). + // Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices + // are the N4 spike). GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck, + GamepadPref::SteamController if linux => GamepadPref::SteamController, // 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 @@ -2789,6 +2811,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref { | GamepadPref::DualSenseEdge | GamepadPref::DualShock4 | GamepadPref::SteamDeck + | GamepadPref::SteamController | GamepadPref::SwitchPro ); if needs_uhid @@ -5356,6 +5379,16 @@ mod tests { 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); + // Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere. + assert_eq!( + pick_gamepad(SteamController, None, true, false), + SteamController + ); + assert_eq!( + pick_gamepad(Auto, Some("steamcontroller"), true, false), + SteamController + ); + assert_eq!(pick_gamepad(SteamController, None, false, true), Xbox360); // DualSense Edge: native on Linux (UHID) AND Windows (UMDF device-type 2); Xbox360 // elsewhere.