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
@@ -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,12 +213,14 @@ impl TritonHandler {
if data.is_empty() {
return;
}
if let Ok(mut fb) = self.feedback.lock() {
if fb.raw.len() >= 32 {
fb.raw.remove(0);
}
fb.raw.push((kind, data));
let Some(feedback) = &self.feedback else {
return;
};
let mut fb = feedback.lock();
if fb.raw.len() >= 32 {
fb.raw.remove(0);
}
fb.raw.push((kind, data));
}
}
@@ -115,7 +247,16 @@ impl UsbInterfaceHandler for TritonHandler {
// with the wrong command type makes Steam drop the pad (confirmed on-glass
// 2026-07-15); the dance can't round-trip to the physical pad synchronously.
(0xA1, 0x01) => {
let reply = triton_feature_reply(&self.last_set, &self.serial, self.unit_id);
let reply = if let Some(status) = self.puck_status {
triton_puck_feature_reply(
&self.last_set,
&self.serial,
self.unit_id,
status,
)
} else {
triton_feature_reply(&self.last_set, &self.serial, self.unit_id)
};
if reply[1] != self.last_get_logged {
self.last_get_logged = reply[1];
tracing::info!(
@@ -125,11 +266,11 @@ impl UsbInterfaceHandler for TritonHandler {
}
reply.to_vec()
}
// HID SET_REPORT (feature): remember the command (it selects the next GET's
// answer) and forward raw for replay on the physical pad. EP0 OUT data may or
// may not carry the report-id byte depending on the writer's stack (the id also
// rides wValue's low byte) — normalize to id-first for the client.
// HID SET_REPORT: report type rides wValue's high byte (2 = OUTPUT, 3 = FEATURE)
// and the report id rides its low byte. EP0 OUT data may or may not repeat the id,
// so normalize to id-first before returning it to the physical-device owner.
(0x21, 0x09) => {
let report_type = (setup.value >> 8) as u8;
let id = (setup.value & 0xFF) as u8;
let framed = if req.first() == Some(&id) && id != 0 {
req.to_vec()
@@ -139,26 +280,39 @@ impl UsbInterfaceHandler for TritonHandler {
v.extend_from_slice(req);
v
};
self.last_set = framed.clone();
self.queue_raw(HID_RAW_FEATURE, framed);
match report_type {
2 => {
if let (Some(r), Some(feedback)) =
(parse_triton_rumble(&framed), self.feedback.as_ref())
{
feedback.lock().rumble = Some(r);
}
self.queue_raw(HID_RAW_OUTPUT, framed);
}
3 => {
self.last_set = framed.clone();
self.queue_raw(HID_RAW_FEATURE, framed);
}
_ => {}
}
vec![]
}
(0x21, 0x0A) | (0x21, 0x0B) => vec![], // SET_IDLE / SET_PROTOCOL
_ => vec![],
})
} else if let Direction::In = ep.direction() {
// Interrupt-IN poll (paced by bInterval = 1 ms): the current report, zero-padded —
// exactly the 64-byte packets the real wired pad produces.
let r = self.report.lock().map(|g| *g).unwrap_or([0u8; 64]);
Ok(r.to_vec())
// Replay continuous state, but consume sparse battery/RSSI/wireless reports exactly
// once so a following 250 Hz state packet cannot erase them before Steam polls.
let r = self.reports.lock().read();
Ok(r.data[..r.len as usize].to_vec())
} else {
// Interrupt-OUT: Steam's haptic output reports (`SDL_hid_write` — id-first framing
// on the wire already). Parse rumble for the universal plane, forward everything raw.
if !req.is_empty() {
if let Some(r) = parse_triton_rumble(req) {
if let Ok(mut fb) = self.feedback.lock() {
fb.rumble = Some(r);
}
if let (Some(r), Some(feedback)) =
(parse_triton_rumble(req), self.feedback.as_ref())
{
feedback.lock().rumble = Some(r);
}
self.queue_raw(HID_RAW_OUTPUT, req.to_vec());
}
@@ -171,11 +325,147 @@ impl UsbInterfaceHandler for TritonHandler {
}
}
#[derive(Debug)]
struct IdleHandler {
class_descriptor: Vec<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]);