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
@@ -32,6 +32,11 @@ class Sc2Capture(
private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed) private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed)
private var activeLink: Int = LINK_NONE private var activeLink: Int = LINK_NONE
/** True when the USB link is a Puck dongle — the only transport whose wireless-status
* reports are authoritative. A WIRED pad also emits them, truthfully reporting "no radio
* link" — acting on that tore the slot down 255 ms after creation (first on-glass run). */
private var dongleLink = false
private var pad: GamepadRouter.ExternalPad? = null private var pad: GamepadRouter.ExternalPad? = null
private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64) private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64)
@@ -53,7 +58,10 @@ class Sc2Capture(
fun startUsb(dev: UsbDevice): Boolean { fun startUsb(dev: UsbDevice): Boolean {
if (activeLink != LINK_NONE) return false if (activeLink != LINK_NONE) return false
val ok = usb.start(dev) val ok = usb.start(dev)
if (ok) activeLink = LINK_USB if (ok) {
activeLink = LINK_USB
dongleLink = dev.productId != Sc2Device.PID_WIRED
}
return ok return ok
} }
@@ -81,6 +89,7 @@ class Sc2Capture(
LINK_BLE -> ble.stop() LINK_BLE -> ble.stop()
} }
activeLink = LINK_NONE activeLink = LINK_NONE
dongleLink = false
releaseSlot() releaseSlot()
} }
@@ -88,10 +97,15 @@ class Sc2Capture(
private fun onReport(report: ByteArray, len: Int) { private fun onReport(report: ByteArray, len: Int) {
val id = report[0].toInt() and 0xFF val id = report[0].toInt() and 0xFF
// A Puck relays connect/disconnect for its controller — track the slot accordingly, so // Wireless status: authoritative ONLY through a Puck dongle (powering the pad off frees
// powering the pad off frees its wire index (and the host's virtual device). // its wire index + the host's virtual device). A wired/BLE pad emits it too — truthfully
// saying "no radio link" — and must NOT tear the slot down (SDL's wired path likewise
// marks the controller connected unconditionally and reconnects on any state report).
if ((id == Sc2Device.ID_WIRELESS || id == Sc2Device.ID_WIRELESS_X) && len >= 2) { if ((id == Sc2Device.ID_WIRELESS || id == Sc2Device.ID_WIRELESS_X) && len >= 2) {
if ((report[1].toInt() and 0xFF) == Sc2Device.WIRELESS_DISCONNECT) releaseSlot() if (dongleLink && (report[1].toInt() and 0xFF) == Sc2Device.WIRELESS_DISCONNECT) {
Log.i(TAG, "Puck reports controller powered off — releasing wire slot")
releaseSlot()
}
return return
} }
if (!Sc2Device.parseState(report, len, state)) { if (!Sc2Device.parseState(report, len, state)) {
@@ -7,7 +7,11 @@ import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager import android.hardware.usb.UsbManager
import android.hardware.usb.UsbRequest
import android.util.Log import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
/** /**
* USB transport for a Steam Controller 2 — wired (`28DE:1302`) or through the wireless Puck * USB transport for a Steam Controller 2 — wired (`28DE:1302`) or through the wireless Puck
@@ -33,6 +37,10 @@ class Sc2UsbLink(
private var reader: Thread? = null private var reader: Thread? = null
/** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread — see
* [readLoop] for why only one thread may drive this connection's [UsbRequest]s. */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
@Volatile private var running = false @Volatile private var running = false
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */ /** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
@@ -72,7 +80,7 @@ class Sc2UsbLink(
), ),
) )
writeFeature(Sc2Device.DISABLE_LIZARD) writeFeature(Sc2Device.DISABLE_LIZARD)
reader = Thread({ readLoop(conn, claimed.second) }, "pf-sc2-usb").apply { reader = Thread({ readLoop(conn, claimed.second, claimed.third) }, "pf-sc2-usb").apply {
isDaemon = true isDaemon = true
start() start()
} }
@@ -119,33 +127,97 @@ class Sc2UsbLink(
return null return null
} }
private fun readLoop(conn: UsbDeviceConnection, ep: UsbEndpoint) { /**
val buf = ByteArray(64) * The read loop, built on [UsbRequest] — NOT `bulkTransfer()`: Android only supports bulk
* transactions on bulk endpoints, and the SC2's endpoints are INTERRUPT. `bulkTransfer()`
* returned the first (already-buffered) report and then `-1` forever, which the first
* on-glass run surfaced as a 250 ms create→unplug flap. One IN request stays queued at all
* times; OUT writes (Steam's forwarded rumble) are queued from [writeRaw]'s thread onto
* [outQueue] and submitted HERE, because `requestWait` returns ANY completed request on the
* connection — a second thread waiting would steal the reader's completions.
*/
private fun readLoop(conn: UsbDeviceConnection, epIn: UsbEndpoint, epOut: UsbEndpoint?) {
val inReq = UsbRequest()
if (!inReq.initialize(conn, epIn)) {
Log.e(TAG, "UsbRequest.initialize(IN) failed")
if (running) {
running = false
onClosed()
}
return
}
val outReq = epOut?.let { ep ->
UsbRequest().takeIf { it.initialize(conn, ep) }
?: run { Log.w(TAG, "UsbRequest.initialize(OUT) failed — output reports dropped"); null }
}
val inBuf = ByteBuffer.allocate(64)
val scratch = ByteArray(64)
var outBusy = false
var lastLizard = 0L var lastLizard = 0L
var failures = 0 var quietSince = 0L // elapsedRealtime of the first silent/failed wait in the streak; 0 = healthy
var reports = 0L
try {
inBuf.clear()
if (!inReq.queue(inBuf)) {
Log.e(TAG, "queue(IN) failed")
return
}
while (running) { while (running) {
val now = android.os.SystemClock.elapsedRealtime() val now = android.os.SystemClock.elapsedRealtime()
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) { if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
writeFeature(Sc2Device.DISABLE_LIZARD) writeFeature(Sc2Device.DISABLE_LIZARD)
lastLizard = now lastLizard = now
} }
val n = conn.bulkTransfer(ep, buf, buf.size, READ_TIMEOUT_MS) // Submit the next pending OUT report while the OUT slot is idle.
when { if (!outBusy && outReq != null) {
n > 0 -> { outQueue.poll()?.let { data ->
failures = 0 if (outReq.queue(ByteBuffer.wrap(data))) outBusy = true
onReport(buf, n)
} }
n == 0 -> {} // empty read — keep going }
else -> { val done = try {
// -1 covers both timeout (normal, idle controller) and unplug. A real unplug conn.requestWait(READ_TIMEOUT_MS.toLong())
// makes every subsequent transfer fail instantly, so many consecutive fast } catch (_: TimeoutException) {
// failures = the device is gone. // Normal while the pad is quiet; a SUSTAINED silence is the unplug signal
if (++failures >= 64) { // (a healthy SC2 streams state continuously at its 1 kHz interval).
Log.i(TAG, "SC2 USB read failing persistently — treating as unplug") if (quietSince == 0L) quietSince = now
if (now - quietSince >= UNPLUG_AFTER_MS) {
Log.i(TAG, "SC2 USB silent for ${now - quietSince} ms (after $reports reports) — treating as unplug")
break
}
continue
}
when {
done === inReq -> {
if (quietSince != 0L) {
Log.i(TAG, "SC2 USB reads recovered after ${now - quietSince} ms")
quietSince = 0L
}
val n = inBuf.position()
if (n > 0) {
inBuf.flip()
inBuf.get(scratch, 0, n)
if (reports++ == 0L) {
Log.i(TAG, "SC2 USB first report: id=0x%02x len=%d".format(scratch[0].toInt() and 0xFF, n))
}
onReport(scratch, n)
}
inBuf.clear()
if (!inReq.queue(inBuf)) {
Log.i(TAG, "re-queue(IN) failed — treating as unplug")
break
}
}
done === outReq -> outBusy = false
done == null -> {
// requestWait error — the connection is gone (unplug / claim revoked).
Log.i(TAG, "SC2 USB requestWait error (after $reports reports) — treating as unplug")
break break
} }
} }
} }
} finally {
runCatching { inReq.cancel(); inReq.close() }
runCatching { outReq?.cancel(); outReq?.close() }
} }
if (running) { if (running) {
running = false running = false
@@ -163,10 +235,11 @@ class Sc2UsbLink(
if (data.isEmpty()) return if (data.isEmpty()) return
when (kind) { when (kind) {
0 -> { 0 -> {
val out = epOut if (epOut != null) {
val conn = connection ?: return // Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
if (out != null) { // newest-wins: these are level-styled commands the host re-sends anyway.
conn.bulkTransfer(out, data, data.size, WRITE_TIMEOUT_MS) while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
} else { } else {
setReport(REPORT_TYPE_OUTPUT, data) setReport(REPORT_TYPE_OUTPUT, data)
} }
@@ -205,6 +278,7 @@ class Sc2UsbLink(
running = false running = false
runCatching { reader?.join(1000) } runCatching { reader?.join(1000) }
reader = null reader = null
outQueue.clear()
runCatching { iface?.let { connection?.releaseInterface(it) } } runCatching { iface?.let { connection?.releaseInterface(it) } }
runCatching { connection?.close() } runCatching { connection?.close() }
connection = null connection = null
@@ -217,6 +291,9 @@ class Sc2UsbLink(
const val TAG = "Sc2UsbLink" const val TAG = "Sc2UsbLink"
const val READ_TIMEOUT_MS = 100 const val READ_TIMEOUT_MS = 100
const val WRITE_TIMEOUT_MS = 250 const val WRITE_TIMEOUT_MS = 250
/** Sustained read-failure window treated as an unplug (a streaming pad reports every
* few ms; even an idle one shouldn't go silent for this long). */
const val UNPLUG_AFTER_MS = 5000L
const val REPORT_TYPE_OUTPUT = 0x02 const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03 const val REPORT_TYPE_FEATURE = 0x03
} }
+6
View File
@@ -576,6 +576,12 @@ pub mod switch_proto;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/proto/triton_proto.rs"] #[path = "inject/proto/triton_proto.rs"]
pub mod triton_proto; 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 /// 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 /// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12). /// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
@@ -12,11 +12,11 @@
//! ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)) and are written unchanged; //! ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)) and are written unchanged;
//! everything Steam writes back (SET_REPORT features, OUTPUT haptics) is acked and forwarded //! everything Steam writes back (SET_REPORT features, OUTPUT haptics) is acked and forwarded
//! raw for replay on the physical controller. //! raw for replay on the physical controller.
//! 3. **UHID-only for now.** Steam Input historically ignores UHID devices for *promotion* //! 3. **usbip first, UHID fallback.** Steam ignores UHID devices (`Interface: -1`) for the
//! (`Interface: -1`; the Deck path grew usbip/gadget transports for this). Whether Steam's //! Triton exactly as it did for the Deck — CONFIRMED on-glass 2026-07-15 — so the preferred
//! Triton support accepts a UHID hidraw is unverified on-glass — the creation log flags it, //! transport is [`super::triton_usbip`] (`vhci_hcd`), which presents a real USB device
//! and a usbip transport (needs the physical pad's captured USB descriptors) is the known //! byte-matched to the physical wired pad's captured descriptors. UHID remains the degraded
//! follow-up if it doesn't. //! fallback (hidraw exists, Steam won't list it) for hosts without `vhci_hcd`/root.
use super::triton_proto::{ use super::triton_proto::{
parse_triton_rumble, serialize_triton_state, strip_report_prefix, TritonState, TRITON_RDESC, 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 /// The Triton-specific half of the shared stateful manager (see [`PadProto`]): raw mirroring
/// with the typed fallback, and the raw-forwarding service pass. /// with the typed fallback, and the raw-forwarding service pass.
#[derive(Default)] #[derive(Default)]
pub struct TritonProto; pub struct TritonProto;
impl PadProto for TritonProto { impl PadProto for TritonProto {
type Pad = TritonPad; type Pad = TritonTransport;
type State = TritonState; type State = TritonState;
const LABEL: &'static str = "Steam Controller 2"; const LABEL: &'static str = "Steam Controller 2";
const DEVICE: &'static str = "Steam Controller 2"; const DEVICE: &'static str = "Steam Controller 2";
const CREATE_HINT: &'static str = ""; const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<TritonPad> { fn open(&mut self, idx: u8) -> Result<TritonTransport> {
let p = TritonPad::open(idx)?; open_transport(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 neutral(&self) -> TritonState { fn neutral(&self) -> TritonState {
@@ -293,15 +341,15 @@ impl PadProto for TritonProto {
// and the synth fallback has no surface for them. // and the synth fallback has no surface for them.
} }
fn write_state(&self, pad: &mut TritonPad, st: &TritonState) { fn write_state(&self, pad: &mut TritonTransport, st: &TritonState) {
let _ = pad.write_state(st); pad.write_state(st);
} }
/// Ack + queue Steam's writes, then hand them to the pump as raw 0xCD events; rumble ALSO /// 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. /// rides the universal 0xCA plane (deduped) so the client's phone-mirror path keeps working.
fn service(&self, pad: &mut TritonPad, idx: u8) -> PadFeedback { fn service(&self, pad: &mut TritonTransport, idx: u8) -> PadFeedback {
let rumble = pad.service(); let (rumble, raw) = pad.service();
let hidout = std::mem::take(&mut pad.pending_raw) let hidout = raw
.into_iter() .into_iter()
.map(|(kind, data)| HidOutput::HidRaw { .map(|(kind, data)| HidOutput::HidRaw {
pad: idx, pad: idx,
@@ -151,7 +151,7 @@ impl UsbInterfaceHandler for IdleHidHandler {
} }
} }
fn boxed( pub(crate) fn boxed(
h: impl UsbInterfaceHandler + Send + 'static, h: impl UsbInterfaceHandler + Send + 'static,
) -> Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>> { ) -> Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>> {
Arc::new(Mutex::new(Box::new(h))) 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 /// A usbip-attached simulated device: the `vhci_hcd` port plus the socket + emulation server
/// disappears, Steam releases its slot) and stops the emulation server. /// keeping it alive. Dropping it detaches the port FIRST (the kernel closes its socket end and
pub struct SteamDeckUsbip { /// tears the device down — Steam releases its slot), then drops the socket and stops the server —
report: Arc<Mutex<[u8; 64]>>, /// the teardown order the Deck transport shipped with. Shared by every usbip-presented pad
feedback: Arc<Mutex<SteamFeedback>>, /// (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. /// The `vhci_hcd` port we attached to — written to the sysfs `detach` file on drop.
vhci_port: u16, vhci_port: u16,
/// Kept alive so the connected socket fd we handed to `vhci_hcd` stays valid (in-process attach /// Kept alive so the connected socket fd we handed to `vhci_hcd` stays valid (in-process attach
@@ -309,41 +310,42 @@ pub struct SteamDeckUsbip {
_client_sock: Option<TcpStream>, _client_sock: Option<TcpStream>,
/// Emulation-server thread; dropped (stopped) after the detach. /// Emulation-server thread; dropped (stopped) after the detach.
_server: ServerThread, _server: ServerThread,
seq: u32,
} }
impl SteamDeckUsbip { impl Drop for UsbipAttachment {
/// Bind a virtual Deck and attach it locally via `vhci_hcd`. `index` varies only the serial. fn drop(&mut self) {
/// Requires `vhci_hcd` loaded and root (the sysfs attach / the CLI both need it). Tries the if let Err(e) = vhci_detach(self.vhci_port) {
/// in-process sysfs attach first, then the `usbip` CLI; `PUNKTFUNK_USBIP_ATTACH=inproc|cli` tracing::debug!(port = self.vhci_port, error = %e, "vhci detach failed (device may already be gone)");
/// pins one path (for debugging). }
pub fn open(index: u8) -> Result<SteamDeckUsbip> { }
}
/// 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(); ensure_modules();
if vhci_base().is_none() { if vhci_base().is_none() {
bail!( bail!("vhci_hcd unavailable (no /sys/devices/platform/vhci_hcd*/status) — is it loaded?");
"vhci_hcd unavailable (no /sys/devices/platform/vhci_hcd*/status) — is it loaded?"
);
} }
let mode = std::env::var("PUNKTFUNK_USBIP_ATTACH").ok(); let mode = std::env::var("PUNKTFUNK_USBIP_ATTACH").ok();
if mode.as_deref() != Some("cli") { if mode.as_deref() != Some("cli") {
match Self::open_in_process(index) { match attach_in_process(build(), label) {
Ok(d) => return Ok(d), Ok(a) => return Ok(a),
Err(e) if mode.as_deref() == Some("inproc") => return Err(e), Err(e) if mode.as_deref() == Some("inproc") => return Err(e),
Err(e) => { Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "in-process vhci attach failed — trying the usbip CLI") tracing::warn!(error = %format!("{e:#}"), "in-process vhci attach failed — trying the usbip CLI")
} }
} }
} }
Self::open_via_cli(index) attach_via_cli(build(), label)
} }
/// In-process attach: emulate on a loopback port, do the import handshake ourselves, hand the /// 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. /// connected socket to `vhci_hcd` via sysfs. No external dependency.
fn open_in_process(index: u8) -> Result<SteamDeckUsbip> { fn attach_in_process(dev: UsbDevice, label: &str) -> Result<UsbipAttachment> {
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). // An ephemeral loopback port (avoids contending the usbip default with another pad).
let listener = let listener =
std::net::TcpListener::bind(("127.0.0.1", 0)).context("bind loopback usbip server")?; std::net::TcpListener::bind(("127.0.0.1", 0)).context("bind loopback usbip server")?;
@@ -369,27 +371,20 @@ impl SteamDeckUsbip {
vhci_attach(vhci_port, sock.as_raw_fd(), devid, speed).context("write vhci_hcd attach")?; vhci_attach(vhci_port, sock.as_raw_fd(), devid, speed).context("write vhci_hcd attach")?;
tracing::info!( tracing::info!(
index, label,
vhci_port, vhci_port,
"virtual Steam Deck attached via usbip (in-process — Steam Input recognizes it)" "attached via usbip (in-process — Steam Input recognizes it)"
); );
Ok(SteamDeckUsbip { Ok(UsbipAttachment {
report,
feedback,
vhci_port, vhci_port,
_client_sock: Some(sock), _client_sock: Some(sock),
_server: server, _server: server,
seq: 0,
}) })
} }
/// Fallback: emulate on the usbip default port and let the `usbip` CLI attach (it picks the vhci /// 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). /// port itself; we recover it by diffing the sysfs status).
fn open_via_cli(index: u8) -> Result<SteamDeckUsbip> { fn attach_via_cli(dev: UsbDevice, label: &str) -> Result<UsbipAttachment> {
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)) 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"))?; .with_context(|| format!("bind usbip default port {USBIP_TCP_PORT} for CLI attach"))?;
listener listener
@@ -403,16 +398,39 @@ impl SteamDeckUsbip {
.context("could not determine the vhci port the usbip CLI attached to")?; .context("could not determine the vhci port the usbip CLI attached to")?;
tracing::info!( tracing::info!(
index, label,
vhci_port, vhci_port,
"virtual Steam Deck attached via usbip (CLI — Steam Input recognizes it)" "attached via usbip (CLI — Steam Input recognizes it)"
); );
Ok(SteamDeckUsbip { Ok(UsbipAttachment {
report,
feedback,
vhci_port, vhci_port,
_client_sock: None, _client_sock: None,
_server: server, _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.
pub fn open(index: u8) -> Result<SteamDeckUsbip> {
let report = Arc::new(Mutex::new(neutral_deck_report()));
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
let attach = attach_device(
|| build_device(index, &report, &feedback),
&format!("virtual Steam Deck {index}"),
)?;
Ok(SteamDeckUsbip {
report,
feedback,
_attach: attach,
seq: 0, 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) ---- // ---- USB/IP import handshake (we act as the usbip *client* before handing the fd to the kernel) ----
const USBIP_VERSION: u16 = 0x0111; 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");
}
}