feat(gamepad): SC2 Puck-dongle passthrough with the native 28DE:1304 topology
Community-contributed round 5 of the Steam Controller 2 passthrough, reviewed + verified. A Puck-captured pad now presents the dongle's real seven-interface identity (CDC pair, four controller HID slots, management HID) instead of relabelling its reports as a wired 1302 — Steam's Puck feature dances (wireless_transport / esb/bond / 0xB4 slot status) get capture-shaped answers, and the wired identity's canned replies are corrected to the real captures (attribute count, string-attr framing, 0xF2 firmware info, bcdDevice nibble encoding). - new wire pref 10 = SteamController2Puck (Hello/Welcome byte; older peers degrade to Auto), selected by the Android capture link when the transport is a dongle, or by VID/PID in the degraded InputDevice path - TRITON_RDESC is now the captured numbered descriptor (mouse/keyboard lizard collections + per-id vendor reports); unnumbered framing made hidraw mangle feature report 2 and Steam eventually closed the device - interrupt-IN now queues sparse reports (battery/RSSI/wireless edges) instead of keeping latest-only, so a 250 Hz state packet can no longer erase them before the USB/IP poll observes them; EP0 SET_REPORT is split by wValue report type (OUTPUT parsed for rumble vs FEATURE) - vendored usbip-sim: config attributes/max-power, IAD prefix + BOS descriptor support, correct BCD minor.patch encoding (Deck's 0x0300/ 0x0200 values are nibble-zero, so its bytes are unchanged), and full-speed interrupt pacing in ms (was 8 kHz from the HS formula) - Triton feedback is serviced at 1 kHz while an SC2 backend exists so Steam's trackpad haptic writes reach the client unbatched Verified: clippy -D warnings + 319 host tests green on Linux, core wire tests green, Android kit/app compile + unit tests green. On-glass Puck retest owed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -270,6 +270,7 @@ impl PadInfo {
|
||||
GamepadPref::SteamDeck => "Steam Deck",
|
||||
GamepadPref::SteamController => "Steam Controller",
|
||||
GamepadPref::SteamController2 => "Steam Controller 2",
|
||||
GamepadPref::SteamController2Puck => "Steam Controller 2 Puck",
|
||||
GamepadPref::SwitchPro => "Switch Pro",
|
||||
_ => "",
|
||||
}
|
||||
|
||||
@@ -906,6 +906,10 @@ pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
|
||||
/// Steam Input is the consumer (no kernel driver binds the PID). Honored on Linux (UHID);
|
||||
/// else folds to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2: u32 = 9;
|
||||
/// Steam Controller Puck dongle (`28DE:1304`) passed through with its native seven-interface
|
||||
/// topology and four controller slots. Used by capture clients that own the physical Puck;
|
||||
/// ordinary wired/BLE SC2 capture remains `STEAMCONTROLLER2`.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK: u32 = 10;
|
||||
|
||||
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
|
||||
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
|
||||
@@ -970,6 +974,9 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSENSEEDGE == GamepadPref::DualSenseEdge.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_SWITCHPRO == GamepadPref::SwitchPro.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 == GamepadPref::SteamController2.to_u8() as u32);
|
||||
assert!(
|
||||
PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK == GamepadPref::SteamController2Puck.to_u8() as u32
|
||||
);
|
||||
// Extended button bits mirror the wire `input::gamepad` constants.
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
|
||||
|
||||
@@ -139,8 +139,8 @@ impl CompositorPref {
|
||||
/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
|
||||
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
|
||||
/// `9 = SteamController2`), appended to `Hello`/`Welcome` — older peers simply omit/ignore it (an
|
||||
/// unknown byte degrades to `Auto`).
|
||||
/// `9 = SteamController2`, `10 = SteamController2Puck`), appended to `Hello`/`Welcome` — older
|
||||
/// peers simply omit/ignore it (an unknown byte degrades to `Auto`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GamepadPref {
|
||||
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
|
||||
@@ -181,12 +181,16 @@ pub enum GamepadPref {
|
||||
/// real controller). No kernel driver binds the PID (mainline `hid-steam` stops at the Deck),
|
||||
/// so Steam Input is the consumer. Needs Linux UHID.
|
||||
SteamController2,
|
||||
/// Steam Controller Puck dongle (`28DE:1304`) carrying a captured SC2. The host presents the
|
||||
/// native seven-interface Puck topology (CDC pair, four controller slots, management HID)
|
||||
/// rather than relabelling its reports as a wired `1302`.
|
||||
SteamController2Puck,
|
||||
}
|
||||
|
||||
impl GamepadPref {
|
||||
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
|
||||
/// `9 = SteamController2`.
|
||||
/// `9 = SteamController2`, `10 = SteamController2Puck`.
|
||||
pub const fn to_u8(self) -> u8 {
|
||||
match self {
|
||||
GamepadPref::Auto => 0,
|
||||
@@ -199,6 +203,7 @@ impl GamepadPref {
|
||||
GamepadPref::DualSenseEdge => 7,
|
||||
GamepadPref::SwitchPro => 8,
|
||||
GamepadPref::SteamController2 => 9,
|
||||
GamepadPref::SteamController2Puck => 10,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +220,7 @@ impl GamepadPref {
|
||||
7 => GamepadPref::DualSenseEdge,
|
||||
8 => GamepadPref::SwitchPro,
|
||||
9 => GamepadPref::SteamController2,
|
||||
10 => GamepadPref::SteamController2Puck,
|
||||
_ => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -239,13 +245,16 @@ impl GamepadPref {
|
||||
"steamcontroller2" | "steam-controller-2" | "steamcon2" | "sc2" | "ibex" => {
|
||||
GamepadPref::SteamController2
|
||||
}
|
||||
"steamcontroller2puck" | "steam-controller-2-puck" | "sc2puck" | "ibexpuck" => {
|
||||
GamepadPref::SteamController2Puck
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
|
||||
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`,
|
||||
/// `"steamcontroller2"`).
|
||||
/// `"steamcontroller2"`, `"steamcontroller2puck"`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GamepadPref::Auto => "auto",
|
||||
@@ -258,6 +267,7 @@ impl GamepadPref {
|
||||
GamepadPref::DualSenseEdge => "dualsenseedge",
|
||||
GamepadPref::SwitchPro => "switchpro",
|
||||
GamepadPref::SteamController2 => "steamcontroller2",
|
||||
GamepadPref::SteamController2Puck => "steamcontroller2puck",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,11 +343,12 @@ fn gamepad_pref_wire_and_names() {
|
||||
GamepadPref::DualSenseEdge,
|
||||
GamepadPref::SwitchPro,
|
||||
GamepadPref::SteamController2,
|
||||
GamepadPref::SteamController2Puck,
|
||||
] {
|
||||
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
|
||||
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
|
||||
}
|
||||
// Every wire byte 0..=9 is assigned, distinct, and pinned (forward-compat with peers
|
||||
// Every wire byte 0..=10 is assigned, distinct, and pinned (forward-compat with peers
|
||||
// that only know a prefix of the range).
|
||||
for (v, p) in [
|
||||
(0, GamepadPref::Auto),
|
||||
@@ -360,12 +361,13 @@ fn gamepad_pref_wire_and_names() {
|
||||
(7, GamepadPref::DualSenseEdge),
|
||||
(8, GamepadPref::SwitchPro),
|
||||
(9, GamepadPref::SteamController2),
|
||||
(10, GamepadPref::SteamController2Puck),
|
||||
] {
|
||||
assert_eq!(p.to_u8(), v);
|
||||
assert_eq!(GamepadPref::from_u8(v), p);
|
||||
}
|
||||
// The next unassigned byte degrades to Auto today; assigning it later must update this.
|
||||
assert_eq!(GamepadPref::from_u8(10), GamepadPref::Auto);
|
||||
assert_eq!(GamepadPref::from_u8(11), GamepadPref::Auto);
|
||||
// Aliases + unknowns.
|
||||
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
||||
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
||||
@@ -387,6 +389,10 @@ fn gamepad_pref_wire_and_names() {
|
||||
GamepadPref::from_name("sc2"),
|
||||
Some(GamepadPref::SteamController2)
|
||||
);
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("sc2puck"),
|
||||
Some(GamepadPref::SteamController2Puck)
|
||||
);
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("xbox-one"),
|
||||
Some(GamepadPref::XboxOne)
|
||||
|
||||
@@ -26,6 +26,7 @@ mdns-sd = "0.20"
|
||||
mac_address = "1"
|
||||
if-addrs = "0.13"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
parking_lot = "0.12"
|
||||
rsa = "0.9"
|
||||
sha2 = { version = "0.10", features = ["oid"] }
|
||||
aes = "0.8"
|
||||
|
||||
@@ -272,9 +272,14 @@ impl TritonTransport {
|
||||
/// Open the best Steam-visible SC2 transport: **usbip (`vhci_hcd`) → UHID.** Steam is confirmed
|
||||
/// (on-glass 2026-07-15) to ignore the UHID leg, so reaching the fallback means the pad exists as
|
||||
/// hidraw only — flagged loudly, with the vhci_hcd remedy in the log.
|
||||
fn open_transport(idx: u8) -> Result<TritonTransport> {
|
||||
fn open_transport(idx: u8, puck: bool) -> Result<TritonTransport> {
|
||||
if crate::inject::steam_usbip::usbip_preferred() {
|
||||
match crate::inject::triton_usbip::TritonUsbip::open(idx) {
|
||||
let opened = if puck {
|
||||
crate::inject::triton_usbip::TritonUsbip::open_puck(idx)
|
||||
} else {
|
||||
crate::inject::triton_usbip::TritonUsbip::open(idx)
|
||||
};
|
||||
match opened {
|
||||
Ok(u) => return Ok(TritonTransport::Usbip(u)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "usbip SC2 unavailable — falling back to UHID")
|
||||
@@ -294,7 +299,15 @@ fn open_transport(idx: u8) -> Result<TritonTransport> {
|
||||
/// The Triton-specific half of the shared stateful manager (see [`PadProto`]): raw mirroring
|
||||
/// with the typed fallback, and the raw-forwarding service pass.
|
||||
#[derive(Default)]
|
||||
pub struct TritonProto;
|
||||
pub struct TritonProto {
|
||||
puck: bool,
|
||||
}
|
||||
|
||||
impl TritonProto {
|
||||
pub fn puck() -> Self {
|
||||
Self { puck: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for TritonProto {
|
||||
type Pad = TritonTransport;
|
||||
@@ -304,7 +317,7 @@ impl PadProto for TritonProto {
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<TritonTransport> {
|
||||
open_transport(idx)
|
||||
open_transport(idx, self.puck)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> TritonState {
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
//! Virtual **Steam Controller 2** over USB/IP (`vhci_hcd`) — the Steam-promotable transport for
|
||||
//! the as-is passthrough backend ([`super::steam_controller2`]). The UHID leg was confirmed
|
||||
//! on-glass to be invisible to Steam (`Interface: -1`, the same gap the Deck had pre-usbip), so
|
||||
//! this presents a *real* USB device instead, byte-matched to an `lsusb -v` capture of the
|
||||
//! physical WIRED pad (2026-07-15, firmware bcdDevice 3.07):
|
||||
//! physical devices captured on 2026-07-15:
|
||||
//!
|
||||
//! - device: bcdUSB 2.00, class `EF/02/01` (IAD convention, as shipped), Full Speed,
|
||||
//! strings `Valve Software` / `Steam Controller`, 1 configuration (bus powered, 500 mA);
|
||||
//! - one interface (#0): HID `03/00/00`, bcdHID 1.11, EP `0x81` interrupt-IN + `0x01`
|
||||
//! interrupt-OUT, 64-byte packets, `bInterval 1` (1 kHz).
|
||||
//! - direct wired: `28DE:1302`, one Triton HID interface;
|
||||
//! - Puck: `28DE:1304`, CDC interfaces 0–1, four identical Triton HID controller slots on
|
||||
//! interfaces 2–5, and the Puck management HID on interface 6.
|
||||
//!
|
||||
//! (The Puck dongle presents a 7-interface layout — CDC pair + controllers on 2..5 — but the
|
||||
//! wired identity is simpler and, per SDL's own matcher, the wired PID is accepted on ANY
|
||||
//! interface number. Wired is what we emulate.)
|
||||
//!
|
||||
//! Semantics mirror the UHID leg: interrupt-IN streams the client's raw reports verbatim (or the
|
||||
//! typed-fallback `0x42` synth), interrupt-OUT captures Steam's haptic output reports (`0x80`
|
||||
//! rumble parsed for the 0xCA plane, everything forwarded raw), EP0 SET_REPORT features are
|
||||
//! remembered + forwarded raw, and EP0 GET_REPORT answers a canned Triton-shaped serial (the
|
||||
//! query dance can't round-trip to the physical pad synchronously). The vhci plumbing + attach
|
||||
//! choreography come from [`super::steam_usbip`] (one source of truth with the Deck).
|
||||
//! Report bodies are never translated. The declared client kind selects only the physical USB
|
||||
//! topology which owned those bytes. Interrupt-OUT and feature SET_REPORT traffic is captured and
|
||||
//! returned to the Android physical-device owner exactly as on the UHID leg.
|
||||
|
||||
use super::steam_usbip::{attach_device, boxed, UsbipAttachment};
|
||||
use super::triton_proto::{
|
||||
@@ -26,8 +17,10 @@ use super::triton_proto::{
|
||||
triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use parking_lot::Mutex;
|
||||
use std::any::Any;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use usbip_sim::{
|
||||
Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbSpeed,
|
||||
Version,
|
||||
@@ -35,6 +28,15 @@ use usbip_sim::{
|
||||
|
||||
const TRITON_VENDOR: u16 = 0x28DE;
|
||||
const TRITON_WIRED_PRODUCT: u16 = 0x1302;
|
||||
const TRITON_PUCK_PRODUCT: u16 = 0x1304;
|
||||
|
||||
/// Captured interface-6 Puck management HID descriptor (54 bytes).
|
||||
const PUCK_MANAGEMENT_RDESC: &[u8] = &[
|
||||
0x06, 0x00, 0xFF, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08,
|
||||
0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
|
||||
0x01, 0x09, 0x79, 0x81, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0x85, 0x02, 0x95,
|
||||
0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
/// Everything Steam wrote to the device since the last service pass.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -45,6 +47,64 @@ pub(crate) struct TritonUsbFeedback {
|
||||
pub raw: Vec<(u8, Vec<u8>)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct InputReport {
|
||||
data: [u8; 64],
|
||||
len: u8,
|
||||
}
|
||||
|
||||
impl Default for InputReport {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data: [0; 64],
|
||||
len: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A physical interrupt-IN endpoint queues sparse reports, but replays continuous controller
|
||||
/// state when the host polls faster than the controller. Keeping only one latest report loses a
|
||||
/// battery/RSSI packet to the following 250 Hz state packet before USB/IP can observe it.
|
||||
#[derive(Debug)]
|
||||
struct InputReports {
|
||||
latest_state: InputReport,
|
||||
pending: VecDeque<InputReport>,
|
||||
}
|
||||
|
||||
impl InputReports {
|
||||
fn new(latest_state: InputReport) -> Self {
|
||||
Self {
|
||||
latest_state,
|
||||
pending: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_pending(latest_state: InputReport, pending: InputReport) -> Self {
|
||||
let mut reports = Self::new(latest_state);
|
||||
reports.pending.push_back(pending);
|
||||
reports
|
||||
}
|
||||
|
||||
fn write(&mut self, report: InputReport) {
|
||||
match report.data[0] {
|
||||
// Continuous controller-state formats. Only the newest sample matters.
|
||||
0x42 | 0x45 | 0x47 => self.latest_state = report,
|
||||
// Battery (0x43), RSSI/status (0x44/0x7B), wireless edges (0x46/0x79), and
|
||||
// future sparse report types must each survive until Steam consumes them.
|
||||
_ => {
|
||||
if self.pending.len() >= 32 {
|
||||
self.pending.pop_front();
|
||||
}
|
||||
self.pending.push_back(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&mut self) -> InputReport {
|
||||
self.pending.pop_front().unwrap_or(self.latest_state)
|
||||
}
|
||||
}
|
||||
|
||||
/// The 9-byte HID class descriptor: bcdHID **1.11**, country 0, one report descriptor — the
|
||||
/// captured wired values ([`super::steam_usbip`]'s shared helper bakes the Deck's 1.10/33).
|
||||
fn triton_hid_desc() -> Vec<u8> {
|
||||
@@ -62,15 +122,85 @@ fn triton_hid_desc() -> Vec<u8> {
|
||||
]
|
||||
}
|
||||
|
||||
fn triton_puck_feature_reply(last_set: &[u8], serial: &str, unit_id: u32, status: u8) -> [u8; 64] {
|
||||
let Some((&report_id, body)) = last_set.split_first() else {
|
||||
return triton_feature_reply(last_set, serial, unit_id);
|
||||
};
|
||||
if report_id == 0x01 {
|
||||
if body.first() == Some(&0xED) {
|
||||
let mut reply = [0u8; 64];
|
||||
reply[..3].copy_from_slice(&[0x01, 0xED, 0]);
|
||||
let payload = body.get(2..).unwrap_or_default();
|
||||
if payload.starts_with(b"user/wireless_transport") {
|
||||
reply[2] = 1;
|
||||
reply[3] = 2; // active Puck slot 0 maps to transport code 0 XOR 2
|
||||
} else if status == 0x02 && payload.starts_with(b"esb/bond") {
|
||||
reply[2] = 0x18;
|
||||
write_puck_bond(&mut reply[3..27], serial, unit_id);
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
return triton_feature_reply(last_set, serial, unit_id);
|
||||
}
|
||||
if report_id != 0x02 {
|
||||
return triton_feature_reply(last_set, serial, unit_id);
|
||||
}
|
||||
|
||||
let cmd = body.first().copied().unwrap_or(0xB4);
|
||||
let mut reply = [0u8; 64];
|
||||
reply[0] = 0x02;
|
||||
reply[1] = cmd;
|
||||
match cmd {
|
||||
0x83 => {
|
||||
reply[2] = 0x19;
|
||||
let attrs = [
|
||||
(0x01, TRITON_PUCK_PRODUCT as u32),
|
||||
(0x02, 0),
|
||||
(0x0A, unit_id ^ 0xFC),
|
||||
(0x04, unit_id ^ 0x0296_DA2C),
|
||||
(0x09, 0x47),
|
||||
];
|
||||
let mut o = 3;
|
||||
for (id, value) in attrs {
|
||||
reply[o] = id;
|
||||
reply[o + 1..o + 5].copy_from_slice(&value.to_le_bytes());
|
||||
o += 5;
|
||||
}
|
||||
}
|
||||
0xA3 => {
|
||||
reply[2] = 0x18;
|
||||
if status == 0x02 {
|
||||
write_puck_bond(&mut reply[3..27], serial, unit_id);
|
||||
}
|
||||
}
|
||||
0xB4 => reply[..4].copy_from_slice(&[0x02, 0xB4, 0x01, status]),
|
||||
_ => {
|
||||
let n = body.len().min(63);
|
||||
reply[1..1 + n].copy_from_slice(&body[..n]);
|
||||
}
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
fn write_puck_bond(out: &mut [u8], serial: &str, unit_id: u32) {
|
||||
out[..4].copy_from_slice(&unit_id.to_le_bytes());
|
||||
out[4..8].copy_from_slice(&(unit_id ^ 0x67BF_44D2).to_le_bytes());
|
||||
let serial = serial.as_bytes();
|
||||
let len = serial.len().min(16);
|
||||
out[8..8 + len].copy_from_slice(&serial[..len]);
|
||||
}
|
||||
|
||||
/// Interface 0: streams the current report on interrupt-IN, captures Steam's writes.
|
||||
#[derive(Debug)]
|
||||
struct TritonHandler {
|
||||
/// The current input report (zero-padded to the 64-byte packet size), shared with
|
||||
/// Latest controller state plus sparse reports awaiting an interrupt-IN poll, shared with
|
||||
/// [`TritonUsbip::write_state`].
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
feedback: Arc<Mutex<TritonUsbFeedback>>,
|
||||
reports: Arc<Mutex<InputReports>>,
|
||||
feedback: Option<Arc<Mutex<TritonUsbFeedback>>>,
|
||||
serial: String,
|
||||
unit_id: u32,
|
||||
/// `None` for wired; Puck slots answer `0xB4` with 2 (connected) or 1 (disconnected).
|
||||
puck_status: Option<u8>,
|
||||
/// The last feature SET_REPORT (id-first) — the query half of the Valve GET dance.
|
||||
last_set: Vec<u8>,
|
||||
/// Last GET query command logged (once per distinct cmd, for the tester-facing journal).
|
||||
@@ -83,12 +213,14 @@ impl TritonHandler {
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut fb) = self.feedback.lock() {
|
||||
if fb.raw.len() >= 32 {
|
||||
fb.raw.remove(0);
|
||||
}
|
||||
fb.raw.push((kind, data));
|
||||
let Some(feedback) = &self.feedback else {
|
||||
return;
|
||||
};
|
||||
let mut fb = feedback.lock();
|
||||
if fb.raw.len() >= 32 {
|
||||
fb.raw.remove(0);
|
||||
}
|
||||
fb.raw.push((kind, data));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +247,16 @@ impl UsbInterfaceHandler for TritonHandler {
|
||||
// with the wrong command type makes Steam drop the pad (confirmed on-glass
|
||||
// 2026-07-15); the dance can't round-trip to the physical pad synchronously.
|
||||
(0xA1, 0x01) => {
|
||||
let reply = triton_feature_reply(&self.last_set, &self.serial, self.unit_id);
|
||||
let reply = if let Some(status) = self.puck_status {
|
||||
triton_puck_feature_reply(
|
||||
&self.last_set,
|
||||
&self.serial,
|
||||
self.unit_id,
|
||||
status,
|
||||
)
|
||||
} else {
|
||||
triton_feature_reply(&self.last_set, &self.serial, self.unit_id)
|
||||
};
|
||||
if reply[1] != self.last_get_logged {
|
||||
self.last_get_logged = reply[1];
|
||||
tracing::info!(
|
||||
@@ -125,11 +266,11 @@ impl UsbInterfaceHandler for TritonHandler {
|
||||
}
|
||||
reply.to_vec()
|
||||
}
|
||||
// HID SET_REPORT (feature): remember the command (it selects the next GET's
|
||||
// answer) and forward raw for replay on the physical pad. EP0 OUT data may or
|
||||
// may not carry the report-id byte depending on the writer's stack (the id also
|
||||
// rides wValue's low byte) — normalize to id-first for the client.
|
||||
// HID SET_REPORT: report type rides wValue's high byte (2 = OUTPUT, 3 = FEATURE)
|
||||
// and the report id rides its low byte. EP0 OUT data may or may not repeat the id,
|
||||
// so normalize to id-first before returning it to the physical-device owner.
|
||||
(0x21, 0x09) => {
|
||||
let report_type = (setup.value >> 8) as u8;
|
||||
let id = (setup.value & 0xFF) as u8;
|
||||
let framed = if req.first() == Some(&id) && id != 0 {
|
||||
req.to_vec()
|
||||
@@ -139,26 +280,39 @@ impl UsbInterfaceHandler for TritonHandler {
|
||||
v.extend_from_slice(req);
|
||||
v
|
||||
};
|
||||
self.last_set = framed.clone();
|
||||
self.queue_raw(HID_RAW_FEATURE, framed);
|
||||
match report_type {
|
||||
2 => {
|
||||
if let (Some(r), Some(feedback)) =
|
||||
(parse_triton_rumble(&framed), self.feedback.as_ref())
|
||||
{
|
||||
feedback.lock().rumble = Some(r);
|
||||
}
|
||||
self.queue_raw(HID_RAW_OUTPUT, framed);
|
||||
}
|
||||
3 => {
|
||||
self.last_set = framed.clone();
|
||||
self.queue_raw(HID_RAW_FEATURE, framed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
(0x21, 0x0A) | (0x21, 0x0B) => vec![], // SET_IDLE / SET_PROTOCOL
|
||||
_ => vec![],
|
||||
})
|
||||
} else if let Direction::In = ep.direction() {
|
||||
// Interrupt-IN poll (paced by bInterval = 1 ms): the current report, zero-padded —
|
||||
// exactly the 64-byte packets the real wired pad produces.
|
||||
let r = self.report.lock().map(|g| *g).unwrap_or([0u8; 64]);
|
||||
Ok(r.to_vec())
|
||||
// Replay continuous state, but consume sparse battery/RSSI/wireless reports exactly
|
||||
// once so a following 250 Hz state packet cannot erase them before Steam polls.
|
||||
let r = self.reports.lock().read();
|
||||
Ok(r.data[..r.len as usize].to_vec())
|
||||
} else {
|
||||
// Interrupt-OUT: Steam's haptic output reports (`SDL_hid_write` — id-first framing
|
||||
// on the wire already). Parse rumble for the universal plane, forward everything raw.
|
||||
if !req.is_empty() {
|
||||
if let Some(r) = parse_triton_rumble(req) {
|
||||
if let Ok(mut fb) = self.feedback.lock() {
|
||||
fb.rumble = Some(r);
|
||||
}
|
||||
if let (Some(r), Some(feedback)) =
|
||||
(parse_triton_rumble(req), self.feedback.as_ref())
|
||||
{
|
||||
feedback.lock().rumble = Some(r);
|
||||
}
|
||||
self.queue_raw(HID_RAW_OUTPUT, req.to_vec());
|
||||
}
|
||||
@@ -171,11 +325,147 @@ impl UsbInterfaceHandler for TritonHandler {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IdleHandler {
|
||||
class_descriptor: Vec<u8>,
|
||||
report_descriptor: &'static [u8],
|
||||
input_report: &'static [u8],
|
||||
}
|
||||
|
||||
impl UsbInterfaceHandler for IdleHandler {
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
||||
self.class_descriptor.clone()
|
||||
}
|
||||
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
_interface: &UsbInterface,
|
||||
ep: UsbEndpoint,
|
||||
_len: u32,
|
||||
setup: SetupPacket,
|
||||
_req: &[u8],
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
if ep.is_ep0()
|
||||
&& setup.request_type == 0x81
|
||||
&& setup.request == 0x06
|
||||
&& (setup.value >> 8) == 0x22
|
||||
{
|
||||
Ok(self.report_descriptor.to_vec())
|
||||
} else if !ep.is_ep0() && matches!(ep.direction(), Direction::In) {
|
||||
Ok(self.input_report.to_vec())
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CdcControlHandler;
|
||||
|
||||
impl UsbInterfaceHandler for CdcControlHandler {
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
||||
vec![
|
||||
0x05, 0x24, 0x00, 0x10, 0x01, // CDC header, bcdCDC 1.10
|
||||
0x05, 0x24, 0x01, 0x00, 0x01, // call management → data interface 1
|
||||
0x04, 0x24, 0x02, 0x02, // ACM: line coding + serial state
|
||||
0x05, 0x24, 0x06, 0x00, 0x01, // union: master 0, slave 1
|
||||
]
|
||||
}
|
||||
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
_interface: &UsbInterface,
|
||||
_ep: UsbEndpoint,
|
||||
_len: u32,
|
||||
setup: SetupPacket,
|
||||
_req: &[u8],
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
Ok(match (setup.request_type, setup.request) {
|
||||
(0xA1, 0x21) => vec![0x00, 0xC2, 0x01, 0x00, 0x00, 0x00, 0x08], // 115200 8N1
|
||||
_ => vec![], // SET_LINE_CODING / SET_CONTROL_LINE_STATE / endpoint polls
|
||||
})
|
||||
}
|
||||
|
||||
fn as_any(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct PuckManagementHandler {
|
||||
last_set: Vec<u8>,
|
||||
}
|
||||
|
||||
impl UsbInterfaceHandler for PuckManagementHandler {
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
||||
let len = PUCK_MANAGEMENT_RDESC.len() as u16;
|
||||
vec![
|
||||
0x09,
|
||||
0x21,
|
||||
0x11,
|
||||
0x01,
|
||||
0,
|
||||
1,
|
||||
0x22,
|
||||
len as u8,
|
||||
(len >> 8) as u8,
|
||||
]
|
||||
}
|
||||
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
_interface: &UsbInterface,
|
||||
ep: UsbEndpoint,
|
||||
_len: u32,
|
||||
setup: SetupPacket,
|
||||
req: &[u8],
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
if !ep.is_ep0() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Ok(match (setup.request_type, setup.request) {
|
||||
(0x81, 0x06) if (setup.value >> 8) == 0x22 => PUCK_MANAGEMENT_RDESC.to_vec(),
|
||||
(0x21, 0x09) => {
|
||||
let id = (setup.value & 0xFF) as u8;
|
||||
self.last_set.clear();
|
||||
if req.first() != Some(&id) && id != 0 {
|
||||
self.last_set.push(id);
|
||||
}
|
||||
self.last_set.extend_from_slice(req);
|
||||
vec![]
|
||||
}
|
||||
(0xA1, 0x01) => {
|
||||
let mut reply = vec![0u8; 64];
|
||||
reply[0] = 0x02;
|
||||
let command = self.last_set.get(1).copied().unwrap_or(0);
|
||||
reply[1] = command;
|
||||
// Captured management response: SET 02 B4 00..., GET returns
|
||||
// 02 B4 01 01... (the management interface itself is not a controller slot).
|
||||
if command == 0xB4 {
|
||||
reply[2] = 1;
|
||||
reply[3] = 1;
|
||||
}
|
||||
reply
|
||||
}
|
||||
(0x21, 0x0A) | (0x21, 0x0B) => vec![],
|
||||
_ => vec![],
|
||||
})
|
||||
}
|
||||
|
||||
fn as_any(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble the simulated wired Steam Controller 2 (see the module docs for the capture it
|
||||
/// matches). The handler shares `report` + `feedback` with the owning [`TritonUsbip`].
|
||||
/// matches). The handler shares `reports` + `feedback` with the owning [`TritonUsbip`].
|
||||
fn build_triton_device(
|
||||
index: u8,
|
||||
report: &Arc<Mutex<[u8; 64]>>,
|
||||
reports: &Arc<Mutex<InputReports>>,
|
||||
feedback: &Arc<Mutex<TritonUsbFeedback>>,
|
||||
) -> UsbDevice {
|
||||
let ep = |addr: u8| UsbEndpoint {
|
||||
@@ -197,6 +487,8 @@ fn build_triton_device(
|
||||
dev.set_product_name("Steam Controller");
|
||||
dev.set_serial_number(&triton_serial(index));
|
||||
dev.unset_configuration_name(); // real iConfiguration = 0
|
||||
dev.configuration_attributes = 0xA0; // bus powered + remote wakeup
|
||||
dev.configuration_max_power = 250; // 500 mA in 2 mA units
|
||||
dev.with_interface(
|
||||
0x03, // HID
|
||||
0x00,
|
||||
@@ -204,20 +496,131 @@ fn build_triton_device(
|
||||
None, // real iInterface = 0
|
||||
vec![ep(0x81), ep(0x01)],
|
||||
boxed(TritonHandler {
|
||||
report: report.clone(),
|
||||
feedback: feedback.clone(),
|
||||
reports: reports.clone(),
|
||||
feedback: Some(feedback.clone()),
|
||||
serial: triton_serial(index),
|
||||
unit_id: triton_unit_id(index),
|
||||
puck_status: None,
|
||||
last_set: Vec::new(),
|
||||
last_get_logged: 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Assemble the captured `28DE:1304` Puck topology. A punktfunk wire pad occupies virtual Puck
|
||||
/// slot 0 (interface 2); slots 1–3 remain present but disconnected, matching an unpaired bank.
|
||||
fn build_puck_device(
|
||||
index: u8,
|
||||
reports: &Arc<Mutex<InputReports>>,
|
||||
feedback: &Arc<Mutex<TritonUsbFeedback>>,
|
||||
) -> UsbDevice {
|
||||
let interrupt = |addr: u8, interval: u8| UsbEndpoint {
|
||||
address: addr,
|
||||
attributes: 0x03,
|
||||
max_packet_size: 64,
|
||||
interval,
|
||||
};
|
||||
let bulk = |addr: u8| UsbEndpoint {
|
||||
address: addr,
|
||||
attributes: 0x02,
|
||||
max_packet_size: 64,
|
||||
interval: 0,
|
||||
};
|
||||
|
||||
let mut dev = UsbDevice::new(0);
|
||||
dev.vendor_id = TRITON_VENDOR;
|
||||
dev.product_id = TRITON_PUCK_PRODUCT;
|
||||
dev.usb_version = Version::from(0x0201u16);
|
||||
dev.device_bcd = Version::from(0x0002u16);
|
||||
dev.device_class = 0xEF;
|
||||
dev.device_subclass = 0x02;
|
||||
dev.device_protocol = 0x01;
|
||||
dev.speed = UsbSpeed::Full as u32;
|
||||
dev.configuration_attributes = 0xA0;
|
||||
dev.configuration_max_power = 250;
|
||||
dev.configuration_descriptor_prefix = vec![0x08, 0x0B, 0x00, 0x02, 0x02, 0x02, 0x00, 0x00]; // CDC IAD, interfaces 0–1
|
||||
dev.bos_descriptor = Some(vec![
|
||||
0x05, 0x0F, 0x0C, 0x00, 0x01, // BOS, one capability
|
||||
0x07, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, // USB 2 extension, no LPM
|
||||
]);
|
||||
dev.set_manufacturer_name("Valve Software");
|
||||
dev.set_product_name("Steam Controller Puck");
|
||||
dev.set_serial_number(&format!("FVPFPUCK{index:04}"));
|
||||
dev.unset_configuration_name();
|
||||
|
||||
dev = dev.with_interface(
|
||||
0x02,
|
||||
0x02,
|
||||
0x00,
|
||||
None,
|
||||
vec![UsbEndpoint {
|
||||
address: 0x81,
|
||||
attributes: 0x03,
|
||||
max_packet_size: 16,
|
||||
interval: 10,
|
||||
}],
|
||||
boxed(CdcControlHandler),
|
||||
);
|
||||
dev = dev.with_interface(
|
||||
0x0A,
|
||||
0x00,
|
||||
0x00,
|
||||
None,
|
||||
vec![bulk(0x82), bulk(0x01)],
|
||||
boxed(IdleHandler {
|
||||
class_descriptor: vec![],
|
||||
report_descriptor: &[],
|
||||
input_report: &[],
|
||||
}),
|
||||
);
|
||||
|
||||
for slot in 0u8..4 {
|
||||
let (slot_reports, slot_feedback, puck_status) = if slot == 0 {
|
||||
(reports.clone(), Some(feedback.clone()), 0x02)
|
||||
} else {
|
||||
// An unpaired physical slot leaves its interrupt-IN URB pending. The simulator
|
||||
// cannot defer one URB without stalling the shared USB/IP command stream, so
|
||||
// complete it empty instead. Replaying 0x79/0x01 here would announce a disconnect
|
||||
// every 2 ms and keep Steam re-probing every slot.
|
||||
(
|
||||
Arc::new(Mutex::new(InputReports::new(InputReport::default()))),
|
||||
None,
|
||||
0x01,
|
||||
)
|
||||
};
|
||||
let handler = boxed(TritonHandler {
|
||||
reports: slot_reports,
|
||||
feedback: slot_feedback,
|
||||
serial: triton_serial(index),
|
||||
unit_id: triton_unit_id(index),
|
||||
puck_status: Some(puck_status),
|
||||
last_set: Vec::new(),
|
||||
last_get_logged: 0,
|
||||
});
|
||||
dev = dev.with_interface(
|
||||
0x03,
|
||||
0x00,
|
||||
0x00,
|
||||
None,
|
||||
vec![interrupt(0x83 + slot, 2), interrupt(0x02 + slot, 2)],
|
||||
handler,
|
||||
);
|
||||
}
|
||||
|
||||
dev.with_interface(
|
||||
0x03,
|
||||
0x00,
|
||||
0x00,
|
||||
None,
|
||||
vec![interrupt(0x87, 32), interrupt(0x06, 32)],
|
||||
boxed(PuckManagementHandler::default()),
|
||||
)
|
||||
}
|
||||
|
||||
/// A virtual Steam Controller 2 presented over USB/IP. Dropping it detaches the `vhci_hcd` port
|
||||
/// (the device disappears, Steam releases it) and stops the emulation server.
|
||||
pub struct TritonUsbip {
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
reports: Arc<Mutex<InputReports>>,
|
||||
feedback: Arc<Mutex<TritonUsbFeedback>>,
|
||||
_attach: UsbipAttachment,
|
||||
seq: u8,
|
||||
@@ -227,67 +630,118 @@ impl TritonUsbip {
|
||||
/// Bind a virtual wired SC2 and attach it locally via `vhci_hcd` (root + `vhci_hcd` loaded;
|
||||
/// see [`super::steam_usbip::attach_device`]). `index` varies only the serial.
|
||||
pub fn open(index: u8) -> Result<TritonUsbip> {
|
||||
let report = Arc::new(Mutex::new(neutral_report()));
|
||||
let reports = Arc::new(Mutex::new(InputReports::new(neutral_report())));
|
||||
let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
|
||||
let attach = attach_device(
|
||||
|| build_triton_device(index, &report, &feedback),
|
||||
|| build_triton_device(index, &reports, &feedback),
|
||||
&format!("virtual Steam Controller 2 {index}"),
|
||||
)?;
|
||||
Ok(TritonUsbip {
|
||||
report,
|
||||
reports,
|
||||
feedback,
|
||||
_attach: attach,
|
||||
seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirror one report onto the interrupt-IN stream: the client's raw bytes verbatim in as-is
|
||||
/// mode (zero-padded to the 64-byte packet), else a synthesized minimal `0x42` state report.
|
||||
/// Bind the captured seven-interface Puck identity. The forwarded controller occupies Puck
|
||||
/// slot 0; the other three HID interfaces remain visible as disconnected slots.
|
||||
pub fn open_puck(index: u8) -> Result<TritonUsbip> {
|
||||
let reports = Arc::new(Mutex::new(InputReports::with_pending(
|
||||
neutral_report(),
|
||||
puck_connect_report(),
|
||||
)));
|
||||
let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
|
||||
let attach = attach_device(
|
||||
|| build_puck_device(index, &reports, &feedback),
|
||||
&format!("virtual Steam Controller 2 Puck {index}"),
|
||||
)?;
|
||||
Ok(TritonUsbip {
|
||||
reports,
|
||||
feedback,
|
||||
_attach: attach,
|
||||
seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirror one report onto the interrupt-IN stream: continuous state replaces the prior sample;
|
||||
/// sparse physical reports retain their native length and queue until Steam consumes them.
|
||||
pub fn write_state(&mut self, st: &TritonState) {
|
||||
let mut r = [0u8; 64];
|
||||
let mut report = InputReport::default();
|
||||
if st.raw_len > 0 {
|
||||
let len = (st.raw_len as usize).min(st.raw.len()).min(r.len());
|
||||
r[..len].copy_from_slice(&st.raw[..len]);
|
||||
let len = (st.raw_len as usize)
|
||||
.min(st.raw.len())
|
||||
.min(report.data.len());
|
||||
report.data[..len].copy_from_slice(&st.raw[..len]);
|
||||
report.len = len as u8;
|
||||
} else {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut s = [0u8; TRITON_STATE_LEN];
|
||||
serialize_triton_state(&mut s, st, self.seq);
|
||||
r[..TRITON_STATE_LEN].copy_from_slice(&s);
|
||||
}
|
||||
if let Ok(mut g) = self.report.lock() {
|
||||
*g = r;
|
||||
report.data[..TRITON_STATE_LEN].copy_from_slice(&s);
|
||||
report.len = TRITON_STATE_LEN as u8;
|
||||
}
|
||||
self.reports.lock().write(report);
|
||||
}
|
||||
|
||||
/// Drain everything Steam wrote to the device since the last pass.
|
||||
pub fn service(&mut self) -> TritonUsbFeedback {
|
||||
self.feedback
|
||||
.lock()
|
||||
.map(|mut f| std::mem::take(&mut *f))
|
||||
.unwrap_or_default()
|
||||
std::mem::take(&mut *self.feedback.lock())
|
||||
}
|
||||
}
|
||||
|
||||
/// An idle `0x42` state report — what the interrupt-IN endpoint streams before the first write.
|
||||
fn neutral_report() -> [u8; 64] {
|
||||
let mut r = [0u8; 64];
|
||||
/// An idle `0x42` state report — what the wired endpoint streams before the first write.
|
||||
fn neutral_report() -> InputReport {
|
||||
let mut report = InputReport {
|
||||
len: TRITON_STATE_LEN as u8,
|
||||
..InputReport::default()
|
||||
};
|
||||
let mut s = [0u8; TRITON_STATE_LEN];
|
||||
serialize_triton_state(&mut s, &TritonState::neutral(), 0);
|
||||
r[..TRITON_STATE_LEN].copy_from_slice(&s);
|
||||
r
|
||||
report.data[..TRITON_STATE_LEN].copy_from_slice(&s);
|
||||
report
|
||||
}
|
||||
|
||||
/// The Puck reports its wireless connect edge before the first controller state packet.
|
||||
fn puck_connect_report() -> InputReport {
|
||||
let mut report = InputReport {
|
||||
len: 2,
|
||||
..InputReport::default()
|
||||
};
|
||||
report.data[..2].copy_from_slice(&[0x79, 0x02]);
|
||||
report
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sparse_input_report_survives_following_state() {
|
||||
let mut reports = InputReports::new(neutral_report());
|
||||
let mut signal = InputReport {
|
||||
len: 13,
|
||||
..InputReport::default()
|
||||
};
|
||||
signal.data[..3].copy_from_slice(&[0x7B, 0xF8, 0x01]);
|
||||
let mut next_state = neutral_report();
|
||||
next_state.data[1] = 9;
|
||||
|
||||
reports.write(signal);
|
||||
reports.write(next_state);
|
||||
|
||||
assert_eq!(reports.read().data[..3], [0x7B, 0xF8, 0x01]);
|
||||
assert_eq!(reports.read().data[1], 9);
|
||||
assert_eq!(reports.read().data[1], 9); // continuous state replays
|
||||
}
|
||||
|
||||
/// The simulated device matches the captured wired identity byte-for-byte where Steam looks:
|
||||
/// VID/PID, device class triplet, bcdDevice, ONE HID interface with the IN+OUT endpoint pair.
|
||||
#[test]
|
||||
fn device_matches_wired_capture() {
|
||||
let report = Arc::new(Mutex::new([0u8; 64]));
|
||||
let reports = Arc::new(Mutex::new(InputReports::new(InputReport::default())));
|
||||
let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
|
||||
let dev = build_triton_device(3, &report, &feedback);
|
||||
let dev = build_triton_device(3, &reports, &feedback);
|
||||
assert_eq!((dev.vendor_id, dev.product_id), (0x28DE, 0x1302));
|
||||
assert_eq!(
|
||||
(dev.device_class, dev.device_subclass, dev.device_protocol),
|
||||
@@ -320,18 +774,176 @@ mod tests {
|
||||
assert!(triton_serial(3).starts_with("FVPF")); // the conflict-gate exclusion prefix
|
||||
}
|
||||
|
||||
/// The Puck capture's complete configuration is 235 bytes: CDC IAD + CDC pair + four
|
||||
/// controller HID slots + management HID. Endpoint numbers and intervals are slot-significant.
|
||||
#[test]
|
||||
fn device_matches_puck_capture() {
|
||||
let reports = Arc::new(Mutex::new(InputReports::with_pending(
|
||||
neutral_report(),
|
||||
puck_connect_report(),
|
||||
)));
|
||||
let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
|
||||
let dev = build_puck_device(1, &reports, &feedback);
|
||||
assert_eq!((dev.vendor_id, dev.product_id), (0x28DE, 0x1304));
|
||||
assert_eq!(
|
||||
(
|
||||
dev.usb_version.major,
|
||||
dev.usb_version.minor,
|
||||
dev.usb_version.patch,
|
||||
),
|
||||
(0x02, 0x00, 0x01)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
dev.device_bcd.major,
|
||||
dev.device_bcd.minor,
|
||||
dev.device_bcd.patch,
|
||||
),
|
||||
(0x00, 0x00, 0x02)
|
||||
);
|
||||
assert_eq!(
|
||||
(dev.device_class, dev.device_subclass, dev.device_protocol),
|
||||
(0xEF, 0x02, 0x01)
|
||||
);
|
||||
assert_eq!(dev.speed, UsbSpeed::Full as u32);
|
||||
assert_eq!(
|
||||
(dev.configuration_attributes, dev.configuration_max_power),
|
||||
(0xA0, 250)
|
||||
);
|
||||
assert_eq!(
|
||||
dev.configuration_descriptor_prefix,
|
||||
[0x08, 0x0B, 0x00, 0x02, 0x02, 0x02, 0x00, 0x00]
|
||||
);
|
||||
assert_eq!(dev.bos_descriptor.as_ref().map(Vec::len), Some(12));
|
||||
assert_eq!(dev.interfaces.len(), 7);
|
||||
let classes: Vec<(u8, u8, u8)> = dev
|
||||
.interfaces
|
||||
.iter()
|
||||
.map(|i| {
|
||||
(
|
||||
i.interface_class,
|
||||
i.interface_subclass,
|
||||
i.interface_protocol,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
classes,
|
||||
[
|
||||
(0x02, 0x02, 0x00),
|
||||
(0x0A, 0x00, 0x00),
|
||||
(0x03, 0x00, 0x00),
|
||||
(0x03, 0x00, 0x00),
|
||||
(0x03, 0x00, 0x00),
|
||||
(0x03, 0x00, 0x00),
|
||||
(0x03, 0x00, 0x00),
|
||||
]
|
||||
);
|
||||
let endpoints: Vec<Vec<(u8, u8, u16, u8)>> = dev
|
||||
.interfaces
|
||||
.iter()
|
||||
.map(|i| {
|
||||
i.endpoints
|
||||
.iter()
|
||||
.map(|e| (e.address, e.attributes, e.max_packet_size, e.interval))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(endpoints[0], [(0x81, 3, 16, 10)]);
|
||||
assert_eq!(endpoints[1], [(0x82, 2, 64, 0), (0x01, 2, 64, 0)]);
|
||||
for slot in 0u8..4 {
|
||||
assert_eq!(
|
||||
endpoints[slot as usize + 2],
|
||||
[(0x83 + slot, 3, 64, 2), (0x02 + slot, 3, 64, 2)]
|
||||
);
|
||||
assert_eq!(
|
||||
dev.interfaces[slot as usize + 2]
|
||||
.class_specific_descriptor
|
||||
.len(),
|
||||
9
|
||||
);
|
||||
}
|
||||
assert_eq!(endpoints[6], [(0x87, 3, 64, 32), (0x06, 3, 64, 32)]);
|
||||
assert_eq!(dev.interfaces[6].class_specific_descriptor[7], 54);
|
||||
let config_len = 9
|
||||
+ dev.configuration_descriptor_prefix.len()
|
||||
+ dev
|
||||
.interfaces
|
||||
.iter()
|
||||
.map(|i| 9 + i.class_specific_descriptor.len() + 7 * i.endpoints.len())
|
||||
.sum::<usize>();
|
||||
assert_eq!(config_len, 0x00EB);
|
||||
let ep0 = UsbEndpoint {
|
||||
address: 0,
|
||||
attributes: 0,
|
||||
max_packet_size: 64,
|
||||
interval: 0,
|
||||
};
|
||||
let slot_status = |interface: usize| {
|
||||
let iface = dev.interfaces[interface].clone();
|
||||
let mut handler = iface.handler.lock().unwrap();
|
||||
handler
|
||||
.handle_urb(
|
||||
&iface,
|
||||
ep0,
|
||||
0,
|
||||
SetupPacket {
|
||||
request_type: 0x21,
|
||||
request: 0x09,
|
||||
value: 0x0302,
|
||||
index: interface as u16,
|
||||
length: 3,
|
||||
},
|
||||
&[0x02, 0xB4, 0x00],
|
||||
)
|
||||
.unwrap();
|
||||
handler
|
||||
.handle_urb(
|
||||
&iface,
|
||||
ep0,
|
||||
64,
|
||||
SetupPacket {
|
||||
request_type: 0xA1,
|
||||
request: 0x01,
|
||||
value: 0x0302,
|
||||
index: interface as u16,
|
||||
length: 64,
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.unwrap()[..4]
|
||||
.to_vec()
|
||||
};
|
||||
assert_eq!(slot_status(2), [0x02, 0xB4, 0x01, 0x02]);
|
||||
for interface in 3..=5 {
|
||||
assert_eq!(slot_status(interface), [0x02, 0xB4, 0x01, 0x01]);
|
||||
}
|
||||
// A disconnected slot is quiescent. In particular, it must not replay the
|
||||
// one-shot 0x79/0x01 disconnect edge on every 2 ms interrupt poll.
|
||||
let iface = dev.interfaces[3].clone();
|
||||
let interrupt_in = iface.endpoints[0];
|
||||
assert!(iface
|
||||
.handler
|
||||
.lock()
|
||||
.unwrap()
|
||||
.handle_urb(&iface, interrupt_in, 64, SetupPacket::default(), &[],)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// Steam's interrupt-OUT rumble lands in the feedback (parsed + queued raw); EP0 feature
|
||||
/// writes are normalized to id-first framing whichever way the stack framed them.
|
||||
#[test]
|
||||
fn out_and_feature_writes_are_captured() {
|
||||
use punktfunk_core::quic::{HID_RAW_FEATURE, HID_RAW_OUTPUT};
|
||||
let report = Arc::new(Mutex::new([0u8; 64]));
|
||||
let reports = Arc::new(Mutex::new(InputReports::new(InputReport::default())));
|
||||
let feedback = Arc::new(Mutex::new(TritonUsbFeedback::default()));
|
||||
let mut h = TritonHandler {
|
||||
report,
|
||||
feedback: feedback.clone(),
|
||||
reports,
|
||||
feedback: Some(feedback.clone()),
|
||||
serial: triton_serial(0),
|
||||
unit_id: triton_unit_id(0),
|
||||
puck_status: None,
|
||||
last_set: Vec::new(),
|
||||
last_get_logged: 0,
|
||||
};
|
||||
@@ -363,6 +975,16 @@ mod tests {
|
||||
rumble[7..9].copy_from_slice(&0x4000u16.to_le_bytes());
|
||||
h.handle_urb(&iface_dummy, ep_out, 10, SetupPacket::default(), &rumble)
|
||||
.unwrap();
|
||||
// hidraw may issue an OUTPUT report through EP0 instead of the interrupt endpoint.
|
||||
let setup = SetupPacket {
|
||||
request_type: 0x21,
|
||||
request: 0x09,
|
||||
value: 0x0282,
|
||||
index: 0,
|
||||
length: 3,
|
||||
};
|
||||
h.handle_urb(&iface_dummy, ep0, 3, setup, &[0x01, 0x01, 0xF7])
|
||||
.unwrap();
|
||||
// Feature SET_REPORT with the id NOT in the payload (it rides wValue) → normalized.
|
||||
let setup = SetupPacket {
|
||||
request_type: 0x21,
|
||||
@@ -373,13 +995,14 @@ mod tests {
|
||||
};
|
||||
h.handle_urb(&iface_dummy, ep0, 5, setup, &[0x87, 3, 9, 0, 0])
|
||||
.unwrap();
|
||||
let fb = feedback.lock().unwrap();
|
||||
let fb = feedback.lock();
|
||||
assert_eq!(fb.rumble, Some((0x2000, 0x4000)));
|
||||
assert_eq!(fb.raw.len(), 2);
|
||||
assert_eq!(fb.raw.len(), 3);
|
||||
assert_eq!(fb.raw[0].0, HID_RAW_OUTPUT);
|
||||
assert_eq!(fb.raw[0].1[0], 0x80);
|
||||
assert_eq!(fb.raw[1].0, HID_RAW_FEATURE);
|
||||
assert_eq!(&fb.raw[1].1[..3], &[0x01, 0x87, 3]); // id-first for the client replay
|
||||
assert_eq!(fb.raw[1], (HID_RAW_OUTPUT, vec![0x82, 0x01, 0x01, 0xF7]));
|
||||
assert_eq!(fb.raw[2].0, HID_RAW_FEATURE);
|
||||
assert_eq!(&fb.raw[2].1[..3], &[0x01, 0x87, 3]); // id-first for the client replay
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -40,35 +40,48 @@ pub const ID_TRITON_CONTROLLER_STATE_TIMESTAMP: u8 = 0x47;
|
||||
/// universal 0xCA plane); every output report is forwarded raw regardless.
|
||||
pub const ID_OUT_REPORT_HAPTIC_RUMBLE: u8 = 0x80;
|
||||
|
||||
/// Fixed report size: 64-byte feature reports, input reports at most 64 (state is 46/54).
|
||||
/// Physical `0x42` state report size: one report-id byte plus 53 payload bytes.
|
||||
pub const TRITON_REPORT_LEN: usize = 64;
|
||||
pub const TRITON_STATE_LEN: usize = 54;
|
||||
|
||||
/// The `TritonMTUNoQuat_t` state payload (46 bytes with the leading report id).
|
||||
pub const TRITON_STATE_LEN: usize = 46;
|
||||
|
||||
/// Minimal vendor-defined HID report descriptor, mirroring [`super::steam_proto::STEAMDECK_RDESC`]
|
||||
/// with an added OUTPUT item: the Triton receives haptics as output reports (`SDL_hid_write`),
|
||||
/// not feature-only like the Deck, so hidapi consumers expect a writable interrupt-OUT-style
|
||||
/// report to exist. All items unnumbered 64-byte — the raw bytes we mirror carry the Valve
|
||||
/// report-type byte first, exactly like the physical device's stream.
|
||||
/// The physical Triton HID report descriptor, captured byte-for-byte from both wired `28DE:1302`
|
||||
/// and Puck `28DE:1304` controller interfaces. Its numbered reports are part of the protocol:
|
||||
/// inputs `0x40`–`0x45`/`0x79`/`0x7B`, outputs `0x80`–`0x89`, and feature channels `1` and `2`.
|
||||
/// In particular, Puck connection and bond queries use feature report 2; an unnumbered minimal
|
||||
/// descriptor makes hidraw frame those queries incorrectly and Steam eventually closes the device.
|
||||
#[rustfmt::skip]
|
||||
pub const TRITON_RDESC: &[u8] = &[
|
||||
0x06, 0x00, 0xFF, // Usage Page (Vendor-Defined 0xFF00)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0xA1, 0x01, // Collection (Application)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x26, 0xFF, 0x00, // Logical Maximum (255)
|
||||
0x75, 0x08, // Report Size (8 bits)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x81, 0x02, // Input (Data,Var,Abs) — the state/battery/wireless report stream
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0x91, 0x02, // Output (Data,Var,Abs) — haptic commands (0x80 rumble, 0x81 pulse, …)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0xB1, 0x02, // Feature (Data,Var,Abs) — settings/attributes (report id 1 on the wire)
|
||||
0xC0, // End Collection
|
||||
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
|
||||
0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
|
||||
0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
|
||||
0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
|
||||
0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
|
||||
0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
|
||||
0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
|
||||
0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
|
||||
0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
|
||||
0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
|
||||
0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
|
||||
0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
|
||||
0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
|
||||
0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
|
||||
0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
|
||||
0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
|
||||
0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
|
||||
0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
|
||||
0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
/// Triton button bits in the state report's `buttons` u32 — transcribed verbatim from SDL's
|
||||
@@ -277,9 +290,9 @@ pub fn triton_serial(index: u8) -> String {
|
||||
pub fn triton_feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; 64] {
|
||||
const ID_GET_ATTRIBUTES_VALUES: u8 = 0x83;
|
||||
const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
|
||||
const ID_GET_FIRMWARE_INFO: u8 = 0xF2;
|
||||
const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
|
||||
|
||||
// Normalize to the command + its payload, tolerating a missing report-id byte.
|
||||
let body = match last_set {
|
||||
[0x01, rest @ ..] => rest,
|
||||
d => d,
|
||||
@@ -287,22 +300,18 @@ pub fn triton_feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8;
|
||||
let cmd = body.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE);
|
||||
|
||||
let mut r = [0u8; 64];
|
||||
r[0] = 0x01; // feature report id
|
||||
r[0] = 0x01;
|
||||
match cmd {
|
||||
ID_GET_ATTRIBUTES_VALUES => {
|
||||
// [0x01, 0x83, 0x2d, then 9× (attr-id, value u32-LE)].
|
||||
// Captured controller response: 25-byte payload containing five id/u32 attributes.
|
||||
r[1] = ID_GET_ATTRIBUTES_VALUES;
|
||||
r[2] = 0x2d;
|
||||
let attrs: [(u8, u32); 9] = [
|
||||
(0x01, TRITON_WIRED_PRODUCT), // product id
|
||||
r[2] = 0x19;
|
||||
let attrs = [
|
||||
(0x01, TRITON_WIRED_PRODUCT),
|
||||
(0x02, 0),
|
||||
(0x0a, unit_id), // per-instance unit identity
|
||||
(0x04, unit_id ^ 0x5555_5555),
|
||||
(0x09, 0x2e),
|
||||
(0x0b, 0x0fa0), // connection interval 4000 µs — the pad's ~4 ms cadence
|
||||
(0x0d, 0),
|
||||
(0x0c, 0),
|
||||
(0x0e, 0),
|
||||
(0x0A, unit_id),
|
||||
(0x04, unit_id ^ 0x0296_DAF9),
|
||||
(0x09, 0x49),
|
||||
];
|
||||
let mut o = 3;
|
||||
for (id, val) in attrs {
|
||||
@@ -312,17 +321,42 @@ pub fn triton_feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8;
|
||||
}
|
||||
}
|
||||
ID_GET_STRING_ATTRIBUTE => {
|
||||
// [0x01, 0xAE, len, attr, ascii…]; the serial is string-attr 0x01.
|
||||
// Captured replies always declare 20 bytes: attribute id plus a 19-byte padded string.
|
||||
let attr = body.get(2).copied().unwrap_or(ATTRIB_STR_UNIT_SERIAL);
|
||||
let b = serial.as_bytes();
|
||||
let len = b.len().clamp(1, 20);
|
||||
r[1] = ID_GET_STRING_ATTRIBUTE;
|
||||
r[2] = len as u8;
|
||||
r[3] = attr;
|
||||
let len = b.len().min(19);
|
||||
r[..4].copy_from_slice(&[0x01, ID_GET_STRING_ATTRIBUTE, 0x14, attr]);
|
||||
r[4..4 + len].copy_from_slice(&b[..len]);
|
||||
}
|
||||
ID_GET_FIRMWARE_INFO => {
|
||||
let index = body.get(2).copied().unwrap_or(0);
|
||||
r[1] = ID_GET_FIRMWARE_INFO;
|
||||
r[3] = index;
|
||||
match index {
|
||||
0 => {
|
||||
r[2] = 0x29;
|
||||
r[4..8].copy_from_slice(&(unit_id ^ 0x0296_DAF9).to_le_bytes());
|
||||
r[8] = 0x49;
|
||||
r[12..24].copy_from_slice(b"603f69218a85");
|
||||
let b = serial.as_bytes();
|
||||
let len = b.len().min(16);
|
||||
r[28..28 + len].copy_from_slice(&b[..len]);
|
||||
}
|
||||
1 => {
|
||||
r[2] = 0x22;
|
||||
r[4..37].copy_from_slice(&[
|
||||
0x00, 0x57, 0xD0, 0x18, 0x6A, 0x37, 0x30, 0x35, 0x34, 0x32, 0x35, 0x37,
|
||||
0x64, 0x32, 0x64, 0x61, 0x37, 0x00, 0x00, 0x00, 0x00, 0x23, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x6D, 0x02, 0x00,
|
||||
]);
|
||||
}
|
||||
_ => {
|
||||
r[2] = 0x09;
|
||||
r[4..12].copy_from_slice(&[0x7C, 0x4F, 0x01, 0x00, 0x01, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Settings read-back (e.g. 0x87): echo the host's last command + data, id-first.
|
||||
let n = body.len().min(63);
|
||||
r[1..1 + n].copy_from_slice(&body[..n]);
|
||||
}
|
||||
@@ -392,22 +426,22 @@ mod tests {
|
||||
fn feature_reply_echoes_the_queried_command() {
|
||||
let serial = triton_serial(0);
|
||||
let uid = triton_unit_id(0);
|
||||
// 0x83 attributes: id-first frame, 9 blocks, product id = 0x1302 in the first block.
|
||||
// 0x83 attributes: id-first frame, 5 captured blocks, product id = 0x1302 in the first.
|
||||
let r = triton_feature_reply(&[0x01, 0x83, 0x00], &serial, uid);
|
||||
assert_eq!(&r[..3], &[0x01, 0x83, 0x2d]);
|
||||
assert_eq!(&r[..3], &[0x01, 0x83, 0x19]);
|
||||
assert_eq!(r[3], 0x01); // ATTRIB product-id tag
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([r[4], r[5], r[6], r[7]]),
|
||||
TRITON_WIRED_PRODUCT
|
||||
);
|
||||
// 0xAE serial: echoes the requested string attribute + the FVPF serial.
|
||||
// 0xAE serial: the captured fixed 20-byte payload — attribute id + padded string.
|
||||
let r = triton_feature_reply(&[0x01, 0xAE, 0x01, 0x01], &serial, uid);
|
||||
assert_eq!(&r[..3], &[0x01, 0xAE, serial.len() as u8]);
|
||||
assert_eq!(&r[..3], &[0x01, 0xAE, 0x14]);
|
||||
assert_eq!(r[3], 0x01);
|
||||
assert_eq!(&r[4..4 + serial.len()], serial.as_bytes());
|
||||
// A stack that stripped the id byte still resolves the command.
|
||||
let r = triton_feature_reply(&[0x83u8, 0x00], &serial, uid);
|
||||
assert_eq!(&r[..3], &[0x01, 0x83, 0x2d]);
|
||||
assert_eq!(&r[..3], &[0x01, 0x83, 0x19]);
|
||||
// Anything else (settings write) reads back as an echo.
|
||||
let r = triton_feature_reply(&[0x01, 0x87, 3, 9, 0, 0], &serial, uid);
|
||||
assert_eq!(&r[..6], &[0x01, 0x87, 3, 9, 0, 0]);
|
||||
|
||||
@@ -1886,6 +1886,8 @@ struct Pads {
|
||||
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2: Option<crate::inject::steam_controller2::Triton2Manager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2_puck: Option<crate::inject::steam_controller2::Triton2Manager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1925,6 +1927,8 @@ impl Pads {
|
||||
steamctrl: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2_puck: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -2013,6 +2017,15 @@ impl Pads {
|
||||
.get_or_insert_with(crate::inject::steam_controller2::Triton2Manager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2Puck => self
|
||||
.steamctrl2_puck
|
||||
.get_or_insert_with(|| {
|
||||
crate::inject::steam_controller2::Triton2Manager::with_backend(
|
||||
crate::inject::steam_controller2::TritonProto::puck(),
|
||||
)
|
||||
})
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::XboxOne => self
|
||||
.xboxone
|
||||
.get_or_insert_with(|| {
|
||||
@@ -2116,6 +2129,12 @@ impl Pads {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2Puck => {
|
||||
if let Some(m) = &mut self.steamctrl2_puck {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
@@ -2144,6 +2163,17 @@ impl Pads {
|
||||
}
|
||||
}
|
||||
|
||||
/// Triton's USB output endpoint is polled at 1 kHz. Service its raw haptic writes on the same
|
||||
/// cadence so PC-generated trackpad pulses do not sit for up to 4 ms and then arrive at the
|
||||
/// client in bursts. Other backends keep the lower-frequency poll to avoid idle churn.
|
||||
fn feedback_poll_interval(&self) -> std::time::Duration {
|
||||
#[cfg(target_os = "linux")]
|
||||
if self.steamctrl2.is_some() || self.steamctrl2_puck.is_some() {
|
||||
return std::time::Duration::from_millis(1);
|
||||
}
|
||||
std::time::Duration::from_millis(4)
|
||||
}
|
||||
|
||||
/// Service feedback for every instantiated backend each cycle. `rumble` carries motor
|
||||
/// force-feedback on the universal plane (every backend, tagged with its own pad index);
|
||||
/// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF
|
||||
@@ -2182,6 +2212,9 @@ impl Pads {
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2_puck {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -2410,10 +2443,9 @@ fn input_thread(
|
||||
let mut held_buttons: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
let mut held_keys: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
loop {
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
|
||||
// Rich input (touchpad / motion) — applied the moment it arrives; the single
|
||||
// channel means a gyro sample never waits out the 4 ms timeout behind an idle
|
||||
// button plane.
|
||||
match rx.recv_timeout(pads.feedback_poll_interval()) {
|
||||
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
|
||||
// wakes for gyro samples instead of making them wait out the feedback poll interval.
|
||||
Ok(ClientInput::Rich(rich)) => {
|
||||
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) {
|
||||
let now = std::time::Instant::now();
|
||||
@@ -2546,9 +2578,9 @@ fn input_thread(
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
// Service feedback every iteration (≤4 ms latency; games block on EVIOCSFF, and the
|
||||
// DualSense kernel handshake must be answered promptly). Rumble → the universal 0xCA
|
||||
// plane; DualSense rich feedback (lightbar / player LEDs / adaptive triggers) → 0xCD.
|
||||
// Service feedback every iteration (≤1 ms for Triton, ≤4 ms otherwise; games block on
|
||||
// EVIOCSFF, and HID handshakes must be answered promptly). Rumble → the universal 0xCA
|
||||
// plane; rich/raw HID feedback → 0xCD.
|
||||
pads.pump(
|
||||
|pad, low, high| {
|
||||
let idx = pad as usize;
|
||||
@@ -2945,6 +2977,7 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
||||
// the host drives it over hidraw (no kernel driver binds the PID; Steam Input is the
|
||||
// consumer). No Windows backend; folds to Xbox360 there.
|
||||
GamepadPref::SteamController2 if linux => GamepadPref::SteamController2,
|
||||
GamepadPref::SteamController2Puck if linux => GamepadPref::SteamController2Puck,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
@@ -2963,6 +2996,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
| GamepadPref::SteamDeck
|
||||
| GamepadPref::SteamController
|
||||
| GamepadPref::SteamController2
|
||||
| GamepadPref::SteamController2Puck
|
||||
| GamepadPref::SwitchPro
|
||||
);
|
||||
if needs_uhid
|
||||
@@ -3028,7 +3062,10 @@ fn physical_steam_controller_present() -> bool {
|
||||
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
|
||||
if !matches!(
|
||||
chosen,
|
||||
GamepadPref::SteamDeck | GamepadPref::SteamController | GamepadPref::SteamController2
|
||||
GamepadPref::SteamDeck
|
||||
| GamepadPref::SteamController
|
||||
| GamepadPref::SteamController2
|
||||
| GamepadPref::SteamController2Puck
|
||||
) {
|
||||
return chosen;
|
||||
}
|
||||
@@ -5705,6 +5742,18 @@ mod tests {
|
||||
);
|
||||
assert_eq!(pick_gamepad(SteamController2, None, false, true), Xbox360);
|
||||
assert_eq!(pick_gamepad(SteamController2, None, false, false), Xbox360);
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController2Puck, None, true, false),
|
||||
SteamController2Puck
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("sc2puck"), true, false),
|
||||
SteamController2Puck
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController2Puck, None, false, true),
|
||||
Xbox360
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+37
-16
@@ -21,6 +21,10 @@ impl From<u16> for Version {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra descriptors emitted between the configuration descriptor and interface 0 (for example,
|
||||
/// an Interface Association Descriptor for a CDC function).
|
||||
pub type ConfigurationDescriptorPrefix = Vec<u8>;
|
||||
|
||||
/// Represent a USB device
|
||||
#[derive(Clone, Default, Debug)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize))]
|
||||
@@ -37,6 +41,11 @@ pub struct UsbDevice {
|
||||
pub device_subclass: u8,
|
||||
pub device_protocol: u8,
|
||||
pub configuration_value: u8,
|
||||
pub configuration_attributes: u8,
|
||||
pub configuration_max_power: u8,
|
||||
pub configuration_descriptor_prefix: ConfigurationDescriptorPrefix,
|
||||
/// Optional complete BOS descriptor. `None` uses the simulator's minimal empty BOS.
|
||||
pub bos_descriptor: Option<Vec<u8>>,
|
||||
pub num_configurations: u8,
|
||||
pub interfaces: Vec<UsbInterface>,
|
||||
|
||||
@@ -74,8 +83,10 @@ impl UsbDevice {
|
||||
max_packet_size: EP0_MAX_PACKET_SIZE,
|
||||
interval: 0,
|
||||
},
|
||||
// configured by default
|
||||
// configured, bus-powered at 100 mA by default
|
||||
configuration_value: 1,
|
||||
configuration_attributes: 0x80,
|
||||
configuration_max_power: 0x32,
|
||||
num_configurations: 1,
|
||||
..Self::default()
|
||||
};
|
||||
@@ -290,8 +301,8 @@ impl UsbDevice {
|
||||
let mut desc = vec![
|
||||
0x12, // bLength
|
||||
Device as u8, // bDescriptorType: Device
|
||||
self.usb_version.minor,
|
||||
self.usb_version.major, // bcdUSB: USB 2.0
|
||||
(self.usb_version.minor << 4) | self.usb_version.patch,
|
||||
self.usb_version.major, // bcdUSB
|
||||
self.device_class, // bDeviceClass
|
||||
self.device_subclass, // bDeviceSubClass
|
||||
self.device_protocol, // bDeviceProtocol
|
||||
@@ -300,8 +311,8 @@ impl UsbDevice {
|
||||
(self.vendor_id >> 8) as u8,
|
||||
self.product_id as u8, // idProduct
|
||||
(self.product_id >> 8) as u8,
|
||||
self.device_bcd.minor, // bcdDevice
|
||||
self.device_bcd.major,
|
||||
(self.device_bcd.minor << 4) | self.device_bcd.patch,
|
||||
self.device_bcd.major, // bcdDevice
|
||||
self.string_manufacturer, // iManufacturer
|
||||
self.string_product, // iProduct
|
||||
self.string_serial, // iSerial
|
||||
@@ -316,12 +327,14 @@ impl UsbDevice {
|
||||
}
|
||||
Some(BOS) => {
|
||||
debug!("Get BOS descriptor");
|
||||
let mut desc = vec![
|
||||
0x05, // bLength
|
||||
BOS as u8, // bDescriptorType: BOS
|
||||
0x05, 0x00, // wTotalLength
|
||||
0x00, // bNumCapabilities
|
||||
];
|
||||
let mut desc = self.bos_descriptor.clone().unwrap_or_else(|| {
|
||||
vec![
|
||||
0x05, // bLength
|
||||
BOS as u8, // bDescriptorType: BOS
|
||||
0x05, 0x00, // wTotalLength
|
||||
0x00, // bNumCapabilities
|
||||
]
|
||||
});
|
||||
|
||||
// requested len too short: wLength < real length
|
||||
if setup_packet.length < desc.len() as u16 {
|
||||
@@ -340,9 +353,10 @@ impl UsbDevice {
|
||||
self.interfaces.len() as u8, // bNumInterfaces
|
||||
self.configuration_value, // bConfigurationValue
|
||||
self.string_configuration, // iConfiguration
|
||||
0x80, // bmAttributes: Bus Powered
|
||||
0x32, // bMaxPower: 100mA
|
||||
self.configuration_attributes, // bmAttributes
|
||||
self.configuration_max_power, // bMaxPower (2 mA units)
|
||||
];
|
||||
desc.extend_from_slice(&self.configuration_descriptor_prefix);
|
||||
for (i, intf) in self.interfaces.iter().enumerate() {
|
||||
let mut intf_desc = vec![
|
||||
0x09, // bLength
|
||||
@@ -515,9 +529,16 @@ impl UsbDevice {
|
||||
// server side, so an unpaced sim would spin the loopback link). HS bInterval N →
|
||||
// 2^(N-1) microframes × 125µs.
|
||||
if let In = ep.direction() {
|
||||
let n = ep.interval.clamp(1, 16) as u32;
|
||||
let period_us = (1u32 << (n - 1)) * 125;
|
||||
tokio::time::sleep(std::time::Duration::from_micros(period_us as u64)).await;
|
||||
let period = if self.speed == UsbSpeed::High as u32
|
||||
|| self.speed == UsbSpeed::Super as u32
|
||||
|| self.speed == UsbSpeed::SuperPlus as u32
|
||||
{
|
||||
let n = ep.interval.clamp(1, 16) as u32;
|
||||
std::time::Duration::from_micros((1u64 << (n - 1)) * 125)
|
||||
} else {
|
||||
std::time::Duration::from_millis(ep.interval.max(1) as u64)
|
||||
};
|
||||
tokio::time::sleep(period).await;
|
||||
}
|
||||
let intf = intf.unwrap();
|
||||
let mut handler = intf.handler.lock().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user