feat(gamepad): SC2 Puck-dongle passthrough with the native 28DE:1304 topology

Community-contributed round 5 of the Steam Controller 2 passthrough,
reviewed + verified. A Puck-captured pad now presents the dongle's real
seven-interface identity (CDC pair, four controller HID slots, management
HID) instead of relabelling its reports as a wired 1302 — Steam's Puck
feature dances (wireless_transport / esb/bond / 0xB4 slot status) get
capture-shaped answers, and the wired identity's canned replies are
corrected to the real captures (attribute count, string-attr framing,
0xF2 firmware info, bcdDevice nibble encoding).

- new wire pref 10 = SteamController2Puck (Hello/Welcome byte; older
  peers degrade to Auto), selected by the Android capture link when the
  transport is a dongle, or by VID/PID in the degraded InputDevice path
- TRITON_RDESC is now the captured numbered descriptor (mouse/keyboard
  lizard collections + per-id vendor reports); unnumbered framing made
  hidraw mangle feature report 2 and Steam eventually closed the device
- interrupt-IN now queues sparse reports (battery/RSSI/wireless edges)
  instead of keeping latest-only, so a 250 Hz state packet can no longer
  erase them before the USB/IP poll observes them; EP0 SET_REPORT is
  split by wValue report type (OUTPUT parsed for rumble vs FEATURE)
- vendored usbip-sim: config attributes/max-power, IAD prefix + BOS
  descriptor support, correct BCD minor.patch encoding (Deck's 0x0300/
  0x0200 values are nibble-zero, so its bytes are unchanged), and
  full-speed interrupt pacing in ms (was 8 kHz from the HS formula)
- Triton feedback is serviced at 1 kHz while an SC2 backend exists so
  Steam's trackpad haptic writes reach the client unbatched

Verified: clippy -D warnings + 319 host tests green on Linux, core wire
tests green, Android kit/app compile + unit tests green. On-glass Puck
retest owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 20:47:52 +02:00
parent b50b698078
commit 01266ff18d
15 changed files with 971 additions and 170 deletions
Generated
+1
View File
@@ -3119,6 +3119,7 @@ dependencies = [
"nvidia-video-codec-sdk",
"openh264",
"opus",
"parking_lot",
"pf-driver-proto",
"pipewire",
"punktfunk-core",
@@ -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"
}
@@ -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
@@ -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,11 +145,21 @@ 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) {
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
}
if (!Sc2Device.parseState(report, len, state)) {
@@ -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 {
+1
View File
@@ -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",
_ => "",
}
+7
View File
@@ -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 P1P4) + 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);
+14 -4
View File
@@ -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",
}
}
}
+8 -2
View File
@@ -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)
+1
View File
@@ -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"
@@ -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<TritonTransport> {
fn open_transport(idx: u8, puck: bool) -> Result<TritonTransport> {
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<TritonTransport> {
/// 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<TritonTransport> {
open_transport(idx)
open_transport(idx, self.puck)
}
fn neutral(&self) -> TritonState {
@@ -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 01, four identical Triton HID controller slots on
//! interfaces 25, 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<u8>)>,
}
#[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<InputReport>,
}
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<u8> {
@@ -62,15 +122,85 @@ fn triton_hid_desc() -> Vec<u8> {
]
}
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<Mutex<[u8; 64]>>,
feedback: Arc<Mutex<TritonUsbFeedback>>,
reports: Arc<Mutex<InputReports>>,
feedback: Option<Arc<Mutex<TritonUsbFeedback>>>,
serial: String,
unit_id: u32,
/// `None` for wired; Puck slots answer `0xB4` with 2 (connected) or 1 (disconnected).
puck_status: Option<u8>,
/// The last feature SET_REPORT (id-first) — the query half of the Valve GET dance.
last_set: Vec<u8>,
/// Last GET query command logged (once per distinct cmd, for the tester-facing journal).
@@ -83,14 +213,16 @@ impl TritonHandler {
if data.is_empty() {
return;
}
if let Ok(mut fb) = self.feedback.lock() {
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));
}
}
}
impl UsbInterfaceHandler for TritonHandler {
fn get_class_specific_descriptor(&self) -> Vec<u8> {
@@ -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
};
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<u8>,
report_descriptor: &'static [u8],
input_report: &'static [u8],
}
impl UsbInterfaceHandler for IdleHandler {
fn get_class_specific_descriptor(&self) -> Vec<u8> {
self.class_descriptor.clone()
}
fn handle_urb(
&mut self,
_interface: &UsbInterface,
ep: UsbEndpoint,
_len: u32,
setup: SetupPacket,
_req: &[u8],
) -> std::io::Result<Vec<u8>> {
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<u8> {
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<Vec<u8>> {
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<u8>,
}
impl UsbInterfaceHandler for PuckManagementHandler {
fn get_class_specific_descriptor(&self) -> Vec<u8> {
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<Vec<u8>> {
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<Mutex<[u8; 64]>>,
reports: &Arc<Mutex<InputReports>>,
feedback: &Arc<Mutex<TritonUsbFeedback>>,
) -> 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 13 remain present but disconnected, matching an unpaired bank.
fn build_puck_device(
index: u8,
reports: &Arc<Mutex<InputReports>>,
feedback: &Arc<Mutex<TritonUsbFeedback>>,
) -> 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 01
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<Mutex<[u8; 64]>>,
reports: Arc<Mutex<InputReports>>,
feedback: Arc<Mutex<TritonUsbFeedback>>,
_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<TritonUsbip> {
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<TritonUsbip> {
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<Vec<(u8, u8, u16, u8)>> = 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::<usize>();
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)]
@@ -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]);
+57 -8
View File
@@ -1886,6 +1886,8 @@ struct Pads {
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
#[cfg(target_os = "linux")]
steamctrl2: Option<crate::inject::steam_controller2::Triton2Manager>,
#[cfg(target_os = "linux")]
steamctrl2_puck: Option<crate::inject::steam_controller2::Triton2Manager>,
#[cfg(target_os = "windows")]
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
#[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<u32> = std::collections::HashSet::new();
let mut held_keys: std::collections::HashSet<u32> = 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]
+32 -11
View File
@@ -21,6 +21,10 @@ impl From<u16> 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<u8>;
/// 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<Vec<u8>>,
pub num_configurations: u8,
pub interfaces: Vec<UsbInterface>,
@@ -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![
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 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;
let period_us = (1u32 << (n - 1)) * 125;
tokio::time::sleep(std::time::Duration::from_micros(period_us as u64)).await;
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();
+5
View File
@@ -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 P1P4) + the misc/capture button, in Moonlight's
// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.