feat(gamepad): virtual DualSense on the Windows host (UMDF shm channel)
Wire the Windows UMDF DualSense driver into the host as a real pad backend, so a client that requests a DualSense gets a genuine one on a Windows host (instead of folding to Xbox 360). - Extract the transport-independent DualSense contract (DsState + from_gamepad, serialize_state, parse_ds_output, DUALSENSE_RDESC, feature blobs, DS_* consts) out of the Linux-only UHID backend into inject/dualsense_proto.rs, shared by both platforms; dualsense.rs is now just the /dev/uhid plumbing. - Add inject/dualsense_windows.rs: DualSenseWindowsManager mirroring the Linux DualSenseManager (same new/handle/apply_rich/pump/heartbeat surface) over a DsWinPad that creates the Global\pfds-shm-<idx> section (CreateFileMappingW + SDDL D:(A;;GA;;;WD) so WUDFHost can open it), writes serialize_state -> input slot, polls output_seq -> parse_ds_output -> rumble/hidout callbacks. - Un-gate the seam: PadBackend::DualSenseWindows arm; pick_gamepad gains a windows flag (DualSense honored on linux||windows; DS4/Xbox One stay Linux-only). Verified: Linux cargo test gamepad_resolution_precedence + clippy clean; Windows cargo check + clippy -D warnings clean (on the RTX box). Device lifecycle still uses an out-of-band devnode (devgen/installer); SwDeviceCreate per session is next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -423,6 +423,13 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
||||
|
||||
#[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")]
|
||||
|
||||
@@ -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<HidOutput>,
|
||||
/// `(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<DualSensePad> {
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HidOutput>,
|
||||
/// `(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());
|
||||
}
|
||||
}
|
||||
@@ -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-<idx>` (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-<index>` 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<DsWinPad> {
|
||||
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::<SECURITY_ATTRIBUTES>() 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<Option<DsWinPad>>,
|
||||
state: Vec<DsState>,
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
last_write: Vec<Instant>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1176,14 +1176,17 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
/// - `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]
|
||||
|
||||
Reference in New Issue
Block a user