refactor(windows-host): confine platform code under windows/ + linux/ folders (Goal-1 stage 6)
Move 36 platform-specific files into per-module `windows/` and `linux/` subfolders (and the
shared HID codecs into `inject/proto/`):
capture/{windows,linux}/ encode/{windows,linux}/ inject/{windows,linux,proto}/
audio/{windows,linux}/ vdisplay/{windows,linux}/
src/windows/ (service, wgc_helper, win_adapter, win_display)
src/linux/ (dmabuf_fence, drm_sync, zerocopy/)
Done with `#[path]`, NOT a module rename: every file moves into its folder while the
`crate::*::*` module names stay FLAT, so all caller paths and every internal `super::`/`crate::`
reference are unchanged — only the parent `mod` decls gained `#[path = "..."]`. This is the
codebase's existing pattern (inject's gamepad_windows) and makes the move byte-identical in
behaviour with ZERO reference churn, far lower risk than collapsing to a single
`crate::capture::windows::` namespace (that deeper rename is an optional follow-on; this delivers
the cfg-sprawl folder confinement the stage is about). Done LAST, after the semantic stages, so
the path churn didn't fight them.
Verified: Linux cargo check + clippy (-D warnings) clean; my mod-decl changes fmt-clean (the 3
remaining fmt diffs are pre-existing local-rustfmt-version skew that moved with their files); all
36 `#[path]` targets exist; no internal `#[path]`/`include!`/file-child-mod in any moved file
(the inline `mod X {` blocks are self-contained). Box build to follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,180 @@
|
||||
//! Transport-independent DualShock 4 HID contract — the pure report codec used by the Windows
|
||||
//! UMDF-driver backend ([`super::dualshock4_windows`]).
|
||||
//!
|
||||
//! FIXME(ds4-dedup): the Linux UHID backend ([`super::dualshock4`]) still carries its own byte-
|
||||
//! identical copy of this codec (`serialize_state` / `parse_ds4_output` / `Ds4Feedback` / the touch
|
||||
//! dims). Fold it onto this module once the Linux build can be re-validated (it is `cfg(linux)`, so
|
||||
//! it can't be compile-checked from a Windows host). Keep the two in sync until then.
|
||||
//!
|
||||
//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4
|
||||
//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper
|
||||
//! ([`DsState::from_gamepad`]) — only the report *byte layout*, the touchpad resolution, and the
|
||||
//! feedback report differ. The Linux backend writes report `0x01` to `/dev/uhid` and reads `0x05` via
|
||||
//! `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF driver and pulls `0x05` back over its
|
||||
//! shared-memory channel — both build/parse the exact same bytes here.
|
||||
//!
|
||||
//! Field offsets are the canonical real-DS4-USB layout the kernel `struct
|
||||
//! dualshock4_input_report_usb` / `_output_report_common` parse.
|
||||
|
||||
use super::dualsense_proto::{DsState, Touch};
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
|
||||
/// DualShock 4 v2 USB identity (Sony Interactive Entertainment / CUH-ZCT2).
|
||||
pub const DS4_VENDOR: u16 = 0x054C;
|
||||
pub const DS4_PRODUCT: u16 = 0x09CC;
|
||||
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
|
||||
pub const DS4_INPUT_REPORT_LEN: usize = 64;
|
||||
/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower
|
||||
/// than the DualSense's 1920×1080.
|
||||
pub const DS4_TOUCH_W: u16 = 1920;
|
||||
pub const DS4_TOUCH_H: u16 = 942;
|
||||
|
||||
/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's:
|
||||
/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y).
|
||||
fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
|
||||
// Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1.
|
||||
let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1));
|
||||
dst[1] = (x & 0xFF) as u8;
|
||||
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
|
||||
dst[3] = ((y >> 4) & 0xFF) as u8;
|
||||
}
|
||||
|
||||
/// Serialize a full DS4 input report `0x01` (pure — unit-testable without a transport). Field offsets
|
||||
/// per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch; touch[3];
|
||||
/// rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp; gyro[3] le16;
|
||||
/// accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common` field at struct
|
||||
/// offset N sits at report byte N+1.
|
||||
pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) {
|
||||
r[0] = 0x01; // report id
|
||||
r[1] = st.lx;
|
||||
r[2] = st.ly;
|
||||
r[3] = st.rx;
|
||||
r[4] = st.ry;
|
||||
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
|
||||
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
|
||||
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
|
||||
r[8] = st.l2; // L2 analog (z)
|
||||
r[9] = st.r2; // R2 analog (rz)
|
||||
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
|
||||
// r[12] temperature stays 0
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12
|
||||
}
|
||||
for (i, v) in st.accel.iter().enumerate() {
|
||||
r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18
|
||||
}
|
||||
// r[25..30] reserved2.
|
||||
// status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report
|
||||
// wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad.
|
||||
r[30] = 0x10 | 0x0B;
|
||||
// r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0.
|
||||
r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one)
|
||||
r[34] = ts as u8; // touch_reports[0].timestamp
|
||||
pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0
|
||||
pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1
|
||||
// remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero
|
||||
}
|
||||
|
||||
/// What one feedback pass extracted from the device's HID output reports. Rumble rides the universal
|
||||
/// 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player LEDs or adaptive
|
||||
/// triggers, so those never appear).
|
||||
#[derive(Default)]
|
||||
pub struct Ds4Feedback {
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Lightbar RGB, if the report carried it (deduped by the manager).
|
||||
pub led: Option<(u8, u8, u8)>,
|
||||
}
|
||||
|
||||
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
|
||||
/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1],
|
||||
/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5],
|
||||
/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write
|
||||
/// doesn't masquerade as a lightbar change.
|
||||
pub fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) {
|
||||
if data.first() != Some(&0x05) || data.len() < 11 {
|
||||
return; // not the USB output report (BT 0x11 is shifted) / too short
|
||||
}
|
||||
let flag0 = data[1];
|
||||
if flag0 & 0x01 != 0 {
|
||||
// motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4];
|
||||
// scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends.
|
||||
let low = (data[5] as u16) << 8;
|
||||
let high = (data[4] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
}
|
||||
if flag0 & 0x02 != 0 {
|
||||
fb.led = Some((data[6], data[7], data[8]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets.
|
||||
#[test]
|
||||
fn serialize_offsets() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let mut st = DsState::from_gamepad(
|
||||
gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB,
|
||||
16384, // lx (right)
|
||||
0,
|
||||
0,
|
||||
-32768, // ry (down) — inverted to 0xFF
|
||||
200, // L2
|
||||
0,
|
||||
);
|
||||
st.gyro = [0x0102, 0x0304, 0x0506];
|
||||
st.accel = [0x1112, 0x1314, 0x1516];
|
||||
st.touch[0] = Touch {
|
||||
active: true,
|
||||
id: 0,
|
||||
x: 100,
|
||||
y: 200,
|
||||
};
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &st, 0, 0);
|
||||
assert_eq!(r[0], 0x01); // report id
|
||||
assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5)
|
||||
assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up)
|
||||
assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit
|
||||
assert_eq!(r[6] & 0x01, 0x01); // L1
|
||||
// gyro le16 at 13..19, accel le16 at 19..25.
|
||||
assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]);
|
||||
assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]);
|
||||
assert_eq!(r[33], 1); // one touch frame
|
||||
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
|
||||
assert_eq!(r[35] & 0x7F, 0); // contact id 0
|
||||
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
|
||||
}
|
||||
|
||||
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
|
||||
/// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched.
|
||||
#[test]
|
||||
fn parse_output_rumble_and_lightbar() {
|
||||
let mut report = [0u8; 32];
|
||||
report[0] = 0x05;
|
||||
report[1] = 0x01 | 0x02; // MOTOR | LED
|
||||
report[4] = 0x40; // motor_right (weak/high)
|
||||
report[5] = 0x80; // motor_left (strong/low)
|
||||
report[6] = 0x11; // R
|
||||
report[7] = 0x22; // G
|
||||
report[8] = 0x33; // B
|
||||
let mut fb = Ds4Feedback::default();
|
||||
parse_ds4_output(&report, &mut fb);
|
||||
assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak)
|
||||
assert_eq!(fb.led, Some((0x11, 0x22, 0x33)));
|
||||
|
||||
let mut motor_only = [0u8; 32];
|
||||
motor_only[0] = 0x05;
|
||||
motor_only[1] = 0x01; // MOTOR only
|
||||
motor_only[5] = 0x10;
|
||||
let mut fb2 = Ds4Feedback::default();
|
||||
parse_ds4_output(&motor_only, &mut fb2);
|
||||
assert!(fb2.rumble.is_some());
|
||||
assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user