Files
punktfunk/crates/punktfunk-host/src/inject/proto/triton_proto.rs
T
enricobuehler 01266ff18d 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>
2026-07-15 20:47:52 +02:00

450 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Transport-independent contract for the virtual **Steam Controller 2** (2026, Valve "Ibex" /
//! SDL "Triton", wired `28DE:1302`) — the as-is passthrough sibling of [`super::steam_proto`].
//!
//! Unlike the Deck/classic-SC backends, this device is NOT re-synthesized from typed wire state:
//! the client captures the physical controller (USB Puck / wired / BLE) and forwards its raw
//! input reports verbatim ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)); the host
//! mirrors them out unchanged, and everything the host's hidraw consumer writes back (Steam's
//! lizard-off / IMU-enable feature reports, `0x80` rumble output reports) is forwarded raw to the
//! client for replay on the real controller ([`HidOutput::HidRaw`](punktfunk_core::quic::HidOutput)).
//! Mainline `hid-steam` does not bind this PID, so no kernel evdev exists — **Steam Input is the
//! consumer**, driving the hidraw node exactly as it drives the physical pad.
//!
//! Protocol ground truth: SDL's `SDL_hidapi_steam_triton.c` + `steam/controller_structs.h`
//! (Valve-maintained). Input report ids `0x42`/`0x45` (`TritonMTUNoQuat_t`, 46 bytes with id),
//! `0x47` (adds a trackpad timestamp), `0x43` battery, `0x46`/`0x79` wireless status. Feature
//! reports are 64 bytes on report id `1`; haptics are OUTPUT reports `0x80..=0x85`.
//!
//! A typed **fallback synthesizer** is kept for the degraded case (a client that declared the
//! kind but sends no raw feed): buttons/sticks/triggers from the ordinary gamepad plane are
//! serialized into a minimal `0x42` state report. The first raw report permanently switches the
//! pad to as-is mode.
use punktfunk_core::input::gamepad as gs;
/// Valve vendor id (same as [`super::steam_proto::STEAM_VENDOR`], repeated to keep this module
/// self-contained).
pub const TRITON_VENDOR: u32 = 0x28DE;
/// The wired Steam Controller 2 identity the virtual pad presents. The BLE (`0x1303`) and Puck
/// dongle (`0x1304`/`0x1305`) identities are client-side transports only — Steam treats the wired
/// PID as the canonical controller.
pub const TRITON_WIRED_PRODUCT: u32 = 0x1302;
/// Triton input-report ids (`ETritonReportIDTypes`, SDL `controller_structs.h`).
pub const ID_TRITON_CONTROLLER_STATE: u8 = 0x42;
pub const ID_TRITON_BATTERY_STATUS: u8 = 0x43;
pub const ID_TRITON_CONTROLLER_STATE_BLE: u8 = 0x45;
pub const ID_TRITON_CONTROLLER_STATE_TIMESTAMP: u8 = 0x47;
/// Haptic OUTPUT report ids (`ID_OUT_REPORT_*`). Only rumble is parsed host-side (for the
/// universal 0xCA plane); every output report is forwarded raw regardless.
pub const ID_OUT_REPORT_HAPTIC_RUMBLE: u8 = 0x80;
/// 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 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] = &[
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
/// `TritonButtons`. Only the bits the typed fallback synthesizes are named; the raw path carries
/// whatever the physical pad set.
pub mod tbtn {
pub const A: u32 = 0x0000_0001;
pub const B: u32 = 0x0000_0002;
pub const X: u32 = 0x0000_0004;
pub const Y: u32 = 0x0000_0008;
pub const QAM: u32 = 0x0000_0010;
pub const R3: u32 = 0x0000_0020;
pub const VIEW: u32 = 0x0000_0040;
pub const R4: u32 = 0x0000_0080;
pub const R5: u32 = 0x0000_0100;
pub const RB: u32 = 0x0000_0200;
pub const DPAD_DOWN: u32 = 0x0000_0400;
pub const DPAD_RIGHT: u32 = 0x0000_0800;
pub const DPAD_LEFT: u32 = 0x0000_1000;
pub const DPAD_UP: u32 = 0x0000_2000;
pub const MENU: u32 = 0x0000_4000;
pub const L3: u32 = 0x0000_8000;
pub const STEAM: u32 = 0x0001_0000;
pub const L4: u32 = 0x0002_0000;
pub const L5: u32 = 0x0004_0000;
pub const LB: u32 = 0x0008_0000;
pub const RPAD_TOUCH: u32 = 0x0020_0000;
pub const RPAD_CLICK: u32 = 0x0040_0000;
pub const RT_CLICK: u32 = 0x0080_0000;
pub const LPAD_TOUCH: u32 = 0x0200_0000;
pub const LPAD_CLICK: u32 = 0x0400_0000;
pub const LT_CLICK: u32 = 0x0800_0000;
}
/// One virtual Triton pad's report state. In as-is mode (`raw_len > 0`) the raw report IS the
/// state; the typed fields only feed the fallback synthesizer until the first raw report lands.
#[derive(Clone, Copy)]
pub struct TritonState {
/// The last raw report the client forwarded (report-id byte first); `raw_len == 0` until the
/// first one arrives, after which the typed fields below stop mattering.
pub raw: [u8; TRITON_REPORT_LEN],
pub raw_len: u8,
/// Typed fallback fields (Triton bit layout / raw axis units), from the ordinary wire plane.
pub buttons: u32,
pub lt: u16,
pub rt: u16,
pub lx: i16,
pub ly: i16,
pub rx: i16,
pub ry: i16,
}
impl TritonState {
pub fn neutral() -> TritonState {
TritonState {
raw: [0u8; TRITON_REPORT_LEN],
raw_len: 0,
buttons: 0,
lt: 0,
rt: 0,
lx: 0,
ly: 0,
rx: 0,
ry: 0,
}
}
/// Typed fallback: fold one wire button/stick frame into Triton fields. Mapping follows the
/// Deck backend's conventions (PADDLE1/2/3/4 = R4/L4/R5/L5, MISC1 = QAM, the DualSense
/// touchpad-click wire bit = right-pad click); sticks are already the device convention
/// (+y up), triggers scale 0..255 → 0..32767.
pub fn from_gamepad(
buttons: u32,
lx: i16,
ly: i16,
rx: i16,
ry: i16,
lt: u8,
rt: u8,
) -> TritonState {
let on = |bit: u32| buttons & bit != 0;
let trig = |v: u8| ((v as u32 * 32767) / 255) as u16;
let mut b = 0u32;
let set = |b: &mut u32, on: bool, m: u32| {
if on {
*b |= m;
}
};
set(&mut b, on(gs::BTN_A), tbtn::A);
set(&mut b, on(gs::BTN_B), tbtn::B);
set(&mut b, on(gs::BTN_X), tbtn::X);
set(&mut b, on(gs::BTN_Y), tbtn::Y);
set(&mut b, on(gs::BTN_LB), tbtn::LB);
set(&mut b, on(gs::BTN_RB), tbtn::RB);
set(&mut b, on(gs::BTN_BACK), tbtn::VIEW);
set(&mut b, on(gs::BTN_START), tbtn::MENU);
set(&mut b, on(gs::BTN_GUIDE), tbtn::STEAM);
set(&mut b, on(gs::BTN_LS_CLICK), tbtn::L3);
set(&mut b, on(gs::BTN_RS_CLICK), tbtn::R3);
set(&mut b, on(gs::BTN_DPAD_UP), tbtn::DPAD_UP);
set(&mut b, on(gs::BTN_DPAD_DOWN), tbtn::DPAD_DOWN);
set(&mut b, on(gs::BTN_DPAD_LEFT), tbtn::DPAD_LEFT);
set(&mut b, on(gs::BTN_DPAD_RIGHT), tbtn::DPAD_RIGHT);
set(&mut b, on(gs::BTN_TOUCHPAD), tbtn::RPAD_CLICK);
set(&mut b, on(gs::BTN_PADDLE1), tbtn::R4);
set(&mut b, on(gs::BTN_PADDLE2), tbtn::L4);
set(&mut b, on(gs::BTN_PADDLE3), tbtn::R5);
set(&mut b, on(gs::BTN_PADDLE4), tbtn::L5);
set(&mut b, on(gs::BTN_MISC1), tbtn::QAM);
// "Fully pressed" digital shadow of the analog triggers (the physical pad's own
// threshold is a hard pull, not first-contact).
set(&mut b, lt >= 240, tbtn::LT_CLICK);
set(&mut b, rt >= 240, tbtn::RT_CLICK);
TritonState {
raw: [0u8; TRITON_REPORT_LEN],
raw_len: 0,
buttons: b,
lt: trig(lt),
rt: trig(rt),
lx,
ly,
rx,
ry,
}
}
}
/// Serialize the typed fallback state into a minimal `0x42` `TritonMTUNoQuat_t` report:
/// `[0x42][seq u8][buttons u32][trigL i16][trigR i16][sticks i16×4][lpad x/y + pressure]
/// [rpad x/y + pressure][imu ts u32 + accel i16×3 + gyro i16×3]` — pads and IMU stay zero
/// (no raw feed = no trackpad/motion source; Steam only sees IMU data after enabling
/// `SETTING_IMU_MODE` on a real feed anyway).
pub fn serialize_triton_state(buf: &mut [u8; TRITON_STATE_LEN], st: &TritonState, seq: u8) {
buf.fill(0);
buf[0] = ID_TRITON_CONTROLLER_STATE;
buf[1] = seq;
buf[2..6].copy_from_slice(&st.buttons.to_le_bytes());
buf[6..8].copy_from_slice(&(st.lt as i16).to_le_bytes());
buf[8..10].copy_from_slice(&(st.rt as i16).to_le_bytes());
buf[10..12].copy_from_slice(&st.lx.to_le_bytes());
buf[12..14].copy_from_slice(&st.ly.to_le_bytes());
buf[14..16].copy_from_slice(&st.rx.to_le_bytes());
buf[16..18].copy_from_slice(&st.ry.to_le_bytes());
// [18..30] left/right pad + pressures stay zero; [30..46] IMU stays zero.
}
/// One service pass's extracted feedback: the raw reports to forward (kind-tagged for
/// [`HidOutput::HidRaw`](punktfunk_core::quic::HidOutput)) plus the rumble level parsed out of a
/// `0x80` report for the universal 0xCA plane (drives the phone-mirror path on clients whose
/// physical pad already gets the raw report).
#[derive(Default)]
pub struct TritonFeedback {
/// `(low, high)` — `left.speed`/`right.speed` of the last rumble output report seen.
pub rumble: Option<(u16, u16)>,
/// Raw reports to forward: `(kind, bytes)` with kind = `HID_RAW_OUTPUT`/`HID_RAW_FEATURE`.
pub raw: Vec<(u8, Vec<u8>)>,
}
/// Parse a Triton haptic-rumble OUTPUT report (`MsgHapticRumble`, 10 bytes with id):
/// `[0x80][type u8][intensity u16][left.speed u16][left.gain i8][right.speed u16][right.gain i8]`.
/// Returns `(left_speed, right_speed)` as `(low, high)`.
pub fn parse_triton_rumble(data: &[u8]) -> Option<(u16, u16)> {
if data.len() < 10 || data[0] != ID_OUT_REPORT_HAPTIC_RUMBLE {
return None;
}
let le = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]);
Some((le(4), le(7)))
}
/// Strip the hidraw unnumbered-report `0x00` prefix if present: Triton report/command ids are all
/// non-zero (`0x42+` input, `0x80+` output, `1` feature), so a leading zero can only be the
/// synthetic report-id byte hidraw prepends on this unnumbered virtual descriptor.
pub fn strip_report_prefix(data: &[u8]) -> &[u8] {
match data {
[0, rest @ ..] if !rest.is_empty() => rest,
d => d,
}
}
/// Per-instance unit id stamped into the fake `0x83` attributes (`'T','R','I'` + index).
pub fn triton_unit_id(index: u8) -> u32 {
0x5452_4900 | index as u32
}
/// The virtual pad's serial, FVPF-prefixed: the 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. Shaped like the real `FXA…` serials
/// (13 chars). Shared by the UHID and usbip legs (identity + `0xAE` replies must agree).
pub fn triton_serial(index: u8) -> String {
format!("FVPF1302{index:02}D03")
}
/// Build the reply to a feature GET_REPORT — the answer half of the Valve query dance. Steam's
/// `GetControllerInfo` SETs a query (`0x83` attributes / `0xAE` string) and then GETs the answer;
/// **the reply's command byte must echo the LAST SET's command** or Steam treats the pad as
/// broken and never adopts it (confirmed on-glass 2026-07-15: answering every GET with a serial
/// blob left the virtual pad unpicked). Mirrors the Deck's validated
/// [`feature_reply`](super::steam_proto::feature_reply), with two Triton deltas: the frame rides
/// feature report id **1** (`[0x01][cmd][len][payload…]`, matching SDL's send framing for this
/// device), and the `0x83` blob carries the Triton's product id. The attribute VALUES beyond the
/// product id mirror the Deck's hidraw capture (same firmware family conventions) — replace them
/// with a capture from a physical pad if Steam still balks.
///
/// `last_set` is the id-first SET payload (`[0x01, cmd, …]`); a stack that already stripped the
/// id byte (`[cmd, …]`, cmd ≥ 0x80) is handled too.
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;
let body = match last_set {
[0x01, rest @ ..] => rest,
d => d,
};
let cmd = body.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE);
let mut r = [0u8; 64];
r[0] = 0x01;
match cmd {
ID_GET_ATTRIBUTES_VALUES => {
// Captured controller response: 25-byte payload containing five id/u32 attributes.
r[1] = ID_GET_ATTRIBUTES_VALUES;
r[2] = 0x19;
let attrs = [
(0x01, TRITON_WIRED_PRODUCT),
(0x02, 0),
(0x0A, unit_id),
(0x04, unit_id ^ 0x0296_DAF9),
(0x09, 0x49),
];
let mut o = 3;
for (id, val) in attrs {
r[o] = id;
r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes());
o += 5;
}
}
ID_GET_STRING_ATTRIBUTE => {
// 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().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]);
}
}
}
_ => {
let n = body.len().min(63);
r[1..1 + n].copy_from_slice(&body[..n]);
}
}
r
}
#[cfg(test)]
mod tests {
use super::*;
/// The typed fallback lands the canonical wire mapping on the SDL-documented bit positions
/// and byte offsets.
#[test]
fn fallback_state_serializes_sdl_layout() {
let st = TritonState::from_gamepad(
gs::BTN_A | gs::BTN_START | gs::BTN_PADDLE1 | gs::BTN_MISC1,
1000,
-2000,
3000,
-32768,
255,
0,
);
assert_eq!(
st.buttons,
tbtn::A | tbtn::MENU | tbtn::R4 | tbtn::QAM | tbtn::LT_CLICK
);
assert_eq!(st.lt, 32767); // exact full-scale, not the *128 approximation
let mut r = [0u8; TRITON_STATE_LEN];
serialize_triton_state(&mut r, &st, 7);
assert_eq!(r[0], ID_TRITON_CONTROLLER_STATE);
assert_eq!(r[1], 7);
assert_eq!(u32::from_le_bytes([r[2], r[3], r[4], r[5]]), st.buttons);
assert_eq!(i16::from_le_bytes([r[6], r[7]]), 32767); // sTriggerLeft
assert_eq!(i16::from_le_bytes([r[10], r[11]]), 1000); // sLeftStickX
assert_eq!(i16::from_le_bytes([r[16], r[17]]), -32768); // sRightStickY
assert!(r[18..].iter().all(|&b| b == 0)); // pads + IMU zero
}
/// A rumble output report parses to `(left_speed, right_speed)`; other ids don't.
#[test]
fn rumble_output_report_parses() {
// [0x80, type, intensity(2), left.speed(2), left.gain, right.speed(2), right.gain]
let mut d = [0u8; 10];
d[0] = ID_OUT_REPORT_HAPTIC_RUMBLE;
d[4..6].copy_from_slice(&0x1234u16.to_le_bytes());
d[7..9].copy_from_slice(&0x5678u16.to_le_bytes());
assert_eq!(parse_triton_rumble(&d), Some((0x1234, 0x5678)));
d[0] = 0x81; // haptic pulse — not rumble
assert_eq!(parse_triton_rumble(&d), None);
assert_eq!(parse_triton_rumble(&d[..8]), None); // short
}
/// The hidraw `0x00` unnumbered prefix strips; genuine command bytes survive.
#[test]
fn report_prefix_strips_only_leading_zero() {
assert_eq!(strip_report_prefix(&[0x00, 0x80, 1, 2]), &[0x80, 1, 2]);
assert_eq!(strip_report_prefix(&[0x80, 1, 2]), &[0x80, 1, 2]);
assert_eq!(strip_report_prefix(&[0x01, 0x87]), &[0x01, 0x87]); // feature id 1 kept
assert_eq!(strip_report_prefix(&[0x00]), &[0x00]); // lone zero: nothing to strip to
}
/// The GET reply echoes the LAST SET's command — the Valve query dance Steam's
/// `GetControllerInfo` runs; a mismatched command type makes Steam drop the pad.
#[test]
fn feature_reply_echoes_the_queried_command() {
let serial = triton_serial(0);
let uid = triton_unit_id(0);
// 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, 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: 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, 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, 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]);
}
}