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
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:
@@ -32,6 +32,11 @@ class Sc2Capture(
|
||||
private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed)
|
||||
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 val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64)
|
||||
|
||||
@@ -53,7 +58,10 @@ class Sc2Capture(
|
||||
fun startUsb(dev: UsbDevice): Boolean {
|
||||
if (activeLink != LINK_NONE) return false
|
||||
val ok = usb.start(dev)
|
||||
if (ok) activeLink = LINK_USB
|
||||
if (ok) {
|
||||
activeLink = LINK_USB
|
||||
dongleLink = dev.productId != Sc2Device.PID_WIRED
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -81,6 +89,7 @@ class Sc2Capture(
|
||||
LINK_BLE -> ble.stop()
|
||||
}
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
}
|
||||
|
||||
@@ -88,10 +97,15 @@ class Sc2Capture(
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val id = report[0].toInt() and 0xFF
|
||||
// A Puck relays connect/disconnect for its controller — track the slot accordingly, so
|
||||
// powering the pad off frees its wire index (and the host's virtual device).
|
||||
// Wireless status: authoritative ONLY through a Puck dongle (powering the pad off frees
|
||||
// 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 ((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
|
||||
}
|
||||
if (!Sc2Device.parseState(report, len, state)) {
|
||||
|
||||
@@ -7,7 +7,11 @@ import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.hardware.usb.UsbRequest
|
||||
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
|
||||
@@ -33,6 +37,10 @@ class Sc2UsbLink(
|
||||
|
||||
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
|
||||
|
||||
/** 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)
|
||||
reader = Thread({ readLoop(conn, claimed.second) }, "pf-sc2-usb").apply {
|
||||
reader = Thread({ readLoop(conn, claimed.second, claimed.third) }, "pf-sc2-usb").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
@@ -119,33 +127,97 @@ class Sc2UsbLink(
|
||||
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 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) {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
|
||||
writeFeature(Sc2Device.DISABLE_LIZARD)
|
||||
lastLizard = now
|
||||
}
|
||||
val n = conn.bulkTransfer(ep, buf, buf.size, READ_TIMEOUT_MS)
|
||||
when {
|
||||
n > 0 -> {
|
||||
failures = 0
|
||||
onReport(buf, n)
|
||||
// Submit the next pending OUT report while the OUT slot is idle.
|
||||
if (!outBusy && outReq != null) {
|
||||
outQueue.poll()?.let { data ->
|
||||
if (outReq.queue(ByteBuffer.wrap(data))) outBusy = true
|
||||
}
|
||||
n == 0 -> {} // empty read — keep going
|
||||
else -> {
|
||||
// -1 covers both timeout (normal, idle controller) and unplug. A real unplug
|
||||
// makes every subsequent transfer fail instantly, so many consecutive fast
|
||||
// failures = the device is gone.
|
||||
if (++failures >= 64) {
|
||||
Log.i(TAG, "SC2 USB read failing persistently — treating as unplug")
|
||||
}
|
||||
val done = try {
|
||||
conn.requestWait(READ_TIMEOUT_MS.toLong())
|
||||
} catch (_: TimeoutException) {
|
||||
// Normal while the pad is quiet; a SUSTAINED silence is the unplug signal
|
||||
// (a healthy SC2 streams state continuously at its 1 kHz interval).
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
runCatching { inReq.cancel(); inReq.close() }
|
||||
runCatching { outReq?.cancel(); outReq?.close() }
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
@@ -163,10 +235,11 @@ class Sc2UsbLink(
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
0 -> {
|
||||
val out = epOut
|
||||
val conn = connection ?: return
|
||||
if (out != null) {
|
||||
conn.bulkTransfer(out, data, data.size, WRITE_TIMEOUT_MS)
|
||||
if (epOut != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the host re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
@@ -205,6 +278,7 @@ class Sc2UsbLink(
|
||||
running = false
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
outQueue.clear()
|
||||
runCatching { iface?.let { connection?.releaseInterface(it) } }
|
||||
runCatching { connection?.close() }
|
||||
connection = null
|
||||
@@ -217,6 +291,9 @@ class Sc2UsbLink(
|
||||
const val TAG = "Sc2UsbLink"
|
||||
const val READ_TIMEOUT_MS = 100
|
||||
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_FEATURE = 0x03
|
||||
}
|
||||
|
||||
@@ -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,41 +310,42 @@ pub struct SteamDeckUsbip {
|
||||
_client_sock: Option<TcpStream>,
|
||||
/// Emulation-server thread; dropped (stopped) after the detach.
|
||||
_server: ServerThread,
|
||||
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> {
|
||||
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?"
|
||||
);
|
||||
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),
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
/// 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);
|
||||
|
||||
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")?;
|
||||
@@ -369,27 +371,20 @@ impl SteamDeckUsbip {
|
||||
vhci_attach(vhci_port, sock.as_raw_fd(), devid, speed).context("write vhci_hcd attach")?;
|
||||
|
||||
tracing::info!(
|
||||
index,
|
||||
label,
|
||||
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 {
|
||||
report,
|
||||
feedback,
|
||||
Ok(UsbipAttachment {
|
||||
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);
|
||||
|
||||
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
|
||||
@@ -403,16 +398,39 @@ impl SteamDeckUsbip {
|
||||
.context("could not determine the vhci port the usbip CLI attached to")?;
|
||||
|
||||
tracing::info!(
|
||||
index,
|
||||
label,
|
||||
vhci_port,
|
||||
"virtual Steam Deck attached via usbip (CLI — Steam Input recognizes it)"
|
||||
"attached via usbip (CLI — Steam Input recognizes it)"
|
||||
);
|
||||
Ok(SteamDeckUsbip {
|
||||
report,
|
||||
feedback,
|
||||
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.
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user