diff --git a/clients/android/native/src/feedback.rs b/clients/android/native/src/feedback.rs index 88416c7..f87eeec 100644 --- a/clients/android/native/src/feedback.rs +++ b/clients/android/native/src/feedback.rs @@ -114,6 +114,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout( out[2..n].copy_from_slice(&effect); n } + HidOutput::TrackpadHaptic { .. } => { + // Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor + // rumble already rides the universal 0xCA plane). + return -1; + } }; n as jint }) diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 6ef215d..d3cf7e1 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -492,6 +492,10 @@ pub const PUNKTFUNK_HIDOUT_LED: u8 = 1; pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2; /// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid). pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3; +/// `PunktfunkHidOutput::kind` — a trackpad haptic pulse (Steam Controller voice-coils). `which` = +/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as +/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it. +pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4; /// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block). pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11; @@ -559,6 +563,23 @@ impl PunktfunkHidOutput { out.effect[..n].copy_from_slice(&effect[..n]); out.effect_len = n as u8; } + HidOutput::TrackpadHaptic { + pad, + side, + amplitude, + period, + count, + } => { + // No new struct (PunktfunkHidOutput has no size guard): pack into the existing + // `which` (side) + `effect[0..6]` (amplitude/period/count LE), `effect_len = 6`. + out.kind = PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC; + out.pad = *pad; + out.which = *side; + out.effect[0..2].copy_from_slice(&litude.to_le_bytes()); + out.effect[2..4].copy_from_slice(&period.to_le_bytes()); + out.effect[4..6].copy_from_slice(&count.to_le_bytes()); + out.effect_len = 6; + } } out } @@ -618,6 +639,11 @@ impl PunktfunkHdrMeta { pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1; /// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid). pub const PUNKTFUNK_RICH_MOTION: u8 = 2; +/// `RichInput::TouchpadEx` kind on the wire — an extended trackpad contact that identifies the +/// surface (0 single / 1 Steam-left / 2 Steam-right) and carries click + pressure. The host decodes +/// it today; *sending* it from a C client needs the size-prefixed `PunktfunkRichInputEx` + +/// `punktfunk_connection_send_rich_input2` (added with client capture). +pub const PUNKTFUNK_RICH_TOUCHPAD_EX: u8 = 3; /// One rich client→host input for the host's virtual DualSense /// ([`punktfunk_connection_send_rich_input`]): a touchpad contact or a motion sample. Set `kind` @@ -714,6 +740,22 @@ 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. +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 +/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360. +pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6; + +/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips +/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's +/// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`. +pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE1: u32 = 0x0001_0000; +pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE2: u32 = 0x0002_0000; +pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE3: u32 = 0x0004_0000; +pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE4: u32 = 0x0008_0000; +pub const PUNKTFUNK_GAMEPAD_BTN_MISC1: u32 = 0x0020_0000; /// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`. /// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to @@ -742,11 +784,28 @@ const _: () = { // Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift). const _: () = { use crate::config::GamepadPref; + use crate::input::gamepad as g; assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32); + assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER == GamepadPref::SteamController.to_u8() as u32); + assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32); + // Extended button bits mirror the wire `input::gamepad` constants. + assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1); + assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2); + assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE3 == g::BTN_PADDLE3); + assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE4 == g::BTN_PADDLE4); + assert!(PUNKTFUNK_GAMEPAD_BTN_MISC1 == g::BTN_MISC1); +}; + +// The additive M3 kinds (TouchpadEx / TrackpadHaptic) must never grow the legacy ABI structs — +// they have no `struct_size` guard, so a layout change would corrupt old-built callers' buffers. +#[cfg(feature = "quic")] +const _: () = { + assert!(core::mem::size_of::() == 20); + assert!(core::mem::size_of::() == 19); }; /// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 1cf885d..ad6ea7e 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -399,4 +399,27 @@ mod tests { c.fec.fec_percent = 15; // 250 + ceil(250*15/100)=288 > 255 assert!(c.validate().is_err()); } + + #[test] + fn gamepad_pref_steam_roundtrip() { + use GamepadPref::*; + // Wire-byte round-trip for the Steam additions; an unknown byte still degrades to Auto. + for (p, b) in [(SteamController, 5u8), (SteamDeck, 6)] { + assert_eq!(p.to_u8(), b); + assert_eq!(GamepadPref::from_u8(b), p); + } + assert_eq!(GamepadPref::from_u8(99), Auto); + // Name parsing + canonical-name round-trip. + assert_eq!(GamepadPref::from_name("steamdeck"), Some(SteamDeck)); + assert_eq!(GamepadPref::from_name("deck"), Some(SteamDeck)); + assert_eq!( + GamepadPref::from_name("steamcontroller"), + Some(SteamController) + ); + assert_eq!(SteamDeck.as_str(), "steamdeck"); + assert_eq!( + GamepadPref::from_name(SteamController.as_str()), + Some(SteamController) + ); + } } diff --git a/crates/punktfunk-core/src/input.rs b/crates/punktfunk-core/src/input.rs index c95594a..9fc4d7b 100644 --- a/crates/punktfunk-core/src/input.rs +++ b/crates/punktfunk-core/src/input.rs @@ -66,10 +66,24 @@ pub mod gamepad { pub const BTN_B: u32 = 0x2000; pub const BTN_X: u32 = 0x4000; pub const BTN_Y: u32 = 0x8000; + // Extended buttons in Moonlight's `buttonFlags2 << 16` namespace (see `gamestream/gamepad.rs`), + // so the GameStream paddle path and the native path share one host injector map. The four Steam + // Deck back grips (L4/L5/R4/R5) reuse the four GameStream/Xbox-Elite paddle slots — a semantic + // 1:1 for binding (the device identity carries the glyph distinction). + /// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`. + pub const BTN_PADDLE1: u32 = 0x0001_0000; + /// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`. + pub const BTN_PADDLE2: u32 = 0x0002_0000; + /// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`. + pub const BTN_PADDLE3: u32 = 0x0004_0000; + /// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`. + pub const BTN_PADDLE4: u32 = 0x0008_0000; /// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2` /// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on /// the same bit. Only the DualSense backend renders it; the xpad has no such button. pub const BTN_TOUCHPAD: u32 = 0x10_0000; + /// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`. + pub const BTN_MISC1: u32 = 0x0020_0000; /// Axis ids for `InputKind::GamepadAxis`. pub const AXIS_LS_X: u32 = 0; diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index cedb1d2..a6a4bca 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -1218,6 +1218,7 @@ pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> { const RICH_TOUCHPAD: u8 = 0x01; const RICH_MOTION: u8 = 0x02; +const RICH_TOUCHPAD_EX: u8 = 0x03; /// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent): /// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is @@ -1241,6 +1242,22 @@ pub enum RichInput { gyro: [i16; 3], accel: [i16; 3], }, + /// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck + /// have two), carries a separate click vs touch state, and a pressure reading. `surface`: + /// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad. + /// Coordinates are **signed** (centred at 0), matching the real Steam report; `pressure` is `0` + /// for a surface with no force sensor. New clients send this for every touch surface; the host + /// decodes both `Touchpad` (`0x01`) and `TouchpadEx` (`0x03`) indefinitely. + TouchpadEx { + pad: u8, + surface: u8, + finger: u8, + touch: bool, + click: bool, + x: i16, + y: i16, + pressure: u16, + }, } impl RichInput { @@ -1264,6 +1281,22 @@ impl RichInput { out.extend_from_slice(&v.to_le_bytes()); } } + RichInput::TouchpadEx { + pad, + surface, + finger, + touch, + click, + x, + y, + pressure, + } => { + let state = (touch as u8) | ((click as u8) << 1); + out.extend_from_slice(&[RICH_TOUCHPAD_EX, pad, surface, finger, state]); + out.extend_from_slice(&x.to_le_bytes()); + out.extend_from_slice(&y.to_le_bytes()); + out.extend_from_slice(&pressure.to_le_bytes()); + } } out } @@ -1288,6 +1321,16 @@ impl RichInput { accel: [i16at(9), i16at(11), i16at(13)], }) } + RICH_TOUCHPAD_EX if b.len() >= 12 => Some(RichInput::TouchpadEx { + pad: b[2], + surface: b[3], + finger: b[4], + touch: b[5] & 0x01 != 0, + click: b[5] & 0x02 != 0, + x: i16::from_le_bytes([b[6], b[7]]), + y: i16::from_le_bytes([b[8], b[9]]), + pressure: u16::from_le_bytes([b[10], b[11]]), + }), _ => None, } } @@ -1296,6 +1339,7 @@ impl RichInput { const HIDOUT_LED: u8 = 0x01; const HIDOUT_PLAYER_LEDS: u8 = 0x02; const HIDOUT_TRIGGER: u8 = 0x03; +const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04; /// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad). /// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram; @@ -1309,6 +1353,16 @@ pub enum HidOutput { /// One adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense /// trigger parameter block (mode + params) for the client to replay on a real controller. Trigger { pad: u8, which: u8, effect: Vec }, + /// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble"). + /// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses) + /// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble). + TrackpadHaptic { + pad: u8, + side: u8, + amplitude: u16, + period: u16, + count: u16, + }, } impl HidOutput { @@ -1325,6 +1379,18 @@ impl HidOutput { out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]); out.extend_from_slice(effect); } + HidOutput::TrackpadHaptic { + pad, + side, + amplitude, + period, + count, + } => { + out.extend_from_slice(&[HIDOUT_TRACKPAD_HAPTIC, *pad, *side]); + out.extend_from_slice(&litude.to_le_bytes()); + out.extend_from_slice(&period.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + } } out } @@ -1349,6 +1415,13 @@ impl HidOutput { which: b[3], effect: b[4..].to_vec(), }), + HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic { + pad: b[2], + side: b[3], + amplitude: u16::from_le_bytes([b[4], b[5]]), + period: u16::from_le_bytes([b[6], b[7]]), + count: u16::from_le_bytes([b[8], b[9]]), + }), _ => None, } } @@ -2486,6 +2559,16 @@ mod tests { gyro: [-100, 200, -300], accel: [16384, -8192, 1], }, + RichInput::TouchpadEx { + pad: 2, + surface: 1, + finger: 1, + touch: true, + click: false, + x: -12345, + y: 30000, + pressure: 4000, + }, ] { let d = ev.encode(); assert_eq!(d[0], RICH_INPUT_MAGIC); @@ -2494,7 +2577,8 @@ mod tests { // Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None. assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none()); assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind - assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none()); + assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none()); // short + assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD_EX, 0, 0, 0, 0]).is_none()); // short } @@ -2516,6 +2600,13 @@ mod tests { which: 1, effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00], }, + HidOutput::TrackpadHaptic { + pad: 0, + side: 1, + amplitude: 0x1234, + period: 0x5678, + count: 9, + }, ]; for ev in &cases { let d = ev.encode(); diff --git a/crates/punktfunk-host/src/gamestream/gamepad.rs b/crates/punktfunk-host/src/gamestream/gamepad.rs index 01410a2..4479479 100644 --- a/crates/punktfunk-host/src/gamestream/gamepad.rs +++ b/crates/punktfunk-host/src/gamestream/gamepad.rs @@ -66,6 +66,13 @@ pub const BTN_A: u32 = 0x1000; pub const BTN_B: u32 = 0x2000; pub const BTN_X: u32 = 0x4000; pub const BTN_Y: u32 = 0x8000; +// Extended buttons in the `buttonFlags2 << 16` namespace (mirror `punktfunk_core::input::gamepad`): +// the four back-grip paddles. `decode` already merges `buttonFlags2 << 16` into `buttons`, but the +// injector map dropped these bits — Sunshine/Moonlight paddle clients were silently no-op'd. +pub const BTN_PADDLE1: u32 = 0x0001_0000; +pub const BTN_PADDLE2: u32 = 0x0002_0000; +pub const BTN_PADDLE3: u32 = 0x0004_0000; +pub const BTN_PADDLE4: u32 = 0x0008_0000; /// Decode one decrypted control plaintext into a controller event, if it is one. Mouse, /// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]). diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index b86807a..2fc4e67 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -252,7 +252,9 @@ impl DualSenseManager { /// arrived first); they're dropped if the pad isn't present. pub fn apply_rich(&mut self, rich: RichInput) { let idx = match rich { - RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, + RichInput::Touchpad { pad, .. } + | RichInput::Motion { pad, .. } + | RichInput::TouchpadEx { pad, .. } => pad as usize, }; if idx >= MAX_PADS || self.pads[idx].is_none() { return; @@ -280,6 +282,26 @@ impl DualSenseManager { 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; + } + } } self.write(idx); } diff --git a/crates/punktfunk-host/src/inject/linux/dualshock4.rs b/crates/punktfunk-host/src/inject/linux/dualshock4.rs index 9a82960..77f0854 100644 --- a/crates/punktfunk-host/src/inject/linux/dualshock4.rs +++ b/crates/punktfunk-host/src/inject/linux/dualshock4.rs @@ -439,7 +439,9 @@ impl DualShock4Manager { /// pad isn't present. pub fn apply_rich(&mut self, rich: RichInput) { let idx = match rich { - RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, + RichInput::Touchpad { pad, .. } + | RichInput::Motion { pad, .. } + | RichInput::TouchpadEx { pad, .. } => pad as usize, }; if idx >= MAX_PADS || self.pads[idx].is_none() { return; @@ -466,6 +468,26 @@ impl DualShock4Manager { 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; + } + } } self.write(idx); } diff --git a/crates/punktfunk-host/src/inject/linux/gamepad.rs b/crates/punktfunk-host/src/inject/linux/gamepad.rs index 96784a8..edf83d5 100644 --- a/crates/punktfunk-host/src/inject/linux/gamepad.rs +++ b/crates/punktfunk-host/src/inject/linux/gamepad.rs @@ -69,9 +69,16 @@ const BTN_START: u16 = 0x13b; const BTN_MODE: u16 = 0x13c; const BTN_THUMBL: u16 = 0x13d; const BTN_THUMBR: u16 = 0x13e; +// Xbox-Elite paddle codes (the xpad convention SDL / Steam Input recognize). A client's back grips — +// and the GameStream `buttonFlags2` paddle bits, which were silently dropped before — land here, so +// the virtual X-Box pad exposes paddles like an Elite controller. PADDLE1/2/3/4 = R4/L4/R5/L5. +const BTN_TRIGGER_HAPPY5: u16 = 0x2c4; +const BTN_TRIGGER_HAPPY6: u16 = 0x2c5; +const BTN_TRIGGER_HAPPY7: u16 = 0x2c6; +const BTN_TRIGGER_HAPPY8: u16 = 0x2c7; /// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead. -const BUTTON_MAP: [(u32, u16); 11] = [ +const BUTTON_MAP: [(u32, u16); 15] = [ (gamepad::BTN_A, BTN_SOUTH), (gamepad::BTN_B, BTN_EAST), (gamepad::BTN_X, BTN_NORTH), @@ -83,6 +90,10 @@ const BUTTON_MAP: [(u32, u16); 11] = [ (gamepad::BTN_GUIDE, BTN_MODE), (gamepad::BTN_LS_CLK, BTN_THUMBL), (gamepad::BTN_RS_CLK, BTN_THUMBR), + (gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5), + (gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6), + (gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7), + (gamepad::BTN_PADDLE4, BTN_TRIGGER_HAPPY8), ]; /// The USB identity a virtual uinput pad presents. SDL/Steam/Proton key their built-in mapping off diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller.rs b/crates/punktfunk-host/src/inject/linux/steam_controller.rs index 91a79b5..7461f02 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller.rs @@ -315,7 +315,9 @@ impl SteamControllerManager { /// Apply a rich client→host event (right trackpad / motion) to an existing pad. pub fn apply_rich(&mut self, rich: RichInput) { let idx = match rich { - RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, + RichInput::Touchpad { pad, .. } + | RichInput::Motion { pad, .. } + | RichInput::TouchpadEx { pad, .. } => pad as usize, }; if idx >= MAX_PADS || self.pads[idx].is_none() { return; @@ -423,6 +425,19 @@ mod tests { rc >= 0 && (bits[(code / 8) as usize] >> (code % 8)) & 1 == 1 } + /// Read the current value of an absolute axis (`EVIOCGABS`) — the first `i32` of `input_absinfo`. + fn abs_value(node: &str, abs: u16) -> Option { + use std::os::unix::io::AsRawFd; + let f = std::fs::File::open(node).ok()?; + let mut info = [0u8; 24]; // struct input_absinfo { value, min, max, fuzz, flat, resolution } + let req: libc::c_ulong = + (2 << 30) | (24 << 16) | (0x45 << 8) | (0x40 + abs as libc::c_ulong); + // SAFETY: EVIOCGABS fills the 24-byte input_absinfo for the valid evdev fd `f`; we read only + // the leading i32 `value`. The buffer is exactly sizeof(input_absinfo), so no overflow. + let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, info.as_mut_ptr()) }; + (rc >= 0).then(|| i32::from_ne_bytes([info[0], info[1], info[2], info[3]])) + } + /// On-box smoke test for the real backend: a `SteamDeckPad` must bind `hid-steam` (creating both /// the gamepad + IMU evdevs), enter `gamepad_mode` via the creation pulse, and land a held button /// on the evdev (`BTN_A`, code 0x130) — proving the entry overlay + byte-exact serialize path — @@ -431,11 +446,24 @@ mod tests { #[test] #[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"] fn backend_binds_and_input_flows() { + use punktfunk_core::input::gamepad as gs; const BTN_A: u16 = 0x130; + const ABS_HAT0X: u16 = 0x10; // left trackpad X let mut pad = SteamDeckPad::open(0).expect("open SteamDeckPad (/dev/uhid + input group?)"); - // Drive past MODE_ENTER (the b9.6 pulse) then hold BTN_A, servicing the handshake. - let mut st = SteamState::neutral(); - st.buttons = btn::A; + // Drive the full M3 wire path: build state through `from_gamepad` (BTN_A + the L4 back grip) + // and `apply_rich` (a left-pad TouchpadEx contact), then hold it past MODE_ENTER (the b9.6 + // pulse), servicing the handshake. + let mut st = SteamState::from_gamepad(gs::BTN_A | gs::BTN_PADDLE2, 0, 0, 0, 0, 0, 0); + st.apply_rich(RichInput::TouchpadEx { + pad: 0, + surface: 1, + finger: 0, + touch: true, + click: false, + x: -8000, + y: 9000, + pressure: 0, + }); let start = Instant::now(); while start.elapsed() < Duration::from_millis(1200) { let _ = pad.service(); @@ -453,6 +481,12 @@ mod tests { key_is_down(&node, BTN_A), "BTN_A not down — gamepad_mode entry or serialize failed" ); + // The left trackpad contact (TouchpadEx surface 1, gated on LPAD_TOUCH) reaches ABS_HAT0X. + assert_eq!( + abs_value(&node, ABS_HAT0X), + Some(-8000), + "left trackpad (TouchpadEx surface 1) did not reach ABS_HAT0X" + ); drop(pad); std::thread::sleep(Duration::from_millis(200)); let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default(); diff --git a/crates/punktfunk-host/src/inject/proto/steam_proto.rs b/crates/punktfunk-host/src/inject/proto/steam_proto.rs index c1b54b7..dace47b 100644 --- a/crates/punktfunk-host/src/inject/proto/steam_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/steam_proto.rs @@ -221,6 +221,13 @@ impl SteamState { // The DualSense touchpad-click wire bit maps to the Deck's RIGHT pad click (the pad that // stands in for the DualSense touchpad — see apply_rich). set(&mut b, on(gs::BTN_TOUCHPAD), btn::RPAD_CLICK); + // Back grips (the whole reason for the Deck identity): the wire paddle bits map to the four + // Deck grips — PADDLE1/2/3/4 = R4/L4/R5/L5 (see `input::gamepad`); MISC1 = the QAM '…' button. + set(&mut b, on(gs::BTN_PADDLE1), btn::R4); + set(&mut b, on(gs::BTN_PADDLE2), btn::L4); + set(&mut b, on(gs::BTN_PADDLE3), btn::R5); + set(&mut b, on(gs::BTN_PADDLE4), btn::L5); + set(&mut b, on(gs::BTN_MISC1), btn::QAM); s.buttons = b; s } @@ -241,6 +248,28 @@ impl SteamState { self.gyro = gyro; self.accel = accel; } + RichInput::TouchpadEx { + surface, + touch, + click, + x, + y, + .. + } => { + // Steam pads are natively signed (centre 0), so x/y map straight in. surface 1 = + // left pad, anything else (0 single / 2 right) = right pad. + if surface == 1 { + self.press(btn::LPAD_TOUCH, touch); + self.press(btn::LPAD_CLICK, click); + self.lpad_x = x; + self.lpad_y = y; + } else { + self.press(btn::RPAD_TOUCH, touch); + self.press(btn::RPAD_CLICK, click); + self.rpad_x = x; + self.rpad_y = y; + } + } } } } @@ -424,6 +453,53 @@ mod tests { assert_eq!(s.accel, [4, 5, 6]); } + /// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the + /// left / right surfaces to the matching pad (signed coords pass straight through). + #[test] + fn back_buttons_and_dual_trackpad_mapping() { + let s = SteamState::from_gamepad( + gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_MISC1, + 0, + 0, + 0, + 0, + 0, + 0, + ); + assert_ne!(s.buttons & btn::R4, 0); // PADDLE1 = R4 + assert_ne!(s.buttons & btn::L4, 0); // PADDLE2 = L4 + assert_ne!(s.buttons & btn::R5, 0); // PADDLE3 = R5 + assert_ne!(s.buttons & btn::L5, 0); // PADDLE4 = L5 + assert_ne!(s.buttons & btn::QAM, 0); // MISC1 = QAM + + let mut s = SteamState::neutral(); + s.apply_rich(RichInput::TouchpadEx { + pad: 0, + surface: 1, + finger: 0, + touch: true, + click: true, + x: -5000, + y: 6000, + pressure: 100, + }); + assert_ne!(s.buttons & btn::LPAD_TOUCH, 0); + assert_ne!(s.buttons & btn::LPAD_CLICK, 0); + assert_eq!((s.lpad_x, s.lpad_y), (-5000, 6000)); + s.apply_rich(RichInput::TouchpadEx { + pad: 0, + surface: 2, + finger: 0, + touch: true, + click: false, + x: 7000, + y: -8000, + pressure: 0, + }); + assert_ne!(s.buttons & btn::RPAD_TOUCH, 0); + assert_eq!((s.rpad_x, s.rpad_y), (7000, -8000)); + } + /// 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/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index e7bda48..d7d080c 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -385,7 +385,9 @@ impl DualSenseWindowsManager { /// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad. pub fn apply_rich(&mut self, rich: RichInput) { let idx = match rich { - RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, + RichInput::Touchpad { pad, .. } + | RichInput::Motion { pad, .. } + | RichInput::TouchpadEx { pad, .. } => pad as usize, }; if idx >= MAX_PADS || self.pads[idx].is_none() { return; @@ -409,6 +411,26 @@ impl DualSenseWindowsManager { 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; + } + } } 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 b2b89dd..802f1c2 100644 --- a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs @@ -186,7 +186,9 @@ impl DualShock4WindowsManager { /// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad. pub fn apply_rich(&mut self, rich: RichInput) { let idx = match rich { - RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, + RichInput::Touchpad { pad, .. } + | RichInput::Motion { pad, .. } + | RichInput::TouchpadEx { pad, .. } => pad as usize, }; if idx >= MAX_PADS || self.pads[idx].is_none() { return; @@ -210,6 +212,26 @@ impl DualShock4WindowsManager { 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; + } + } } self.write(idx); } diff --git a/design/steam-controller-deck-support.md b/design/steam-controller-deck-support.md index 2c7565d..d3e24d0 100644 --- a/design/steam-controller-deck-support.md +++ b/design/steam-controller-deck-support.md @@ -1,12 +1,27 @@ # Rich Steam Controller & Steam Deck support -> **Status:** **M0–M2 GREEN — Linux virtual Deck binds, is byte-exact, AND is a wired host backend, +> **Status:** **M0–M3 GREEN — virtual Deck binds + byte-exact + wired backend + the rich wire, > on-box (2026-06-29).** The greenfield virtual `hid-steam` device works end-to-end as a selectable -> host gamepad backend: `PUNKTFUNK_GAMEPAD=steamdeck` builds a per-session `SteamControllerManager` -> that creates a `/dev/uhid` `28DE:1205` device, enters `gamepad_mode`, and feeds the byte-exact Deck -> report. Next: M3 (the protocol/ABI wire surface — back-button bits, `TouchpadEx`, the C-ABI -> `GamepadPref` constants) + M4 (client capture). This remains the design + milestone plan; the Steam -> analogue of the shipped virtual DualSense (`design/windows-dualsense-scoping.md`). +> host gamepad backend (`PUNKTFUNK_GAMEPAD=steamdeck`), and the protocol now carries the rich Steam +> inputs (back buttons + second trackpad). Next: M4 (client capture — SDL Steam hints, paddles, 2nd +> touchpad, the Decky Disable-Steam-Input UX, + the C-ABI `PunktfunkRichInputEx`/`send_rich_input2` +> for the Apple/embedder send path). The Steam analogue of the shipped virtual DualSense. +> +> **M3 result (protocol / ABI wire, on-box):** strictly additive + forward-compatible (§5). +> Core: back-button bits `BTN_PADDLE1..4` + `BTN_MISC1` (in Moonlight's `buttonFlags2<<16` +> namespace, so GameStream paddle + native grips share one map); `RichInput::TouchpadEx` (kind +> `0x03` — surface 0/1/2, click, signed coords, pressure); `HidOutput::TrackpadHaptic` (kind `0x04`). +> ABI: `PUNKTFUNK_GAMEPAD_STEAMDECK=6`/`_STEAMCONTROLLER=5` + the paddle/`RICH_TOUCHPAD_EX`/ +> `HIDOUT_TRACKPAD_HAPTIC` constants, `from_hid` packs `TrackpadHaptic` into the existing +> `which`+`effect[0..6]` (the legacy structs do **not** grow — guarded by `size_of==20/19` asserts); +> regenerated `punktfunk_core.h`. Host: `steam_proto::from_gamepad` maps the paddles → the four Deck +> grips + QAM; `apply_rich` routes `TouchpadEx` left/right → the matching pad; every DS manager +> (DualSense/DS4, Linux + Windows) gained a `TouchpadEx` arm (surface 0/2 → its one touchpad); the +> xpad `BUTTON_MAP` finally consumes the GameStream paddle bits (`BTN_TRIGGER_HAPPY5-8`, previously +> dropped). Wire round-trips + mapping unit-tested; the on-box backend test now drives the full path +> (`from_gamepad` grip + `apply_rich` left-pad) → evdev `BTN_A` + `ABS_HAT0X`. Workspace +> clippy/fmt/test green. **Deferred to M4:** the C-ABI `PunktfunkRichInputEx` + `send_rich_input2` +> (only the Apple/C *send* path needs it; the host decodes `TouchpadEx` today). > > **M2 result (backend + wiring, on-box):** `inject/linux/steam_controller.rs` > (`SteamControllerManager`/`SteamDeckPad`, mirroring `dualsense.rs`) is wired into `PadBackend` diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index d963b57..c1f446c 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -28,6 +28,11 @@ // `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid). #define PUNKTFUNK_HIDOUT_TRIGGER 3 +// `PunktfunkHidOutput::kind` — a trackpad haptic pulse (Steam Controller voice-coils). `which` = +// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as +// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it. +#define PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC 4 + // Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block). #define PUNKTFUNK_HID_EFFECT_MAX 11 @@ -37,6 +42,12 @@ // `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid). #define PUNKTFUNK_RICH_MOTION 2 +// `RichInput::TouchpadEx` kind on the wire — an extended trackpad contact that identifies the +// surface (0 single / 1 Steam-left / 2 Steam-right) and carries click + pressure. The host decodes +// it today; *sending* it from a C client needs the size-prefixed `PunktfunkRichInputEx` + +// `punktfunk_connection_send_rich_input2` (added with client capture). +#define PUNKTFUNK_RICH_TOUCHPAD_EX 3 + // Compositor preference for [`punktfunk_connect_ex`] (`compositor` arg). `AUTO` lets the host // pick (auto-detect from its running desktop); a concrete value is honored only if that backend // is available on the host right now, else the host falls back to auto-detect. The resolved @@ -82,6 +93,28 @@ // hosts); otherwise the host falls back to X-Box 360. #define PUNKTFUNK_GAMEPAD_DUALSHOCK4 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. +#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 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 +// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360. +#define PUNKTFUNK_GAMEPAD_STEAMDECK 6 + +// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips +// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's +// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`. +#define PUNKTFUNK_GAMEPAD_BTN_PADDLE1 65536 + +#define PUNKTFUNK_GAMEPAD_BTN_PADDLE2 131072 + +#define PUNKTFUNK_GAMEPAD_BTN_PADDLE3 262144 + +#define PUNKTFUNK_GAMEPAD_BTN_PADDLE4 524288 + +#define PUNKTFUNK_GAMEPAD_BTN_MISC1 2097152 + // Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`. // Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to // [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`. @@ -139,11 +172,26 @@ #define PUNKTFUNK_BTN_Y 32768 +// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`. +#define BTN_PADDLE1 65536 + +// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`. +#define BTN_PADDLE2 131072 + +// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`. +#define BTN_PADDLE3 262144 + +// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`. +#define BTN_PADDLE4 524288 + // DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2` // merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on // the same bit. Only the DualSense backend renders it; the xpad has no such button. #define PUNKTFUNK_BTN_TOUCHPAD 1048576 +// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`. +#define BTN_MISC1 2097152 + // Axis ids for `InputKind::GamepadAxis`. #define PUNKTFUNK_AXIS_LS_X 0