diff --git a/Cargo.lock b/Cargo.lock index 840b0a3b..85e74742 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3119,6 +3119,7 @@ dependencies = [ "nvidia-video-codec-sdk", "openh264", "opus", + "parking_lot", "pf-driver-proto", "pipewire", "punktfunk-core", diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index fd643933..9211fb34 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -495,6 +495,7 @@ private fun prefLabel(pref: Int): String = when (pref) { Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge" Gamepad.PREF_SWITCHPRO -> "Switch Pro" Gamepad.PREF_STEAMCONTROLLER2 -> "Steam Controller 2" + Gamepad.PREF_STEAMCONTROLLER2_PUCK -> "Steam Controller 2 Puck" else -> "Automatic" } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index ab7536eb..08cdc313 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -65,6 +65,7 @@ object Gamepad { const val PREF_DUALSENSEEDGE = 7 const val PREF_SWITCHPRO = 8 const val PREF_STEAMCONTROLLER2 = 9 + const val PREF_STEAMCONTROLLER2_PUCK = 10 // USB vendor ids of the controllers we can identify by VID/PID. private const val VID_SONY = 0x054C @@ -92,12 +93,11 @@ object Gamepad { private val PID_STEAMDECK = setOf(0x1205) private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142) - // Steam Controller 2: wired (0x1302), BLE (0x1303), Puck dongles (0x1304/0x1305). Normally - // the Sc2 capture link CLAIMS the USB device (detaching it from the input stack), so this - // entry only fires in the degraded path — capture off / permission denied — where the pad - // surfaces as a plain InputDevice. Declaring SC2 there lets the host build the matching - // virtual pad from the typed plane instead of falling back to Xbox 360. - private val PID_STEAMCONTROLLER2 = setOf(0x1302, 0x1303, 0x1304, 0x1305) + // Steam Controller 2: wired (0x1302), BLE (0x1303), and Puck dongles (0x1304/0x1305). + // Sc2Capture normally claims these directly; the plain InputDevice path is only a degraded + // fallback. Keep Puck distinct so even that path requests the native multi-interface identity. + private val PID_STEAMCONTROLLER2 = setOf(0x1302, 0x1303) + private val PID_STEAMCONTROLLER2_PUCK = setOf(0x1304, 0x1305) // Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All // behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte. @@ -125,6 +125,8 @@ object Gamepad { vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER + vid == VID_VALVE && pid in PID_STEAMCONTROLLER2_PUCK -> + PREF_STEAMCONTROLLER2_PUCK vid == VID_VALVE && pid in PID_STEAMCONTROLLER2 -> PREF_STEAMCONTROLLER2 vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO else -> PREF_XBOX360 diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt index 5722f118..83c4462f 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt @@ -47,6 +47,10 @@ class Sc2Capture( private var pad: GamepadRouter.ExternalPad? = null private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64) + /** Puck connect arrives before its first state report (and therefore before a wire pad exists). + * Preserve it so the native virtual Puck slot sees the same connect edge before state. */ + private val pendingWireless = ByteArray(2) + private var pendingWirelessLen = 0 // Typed-mirror diff state (wire units). private val state = Sc2Device.State() @@ -141,10 +145,20 @@ class Sc2Capture( // saying "no radio link" — and must NOT tear the slot down (SDL's wired path likewise // marks the controller connected unconditionally and reconnects on any state report). if ((id == Sc2Device.ID_WIRELESS || id == Sc2Device.ID_WIRELESS_X) && len >= 2) { - if (dongleLink && (report[1].toInt() and 0xFF) == Sc2Device.WIRELESS_DISCONNECT) { - Log.i(TAG, "Puck reports controller powered off — releasing wire slot") - releaseSlot() - releaseUiKeys() + if (dongleLink) { + when (report[1].toInt() and 0xFF) { + Sc2Device.WIRELESS_CONNECT -> { + pendingWireless[0] = report[0] + pendingWireless[1] = report[1] + pendingWirelessLen = 2 + } + Sc2Device.WIRELESS_DISCONNECT -> { + pendingWirelessLen = 0 + Log.i(TAG, "Puck reports controller powered off — releasing wire slot") + releaseSlot() + releaseUiKeys() + } + } } return } @@ -157,9 +171,21 @@ class Sc2Capture( mirrorUi() return } - val p = pad ?: router.openExternal(Gamepad.PREF_STEAMCONTROLLER2)?.also { + val pref = if (dongleLink) { + Gamepad.PREF_STEAMCONTROLLER2_PUCK + } else { + Gamepad.PREF_STEAMCONTROLLER2 + } + val p = pad ?: router.openExternal(pref)?.also { pad = it - Log.i(TAG, "SC2 captured → wire pad ${it.index} (as-is passthrough)") + Log.i( + TAG, + "SC2 captured → wire pad ${it.index} (${if (dongleLink) "Puck" else "direct"} passthrough)", + ) + if (pendingWirelessLen > 0) { + forwardRaw(pendingWireless, pendingWirelessLen) + pendingWirelessLen = 0 + } } ?: return // all 16 wire indices taken — drop until one frees forwardRaw(report, len) mirrorTyped(p) @@ -259,6 +285,7 @@ class Sc2Capture( pad = null wireButtons = 0 lastAxis.fill(Int.MIN_VALUE) + pendingWirelessLen = 0 } private companion object { diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 7266f6d0..ab869982 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -270,6 +270,7 @@ impl PadInfo { GamepadPref::SteamDeck => "Steam Deck", GamepadPref::SteamController => "Steam Controller", GamepadPref::SteamController2 => "Steam Controller 2", + GamepadPref::SteamController2Puck => "Steam Controller 2 Puck", GamepadPref::SwitchPro => "Switch Pro", _ => "", } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index be8757fc..7a6a0e1e 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -906,6 +906,10 @@ pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8; /// Steam Input is the consumer (no kernel driver binds the PID). Honored on Linux (UHID); /// else folds to X-Box 360. pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2: u32 = 9; +/// Steam Controller Puck dongle (`28DE:1304`) passed through with its native seven-interface +/// topology and four controller slots. Used by capture clients that own the physical Puck; +/// ordinary wired/BLE SC2 capture remains `STEAMCONTROLLER2`. +pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK: u32 = 10; /// 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 @@ -970,6 +974,9 @@ const _: () = { assert!(PUNKTFUNK_GAMEPAD_DUALSENSEEDGE == GamepadPref::DualSenseEdge.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_SWITCHPRO == GamepadPref::SwitchPro.to_u8() as u32); assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 == GamepadPref::SteamController2.to_u8() as u32); + assert!( + PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK == GamepadPref::SteamController2Puck.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); diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 521a1557..169493f0 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -139,8 +139,8 @@ impl CompositorPref { /// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single /// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`, -/// `9 = SteamController2`), appended to `Hello`/`Welcome` — older peers simply omit/ignore it (an -/// unknown byte degrades to `Auto`). +/// `9 = SteamController2`, `10 = SteamController2Puck`), appended to `Hello`/`Welcome` — older +/// peers simply omit/ignore it (an unknown byte degrades to `Auto`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum GamepadPref { /// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). @@ -181,12 +181,16 @@ pub enum GamepadPref { /// real controller). No kernel driver binds the PID (mainline `hid-steam` stops at the Deck), /// so Steam Input is the consumer. Needs Linux UHID. SteamController2, + /// Steam Controller Puck dongle (`28DE:1304`) carrying a captured SC2. The host presents the + /// native seven-interface Puck topology (CDC pair, four controller slots, management HID) + /// rather than relabelling its reports as a wired `1302`. + SteamController2Puck, } impl GamepadPref { /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`, - /// `9 = SteamController2`. + /// `9 = SteamController2`, `10 = SteamController2Puck`. pub const fn to_u8(self) -> u8 { match self { GamepadPref::Auto => 0, @@ -199,6 +203,7 @@ impl GamepadPref { GamepadPref::DualSenseEdge => 7, GamepadPref::SwitchPro => 8, GamepadPref::SteamController2 => 9, + GamepadPref::SteamController2Puck => 10, } } @@ -215,6 +220,7 @@ impl GamepadPref { 7 => GamepadPref::DualSenseEdge, 8 => GamepadPref::SwitchPro, 9 => GamepadPref::SteamController2, + 10 => GamepadPref::SteamController2Puck, _ => GamepadPref::Auto, } } @@ -239,13 +245,16 @@ impl GamepadPref { "steamcontroller2" | "steam-controller-2" | "steamcon2" | "sc2" | "ibex" => { GamepadPref::SteamController2 } + "steamcontroller2puck" | "steam-controller-2-puck" | "sc2puck" | "ibexpuck" => { + GamepadPref::SteamController2Puck + } _ => return None, }) } /// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`, /// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`, - /// `"steamcontroller2"`). + /// `"steamcontroller2"`, `"steamcontroller2puck"`). pub fn as_str(self) -> &'static str { match self { GamepadPref::Auto => "auto", @@ -258,6 +267,7 @@ impl GamepadPref { GamepadPref::DualSenseEdge => "dualsenseedge", GamepadPref::SwitchPro => "switchpro", GamepadPref::SteamController2 => "steamcontroller2", + GamepadPref::SteamController2Puck => "steamcontroller2puck", } } } diff --git a/crates/punktfunk-core/src/quic/tests.rs b/crates/punktfunk-core/src/quic/tests.rs index 39c30348..af08942b 100644 --- a/crates/punktfunk-core/src/quic/tests.rs +++ b/crates/punktfunk-core/src/quic/tests.rs @@ -343,11 +343,12 @@ fn gamepad_pref_wire_and_names() { GamepadPref::DualSenseEdge, GamepadPref::SwitchPro, GamepadPref::SteamController2, + GamepadPref::SteamController2Puck, ] { assert_eq!(GamepadPref::from_u8(p.to_u8()), p); assert_eq!(GamepadPref::from_name(p.as_str()), Some(p)); } - // Every wire byte 0..=9 is assigned, distinct, and pinned (forward-compat with peers + // Every wire byte 0..=10 is assigned, distinct, and pinned (forward-compat with peers // that only know a prefix of the range). for (v, p) in [ (0, GamepadPref::Auto), @@ -360,12 +361,13 @@ fn gamepad_pref_wire_and_names() { (7, GamepadPref::DualSenseEdge), (8, GamepadPref::SwitchPro), (9, GamepadPref::SteamController2), + (10, GamepadPref::SteamController2Puck), ] { assert_eq!(p.to_u8(), v); assert_eq!(GamepadPref::from_u8(v), p); } // The next unassigned byte degrades to Auto today; assigning it later must update this. - assert_eq!(GamepadPref::from_u8(10), GamepadPref::Auto); + assert_eq!(GamepadPref::from_u8(11), GamepadPref::Auto); // Aliases + unknowns. assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense)); assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360)); @@ -387,6 +389,10 @@ fn gamepad_pref_wire_and_names() { GamepadPref::from_name("sc2"), Some(GamepadPref::SteamController2) ); + assert_eq!( + GamepadPref::from_name("sc2puck"), + Some(GamepadPref::SteamController2Puck) + ); assert_eq!( GamepadPref::from_name("xbox-one"), Some(GamepadPref::XboxOne) diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 2cf384e3..d19998bb 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -26,6 +26,7 @@ mdns-sd = "0.20" mac_address = "1" if-addrs = "0.13" tokio = { version = "1", features = ["full"] } +parking_lot = "0.12" rsa = "0.9" sha2 = { version = "0.10", features = ["oid"] } aes = "0.8" diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller2.rs b/crates/punktfunk-host/src/inject/linux/steam_controller2.rs index 3b45ed10..81b492ef 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller2.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller2.rs @@ -272,9 +272,14 @@ impl TritonTransport { /// Open the best Steam-visible SC2 transport: **usbip (`vhci_hcd`) → UHID.** Steam is confirmed /// (on-glass 2026-07-15) to ignore the UHID leg, so reaching the fallback means the pad exists as /// hidraw only — flagged loudly, with the vhci_hcd remedy in the log. -fn open_transport(idx: u8) -> Result { +fn open_transport(idx: u8, puck: bool) -> Result { if crate::inject::steam_usbip::usbip_preferred() { - match crate::inject::triton_usbip::TritonUsbip::open(idx) { + let opened = if puck { + crate::inject::triton_usbip::TritonUsbip::open_puck(idx) + } else { + crate::inject::triton_usbip::TritonUsbip::open(idx) + }; + match opened { Ok(u) => return Ok(TritonTransport::Usbip(u)), Err(e) => { tracing::warn!(error = %format!("{e:#}"), "usbip SC2 unavailable — falling back to UHID") @@ -294,7 +299,15 @@ fn open_transport(idx: u8) -> Result { /// The Triton-specific half of the shared stateful manager (see [`PadProto`]): raw mirroring /// with the typed fallback, and the raw-forwarding service pass. #[derive(Default)] -pub struct TritonProto; +pub struct TritonProto { + puck: bool, +} + +impl TritonProto { + pub fn puck() -> Self { + Self { puck: true } + } +} impl PadProto for TritonProto { type Pad = TritonTransport; @@ -304,7 +317,7 @@ impl PadProto for TritonProto { const CREATE_HINT: &'static str = ""; fn open(&mut self, idx: u8) -> Result { - open_transport(idx) + open_transport(idx, self.puck) } fn neutral(&self) -> TritonState { diff --git a/crates/punktfunk-host/src/inject/linux/triton_usbip.rs b/crates/punktfunk-host/src/inject/linux/triton_usbip.rs index 8935ad5e..238754a6 100644 --- a/crates/punktfunk-host/src/inject/linux/triton_usbip.rs +++ b/crates/punktfunk-host/src/inject/linux/triton_usbip.rs @@ -1,24 +1,15 @@ //! Virtual **Steam Controller 2** over USB/IP (`vhci_hcd`) — the Steam-promotable transport for //! the as-is passthrough backend ([`super::steam_controller2`]). The UHID leg was confirmed //! on-glass to be invisible to Steam (`Interface: -1`, the same gap the Deck had pre-usbip), so -//! this presents a *real* USB device instead, byte-matched to an `lsusb -v` capture of the -//! physical WIRED pad (2026-07-15, firmware bcdDevice 3.07): +//! physical devices captured on 2026-07-15: //! -//! - device: bcdUSB 2.00, class `EF/02/01` (IAD convention, as shipped), Full Speed, -//! strings `Valve Software` / `Steam Controller`, 1 configuration (bus powered, 500 mA); -//! - one interface (#0): HID `03/00/00`, bcdHID 1.11, EP `0x81` interrupt-IN + `0x01` -//! interrupt-OUT, 64-byte packets, `bInterval 1` (1 kHz). +//! - direct wired: `28DE:1302`, one Triton HID interface; +//! - Puck: `28DE:1304`, CDC interfaces 0–1, four identical Triton HID controller slots on +//! interfaces 2–5, and the Puck management HID on interface 6. //! -//! (The Puck dongle presents a 7-interface layout — CDC pair + controllers on 2..5 — but the -//! wired identity is simpler and, per SDL's own matcher, the wired PID is accepted on ANY -//! interface number. Wired is what we emulate.) -//! -//! Semantics mirror the UHID leg: interrupt-IN streams the client's raw reports verbatim (or the -//! typed-fallback `0x42` synth), interrupt-OUT captures Steam's haptic output reports (`0x80` -//! rumble parsed for the 0xCA plane, everything forwarded raw), EP0 SET_REPORT features are -//! remembered + forwarded raw, and EP0 GET_REPORT answers a canned Triton-shaped serial (the -//! query dance can't round-trip to the physical pad synchronously). The vhci plumbing + attach -//! choreography come from [`super::steam_usbip`] (one source of truth with the Deck). +//! Report bodies are never translated. The declared client kind selects only the physical USB +//! topology which owned those bytes. Interrupt-OUT and feature SET_REPORT traffic is captured and +//! returned to the Android physical-device owner exactly as on the UHID leg. use super::steam_usbip::{attach_device, boxed, UsbipAttachment}; use super::triton_proto::{ @@ -26,8 +17,10 @@ use super::triton_proto::{ triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN, }; use anyhow::Result; +use parking_lot::Mutex; use std::any::Any; -use std::sync::{Arc, Mutex}; +use std::collections::VecDeque; +use std::sync::Arc; use usbip_sim::{ Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbSpeed, Version, @@ -35,6 +28,15 @@ use usbip_sim::{ const TRITON_VENDOR: u16 = 0x28DE; const TRITON_WIRED_PRODUCT: u16 = 0x1302; +const TRITON_PUCK_PRODUCT: u16 = 0x1304; + +/// Captured interface-6 Puck management HID descriptor (54 bytes). +const PUCK_MANAGEMENT_RDESC: &[u8] = &[ + 0x06, 0x00, 0xFF, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, + 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, + 0x01, 0x09, 0x79, 0x81, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, + 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0, +]; /// Everything Steam wrote to the device since the last service pass. #[derive(Debug, Default)] @@ -45,6 +47,64 @@ pub(crate) struct TritonUsbFeedback { pub raw: Vec<(u8, Vec)>, } +#[derive(Clone, Copy, Debug)] +struct InputReport { + data: [u8; 64], + len: u8, +} + +impl Default for InputReport { + fn default() -> Self { + Self { + data: [0; 64], + len: 0, + } + } +} + +/// A physical interrupt-IN endpoint queues sparse reports, but replays continuous controller +/// state when the host polls faster than the controller. Keeping only one latest report loses a +/// battery/RSSI packet to the following 250 Hz state packet before USB/IP can observe it. +#[derive(Debug)] +struct InputReports { + latest_state: InputReport, + pending: VecDeque, +} + +impl InputReports { + fn new(latest_state: InputReport) -> Self { + Self { + latest_state, + pending: VecDeque::new(), + } + } + + fn with_pending(latest_state: InputReport, pending: InputReport) -> Self { + let mut reports = Self::new(latest_state); + reports.pending.push_back(pending); + reports + } + + fn write(&mut self, report: InputReport) { + match report.data[0] { + // Continuous controller-state formats. Only the newest sample matters. + 0x42 | 0x45 | 0x47 => self.latest_state = report, + // Battery (0x43), RSSI/status (0x44/0x7B), wireless edges (0x46/0x79), and + // future sparse report types must each survive until Steam consumes them. + _ => { + if self.pending.len() >= 32 { + self.pending.pop_front(); + } + self.pending.push_back(report); + } + } + } + + fn read(&mut self) -> InputReport { + self.pending.pop_front().unwrap_or(self.latest_state) + } +} + /// The 9-byte HID class descriptor: bcdHID **1.11**, country 0, one report descriptor — the /// captured wired values ([`super::steam_usbip`]'s shared helper bakes the Deck's 1.10/33). fn triton_hid_desc() -> Vec { @@ -62,15 +122,85 @@ fn triton_hid_desc() -> Vec { ] } +fn triton_puck_feature_reply(last_set: &[u8], serial: &str, unit_id: u32, status: u8) -> [u8; 64] { + let Some((&report_id, body)) = last_set.split_first() else { + return triton_feature_reply(last_set, serial, unit_id); + }; + if report_id == 0x01 { + if body.first() == Some(&0xED) { + let mut reply = [0u8; 64]; + reply[..3].copy_from_slice(&[0x01, 0xED, 0]); + let payload = body.get(2..).unwrap_or_default(); + if payload.starts_with(b"user/wireless_transport") { + reply[2] = 1; + reply[3] = 2; // active Puck slot 0 maps to transport code 0 XOR 2 + } else if status == 0x02 && payload.starts_with(b"esb/bond") { + reply[2] = 0x18; + write_puck_bond(&mut reply[3..27], serial, unit_id); + } + return reply; + } + return triton_feature_reply(last_set, serial, unit_id); + } + if report_id != 0x02 { + return triton_feature_reply(last_set, serial, unit_id); + } + + let cmd = body.first().copied().unwrap_or(0xB4); + let mut reply = [0u8; 64]; + reply[0] = 0x02; + reply[1] = cmd; + match cmd { + 0x83 => { + reply[2] = 0x19; + let attrs = [ + (0x01, TRITON_PUCK_PRODUCT as u32), + (0x02, 0), + (0x0A, unit_id ^ 0xFC), + (0x04, unit_id ^ 0x0296_DA2C), + (0x09, 0x47), + ]; + let mut o = 3; + for (id, value) in attrs { + reply[o] = id; + reply[o + 1..o + 5].copy_from_slice(&value.to_le_bytes()); + o += 5; + } + } + 0xA3 => { + reply[2] = 0x18; + if status == 0x02 { + write_puck_bond(&mut reply[3..27], serial, unit_id); + } + } + 0xB4 => reply[..4].copy_from_slice(&[0x02, 0xB4, 0x01, status]), + _ => { + let n = body.len().min(63); + reply[1..1 + n].copy_from_slice(&body[..n]); + } + } + reply +} + +fn write_puck_bond(out: &mut [u8], serial: &str, unit_id: u32) { + out[..4].copy_from_slice(&unit_id.to_le_bytes()); + out[4..8].copy_from_slice(&(unit_id ^ 0x67BF_44D2).to_le_bytes()); + let serial = serial.as_bytes(); + let len = serial.len().min(16); + out[8..8 + len].copy_from_slice(&serial[..len]); +} + /// Interface 0: streams the current report on interrupt-IN, captures Steam's writes. #[derive(Debug)] struct TritonHandler { - /// The current input report (zero-padded to the 64-byte packet size), shared with + /// Latest controller state plus sparse reports awaiting an interrupt-IN poll, shared with /// [`TritonUsbip::write_state`]. - report: Arc>, - feedback: Arc>, + reports: Arc>, + feedback: Option>>, serial: String, unit_id: u32, + /// `None` for wired; Puck slots answer `0xB4` with 2 (connected) or 1 (disconnected). + puck_status: Option, /// The last feature SET_REPORT (id-first) — the query half of the Valve GET dance. last_set: Vec, /// Last GET query command logged (once per distinct cmd, for the tester-facing journal). @@ -83,12 +213,14 @@ impl TritonHandler { if data.is_empty() { return; } - if let Ok(mut fb) = self.feedback.lock() { - if fb.raw.len() >= 32 { - fb.raw.remove(0); - } - fb.raw.push((kind, data)); + let Some(feedback) = &self.feedback else { + return; + }; + let mut fb = feedback.lock(); + if fb.raw.len() >= 32 { + fb.raw.remove(0); } + fb.raw.push((kind, data)); } } @@ -115,7 +247,16 @@ impl UsbInterfaceHandler for TritonHandler { // with the wrong command type makes Steam drop the pad (confirmed on-glass // 2026-07-15); the dance can't round-trip to the physical pad synchronously. (0xA1, 0x01) => { - let reply = triton_feature_reply(&self.last_set, &self.serial, self.unit_id); + let reply = if let Some(status) = self.puck_status { + triton_puck_feature_reply( + &self.last_set, + &self.serial, + self.unit_id, + status, + ) + } else { + triton_feature_reply(&self.last_set, &self.serial, self.unit_id) + }; if reply[1] != self.last_get_logged { self.last_get_logged = reply[1]; tracing::info!( @@ -125,11 +266,11 @@ impl UsbInterfaceHandler for TritonHandler { } reply.to_vec() } - // HID SET_REPORT (feature): remember the command (it selects the next GET's - // answer) and forward raw for replay on the physical pad. EP0 OUT data may or - // may not carry the report-id byte depending on the writer's stack (the id also - // rides wValue's low byte) — normalize to id-first for the client. + // HID SET_REPORT: report type rides wValue's high byte (2 = OUTPUT, 3 = FEATURE) + // and the report id rides its low byte. EP0 OUT data may or may not repeat the id, + // so normalize to id-first before returning it to the physical-device owner. (0x21, 0x09) => { + let report_type = (setup.value >> 8) as u8; let id = (setup.value & 0xFF) as u8; let framed = if req.first() == Some(&id) && id != 0 { req.to_vec() @@ -139,26 +280,39 @@ impl UsbInterfaceHandler for TritonHandler { v.extend_from_slice(req); v }; - self.last_set = framed.clone(); - self.queue_raw(HID_RAW_FEATURE, framed); + match report_type { + 2 => { + if let (Some(r), Some(feedback)) = + (parse_triton_rumble(&framed), self.feedback.as_ref()) + { + feedback.lock().rumble = Some(r); + } + self.queue_raw(HID_RAW_OUTPUT, framed); + } + 3 => { + self.last_set = framed.clone(); + self.queue_raw(HID_RAW_FEATURE, framed); + } + _ => {} + } vec![] } (0x21, 0x0A) | (0x21, 0x0B) => vec![], // SET_IDLE / SET_PROTOCOL _ => vec![], }) } else if let Direction::In = ep.direction() { - // Interrupt-IN poll (paced by bInterval = 1 ms): the current report, zero-padded — - // exactly the 64-byte packets the real wired pad produces. - let r = self.report.lock().map(|g| *g).unwrap_or([0u8; 64]); - Ok(r.to_vec()) + // Replay continuous state, but consume sparse battery/RSSI/wireless reports exactly + // once so a following 250 Hz state packet cannot erase them before Steam polls. + let r = self.reports.lock().read(); + Ok(r.data[..r.len as usize].to_vec()) } else { // Interrupt-OUT: Steam's haptic output reports (`SDL_hid_write` — id-first framing // on the wire already). Parse rumble for the universal plane, forward everything raw. if !req.is_empty() { - if let Some(r) = parse_triton_rumble(req) { - if let Ok(mut fb) = self.feedback.lock() { - fb.rumble = Some(r); - } + if let (Some(r), Some(feedback)) = + (parse_triton_rumble(req), self.feedback.as_ref()) + { + feedback.lock().rumble = Some(r); } self.queue_raw(HID_RAW_OUTPUT, req.to_vec()); } @@ -171,11 +325,147 @@ impl UsbInterfaceHandler for TritonHandler { } } +#[derive(Debug)] +struct IdleHandler { + class_descriptor: Vec, + report_descriptor: &'static [u8], + input_report: &'static [u8], +} + +impl UsbInterfaceHandler for IdleHandler { + fn get_class_specific_descriptor(&self) -> Vec { + self.class_descriptor.clone() + } + + fn handle_urb( + &mut self, + _interface: &UsbInterface, + ep: UsbEndpoint, + _len: u32, + setup: SetupPacket, + _req: &[u8], + ) -> std::io::Result> { + if ep.is_ep0() + && setup.request_type == 0x81 + && setup.request == 0x06 + && (setup.value >> 8) == 0x22 + { + Ok(self.report_descriptor.to_vec()) + } else if !ep.is_ep0() && matches!(ep.direction(), Direction::In) { + Ok(self.input_report.to_vec()) + } else { + Ok(vec![]) + } + } + + fn as_any(&mut self) -> &mut dyn Any { + self + } +} + +#[derive(Debug)] +struct CdcControlHandler; + +impl UsbInterfaceHandler for CdcControlHandler { + fn get_class_specific_descriptor(&self) -> Vec { + vec![ + 0x05, 0x24, 0x00, 0x10, 0x01, // CDC header, bcdCDC 1.10 + 0x05, 0x24, 0x01, 0x00, 0x01, // call management → data interface 1 + 0x04, 0x24, 0x02, 0x02, // ACM: line coding + serial state + 0x05, 0x24, 0x06, 0x00, 0x01, // union: master 0, slave 1 + ] + } + + fn handle_urb( + &mut self, + _interface: &UsbInterface, + _ep: UsbEndpoint, + _len: u32, + setup: SetupPacket, + _req: &[u8], + ) -> std::io::Result> { + Ok(match (setup.request_type, setup.request) { + (0xA1, 0x21) => vec![0x00, 0xC2, 0x01, 0x00, 0x00, 0x00, 0x08], // 115200 8N1 + _ => vec![], // SET_LINE_CODING / SET_CONTROL_LINE_STATE / endpoint polls + }) + } + + fn as_any(&mut self) -> &mut dyn Any { + self + } +} + +#[derive(Debug, Default)] +struct PuckManagementHandler { + last_set: Vec, +} + +impl UsbInterfaceHandler for PuckManagementHandler { + fn get_class_specific_descriptor(&self) -> Vec { + let len = PUCK_MANAGEMENT_RDESC.len() as u16; + vec![ + 0x09, + 0x21, + 0x11, + 0x01, + 0, + 1, + 0x22, + len as u8, + (len >> 8) as u8, + ] + } + + fn handle_urb( + &mut self, + _interface: &UsbInterface, + ep: UsbEndpoint, + _len: u32, + setup: SetupPacket, + req: &[u8], + ) -> std::io::Result> { + if !ep.is_ep0() { + return Ok(vec![]); + } + Ok(match (setup.request_type, setup.request) { + (0x81, 0x06) if (setup.value >> 8) == 0x22 => PUCK_MANAGEMENT_RDESC.to_vec(), + (0x21, 0x09) => { + let id = (setup.value & 0xFF) as u8; + self.last_set.clear(); + if req.first() != Some(&id) && id != 0 { + self.last_set.push(id); + } + self.last_set.extend_from_slice(req); + vec![] + } + (0xA1, 0x01) => { + let mut reply = vec![0u8; 64]; + reply[0] = 0x02; + let command = self.last_set.get(1).copied().unwrap_or(0); + reply[1] = command; + // Captured management response: SET 02 B4 00..., GET returns + // 02 B4 01 01... (the management interface itself is not a controller slot). + if command == 0xB4 { + reply[2] = 1; + reply[3] = 1; + } + reply + } + (0x21, 0x0A) | (0x21, 0x0B) => vec![], + _ => vec![], + }) + } + + fn as_any(&mut self) -> &mut dyn Any { + self + } +} + /// Assemble the simulated wired Steam Controller 2 (see the module docs for the capture it -/// matches). The handler shares `report` + `feedback` with the owning [`TritonUsbip`]. +/// matches). The handler shares `reports` + `feedback` with the owning [`TritonUsbip`]. fn build_triton_device( index: u8, - report: &Arc>, + reports: &Arc>, feedback: &Arc>, ) -> UsbDevice { let ep = |addr: u8| UsbEndpoint { @@ -197,6 +487,8 @@ fn build_triton_device( dev.set_product_name("Steam Controller"); dev.set_serial_number(&triton_serial(index)); dev.unset_configuration_name(); // real iConfiguration = 0 + dev.configuration_attributes = 0xA0; // bus powered + remote wakeup + dev.configuration_max_power = 250; // 500 mA in 2 mA units dev.with_interface( 0x03, // HID 0x00, @@ -204,20 +496,131 @@ fn build_triton_device( None, // real iInterface = 0 vec![ep(0x81), ep(0x01)], boxed(TritonHandler { - report: report.clone(), - feedback: feedback.clone(), + reports: reports.clone(), + feedback: Some(feedback.clone()), serial: triton_serial(index), unit_id: triton_unit_id(index), + puck_status: None, last_set: Vec::new(), last_get_logged: 0, }), ) } +/// Assemble the captured `28DE:1304` Puck topology. A punktfunk wire pad occupies virtual Puck +/// slot 0 (interface 2); slots 1–3 remain present but disconnected, matching an unpaired bank. +fn build_puck_device( + index: u8, + reports: &Arc>, + feedback: &Arc>, +) -> UsbDevice { + let interrupt = |addr: u8, interval: u8| UsbEndpoint { + address: addr, + attributes: 0x03, + max_packet_size: 64, + interval, + }; + let bulk = |addr: u8| UsbEndpoint { + address: addr, + attributes: 0x02, + max_packet_size: 64, + interval: 0, + }; + + let mut dev = UsbDevice::new(0); + dev.vendor_id = TRITON_VENDOR; + dev.product_id = TRITON_PUCK_PRODUCT; + dev.usb_version = Version::from(0x0201u16); + dev.device_bcd = Version::from(0x0002u16); + dev.device_class = 0xEF; + dev.device_subclass = 0x02; + dev.device_protocol = 0x01; + dev.speed = UsbSpeed::Full as u32; + dev.configuration_attributes = 0xA0; + dev.configuration_max_power = 250; + dev.configuration_descriptor_prefix = vec![0x08, 0x0B, 0x00, 0x02, 0x02, 0x02, 0x00, 0x00]; // CDC IAD, interfaces 0–1 + dev.bos_descriptor = Some(vec![ + 0x05, 0x0F, 0x0C, 0x00, 0x01, // BOS, one capability + 0x07, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, // USB 2 extension, no LPM + ]); + dev.set_manufacturer_name("Valve Software"); + dev.set_product_name("Steam Controller Puck"); + dev.set_serial_number(&format!("FVPFPUCK{index:04}")); + dev.unset_configuration_name(); + + dev = dev.with_interface( + 0x02, + 0x02, + 0x00, + None, + vec![UsbEndpoint { + address: 0x81, + attributes: 0x03, + max_packet_size: 16, + interval: 10, + }], + boxed(CdcControlHandler), + ); + dev = dev.with_interface( + 0x0A, + 0x00, + 0x00, + None, + vec![bulk(0x82), bulk(0x01)], + boxed(IdleHandler { + class_descriptor: vec![], + report_descriptor: &[], + input_report: &[], + }), + ); + + for slot in 0u8..4 { + let (slot_reports, slot_feedback, puck_status) = if slot == 0 { + (reports.clone(), Some(feedback.clone()), 0x02) + } else { + // An unpaired physical slot leaves its interrupt-IN URB pending. The simulator + // cannot defer one URB without stalling the shared USB/IP command stream, so + // complete it empty instead. Replaying 0x79/0x01 here would announce a disconnect + // every 2 ms and keep Steam re-probing every slot. + ( + Arc::new(Mutex::new(InputReports::new(InputReport::default()))), + None, + 0x01, + ) + }; + let handler = boxed(TritonHandler { + reports: slot_reports, + feedback: slot_feedback, + serial: triton_serial(index), + unit_id: triton_unit_id(index), + puck_status: Some(puck_status), + last_set: Vec::new(), + last_get_logged: 0, + }); + dev = dev.with_interface( + 0x03, + 0x00, + 0x00, + None, + vec![interrupt(0x83 + slot, 2), interrupt(0x02 + slot, 2)], + handler, + ); + } + + dev.with_interface( + 0x03, + 0x00, + 0x00, + None, + vec![interrupt(0x87, 32), interrupt(0x06, 32)], + boxed(PuckManagementHandler::default()), + ) +} + /// A virtual Steam Controller 2 presented over USB/IP. Dropping it detaches the `vhci_hcd` port /// (the device disappears, Steam releases it) and stops the emulation server. pub struct TritonUsbip { - report: Arc>, + reports: Arc>, feedback: Arc>, _attach: UsbipAttachment, seq: u8, @@ -227,67 +630,118 @@ impl TritonUsbip { /// Bind a virtual wired SC2 and attach it locally via `vhci_hcd` (root + `vhci_hcd` loaded; /// see [`super::steam_usbip::attach_device`]). `index` varies only the serial. pub fn open(index: u8) -> Result { - let report = Arc::new(Mutex::new(neutral_report())); + let reports = Arc::new(Mutex::new(InputReports::new(neutral_report()))); let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default())); let attach = attach_device( - || build_triton_device(index, &report, &feedback), + || build_triton_device(index, &reports, &feedback), &format!("virtual Steam Controller 2 {index}"), )?; Ok(TritonUsbip { - report, + reports, feedback, _attach: attach, seq: 0, }) } - /// Mirror one report onto the interrupt-IN stream: the client's raw bytes verbatim in as-is - /// mode (zero-padded to the 64-byte packet), else a synthesized minimal `0x42` state report. + /// Bind the captured seven-interface Puck identity. The forwarded controller occupies Puck + /// slot 0; the other three HID interfaces remain visible as disconnected slots. + pub fn open_puck(index: u8) -> Result { + let reports = Arc::new(Mutex::new(InputReports::with_pending( + neutral_report(), + puck_connect_report(), + ))); + let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default())); + let attach = attach_device( + || build_puck_device(index, &reports, &feedback), + &format!("virtual Steam Controller 2 Puck {index}"), + )?; + Ok(TritonUsbip { + reports, + feedback, + _attach: attach, + seq: 0, + }) + } + + /// Mirror one report onto the interrupt-IN stream: continuous state replaces the prior sample; + /// sparse physical reports retain their native length and queue until Steam consumes them. pub fn write_state(&mut self, st: &TritonState) { - let mut r = [0u8; 64]; + let mut report = InputReport::default(); if st.raw_len > 0 { - let len = (st.raw_len as usize).min(st.raw.len()).min(r.len()); - r[..len].copy_from_slice(&st.raw[..len]); + let len = (st.raw_len as usize) + .min(st.raw.len()) + .min(report.data.len()); + report.data[..len].copy_from_slice(&st.raw[..len]); + report.len = len as u8; } else { self.seq = self.seq.wrapping_add(1); let mut s = [0u8; TRITON_STATE_LEN]; serialize_triton_state(&mut s, st, self.seq); - r[..TRITON_STATE_LEN].copy_from_slice(&s); - } - if let Ok(mut g) = self.report.lock() { - *g = r; + report.data[..TRITON_STATE_LEN].copy_from_slice(&s); + report.len = TRITON_STATE_LEN as u8; } + self.reports.lock().write(report); } /// Drain everything Steam wrote to the device since the last pass. pub fn service(&mut self) -> TritonUsbFeedback { - self.feedback - .lock() - .map(|mut f| std::mem::take(&mut *f)) - .unwrap_or_default() + std::mem::take(&mut *self.feedback.lock()) } } -/// An idle `0x42` state report — what the interrupt-IN endpoint streams before the first write. -fn neutral_report() -> [u8; 64] { - let mut r = [0u8; 64]; +/// An idle `0x42` state report — what the wired endpoint streams before the first write. +fn neutral_report() -> InputReport { + let mut report = InputReport { + len: TRITON_STATE_LEN as u8, + ..InputReport::default() + }; let mut s = [0u8; TRITON_STATE_LEN]; serialize_triton_state(&mut s, &TritonState::neutral(), 0); - r[..TRITON_STATE_LEN].copy_from_slice(&s); - r + report.data[..TRITON_STATE_LEN].copy_from_slice(&s); + report +} + +/// The Puck reports its wireless connect edge before the first controller state packet. +fn puck_connect_report() -> InputReport { + let mut report = InputReport { + len: 2, + ..InputReport::default() + }; + report.data[..2].copy_from_slice(&[0x79, 0x02]); + report } #[cfg(test)] mod tests { use super::*; + #[test] + fn sparse_input_report_survives_following_state() { + let mut reports = InputReports::new(neutral_report()); + let mut signal = InputReport { + len: 13, + ..InputReport::default() + }; + signal.data[..3].copy_from_slice(&[0x7B, 0xF8, 0x01]); + let mut next_state = neutral_report(); + next_state.data[1] = 9; + + reports.write(signal); + reports.write(next_state); + + assert_eq!(reports.read().data[..3], [0x7B, 0xF8, 0x01]); + assert_eq!(reports.read().data[1], 9); + assert_eq!(reports.read().data[1], 9); // continuous state replays + } + /// The simulated device matches the captured wired identity byte-for-byte where Steam looks: /// VID/PID, device class triplet, bcdDevice, ONE HID interface with the IN+OUT endpoint pair. #[test] fn device_matches_wired_capture() { - let report = Arc::new(Mutex::new([0u8; 64])); + let reports = Arc::new(Mutex::new(InputReports::new(InputReport::default()))); let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default())); - let dev = build_triton_device(3, &report, &feedback); + let dev = build_triton_device(3, &reports, &feedback); assert_eq!((dev.vendor_id, dev.product_id), (0x28DE, 0x1302)); assert_eq!( (dev.device_class, dev.device_subclass, dev.device_protocol), @@ -320,18 +774,176 @@ mod tests { assert!(triton_serial(3).starts_with("FVPF")); // the conflict-gate exclusion prefix } + /// The Puck capture's complete configuration is 235 bytes: CDC IAD + CDC pair + four + /// controller HID slots + management HID. Endpoint numbers and intervals are slot-significant. + #[test] + fn device_matches_puck_capture() { + let reports = Arc::new(Mutex::new(InputReports::with_pending( + neutral_report(), + puck_connect_report(), + ))); + let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default())); + let dev = build_puck_device(1, &reports, &feedback); + assert_eq!((dev.vendor_id, dev.product_id), (0x28DE, 0x1304)); + assert_eq!( + ( + dev.usb_version.major, + dev.usb_version.minor, + dev.usb_version.patch, + ), + (0x02, 0x00, 0x01) + ); + assert_eq!( + ( + dev.device_bcd.major, + dev.device_bcd.minor, + dev.device_bcd.patch, + ), + (0x00, 0x00, 0x02) + ); + assert_eq!( + (dev.device_class, dev.device_subclass, dev.device_protocol), + (0xEF, 0x02, 0x01) + ); + assert_eq!(dev.speed, UsbSpeed::Full as u32); + assert_eq!( + (dev.configuration_attributes, dev.configuration_max_power), + (0xA0, 250) + ); + assert_eq!( + dev.configuration_descriptor_prefix, + [0x08, 0x0B, 0x00, 0x02, 0x02, 0x02, 0x00, 0x00] + ); + assert_eq!(dev.bos_descriptor.as_ref().map(Vec::len), Some(12)); + assert_eq!(dev.interfaces.len(), 7); + let classes: Vec<(u8, u8, u8)> = dev + .interfaces + .iter() + .map(|i| { + ( + i.interface_class, + i.interface_subclass, + i.interface_protocol, + ) + }) + .collect(); + assert_eq!( + classes, + [ + (0x02, 0x02, 0x00), + (0x0A, 0x00, 0x00), + (0x03, 0x00, 0x00), + (0x03, 0x00, 0x00), + (0x03, 0x00, 0x00), + (0x03, 0x00, 0x00), + (0x03, 0x00, 0x00), + ] + ); + let endpoints: Vec> = dev + .interfaces + .iter() + .map(|i| { + i.endpoints + .iter() + .map(|e| (e.address, e.attributes, e.max_packet_size, e.interval)) + .collect() + }) + .collect(); + assert_eq!(endpoints[0], [(0x81, 3, 16, 10)]); + assert_eq!(endpoints[1], [(0x82, 2, 64, 0), (0x01, 2, 64, 0)]); + for slot in 0u8..4 { + assert_eq!( + endpoints[slot as usize + 2], + [(0x83 + slot, 3, 64, 2), (0x02 + slot, 3, 64, 2)] + ); + assert_eq!( + dev.interfaces[slot as usize + 2] + .class_specific_descriptor + .len(), + 9 + ); + } + assert_eq!(endpoints[6], [(0x87, 3, 64, 32), (0x06, 3, 64, 32)]); + assert_eq!(dev.interfaces[6].class_specific_descriptor[7], 54); + let config_len = 9 + + dev.configuration_descriptor_prefix.len() + + dev + .interfaces + .iter() + .map(|i| 9 + i.class_specific_descriptor.len() + 7 * i.endpoints.len()) + .sum::(); + assert_eq!(config_len, 0x00EB); + let ep0 = UsbEndpoint { + address: 0, + attributes: 0, + max_packet_size: 64, + interval: 0, + }; + let slot_status = |interface: usize| { + let iface = dev.interfaces[interface].clone(); + let mut handler = iface.handler.lock().unwrap(); + handler + .handle_urb( + &iface, + ep0, + 0, + SetupPacket { + request_type: 0x21, + request: 0x09, + value: 0x0302, + index: interface as u16, + length: 3, + }, + &[0x02, 0xB4, 0x00], + ) + .unwrap(); + handler + .handle_urb( + &iface, + ep0, + 64, + SetupPacket { + request_type: 0xA1, + request: 0x01, + value: 0x0302, + index: interface as u16, + length: 64, + }, + &[], + ) + .unwrap()[..4] + .to_vec() + }; + assert_eq!(slot_status(2), [0x02, 0xB4, 0x01, 0x02]); + for interface in 3..=5 { + assert_eq!(slot_status(interface), [0x02, 0xB4, 0x01, 0x01]); + } + // A disconnected slot is quiescent. In particular, it must not replay the + // one-shot 0x79/0x01 disconnect edge on every 2 ms interrupt poll. + let iface = dev.interfaces[3].clone(); + let interrupt_in = iface.endpoints[0]; + assert!(iface + .handler + .lock() + .unwrap() + .handle_urb(&iface, interrupt_in, 64, SetupPacket::default(), &[],) + .unwrap() + .is_empty()); + } + /// Steam's interrupt-OUT rumble lands in the feedback (parsed + queued raw); EP0 feature /// writes are normalized to id-first framing whichever way the stack framed them. #[test] fn out_and_feature_writes_are_captured() { use punktfunk_core::quic::{HID_RAW_FEATURE, HID_RAW_OUTPUT}; - let report = Arc::new(Mutex::new([0u8; 64])); + let reports = Arc::new(Mutex::new(InputReports::new(InputReport::default()))); let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default())); let mut h = TritonHandler { - report, - feedback: feedback.clone(), + reports, + feedback: Some(feedback.clone()), serial: triton_serial(0), unit_id: triton_unit_id(0), + puck_status: None, last_set: Vec::new(), last_get_logged: 0, }; @@ -363,6 +975,16 @@ mod tests { rumble[7..9].copy_from_slice(&0x4000u16.to_le_bytes()); h.handle_urb(&iface_dummy, ep_out, 10, SetupPacket::default(), &rumble) .unwrap(); + // hidraw may issue an OUTPUT report through EP0 instead of the interrupt endpoint. + let setup = SetupPacket { + request_type: 0x21, + request: 0x09, + value: 0x0282, + index: 0, + length: 3, + }; + h.handle_urb(&iface_dummy, ep0, 3, setup, &[0x01, 0x01, 0xF7]) + .unwrap(); // Feature SET_REPORT with the id NOT in the payload (it rides wValue) → normalized. let setup = SetupPacket { request_type: 0x21, @@ -373,13 +995,14 @@ mod tests { }; h.handle_urb(&iface_dummy, ep0, 5, setup, &[0x87, 3, 9, 0, 0]) .unwrap(); - let fb = feedback.lock().unwrap(); + let fb = feedback.lock(); assert_eq!(fb.rumble, Some((0x2000, 0x4000))); - assert_eq!(fb.raw.len(), 2); + assert_eq!(fb.raw.len(), 3); assert_eq!(fb.raw[0].0, HID_RAW_OUTPUT); assert_eq!(fb.raw[0].1[0], 0x80); - assert_eq!(fb.raw[1].0, HID_RAW_FEATURE); - assert_eq!(&fb.raw[1].1[..3], &[0x01, 0x87, 3]); // id-first for the client replay + assert_eq!(fb.raw[1], (HID_RAW_OUTPUT, vec![0x82, 0x01, 0x01, 0xF7])); + assert_eq!(fb.raw[2].0, HID_RAW_FEATURE); + assert_eq!(&fb.raw[2].1[..3], &[0x01, 0x87, 3]); // id-first for the client replay } #[derive(Debug)] diff --git a/crates/punktfunk-host/src/inject/proto/triton_proto.rs b/crates/punktfunk-host/src/inject/proto/triton_proto.rs index 76902bc5..626c9089 100644 --- a/crates/punktfunk-host/src/inject/proto/triton_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/triton_proto.rs @@ -40,35 +40,48 @@ pub const ID_TRITON_CONTROLLER_STATE_TIMESTAMP: u8 = 0x47; /// universal 0xCA plane); every output report is forwarded raw regardless. pub const ID_OUT_REPORT_HAPTIC_RUMBLE: u8 = 0x80; -/// Fixed report size: 64-byte feature reports, input reports at most 64 (state is 46/54). +/// Physical `0x42` state report size: one report-id byte plus 53 payload bytes. pub const TRITON_REPORT_LEN: usize = 64; +pub const TRITON_STATE_LEN: usize = 54; -/// The `TritonMTUNoQuat_t` state payload (46 bytes with the leading report id). -pub const TRITON_STATE_LEN: usize = 46; - -/// Minimal vendor-defined HID report descriptor, mirroring [`super::steam_proto::STEAMDECK_RDESC`] -/// with an added OUTPUT item: the Triton receives haptics as output reports (`SDL_hid_write`), -/// not feature-only like the Deck, so hidapi consumers expect a writable interrupt-OUT-style -/// report to exist. All items unnumbered 64-byte — the raw bytes we mirror carry the Valve -/// report-type byte first, exactly like the physical device's stream. +/// The physical Triton HID report descriptor, captured byte-for-byte from both wired `28DE:1302` +/// and Puck `28DE:1304` controller interfaces. Its numbered reports are part of the protocol: +/// inputs `0x40`–`0x45`/`0x79`/`0x7B`, outputs `0x80`–`0x89`, and feature channels `1` and `2`. +/// In particular, Puck connection and bond queries use feature report 2; an unnumbered minimal +/// descriptor makes hidraw frame those queries incorrectly and Steam eventually closes the device. #[rustfmt::skip] pub const TRITON_RDESC: &[u8] = &[ - 0x06, 0x00, 0xFF, // Usage Page (Vendor-Defined 0xFF00) - 0x09, 0x01, // Usage (0x01) - 0xA1, 0x01, // Collection (Application) - 0x15, 0x00, // Logical Minimum (0) - 0x26, 0xFF, 0x00, // Logical Maximum (255) - 0x75, 0x08, // Report Size (8 bits) - 0x95, 0x40, // Report Count (64) - 0x09, 0x01, // Usage (0x01) - 0x81, 0x02, // Input (Data,Var,Abs) — the state/battery/wireless report stream - 0x09, 0x01, // Usage (0x01) - 0x95, 0x40, // Report Count (64) - 0x91, 0x02, // Output (Data,Var,Abs) — haptic commands (0x80 rumble, 0x81 pulse, …) - 0x09, 0x01, // Usage (0x01) - 0x95, 0x40, // Report Count (64) - 0xB1, 0x02, // Feature (Data,Var,Abs) — settings/attributes (report id 1 on the wire) - 0xC0, // End Collection + 0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00, + 0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, + 0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01, + 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, + 0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38, + 0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1, + 0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, + 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29, + 0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0, + 0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26, + 0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44, + 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81, + 0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01, + 0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, + 0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26, + 0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45, + 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81, + 0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, + 0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, + 0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26, + 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83, + 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91, + 0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08, + 0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, + 0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26, + 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87, + 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91, + 0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, + 0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, + 0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09, + 0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0, ]; /// Triton button bits in the state report's `buttons` u32 — transcribed verbatim from SDL's @@ -277,9 +290,9 @@ pub fn triton_serial(index: u8) -> String { pub fn triton_feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; 64] { const ID_GET_ATTRIBUTES_VALUES: u8 = 0x83; const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE; + const ID_GET_FIRMWARE_INFO: u8 = 0xF2; const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01; - // Normalize to the command + its payload, tolerating a missing report-id byte. let body = match last_set { [0x01, rest @ ..] => rest, d => d, @@ -287,22 +300,18 @@ pub fn triton_feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; let cmd = body.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE); let mut r = [0u8; 64]; - r[0] = 0x01; // feature report id + r[0] = 0x01; match cmd { ID_GET_ATTRIBUTES_VALUES => { - // [0x01, 0x83, 0x2d, then 9× (attr-id, value u32-LE)]. + // Captured controller response: 25-byte payload containing five id/u32 attributes. r[1] = ID_GET_ATTRIBUTES_VALUES; - r[2] = 0x2d; - let attrs: [(u8, u32); 9] = [ - (0x01, TRITON_WIRED_PRODUCT), // product id + r[2] = 0x19; + let attrs = [ + (0x01, TRITON_WIRED_PRODUCT), (0x02, 0), - (0x0a, unit_id), // per-instance unit identity - (0x04, unit_id ^ 0x5555_5555), - (0x09, 0x2e), - (0x0b, 0x0fa0), // connection interval 4000 µs — the pad's ~4 ms cadence - (0x0d, 0), - (0x0c, 0), - (0x0e, 0), + (0x0A, unit_id), + (0x04, unit_id ^ 0x0296_DAF9), + (0x09, 0x49), ]; let mut o = 3; for (id, val) in attrs { @@ -312,17 +321,42 @@ pub fn triton_feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; } } ID_GET_STRING_ATTRIBUTE => { - // [0x01, 0xAE, len, attr, ascii…]; the serial is string-attr 0x01. + // Captured replies always declare 20 bytes: attribute id plus a 19-byte padded string. let attr = body.get(2).copied().unwrap_or(ATTRIB_STR_UNIT_SERIAL); let b = serial.as_bytes(); - let len = b.len().clamp(1, 20); - r[1] = ID_GET_STRING_ATTRIBUTE; - r[2] = len as u8; - r[3] = attr; + let len = b.len().min(19); + r[..4].copy_from_slice(&[0x01, ID_GET_STRING_ATTRIBUTE, 0x14, attr]); r[4..4 + len].copy_from_slice(&b[..len]); } + ID_GET_FIRMWARE_INFO => { + let index = body.get(2).copied().unwrap_or(0); + r[1] = ID_GET_FIRMWARE_INFO; + r[3] = index; + match index { + 0 => { + r[2] = 0x29; + r[4..8].copy_from_slice(&(unit_id ^ 0x0296_DAF9).to_le_bytes()); + r[8] = 0x49; + r[12..24].copy_from_slice(b"603f69218a85"); + let b = serial.as_bytes(); + let len = b.len().min(16); + r[28..28 + len].copy_from_slice(&b[..len]); + } + 1 => { + r[2] = 0x22; + r[4..37].copy_from_slice(&[ + 0x00, 0x57, 0xD0, 0x18, 0x6A, 0x37, 0x30, 0x35, 0x34, 0x32, 0x35, 0x37, + 0x64, 0x32, 0x64, 0x61, 0x37, 0x00, 0x00, 0x00, 0x00, 0x23, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x6D, 0x02, 0x00, + ]); + } + _ => { + r[2] = 0x09; + r[4..12].copy_from_slice(&[0x7C, 0x4F, 0x01, 0x00, 0x01, 0, 0, 0]); + } + } + } _ => { - // Settings read-back (e.g. 0x87): echo the host's last command + data, id-first. let n = body.len().min(63); r[1..1 + n].copy_from_slice(&body[..n]); } @@ -392,22 +426,22 @@ mod tests { fn feature_reply_echoes_the_queried_command() { let serial = triton_serial(0); let uid = triton_unit_id(0); - // 0x83 attributes: id-first frame, 9 blocks, product id = 0x1302 in the first block. + // 0x83 attributes: id-first frame, 5 captured blocks, product id = 0x1302 in the first. let r = triton_feature_reply(&[0x01, 0x83, 0x00], &serial, uid); - assert_eq!(&r[..3], &[0x01, 0x83, 0x2d]); + assert_eq!(&r[..3], &[0x01, 0x83, 0x19]); assert_eq!(r[3], 0x01); // ATTRIB product-id tag assert_eq!( u32::from_le_bytes([r[4], r[5], r[6], r[7]]), TRITON_WIRED_PRODUCT ); - // 0xAE serial: echoes the requested string attribute + the FVPF serial. + // 0xAE serial: the captured fixed 20-byte payload — attribute id + padded string. let r = triton_feature_reply(&[0x01, 0xAE, 0x01, 0x01], &serial, uid); - assert_eq!(&r[..3], &[0x01, 0xAE, serial.len() as u8]); + assert_eq!(&r[..3], &[0x01, 0xAE, 0x14]); assert_eq!(r[3], 0x01); assert_eq!(&r[4..4 + serial.len()], serial.as_bytes()); // A stack that stripped the id byte still resolves the command. let r = triton_feature_reply(&[0x83u8, 0x00], &serial, uid); - assert_eq!(&r[..3], &[0x01, 0x83, 0x2d]); + assert_eq!(&r[..3], &[0x01, 0x83, 0x19]); // Anything else (settings write) reads back as an echo. let r = triton_feature_reply(&[0x01, 0x87, 3, 9, 0, 0], &serial, uid); assert_eq!(&r[..6], &[0x01, 0x87, 3, 9, 0, 0]); diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 63876ba7..56759751 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1886,6 +1886,8 @@ struct Pads { steamctrl: Option, #[cfg(target_os = "linux")] steamctrl2: Option, + #[cfg(target_os = "linux")] + steamctrl2_puck: Option, #[cfg(target_os = "windows")] dualsense_win: Option, #[cfg(target_os = "windows")] @@ -1925,6 +1927,8 @@ impl Pads { steamctrl: None, #[cfg(target_os = "linux")] steamctrl2: None, + #[cfg(target_os = "linux")] + steamctrl2_puck: None, #[cfg(target_os = "windows")] dualsense_win: None, #[cfg(target_os = "windows")] @@ -2013,6 +2017,15 @@ impl Pads { .get_or_insert_with(crate::inject::steam_controller2::Triton2Manager::new) .handle(ev), #[cfg(target_os = "linux")] + GamepadPref::SteamController2Puck => self + .steamctrl2_puck + .get_or_insert_with(|| { + crate::inject::steam_controller2::Triton2Manager::with_backend( + crate::inject::steam_controller2::TritonProto::puck(), + ) + }) + .handle(ev), + #[cfg(target_os = "linux")] GamepadPref::XboxOne => self .xboxone .get_or_insert_with(|| { @@ -2116,6 +2129,12 @@ impl Pads { m.apply_rich(rich) } } + #[cfg(target_os = "linux")] + GamepadPref::SteamController2Puck => { + if let Some(m) = &mut self.steamctrl2_puck { + m.apply_rich(rich) + } + } #[cfg(target_os = "windows")] GamepadPref::DualSense => { if let Some(m) = &mut self.dualsense_win { @@ -2144,6 +2163,17 @@ impl Pads { } } + /// Triton's USB output endpoint is polled at 1 kHz. Service its raw haptic writes on the same + /// cadence so PC-generated trackpad pulses do not sit for up to 4 ms and then arrive at the + /// client in bursts. Other backends keep the lower-frequency poll to avoid idle churn. + fn feedback_poll_interval(&self) -> std::time::Duration { + #[cfg(target_os = "linux")] + if self.steamctrl2.is_some() || self.steamctrl2_puck.is_some() { + return std::time::Duration::from_millis(1); + } + std::time::Duration::from_millis(4) + } + /// Service feedback for every instantiated backend each cycle. `rumble` carries motor /// force-feedback on the universal plane (every backend, tagged with its own pad index); /// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF @@ -2182,6 +2212,9 @@ impl Pads { if let Some(m) = &mut self.steamctrl2 { m.pump(&mut rumble, &mut hidout); } + if let Some(m) = &mut self.steamctrl2_puck { + m.pump(&mut rumble, &mut hidout); + } } #[cfg(target_os = "windows")] { @@ -2410,10 +2443,9 @@ fn input_thread( let mut held_buttons: std::collections::HashSet = std::collections::HashSet::new(); let mut held_keys: std::collections::HashSet = std::collections::HashSet::new(); loop { - match rx.recv_timeout(std::time::Duration::from_millis(4)) { - // Rich input (touchpad / motion) — applied the moment it arrives; the single - // channel means a gyro sample never waits out the 4 ms timeout behind an idle - // button plane. + match rx.recv_timeout(pads.feedback_poll_interval()) { + // Rich input (touchpad / motion) is applied the moment it arrives; the single channel + // wakes for gyro samples instead of making them wait out the feedback poll interval. Ok(ClientInput::Rich(rich)) => { if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) { let now = std::time::Instant::now(); @@ -2546,9 +2578,9 @@ fn input_thread( Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, } - // Service feedback every iteration (≤4 ms latency; games block on EVIOCSFF, and the - // DualSense kernel handshake must be answered promptly). Rumble → the universal 0xCA - // plane; DualSense rich feedback (lightbar / player LEDs / adaptive triggers) → 0xCD. + // Service feedback every iteration (≤1 ms for Triton, ≤4 ms otherwise; games block on + // EVIOCSFF, and HID handshakes must be answered promptly). Rumble → the universal 0xCA + // plane; rich/raw HID feedback → 0xCD. pads.pump( |pad, low, high| { let idx = pad as usize; @@ -2945,6 +2977,7 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool // the host drives it over hidraw (no kernel driver binds the PID; Steam Input is the // consumer). No Windows backend; folds to Xbox360 there. GamepadPref::SteamController2 if linux => GamepadPref::SteamController2, + GamepadPref::SteamController2Puck if linux => GamepadPref::SteamController2Puck, _ => GamepadPref::Xbox360, } } @@ -2963,6 +2996,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref { | GamepadPref::SteamDeck | GamepadPref::SteamController | GamepadPref::SteamController2 + | GamepadPref::SteamController2Puck | GamepadPref::SwitchPro ); if needs_uhid @@ -3028,7 +3062,10 @@ fn physical_steam_controller_present() -> bool { fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref { if !matches!( chosen, - GamepadPref::SteamDeck | GamepadPref::SteamController | GamepadPref::SteamController2 + GamepadPref::SteamDeck + | GamepadPref::SteamController + | GamepadPref::SteamController2 + | GamepadPref::SteamController2Puck ) { return chosen; } @@ -5705,6 +5742,18 @@ mod tests { ); assert_eq!(pick_gamepad(SteamController2, None, false, true), Xbox360); assert_eq!(pick_gamepad(SteamController2, None, false, false), Xbox360); + assert_eq!( + pick_gamepad(SteamController2Puck, None, true, false), + SteamController2Puck + ); + assert_eq!( + pick_gamepad(Auto, Some("sc2puck"), true, false), + SteamController2Puck + ); + assert_eq!( + pick_gamepad(SteamController2Puck, None, false, true), + Xbox360 + ); } #[test] diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/device.rs b/crates/punktfunk-host/vendor/usbip-sim/src/device.rs index e6f8ace7..bd7f5171 100644 --- a/crates/punktfunk-host/vendor/usbip-sim/src/device.rs +++ b/crates/punktfunk-host/vendor/usbip-sim/src/device.rs @@ -21,6 +21,10 @@ impl From for Version { } } +/// Extra descriptors emitted between the configuration descriptor and interface 0 (for example, +/// an Interface Association Descriptor for a CDC function). +pub type ConfigurationDescriptorPrefix = Vec; + /// Represent a USB device #[derive(Clone, Default, Debug)] #[cfg_attr(feature = "serde", derive(Serialize))] @@ -37,6 +41,11 @@ pub struct UsbDevice { pub device_subclass: u8, pub device_protocol: u8, pub configuration_value: u8, + pub configuration_attributes: u8, + pub configuration_max_power: u8, + pub configuration_descriptor_prefix: ConfigurationDescriptorPrefix, + /// Optional complete BOS descriptor. `None` uses the simulator's minimal empty BOS. + pub bos_descriptor: Option>, pub num_configurations: u8, pub interfaces: Vec, @@ -74,8 +83,10 @@ impl UsbDevice { max_packet_size: EP0_MAX_PACKET_SIZE, interval: 0, }, - // configured by default + // configured, bus-powered at 100 mA by default configuration_value: 1, + configuration_attributes: 0x80, + configuration_max_power: 0x32, num_configurations: 1, ..Self::default() }; @@ -290,8 +301,8 @@ impl UsbDevice { let mut desc = vec![ 0x12, // bLength Device as u8, // bDescriptorType: Device - self.usb_version.minor, - self.usb_version.major, // bcdUSB: USB 2.0 + (self.usb_version.minor << 4) | self.usb_version.patch, + self.usb_version.major, // bcdUSB self.device_class, // bDeviceClass self.device_subclass, // bDeviceSubClass self.device_protocol, // bDeviceProtocol @@ -300,8 +311,8 @@ impl UsbDevice { (self.vendor_id >> 8) as u8, self.product_id as u8, // idProduct (self.product_id >> 8) as u8, - self.device_bcd.minor, // bcdDevice - self.device_bcd.major, + (self.device_bcd.minor << 4) | self.device_bcd.patch, + self.device_bcd.major, // bcdDevice self.string_manufacturer, // iManufacturer self.string_product, // iProduct self.string_serial, // iSerial @@ -316,12 +327,14 @@ impl UsbDevice { } Some(BOS) => { debug!("Get BOS descriptor"); - let mut desc = vec![ - 0x05, // bLength - BOS as u8, // bDescriptorType: BOS - 0x05, 0x00, // wTotalLength - 0x00, // bNumCapabilities - ]; + let mut desc = self.bos_descriptor.clone().unwrap_or_else(|| { + vec![ + 0x05, // bLength + BOS as u8, // bDescriptorType: BOS + 0x05, 0x00, // wTotalLength + 0x00, // bNumCapabilities + ] + }); // requested len too short: wLength < real length if setup_packet.length < desc.len() as u16 { @@ -340,9 +353,10 @@ impl UsbDevice { self.interfaces.len() as u8, // bNumInterfaces self.configuration_value, // bConfigurationValue self.string_configuration, // iConfiguration - 0x80, // bmAttributes: Bus Powered - 0x32, // bMaxPower: 100mA + self.configuration_attributes, // bmAttributes + self.configuration_max_power, // bMaxPower (2 mA units) ]; + desc.extend_from_slice(&self.configuration_descriptor_prefix); for (i, intf) in self.interfaces.iter().enumerate() { let mut intf_desc = vec![ 0x09, // bLength @@ -515,9 +529,16 @@ impl UsbDevice { // server side, so an unpaced sim would spin the loopback link). HS bInterval N → // 2^(N-1) microframes × 125µs. if let In = ep.direction() { - let n = ep.interval.clamp(1, 16) as u32; - let period_us = (1u32 << (n - 1)) * 125; - tokio::time::sleep(std::time::Duration::from_micros(period_us as u64)).await; + let period = if self.speed == UsbSpeed::High as u32 + || self.speed == UsbSpeed::Super as u32 + || self.speed == UsbSpeed::SuperPlus as u32 + { + let n = ep.interval.clamp(1, 16) as u32; + std::time::Duration::from_micros((1u64 << (n - 1)) * 125) + } else { + std::time::Duration::from_millis(ep.interval.max(1) as u64) + }; + tokio::time::sleep(period).await; } let intf = intf.unwrap(); let mut handler = intf.handler.lock().unwrap(); diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index edd65e9a..c93ff526 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -141,6 +141,11 @@ // else folds to X-Box 360. #define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 9 +// Steam Controller Puck dongle (`28DE:1304`) passed through with its native seven-interface +// topology and four controller slots. Used by capture clients that own the physical Puck; +// ordinary wired/BLE SC2 capture remains `STEAMCONTROLLER2`. +#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK 10 + // 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`.