diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 3bf8426..e9b81de 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -116,6 +116,9 @@ libloading = "0.8" windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_Security", + # ConvertStringSecurityDescriptorToSecurityDescriptorW — the SDDL on the virtual-DualSense + # shared-memory section (inject/dualsense_windows.rs) so the UMDF host can open it. + "Win32_Security_Authorization", "Win32_Devices_DeviceAndDriverInstallation", "Win32_Devices_Display", "Win32_Storage_FileSystem", diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 877bb01..543fcad 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -423,6 +423,13 @@ fn gs_button_to_evdev(b: u32) -> Option { #[cfg(target_os = "linux")] pub mod dualsense; +/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`]) +/// and the Windows UMDF-driver backend ([`dualsense_windows`]). +#[cfg(any(target_os = "linux", target_os = "windows"))] +pub mod dualsense_proto; +/// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel. +#[cfg(target_os = "windows")] +pub mod dualsense_windows; #[cfg(target_os = "linux")] pub mod dualshock4; #[cfg(target_os = "linux")] diff --git a/crates/punktfunk-host/src/inject/dualsense.rs b/crates/punktfunk-host/src/inject/dualsense.rs index 202e8a3..b86807a 100644 --- a/crates/punktfunk-host/src/inject/dualsense.rs +++ b/crates/punktfunk-host/src/inject/dualsense.rs @@ -8,9 +8,15 @@ //! **output** reports (report `0x02`, a game's rumble/LED/trigger feedback) back, which it //! forwards to the client as [`punktfunk_core::quic::HidOutput`]. //! -//! The report descriptor + field layout are the canonical inputtino ones (games-on-whales/ -//! inputtino `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` binds the same as a USB pad. +//! The transport-independent contract (report descriptor, feature blobs, [`DsState`], the `0x01` +//! serializer and `0x02` parser) lives in [`super::dualsense_proto`], shared with the Windows +//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it. +use super::dualsense_proto::{ + parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION, + DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, + DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC, +}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; @@ -32,275 +38,10 @@ const HID_MAX_DESCRIPTOR_SIZE: usize = 4096; const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2) const BUS_USB: u16 = 0x03; -// Feature reports `hid-playstation` GET_REPORTs during init — without these replies it 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] -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] -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] -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 (232 bytes), verbatim from inputtino — the exact -/// descriptor `hid-playstation` parses to bind a UHID device as a DualSense. -#[rustfmt::skip] -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, -]; - -const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment -const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller -/// USB input report `0x01` is 64 bytes total (report id + 63-byte body). -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). -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. -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). -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 `/dev/uhid`). 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]. -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 [`DualSensePad::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)>, +/// Copy a NUL-padded C string field into the event buffer. +fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { + let n = s.len().min(cap - 1); + ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated) } /// A virtual DualSense backed by `/dev/uhid` (hand-rolled codec — no bindgen, mirroring the @@ -312,12 +53,6 @@ pub struct DualSensePad { ts: u32, } -/// Copy a NUL-padded C string field into the event buffer. -fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { - let n = s.len().min(cap - 1); - ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated) -} - impl DualSensePad { /// Create the UHID DualSense for pad `index` (used only to make the device name/uniq unique). pub fn open(index: u8) -> Result { @@ -427,61 +162,6 @@ impl Drop for DualSensePad { } } -/// 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 kernel zeroes -/// the rest), so an ungated parse would turn every plain rumble write into a lightbar-off + -/// triggers-off broadcast. -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(), - }); - } - } -} - /// All virtual DualSense pads of a session — the rich-controller analog of /// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`. /// @@ -678,132 +358,3 @@ impl DualSenseManager { } } } - -#[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 kernel - /// zeroes the rest of the report) — 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() { - // Kernel-style 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()); - } -} diff --git a/crates/punktfunk-host/src/inject/dualsense_proto.rs b/crates/punktfunk-host/src/inject/dualsense_proto.rs new file mode 100644 index 0000000..c488db7 --- /dev/null +++ b/crates/punktfunk-host/src/inject/dualsense_proto.rs @@ -0,0 +1,476 @@ +//! 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()); + } +} diff --git a/crates/punktfunk-host/src/inject/dualsense_windows.rs b/crates/punktfunk-host/src/inject/dualsense_windows.rs new file mode 100644 index 0000000..57b3593 --- /dev/null +++ b/crates/punktfunk-host/src/inject/dualsense_windows.rs @@ -0,0 +1,331 @@ +//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/dualsense-driver`). +//! +//! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and +//! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where +//! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`, +//! the Windows backend talks to the UMDF driver over a **named shared-memory section** +//! `Global\pfds-shm-` (256 B: magic `u32@0`, input report `@8`, output seq `u32@72`, output +//! report `@76`). The host creates the section (privileged → a permissive SDDL so the WUDFHost can +//! open it); the driver maps it from its timer, feeds game `READ_REPORT`s from the input bytes, and +//! publishes a game's `0x02` (rumble / lightbar / player-LEDs / adaptive triggers) into the output +//! bytes. `hidclass` gates the device stack, so this user-mode IPC is the only viable channel (a +//! UMDF driver has no control device); see `windows-dualsense-scoping.md`. +//! +//! Device lifecycle: the `root\pf_dualsense` devnode is currently created out-of-band (the dev-box +//! `devgen` for tests; the installer for fleet use). Per-session creation via `SwDeviceCreate` (so the +//! pad appears/disappears with the session, matching the Linux UHID lifecycle) is the next step — +//! see [`DsWinPad::open`]. + +use super::dualsense_proto::{ + parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H, + DS_TOUCH_W, +}; +use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use anyhow::{anyhow, Result}; +use punktfunk_core::quic::{HidOutput, RichInput}; +use std::ffi::c_void; +use std::time::{Duration, Instant}; +use windows::core::{w, HSTRING, PCWSTR}; +use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; +use windows::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, +}; +use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; +use windows::Win32::System::Memory::{ + CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, + MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, +}; + +/// Shared-section layout — must match `packaging/windows/dualsense-driver/src/lib.rs`. +const SHM_SIZE: usize = 256; +const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS" +const OFF_INPUT: usize = 8; +const OFF_OUT_SEQ: usize = 72; +const OFF_OUTPUT: usize = 76; + +/// A single virtual DualSense: the shared-memory section the driver maps (and, in future, the +/// `HSWDEVICE` from `SwDeviceCreate`). Dropping it unmaps + closes the section. +struct DsWinPad { + map: HANDLE, + view: *mut u8, + seq: u8, + ts: u32, + last_out_seq: u32, +} + +impl DsWinPad { + /// Create + map the section `Global\pfds-shm-` and stamp the magic so the driver accepts + /// it. (TODO: also `SwDeviceCreate("root\\pf_dualsense")` here to spawn the devnode per session; + /// for now the devnode is created out-of-band by the installer / dev-box `devgen`.) + fn open(index: u8) -> Result { + let name = HSTRING::from(format!("Global\\pfds-shm-{index}")); + + // A permissive DACL so the WUDFHost (whatever account it runs as) can open the section. + let mut psd = PSECURITY_DESCRIPTOR::default(); + // SAFETY: the SDDL literal is valid; psd receives an allocated descriptor (freed by the OS + // when the process exits — acceptable for a host-lifetime object). + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + w!("D:(A;;GA;;;WD)"), + SDDL_REVISION_1, + &mut psd, + None, + )?; + } + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: psd.0, + bInheritHandle: false.into(), + }; + + // SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above. + let map = unsafe { + CreateFileMappingW( + INVALID_HANDLE_VALUE, + Some(&sa), + PAGE_READWRITE, + 0, + SHM_SIZE as u32, + PCWSTR(name.as_ptr()), + )? + }; + // SAFETY: map is a valid section handle; map the whole thing. + let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) }; + if view.Value.is_null() { + // SAFETY: map is valid. + unsafe { + let _ = CloseHandle(map); + } + return Err(anyhow!("MapViewOfFile failed for {name}")); + } + let base = view.Value as *mut u8; + // Zero the section then stamp the magic LAST (the driver only accepts it once magic is set). + // SAFETY: base points at SHM_SIZE writable bytes. + unsafe { + std::ptr::write_bytes(base, 0, SHM_SIZE); + std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], { + let mut r = [0u8; DS_INPUT_REPORT_LEN]; + serialize_state(&mut r, &DsState::neutral(), 0, 0); + r + }); + std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); + } + Ok(DsWinPad { + map, + view: base, + seq: 0, + ts: 0, + last_out_seq: 0, + }) + } + + /// Serialize `st` into report `0x01` and publish it to the section's input slot. + fn write_state(&mut self, st: &DsState) { + self.seq = self.seq.wrapping_add(1); + self.ts = self.ts.wrapping_add(1); + let mut r = [0u8; DS_INPUT_REPORT_LEN]; + serialize_state(&mut r, st, self.seq, self.ts); + // SAFETY: view points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. + unsafe { std::ptr::copy_nonoverlapping(r.as_ptr(), self.view.add(OFF_INPUT), r.len()) }; + } + + /// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a + /// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything new. + fn service(&mut self, pad: u8) -> DsFeedback { + let mut fb = DsFeedback::default(); + // SAFETY: view points at SHM_SIZE bytes. + let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_OUT_SEQ) as *const u32) }; + if seq != self.last_out_seq { + self.last_out_seq = seq; + let mut out = [0u8; 64]; + // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. + unsafe { + std::ptr::copy_nonoverlapping(self.view.add(OFF_OUTPUT), out.as_mut_ptr(), 64) + }; + parse_ds_output(pad, &out, &mut fb); + } + fb + } +} + +impl Drop for DsWinPad { + fn drop(&mut self) { + // SAFETY: view came from MapViewOfFile; map from CreateFileMappingW. + unsafe { + let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { + Value: self.view as *mut c_void, + }); + let _ = CloseHandle(self.map); + } + } +} + +/// All virtual DualSense pads of a session — the Windows analogue of +/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input +/// thread drives either backend identically. +pub struct DualSenseWindowsManager { + pads: Vec>, + state: Vec, + last_rumble: Vec<(u16, u16)>, + last_write: Vec, + broken: bool, +} + +impl Default for DualSenseWindowsManager { + fn default() -> DualSenseWindowsManager { + DualSenseWindowsManager::new() + } +} + +impl DualSenseWindowsManager { + pub fn new() -> DualSenseWindowsManager { + DualSenseWindowsManager { + pads: (0..MAX_PADS).map(|_| None).collect(), + state: vec![DsState::neutral(); MAX_PADS], + last_rumble: vec![(0, 0); MAX_PADS], + last_write: vec![Instant::now(); MAX_PADS], + broken: false, + } + } + + /// Handle one decoded controller event (create/destroy by mask, then merge button/stick state). + pub fn handle(&mut self, ev: &GamepadEvent) { + match ev { + GamepadEvent::Arrival { index, kind, .. } => { + tracing::info!(index, kind, "controller arrival (DualSense/Windows)"); + self.ensure(*index as usize); + } + GamepadEvent::State(f) => { + let idx = f.index as usize; + if idx >= MAX_PADS { + return; + } + for (i, slot) in self.pads.iter_mut().enumerate() { + if slot.is_some() && f.active_mask & (1 << i) == 0 { + tracing::info!(index = i, "controller unplugged (DualSense/Windows)"); + *slot = None; + self.state[i] = DsState::neutral(); + self.last_rumble[i] = (0, 0); + } + } + if f.active_mask & (1 << idx) == 0 { + return; + } + self.ensure(idx); + let prev = self.state[idx]; + let mut s = DsState::from_gamepad( + f.buttons, + f.ls_x, + f.ls_y, + f.rs_x, + f.rs_y, + f.left_trigger, + f.right_trigger, + ); + s.touch = prev.touch; + s.gyro = prev.gyro; + s.accel = prev.accel; + self.state[idx] = s; + self.write(idx); + } + } + } + + /// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad. + pub fn apply_rich(&mut self, rich: RichInput) { + let idx = match rich { + RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, + }; + if idx >= MAX_PADS || self.pads[idx].is_none() { + return; + } + match rich { + RichInput::Touchpad { + finger, + active, + x, + y, + .. + } => { + let slot = (finger as usize).min(1); + let t = &mut self.state[idx].touch[slot]; + t.active = active; + t.id = slot as u8; + t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16; + t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16; + } + RichInput::Motion { gyro, accel, .. } => { + self.state[idx].gyro = gyro; + self.state[idx].accel = accel; + } + } + self.write(idx); + } + + fn write(&mut self, idx: usize) { + let st = self.state[idx]; + if let Some(pad) = self.pads[idx].as_mut() { + pad.write_state(&st); + } + self.last_write[idx] = Instant::now(); + } + + /// Re-emit each live pad's current report if it's been silent for `max_gap` (the driver's timer + /// streams whatever's in the section, so this just keeps the section fresh / future-proofs parity + /// with the UHID backend's heartbeat). + pub fn heartbeat(&mut self, max_gap: Duration) { + let now = Instant::now(); + for i in 0..self.pads.len() { + if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap { + self.write(i); + } + } + } + + fn ensure(&mut self, idx: usize) { + if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + return; + } + match DsWinPad::open(idx as u8) { + Ok(p) => { + tracing::info!( + index = idx, + "virtual DualSense created (Windows UMDF shm channel)" + ); + self.pads[idx] = Some(p); + self.state[idx] = DsState::neutral(); + self.last_rumble[idx] = (0, 0); + self.last_write[idx] = Instant::now(); + } + Err(e) => { + tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled"); + self.broken = true; + } + } + } + + /// Service every pad: poll the section for a game's feedback. `rumble` fires `(index, low, high)` + /// only on change (universal 0xCA plane); `hidout` fires for each rich DualSense feedback event + /// (lightbar / player LEDs / adaptive triggers — 0xCD plane). + pub fn pump( + &mut self, + mut rumble: impl FnMut(u16, u16, u16), + mut hidout: impl FnMut(HidOutput), + ) { + for i in 0..self.pads.len() { + let Some(pad) = self.pads[i].as_mut() else { + continue; + }; + let fb = pad.service(i as u8); + if let Some(r) = fb.rumble { + if self.last_rumble[i] != r { + self.last_rumble[i] = r; + rumble(i as u16, r.0, r.1); + } + } + for h in fb.hidout { + hidout(h); + } + } + } +} diff --git a/crates/punktfunk-host/src/inject/dualshock4.rs b/crates/punktfunk-host/src/inject/dualshock4.rs index 4f3cef9..cda75ef 100644 --- a/crates/punktfunk-host/src/inject/dualshock4.rs +++ b/crates/punktfunk-host/src/inject/dualshock4.rs @@ -13,7 +13,7 @@ //! resolution differ. The report descriptor + struct offsets are the canonical real-DS4-USB layout //! the kernel `struct dualshock4_input_report_usb` / `_output_report_common` parse. -use super::dualsense::{DsState, Touch}; +use super::dualsense_proto::{DsState, Touch}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 702487e..5ec2239 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -152,7 +152,8 @@ fn real_main() -> Result<()> { // sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`. #[cfg(target_os = "linux")] Some("dualsense-test") => { - use inject::dualsense::{DsState, DualSensePad}; + use inject::dualsense::DualSensePad; + use inject::dualsense_proto::DsState; let secs: u64 = args .iter() .skip_while(|a| *a != "--seconds") diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 5aaa670..a8c1990 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1176,14 +1176,17 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver>) { /// - `DualShock4` (`PUNKTFUNK_GAMEPAD=ps4`) — virtual DualShock 4 via the same UHID path: lightbar, /// touchpad, motion, rumble (DualSense minus adaptive triggers / player LEDs / mute). /// -/// The two UHID pads are Linux-only; off Linux the resolver already folds them (and One/Series) -/// into `Xbox360`, so a non-Linux build never constructs them. +/// DualShock 4 + One/Series are Linux-only; DualSense has both a Linux (UHID) and a Windows (UMDF +/// minidriver) backend. The resolver folds any type a platform can't build into `Xbox360`, so a +/// build never constructs a variant it lacks. enum PadBackend { Xbox360(crate::inject::gamepad::GamepadManager), #[cfg(target_os = "linux")] DualSense(crate::inject::dualsense::DualSenseManager), #[cfg(target_os = "linux")] DualShock4(crate::inject::dualshock4::DualShock4Manager), + #[cfg(target_os = "windows")] + DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager), } impl PadBackend { @@ -1209,6 +1212,13 @@ impl PadBackend { } _ => {} } + #[cfg(target_os = "windows")] + if kind == GamepadPref::DualSense { + tracing::info!("gamepad backend: virtual DualSense (Windows UMDF shm channel)"); + return PadBackend::DualSenseWindows( + crate::inject::dualsense_windows::DualSenseWindowsManager::new(), + ); + } let _ = kind; PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new()) } @@ -1220,17 +1230,22 @@ impl PadBackend { PadBackend::DualSense(m) => m.handle(ev), #[cfg(target_os = "linux")] PadBackend::DualShock4(m) => m.handle(ev), + #[cfg(target_os = "windows")] + PadBackend::DualSenseWindows(m) => m.handle(ev), } } /// Apply a rich client→host event (touchpad / motion). A no-op for the X-Box pad, which has no /// equivalent; the DualSense and DualShock 4 pads both carry a touchpad + motion sensors. fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) { - #[cfg(target_os = "linux")] match self { - PadBackend::DualSense(m) => m.apply_rich(_rich), - PadBackend::DualShock4(m) => m.apply_rich(_rich), PadBackend::Xbox360(_) => {} + #[cfg(target_os = "linux")] + PadBackend::DualSense(m) => m.apply_rich(_rich), + #[cfg(target_os = "linux")] + PadBackend::DualShock4(m) => m.apply_rich(_rich), + #[cfg(target_os = "windows")] + PadBackend::DualSenseWindows(m) => m.apply_rich(_rich), } } @@ -1252,6 +1267,8 @@ impl PadBackend { PadBackend::DualSense(m) => m.pump(rumble, hidout), #[cfg(target_os = "linux")] PadBackend::DualShock4(m) => m.pump(rumble, hidout), + #[cfg(target_os = "windows")] + PadBackend::DualSenseWindows(m) => m.pump(rumble, hidout), } } @@ -1267,6 +1284,8 @@ impl PadBackend { PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)), #[cfg(target_os = "linux")] PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)), + #[cfg(target_os = "windows")] + PadBackend::DualSenseWindows(m) => m.heartbeat(std::time::Duration::from_millis(8)), } } } @@ -1555,7 +1574,7 @@ fn synthetic_stream( /// 4) need it — off Linux any such wish degrades to X-Box 360 (never an error: a session without rich /// pads still streams). X-Box One/Series is a distinct uinput *identity* on Linux, but XInput-identical /// to the 360 pad on Windows (ViGEm has no One target), so it degrades to `Xbox360` there. -fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool) -> GamepadPref { +fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool) -> GamepadPref { let want = match pref { GamepadPref::Auto => env .and_then(GamepadPref::from_name) @@ -1563,7 +1582,8 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool) -> GamepadPre explicit => explicit, }; match want { - GamepadPref::DualSense if linux => GamepadPref::DualSense, + // DualSense: Linux UHID hid-playstation, or the Windows UMDF minidriver backend. + GamepadPref::DualSense if linux || windows => GamepadPref::DualSense, GamepadPref::DualShock4 if linux => GamepadPref::DualShock4, // One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on // Windows (XInput can't tell them apart anyway). @@ -1576,7 +1596,12 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool) -> GamepadPre /// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive. fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { let env = std::env::var("PUNKTFUNK_GAMEPAD").ok(); - let chosen = pick_gamepad(pref, env.as_deref(), cfg!(target_os = "linux")); + let chosen = pick_gamepad( + pref, + env.as_deref(), + cfg!(target_os = "linux"), + cfg!(target_os = "windows"), + ); match pref { GamepadPref::Auto => { // The operator's env knob deserves a diagnostic when it didn't drive the @@ -3040,26 +3065,41 @@ mod tests { #[test] fn gamepad_resolution_precedence() { use GamepadPref::*; + // Trailing args are (linux, windows). // An explicit client choice wins over the env var. - assert_eq!(pick_gamepad(DualSense, Some("xbox360"), true), DualSense); - assert_eq!(pick_gamepad(Xbox360, Some("dualsense"), true), Xbox360); + assert_eq!( + pick_gamepad(DualSense, Some("xbox360"), true, false), + DualSense + ); + assert_eq!( + pick_gamepad(Xbox360, Some("dualsense"), true, false), + Xbox360 + ); // Client Auto defers to the env var. - assert_eq!(pick_gamepad(Auto, Some("dualsense"), true), DualSense); - assert_eq!(pick_gamepad(Auto, Some("xbox360"), true), Xbox360); + assert_eq!( + pick_gamepad(Auto, Some("dualsense"), true, false), + DualSense + ); + assert_eq!(pick_gamepad(Auto, Some("xbox360"), true, false), Xbox360); // Auto + no env (or an unparseable one) → X-Box 360. - assert_eq!(pick_gamepad(Auto, None, true), Xbox360); - assert_eq!(pick_gamepad(Auto, Some("bogus"), true), Xbox360); - // DualSense degrades to X-Box 360 where the backend doesn't exist (non-Linux). - assert_eq!(pick_gamepad(DualSense, None, false), Xbox360); - assert_eq!(pick_gamepad(Auto, Some("dualsense"), false), Xbox360); - // DualShock 4: honored on Linux (UHID), degrades to X-Box 360 off it. - assert_eq!(pick_gamepad(DualShock4, None, true), DualShock4); - assert_eq!(pick_gamepad(Auto, Some("ps4"), true), DualShock4); - assert_eq!(pick_gamepad(DualShock4, None, false), Xbox360); + assert_eq!(pick_gamepad(Auto, None, true, false), Xbox360); + assert_eq!(pick_gamepad(Auto, Some("bogus"), true, false), Xbox360); + // DualSense: honored on Linux (UHID) AND Windows (UMDF minidriver); degrades elsewhere. + assert_eq!(pick_gamepad(DualSense, None, false, true), DualSense); + assert_eq!( + pick_gamepad(Auto, Some("dualsense"), false, true), + DualSense + ); + assert_eq!(pick_gamepad(DualSense, None, false, false), Xbox360); + assert_eq!(pick_gamepad(Auto, Some("dualsense"), false, false), Xbox360); + // DualShock 4: Linux-only (UHID); degrades to X-Box 360 off it (including Windows). + assert_eq!(pick_gamepad(DualShock4, None, true, false), DualShock4); + assert_eq!(pick_gamepad(Auto, Some("ps4"), true, false), DualShock4); + assert_eq!(pick_gamepad(DualShock4, None, false, true), Xbox360); // X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows. - assert_eq!(pick_gamepad(XboxOne, None, true), XboxOne); - assert_eq!(pick_gamepad(Auto, Some("series"), true), XboxOne); - assert_eq!(pick_gamepad(XboxOne, None, false), Xbox360); + assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne); + assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne); + assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360); } #[test]