Files
punktfunk/crates/punktfunk-host/src/inject/linux/triton_usbip.rs
T
enricobuehler 01266ff18d 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>
2026-07-15 20:47:52 +02:00

1069 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
//! physical devices captured on 2026-07-15:
//!
//! - 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.
//!
//! 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::{
parse_triton_rumble, serialize_triton_state, triton_feature_reply, triton_serial,
triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN,
};
use anyhow::Result;
use parking_lot::Mutex;
use std::any::Any;
use std::collections::VecDeque;
use std::sync::Arc;
use usbip_sim::{
Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbSpeed,
Version,
};
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)]
pub(crate) struct TritonUsbFeedback {
/// `(low, high)` from the last `0x80` rumble output report.
pub rumble: Option<(u16, u16)>,
/// Raw reports to forward, `(kind, bytes)` — kind = `HID_RAW_OUTPUT`/`HID_RAW_FEATURE`.
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> {
let l = TRITON_RDESC.len() as u16;
vec![
0x09,
0x21,
0x11,
0x01,
0,
1,
0x22,
(l & 0xff) as u8,
(l >> 8) as 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 {
/// Latest controller state plus sparse reports awaiting an interrupt-IN poll, shared with
/// [`TritonUsbip::write_state`].
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).
last_get_logged: u8,
}
impl TritonHandler {
/// Queue one raw report for forwarding, newest-wins bounded like the UHID leg.
fn queue_raw(&self, kind: u8, data: Vec<u8>) {
if data.is_empty() {
return;
}
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> {
triton_hid_desc()
}
fn handle_urb(
&mut self,
_interface: &UsbInterface,
ep: UsbEndpoint,
_len: u32,
setup: SetupPacket,
req: &[u8],
) -> std::io::Result<Vec<u8>> {
use punktfunk_core::quic::{HID_RAW_FEATURE, HID_RAW_OUTPUT};
if ep.is_ep0() {
Ok(match (setup.request_type, setup.request) {
// GET report descriptor (standard, interface recipient).
(0x81, 0x06) if (setup.value >> 8) == 0x22 => TRITON_RDESC.to_vec(),
// HID GET_REPORT (feature): the answer half of the Valve query dance — echo the
// LAST SET's command with a plausible payload (attributes / serial). Answering
// 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 = 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!(
cmd = format!("{:#04x}", reply[1]),
"virtual SC2 usbip: answering feature GET"
);
}
reply.to_vec()
}
// 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()
} else {
let mut v = Vec::with_capacity(req.len() + 1);
v.push(id);
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() {
// 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), Some(feedback)) =
(parse_triton_rumble(req), self.feedback.as_ref())
{
feedback.lock().rumble = Some(r);
}
self.queue_raw(HID_RAW_OUTPUT, req.to_vec());
}
Ok(vec![])
}
}
fn as_any(&mut self) -> &mut dyn Any {
self
}
}
#[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 `reports` + `feedback` with the owning [`TritonUsbip`].
fn build_triton_device(
index: u8,
reports: &Arc<Mutex<InputReports>>,
feedback: &Arc<Mutex<TritonUsbFeedback>>,
) -> UsbDevice {
let ep = |addr: u8| UsbEndpoint {
address: addr,
attributes: 0x03, // interrupt
max_packet_size: 64, // wMaxPacketSize 0x0040
interval: 1, // bInterval 1 — the real pad's 1 kHz
};
let mut dev = UsbDevice::new(0);
dev.vendor_id = TRITON_VENDOR;
dev.product_id = TRITON_WIRED_PRODUCT;
dev.usb_version = Version::from(0x0200u16); // bcdUSB 2.00
dev.device_bcd = Version::from(0x0307u16); // bcdDevice 3.07 (the captured firmware)
dev.device_class = 0xEF; // Miscellaneous / IAD — as the real pad ships
dev.device_subclass = 0x02;
dev.device_protocol = 0x01;
dev.speed = UsbSpeed::Full as u32; // negotiated Full Speed (12 Mbps) on the capture
dev.set_manufacturer_name("Valve Software");
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,
0x00,
None, // real iInterface = 0
vec![ep(0x81), ep(0x01)],
boxed(TritonHandler {
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 {
reports: Arc<Mutex<InputReports>>,
feedback: Arc<Mutex<TritonUsbFeedback>>,
_attach: UsbipAttachment,
seq: u8,
}
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 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, &reports, &feedback),
&format!("virtual Steam Controller 2 {index}"),
)?;
Ok(TritonUsbip {
reports,
feedback,
_attach: attach,
seq: 0,
})
}
/// 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 report = InputReport::default();
if st.raw_len > 0 {
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);
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 {
std::mem::take(&mut *self.feedback.lock())
}
}
/// 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);
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 reports = Arc::new(Mutex::new(InputReports::new(InputReport::default())));
let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
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),
(0xEF, 0x02, 0x01)
);
assert_eq!(dev.speed, UsbSpeed::Full as u32);
assert_eq!(dev.interfaces.len(), 1);
let i = &dev.interfaces[0];
assert_eq!(
(
i.interface_class,
i.interface_subclass,
i.interface_protocol
),
(0x03, 0x00, 0x00)
);
let eps: Vec<(u8, u8, u16, u8)> = i
.endpoints
.iter()
.map(|e| (e.address, e.attributes, e.max_packet_size, e.interval))
.collect();
assert_eq!(eps, vec![(0x81, 3, 64, 1), (0x01, 3, 64, 1)]);
// bcdHID 1.11 + the served report descriptor's length in the HID class descriptor.
let hid = triton_hid_desc();
assert_eq!(&hid[2..4], &[0x11, 0x01]);
assert_eq!(
u16::from_le_bytes([hid[7], hid[8]]) as usize,
TRITON_RDESC.len()
);
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 reports = Arc::new(Mutex::new(InputReports::new(InputReport::default())));
let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
let mut h = TritonHandler {
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,
};
let iface_dummy = UsbInterface {
interface_class: 3,
interface_subclass: 0,
interface_protocol: 0,
endpoints: vec![],
string_interface: 0,
class_specific_descriptor: vec![],
handler: boxed(IdleDummy),
};
let ep_out = UsbEndpoint {
address: 0x01,
attributes: 0x03,
max_packet_size: 64,
interval: 1,
};
let ep0 = UsbEndpoint {
address: 0x00,
attributes: 0x00, // control
max_packet_size: 64,
interval: 0,
};
// Rumble output report: [0x80, type, intensity u16, left u16+gain, right u16+gain].
let mut rumble = [0u8; 10];
rumble[0] = 0x80;
rumble[4..6].copy_from_slice(&0x2000u16.to_le_bytes());
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,
request: 0x09,
value: 0x0301,
index: 0,
length: 5,
};
h.handle_urb(&iface_dummy, ep0, 5, setup, &[0x87, 3, 9, 0, 0])
.unwrap();
let fb = feedback.lock();
assert_eq!(fb.rumble, Some((0x2000, 0x4000)));
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], (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)]
struct IdleDummy;
impl UsbInterfaceHandler for IdleDummy {
fn get_class_specific_descriptor(&self) -> Vec<u8> {
vec![]
}
fn handle_urb(
&mut self,
_i: &UsbInterface,
_e: UsbEndpoint,
_l: u32,
_s: SetupPacket,
_r: &[u8],
) -> std::io::Result<Vec<u8>> {
Ok(vec![])
}
fn as_any(&mut self) -> &mut dyn Any {
self
}
}
/// On-box smoke (root + `vhci_hcd`): attach the virtual wired SC2, confirm the USB device
/// enumerates with the Valve identity on a REAL interface number (the whole point vs UHID),
/// and that it tears down on drop. `#[ignore]`d in CI.
#[test]
#[ignore = "attaches a real vhci_hcd device; needs root + vhci_hcd"]
fn usbip_triton_enumerates_and_tears_down() {
super::super::steam_usbip::ensure_modules();
let mut pad = TritonUsbip::open(0).expect("open TritonUsbip (root + vhci_hcd?)");
let mut st = TritonState::neutral();
let raw: &[u8] = &[0x42, 1, 0x01, 0, 0, 0]; // A held (truncated report is fine)
st.raw[..raw.len()].copy_from_slice(raw);
st.raw_len = raw.len() as u8;
let start = std::time::Instant::now();
while start.elapsed() < std::time::Duration::from_millis(1500) {
pad.write_state(&st);
let _ = pad.service();
std::thread::sleep(std::time::Duration::from_millis(8));
}
// A real USB HID device now exists: /sys/bus/hid device named ...:28DE:1302 whose path
// resolves through vhci_hcd (NOT /devices/virtual), carrying interface number 0.
let found = std::fs::read_dir("/sys/bus/hid/devices")
.expect("/sys/bus/hid/devices")
.flatten()
.find(|e| e.file_name().to_string_lossy().contains(":28DE:1302"));
let entry = found.expect("virtual 28DE:1302 did not enumerate via vhci_hcd");
let target = std::fs::read_link(entry.path()).expect("hid device link");
assert!(
target.to_string_lossy().contains("vhci_hcd"),
"28DE:1302 present but not via vhci_hcd: {}",
target.display()
);
drop(pad);
std::thread::sleep(std::time::Duration::from_millis(400));
let still = std::fs::read_dir("/sys/bus/hid/devices")
.expect("/sys/bus/hid/devices")
.flatten()
.any(|e| e.file_name().to_string_lossy().contains(":28DE:1302"));
assert!(!still, "device not torn down on drop");
}
}