//! 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; // 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, ]; /// 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, ]; pub const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment pub const DS_PRODUCT: u32 = 0x0CE6; // DualSense 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 (+ a rolling counter in the high bits). pub mod btn2 { pub const PS: u8 = 0x01; pub const TOUCHPAD: u8 = 0x02; #[allow(dead_code)] pub const MUTE: u8 = 0x04; } /// 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], } 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; } 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, }; } } /// 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.buttons[2]; // off 9 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, /// `(low, high)` motor levels (0..=0xFFFF), if a report carried them. pub rumble: Option<(u16, u16)>, } /// 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). if flag0 & 0x03 != 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::*; /// 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 bit (Moonlight's extended position) lands 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); let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0); assert_eq!(s.buttons[2], 0); } /// 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()); } }