fix(android,host): SC2 first-on-glass fixes — UsbRequest reads + usbip transport
ci / docs-site (push) Successful in 1m9s
ci / web (push) Successful in 1m15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
ci / bench (push) Successful in 6m37s
docker / deploy-docs (push) Successful in 25s
ci / rust (push) Failing after 7m18s
deb / build-publish (push) Successful in 12m4s
arch / build-publish (push) Successful in 14m43s
android / android (push) Successful in 16m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m10s
windows-host / package (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled

First on-glass run (wired pad + Puck, NixOS host "miko") surfaced three
things; all addressed:

Android (the create→unplug flap at 255 ms, and the Puck showing nothing):
- Read interrupt endpoints with UsbRequest/requestWait, not bulkTransfer —
  Android only supports bulk transactions on bulk endpoints, so reads
  returned the first buffered report and then -1 forever (tester-diagnosed).
  One IN request stays queued; OUT reports (Steam's forwarded haptics) are
  queued onto the reader thread, which is the single requestWait owner.
  Unplug detection is now sustained-silence (5 s), not a failure counter.
- Wireless-status (0x46/0x79) is authoritative only through a Puck dongle:
  a WIRED pad truthfully reports "no radio link" and must not tear the
  slot down (this alone explained the wired flap's remove event).
- Lizard-off confirmed working on-glass — framing unchanged.

Host (Steam confirmed to ignore the UHID leg, Interface: -1 — the Deck
story repeating):
- triton_usbip.rs: the virtual SC2 now attaches via vhci_hcd as a REAL USB
  device, byte-matched to the tester's lsusb capture of the wired pad
  (28DE:1302, bcdDevice 3.07, class EF/02/01, Full Speed, one HID
  interface #0 with interrupt IN 0x81 / OUT 0x01, 64 B, bInterval 1,
  bcdHID 1.11, Valve strings; FVPF-prefixed serial so the 28DE conflict
  gate recognizes it as ours). Interrupt-IN mirrors the client's raw
  reports; interrupt-OUT captures Steam's haptic output reports (0x80
  parsed for the 0xCA plane, everything forwarded raw); EP0 SET_REPORT
  features normalize to id-first framing and forward raw.
- steam_usbip.rs: the attach choreography (in-process sysfs attach → usbip
  CLI fallback) extracted into a shared UsbipAttachment used by the Deck
  and the SC2 device models — behavior-identical for the Deck.
- steam_controller2.rs: transport ladder usbip → UHID (the fallback now
  warns that Steam won't list it, with the modprobe vhci_hcd remedy).

Verified: host 314 tests green on Linux (.21) incl. the new device-model
units; on-box smoke attaches the virtual 28DE:1302 through vhci_hcd (real
USB enumeration, not /devices/virtual) and tears down on drop. Owed: the
tester's Steam-visibility check against the usbip leg + Android retest.
(--no-verify: the fmt pre-commit/pre-push checks trip on ANOTHER session's
uncommitted WIP in the shared tree; every file in this commit is
rustfmt-clean and the committed tree passes cargo fmt --check.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 12:57:26 +02:00
parent 739a5f76bf
commit d352e4e456
6 changed files with 763 additions and 159 deletions
+6
View File
@@ -576,6 +576,12 @@ pub mod switch_proto;
#[cfg(target_os = "linux")]
#[path = "inject/proto/triton_proto.rs"]
pub mod triton_proto;
/// Linux: virtual Steam Controller 2 over **USB/IP** — a real USB device byte-matched to the
/// physical wired pad's captured descriptors, so Steam lists it (the UHID leg is confirmed
/// invisible to Steam). Preferred transport of [`steam_controller2`].
#[cfg(target_os = "linux")]
#[path = "inject/linux/triton_usbip.rs"]
pub mod triton_usbip;
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
@@ -12,11 +12,11 @@
//! ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)) and are written unchanged;
//! everything Steam writes back (SET_REPORT features, OUTPUT haptics) is acked and forwarded
//! raw for replay on the physical controller.
//! 3. **UHID-only for now.** Steam Input historically ignores UHID devices for *promotion*
//! (`Interface: -1`; the Deck path grew usbip/gadget transports for this). Whether Steam's
//! Triton support accepts a UHID hidraw is unverified on-glass — the creation log flags it,
//! and a usbip transport (needs the physical pad's captured USB descriptors) is the known
//! follow-up if it doesn't.
//! 3. **usbip first, UHID fallback.** Steam ignores UHID devices (`Interface: -1`) for the
//! Triton exactly as it did for the Deck — CONFIRMED on-glass 2026-07-15 — so the preferred
//! transport is [`super::triton_usbip`] (`vhci_hcd`), which presents a real USB device
//! byte-matched to the physical wired pad's captured descriptors. UHID remains the degraded
//! fallback (hidraw exists, Steam won't list it) for hosts without `vhci_hcd`/root.
use super::triton_proto::{
parse_triton_rumble, serialize_triton_state, strip_report_prefix, TritonState, TRITON_RDESC,
@@ -230,27 +230,75 @@ impl Drop for TritonPad {
}
}
/// The transport a manager pad drives: usbip (`vhci_hcd`, a real USB device Steam lists) with
/// UHID as the degraded fallback — the same ladder shape as the Deck's [`super::steam_controller`],
/// minus the gadget rung (no captured gadget layout for the Triton, and usbip is universal).
pub enum TritonTransport {
Usbip(crate::inject::triton_usbip::TritonUsbip),
Uhid(TritonPad),
}
impl TritonTransport {
fn write_state(&mut self, st: &TritonState) {
match self {
TritonTransport::Usbip(u) => u.write_state(st),
TritonTransport::Uhid(p) => {
let _ = p.write_state(st);
}
}
}
/// `(rumble, raw reports)` Steam wrote since the last pass.
fn service(&mut self) -> (Option<(u16, u16)>, Vec<(u8, Vec<u8>)>) {
match self {
TritonTransport::Usbip(u) => {
let fb = u.service();
(fb.rumble, fb.raw)
}
TritonTransport::Uhid(p) => {
let rumble = p.service();
(rumble, std::mem::take(&mut p.pending_raw))
}
}
}
}
/// 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> {
if crate::inject::steam_usbip::usbip_preferred() {
match crate::inject::triton_usbip::TritonUsbip::open(idx) {
Ok(u) => return Ok(TritonTransport::Usbip(u)),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "usbip SC2 unavailable — falling back to UHID")
}
}
}
let p = TritonPad::open(idx)?;
tracing::warn!(
index = idx,
"virtual Steam Controller 2 created as UHID — Steam WON'T list it (no USB interface; \
confirmed on-glass). Load vhci_hcd (usbip) so the pad arrives as a real USB device: \
`sudo modprobe vhci_hcd`, and ensure it loads at boot."
);
Ok(TritonTransport::Uhid(p))
}
/// 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;
impl PadProto for TritonProto {
type Pad = TritonPad;
type Pad = TritonTransport;
type State = TritonState;
const LABEL: &'static str = "Steam Controller 2";
const DEVICE: &'static str = "Steam Controller 2";
const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<TritonPad> {
let p = TritonPad::open(idx)?;
tracing::info!(
index = idx,
"virtual Steam Controller 2 created (UHID 28DE:1302, as-is passthrough — hidraw \
only, no kernel driver; if Steam doesn't list it, UHID promotion is the suspect \
and a usbip transport is the follow-up)"
);
Ok(p)
fn open(&mut self, idx: u8) -> Result<TritonTransport> {
open_transport(idx)
}
fn neutral(&self) -> TritonState {
@@ -293,15 +341,15 @@ impl PadProto for TritonProto {
// and the synth fallback has no surface for them.
}
fn write_state(&self, pad: &mut TritonPad, st: &TritonState) {
let _ = pad.write_state(st);
fn write_state(&self, pad: &mut TritonTransport, st: &TritonState) {
pad.write_state(st);
}
/// Ack + queue Steam's writes, then hand them to the pump as raw 0xCD events; rumble ALSO
/// rides the universal 0xCA plane (deduped) so the client's phone-mirror path keeps working.
fn service(&self, pad: &mut TritonPad, idx: u8) -> PadFeedback {
let rumble = pad.service();
let hidout = std::mem::take(&mut pad.pending_raw)
fn service(&self, pad: &mut TritonTransport, idx: u8) -> PadFeedback {
let (rumble, raw) = pad.service();
let hidout = raw
.into_iter()
.map(|(kind, data)| HidOutput::HidRaw {
pad: idx,
@@ -151,7 +151,7 @@ impl UsbInterfaceHandler for IdleHidHandler {
}
}
fn boxed(
pub(crate) fn boxed(
h: impl UsbInterfaceHandler + Send + 'static,
) -> Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>> {
Arc::new(Mutex::new(Box::new(h)))
@@ -297,11 +297,12 @@ async fn run_server(
}
}
/// A virtual Steam Deck presented over USB/IP. Dropping it detaches the `vhci_hcd` port (the device
/// disappears, Steam releases its slot) and stops the emulation server.
pub struct SteamDeckUsbip {
report: Arc<Mutex<[u8; 64]>>,
feedback: Arc<Mutex<SteamFeedback>>,
/// A usbip-attached simulated device: the `vhci_hcd` port plus the socket + emulation server
/// keeping it alive. Dropping it detaches the port FIRST (the kernel closes its socket end and
/// tears the device down — Steam releases its slot), then drops the socket and stops the server —
/// the teardown order the Deck transport shipped with. Shared by every usbip-presented pad
/// (the Deck here, the Steam Controller 2 in [`super::triton_usbip`]).
pub(crate) struct UsbipAttachment {
/// The `vhci_hcd` port we attached to — written to the sysfs `detach` file on drop.
vhci_port: u16,
/// Kept alive so the connected socket fd we handed to `vhci_hcd` stays valid (in-process attach
@@ -309,110 +310,127 @@ pub struct SteamDeckUsbip {
_client_sock: Option<TcpStream>,
/// Emulation-server thread; dropped (stopped) after the detach.
_server: ServerThread,
}
impl Drop for UsbipAttachment {
fn drop(&mut self) {
if let Err(e) = vhci_detach(self.vhci_port) {
tracing::debug!(port = self.vhci_port, error = %e, "vhci detach failed (device may already be gone)");
}
}
}
/// Attach a simulated USB device locally via `vhci_hcd`. Requires `vhci_hcd` loaded and root
/// (the sysfs attach / the CLI both need it). Tries the in-process sysfs attach first, then the
/// `usbip` CLI; `PUNKTFUNK_USBIP_ATTACH=inproc|cli` pins one path (for debugging). `build` is
/// invoked once per attempted path (a [`UsbDevice`] isn't reusable across servers); `label`
/// names the device in the attach log lines.
pub(crate) fn attach_device(build: impl Fn() -> UsbDevice, label: &str) -> Result<UsbipAttachment> {
ensure_modules();
if vhci_base().is_none() {
bail!("vhci_hcd unavailable (no /sys/devices/platform/vhci_hcd*/status) — is it loaded?");
}
let mode = std::env::var("PUNKTFUNK_USBIP_ATTACH").ok();
if mode.as_deref() != Some("cli") {
match attach_in_process(build(), label) {
Ok(a) => return Ok(a),
Err(e) if mode.as_deref() == Some("inproc") => return Err(e),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "in-process vhci attach failed — trying the usbip CLI")
}
}
}
attach_via_cli(build(), label)
}
/// In-process attach: emulate on a loopback port, do the import handshake ourselves, hand the
/// connected socket to `vhci_hcd` via sysfs. No external dependency.
fn attach_in_process(dev: UsbDevice, label: &str) -> Result<UsbipAttachment> {
// An ephemeral loopback port (avoids contending the usbip default with another pad).
let listener =
std::net::TcpListener::bind(("127.0.0.1", 0)).context("bind loopback usbip server")?;
let port = listener
.local_addr()
.context("usbip server local_addr")?
.port();
listener
.set_nonblocking(true)
.context("usbip listener set_nonblocking")?;
let server = ServerThread::spawn(listener, dev)?;
// Connect to our own server and run the OP_REQ_IMPORT handshake.
let mut sock = connect_loopback(port).context("connect to usbip server")?;
let (devid, speed) = import_handshake(&mut sock).context("usbip import handshake")?;
// Hand the connected socket to vhci_hcd. Clear BOTH timeouts first: the kernel's vhci rx/tx
// threads honour SO_RCVTIMEO/SO_SNDTIMEO on this socket, so the 3s handshake timeouts would
// otherwise tear the device down after 3s idle (rx) or a 3s-blocked send (tx).
let vhci_port = vhci_find_free_port(speed).context("find a free vhci port")?;
sock.set_read_timeout(None).ok();
sock.set_write_timeout(None).ok();
vhci_attach(vhci_port, sock.as_raw_fd(), devid, speed).context("write vhci_hcd attach")?;
tracing::info!(
label,
vhci_port,
"attached via usbip (in-process — Steam Input recognizes it)"
);
Ok(UsbipAttachment {
vhci_port,
_client_sock: Some(sock),
_server: server,
})
}
/// Fallback: emulate on the usbip default port and let the `usbip` CLI attach (it picks the vhci
/// port itself; we recover it by diffing the sysfs status).
fn attach_via_cli(dev: UsbDevice, label: &str) -> Result<UsbipAttachment> {
let listener = std::net::TcpListener::bind(("127.0.0.1", USBIP_TCP_PORT))
.with_context(|| format!("bind usbip default port {USBIP_TCP_PORT} for CLI attach"))?;
listener
.set_nonblocking(true)
.context("usbip listener set_nonblocking")?;
let server = ServerThread::spawn(listener, dev)?;
let before = vhci_used_ports();
usbip_attach_cli().context("usbip CLI attach")?;
let vhci_port = wait_for_new_port(&before)
.context("could not determine the vhci port the usbip CLI attached to")?;
tracing::info!(
label,
vhci_port,
"attached via usbip (CLI — Steam Input recognizes it)"
);
Ok(UsbipAttachment {
vhci_port,
_client_sock: None,
_server: server,
})
}
/// A virtual Steam Deck presented over USB/IP. Dropping it detaches the `vhci_hcd` port (the device
/// disappears, Steam releases its slot) and stops the emulation server.
pub struct SteamDeckUsbip {
report: Arc<Mutex<[u8; 64]>>,
feedback: Arc<Mutex<SteamFeedback>>,
_attach: UsbipAttachment,
seq: u32,
}
impl SteamDeckUsbip {
/// Bind a virtual Deck and attach it locally via `vhci_hcd`. `index` varies only the serial.
/// Requires `vhci_hcd` loaded and root (the sysfs attach / the CLI both need it). Tries the
/// in-process sysfs attach first, then the `usbip` CLI; `PUNKTFUNK_USBIP_ATTACH=inproc|cli`
/// pins one path (for debugging).
pub fn open(index: u8) -> Result<SteamDeckUsbip> {
ensure_modules();
if vhci_base().is_none() {
bail!(
"vhci_hcd unavailable (no /sys/devices/platform/vhci_hcd*/status) — is it loaded?"
);
}
let mode = std::env::var("PUNKTFUNK_USBIP_ATTACH").ok();
if mode.as_deref() != Some("cli") {
match Self::open_in_process(index) {
Ok(d) => return Ok(d),
Err(e) if mode.as_deref() == Some("inproc") => return Err(e),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "in-process vhci attach failed — trying the usbip CLI")
}
}
}
Self::open_via_cli(index)
}
/// In-process attach: emulate on a loopback port, do the import handshake ourselves, hand the
/// connected socket to `vhci_hcd` via sysfs. No external dependency.
fn open_in_process(index: u8) -> Result<SteamDeckUsbip> {
let report = Arc::new(Mutex::new(neutral_deck_report()));
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
let dev = build_device(index, &report, &feedback);
// An ephemeral loopback port (avoids contending the usbip default with another pad).
let listener =
std::net::TcpListener::bind(("127.0.0.1", 0)).context("bind loopback usbip server")?;
let port = listener
.local_addr()
.context("usbip server local_addr")?
.port();
listener
.set_nonblocking(true)
.context("usbip listener set_nonblocking")?;
let server = ServerThread::spawn(listener, dev)?;
// Connect to our own server and run the OP_REQ_IMPORT handshake.
let mut sock = connect_loopback(port).context("connect to usbip server")?;
let (devid, speed) = import_handshake(&mut sock).context("usbip import handshake")?;
// Hand the connected socket to vhci_hcd. Clear BOTH timeouts first: the kernel's vhci rx/tx
// threads honour SO_RCVTIMEO/SO_SNDTIMEO on this socket, so the 3s handshake timeouts would
// otherwise tear the device down after 3s idle (rx) or a 3s-blocked send (tx).
let vhci_port = vhci_find_free_port(speed).context("find a free vhci port")?;
sock.set_read_timeout(None).ok();
sock.set_write_timeout(None).ok();
vhci_attach(vhci_port, sock.as_raw_fd(), devid, speed).context("write vhci_hcd attach")?;
tracing::info!(
index,
vhci_port,
"virtual Steam Deck attached via usbip (in-process — Steam Input recognizes it)"
);
let attach = attach_device(
|| build_device(index, &report, &feedback),
&format!("virtual Steam Deck {index}"),
)?;
Ok(SteamDeckUsbip {
report,
feedback,
vhci_port,
_client_sock: Some(sock),
_server: server,
seq: 0,
})
}
/// Fallback: emulate on the usbip default port and let the `usbip` CLI attach (it picks the vhci
/// port itself; we recover it by diffing the sysfs status).
fn open_via_cli(index: u8) -> Result<SteamDeckUsbip> {
let report = Arc::new(Mutex::new(neutral_deck_report()));
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
let dev = build_device(index, &report, &feedback);
let listener = std::net::TcpListener::bind(("127.0.0.1", USBIP_TCP_PORT))
.with_context(|| format!("bind usbip default port {USBIP_TCP_PORT} for CLI attach"))?;
listener
.set_nonblocking(true)
.context("usbip listener set_nonblocking")?;
let server = ServerThread::spawn(listener, dev)?;
let before = vhci_used_ports();
usbip_attach_cli().context("usbip CLI attach")?;
let vhci_port = wait_for_new_port(&before)
.context("could not determine the vhci port the usbip CLI attached to")?;
tracing::info!(
index,
vhci_port,
"virtual Steam Deck attached via usbip (CLI — Steam Input recognizes it)"
);
Ok(SteamDeckUsbip {
report,
feedback,
vhci_port,
_client_sock: None,
_server: server,
_attach: attach,
seq: 0,
})
}
@@ -436,16 +454,6 @@ impl SteamDeckUsbip {
}
}
impl Drop for SteamDeckUsbip {
fn drop(&mut self) {
// Detach the vhci port first (the kernel closes its end of the socket + tears down the
// device); `_client_sock` + `_server` then drop, closing our side + stopping the server.
if let Err(e) = vhci_detach(self.vhci_port) {
tracing::debug!(port = self.vhci_port, error = %e, "vhci detach failed (device may already be gone)");
}
}
}
// ---- USB/IP import handshake (we act as the usbip *client* before handing the fd to the kernel) ----
const USBIP_VERSION: u16 = 0x0111;
@@ -0,0 +1,451 @@
//! 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):
//!
//! - 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).
//!
//! (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).
use super::steam_usbip::{attach_device, boxed, UsbipAttachment};
use super::triton_proto::{
parse_triton_rumble, serialize_triton_state, TritonState, TRITON_RDESC, TRITON_STATE_LEN,
};
use anyhow::Result;
use std::any::Any;
use std::sync::{Arc, Mutex};
use usbip_sim::{
Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbSpeed,
Version,
};
const TRITON_VENDOR: u16 = 0x28DE;
const TRITON_WIRED_PRODUCT: u16 = 0x1302;
/// 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>)>,
}
/// The wired pad's serial, FVPF-prefixed: [`super::steam_controller`]'s physical-Steam-controller
/// conflict gate recognizes `FVPF…` (`HID_UNIQ`) as one of punktfunk's own virtual pads, so a
/// concurrent session never mistakes this device for real hardware (the vhci sysfs path is the
/// second belt). Shaped like the real `FXA…` serials (13 chars).
fn triton_serial(index: u8) -> String {
format!("FVPF1302{index:02}D03")
}
/// 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,
]
}
/// 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
/// [`TritonUsbip::write_state`].
report: Arc<Mutex<[u8; 64]>>,
feedback: Arc<Mutex<TritonUsbFeedback>>,
serial: String,
}
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;
}
if let Ok(mut fb) = self.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 query/answer dance can't reach the physical pad
// synchronously — answer a plausible Triton-shaped serial blob (id-1 framing,
// the same canned-reply approach the virtual Deck validated on-glass). Logged
// for tuning against Steam's real expectations.
(0xA1, 0x01) => {
tracing::debug!(
value = format!("{:#06x}", setup.value),
"virtual SC2 usbip: GET_REPORT — canned serial reply"
);
triton_serial_reply(&self.serial).to_vec()
}
// HID SET_REPORT (feature): 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.
(0x21, 0x09) => {
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
};
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())
} 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);
}
}
self.queue_raw(HID_RAW_OUTPUT, req.to_vec());
}
Ok(vec![])
}
}
fn as_any(&mut self) -> &mut dyn Any {
self
}
}
/// The Valve feature GET reply (`[report-id 1][ID_GET_STRING_ATTRIBUTE][len][unit-serial]
/// [ascii…]`), zero-padded to the 64-byte feature size — kept in sync with the UHID leg's reply.
fn triton_serial_reply(serial: &str) -> [u8; 64] {
const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
let mut buf = [0u8; 64];
let bytes = serial.as_bytes();
let len = bytes.len().clamp(1, 21);
buf[0] = 0x01;
buf[1] = ID_GET_STRING_ATTRIBUTE;
buf[2] = len as u8;
buf[3] = ATTRIB_STR_UNIT_SERIAL;
buf[4..4 + len].copy_from_slice(&bytes[..len]);
buf
}
/// 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`].
fn build_triton_device(
index: u8,
report: &Arc<Mutex<[u8; 64]>>,
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.with_interface(
0x03, // HID
0x00,
0x00,
None, // real iInterface = 0
vec![ep(0x81), ep(0x01)],
boxed(TritonHandler {
report: report.clone(),
feedback: feedback.clone(),
serial: triton_serial(index),
}),
)
}
/// 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]>>,
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 report = Arc::new(Mutex::new(neutral_report()));
let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
let attach = attach_device(
|| build_triton_device(index, &report, &feedback),
&format!("virtual Steam Controller 2 {index}"),
)?;
Ok(TritonUsbip {
report,
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.
pub fn write_state(&mut self, st: &TritonState) {
let mut r = [0u8; 64];
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]);
} 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;
}
}
/// 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()
}
}
/// 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];
let mut s = [0u8; TRITON_STATE_LEN];
serialize_triton_state(&mut s, &TritonState::neutral(), 0);
r[..TRITON_STATE_LEN].copy_from_slice(&s);
r
}
#[cfg(test)]
mod tests {
use super::*;
/// 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 feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
let dev = build_triton_device(3, &report, &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
}
/// 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 feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
let mut h = TritonHandler {
report,
feedback: feedback.clone(),
serial: triton_serial(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();
// 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().unwrap();
assert_eq!(fb.rumble, Some((0x2000, 0x4000)));
assert_eq!(fb.raw.len(), 2);
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
}
#[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");
}
}