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:
@@ -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 4–7 (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 P1–P4) 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
|
||||
/// 4–7 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 0–6), 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user