refactor(host/W6.2): extract the input-injection backends into the pf-inject crate

inject.rs + inject/* (the per-OS injectors — wlroots virtual-input, KWin
fake_input, libei/reis, gamescope-EI on Linux; SendInput on Windows — plus the
virtual-gamepad HID stack: DualSense/DualShock4/Switch Pro/Steam Controller/Deck
over uhid/usbip and the Windows UMDF drivers, the proto codecs, the injector
service, and the uhid manager) move into crates/pf-inject behind the
InputInjector trait (plan §W6). It consumes punktfunk_core::input (the neutral
GamepadEvent/InputEvent vocabulary, moved to core in W5) + the pf-driver-proto
wire contract, and reaches pf-capture only for the Windows gamepad-channel
WUDFHost check + the resident-mouse compose-kick hook.

The one inject->vdisplay coupling (the libei gamescope-EI backend needs the EIS
relay socket path) is broken via a leaf: gamescope_ei_socket_file moves to
pf-paths as the shared contract — the gamescope producer (host vdisplay) keeps
its session-env-lock wrapper around it, the libei consumer (pf-inject) reads it
directly post-retarget. The host keeps a `mod inject { pub use pf_inject::* }`
shim so every crate::inject::* path (the native/gamestream input planes + devtest)
is unchanged; the heavy input deps (wayland/reis/xkbcommon/usbip + the KWin
fake-input protocol XML) moved with the crate.

Verified: Linux clippy -D warnings (pf-inject + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-inject 69/69 + host 230/230 tests; Windows clippy -D warnings
(pf-inject --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 11:52:02 +02:00
co-authored by Claude Opus 4.8
parent 0992548de7
commit f6c6e4e594
43 changed files with 199 additions and 73 deletions
@@ -0,0 +1,847 @@
//! Transport-independent DualSense HID contract — shared by the Linux UHID backend
//! ([`super::dualsense`]) and the Windows UMDF-driver backend ([`super::dualsense_windows`]).
//!
//! This is the pure logic: the report descriptor, feature blobs, the [`DsState`] controller model
//! and its `GameStream`/XInput mapper, the input-report serializer (report `0x01`) and the
//! output-report parser (report `0x02`, a game's rumble / lightbar / player-LED / adaptive-trigger
//! feedback). Neither half depends on a transport — the Linux backend writes `0x01` to `/dev/uhid`
//! and reads `0x02` via `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF driver and
//! pulls `0x02` back over its control channel — but both build/parse the exact same bytes.
//!
//! The descriptor + field layout are the canonical inputtino ones (games-on-whales/inputtino
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
//! same as a real USB DualSense.
use punktfunk_core::quic::{HidOutput, RichInput};
// Feature reports the host stack GET_REPORTs during init — without these replies the kernel
// (`hid-playstation`) never finishes calibration and creates no input devices. Verbatim from
// inputtino (each array's first byte is the report id). The pairing report carries a fixed
// virtual MAC.
#[rustfmt::skip]
// FIXME(cal-len): the descriptor declares report 0x05 as a 40-byte feature (id + 40 = 41 total),
// but this blob is 42 bytes (one trailing pad byte too many). Linux `hid-playstation` tolerates it
// (the backend is live-validated), and `hidclass` truncates to the declared length, so it is not
// currently blocking; trim the trailing 0x00 to 41 once a physical DualSense is available to
// re-verify motion calibration on both backends.
pub const DS_FEATURE_CALIBRATION: &[u8] = &[ // report 0x05 (motion calibration)
0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0xF4, 0x01, 0xF4, 0x01, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
#[rustfmt::skip]
pub const DS_FEATURE_PAIRING: &[u8] = &[ // report 0x09 (pairing info: MAC at bytes 1..7)
0x09, 0x74, 0xE7, 0xD6, 0x3A, 0x53, 0x35, 0x08, 0x25, 0x00, 0x1E, 0x00, 0xEE, 0x74, 0xD0, 0xBC,
0x00, 0x00, 0x00, 0x00,
];
#[rustfmt::skip]
pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date)
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
/// The pairing reply (report `0x09`) for wire pad `pad`: [`DS_FEATURE_PAIRING`] with the MAC's low
/// octet offset by the pad index. The MAC must be **unique per pad**: `hid-playstation` adopts it
/// as the HID `uniq` (replacing whatever uniq the device was created with), and SDL/Steam dedup
/// controllers by that serial — with identical MACs a second virtual pad reads as the *first* pad
/// re-appearing over another transport and is merged/ignored.
pub fn ds_pairing_reply(pad: u8) -> [u8; 20] {
let mut r = [0u8; 20];
r.copy_from_slice(DS_FEATURE_PAIRING);
r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first
r
}
/// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino — the exact
/// descriptor `hid-playstation` (Linux) / `hidclass` (Windows) parses to bind a DualSense.
#[rustfmt::skip]
pub const DUALSENSE_RDESC: &[u8] = &[
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x2F, 0x91, 0x02,
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x0F, 0xB1, 0x02,
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
0xC0,
];
/// Sony DualSense **Edge** USB HID report descriptor (389 bytes) — a verbatim real-device
/// capture (hid-recorder, hhd-dev/hwinfo `devices/ds5_edge`, cross-checked byte-for-byte against
/// the raw usbmon pcap in the same repo and the descriptor Handheld Daemon ships for ITS virtual
/// UHID Edge). vs the plain DS5 descriptor: output report `0x02` grows 47→63 bytes, feature
/// `0xF2` 15→52, and 19 vendor feature reports (`0x60..=0x7B`, the Edge profile slots) are
/// appended — input report `0x01` is bit-identical (the Edge's Fn/back buttons ride previously
/// reserved bits of `buttons[2]`, see [`btn2`]).
#[rustfmt::skip]
pub const DUALSENSE_EDGE_RDESC: &[u8] = &[
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x3F, 0x91, 0x02,
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x34, 0xB1, 0x02,
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
0x85, 0x60, 0x09, 0x41, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x61, 0x09, 0x42, 0xB1, 0x02, 0x85, 0x62,
0x09, 0x43, 0xB1, 0x02, 0x85, 0x63, 0x09, 0x44, 0xB1, 0x02, 0x85, 0x64, 0x09, 0x45, 0xB1, 0x02,
0x85, 0x65, 0x09, 0x46, 0xB1, 0x02, 0x85, 0x68, 0x09, 0x47, 0xB1, 0x02, 0x85, 0x70, 0x09, 0x48,
0xB1, 0x02, 0x85, 0x71, 0x09, 0x49, 0xB1, 0x02, 0x85, 0x72, 0x09, 0x4A, 0xB1, 0x02, 0x85, 0x73,
0x09, 0x4B, 0xB1, 0x02, 0x85, 0x74, 0x09, 0x4C, 0xB1, 0x02, 0x85, 0x75, 0x09, 0x4D, 0xB1, 0x02,
0x85, 0x76, 0x09, 0x4E, 0xB1, 0x02, 0x85, 0x77, 0x09, 0x4F, 0xB1, 0x02, 0x85, 0x78, 0x09, 0x50,
0xB1, 0x02, 0x85, 0x79, 0x09, 0x51, 0xB1, 0x02, 0x85, 0x7A, 0x09, 0x52, 0xB1, 0x02, 0x85, 0x7B,
0x09, 0x53, 0xB1, 0x02, 0xC0,
];
pub const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
pub const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller
pub const DS_EDGE_PRODUCT: u32 = 0x0DF2; // DualSense Edge Wireless Controller
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
pub const DS_INPUT_REPORT_LEN: usize = 64;
/// The DualSense touchpad's reported resolution (the kernel exposes it as ABS_MT 0..1920/1080).
pub const DS_TOUCH_W: u16 = 1920;
pub const DS_TOUCH_H: u16 = 1080;
/// Bit positions inside the DualSense face/dpad button byte (`buttons[0]`, low nibble = hat).
pub mod btn0 {
pub const SQUARE: u8 = 0x10;
pub const CROSS: u8 = 0x20;
pub const CIRCLE: u8 = 0x40;
pub const TRIANGLE: u8 = 0x80;
}
/// `buttons[1]`: shoulders, triggers-as-buttons, create/options, stick clicks.
pub mod btn1 {
pub const L1: u8 = 0x01;
pub const R1: u8 = 0x02;
pub const L2: u8 = 0x04;
pub const R2: u8 = 0x08;
pub const CREATE: u8 = 0x10; // "Share"
pub const OPTIONS: u8 = 0x20;
pub const L3: u8 = 0x40;
pub const R3: u8 = 0x80;
}
/// `buttons[2]`: PS, touchpad click, mute — plus, on the DualSense **Edge**, the two Fn and two
/// back buttons in bits 47 (kernel `DS_EDGE_BUTTONS_*` / SDL `SDL_GAMEPAD_BUTTON_PS5_*`; the
/// plain DS5 leaves those bits reserved). The kernel maps them to `BTN_TRIGGER_HAPPY1..4`
/// (Fn-L, Fn-R, back-L, back-R) since 7.2; SDL/Steam read them off hidraw on any kernel.
pub mod btn2 {
pub const PS: u8 = 0x01;
pub const TOUCHPAD: u8 = 0x02;
/// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`.
pub const MUTE: u8 = 0x04;
/// Edge left Fn button (below the left stick).
pub const EDGE_FN_LEFT: u8 = 0x10;
/// Edge right Fn button.
pub const EDGE_FN_RIGHT: u8 = 0x20;
/// Edge left back button (rear paddle).
pub const EDGE_BACK_LEFT: u8 = 0x40;
/// Edge right back button (rear paddle).
pub const EDGE_BACK_RIGHT: u8 = 0x80;
}
/// Map the wire back-grip bits onto the DualSense Edge's `buttons[2]` bits — the reason the Edge
/// backend exists: all four client paddles (Deck grips L4/L5/R4/R5, Elite P1P4) land on native
/// slots instead of the fold/drop policy. Wire PADDLE1/2 = R4/L4 (the primary pair, Steam
/// convention) → the Edge's right/left BACK buttons; PADDLE3/4 = R5/L5 → the right/left Fn
/// buttons (real-HW Fn is profile-switch chrome, but on a virtual pad the bits reach consumers
/// as ordinary buttons — kernel `BTN_TRIGGER_HAPPY1/2`, SDL `LEFT/RIGHT_FUNCTION`).
pub fn edge_paddle_bits(buttons: u32) -> u8 {
use punktfunk_core::input::gamepad as gs;
let mut b = 0;
if buttons & gs::BTN_PADDLE1 != 0 {
b |= btn2::EDGE_BACK_RIGHT; // R4
}
if buttons & gs::BTN_PADDLE2 != 0 {
b |= btn2::EDGE_BACK_LEFT; // L4
}
if buttons & gs::BTN_PADDLE3 != 0 {
b |= btn2::EDGE_FN_RIGHT; // R5
}
if buttons & gs::BTN_PADDLE4 != 0 {
b |= btn2::EDGE_FN_LEFT; // L5
}
b
}
/// One touchpad contact for the report.
#[derive(Clone, Copy, Default)]
pub struct Touch {
pub active: bool,
pub id: u8,
pub x: u16, // 0..DS_TOUCH_W
pub y: u16, // 0..DS_TOUCH_H
}
/// Full DualSense controller state to serialize into report `0x01`. Sticks/triggers are 8-bit
/// (`0x80` neutral for sticks, `0x00` released for triggers); `dpad` is the 8-way hat (`8` =
/// centered); `buttons[0..3]` are the packed DualSense button bytes; gyro/accel are raw i16.
#[derive(Clone, Copy, Default)]
pub struct DsState {
pub lx: u8,
pub ly: u8,
pub rx: u8,
pub ry: u8,
pub l2: u8,
pub r2: u8,
pub dpad: u8, // 0..7 direction, 8 = neutral
pub buttons: [u8; 4],
pub gyro: [i16; 3],
pub accel: [i16; 3],
pub touch: [Touch; 2],
/// Per-contact-slot click state from the rich plane (`TouchpadEx.click` — a Steam pad's
/// physical pad-click). The serializers OR any held slot into the touchpad-click button
/// bit: the DualSense has ONE clickable pad, so either Deck pad clicking counts. Lives
/// outside `buttons` because `from_gamepad` rebuilds those from every button frame —
/// managers must persist this across rebuilds like `touch`/`gyro`/`accel`.
pub touch_click: [bool; 2],
}
impl DsState {
/// A centered, nothing-pressed state (sticks 0x80, dpad neutral).
pub fn neutral() -> DsState {
DsState {
lx: 0x80,
ly: 0x80,
rx: 0x80,
ry: 0x80,
dpad: 8,
..Default::default()
}
}
/// Map a GameStream/XInput pad frame (button bitmask + i16 sticks + u8 triggers) into the
/// DualSense report fields. Sticks are recentred to `0x80`; the Y axes are inverted (XInput
/// `+y = up`, DualSense `0 = up`). Triggers double as the L2/R2 buttons when pressed. Touchpad
/// + motion are filled separately from rich-input events.
pub fn from_gamepad(
buttons: u32,
lx: i16,
ly: i16,
rx: i16,
ry: i16,
lt: u8,
rt: u8,
) -> DsState {
use punktfunk_core::input::gamepad as gs;
let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8;
let on = |bit: u32| buttons & bit != 0;
let mut s = DsState {
lx: to_u8(lx),
ly: 255 - to_u8(ly),
rx: to_u8(rx),
ry: 255 - to_u8(ry),
l2: lt,
r2: rt,
..DsState::neutral()
};
s.set_dpad(
on(gs::BTN_DPAD_UP),
on(gs::BTN_DPAD_DOWN),
on(gs::BTN_DPAD_LEFT),
on(gs::BTN_DPAD_RIGHT),
);
let mut b0 = 0;
if on(gs::BTN_A) {
b0 |= btn0::CROSS;
}
if on(gs::BTN_B) {
b0 |= btn0::CIRCLE;
}
if on(gs::BTN_X) {
b0 |= btn0::SQUARE;
}
if on(gs::BTN_Y) {
b0 |= btn0::TRIANGLE;
}
s.buttons[0] = b0; // face buttons (high nibble); dpad merged in write_state
let mut b1 = 0;
if on(gs::BTN_LB) {
b1 |= btn1::L1;
}
if on(gs::BTN_RB) {
b1 |= btn1::R1;
}
if lt > 0 {
b1 |= btn1::L2;
}
if rt > 0 {
b1 |= btn1::R2;
}
if on(gs::BTN_BACK) {
b1 |= btn1::CREATE;
}
if on(gs::BTN_START) {
b1 |= btn1::OPTIONS;
}
if on(gs::BTN_LS_CLICK) {
b1 |= btn1::L3;
}
if on(gs::BTN_RS_CLICK) {
b1 |= btn1::R3;
}
s.buttons[1] = b1;
if on(gs::BTN_GUIDE) {
s.buttons[2] |= btn2::PS;
}
if on(gs::BTN_TOUCHPAD) {
s.buttons[2] |= btn2::TOUCHPAD;
}
// The mic-mute / capture button (Deck '…' QAM on the Steam path). Clients send it as
// BTN_MISC1; without this the DualSense mute button was inert on every PlayStation-family
// virtual pad. Rebuilt from the wire bit each frame like PS/TOUCHPAD, so no persistence gap.
if on(gs::BTN_MISC1) {
s.buttons[2] |= btn2::MUTE;
}
s
}
/// Set the dpad hat from the four GameStream dpad booleans (up/down/left/right).
pub fn set_dpad(&mut self, up: bool, down: bool, left: bool, right: bool) {
// DualSense hat: 0=N,1=NE,2=E,3=SE,4=S,5=SW,6=W,7=NW,8=neutral.
self.dpad = match (up, right, down, left) {
(true, false, false, false) => 0,
(true, true, false, false) => 1,
(false, true, false, false) => 2,
(false, true, true, false) => 3,
(false, false, true, false) => 4,
(false, false, true, true) => 5,
(false, false, false, true) => 6,
(true, false, false, true) => 7,
_ => 8,
};
}
/// Apply one rich client→host event (touchpad contact / motion sample) into this state —
/// the ONE mapping shared by every DualSense-family backend (Linux UHID, Windows UMDF,
/// DS4 both ways; `touch_w`/`touch_h` are the pad's advertised extents, 1920×1080 vs
/// 1920×942).
///
/// Wire touch coordinates are screen convention (+x right, +y down) — same as the
/// DualSense pad's own (top-left origin), so no flip here.
///
/// A Steam Deck / Steam Controller client sends TWO pads as `TouchpadEx` surfaces; the
/// DualSense has one pad with two contact slots, so the surfaces SPLIT it — left pad →
/// contact 0 on the left half, right pad → contact 1 on the right half. That mirrors the
/// physical thumb layout and lands exactly on the split-pad zones games and Steam Input
/// already use for the DS4/DualSense touchpad. Pad clicks ride `touch_click` (the
/// serializer ORs them into the touchpad-click button — one clickable pad, either
/// surface counts); dropping them was the "Deck pad click does nothing on a DualSense
/// host" gap.
pub fn apply_rich(&mut self, rich: RichInput, touch_w: u16, touch_h: u16) {
// Normalized position → pad extents. The kernel/driver advertises 0..=W-1 / 0..=H-1.
let scale = |n: u32, extent: u16| ((n * (extent - 1) as u32) / u16::MAX as u32) as u16;
match rich {
RichInput::Touchpad {
finger,
active,
x,
y,
..
} => {
// The DualSense touchpad carries two contacts; clamp to a valid slot and keep
// the reported contact id consistent with it (the wire `finger` is untrusted).
let slot = (finger as usize).min(1);
self.touch[slot] = Touch {
active,
id: slot as u8,
x: scale(x as u32, touch_w),
y: scale(y as u32, touch_h),
};
}
RichInput::Motion { gyro, accel, .. } => {
// The wire is already DualSense-convention units (20 LSB/°·s, 10000 LSB/g).
self.gyro = gyro;
self.accel = accel;
}
RichInput::TouchpadEx {
surface,
finger,
touch,
click,
x,
y,
..
} => {
let n = |v: i16| ((v as i32) + 32768) as u32; // signed centre-0 → 0..=65535
let half = touch_w / 2;
let (slot, tx) = match surface {
// The single / DualSense pad: full extent, slot by finger.
0 => ((finger as usize).min(1), scale(n(x), touch_w)),
// Steam LEFT pad → contact 0 on the left half.
1 => (0, scale(n(x), half)),
// Steam RIGHT pad (or anything newer) → contact 1 on the right half.
_ => (1, half + scale(n(x), half)),
};
self.touch[slot] = Touch {
active: touch,
id: slot as u8,
x: tx,
y: scale(n(y), touch_h),
};
self.touch_click[slot] = click;
}
// Raw as-is passthrough reports belong to the Triton backend, never a DS state.
RichInput::HidReport { .. } => {}
}
}
/// `buttons[2]` as serialized: the live button frame plus the touchpad-click bit when a
/// rich-plane pad click is held (see [`DsState::touch_click`]).
pub fn buttons2_with_click(&self) -> u8 {
let mut b = self.buttons[2];
if self.touch_click.iter().any(|c| *c) {
b |= btn2::TOUCHPAD;
}
b
}
}
/// Serialize a full input report `0x01` (pure — unit-testable without a transport). Field
/// offsets per the kernel's `struct dualsense_input_report`, this report's one consumer:
/// x..rz 0-5, seq 6, buttons[4] 7-10, reserved[4] 11-14, gyro[3] 15-20, accel[3] 21-26,
/// sensor_timestamp 27-30, reserved2 31, points[2] 32-39 (static_assert(sizeof == 63)).
/// The report id occupies r[0], so struct offset N = r[N + 1].
pub fn serialize_state(r: &mut [u8; DS_INPUT_REPORT_LEN], st: &DsState, seq: u8, ts: u32) {
r[0] = 0x01; // report id; the struct fields follow (struct offset 0 == r[1])
r[1] = st.lx;
r[2] = st.ly;
r[3] = st.rx;
r[4] = st.ry;
r[5] = st.l2;
r[6] = st.r2;
r[7] = seq; // seq_number (struct off 6)
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
r[9] = st.buttons[1]; // off 8
r[10] = st.buttons2_with_click(); // off 9 (PS/touchpad-click/mute; rich pad clicks OR in)
r[11] = st.buttons[3]; // off 10
for (i, v) in st.gyro.iter().enumerate() {
r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15
}
for (i, v) in st.accel.iter().enumerate() {
r[22 + i * 2..24 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 21
}
r[28..32].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 27)
pack_touch(&mut r[33..37], &st.touch[0]); // touch point 1 (struct off 32)
pack_touch(&mut r[37..41], &st.touch[1]); // touch point 2
// status byte (struct off 52 → r[53]) — hid-playstation reads battery here: low nibble =
// capacity (×10+5 %), high nibble = charging state (0 = discharging). A virtual pad has no
// real cell, so report "discharging, full" (0x0A → 100 %); leaving it 0 makes SteamOS / the
// kernel see ~5 % and warn "low battery". (We don't forward the client pad's real charge yet.)
r[53] = 0x0A;
}
fn pack_touch(dst: &mut [u8], t: &Touch) {
// byte0: bit7 = NOT active (1 = no contact), bits0-6 = contact id.
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
// The kernel advertises ABS_MT ranges 0..=W-1 / 0..=H-1 — never emit the size itself.
let (x, y) = (t.x.min(DS_TOUCH_W - 1), t.y.min(DS_TOUCH_H - 1));
dst[1] = (x & 0xFF) as u8;
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
dst[3] = ((y >> 4) & 0xFF) as u8;
}
/// What one service pass extracted from the device's HID output reports.
/// Rich feedback (lightbar / player LEDs / adaptive triggers) rides the HID-output plane (0xCD);
/// motor rumble rides the universal rumble plane (0xCA) so non-DualSense clients still feel it.
#[derive(Default)]
pub struct DsFeedback {
pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
pub rumble: Option<(u16, u16)>,
/// Whether a fresh output report was seen this poll (set by the backend's section poll, not by
/// the parser) — the game-activity signal the [`UhidManager`](crate::uhid_manager)
/// abandoned-rumble force-off keys on.
pub fresh: bool,
}
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
///
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
/// so an ungated parse would turn every plain rumble write into a lightbar-off + triggers-off
/// broadcast.
pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
// data[0] is the report id (0x02). Be defensive about short reports.
if data.first() != Some(&0x02) || data.len() < 48 {
return;
}
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
// and route to the universal rumble plane (0xCA).
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises 0x0154 so the
// kernel and SDL stay on the flag0 convention, but a writer that hardcodes v2 would
// otherwise have its rumble — including stops — silently ignored, and a missed stop
// buzzes for the rest of the session (the 500 ms refresh re-sends stale state forever).
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
let high = (data[3] as u16) << 8;
let low = (data[4] as u16) << 8;
fb.rumble = Some((low, high));
}
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
if flag1 & 0x04 != 0 {
let (r, g, b) = (data[45], data[46], data[47]);
fb.hidout.push(HidOutput::Led { pad, r, g, b });
}
if flag1 & 0x10 != 0 {
fb.hidout.push(HidOutput::PlayerLeds {
pad,
bits: data[44] & 0x1F,
});
}
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
if data.len() >= 33 {
if flag0 & 0x04 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 1,
effect: data[11..22].to_vec(),
});
}
if flag0 & 0x08 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 0,
effect: data[22..33].to_vec(),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0
/// on the left half, right pad (surface 2) contact 1 on the right half; y follows the
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
/// button bit in the serialized report.
#[test]
fn steam_surfaces_split_the_touchpad() {
let mut s = DsState::neutral();
// Left pad, centre → middle of the LEFT half.
s.apply_rich(
RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: false,
x: 0,
y: 0,
pressure: 0,
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert!(s.touch[0].active);
assert_eq!(s.touch[0].id, 0);
assert_eq!(s.touch[0].x, (DS_TOUCH_W / 2 - 1) / 2); // centre of 0..=959
assert_eq!(s.touch[0].y, (DS_TOUCH_H - 1) / 2);
// Right pad, top-right corner → right edge of the RIGHT half, y = 0 (screen top).
s.apply_rich(
RichInput::TouchpadEx {
pad: 0,
surface: 2,
finger: 0,
touch: true,
click: true,
x: i16::MAX,
y: i16::MIN,
pressure: 0,
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert!(s.touch[1].active);
assert_eq!(s.touch[1].id, 1);
assert_eq!(s.touch[1].x, DS_TOUCH_W - 1);
assert_eq!(s.touch[1].y, 0);
// The right pad's click reaches the (single) touchpad-click button bit.
assert!(s.touch_click[1]);
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, btn2::TOUCHPAD);
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &s, 0, 0);
assert_eq!(r[10] & btn2::TOUCHPAD, btn2::TOUCHPAD);
// Releasing the click clears the bit again.
s.apply_rich(
RichInput::TouchpadEx {
pad: 0,
surface: 2,
finger: 0,
touch: true,
click: false,
x: 0,
y: 0,
pressure: 0,
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, 0);
}
/// The single-surface forms keep their full-pad mapping: unsigned `Touchpad` and
/// `TouchpadEx` surface 0 both span the whole touchpad, slot picked by finger.
#[test]
fn single_surface_spans_full_pad() {
let mut s = DsState::neutral();
s.apply_rich(
RichInput::Touchpad {
pad: 0,
finger: 0,
active: true,
x: 65535,
y: 65535,
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert_eq!(
(s.touch[0].x, s.touch[0].y),
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
);
s.apply_rich(
RichInput::TouchpadEx {
pad: 0,
surface: 0,
finger: 1,
touch: true,
click: false,
x: i16::MAX,
y: i16::MAX,
pressure: 0,
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert_eq!(
(s.touch[1].x, s.touch[1].y),
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
);
// Motion is unit-passthrough (wire is already DualSense convention).
s.apply_rich(
RichInput::Motion {
pad: 0,
gyro: [100, -200, 300],
accel: [-1000, 2000, -3000],
},
DS_TOUCH_W,
DS_TOUCH_H,
);
assert_eq!(s.gyro, [100, -200, 300]);
assert_eq!(s.accel, [-1000, 2000, -3000]);
}
/// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor
/// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with
/// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2).
#[test]
fn parse_output_report() {
let mut data = vec![0u8; 48];
data[0] = 0x02; // report id
data[1] = 0x0F; // valid_flag0: vibration + haptics + R2 + L2 triggers
data[2] = 0x14; // valid_flag1: lightbar + player indicators
data[3] = 0x80; // right (high-freq) motor
data[4] = 0x40; // left (low-freq) motor
data[11] = 0x21; // right-trigger block mode byte (report bytes 11..22)
data[22] = 0x26; // left-trigger block mode byte (report bytes 22..33)
data[44] = 0x03; // player LEDs (low 5 bits)
data[45] = 10; // R
data[46] = 20; // G
data[47] = 30; // B
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
// (low, high) = (left<<8, right<<8).
assert_eq!(fb.rumble, Some((0x4000, 0x8000)));
assert!(fb.hidout.contains(&HidOutput::Led {
pad: 0,
r: 10,
g: 20,
b: 30
}));
assert!(fb
.hidout
.contains(&HidOutput::PlayerLeds { pad: 0, bits: 3 }));
// The report's FIRST block (bytes 11..22) is the RIGHT trigger → wire which = 1.
let triggers: Vec<_> = fb
.hidout
.iter()
.filter_map(|h| match h {
HidOutput::Trigger { which, effect, .. } => Some((*which, effect[0])),
_ => None,
})
.collect();
assert_eq!(triggers, vec![(1, 0x21), (0, 0x26)]);
}
/// Writers set only the valid-flag bits for the fields they mean to change (the rest of the
/// report is zeroed) — a plain rumble write must NOT blank the lightbar / player LEDs /
/// triggers, and an LED-only write must not stop the motors.
#[test]
fn parse_output_respects_valid_flags() {
// Rumble write: only the vibration flags set, everything else zero.
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[1] = 0x03; // compatible vibration + haptics select
data[3] = 0xFF;
data[4] = 0xFF;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert_eq!(fb.rumble, Some((0xFF00, 0xFF00)));
assert!(fb.hidout.is_empty(), "rumble write must not emit hidout");
// Lightbar-only write: no rumble surfaced (would otherwise spam rumble-stops).
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[2] = 0x04; // lightbar control enable
data[45] = 1;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert!(fb.rumble.is_none());
assert_eq!(fb.hidout.len(), 1);
assert!(matches!(fb.hidout[0], HidOutput::Led { r: 1, .. }));
}
/// The input report's sensor/touch bytes must land exactly where the kernel's
/// `struct dualsense_input_report` reads them (gyro at struct offset 15, accel 21,
/// timestamp 27, touch points 32 — report byte = struct offset + 1). A one-byte slip
/// here turns client motion into noise and conjures phantom touch contacts.
#[test]
fn input_report_layout_matches_hid_playstation() {
let mut st = DsState::neutral();
st.gyro = [0x1122, 0x3344, 0x5566];
st.accel = [0x778, 0x99A, 0xBBC];
st.touch[0] = Touch {
active: true,
id: 5,
x: 0x123,
y: 0x356,
};
// touch[1] stays inactive — its NOT-active bit must be set.
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &st, 7, 0xAABBCCDD);
assert_eq!(r[0], 0x01);
assert_eq!(r[7], 7); // seq_number (struct off 6)
assert_eq!(&r[16..22], &[0x22, 0x11, 0x44, 0x33, 0x66, 0x55]); // gyro LE
assert_eq!(&r[22..28], &[0x78, 0x07, 0x9A, 0x09, 0xBC, 0x0B]); // accel LE
assert_eq!(&r[28..32], &[0xDD, 0xCC, 0xBB, 0xAA]); // sensor_timestamp LE
// Touch point 1 at struct off 32 = r[33..37]: contact byte (active → bit7 clear),
// then 12-bit x / 12-bit y packed.
assert_eq!(r[33], 5);
assert_eq!(r[34], 0x23);
assert_eq!(r[35], 0x61); // x_hi nibble 0x1 | (y & 0xF) << 4 (y=0x356 → 0x6 << 4)
assert_eq!(r[36], 0x35); // y >> 4
assert_eq!(r[37] & 0x80, 0x80); // touch point 2 inactive
// status byte (struct off 52): discharging (high nibble 0) + full capacity (low nibble
// 0xA → 100 %), so SteamOS/hid-playstation never reports a false "low battery".
assert_eq!(r[53], 0x0A);
}
/// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in
/// `buttons[2]`.
#[test]
fn from_gamepad_maps_touchpad_click() {
use punktfunk_core::input::gamepad as gs;
let s = DsState::from_gamepad(gs::BTN_TOUCHPAD | gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], btn2::PS | btn2::TOUCHPAD);
// BTN_MISC1 → the mic-mute / capture button (G6: was previously dropped entirely).
let s = DsState::from_gamepad(gs::BTN_MISC1, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], btn2::MUTE);
let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
assert_eq!(s.buttons[2], 0);
}
/// The Edge paddle map, pinned against hid-playstation's `DS_EDGE_BUTTONS_*` masks (bits
/// 47 of `buttons[2]`) and SDL's `SDL_GAMEPAD_BUTTON_PS5_*` (same byte off hidraw):
/// PADDLE1/2 (R4/L4) → right/left BACK, PADDLE3/4 (R5/L5) → right/left Fn — and the mapped
/// bits land in the serialized report's byte 10 next to the ordinary buttons[2] bits.
#[test]
fn edge_paddles_map_to_native_bits() {
use punktfunk_core::input::gamepad as gs;
assert_eq!(edge_paddle_bits(0), 0);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE1), btn2::EDGE_BACK_RIGHT);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE2), btn2::EDGE_BACK_LEFT);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE3), btn2::EDGE_FN_RIGHT);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE4), btn2::EDGE_FN_LEFT);
// Exact kernel/SDL bit values (a one-bit slip ships dead paddles).
assert_eq!(btn2::EDGE_FN_LEFT, 0x10);
assert_eq!(btn2::EDGE_FN_RIGHT, 0x20);
assert_eq!(btn2::EDGE_BACK_LEFT, 0x40);
assert_eq!(btn2::EDGE_BACK_RIGHT, 0x80);
// All four + a non-paddle bit: paddles map, the rest is ignored here.
let all = gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_A;
assert_eq!(edge_paddle_bits(all), 0xF0);
// Serialized: the Edge merge ORs into buttons[2]; byte 10 carries both the paddles and
// the ordinary bits (e.g. a simultaneous PS press).
let mut s = DsState::from_gamepad(gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
s.buttons[2] |= edge_paddle_bits(gs::BTN_PADDLE2 | gs::BTN_PADDLE3);
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &s, 0, 0);
assert_eq!(r[10], btn2::PS | btn2::EDGE_BACK_LEFT | btn2::EDGE_FN_RIGHT);
}
/// The Edge descriptor is the real-device capture: exact length, the three deltas vs the
/// plain DS5 descriptor (output 0x02 count 63, feature 0xF2 count 52, the appended profile
/// feature reports), and an unchanged input-report prefix (report 0x01 is bit-identical —
/// the serializer needs no Edge variant).
#[test]
fn edge_descriptor_shape() {
assert_eq!(DUALSENSE_RDESC.len(), 273);
assert_eq!(DUALSENSE_EDGE_RDESC.len(), 389);
// Identical through the input-report + output-report-id prefix; the first delta is the
// output report 0x02's Report Count at offset 109 (47 → 63 bytes of payload).
assert_eq!(DUALSENSE_EDGE_RDESC[..109], DUALSENSE_RDESC[..109]);
assert_eq!(DUALSENSE_RDESC[109], 0x2F);
assert_eq!(DUALSENSE_EDGE_RDESC[109], 0x3F);
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
}
/// A short / wrong-id report yields nothing.
#[test]
fn parse_output_rejects_garbage() {
let mut fb = DsFeedback::default();
parse_ds_output(0, &[0x01, 0, 0], &mut fb); // wrong report id, too short
assert!(fb.rumble.is_none());
assert!(fb.hidout.is_empty());
}
/// The pairing reply keeps the report id and differs across pads ONLY in the MAC low octet —
/// distinct serials so SDL/Steam never dedup two virtual pads into one controller.
#[test]
fn pairing_reply_mac_is_per_pad() {
assert_eq!(ds_pairing_reply(0).as_slice(), DS_FEATURE_PAIRING);
let (a, b) = (ds_pairing_reply(1), ds_pairing_reply(2));
assert_eq!(a[0], 0x09); // report id untouched
assert_eq!(a[1], DS_FEATURE_PAIRING[1].wrapping_add(1));
assert_eq!(b[1], DS_FEATURE_PAIRING[1].wrapping_add(2));
assert_eq!(a[2..], b[2..]); // everything but the low octet identical
}
}
@@ -0,0 +1,186 @@
//! Transport-independent DualShock 4 HID contract — the pure report codec shared by the Windows
//! UMDF-driver backend ([`super::dualshock4_windows`]) and the Linux UHID backend
//! ([`super::dualshock4`]).
//!
//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4
//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper
//! ([`DsState::from_gamepad`]) — only the report *byte layout*, the touchpad resolution, and the
//! feedback report differ. The Linux backend writes report `0x01` to `/dev/uhid` and reads `0x05` via
//! `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF driver and pulls `0x05` back over its
//! shared-memory channel — both build/parse the exact same bytes here.
//!
//! Field offsets are the canonical real-DS4-USB layout the kernel `struct
//! dualshock4_input_report_usb` / `_output_report_common` parse.
use super::dualsense_proto::{DsState, Touch};
/// DualShock 4 v2 USB identity (Sony Interactive Entertainment / CUH-ZCT2).
pub const DS4_VENDOR: u16 = 0x054C;
pub const DS4_PRODUCT: u16 = 0x09CC;
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
pub const DS4_INPUT_REPORT_LEN: usize = 64;
/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower
/// than the DualSense's 1920×1080.
pub const DS4_TOUCH_W: u16 = 1920;
pub const DS4_TOUCH_H: u16 = 942;
/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's:
/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y).
fn pack_touch(dst: &mut [u8], t: &Touch) {
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
// Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1.
let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1));
dst[1] = (x & 0xFF) as u8;
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
dst[3] = ((y >> 4) & 0xFF) as u8;
}
/// Serialize a full DS4 input report `0x01` (pure — unit-testable without a transport). Field offsets
/// per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch; touch[3];
/// rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp; gyro[3] le16;
/// accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common` field at struct
/// offset N sits at report byte N+1.
pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) {
r[0] = 0x01; // report id
r[1] = st.lx;
r[2] = st.ly;
r[3] = st.rx;
r[4] = st.ry;
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
r[7] = (st.buttons2_with_click() & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click (incl. rich pad clicks) + report counter
r[8] = st.l2; // L2 analog (z)
r[9] = st.r2; // R2 analog (rz)
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
// r[12] temperature stays 0
for (i, v) in st.gyro.iter().enumerate() {
r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12
}
for (i, v) in st.accel.iter().enumerate() {
r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18
}
// r[25..30] reserved2.
// status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report
// wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad.
r[30] = 0x10 | 0x0B;
// r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0.
r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one)
r[34] = ts as u8; // touch_reports[0].timestamp
pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0
pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1
// remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero
}
/// What one feedback pass extracted from the device's HID output reports. Rumble rides the universal
/// 0xCA plane; the lightbar rides the HID-output 0xCD plane as a `Led` event (DS4 has no player LEDs
/// or adaptive triggers, so those never appear).
#[derive(Default)]
pub struct Ds4Feedback {
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
pub rumble: Option<(u16, u16)>,
/// Lightbar RGB, if the report carried it (deduped by the manager).
pub led: Option<(u8, u8, u8)>,
/// Whether a fresh output report was seen this poll (set by the backend's section poll, not by
/// the parser) — the game-activity signal the [`UhidManager`](crate::uhid_manager)
/// abandoned-rumble force-off keys on.
pub fresh: bool,
}
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1],
/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5],
/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write
/// doesn't masquerade as a lightbar change.
pub fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) {
if data.first() != Some(&0x05) || data.len() < 11 {
return; // not the USB output report (BT 0x11 is shifted) / too short
}
let flag0 = data[1];
if flag0 & 0x01 != 0 {
// motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4];
// scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends.
let low = (data[5] as u16) << 8;
let high = (data[4] as u16) << 8;
fb.rumble = Some((low, high));
}
if flag0 & 0x02 != 0 {
fb.led = Some((data[6], data[7], data[8]));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets.
#[test]
fn serialize_offsets() {
use punktfunk_core::input::gamepad as gs;
let mut st = DsState::from_gamepad(
gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB,
16384, // lx (right)
0,
0,
-32768, // ry (down) — inverted to 0xFF
200, // L2
0,
);
st.gyro = [0x0102, 0x0304, 0x0506];
st.accel = [0x1112, 0x1314, 0x1516];
st.touch[0] = Touch {
active: true,
id: 0,
x: 100,
y: 200,
};
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, &st, 0, 0);
assert_eq!(r[0], 0x01); // report id
assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5)
assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up)
assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit
assert_eq!(r[6] & 0x01, 0x01); // L1
// gyro le16 at 13..19, accel le16 at 19..25.
assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]);
assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]);
assert_eq!(r[33], 1); // one touch frame
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
assert_eq!(r[35] & 0x7F, 0); // contact id 0
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
// A rich-plane pad click (`touch_click`, no BTN_TOUCHPAD in the frame) rides the
// touchpad-click bit at byte 7 bit 1 via `buttons2_with_click` — the Linux backend used to
// serialize raw `buttons[2]` here and drop it.
assert_eq!(r[7] & 0x02, 0); // no click yet
st.touch_click[0] = true;
serialize_state(&mut r, &st, 0, 0);
assert_eq!(r[7] & 0x02, 0x02);
}
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
/// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched.
#[test]
fn parse_output_rumble_and_lightbar() {
let mut report = [0u8; 32];
report[0] = 0x05;
report[1] = 0x01 | 0x02; // MOTOR | LED
report[4] = 0x40; // motor_right (weak/high)
report[5] = 0x80; // motor_left (strong/low)
report[6] = 0x11; // R
report[7] = 0x22; // G
report[8] = 0x33; // B
let mut fb = Ds4Feedback::default();
parse_ds4_output(&report, &mut fb);
assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak)
assert_eq!(fb.led, Some((0x11, 0x22, 0x33)));
let mut motor_only = [0u8; 32];
motor_only[0] = 0x05;
motor_only[1] = 0x01; // MOTOR only
motor_only[5] = 0x10;
let mut fb2 = Ds4Feedback::default();
parse_ds4_output(&motor_only, &mut fb2);
assert!(fb2.rumble.is_some());
assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change
}
}
@@ -0,0 +1,952 @@
//! Transport-independent Steam Controller / Steam Deck HID contract — the Steam analogue of
//! [`super::dualsense_proto`]. The report descriptor, the command/feature IDs, the byte-exact
//! Deck input-report serializer, the `XInput`/rich-input → state mappers, and the rumble-feedback
//! parser. Pure logic, shared by the Linux UHID backend and (later) a Windows UMDF backend.
//!
//! **Layout source of truth:** the kernel `drivers/hid/hid-steam.c` `steam_do_deck_input_event`
//! (+ `steam_do_deck_sensors_event`) — every offset/bit/sign below is transcribed verbatim from
//! it and on-box-validated against kernel 7.0 (see `design/steam-controller-deck-support.md`).
//! M0 proved the device binds + parses; M1 (here) makes the serializer byte-exact.
//!
//! Three load-bearing details the DualSense path does NOT have:
//! * **report id 0 / unnumbered**: input reports are the raw 64 bytes starting `[0x01,0x00,0x09]`
//! (no report-id prefix); FEATURE get/set reports DO carry a leading `0x00` report-id byte
//! (`steam_send_report` does `memcpy(buf+1, cmd, …)`, `steam_recv_report` strips `buf[0]`).
//! * **`gamepad_mode` gate**: `steam_do_deck_input_event` early-returns when
//! `!gamepad_mode && lizard_mode` (the module param, default on). `gamepad_mode` starts false
//! and TOGGLES when [`btn::STEAM_MENU_RIGHT`] (`b9.6`, the mode-switch) is held ~450 ms while
//! no hidraw client is open. The backend enters gamepad mode at session start (pulse that bit,
//! or load `hid_steam lizard_mode=0`) — see the backend, not this module.
//! * **the `UHID_SET_REPORT` handshake** must be answered (DualSense omits it).
#![allow(dead_code)] // Some of the full model is consumed only once the M2 backend + M3 wire land.
use punktfunk_core::input::gamepad as gs;
use punktfunk_core::quic::RichInput;
/// Valve. `hid-steam` matches purely by VID/PID over `BUS_USB`.
pub const STEAM_VENDOR: u32 = 0x28DE;
/// Steam Deck built-in controller (same PID on LCD + OLED).
pub const STEAMDECK_PRODUCT: u32 = 0x1205;
/// Classic Steam Controller, wired (report id 1 / `ID_CONTROLLER_STATE`; a later model).
pub const STEAMCTRL_WIRED_PRODUCT: u32 = 0x1102;
/// The Steam HID state/command report is a fixed 64-byte, **unnumbered** (report-id-0) frame.
pub const STEAM_REPORT_LEN: usize = 64;
// Command IDs (drivers/hid/hid-steam.c), confirmed against the kernel source.
pub const ID_CLEAR_DIGITAL_MAPPINGS: u8 = 0x81;
pub const ID_GET_ATTRIBUTES_VALUES: u8 = 0x83;
pub const ID_SET_SETTINGS_VALUES: u8 = 0x87;
pub const ID_LOAD_DEFAULT_SETTINGS: u8 = 0x8E;
pub const ID_GET_DEVICE_INFO: u8 = 0xA1;
pub const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
pub const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
/// Host→client feedback: `steam_haptic_rumble` emits report `[0xEB, 9, …]` (FF_RUMBLE → trackpad
/// actuators / Deck motors). The Deck's rumble path; the classic SC also has `0x8F` pad pulses.
pub const ID_TRIGGER_RUMBLE_CMD: u8 = 0xEB;
pub const ID_TRIGGER_HAPTIC_PULSE: u8 = 0x8F;
/// Input report message types: SC = `ID_CONTROLLER_STATE`, Deck = `ID_CONTROLLER_DECK_STATE`.
pub const ID_CONTROLLER_STATE: u8 = 0x01;
pub const ID_CONTROLLER_DECK_STATE: u8 = 0x09;
/// Which Steam device identity to present. M1 implements the Deck fully; the classic Controller
/// (dual trackpads, report id 1, trackpad-only haptics) is a later identity behind the same path.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SteamModel {
Deck,
Controller,
}
impl SteamModel {
pub fn product(self) -> u32 {
match self {
SteamModel::Deck => STEAMDECK_PRODUCT,
SteamModel::Controller => STEAMCTRL_WIRED_PRODUCT,
}
}
}
/// Minimal vendor-defined HID report descriptor: one application collection with a 64-byte input
/// report and a 64-byte feature report, both UNNUMBERED (report id 0). `hid-steam` is a raw-event
/// driver, so the field layout is cosmetic — but `steam_probe` requires `hid_parse` to succeed AND
/// a non-empty FEATURE report list (`steam_is_valve_interface`), so the feature item is mandatory.
#[rustfmt::skip]
pub const STEAMDECK_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 64-byte state report
0x09, 0x01, // Usage (0x01)
0x95, 0x40, // Report Count (64)
0xB1, 0x02, // Feature (Data,Var,Abs) — makes steam_is_valve_interface() true
0xC0, // End Collection
];
/// Deck button bits, indexed in the `u64` packed across report bytes 8..16 — bit `(byte-8)*8 + bit`,
/// transcribed verbatim from `steam_do_deck_input_event` (bytes 12 + 15 carry no buttons). Naming
/// follows the physical Deck control; the trailing comment is the kernel `BTN_*` it maps to.
pub mod btn {
// byte 8
pub const RT_FULL: u64 = 1 << 0; // BTN_TR2 — right trigger fully pressed
pub const LT_FULL: u64 = 1 << 1; // BTN_TL2 — left trigger fully pressed
pub const RB: u64 = 1 << 2; // BTN_TR — right shoulder
pub const LB: u64 = 1 << 3; // BTN_TL — left shoulder
pub const Y: u64 = 1 << 4;
pub const B: u64 = 1 << 5;
pub const X: u64 = 1 << 6;
pub const A: u64 = 1 << 7;
// byte 9
pub const DPAD_UP: u64 = 1 << 8;
pub const DPAD_RIGHT: u64 = 1 << 9;
pub const DPAD_LEFT: u64 = 1 << 10;
pub const DPAD_DOWN: u64 = 1 << 11;
pub const VIEW: u64 = 1 << 12; // BTN_SELECT — "menu left" (View / Back)
pub const STEAM: u64 = 1 << 13; // BTN_MODE — Steam logo button
pub const MENU: u64 = 1 << 14; // BTN_START — "menu right" (Start / Options)
pub const L5: u64 = 1 << 15; // BTN_GRIPL2 — left BOTTOM back grip
// byte 10
pub const R5: u64 = 1 << 16; // BTN_GRIPR2 — right BOTTOM back grip
pub const LPAD_CLICK: u64 = 1 << 17; // BTN_THUMB — left pad pressed (click)
pub const RPAD_CLICK: u64 = 1 << 18; // BTN_THUMB2 — right pad pressed (click)
pub const LPAD_TOUCH: u64 = 1 << 19; // gates ABS_HAT0 (left pad coords)
pub const RPAD_TOUCH: u64 = 1 << 20; // gates ABS_HAT1 (right pad coords)
pub const L3: u64 = 1 << 22; // BTN_THUMBL — left joystick click
// byte 11
pub const R3: u64 = 1 << 26; // BTN_THUMBR — right joystick click
// byte 13
pub const L4: u64 = 1 << 41; // BTN_GRIPL — left TOP back grip
pub const R4: u64 = 1 << 42; // BTN_GRIPR — right TOP back grip
pub const LJOY_TOUCH: u64 = 1 << 46;
pub const RJOY_TOUCH: u64 = 1 << 47;
// byte 14
pub const QAM: u64 = 1 << 50; // BTN_BASE — quick-access (…) button
/// `b9.6` doubles as the mode-switch: held ~450 ms (no hidraw client) it toggles `gamepad_mode`.
pub const STEAM_MENU_RIGHT: u64 = MENU;
}
/// Full virtual Steam Deck controller state. All analog fields are stored as the RAW little-endian
/// report values the kernel reads (so [`serialize_deck_state`] is a pure memcpy); the kernel applies
/// its own sign conventions on top (`ABS_Y = -raw`, etc.) — see [`SteamState::from_gamepad`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SteamState {
/// Packed button bits (see [`btn`]); occupies report bytes 8..16.
pub buttons: u64,
/// Left / right joystick, raw s16 (report 48/50/52/54). The kernel negates the Y axes.
pub lx: i16,
pub ly: i16,
pub rx: i16,
pub ry: i16,
/// Left / right analog trigger, raw u16 (report 44/46 → ABS_HAT2Y/X).
pub lt: u16,
pub rt: u16,
/// Left / right trackpad position, raw s16, centred 0 (report 16/18/20/22). Only surfaced by
/// the kernel while the matching `*PAD_TOUCH` button bit is set.
pub lpad_x: i16,
pub lpad_y: i16,
pub rpad_x: i16,
pub rpad_y: i16,
pub lpad_pressure: u16,
pub rpad_pressure: u16,
/// IMU, raw s16. `accel`/`gyro` are `[X, Y, Z]`; the kernel maps them to ABS_X/Z/Y + ABS_RX/RZ/RY
/// (with Z/RZ negated) on the separate sensors evdev.
pub accel: [i16; 3],
pub gyro: [i16; 3],
/// Trackpad CLICK from the rich plane ([`apply_rich`]), kept OUTSIDE `buttons` because
/// [`SteamControllerManager::handle`](super::super::linux::steam_controller::SteamControllerManager)
/// rebuilds `buttons` from the gamepad frame every tick — exactly why DualSense keeps
/// `touch_click` separate. Merged into the report's click bits in [`serialize_deck_state`]. The
/// DualSense touchpad-click WIRE button still sets `RPAD_CLICK` in `buttons` via
/// [`from_gamepad`](Self::from_gamepad); the two sources are OR'd at serialize, so each releases
/// independently (a released `BTN_TOUCHPAD` can't strand a rich click, and vice-versa).
pub lpad_click: bool,
pub rpad_click: bool,
}
impl SteamState {
pub fn neutral() -> SteamState {
SteamState::default()
}
/// Set/clear a button (or group) by its [`btn`] mask.
pub fn press(&mut self, mask: u64, down: bool) {
if down {
self.buttons |= mask;
} else {
self.buttons &= !mask;
}
}
/// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck
/// state. Sticks pass through (the kernel negates Y, which yields the conventional direction —
/// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when
/// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire).
pub fn from_gamepad(
buttons: u32,
lx: i16,
ly: i16,
rx: i16,
ry: i16,
lt: u8,
rt: u8,
) -> SteamState {
let on = |bit: u32| buttons & bit != 0;
let mut s = SteamState {
lx,
ly,
rx,
ry,
lt: (lt as u16) * 128,
rt: (rt as u16) * 128,
..SteamState::neutral()
};
let mut b = 0u64;
let set = |b: &mut u64, on: bool, m: u64| {
if on {
*b |= m;
}
};
set(&mut b, on(gs::BTN_A), btn::A);
set(&mut b, on(gs::BTN_B), btn::B);
set(&mut b, on(gs::BTN_X), btn::X);
set(&mut b, on(gs::BTN_Y), btn::Y);
set(&mut b, on(gs::BTN_LB), btn::LB);
set(&mut b, on(gs::BTN_RB), btn::RB);
set(&mut b, lt > 0, btn::LT_FULL);
set(&mut b, rt > 0, btn::RT_FULL);
set(&mut b, on(gs::BTN_BACK), btn::VIEW);
set(&mut b, on(gs::BTN_START), btn::MENU);
set(&mut b, on(gs::BTN_GUIDE), btn::STEAM);
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
set(&mut b, on(gs::BTN_RS_CLICK), btn::R3);
set(&mut b, on(gs::BTN_DPAD_UP), btn::DPAD_UP);
set(&mut b, on(gs::BTN_DPAD_DOWN), btn::DPAD_DOWN);
set(&mut b, on(gs::BTN_DPAD_LEFT), btn::DPAD_LEFT);
set(&mut b, on(gs::BTN_DPAD_RIGHT), btn::DPAD_RIGHT);
// The DualSense touchpad-click wire bit maps to the Deck's RIGHT pad click (the pad that
// stands in for the DualSense touchpad — see apply_rich).
set(&mut b, on(gs::BTN_TOUCHPAD), btn::RPAD_CLICK);
// Back grips (the whole reason for the Deck identity): the wire paddle bits map to the four
// Deck grips — PADDLE1/2/3/4 = R4/L4/R5/L5 (see `input::gamepad`); MISC1 = the QAM '…' button.
set(&mut b, on(gs::BTN_PADDLE1), btn::R4);
set(&mut b, on(gs::BTN_PADDLE2), btn::L4);
set(&mut b, on(gs::BTN_PADDLE3), btn::R5);
set(&mut b, on(gs::BTN_PADDLE4), btn::L5);
set(&mut b, on(gs::BTN_MISC1), btn::QAM);
s.buttons = b;
s
}
/// Apply one rich client→host event into this state, preserving everything else. The single-pad
/// wire [`RichInput::Touchpad`] maps to the **right** trackpad (the Deck pad analogous to the
/// DualSense touchpad); the left pad arrives via the M3 `TouchpadEx` surface. [`RichInput::Motion`]
/// passes gyro/accel straight through (raw i16; cross-device unit scaling is M3).
///
/// The wire's touch coordinates are SCREEN convention — +y DOWN, what SDL/Windows/Android
/// capture APIs all produce — but the Deck's raw trackpad fields are stick convention
/// (+y UP, centre origin), and Steam Input parses our report as real Deck hardware. Y is
/// therefore negated here, on the device boundary; leaving it through was the "both
/// trackpads inverted" bug the first live Deck-to-Deck session surfaced (2026-07-08).
pub fn apply_rich(&mut self, rich: RichInput) {
/// Screen-convention (+down) wire Y → Deck raw (+up), saturating (-32768 has no i16 negation).
fn flip_y(y: i16) -> i16 {
(y as i32).saturating_neg().clamp(-32768, 32767) as i16
}
match rich {
RichInput::Touchpad { active, x, y, .. } => {
self.press(btn::RPAD_TOUCH, active);
// Normalized 0..=65535 (centre 32768, +y down) → the pad's centred s16 range (+y up).
self.rpad_x = ((x as i32) - 32768) as i16;
self.rpad_y = (32768 - (y as i32)).min(32767) as i16;
}
RichInput::Motion { gyro, accel, .. } => {
// The wire carries DualSense-convention units (what every client capture emits); the
// Deck's hid-steam report wants 16 LSB/°·s + 16384 LSB/g, so rescale here.
let (g, a) = super::steam_remap::motion_wire_to_deck(gyro, accel);
self.gyro = g;
self.accel = a;
}
RichInput::TouchpadEx {
surface,
touch,
click,
x,
y,
..
} => {
// Signed centre-0 x maps straight in; y flips to the Deck's +up. surface 1 =
// left pad, anything else (0 single / 2 right) = right pad.
if surface == 1 {
self.press(btn::LPAD_TOUCH, touch);
// Click lives in its own field, NOT `buttons` — `handle()` rebuilds `buttons`
// every gamepad frame and would otherwise wipe a held click (the bug this fixes).
self.lpad_click = click;
self.lpad_x = x;
self.lpad_y = flip_y(y);
} else {
self.press(btn::RPAD_TOUCH, touch);
self.rpad_click = click;
self.rpad_x = x;
self.rpad_y = flip_y(y);
}
}
// Raw as-is passthrough reports belong to the Triton backend, never a Deck/SC state.
RichInput::HidReport { .. } => {}
}
}
}
/// Serialize the full Deck input report (`ID_CONTROLLER_DECK_STATE`) into the 64-byte unnumbered
/// frame `hid-steam` parses. Pure + byte-exact against `steam_do_deck_input_event`; the report-id
/// constant is `data[0]=0x01` (NOT a HID report id — this report is unnumbered).
pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) {
r.fill(0);
r[0] = 0x01;
r[1] = 0x00;
r[2] = ID_CONTROLLER_DECK_STATE;
r[3] = 0x3C; // payload length; the kernel ignores it
r[4..8].copy_from_slice(&seq.to_le_bytes());
// Rich-plane trackpad clicks live in their own fields (see `SteamState`) so a button-only frame
// can't wipe them; merge them into the report's click bits here. RPAD_CLICK may ALSO come from
// the DualSense touchpad-click wire button via `from_gamepad` — OR both, so either source lights
// it and each releases independently.
let mut buttons = st.buttons;
if st.lpad_click {
buttons |= btn::LPAD_CLICK;
}
if st.rpad_click {
buttons |= btn::RPAD_CLICK;
}
r[8..16].copy_from_slice(&buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0)
r[16..18].copy_from_slice(&st.lpad_x.to_le_bytes());
r[18..20].copy_from_slice(&st.lpad_y.to_le_bytes());
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
r[22..24].copy_from_slice(&st.rpad_y.to_le_bytes());
r[24..26].copy_from_slice(&st.accel[0].to_le_bytes()); // accel X → IMU ABS_X
r[26..28].copy_from_slice(&st.accel[1].to_le_bytes()); // accel Y → IMU ABS_Z (kernel negates)
r[28..30].copy_from_slice(&st.accel[2].to_le_bytes()); // accel Z → IMU ABS_Y
r[30..32].copy_from_slice(&st.gyro[0].to_le_bytes()); // gyro X → IMU ABS_RX
r[32..34].copy_from_slice(&st.gyro[1].to_le_bytes()); // gyro Y → IMU ABS_RZ (kernel negates)
r[34..36].copy_from_slice(&st.gyro[2].to_le_bytes()); // gyro Z → IMU ABS_RY
// 36..44 quaternion — left 0 (optional; the kernel does not surface it)
r[44..46].copy_from_slice(&st.lt.to_le_bytes()); // left trigger → ABS_HAT2Y
r[46..48].copy_from_slice(&st.rt.to_le_bytes()); // right trigger → ABS_HAT2X
r[48..50].copy_from_slice(&st.lx.to_le_bytes()); // left joystick X → ABS_X
r[50..52].copy_from_slice(&st.ly.to_le_bytes()); // left joystick Y → ABS_Y (kernel negates)
r[52..54].copy_from_slice(&st.rx.to_le_bytes()); // right joystick X → ABS_RX
r[54..56].copy_from_slice(&st.ry.to_le_bytes()); // right joystick Y → ABS_RY (kernel negates)
r[56..58].copy_from_slice(&st.lpad_pressure.to_le_bytes());
r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes());
}
/// Map an `XInput`/GameStream pad frame into **classic Steam Controller** state. The SC's 24-bit
/// button field (report bytes 8..10) shares its low-bit layout with the Deck's (face/shoulder/
/// trigger-full byte 8; dpad/View/Steam/Menu byte 9 bits 06), so this reuses the [`btn`] masks —
/// with the SC-specific tail per the kernel's `ID_CONTROLLER_STATE` table:
/// - `9.7`/`10.0` are the SC's TWO grips (the bit positions the Deck calls L5/R5): wire
/// `BTN_PADDLE2`/`BTN_PADDLE1` (L4/R4, the primary pair) land there; fold PADDLE3/4 via
/// [`super::steam_remap`] BEFORE calling this.
/// - `10.2` = right-pad clicked (the SC has no right stick): wire `BTN_RS_CLICK` and the
/// DualSense `BTN_TOUCHPAD` click both land there.
/// - `10.6` = joystick clicked = wire `BTN_LS_CLICK` (the same bit the Deck calls L3).
/// - No QAM/misc slot — `BTN_MISC1` is dropped (fold it upstream if a policy wants it).
///
/// The wire right STICK drives the right-pad coordinates (`rpad_x/y` + the `10.4` touched bit
/// while deflected) — the SC's camera surface; the loss of a true second stick is inherent to
/// the hardware. The left stick rides the joystick fields; a left-pad `TouchpadEx` contact
/// (via [`SteamState::apply_rich`]) SHADOWS the joystick while touched (the report multiplexes
/// them at bytes 16..20, exactly like real hardware's `lpad_touched` flag).
pub fn sc_from_gamepad(
buttons: u32,
lx: i16,
ly: i16,
rx: i16,
ry: i16,
lt: u8,
rt: u8,
) -> SteamState {
let on = |bit: u32| buttons & bit != 0;
let mut s = SteamState {
lx,
ly,
rx: 0,
ry: 0,
lt: (lt as u16) * 128,
rt: (rt as u16) * 128,
// The wire right stick becomes a right-pad contact (see the doc above).
rpad_x: rx,
rpad_y: ry,
..SteamState::neutral()
};
let mut b = 0u64;
let set = |b: &mut u64, on: bool, m: u64| {
if on {
*b |= m;
}
};
set(&mut b, on(gs::BTN_A), btn::A);
set(&mut b, on(gs::BTN_B), btn::B);
set(&mut b, on(gs::BTN_X), btn::X);
set(&mut b, on(gs::BTN_Y), btn::Y);
set(&mut b, on(gs::BTN_LB), btn::LB);
set(&mut b, on(gs::BTN_RB), btn::RB);
set(&mut b, lt > 0, btn::LT_FULL);
set(&mut b, rt > 0, btn::RT_FULL);
set(&mut b, on(gs::BTN_BACK), btn::VIEW);
set(&mut b, on(gs::BTN_START), btn::MENU);
set(&mut b, on(gs::BTN_GUIDE), btn::STEAM);
set(&mut b, on(gs::BTN_DPAD_UP), btn::DPAD_UP);
set(&mut b, on(gs::BTN_DPAD_DOWN), btn::DPAD_DOWN);
set(&mut b, on(gs::BTN_DPAD_LEFT), btn::DPAD_LEFT);
set(&mut b, on(gs::BTN_DPAD_RIGHT), btn::DPAD_RIGHT);
// SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4.
set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip
set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
set(
&mut b,
on(gs::BTN_RS_CLICK) || on(gs::BTN_TOUCHPAD),
btn::RPAD_CLICK,
);
// Right-pad touched (10.4) while the wire stick is deflected — the coords are live then.
set(&mut b, rx != 0 || ry != 0, btn::RPAD_TOUCH);
s.buttons = b;
s
}
/// Serialize the classic Steam Controller input report (`ID_CONTROLLER_STATE`) into the 64-byte
/// unnumbered frame `steam_do_input_event` parses. Byte-exact against the kernel's message
/// table: 24-bit buttons at 8..11, **u8** triggers at 11/12 (the Deck uses u16 at 44/46),
/// the joystick/left-pad MULTIPLEX at 16..20 (left-pad coords shadow the joystick while the
/// `10.3` touched bit is set), the right pad at 20..24, and the (kernel-ignored, hidraw-visible)
/// accel/gyro at 28..39. The kernel negates both Y axes on top of these raw values.
pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) {
r.fill(0);
r[0] = 0x01;
r[1] = 0x00;
r[2] = ID_CONTROLLER_STATE;
r[3] = 0x3C;
r[4..8].copy_from_slice(&seq.to_le_bytes());
// Rich-plane pad clicks merge like the Deck path: left-pad clicked = 10.1 (hidraw-only —
// the kernel maps no key to it), right-pad clicked = 10.2.
let mut buttons = st.buttons;
if st.lpad_click {
buttons |= btn::LPAD_CLICK;
}
if st.rpad_click {
buttons |= btn::RPAD_CLICK;
}
r[8] = (buttons & 0xFF) as u8;
r[9] = ((buttons >> 8) & 0xFF) as u8;
r[10] = ((buttons >> 16) & 0xFF) as u8;
r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8
r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
(st.lpad_x, st.lpad_y)
} else {
(st.lx, st.ly)
};
r[16..18].copy_from_slice(&x.to_le_bytes());
r[18..20].copy_from_slice(&y.to_le_bytes());
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
r[22..24].copy_from_slice(&st.rpad_y.to_le_bytes());
// IMU: present in the frame (28..39) for hidraw readers, but the kernel maps none of it
// ("accelerator/gyro is disabled by default" — no sensors evdev for the SC).
r[28..30].copy_from_slice(&st.accel[0].to_le_bytes());
r[30..32].copy_from_slice(&st.accel[1].to_le_bytes());
r[32..34].copy_from_slice(&st.accel[2].to_le_bytes());
r[34..36].copy_from_slice(&st.gyro[0].to_le_bytes());
r[36..38].copy_from_slice(&st.gyro[1].to_le_bytes());
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
}
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
/// `1<=reply[1]<=21`, `reply[2]==0x01`. Non-fatal (a bad reply → the `"XXXXXXXXXX"` fallback).
pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] {
let mut buf = [0u8; STEAM_REPORT_LEN];
let bytes = serial.as_bytes();
let len = bytes.len().clamp(1, 21);
buf[0] = 0x00; // report id 0 — stripped by steam_recv_report
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
}
/// One service pass's extracted feedback. Rumble rides the universal 0xCA plane (so any client
/// feels it); the classic SC's trackpad-pulse haptics (`0x8F`) are a later, model-specific add.
#[derive(Default, Debug, PartialEq, Eq)]
pub struct SteamFeedback {
/// `(low, high)` motor levels (left/strong, right/weak), if a rumble report carried them.
pub rumble: Option<(u16, u16)>,
}
/// Parse a feature/output report the kernel wrote to our device. The Steam feedback path is a
/// FEATURE `SET_REPORT` whose wire data is `[0x00 report-id, cmd, len, …]`; `cmd == 0xEB`
/// (`steam_haptic_rumble`) carries `[…, 0, intensity(2), left_speed(2), right_speed(2), gains(2)]`.
/// We surface `(left_speed, right_speed)` as `(low, high)` for the 0xCA rumble plane.
pub fn parse_steam_output(data: &[u8]) -> SteamFeedback {
let mut fb = SteamFeedback::default();
// data[0] is the stripped report-id byte (0); the command id follows.
if data.len() >= 10 && data[1] == ID_TRIGGER_RUMBLE_CMD {
let le = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]);
let left = le(6); // left_speed (report[5..7]) → low / strong motor
let right = le(8); // right_speed (report[7..9]) → high / weak motor
fb.rumble = Some((left, right));
}
fb
}
// ===========================================================================================
// Real-USB Deck device contract (the gadget + usbip transports present a *real* 3-interface USB
// Deck so Steam Input promotes it; the UHID path above uses the minimal [`STEAMDECK_RDESC`]).
//
// These descriptors are captured verbatim from a physical Steam Deck (28DE:1205): mouse =
// interface 0, keyboard = interface 1, **controller = interface 2** (the interface number Steam's
// own driver filters on — the reason a UHID Deck, `Interface: -1`, is never promoted). The
// `0x83`/`0xAE` feature contract is what stops Steam re-probing (the gamepad-evdev churn). Shared
// by [`super::super::steam_gadget`] (raw_gadget) and [`super::super::steam_usbip`] (usbip/vhci).
// ===========================================================================================
/// Captured Deck **mouse** report descriptor (interface 0, EP 0x81).
#[rustfmt::skip]
pub const RDESC_DECK_MOUSE: &[u8] = &[
0x05,0x01,0x09,0x02,0xa1,0x01,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];
/// Captured Deck **keyboard** (boot) report descriptor (interface 1, EP 0x82).
#[rustfmt::skip]
pub const RDESC_DECK_KBD: &[u8] = &[
0x05,0x01,0x09,0x06,0xa1,0x01,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];
/// Captured Deck **controller** report descriptor (interface 2, EP 0x83; Usage Page `0xFFFF`,
/// `bCountryCode 33`). The vendor-defined report the `hid-steam` driver binds.
#[rustfmt::skip]
pub const RDESC_DECK_CTRL: &[u8] = &[
0x06,0xff,0xff,0x09,0x01,0xa1,0x01,0x09,0x02,0x09,0x03,0x15,0x00,0x26,0xff,0x00,
0x75,0x08,0x95,0x40,0x81,0x02,0x09,0x06,0x09,0x07,0x15,0x00,0x26,0xff,0x00,0x75,
0x08,0x95,0x40,0xb1,0x02,0xc0];
/// Per-instance Deck unit id stamped into the `0x83` GET_ATTRIBUTES device-id attrs (`0x0a`/`0x04`)
/// so a virtual Deck never collides with a real one or another instance. `"PF"` high word + index.
pub fn deck_unit_id(index: u8) -> u32 {
0x5046_0000 | index as u32
}
/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`). Steam
/// validates the serial's FORMAT before accepting it: a `"PF"`-leading serial is REJECTED
/// ("Invalid or missing unit serial number …") and Steam then substitutes a hash AND mangles the
/// displayed controller name (observed as "Steam Deck Controllerggg" on Windows). An `'F'`-leading
/// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a
/// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check.
/// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-dualsense lib.rs.)
pub fn deck_serial(index: u8) -> String {
format!("FVPF{:08X}", deck_unit_id(index))
}
/// The neutral 64-byte Deck input report (header only, all controls released) — the report the
/// real-USB transports stream until the first [`serialize_deck_state`] call updates it.
pub fn neutral_deck_report() -> [u8; STEAM_REPORT_LEN] {
let mut r = [0u8; STEAM_REPORT_LEN];
r[0] = 0x01;
r[2] = ID_CONTROLLER_DECK_STATE;
r[3] = 0x3C;
r
}
/// Build the HID feature GET_REPORT reply for the host's last SET_REPORT command, for the *real-USB*
/// Deck (gadget + usbip). Steam's `GetControllerInfo` reads the `0x83` attributes + the `0xAE`
/// serial; **serving the real `0x83` blob is what stops Steam re-probing** (the gamepad-evdev churn).
/// The 9-attribute `0x83` layout + the `0xAE` string format were captured from a physical Deck via
/// hidraw. `unit_id` (see [`deck_unit_id`]) stamps a per-instance value into the device-id attrs.
///
/// Note this is the raw 64-byte EP0 feature payload (command id first, no report-id prefix) — the USB
/// control path, distinct from [`serial_reply`] which carries the UHID report-id byte the kernel
/// strips.
pub fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; STEAM_REPORT_LEN] {
let cmd = last_set.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE);
let mut r = [0u8; STEAM_REPORT_LEN];
match cmd {
ID_GET_ATTRIBUTES_VALUES => {
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9× (attr-id, value u32-LE)].
r[0] = ID_GET_ATTRIBUTES_VALUES;
r[1] = 0x2d;
let attrs: [(u8, u32); 9] = [
(0x01, 0x1205), // product id
(0x02, 0),
(0x0a, unit_id), // unit serial number (per-instance)
(0x04, unit_id ^ 0x5555_5555),
(0x09, 0x2e),
(0x0b, 0x0fa0),
(0x0d, 0),
(0x0c, 0),
(0x0e, 0),
];
let mut o = 2;
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 => {
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. The kernel validates the serial (attr
// 0x01) wants reply[2]==0x01 and 1<=len<=21; for other attrs we echo the requested id.
let attr = last_set.get(2).copied().unwrap_or(ATTRIB_STR_UNIT_SERIAL);
let b = serial.as_bytes();
let len = b.len().clamp(1, 20);
r[0] = ID_GET_STRING_ATTRIBUTE;
r[1] = len as u8;
r[2] = attr;
r[3..3 + len].copy_from_slice(&b[..len]);
}
_ => {
// Settings read-back (e.g. 0x87): echo the host's last command + data.
let n = last_set.len().min(STEAM_REPORT_LEN);
r[..n].copy_from_slice(&last_set[..n]);
}
}
r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn descriptor_declares_input_and_feature_reports() {
assert!(
STEAMDECK_RDESC.contains(&0xB1),
"missing Feature main item — steam_is_valve_interface() would fail"
);
assert!(STEAMDECK_RDESC.contains(&0x81), "missing Input main item");
assert_eq!(
*STEAMDECK_RDESC.last().unwrap(),
0xC0,
"unterminated collection"
);
}
/// Every analog field lands at the exact offset `steam_do_deck_input_event` reads, the header is
/// what `steam_raw_event` requires, and the buttons pack into bytes 8..16 (12+15 zero). A
/// one-byte slip here turns the whole controller into noise.
#[test]
fn serialize_is_byte_exact() {
let mut st = SteamState::neutral();
st.buttons = btn::A | btn::L4 | btn::R5 | btn::QAM;
st.lx = 0x1122;
st.ly = 0x3344;
st.rx = 0x5566;
st.ry = 0x778;
st.lt = 0xABCD;
st.rt = 0xEF01;
st.lpad_x = 0x0A0B;
st.lpad_y = 0x0C0D;
st.rpad_x = 0x0E0F;
st.rpad_y = 0x1011;
st.accel = [0x0102, 0x0304, 0x0506];
st.gyro = [0x0708, 0x090A, 0x0B0C];
st.lpad_pressure = 0x1314;
st.rpad_pressure = 0x1516;
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_deck_state(&mut r, &st, 0xAABB_CCDD);
assert_eq!(&r[0..4], &[0x01, 0x00, 0x09, 0x3C]);
assert_eq!(&r[4..8], &[0xDD, 0xCC, 0xBB, 0xAA]); // seq LE
// buttons: A=bit7 (byte8), L4=bit41 (byte13.1), R5=bit16 (byte10.0), QAM=bit50 (byte14.2).
assert_eq!(r[8], 0x80); // A
assert_eq!(r[10], 0x01); // R5
assert_eq!(r[12], 0x00); // unused button byte
assert_eq!(r[13], 0x02); // L4 (bit 1)
assert_eq!(r[14], 0x04); // QAM (bit 2)
assert_eq!(r[15], 0x00); // unused button byte
assert_eq!(&r[16..18], &0x0A0Bi16.to_le_bytes()); // lpad X
assert_eq!(&r[20..22], &0x0E0Fi16.to_le_bytes()); // rpad X
assert_eq!(&r[24..26], &0x0102i16.to_le_bytes()); // accel X
assert_eq!(&r[26..28], &0x0304i16.to_le_bytes()); // accel Y
assert_eq!(&r[28..30], &0x0506i16.to_le_bytes()); // accel Z
assert_eq!(&r[30..32], &0x0708i16.to_le_bytes()); // gyro X
assert_eq!(&r[44..46], &0xABCDu16.to_le_bytes()); // left trigger
assert_eq!(&r[46..48], &0xEF01u16.to_le_bytes()); // right trigger
assert_eq!(&r[48..50], &0x1122i16.to_le_bytes()); // left joy X
assert_eq!(&r[50..52], &0x3344i16.to_le_bytes()); // left joy Y
assert_eq!(&r[52..54], &0x5566i16.to_le_bytes()); // right joy X
assert_eq!(&r[56..58], &0x1314u16.to_le_bytes()); // left pad pressure
assert_eq!(&r[58..60], &0x1516u16.to_le_bytes()); // right pad pressure
}
/// `from_gamepad` sets the right Deck bits + scales triggers, and a touched flag is merged when
/// a trackpad contact arrives via `apply_rich`.
#[test]
fn from_gamepad_and_rich_mapping() {
let s = SteamState::from_gamepad(
gs::BTN_A | gs::BTN_START | gs::BTN_GUIDE | gs::BTN_LB,
1000,
-2000,
0,
0,
255,
0,
);
assert_ne!(s.buttons & btn::A, 0);
assert_ne!(s.buttons & btn::MENU, 0);
assert_ne!(s.buttons & btn::STEAM, 0);
assert_ne!(s.buttons & btn::LB, 0);
assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit
assert_eq!(s.lt, 255 * 128);
assert_eq!(s.lx, 1000);
assert_eq!(s.ly, -2000);
let mut s = SteamState::neutral();
s.apply_rich(RichInput::Touchpad {
pad: 0,
finger: 0,
active: true,
x: 65535,
y: 0,
});
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
assert_eq!(s.rpad_x, 32767); // 65535-32768
assert_eq!(s.rpad_y, 32767); // wire y=0 = TOP (screen conv) → Deck raw +up (clamped)
// Motion is rescaled from the wire (DualSense) convention into Deck units (gyro ×16/20,
// accel ×16384/10000) — see steam_remap::motion_wire_to_deck.
s.apply_rich(RichInput::Motion {
pad: 0,
gyro: [1000, -2000, 0],
accel: [10000, -5000, 0],
});
assert_eq!(s.gyro, [800, -1600, 0]);
assert_eq!(s.accel, [16384, -8192, 0]);
}
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
/// left / right surfaces to the matching pad (x passes straight through; y flips from the
/// wire's screen convention (+down) to the Deck's raw +up — the live-verified direction).
#[test]
fn back_buttons_and_dual_trackpad_mapping() {
let s = SteamState::from_gamepad(
gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_MISC1,
0,
0,
0,
0,
0,
0,
);
assert_ne!(s.buttons & btn::R4, 0); // PADDLE1 = R4
assert_ne!(s.buttons & btn::L4, 0); // PADDLE2 = L4
assert_ne!(s.buttons & btn::R5, 0); // PADDLE3 = R5
assert_ne!(s.buttons & btn::L5, 0); // PADDLE4 = L5
assert_ne!(s.buttons & btn::QAM, 0); // MISC1 = QAM
let mut s = SteamState::neutral();
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: true,
x: -5000,
y: 6000,
pressure: 100,
});
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0);
// Click now rides its own field (kept OUT of `buttons`, which handle() rebuilds each frame).
assert!(s.lpad_click);
assert_eq!(s.buttons & btn::LPAD_CLICK, 0);
assert_eq!((s.lpad_x, s.lpad_y), (-5000, -6000));
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 2,
finger: 0,
touch: true,
click: false,
x: 7000,
y: -8000,
pressure: 0,
});
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
assert!(!s.rpad_click); // click:false → field cleared
assert_eq!((s.rpad_x, s.rpad_y), (7000, 8000));
// The i16 edge: wire y = -32768 (top-most) must clamp, not overflow.
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 2,
finger: 0,
touch: true,
click: false,
x: 0,
y: -32768,
pressure: 0,
});
assert_eq!(s.rpad_y, 32767);
}
/// Regression (G2): a held trackpad click set on the rich plane must survive the per-frame
/// `buttons` rebuild that `SteamControllerManager::handle` performs via `from_gamepad`. Before
/// the fix, click lived in `buttons` and the rebuild wiped it every gamepad frame.
#[test]
fn rich_click_survives_a_buttons_rebuild() {
let mut held = SteamState::neutral();
held.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: true,
x: 0,
y: 0,
pressure: 0,
});
assert!(held.lpad_click);
// A following button-only frame: from_gamepad rebuilds buttons (dropping the click bit),
// then handle() carries the rich fields over — the click must still reach the report.
let mut merged = SteamState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
assert_eq!(merged.buttons & btn::LPAD_CLICK, 0); // the rebuild alone loses it (the old bug)
merged.lpad_click = held.lpad_click; // what handle() now preserves
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_deck_state(&mut r, &merged, 0);
let serialized = u64::from_le_bytes(r[8..16].try_into().unwrap());
assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild
}
/// The classic-SC frame, byte-exact against the kernel's `ID_CONTROLLER_STATE` table: 24-bit
/// buttons at 8..11, u8 triggers at 11/12, the joystick/left-pad multiplex at 16..20, right
/// pad at 20..24 — and the SC-specific button tail (grips at 9.7/10.0, right-pad click at
/// 10.2, joystick click at 10.6).
#[test]
fn sc_serialize_and_mapping() {
// Full mapping: face + grips + clicks + a deflected right stick.
let s = sc_from_gamepad(
gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK,
1000,
-2000,
3000,
-4000,
255,
0,
);
assert_ne!(s.buttons & btn::A, 0);
assert_ne!(s.buttons & btn::R5, 0); // PADDLE1 → right grip (10.0)
assert_ne!(s.buttons & btn::L5, 0); // PADDLE2 → left grip (9.7)
assert_ne!(s.buttons & btn::L3, 0); // LS click → joystick clicked (10.6)
assert_ne!(s.buttons & btn::RPAD_CLICK, 0); // RS click → right-pad clicked (10.2)
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0); // deflected stick = touched pad (10.4)
assert_eq!((s.rpad_x, s.rpad_y), (3000, -4000)); // right stick rides the right pad
assert_eq!((s.rx, s.ry), (0, 0));
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_sc_state(&mut r, &s, 0x0102_0304);
assert_eq!(&r[0..4], &[0x01, 0x00, 0x01, 0x3C]); // ID_CONTROLLER_STATE
assert_eq!(&r[4..8], &[0x04, 0x03, 0x02, 0x01]);
assert_eq!(r[8] & 0x80, 0x80); // A = 8.7
assert_eq!(r[9] & 0x80, 0x80); // left grip = 9.7
assert_eq!(r[10] & 0x01, 0x01); // right grip = 10.0
assert_eq!(r[10] & 0x04, 0x04); // right-pad clicked = 10.2
assert_eq!(r[10] & 0x40, 0x40); // joystick clicked = 10.6
assert_eq!(r[11], 255); // left trigger u8
assert_eq!(r[12], 0); // right trigger u8
assert_eq!(&r[16..18], &1000i16.to_le_bytes()); // joystick X (lpad untouched)
assert_eq!(&r[18..20], &(-2000i16).to_le_bytes());
assert_eq!(&r[20..22], &3000i16.to_le_bytes()); // right pad X
assert_eq!(&r[22..24], &(-4000i16).to_le_bytes());
// Left-pad multiplex: a TouchpadEx surface-1 contact shadows the joystick at 16..20
// and sets the 10.3 touched bit (+ the 10.1 click bit from the rich field).
let mut s = sc_from_gamepad(0, 1234, 0, 0, 0, 0, 0);
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: true,
x: -5000,
y: 6000,
pressure: 0,
});
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_sc_state(&mut r, &s, 0);
assert_eq!(r[10] & 0x08, 0x08); // left-pad touched = 10.3
assert_eq!(r[10] & 0x02, 0x02); // left-pad clicked = 10.1 (rich click merged)
assert_eq!(&r[16..18], &(-5000i16).to_le_bytes()); // lpad coords shadow the joystick
assert_eq!(&r[18..20], &(-6000i16).to_le_bytes()); // screen +down → raw +up (flip)
}
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
#[test]
fn serial_reply_has_stripped_prefix() {
let r = serial_reply("PUNKTFUNK01");
assert_eq!(r[0], 0x00); // report id, stripped by steam_recv_report
assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE); // becomes reply[0] after strip
assert!((1..=21).contains(&r[2]));
assert_eq!(r[3], ATTRIB_STR_UNIT_SERIAL);
assert_eq!(&r[4..4 + r[2] as usize], b"PUNKTFUNK01");
}
/// A `0xEB` rumble feature report parses to `(left_speed, right_speed)`; other commands don't.
#[test]
fn parse_rumble_feedback() {
// [report-id 0, 0xEB, len 9, 0, intensity(2), left(2), right(2), gains(2)]
let mut d = vec![0u8; 12];
d[1] = ID_TRIGGER_RUMBLE_CMD;
d[2] = 9;
d[6..8].copy_from_slice(&0x8000u16.to_le_bytes()); // left_speed
d[8..10].copy_from_slice(&0x4000u16.to_le_bytes()); // right_speed
assert_eq!(parse_steam_output(&d).rumble, Some((0x8000, 0x4000)));
let mut d = vec![0u8; 12];
d[1] = ID_SET_SETTINGS_VALUES; // a settings write — no rumble
assert_eq!(parse_steam_output(&d).rumble, None);
}
/// The shared real-USB Deck feature contract (gadget + usbip): the `0x83` GET_ATTRIBUTES reply
/// carries the 9-attribute blob with the per-instance unit id, and the `0xAE` reply carries the
/// Steam-accepted serial — both keyed off the host's last SET_REPORT command. A slip here is the
/// gamepad-evdev churn (Steam re-probing).
#[test]
fn deck_feature_reply_contract() {
let serial = deck_serial(0);
let unit_id = deck_unit_id(0);
assert_eq!(serial, "FVPF50460000"); // 12-char alphanumeric, derived from the unit id
assert_eq!(serial.len(), 12);
// 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot.
let r = feature_reply(&[ID_GET_ATTRIBUTES_VALUES], &serial, unit_id);
assert_eq!(r[0], ID_GET_ATTRIBUTES_VALUES);
assert_eq!(r[1], 0x2d);
assert_eq!(r[12], 0x0a); // 3rd attr id (slots at 2,7,12,…)
assert_eq!(
u32::from_le_bytes([r[13], r[14], r[15], r[16]]),
unit_id,
"unit serial attribute must carry the per-instance unit id"
);
// 0xAE GET_STRING_ATTRIBUTE: [0xAE, len, attr(0x01), ascii serial…].
let r = feature_reply(
&[ID_GET_STRING_ATTRIBUTE, 0, ATTRIB_STR_UNIT_SERIAL],
&serial,
unit_id,
);
assert_eq!(r[0], ID_GET_STRING_ATTRIBUTE);
assert_eq!(r[1] as usize, serial.len());
assert_eq!(r[2], ATTRIB_STR_UNIT_SERIAL);
assert_eq!(&r[3..3 + serial.len()], serial.as_bytes());
// Distinct pad indices get distinct unit ids + serials (no collision between virtual Decks).
assert_ne!(deck_unit_id(0), deck_unit_id(1));
assert_ne!(deck_serial(0), deck_serial(1));
}
}
@@ -0,0 +1,149 @@
//! Pure fallback-remap policy for the Steam Controller / Steam Deck rich inputs when the resolved
//! host backend is **not** the virtual `hid-steam` device (DualSense / DualShock 4 / Xbox), so a
//! client's Steam-only inputs aren't silently dropped — plus the cross-device motion rescale the
//! Deck backend itself needs.
//!
//! Driven by the host's `PUNKTFUNK_STEAM_REMAP` env (`key=value`, `,`/`;`-separated, e.g.
//! `paddles=stickclicks`). No I/O beyond [`RemapConfig::from_env`]; everything else is pure +
//! unit-testable. The uinput Xbox pad already exposes the back grips as Elite paddles
//! (`BTN_TRIGGER_HAPPY5-8`), so only the slot-less DualSense / DS4 backends fold them.
use punktfunk_core::input::gamepad as gs;
/// Where the four Steam back grips go on a backend with no native back-button HID slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PaddleFallback {
/// Drop them — the back buttons are simply absent on this pad. The honest default: don't fire
/// buttons the user didn't ask for. Set the env to map them instead.
#[default]
Drop,
/// L4/L5 → left-stick click, R4/R5 → right-stick click.
StickClicks,
/// L4/L5 → left bumper, R4/R5 → right bumper.
Shoulders,
}
/// Fallback-remap knobs parsed from `PUNKTFUNK_STEAM_REMAP`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RemapConfig {
pub paddles: PaddleFallback,
}
impl RemapConfig {
/// Parse the host's `PUNKTFUNK_STEAM_REMAP` env (absent / unrecognized → defaults).
pub fn from_env() -> RemapConfig {
std::env::var("PUNKTFUNK_STEAM_REMAP")
.map(|s| RemapConfig::parse(&s))
.unwrap_or_default()
}
/// Pure parse of the `key=value[,key=value…]` string (the testable core of [`from_env`]).
pub fn parse(s: &str) -> RemapConfig {
let mut cfg = RemapConfig::default();
for kv in s.split([',', ';']) {
let mut it = kv.splitn(2, '=');
if let (Some(k), Some(v)) = (it.next(), it.next()) {
if k.trim().eq_ignore_ascii_case("paddles") {
cfg.paddles = match v.trim().to_ascii_lowercase().as_str() {
"stickclicks" | "l3r3" | "sticks" => PaddleFallback::StickClicks,
"shoulders" | "lbrb" | "bumpers" => PaddleFallback::Shoulders,
_ => PaddleFallback::Drop,
};
}
}
}
cfg
}
}
/// Fold the wire back-grip bits (`BTN_PADDLE1..4`) into standard buttons per `policy` for a pad with
/// no native back-button slot, clearing the paddle bits. Pure. PADDLE1/2/3/4 = R4/L4/R5/L5.
pub fn fold_paddles(mut buttons: u32, policy: PaddleFallback) -> u32 {
let left = buttons & (gs::BTN_PADDLE2 | gs::BTN_PADDLE4) != 0; // L4 | L5
let right = buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE3) != 0; // R4 | R5
buttons &= !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4);
let (lbit, rbit) = match policy {
PaddleFallback::Drop => return buttons,
PaddleFallback::StickClicks => (gs::BTN_LS_CLICK, gs::BTN_RS_CLICK),
PaddleFallback::Shoulders => (gs::BTN_LB, gs::BTN_RB),
};
if left {
buttons |= lbit;
}
if right {
buttons |= rbit;
}
buttons
}
// Motion rescale. The wire uses the DualSense convention (20 LSB/°·s gyro, 10000 LSB/g accel — the
// scale every client capture applies). The Steam Deck's `hid-steam` report wants 16 LSB/°·s and
// 16384 LSB/g, so the Deck backend rescales; the DualSense / DS4 backends consume the wire 1:1.
const GYRO_NUM: i32 = 16;
const GYRO_DEN: i32 = 20;
const ACCEL_NUM: i32 = 16384;
const ACCEL_DEN: i32 = 10000;
fn scale(v: i16, num: i32, den: i32) -> i16 {
((v as i32 * num) / den).clamp(i16::MIN as i32, i16::MAX as i32) as i16
}
/// Rescale a wire (DualSense-convention) motion sample into the Steam Deck's `hid-steam` units.
pub fn motion_wire_to_deck(gyro: [i16; 3], accel: [i16; 3]) -> ([i16; 3], [i16; 3]) {
(
gyro.map(|g| scale(g, GYRO_NUM, GYRO_DEN)),
accel.map(|a| scale(a, ACCEL_NUM, ACCEL_DEN)),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_paddle_policy() {
assert_eq!(RemapConfig::parse("").paddles, PaddleFallback::Drop);
assert_eq!(
RemapConfig::parse("paddles=stickclicks").paddles,
PaddleFallback::StickClicks
);
assert_eq!(
RemapConfig::parse("foo=bar; paddles = Shoulders").paddles,
PaddleFallback::Shoulders
);
assert_eq!(
RemapConfig::parse("paddles=nonsense").paddles,
PaddleFallback::Drop
);
}
#[test]
fn fold_paddles_maps_and_clears() {
// All four grips set + a real A button.
let b = gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4;
// Drop: paddle bits cleared, A preserved, nothing added.
assert_eq!(fold_paddles(b, PaddleFallback::Drop), gs::BTN_A);
// StickClicks: left grips → L3, right grips → R3.
assert_eq!(
fold_paddles(b, PaddleFallback::StickClicks),
gs::BTN_A | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK
);
// Only a left grip (L4 = PADDLE2) → only the left bumper under Shoulders.
assert_eq!(
fold_paddles(gs::BTN_PADDLE2, PaddleFallback::Shoulders),
gs::BTN_LB
);
}
#[test]
fn motion_rescale_to_deck_units() {
// gyro × 16/20 = 0.8; accel × 16384/10000 = 1.6384.
let (g, a) = motion_wire_to_deck([1000, -2000, 0], [10000, -5000, 0]);
assert_eq!(g, [800, -1600, 0]);
assert_eq!(a, [16384, -8192, 0]);
// Saturates rather than wraps.
let (_, a) = motion_wire_to_deck([0; 3], [32767, i16::MIN, 0]);
assert_eq!(a[0], i16::MAX);
assert_eq!(a[1], i16::MIN);
}
}
@@ -0,0 +1,654 @@
//! Transport-independent Nintendo Switch Pro Controller contract — the report codec + canned
//! handshake replies the Linux UHID backend ([`super::switch_pro`]) drives `hid-nintendo` with.
//!
//! Everything here is pinned against the kernel driver source (drivers/hid/hid-nintendo.c —
//! the ONE consumer of these bytes; a virtual pad must answer its probe exactly or no input
//! devices appear):
//!
//! - **USB handshake**: 2-byte output reports `0x80 <cmd>` (handshake / baudrate / no-timeout),
//! each ACKed with an input report `0x81 <cmd>` (`joycon_send_usb` matches only those two
//! bytes).
//! - **Subcommands**: output report `0x01` (packet counter + 8 rumble bytes + subcommand id +
//! args), ACKed with input report `0x21` — a 12-byte input-state header, then ack byte /
//! echoed subcommand id / payload. The driver matches ONLY the echoed id (byte 14) and
//! requires ≥ 49 bytes; real hardware sends 64.
//! - **SPI flash reads** (subcommand `0x10`): the driver reads the user-calibration magics
//! (absent here → `0xFF 0xFF`, so it takes the factory path), the factory stick calibrations
//! (9-byte packed 12-bit triples — max/center/min order DIFFERS left vs right), and the
//! 24-byte factory IMU calibration. We serve blobs chosen so the math is clean: sticks
//! centered at [`STICK_CENTER`] ± [`STICK_RANGE`], IMU offsets 0 with the driver's default
//! scales (accel 16384, gyro 13371) so raw units pass through 1:1.
//! - **Input report `0x30`**: 3 button bytes (bit layout per `JC_BTN_*`), two packed 12-bit
//! stick triples, battery/connection, and 3 IMU sample frames (accel then gyro, i16 LE).
//! - **Rumble**: 4 encoded bytes per side in every `0x01`/`0x10` output; we decode the
//! amplitude through the driver's own `joycon_rumble_amplitudes` table (inverted) back to the
//! 0..=0xFFFF wire magnitudes it was scaled from (left = strong/low, right = weak/high).
//!
//! Wire-mapping subtleties (see the plan doc, gamepad-new-types §4):
//! - **Positional swap.** Wire `BTN_A` is the SOUTH button (GameStream convention); on a Switch
//! pad SOUTH is `B`. `from_gamepad` maps wire-south → the report's B bit (and X/Y likewise),
//! so the physical-position ↔ glyph relationship stays correct end-to-end.
//! - **Units.** Wire motion is DualSense-convention (20 LSB/°·s, 10000 LSB/g); the report wants
//! real-Pro-Controller raw units (≈14.247 LSB/°·s per `JC_IMU_GYRO_RES_PER_DPS`, 4096 LSB/g
//! per `JC_IMU_ACCEL_RES_PER_G`), which our calibration blobs make the driver consume 1:1.
use punktfunk_core::input::gamepad as gs;
pub const SWITCH_VENDOR: u32 = 0x057E; // Nintendo Co., Ltd
pub const SWITCH_PRODUCT: u32 = 0x2009; // Pro Controller
/// Nintendo Switch Pro Controller **USB** HID report descriptor (203 bytes) — a verbatim
/// real-device capture (usbhid-dump off a wired Pro Controller; three independent public
/// captures agree byte-for-byte: mzyy94's usbhid-dump, ToadKing's full USB capture, and
/// spacemeowx2's annotated dump). Declares exactly the report ids `hid-nintendo` exchanges
/// wired (inputs 0x30/0x21/0x81, outputs 0x01/0x10/0x80/0x82); the driver reads raw events,
/// so the descriptor only has to `hid_parse()` — but this is what real hardware presents.
/// NOT the Bluetooth descriptor (that one is ~170 bytes with a different report set).
#[rustfmt::skip]
pub const PROCON_RDESC: &[u8] = &[
0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01, 0x05, 0x09, 0x19, 0x01,
0x29, 0x0A, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0A, 0x55, 0x00, 0x65, 0x00, 0x81, 0x02,
0x05, 0x09, 0x19, 0x0B, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81, 0x02,
0x75, 0x01, 0x95, 0x02, 0x81, 0x03, 0x0B, 0x01, 0x00, 0x01, 0x00, 0xA1, 0x00, 0x0B, 0x30, 0x00,
0x01, 0x00, 0x0B, 0x31, 0x00, 0x01, 0x00, 0x0B, 0x32, 0x00, 0x01, 0x00, 0x0B, 0x35, 0x00, 0x01,
0x00, 0x15, 0x00, 0x27, 0xFF, 0xFF, 0x00, 0x00, 0x75, 0x10, 0x95, 0x04, 0x81, 0x02, 0xC0, 0x0B,
0x39, 0x00, 0x01, 0x00, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75,
0x04, 0x95, 0x01, 0x81, 0x02, 0x05, 0x09, 0x19, 0x0F, 0x29, 0x12, 0x15, 0x00, 0x25, 0x01, 0x75,
0x01, 0x95, 0x04, 0x81, 0x02, 0x75, 0x08, 0x95, 0x34, 0x81, 0x03, 0x06, 0x00, 0xFF, 0x85, 0x21,
0x09, 0x01, 0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x81, 0x09, 0x02, 0x75, 0x08, 0x95, 0x3F,
0x81, 0x03, 0x85, 0x01, 0x09, 0x03, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x10, 0x09, 0x04,
0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x80, 0x09, 0x05, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83,
0x85, 0x82, 0x09, 0x06, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0xC0,
];
/// Every input report we emit is the full USB size (the driver requires ≥ 49 for `0x21`).
pub const SWITCH_REPORT_LEN: usize = 64;
/// Stick raw center + full-deflection range of OUR virtual pad's calibration (12-bit axis).
/// The factory blobs below advertise exactly this, so the driver maps
/// `center ± range → ∓/± 32767` — one clean linear scale from the wire values.
pub const STICK_CENTER: u16 = 2048;
pub const STICK_RANGE: u16 = 1400;
/// `battery and connection info` byte (report byte 2): high 3 bits = level (4 = full),
/// BIT(4) = charging, BIT(0) = host powered — "full + charging + wired", so no low-battery
/// warnings ever.
pub const BAT_CON_FULL_WIRED: u8 = 0x91;
/// `vibrator_report` (report byte 12): must be non-zero or the driver stops pumping its rumble
/// queue (`joycon_ctlr_read_handler` gates on it). Real hardware sends 0x70-ish.
pub const VIBRATOR_READY: u8 = 0x70;
// Button bits of the 24-bit little-endian button field (report bytes 3..6), per the kernel's
// JC_BTN_* defines.
pub mod btn {
pub const Y: u32 = 1 << 0;
pub const X: u32 = 1 << 1;
pub const B: u32 = 1 << 2;
pub const A: u32 = 1 << 3;
pub const R: u32 = 1 << 6;
pub const ZR: u32 = 1 << 7;
pub const MINUS: u32 = 1 << 8;
pub const PLUS: u32 = 1 << 9;
pub const RSTICK: u32 = 1 << 10;
pub const LSTICK: u32 = 1 << 11;
pub const HOME: u32 = 1 << 12;
pub const CAPTURE: u32 = 1 << 13;
pub const DOWN: u32 = 1 << 16;
pub const UP: u32 = 1 << 17;
pub const RIGHT: u32 = 1 << 18;
pub const LEFT: u32 = 1 << 19;
pub const L: u32 = 1 << 22;
pub const ZL: u32 = 1 << 23;
}
/// Full Pro Controller state serialized into report `0x30` (and the `0x21` reply headers).
/// Sticks are the RAW 12-bit values ([`STICK_CENTER`]-centered); motion is raw IMU units.
#[derive(Clone, Copy)]
pub struct SwitchState {
/// 24-bit `JC_BTN_*` field.
pub buttons: u32,
pub lx: u16,
pub ly: u16,
pub rx: u16,
pub ry: u16,
/// Raw gyro (≈14.247 LSB/°·s) and accel (4096 LSB/g), driver axis order x/y/z.
pub gyro: [i16; 3],
pub accel: [i16; 3],
}
impl SwitchState {
/// Centered sticks, nothing pressed, flat at rest (1 g on +Z — a pad lying on the desk, so
/// SDL/games don't see a free-falling controller).
pub fn neutral() -> SwitchState {
SwitchState {
buttons: 0,
lx: STICK_CENTER,
ly: STICK_CENTER,
rx: STICK_CENTER,
ry: STICK_CENTER,
gyro: [0; 3],
accel: [0, 0, 4096],
}
}
/// Map a GameStream/XInput pad frame into Pro Controller state. Face buttons are mapped
/// **positionally** (wire A = south → Switch B, etc. — see the module doc); triggers are
/// digital on a Pro Controller, so any analog pull presses ZL/ZR. The wire paddles have no
/// Switch slot — fold them via [`super::steam_remap`] BEFORE calling this (like the
/// DualSense-family backends do).
pub fn from_gamepad(
buttons: u32,
lx: i16,
ly: i16,
rx: i16,
ry: i16,
lt: u8,
rt: u8,
) -> SwitchState {
let on = |bit: u32| buttons & bit != 0;
let mut b = 0u32;
// Positional: wire south/east/west/north → the Switch button at that position.
if on(gs::BTN_A) {
b |= btn::B; // south
}
if on(gs::BTN_B) {
b |= btn::A; // east
}
if on(gs::BTN_X) {
b |= btn::Y; // west
}
if on(gs::BTN_Y) {
b |= btn::X; // north
}
if on(gs::BTN_LB) {
b |= btn::L;
}
if on(gs::BTN_RB) {
b |= btn::R;
}
if lt > 0 {
b |= btn::ZL;
}
if rt > 0 {
b |= btn::ZR;
}
if on(gs::BTN_BACK) {
b |= btn::MINUS;
}
if on(gs::BTN_START) {
b |= btn::PLUS;
}
if on(gs::BTN_LS_CLICK) {
b |= btn::LSTICK;
}
if on(gs::BTN_RS_CLICK) {
b |= btn::RSTICK;
}
if on(gs::BTN_GUIDE) {
b |= btn::HOME;
}
if on(gs::BTN_MISC1) {
b |= btn::CAPTURE;
}
if on(gs::BTN_DPAD_UP) {
b |= btn::UP;
}
if on(gs::BTN_DPAD_DOWN) {
b |= btn::DOWN;
}
if on(gs::BTN_DPAD_LEFT) {
b |= btn::LEFT;
}
if on(gs::BTN_DPAD_RIGHT) {
b |= btn::RIGHT;
}
SwitchState {
buttons: b,
lx: stick_raw(lx),
ly: stick_raw(ly),
rx: stick_raw(rx),
ry: stick_raw(ry),
..SwitchState::neutral()
}
}
/// Apply a wire motion sample (DualSense-convention units) as raw IMU values. No axis flip:
/// both conventions are x-toward-triggers / z-up for a Pro Controller held like a DualSense,
/// and the driver applies no negation for the Pro (only the right Joy-Con negates).
pub fn apply_motion(&mut self, gyro: [i16; 3], accel: [i16; 3]) {
// gyro: wire 20 LSB/°·s → raw 14.247 LSB/°·s; accel: wire 10000 LSB/g → raw 4096 LSB/g.
self.gyro = gyro.map(|v| ((v as i32 * 14247) / 20000) as i16);
self.accel = accel.map(|v| ((v as i32 * 4096) / 10000) as i16);
}
}
/// Wire stick value (i16, +32767 = right/up) → raw 12-bit axis. The driver Y-negates BOTH the
/// wire's and evdev's conventions away: it computes `evdev_y = -scale(raw_y)`, and evdev's
/// gamepad convention is negative-up — so wire +y (up) maps to raw above-center, exactly like x.
pub fn stick_raw(v: i16) -> u16 {
let raw = STICK_CENTER as i32 + (v as i32 * STICK_RANGE as i32) / 32767;
raw.clamp(0, 0xFFF) as u16
}
/// Pack two 12-bit values into the 3-byte stick / calibration wire form
/// (`hid_field_extract` little-endian bitfield order).
pub fn pack12(a: u16, b: u16) -> [u8; 3] {
[
(a & 0xFF) as u8,
((a >> 8) & 0x0F) as u8 | ((b & 0x0F) << 4) as u8,
((b >> 4) & 0xFF) as u8,
]
}
/// Write the shared 13-byte input-state header (report id .. `vibrator_report`) that both the
/// `0x30` stream and every `0x21` subcommand reply carry.
fn write_header(r: &mut [u8; SWITCH_REPORT_LEN], id: u8, st: &SwitchState, timer: u8) {
r[0] = id;
r[1] = timer;
r[2] = BAT_CON_FULL_WIRED;
r[3] = (st.buttons & 0xFF) as u8;
r[4] = ((st.buttons >> 8) & 0xFF) as u8;
r[5] = ((st.buttons >> 16) & 0xFF) as u8;
r[6..9].copy_from_slice(&pack12(st.lx, st.ly));
r[9..12].copy_from_slice(&pack12(st.rx, st.ry));
r[12] = VIBRATOR_READY;
}
/// Serialize the full/standard input report `0x30`: state header + 3 IMU sample frames
/// (accel x/y/z then gyro x/y/z, i16 LE — `struct joycon_imu_data`). We repeat the current
/// sample across all three 5 ms sub-frames (we sample per report, not per sub-frame).
pub fn serialize_report_0x30(st: &SwitchState, timer: u8) -> [u8; SWITCH_REPORT_LEN] {
let mut r = [0u8; SWITCH_REPORT_LEN];
write_header(&mut r, 0x30, st, timer);
for frame in 0..3 {
let off = 13 + frame * 12;
for (i, v) in st.accel.iter().enumerate() {
r[off + i * 2..off + i * 2 + 2].copy_from_slice(&v.to_le_bytes());
}
for (i, v) in st.gyro.iter().enumerate() {
r[off + 6 + i * 2..off + 6 + i * 2 + 2].copy_from_slice(&v.to_le_bytes());
}
}
r
}
/// Build the `0x81 <cmd>` input report acknowledging a USB `0x80 <cmd>` command
/// (`joycon_send_usb` matches exactly those two bytes).
pub fn build_usb_ack(cmd: u8) -> [u8; SWITCH_REPORT_LEN] {
let mut r = [0u8; SWITCH_REPORT_LEN];
r[0] = 0x81;
r[1] = cmd;
r
}
/// Build a `0x21` subcommand reply: state header, then ack / echoed subcommand id / payload.
/// The driver matches on the echoed id only; the MSB-set ack byte mirrors real hardware
/// (`0x80` plain ack, `0x80 | data-type` when a payload follows).
pub fn build_subcmd_reply(
st: &SwitchState,
timer: u8,
ack: u8,
subcmd: u8,
payload: &[u8],
) -> [u8; SWITCH_REPORT_LEN] {
let mut r = [0u8; SWITCH_REPORT_LEN];
write_header(&mut r, 0x21, st, timer);
r[13] = ack;
r[14] = subcmd;
let n = payload.len().min(SWITCH_REPORT_LEN - 15);
r[15..15 + n].copy_from_slice(&payload[..n]);
r
}
/// The device-info payload (subcommand `0x02`): firmware 4.33, type `0x03` = **Pro Controller**
/// (`ctlr_type` — the value that selects the Pro button/stick/IMU paths), `0x02`, the 6-byte
/// MAC (parsed into `ctlr->mac_addr`, printed + used as the input devices' `uniq`), `0x01`,
/// and `0x01` = "colors in SPI" (not read by the driver).
pub fn device_info_payload(mac: &[u8; 6]) -> [u8; 12] {
let mut p = [0u8; 12];
p[0] = 0x04;
p[1] = 0x21;
p[2] = 0x03; // JOYCON_CTLR_TYPE_PRO
p[3] = 0x02;
p[4..10].copy_from_slice(mac);
p[10] = 0x01;
p[11] = 0x01;
p
}
/// A stable per-pad virtual MAC (Nintendo OUI + our index) — the driver requires one from
/// device info and keys the input devices' `uniq` off it.
pub fn switch_mac(index: u8) -> [u8; 6] {
[0x7C, 0xBB, 0x8A, 0xDF, 0x00, index]
}
/// The canned SPI-flash contents (subcommand `0x10`): reply payload = echoed LE address +
/// echoed length + the flash bytes. `None` for an unmapped range (the caller then replies with
/// zeroes — the driver falls back to defaults rather than aborting).
///
/// Served ranges:
/// - `0x8010`/`0x801B`/`0x8026` (user-cal magics, 2 B): NOT `0xB2 0xA1` → user cal absent, the
/// driver takes the factory path.
/// - `0x603D`/`0x6046` (factory stick cal, 9 B): [`STICK_CENTER`] ± [`STICK_RANGE`] on every
/// axis. **Byte order differs**: left = max-above ++ center ++ min-below; right = center ++
/// min-below ++ max-above (`joycon_read_stick_calibration`).
/// - `0x6020` (factory IMU cal, 24 B): offsets 0, accel scale 16384, gyro scale 13371 — the
/// driver's own defaults, making its per-sample math the identity (accel) / ×1000 (gyro).
pub fn spi_flash_read(addr: u32, len: u8) -> Option<Vec<u8>> {
let cal_pair = pack12(STICK_RANGE, STICK_RANGE);
let center_pair = pack12(STICK_CENTER, STICK_CENTER);
let data: Vec<u8> = match (addr, len) {
(0x8010 | 0x801B | 0x8026, 2) => vec![0xFF, 0xFF],
(0x603D, 9) => [cal_pair, center_pair, cal_pair].concat(),
(0x6046, 9) => [center_pair, cal_pair, cal_pair].concat(),
(0x6020, 24) => {
let mut v = Vec::with_capacity(24);
v.extend_from_slice(&[0u8; 6]); // accel offsets = 0
for _ in 0..3 {
v.extend_from_slice(&16384u16.to_le_bytes()); // accel scale (driver default)
}
v.extend_from_slice(&[0u8; 6]); // gyro offsets = 0
for _ in 0..3 {
v.extend_from_slice(&13371u16.to_le_bytes()); // gyro scale (driver default)
}
v
}
_ => return None,
};
let mut payload = Vec::with_capacity(5 + data.len());
payload.extend_from_slice(&addr.to_le_bytes());
payload.push(len);
payload.extend_from_slice(&data);
Some(payload)
}
/// One decoded host-bound output report from the driver.
pub enum SwitchOutput {
/// `0x80 <cmd>` USB command — answer with [`build_usb_ack`].
UsbCmd(u8),
/// `0x01` subcommand (with its rumble bytes) — answer with a `0x21` reply.
Subcmd {
id: u8,
/// Subcommand argument bytes (report bytes 11..).
args: Vec<u8>,
/// Decoded rumble `(low, high)` magnitudes.
rumble: (u16, u16),
},
/// `0x10` rumble-only report — no reply expected.
Rumble((u16, u16)),
}
/// Parse one output report from the driver. Returns `None` for anything unrecognized/short.
pub fn parse_output(data: &[u8]) -> Option<SwitchOutput> {
match *data.first()? {
0x80 => Some(SwitchOutput::UsbCmd(*data.get(1)?)),
0x01 if data.len() >= 11 => Some(SwitchOutput::Subcmd {
id: data[10],
args: data.get(11..).map(|s| s.to_vec()).unwrap_or_default(),
rumble: decode_rumble(&data[2..10]),
}),
0x10 if data.len() >= 10 => Some(SwitchOutput::Rumble(decode_rumble(&data[2..10]))),
_ => None,
}
}
/// The driver's `joycon_rumble_amplitudes` table, amplitude column only, indexed by
/// `amp_high / 2` (the encoded high-band amplitude byte is always even). Copied verbatim from
/// hid-nintendo.c; last entry = `joycon_max_rumble_amp` (1003).
#[rustfmt::skip]
const RUMBLE_AMPS: [u16; 101] = [
0, 10, 12, 14, 17, 20, 24, 28, 33, 40,
47, 56, 67, 80, 95, 112, 117, 123, 128, 134,
140, 146, 152, 159, 166, 173, 181, 189, 198, 206,
215, 225, 230, 235, 240, 245, 251, 256, 262, 268,
273, 279, 286, 292, 298, 305, 311, 318, 325, 332,
340, 347, 355, 362, 370, 378, 387, 395, 404, 413,
422, 431, 440, 450, 460, 470, 480, 491, 501, 512,
524, 535, 547, 559, 571, 584, 596, 609, 623, 636,
650, 665, 679, 694, 709, 725, 741, 757, 773, 790,
808, 825, 843, 862, 881, 900, 920, 940, 960, 981,
1003,
];
/// Invert the driver's per-side rumble encoding back to the 0..=0xFFFF magnitude it scaled
/// from: byte1's even bits carry the amplitude-table index × 2 (`data[1] = freq_high_lo +
/// amp.high`, where the freq contribution is only ever bit 0).
fn side_amplitude(side: &[u8]) -> u16 {
let idx = ((side[1] & 0xFE) / 2) as usize;
let amp = RUMBLE_AMPS[idx.min(RUMBLE_AMPS.len() - 1)] as u32;
// Driver: amp = magnitude * 1003 / 65535 — invert, saturating at full scale.
((amp * 65535) / 1003).min(65535) as u16
}
/// Decode the 8 rumble bytes (left side = strong → wire `low`, right side = weak → wire
/// `high`, per `joycon_play_effect`).
pub fn decode_rumble(bytes: &[u8]) -> (u16, u16) {
if bytes.len() < 8 {
return (0, 0);
}
(side_amplitude(&bytes[..4]), side_amplitude(&bytes[4..8]))
}
/// Decode a player-lights subcommand payload (`(flash << 4) | on`, one bit per LED) into the
/// wire `PlayerLeds` bits: a flashing LED counts as on.
pub fn player_leds_bits(arg: u8) -> u8 {
(arg & 0x0F) | (arg >> 4)
}
#[cfg(test)]
mod tests {
use super::*;
/// The positional swap, pinned: wire south/east/west/north land on the Switch B/A/Y/X bits
/// (the driver then maps them back to BTN_SOUTH/EAST/WEST/NORTH — position-correct
/// end-to-end), and the rest of the buttons land on their JC_BTN_* bits.
#[test]
fn positional_swap_and_button_bits() {
let st = SwitchState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
assert_eq!(st.buttons, btn::B);
let st = SwitchState::from_gamepad(gs::BTN_B, 0, 0, 0, 0, 0, 0);
assert_eq!(st.buttons, btn::A);
let st = SwitchState::from_gamepad(gs::BTN_X, 0, 0, 0, 0, 0, 0);
assert_eq!(st.buttons, btn::Y);
let st = SwitchState::from_gamepad(gs::BTN_Y, 0, 0, 0, 0, 0, 0);
assert_eq!(st.buttons, btn::X);
// Shoulders / sticks / meta / dpad / triggers-as-digital.
let st = SwitchState::from_gamepad(
gs::BTN_LB | gs::BTN_RB | gs::BTN_BACK | gs::BTN_START | gs::BTN_GUIDE | gs::BTN_MISC1,
0,
0,
0,
0,
255,
1,
);
assert_eq!(
st.buttons,
btn::L | btn::R | btn::MINUS | btn::PLUS | btn::HOME | btn::CAPTURE | btn::ZL | btn::ZR
);
let st = SwitchState::from_gamepad(gs::BTN_DPAD_UP | gs::BTN_DPAD_LEFT, 0, 0, 0, 0, 0, 0);
assert_eq!(st.buttons, btn::UP | btn::LEFT);
}
/// Sticks: wire full deflection → center ± range on the raw 12-bit axis, both axes the same
/// direction (the driver's own Y negation restores evdev's negative-up).
#[test]
fn stick_scaling() {
assert_eq!(stick_raw(0), STICK_CENTER);
assert_eq!(stick_raw(32767), STICK_CENTER + STICK_RANGE);
assert_eq!(stick_raw(-32767), STICK_CENTER - STICK_RANGE);
// Extreme min doesn't underflow past the 12-bit range.
assert!(stick_raw(i16::MIN) <= 0xFFF);
}
/// The 3-byte 12-bit packing matches `hid_field_extract`'s little-endian bitfield order:
/// value A at bit 0, value B at bit 12.
#[test]
fn pack12_layout() {
assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair)
assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair)
// Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4.
let p = pack12(0xABC, 0x123);
let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8);
let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4);
assert_eq!((a, b), (0xABC, 0x123));
}
/// Report 0x30 layout, pinned against `struct joycon_input_report` + `joycon_imu_data`:
/// header bytes, packed sticks, and the 3 × 12-byte IMU frames (accel then gyro, LE).
#[test]
fn report_0x30_layout() {
let mut st = SwitchState::neutral();
st.buttons = btn::B | btn::MINUS | btn::ZL;
st.gyro = [0x1122, -2, 3];
st.accel = [-1, 0x3344, 5];
let r = serialize_report_0x30(&st, 7);
assert_eq!(r[0], 0x30);
assert_eq!(r[1], 7);
assert_eq!(r[2], BAT_CON_FULL_WIRED);
assert_eq!(r[3], 0x04); // B = bit 2
assert_eq!(r[4], 0x01); // MINUS = bit 8
assert_eq!(r[5], 0x80); // ZL = bit 23
assert_eq!(&r[6..9], &pack12(STICK_CENTER, STICK_CENTER));
assert_eq!(&r[9..12], &pack12(STICK_CENTER, STICK_CENTER));
assert_eq!(r[12], VIBRATOR_READY);
// Frame 0 at byte 13: accel x/y/z then gyro x/y/z, i16 LE.
assert_eq!(&r[13..15], &(-1i16).to_le_bytes());
assert_eq!(&r[15..17], &0x3344u16.to_le_bytes());
assert_eq!(&r[19..21], &0x1122u16.to_le_bytes());
// Frames repeat identically at +12 and +24.
assert_eq!(&r[13..25], &r[25..37]);
assert_eq!(&r[13..25], &r[37..49]);
}
/// Subcommand replies: ≥ 49 bytes (we send 64), ack at byte 13, echoed id at byte 14 (the
/// ONLY byte the driver's matcher checks), payload from byte 15.
#[test]
fn subcmd_reply_layout() {
let st = SwitchState::neutral();
let r = build_subcmd_reply(&st, 3, 0x90, 0x10, &[0xAA, 0xBB]);
assert_eq!(r.len(), SWITCH_REPORT_LEN);
assert_eq!(r[0], 0x21);
assert_eq!(r[13], 0x90);
assert_eq!(r[14], 0x10);
assert_eq!(&r[15..17], &[0xAA, 0xBB]);
// USB ack: exactly the two bytes joycon_send_usb matches.
let a = build_usb_ack(0x02);
assert_eq!((a[0], a[1]), (0x81, 0x02));
}
/// SPI blobs: magics read as ABSENT (≠ B2 A1); the stick blobs put center strictly between
/// min and max on both axes in the driver's per-side byte order; the reply echoes addr+len.
#[test]
fn spi_blobs_valid() {
for addr in [0x8010u32, 0x801B, 0x8026] {
let p = spi_flash_read(addr, 2).unwrap();
assert_eq!(&p[..4], &addr.to_le_bytes());
assert_eq!(p[4], 2);
assert!(!(p[5] == 0xB2 && p[6] == 0xA1));
}
let unpack = |b: &[u8]| -> (u16, u16) {
let a = b[0] as u16 | ((b[1] as u16 & 0xF) << 8);
let y = ((b[1] as u16) >> 4) | ((b[2] as u16) << 4);
(a, y)
};
// Left: max-above ++ center ++ min-below.
let l = spi_flash_read(0x603D, 9).unwrap();
let (data, hdr) = (&l[5..], &l[..5]);
assert_eq!(hdr, &[0x3D, 0x60, 0, 0, 9]);
let (max_above, _) = unpack(&data[0..3]);
let (center, _) = unpack(&data[3..6]);
let (min_below, _) = unpack(&data[6..9]);
assert_eq!(center, STICK_CENTER);
assert!(center - min_below < center && center < center + max_above);
// Right: center ++ min-below ++ max-above.
let r = spi_flash_read(0x6046, 9).unwrap();
let (rc, _) = unpack(&r[5..8]);
assert_eq!(rc, STICK_CENTER);
// IMU: offsets 0, driver-default scales — the identity calibration.
let imu = spi_flash_read(0x6020, 24).unwrap();
let d = &imu[5..];
assert_eq!(&d[0..6], &[0; 6]);
assert_eq!(&d[6..8], &16384u16.to_le_bytes());
assert_eq!(&d[12..18], &[0; 6]);
assert_eq!(&d[18..20], &13371u16.to_le_bytes());
// Unmapped range → None.
assert!(spi_flash_read(0x6050, 12).is_none());
}
/// Motion unit conversion: wire (20 LSB/°·s, 10000 LSB/g) → raw (14.247 LSB/°·s, 4096 LSB/g).
#[test]
fn motion_units() {
let mut st = SwitchState::neutral();
// 100 °/s = wire 2000 → raw ≈ 1424; 1 g = wire 10000 → raw 4096.
st.apply_motion([2000, 0, -2000], [10000, -10000, 0]);
assert_eq!(st.gyro, [1424, 0, -1424]);
assert_eq!(st.accel, [4096, -4096, 0]);
}
/// Rumble decode inverts the driver's encoder: a neutral packet decodes to silence; the
/// max-amplitude packet decodes to full scale; left = low/strong, right = high/weak.
#[test]
fn rumble_decode() {
// Neutral per the driver's tables: freq defaults + amp 0.
let neutral = [0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40];
assert_eq!(decode_rumble(&neutral), (0, 0));
// Max amp (0xC8 → index 100 → 1003 → 65535) on the LEFT only → (low=full, high=0).
let left_max = [0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40];
assert_eq!(decode_rumble(&left_max), (65535, 0));
// Mid-table on the right: amp_high 0x20 → index 16 → 117 → 117*65535/1003 = 7644.
let right_mid = [0x00, 0x01, 0x40, 0x40, 0x00, 0x20, 0x48, 0x40];
assert_eq!(decode_rumble(&right_mid), (0, 7644));
// The freq bit riding data[1] bit0 must not disturb the amplitude index.
let with_freq_bit = [0x00, 0x21, 0x48, 0x40, 0x00, 0x01, 0x40, 0x40];
assert_eq!(decode_rumble(&with_freq_bit).0, 7644);
// Short slice → silence, not a panic.
assert_eq!(decode_rumble(&[0x10; 4]), (0, 0));
}
/// Output-report parse: the three shapes the driver sends.
#[test]
fn parse_output_shapes() {
assert!(matches!(
parse_output(&[0x80, 0x02]),
Some(SwitchOutput::UsbCmd(0x02))
));
let mut sub = vec![0x01, 0x05];
sub.extend_from_slice(&[0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40]);
sub.push(0x10); // subcmd id
sub.extend_from_slice(&[0x3D, 0x60, 0x00, 0x00, 0x09]); // SPI addr+len args
match parse_output(&sub) {
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
assert_eq!(id, 0x10);
assert_eq!(&args[..5], &[0x3D, 0x60, 0x00, 0x00, 0x09]);
assert_eq!(rumble, (0, 0));
}
_ => panic!("expected subcmd"),
}
let mut rum = vec![0x10, 0x06];
rum.extend_from_slice(&[0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40]);
assert!(matches!(
parse_output(&rum),
Some(SwitchOutput::Rumble((65535, 0)))
));
assert!(parse_output(&[0x21]).is_none());
assert!(parse_output(&[]).is_none());
}
/// Player lights: solid + flashing nibbles both count as lit.
#[test]
fn player_lights() {
assert_eq!(player_leds_bits(0x01), 0b0001);
assert_eq!(player_leds_bits(0x10), 0b0001); // flashing LED 1
assert_eq!(player_leds_bits(0x23), 0b0011 | 0b0010);
}
/// Device info: type byte 0x03 (Pro Controller) at payload[2], MAC at [4..10].
#[test]
fn device_info_shape() {
let mac = switch_mac(3);
let p = device_info_payload(&mac);
assert_eq!(p[2], 0x03);
assert_eq!(&p[4..10], &mac);
assert_eq!(mac[5], 3);
}
}
@@ -0,0 +1,449 @@
//! 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]);
}
}