diff --git a/clients/probe/src/main.rs b/clients/probe/src/main.rs index 112fd4ce..399704f0 100644 --- a/clients/probe/src/main.rs +++ b/clients/probe/src/main.rs @@ -1156,7 +1156,7 @@ async fn session(args: Args) -> Result<()> { if args.rich_input_test { let conn2 = conn.clone(); tokio::spawn(async move { - use punktfunk_core::input::gamepad::AXIS_LS_X; + use punktfunk_core::input::gamepad::{AXIS_LS_X, MOTION_ACCEL_LSB_PER_G}; use punktfunk_core::quic::RichInput; tokio::time::sleep(std::time::Duration::from_secs(2)).await; // A neutral gamepad axis event makes the host create the virtual DualSense pad 0. @@ -1184,12 +1184,14 @@ async fn session(args: Args) -> Result<()> { for i in 0..60u32 { let x = ((i * 65535) / 60) as u16; let _ = conn2.send_datagram(touch(true, x, 32768).encode().into()); - let g = (((i as i32 % 20) - 10) * 500) as i16; // gyro wobble + let g = (((i as i32 % 20) - 10) * 500) as i16; // gyro wobble, ±250 °/s let _ = conn2.send_datagram( RichInput::Motion { pad: 0, gyro: [g, 0, 0], - accel: [0, 0, 16384], + // At rest, gravity is 1 g on +Z — in WIRE units, which are 10000 LSB/g + // and not the 8192 or 16384 a particular driver happens to use. + accel: [0, 0, MOTION_ACCEL_LSB_PER_G as i16], } .encode() .into(), diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 80a50293..e8a1d826 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -39,11 +39,13 @@ use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -/// Motion scale constants, shared convention with the Swift client (`GamepadWire`): -/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands -/// us gyro in rad/s and accel in m/s²; the DualSense report wants raw LSBs. -const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI; -const ACCEL_LSB_PER_G: f32 = 10_000.0; +/// Motion scale constants, shared convention with the Swift client (`GamepadWire`): the wire's +/// units ([`wire::MOTION_GYRO_LSB_PER_DEG_S`] / [`wire::MOTION_ACCEL_LSB_PER_G`]), which the host's +/// fixed calibration blobs declare back to their own consumers. SDL hands us gyro in rad/s and +/// accel in m/s²; the DualSense report wants raw LSBs. +const GYRO_LSB_PER_RAD_S: f32 = + wire::MOTION_GYRO_LSB_PER_DEG_S as f32 * 180.0 / std::f32::consts::PI; +const ACCEL_LSB_PER_G: f32 = wire::MOTION_ACCEL_LSB_PER_G as f32; const G: f32 = 9.80665; /// The controller "escape" chord (Moonlight convention): L1 + R1 + Start + Select held @@ -858,6 +860,10 @@ struct Slot { /// close lift a click held across detach/unplug. held_clicks: [bool; 2], last_accel: [i16; 3], + /// This slot has put at least one motion sample on the wire, so the host is holding one. + /// Gates the zero-gyro park in [`Worker::flush_slot`] — a pad with no gyro must not start + /// looking like one just because it closed. + sent_motion: bool, /// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's /// `guide_gesture` policy is on. gesture: SelectGesture, @@ -884,6 +890,7 @@ impl Slot { surface_last: [(0, 0, false); 2], held_clicks: [false; 2], last_accel: [0; 3], + sent_motion: false, gesture: SelectGesture::default(), audio_caps: 0, rumble_suppressed_logged: false, @@ -1480,6 +1487,19 @@ impl Worker { }; let _ = c.send_rich_input(rich); } + // Park motion. Gyro is level-triggered host-side — the last sample is preserved across + // button frames and re-emitted by the pad heartbeat — so a slot closing mid-rotation + // leaves the virtual pad turning, and a game integrating gyro aim turns with it. The host + // has an idle watchdog for the cases nobody can flush (a dropped link); this is the case + // we can, so take it immediately. Acceleration is kept: gravity doesn't stop when the + // session does. + if std::mem::take(&mut slot.sent_motion) { + let _ = c.send_rich_input(RichInput::Motion { + pad, + gyro: [0; 3], + accel: slot.last_accel, + }); + } } /// True when any one forwarded pad holds the entire escape chord (any player can leave). @@ -1992,6 +2012,7 @@ impl Worker { for (i, v) in data.iter().enumerate() { gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16; } + slot.sent_motion = true; let _ = c.send_rich_input(RichInput::Motion { pad: slot.index, gyro, diff --git a/crates/pf-inject/src/inject/linux/dualsense.rs b/crates/pf-inject/src/inject/linux/dualsense.rs index 2e90f5f0..b852a523 100644 --- a/crates/pf-inject/src/inject/linux/dualsense.rs +++ b/crates/pf-inject/src/inject/linux/dualsense.rs @@ -17,6 +17,7 @@ use super::dualsense_proto::{ DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC, }; +use crate::sensor_clock::SensorClock; use crate::uhid_abi::{ put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE, UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, @@ -28,6 +29,7 @@ use punktfunk_core::quic::RichInput; use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::OpenOptionsExt; +use std::time::Instant; /// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same /// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra @@ -71,7 +73,7 @@ impl DsUhidIdentity { pub struct DualSensePad { fd: File, seq: u8, - ts: u32, + clock: SensorClock, } impl DualSensePad { @@ -86,7 +88,11 @@ impl DualSensePad { .with_context(|| { format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)") })?; - let mut ds = DualSensePad { fd, seq: 0, ts: 0 }; + let mut ds = DualSensePad { + fd, + seq: 0, + clock: SensorClock::dualsense(), + }; ds.send_create2(index, id) .context("UHID_CREATE2 DualSense")?; Ok(ds) @@ -116,9 +122,9 @@ impl DualSensePad { /// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2). pub fn write_state(&mut self, st: &DsState) -> Result<()> { self.seq = self.seq.wrapping_add(1); - self.ts = self.ts.wrapping_add(1); // monotonic sensor timestamp is all the kernel needs + let ts = self.clock.ds_ticks(Instant::now()); let mut r = [0u8; DS_INPUT_REPORT_LEN]; - serialize_state(&mut r, st, self.seq, self.ts); + serialize_state(&mut r, st, self.seq, ts); let mut ev = [0u8; UHID_EVENT_SIZE]; ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes()); @@ -275,6 +281,14 @@ impl PadProto for DsLinuxProto { st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H); } + fn neutralize_gyro(&self, st: &mut DsState) -> bool { + st.neutralize_gyro() + } + + fn clear_rich(&self, st: &mut DsState) { + st.clear_rich(); + } + fn write_state(&self, pad: &mut DualSensePad, st: &DsState) { let _ = pad.write_state(st); } @@ -368,6 +382,14 @@ impl PadProto for DsEdgeLinuxProto { st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H); } + fn neutralize_gyro(&self, st: &mut DsState) -> bool { + st.neutralize_gyro() + } + + fn clear_rich(&self, st: &mut DsState) { + st.clear_rich(); + } + fn write_state(&self, pad: &mut DualSensePad, st: &DsState) { let _ = pad.write_state(st); } diff --git a/crates/pf-inject/src/inject/linux/dualshock4.rs b/crates/pf-inject/src/inject/linux/dualshock4.rs index fd37d9ee..8929b2ec 100644 --- a/crates/pf-inject/src/inject/linux/dualshock4.rs +++ b/crates/pf-inject/src/inject/linux/dualshock4.rs @@ -9,15 +9,17 @@ //! button (the DS4 hardware has none), so the only feedback it surfaces is motor rumble (universal //! 0xCA plane) and the lightbar (HID-output 0xCD `Led`). The button/stick/dpad/touchpad mapping is //! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; the -//! report codec (input `0x01` serializer, output `0x05` parser, touch dims) is the pure -//! [`super::dualshock4_proto`], shared with the Windows UMDF backend — this module is only the -//! `/dev/uhid` transport plus the report descriptor + feature-report handshake the kernel needs. +//! report codec (input `0x01` serializer, output `0x05` parser, touch dims, and the feature blobs +//! the kernel GET_REPORTs) is the pure [`super::dualshock4_proto`], shared with the Windows UMDF +//! backend — this module is only the `/dev/uhid` transport plus the report descriptor and the +//! handshake that answers those GET_REPORTs. use super::dualsense_proto::DsState; use super::dualshock4_proto::{ - parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H, - DS4_TOUCH_W, DS4_VENDOR, + ds4_pairing_reply, parse_ds4_output, serialize_state, Ds4Feedback, DS4_FEATURE_CALIBRATION, + DS4_FEATURE_FIRMWARE, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H, DS4_TOUCH_W, DS4_VENDOR, }; +use crate::sensor_clock::SensorClock; use crate::uhid_abi::{ put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE, UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, @@ -29,60 +31,7 @@ use punktfunk_core::quic::{HidOutput, RichInput}; use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::OpenOptionsExt; - -// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is -// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the -// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are -// non-fatal (the kernel warns + falls back to identity IMU calibration), but we answer them for -// correct motion scaling. Each array's first byte is the report id (the kernel hard-checks it). -#[rustfmt::skip] -const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01) - 0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -]; - -/// The pairing reply for wire pad `pad`: [`DS4_FEATURE_PAIRING`] with the MAC's low octet offset -/// by the pad index — same per-pad-serial contract as the DualSense's -/// [`ds_pairing_reply`](super::dualsense_proto::ds_pairing_reply): the kernel adopts the MAC as -/// the HID uniq, and SDL/Steam dedup controllers by that serial. -fn ds4_pairing_reply(pad: u8) -> [u8; 16] { - let mut r = [0u8; 16]; - r.copy_from_slice(DS4_FEATURE_PAIRING); - r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first - r -} -#[rustfmt::skip] -const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words) - 0x02, - 0x00, 0x00, // gyro_pitch_bias = 0 - 0x00, 0x00, // gyro_yaw_bias = 0 - 0x00, 0x00, // gyro_roll_bias = 0 - 0x10, 0x00, // gyro_pitch_plus = +16 - 0xF0, 0xFF, // gyro_pitch_minus = -16 - 0x10, 0x00, // gyro_yaw_plus = +16 - 0xF0, 0xFF, // gyro_yaw_minus = -16 - 0x10, 0x00, // gyro_roll_plus = +16 - 0xF0, 0xFF, // gyro_roll_minus = -16 - 0x20, 0x00, // gyro_speed_plus = +32 - 0x20, 0x00, // gyro_speed_minus = +32 - 0x00, 0x20, // acc_x_plus = +8192 - 0x00, 0xE0, // acc_x_minus = -8192 - 0x00, 0x20, // acc_y_plus = +8192 - 0x00, 0xE0, // acc_y_minus = -8192 - 0x00, 0x20, // acc_z_plus = +8192 - 0x00, 0xE0, // acc_z_minus = -8192 - 0x00, 0x00, // trailing pad (descriptor declares 36 data bytes) -]; -#[rustfmt::skip] -const DS4_FEATURE_FIRMWARE: &[u8] = &[ // report 0xa3 (build date string + hw/fw versions; cosmetic) - 0xA3, 0x41, 0x75, 0x67, 0x20, 0x20, 0x33, 0x20, 0x32, 0x30, 0x31, 0x33, // "Aug 3 2013" - 0x00, 0x00, 0x00, 0x00, 0x00, - 0x30, 0x37, 0x3A, 0x30, 0x31, 0x3A, 0x31, 0x32, // "07:01:12" - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xA0, // hw_version = 0xA000 (buf[35]) - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x01, // fw_version = 0x0100 (buf[41]) - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // trailing pad (buf[43..49]) → 49 bytes total -]; +use std::time::Instant; /// Sony DualShock 4 v2 USB HID report descriptor (507 bytes) — a verbatim real-device capture /// (CUH-ZCT2E, `054C:09CC`). Declares input `0x01` (64 B), output `0x05` (32 B), and the feature @@ -140,7 +89,7 @@ const DS4_RDESC: &[u8] = &[ pub struct DualShock4Pad { fd: File, counter: u8, - ts: u16, + clock: SensorClock, } impl DualShock4Pad { @@ -157,7 +106,7 @@ impl DualShock4Pad { let mut ds = DualShock4Pad { fd, counter: 0, - ts: 0, + clock: SensorClock::dualshock4(), }; ds.send_create2(index).context("UHID_CREATE2 DualShock4")?; Ok(ds) @@ -187,9 +136,9 @@ impl DualShock4Pad { /// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2). pub fn write_state(&mut self, st: &DsState) -> Result<()> { self.counter = self.counter.wrapping_add(1); - self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units + let ts = self.clock.ds4_ticks(Instant::now()); let mut r = [0u8; DS4_INPUT_REPORT_LEN]; - serialize_state(&mut r, st, self.counter, self.ts); + serialize_state(&mut r, st, self.counter, ts); let mut ev = [0u8; UHID_EVENT_SIZE]; ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes()); @@ -346,6 +295,14 @@ impl PadProto for Ds4LinuxProto { st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H); } + fn neutralize_gyro(&self, st: &mut DsState) -> bool { + st.neutralize_gyro() + } + + fn clear_rich(&self, st: &mut DsState) { + st.clear_rich(); + } + fn write_state(&self, pad: &mut DualShock4Pad, st: &DsState) { let _ = pad.write_state(st); } @@ -383,6 +340,7 @@ pub type DualShock4Manager = UhidManager; #[cfg(test)] mod tests { use super::*; + use crate::dualshock4_proto::DS4_FEATURE_PAIRING; // The report 0x01 serializer + output 0x05 parser are covered in `dualshock4_proto` (the codec // is shared with the Windows backend); only the UHID-transport-specific pieces are tested here. diff --git a/crates/pf-inject/src/inject/linux/steam_controller.rs b/crates/pf-inject/src/inject/linux/steam_controller.rs index 12e716bb..5bd26231 100644 --- a/crates/pf-inject/src/inject/linux/steam_controller.rs +++ b/crates/pf-inject/src/inject/linux/steam_controller.rs @@ -422,6 +422,14 @@ impl PadProto for SteamProto { st.apply_rich(rich); } + fn neutralize_gyro(&self, st: &mut SteamState) -> bool { + st.neutralize_gyro() + } + + fn clear_rich(&self, st: &mut SteamState) { + st.clear_rich(); + } + fn write_state(&self, pad: &mut DeckTransport, st: &SteamState) { pad.write_state(st); } @@ -544,6 +552,14 @@ impl PadProto for ScProto { st.apply_rich(rich); } + fn neutralize_gyro(&self, st: &mut SteamState) -> bool { + st.neutralize_gyro() + } + + fn clear_rich(&self, st: &mut SteamState) { + st.clear_rich(); + } + fn write_state(&self, pad: &mut SteamDeckPad, st: &SteamState) { let _ = pad.write_state(st); } diff --git a/crates/pf-inject/src/inject/linux/steam_controller2.rs b/crates/pf-inject/src/inject/linux/steam_controller2.rs index ad9c0f9a..acd8efd0 100644 --- a/crates/pf-inject/src/inject/linux/steam_controller2.rs +++ b/crates/pf-inject/src/inject/linux/steam_controller2.rs @@ -346,6 +346,12 @@ impl PadProto for TritonProto { // and the synth fallback has no surface for them. } + // `neutralize_gyro` / `clear_rich` stay the no-op defaults: this backend never sees a + // `RichInput::Motion` to go stale, and its motion lives inside an opaque passthrough report + // whose bytes we would have to reach into blind. A raw feed that stops is the client's own + // device report stopping, so the same last-report re-emission applies here — worth revisiting + // if SC2 gyro ever shows the phantom-rotation signature the DualSense family had. + fn write_state(&self, pad: &mut TritonTransport, st: &TritonState) { pad.write_state(st); } diff --git a/crates/pf-inject/src/inject/linux/switch_pro.rs b/crates/pf-inject/src/inject/linux/switch_pro.rs index c6e5e104..015169ee 100644 --- a/crates/pf-inject/src/inject/linux/switch_pro.rs +++ b/crates/pf-inject/src/inject/linux/switch_pro.rs @@ -288,6 +288,14 @@ impl PadProto for SwitchProProto { } } + fn neutralize_gyro(&self, st: &mut SwitchState) -> bool { + st.neutralize_gyro() + } + + fn clear_rich(&self, st: &mut SwitchState) { + st.clear_rich(); + } + fn write_state(&self, pad: &mut SwitchProPad, st: &SwitchState) { let _ = pad.write_state(st); } diff --git a/crates/pf-inject/src/inject/pad_slots.rs b/crates/pf-inject/src/inject/pad_slots.rs index 8538fab7..e84a74a9 100644 --- a/crates/pf-inject/src/inject/pad_slots.rs +++ b/crates/pf-inject/src/inject/pad_slots.rs @@ -16,6 +16,22 @@ const _: () = assert!(MAX_PADS <= 16); /// quiet. const SWEEP_GRACE: Duration = Duration::from_millis(300); +/// What one [`PadSlots::sweep`] changed, as bitmasks over the wire pad indices. +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] +pub struct Sweep { + /// Slots whose pad was torn down because its grace ran out — the caller resets their + /// per-index sibling state. + pub dropped: u16, + /// Slots whose `active_mask` bit returned *inside* the grace window. The debounce did its job + /// and no devnode flapped — but the pad that comes back is not necessarily the pad that left: + /// unplug one controller and plug another into the same wire index within [`SWEEP_GRACE`] and + /// the new one drives the old one's live virtual pad, skipping the create path (and therefore + /// the manager's reset) entirely. Anything the manager persists on the client's behalf — + /// touch contacts, motion — is the previous controller's and has to go; a pad with no gyro + /// would otherwise inherit the last one's rotation and never send a sample to correct it. + pub reclaimed: u16, +} + /// The slot table + lifecycle every virtual-pad manager repeats: `Vec>` keyed by wire pad /// index, the `active_mask` unplug sweep, and the [`PadGate`]-guarded create. Extracted verbatim /// from seven copy-pasted managers (G12) so a lifecycle fix lands once, not seven times. @@ -63,16 +79,16 @@ impl

PadSlots

{ } /// Fold one state frame's `active_mask` into the grace clocks, then drop whatever has run out - /// (see [`Self::reap`]). Returns the dropped indices as a bitmask so the caller resets its - /// per-index sibling state; an index another manager owns is `None` here, so it is never - /// touched. The grace is the devnode-churn debounce: a mask that glitches clear for a few - /// frames and returns re-arms nothing. + /// (see [`Self::reap`]). Returns what changed so the caller can fix up its per-index sibling + /// state; an index another manager owns is `None` here, so it is never touched. The grace is + /// the devnode-churn debounce: a mask that glitches clear for a few frames and returns re-arms + /// nothing. /// /// A frame can only ARM the grace, never complete it — no time has passed at the instant the /// clock starts. Since the producer emits exactly ONE frame per detach, [`Self::reap`] on the /// manager's periodic pump is what actually finishes the unplug; a backend that only ever /// called `sweep` would keep the detached pad alive for the rest of the session. - pub fn sweep(&mut self, active_mask: u16) -> u16 { + pub fn sweep(&mut self, active_mask: u16) -> Sweep { self.sweep_at(active_mask, Instant::now()) } @@ -98,15 +114,24 @@ impl

PadSlots

{ /// [`Self::sweep`] with an injectable clock (unit tests drive the grace window): arm or disarm /// each slot's clock from the mask, then reap whatever has already run out. - fn sweep_at(&mut self, active_mask: u16, now: Instant) -> u16 { + fn sweep_at(&mut self, active_mask: u16, now: Instant) -> Sweep { + let mut reclaimed = 0u16; for i in 0..MAX_PADS { if active_mask & (1 << i) != 0 { - self.inactive_since[i] = None; // active (again): a glitch never reaches the drop + // Active (again): a glitch never reaches the drop. If a clock WAS armed, this slot + // just handed a live pad to whatever controller is present now, without passing + // through `ensure` — see `Sweep::reclaimed`. + if self.inactive_since[i].take().is_some() { + reclaimed |= 1 << i; + } } else if self.pads[i].is_some() && self.inactive_since[i].is_none() { self.inactive_since[i] = Some(now); // newly inactive — start the grace } } - self.reap_at(now) + Sweep { + dropped: self.reap_at(now), + reclaimed, + } } /// [`Self::reap`] with an injectable clock. Deliberately arms nothing — it only ever reads @@ -195,7 +220,7 @@ mod tests { assert!(s.ensure(2, |i| Ok(i as u32))); assert_eq!( s.sweep(0b0), - 0, + Sweep::default(), "a frame arms the grace but cannot itself drop" ); assert!(s.get(2).is_some()); @@ -227,16 +252,35 @@ mod tests { // comes back must not churn a PnP devnode. let mut s = slots(); assert!(s.ensure(0, |i| Ok(i as u32))); - assert_eq!(s.sweep(0b0), 0); // bit clears — arms only + assert_eq!(s.sweep(0b0), Sweep::default()); // bit clears — arms only for _ in 0..5 { assert_eq!(s.reap(), 0, "dropped a pad inside its grace"); } - assert_eq!(s.sweep(0b1), 0); // the bit returns — disarms + // The bit returns — disarms, and reports the re-claim: the pad survived, but whoever is + // driving it now may not be the controller that armed the clock. + assert_eq!( + s.sweep(0b1), + Sweep { + dropped: 0, + reclaimed: 1, + } + ); s.expire_grace(); assert_eq!(s.reap(), 0, "a returned bit must leave nothing armed"); assert!(s.get(0).is_some()); } + #[test] + fn a_mask_that_never_went_clear_is_not_a_reclaim() { + // `reclaimed` must mean "came back inside the grace", not "is present" — a steady-state + // frame stream would otherwise clear the client's touch and motion on every single frame. + let mut s = slots(); + assert!(s.ensure(0, |i| Ok(i as u32))); + for _ in 0..5 { + assert_eq!(s.sweep(0b1), Sweep::default()); + } + } + #[test] fn ensure_creates_once_and_reports_freshness() { let mut s = slots(); @@ -259,16 +303,25 @@ mod tests { // Mask keeps 2, clears 0 and 5; empty slots (1, 3, …) are untouched non-events. The // first sweep only ARMS the grace clock… let t0 = Instant::now(); - assert_eq!(s.sweep_at(0b0000_0100, t0), 0); + assert_eq!(s.sweep_at(0b0000_0100, t0), Sweep::default()); assert_eq!(s.get(0), Some(&0), "still inside the grace"); // …and the drop lands once the bits have stayed clear for the whole grace. let swept = s.sweep_at(0b0000_0100, t0 + SWEEP_GRACE); - assert_eq!(swept, 0b0010_0001); + assert_eq!( + swept, + Sweep { + dropped: 0b0010_0001, + reclaimed: 0, + } + ); assert_eq!(s.get(0), None); assert_eq!(s.get(2), Some(&2)); assert_eq!(s.get(5), None); // A further identical sweep is a no-op: the indices were returned exactly once. - assert_eq!(s.sweep_at(0b0000_0100, t0 + SWEEP_GRACE * 2), 0); + assert_eq!( + s.sweep_at(0b0000_0100, t0 + SWEEP_GRACE * 2), + Sweep::default() + ); } #[test] @@ -278,9 +331,22 @@ mod tests { let mut s = slots(); assert!(s.ensure(1, |_| Ok(7))); let t0 = Instant::now(); - assert_eq!(s.sweep_at(0, t0), 0); // bit clears — grace armed - assert_eq!(s.sweep_at(0b0000_0010, t0 + SWEEP_GRACE / 2), 0); // bit returns — disarmed - assert_eq!(s.sweep_at(0b0000_0010, t0 + SWEEP_GRACE * 10), 0); + // The bit clears — grace armed. + assert_eq!(s.sweep_at(0, t0), Sweep::default()); + // The bit returns: disarmed, and reported as a re-claim (the pad lives, but its owner may + // have changed — see `Sweep::reclaimed`). + assert_eq!( + s.sweep_at(0b0000_0010, t0 + SWEEP_GRACE / 2), + Sweep { + dropped: 0, + reclaimed: 0b0000_0010, + } + ); + // …and once, not on every subsequent frame. + assert_eq!( + s.sweep_at(0b0000_0010, t0 + SWEEP_GRACE * 10), + Sweep::default() + ); assert_eq!(s.get(1), Some(&7), "the glitch never reached the drop"); } diff --git a/crates/pf-inject/src/inject/proto/dualsense_proto.rs b/crates/pf-inject/src/inject/proto/dualsense_proto.rs index c961843e..1f59c880 100644 --- a/crates/pf-inject/src/inject/proto/dualsense_proto.rs +++ b/crates/pf-inject/src/inject/proto/dualsense_proto.rs @@ -234,6 +234,26 @@ impl DsState { } } + /// Zero angular velocity, keeping acceleration (gravity is legitimately persistent) and + /// everything else. Returns whether anything changed — the host's idle-motion watchdog, + /// `PadProto::neutralize_gyro`. + pub fn neutralize_gyro(&mut self) -> bool { + let changed = self.gyro != [0; 3]; + self.gyro = [0; 3]; + changed + } + + /// Reset the rich-plane fields — touch contacts, pad clicks, motion — to a fresh pad's, + /// leaving buttons/sticks/triggers alone. `PadProto::clear_rich`: a controller that took over + /// this slot inside the replug grace must not inherit the last one's finger or rotation. + pub fn clear_rich(&mut self) { + let fresh = DsState::neutral(); + self.touch = fresh.touch; + self.touch_click = fresh.touch_click; + self.gyro = fresh.gyro; + self.accel = fresh.accel; + } + /// 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 diff --git a/crates/pf-inject/src/inject/proto/dualshock4_proto.rs b/crates/pf-inject/src/inject/proto/dualshock4_proto.rs index d3955dcb..22e93643 100644 --- a/crates/pf-inject/src/inject/proto/dualshock4_proto.rs +++ b/crates/pf-inject/src/inject/proto/dualshock4_proto.rs @@ -2,12 +2,13 @@ //! UMDF-driver backend ([`super::dualshock4_windows`]) and the Linux UHID backend //! ([`super::dualshock4`]). //! -//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4 -//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper -//! ([`DsState::from_gamepad`]) — only the report *byte layout*, the touchpad resolution, and the -//! feedback report differ. The Linux backend writes report `0x01` to `/dev/uhid` and reads `0x05` via -//! `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF driver and pulls `0x05` back over its -//! shared-memory channel — both build/parse the exact same bytes here. +//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec and the fixed feature +//! blobs, 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. @@ -24,6 +25,82 @@ pub const DS4_INPUT_REPORT_LEN: usize = 64; pub const DS4_TOUCH_W: u16 = 1920; pub const DS4_TOUCH_H: u16 = 942; +// Feature reports the host stack GET_REPORTs during DS4 init, the PS4 counterpart of +// `dualsense_proto`'s DS_FEATURE_* blobs. PAIRING (0x12) is MANDATORY — without a valid reply +// `dualshock4_create()` aborts and creates NO input devices; the kernel reads the 6-byte device MAC +// from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are non-fatal (the kernel warns and falls +// back to identity IMU calibration), but we answer them so motion scales correctly. Each array's +// first byte is the report id (the kernel hard-checks it). +#[rustfmt::skip] +pub const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01) + 0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +/// IMU calibration (report `0x02`) — the numbers that decide what a *degree per second* means to +/// every consumer of this pad. +/// +/// A consumer (kernel `hid-playstation`, SDL's `SDL_hidapi_ps4`) derives its scale from this blob, +/// it does not assume one: gyro resolution = `(|pitch_plus| + |pitch_minus|) / (speed_plus + +/// speed_minus)` LSB per °/s, accel resolution = `(acc_plus - acc_minus) / 2` LSB per g. So the +/// blob is where the wire contract +/// ([`MOTION_GYRO_LSB_PER_DEG_S`](punktfunk_core::input::gamepad::MOTION_GYRO_LSB_PER_DEG_S)) is +/// *declared* on this backend, and it must state exactly what the wire delivers. These values are +/// the DualSense blob's, which is the same statement in the same units — deliberately, since both +/// pads consume the identical wire sample. +/// +/// ⚠ The per-axis order is INTERLEAVED (`pitch±`, `yaw±`, `roll±`), which is the **USB** layout; +/// Bluetooth groups all three plusses first. Our virtual pad declares `BUS_USB`, so interleaved is +/// correct — do not "fix" it to grouped. +/// +/// The Windows UMDF driver serves its own copy of this blob +/// (`packaging/windows/drivers/pf-gamepad/src/lib.rs`) because it lives in a separate WDK +/// workspace and cannot depend on this crate; the `motion_contract` test derives the units from +/// *that* file's source too, so the two can't drift. +#[rustfmt::skip] +pub const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words) + 0x02, + 0x00, 0x00, // gyro_pitch_bias = 0 + 0x00, 0x00, // gyro_yaw_bias = 0 + 0x00, 0x00, // gyro_roll_bias = 0 + 0x10, 0x27, // gyro_pitch_plus = +10000 + 0xF0, 0xD8, // gyro_pitch_minus = -10000 + 0x10, 0x27, // gyro_yaw_plus = +10000 + 0xF0, 0xD8, // gyro_yaw_minus = -10000 + 0x10, 0x27, // gyro_roll_plus = +10000 + 0xF0, 0xD8, // gyro_roll_minus = -10000 + 0xF4, 0x01, // gyro_speed_plus = +500 ⇒ 20000/1000 = 20 LSB per °/s + 0xF4, 0x01, // gyro_speed_minus = +500 + 0x10, 0x27, // acc_x_plus = +10000 ⇒ 20000/2 = 10000 LSB per g + 0xF0, 0xD8, // acc_x_minus = -10000 + 0x10, 0x27, // acc_y_plus = +10000 + 0xF0, 0xD8, // acc_y_minus = -10000 + 0x10, 0x27, // acc_z_plus = +10000 + 0xF0, 0xD8, // acc_z_minus = -10000 + 0x00, 0x00, // trailing pad (descriptor declares 36 data bytes) +]; +#[rustfmt::skip] +pub const DS4_FEATURE_FIRMWARE: &[u8] = &[ // report 0xa3 (build date string + hw/fw versions; cosmetic) + 0xA3, 0x41, 0x75, 0x67, 0x20, 0x20, 0x33, 0x20, 0x32, 0x30, 0x31, 0x33, // "Aug 3 2013" + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x30, 0x37, 0x3A, 0x30, 0x31, 0x3A, 0x31, 0x32, // "07:01:12" + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xA0, // hw_version = 0xA000 (buf[35]) + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, // fw_version = 0x0100 (buf[41]) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // trailing pad (buf[43..49]) → 49 bytes total +]; + +/// The pairing reply (report `0x12`) for wire pad `pad`: [`DS4_FEATURE_PAIRING`] with the MAC's low +/// octet offset by the pad index — same per-pad-serial contract as the DualSense's +/// [`ds_pairing_reply`](super::dualsense_proto::ds_pairing_reply): the kernel adopts the MAC as the +/// HID uniq, and SDL/Steam dedup controllers by that serial. +pub fn ds4_pairing_reply(pad: u8) -> [u8; 16] { + let mut r = [0u8; 16]; + r.copy_from_slice(DS4_FEATURE_PAIRING); + r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first + r +} + /// 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) { diff --git a/crates/pf-inject/src/inject/proto/steam_proto.rs b/crates/pf-inject/src/inject/proto/steam_proto.rs index 125894c7..fa3ef8bf 100644 --- a/crates/pf-inject/src/inject/proto/steam_proto.rs +++ b/crates/pf-inject/src/inject/proto/steam_proto.rs @@ -172,6 +172,33 @@ impl SteamState { SteamState::default() } + /// Zero angular velocity, keeping acceleration (gravity is legitimately persistent) and + /// everything else. Returns whether anything changed — the host's idle-motion watchdog, + /// `PadProto::neutralize_gyro`. + pub fn neutralize_gyro(&mut self) -> bool { + let changed = self.gyro != [0; 3]; + self.gyro = [0; 3]; + changed + } + + /// Reset the rich-plane fields — both trackpads' position/pressure/click, and motion — to a + /// fresh pad's, leaving buttons/sticks/triggers alone. `PadProto::clear_rich`: a controller + /// that took over this slot inside the replug grace must not inherit the last one's finger or + /// rotation. + pub fn clear_rich(&mut self) { + let fresh = SteamState::neutral(); + self.lpad_x = fresh.lpad_x; + self.lpad_y = fresh.lpad_y; + self.rpad_x = fresh.rpad_x; + self.rpad_y = fresh.rpad_y; + self.lpad_pressure = fresh.lpad_pressure; + self.rpad_pressure = fresh.rpad_pressure; + self.lpad_click = fresh.lpad_click; + self.rpad_click = fresh.rpad_click; + self.gyro = fresh.gyro; + self.accel = fresh.accel; + } + /// Set/clear a button (or group) by its [`btn`] mask. pub fn press(&mut self, mask: u64, down: bool) { if down { diff --git a/crates/pf-inject/src/inject/proto/steam_remap.rs b/crates/pf-inject/src/inject/proto/steam_remap.rs index 6cb5ae97..cc3e62d8 100644 --- a/crates/pf-inject/src/inject/proto/steam_remap.rs +++ b/crates/pf-inject/src/inject/proto/steam_remap.rs @@ -76,13 +76,15 @@ pub fn fold_paddles(mut buttons: u32, policy: PaddleFallback) -> u32 { buttons } -// Motion rescale. The wire uses the DualSense convention (20 LSB/°·s gyro, 10000 LSB/g accel — the -// scale every client capture applies). The Steam Deck's `hid-steam` report wants 16 LSB/°·s and -// 16384 LSB/g, so the Deck backend rescales; the DualSense / DS4 backends consume the wire 1:1. +// Motion rescale. The wire uses the DualSense convention (`gs::MOTION_*` — the scale every client +// capture applies); the Steam Deck's `hid-steam` fixes STEAM_DECK_GYRO_RES_PER_DPS = 16 and +// STEAM_DECK_ACCEL_RES_PER_G = 16384, so the Deck backend rescales. The DualSense / DS4 backends +// consume the wire 1:1 instead, because their calibration blobs declare the wire's own units. +// pf-inject's `motion_contract` test pins both halves of that sentence. const GYRO_NUM: i32 = 16; -const GYRO_DEN: i32 = 20; +const GYRO_DEN: i32 = gs::MOTION_GYRO_LSB_PER_DEG_S; const ACCEL_NUM: i32 = 16384; -const ACCEL_DEN: i32 = 10000; +const ACCEL_DEN: i32 = gs::MOTION_ACCEL_LSB_PER_G; fn scale(v: i16, num: i32, den: i32) -> i16 { ((v as i32 * num) / den).clamp(i16::MIN as i32, i16::MAX as i32) as i16 diff --git a/crates/pf-inject/src/inject/proto/switch_proto.rs b/crates/pf-inject/src/inject/proto/switch_proto.rs index 8f1aa2b2..56b3734a 100644 --- a/crates/pf-inject/src/inject/proto/switch_proto.rs +++ b/crates/pf-inject/src/inject/proto/switch_proto.rs @@ -37,6 +37,14 @@ use punktfunk_core::input::gamepad as gs; pub const SWITCH_VENDOR: u32 = 0x057E; // Nintendo Co., Ltd pub const SWITCH_PRODUCT: u32 = 0x2009; // Pro Controller +/// The raw IMU resolutions `hid-nintendo` reports a Pro Controller at — its own +/// `JC_IMU_GYRO_RES_PER_DPS` (14.247, carried here in thousandths so the ratio stays exact) and +/// `JC_IMU_ACCEL_RES_PER_G`. Fixed by the driver, not by us: the factory-calibration blob we serve +/// is the driver's identity default, so it consumes our report at exactly these numbers. +const JC_IMU_GYRO_MILLI_RES_PER_DPS: i32 = 14_247; +/// See [`JC_IMU_GYRO_MILLI_RES_PER_DPS`]. +const JC_IMU_ACCEL_RES_PER_G: i32 = 4096; + /// Nintendo Switch Pro Controller **USB** HID report descriptor (203 bytes) — a verbatim /// real-device capture (usbhid-dump off a wired Pro Controller; three independent public /// captures agree byte-for-byte: mzyy94's usbhid-dump, ToadKing's full USB capture, and @@ -211,13 +219,32 @@ impl SwitchState { } } + /// Zero angular velocity, keeping acceleration (gravity is legitimately persistent) and + /// everything else. Returns whether anything changed — the host's idle-motion watchdog, + /// `PadProto::neutralize_gyro`. + pub fn neutralize_gyro(&mut self) -> bool { + let changed = self.gyro != [0; 3]; + self.gyro = [0; 3]; + changed + } + + /// Reset the rich-plane fields to a fresh pad's, leaving buttons/sticks alone — for the Pro + /// Controller that is motion only (it has no touchpad). `PadProto::clear_rich`. + pub fn clear_rich(&mut self) { + let fresh = SwitchState::neutral(); + self.gyro = fresh.gyro; + self.accel = fresh.accel; + } + /// Apply a wire motion sample (DualSense-convention units) as raw IMU values. No axis flip: /// both conventions are x-toward-triggers / z-up for a Pro Controller held like a DualSense, /// and the driver applies no negation for the Pro (only the right Joy-Con negates). pub fn apply_motion(&mut self, gyro: [i16; 3], accel: [i16; 3]) { - // gyro: wire 20 LSB/°·s → raw 14.247 LSB/°·s; accel: wire 10000 LSB/g → raw 4096 LSB/g. - self.gyro = gyro.map(|v| ((v as i32 * 14247) / 20000) as i16); - self.accel = accel.map(|v| ((v as i32 * 4096) / 10000) as i16); + // Wire units → the driver's raw units. Gyro is carried in thousandths so 14.247 stays exact. + let gyro_den = 1000 * gs::MOTION_GYRO_LSB_PER_DEG_S; + self.gyro = gyro.map(|v| ((v as i32 * JC_IMU_GYRO_MILLI_RES_PER_DPS) / gyro_den) as i16); + self.accel = accel + .map(|v| ((v as i32 * JC_IMU_ACCEL_RES_PER_G) / gs::MOTION_ACCEL_LSB_PER_G) as i16); } } diff --git a/crates/pf-inject/src/inject/sensor_clock.rs b/crates/pf-inject/src/inject/sensor_clock.rs new file mode 100644 index 00000000..3ddea639 --- /dev/null +++ b/crates/pf-inject/src/inject/sensor_clock.rs @@ -0,0 +1,141 @@ +//! The `sensor_timestamp` a virtual Sony pad stamps into every input report. +//! +//! Real hardware fills this field from its IMU's own clock, and it is the **only** time basis a +//! consumer has for the motion samples in the same report: `hid-playstation` forwards it as +//! `MSC_TIMESTAMP`, SDL reads it straight out of the report on Windows, and anything doing gyro aim +//! integrates angular velocity against the `dt` it implies. A clock in the wrong units doesn't look +//! broken — it looks like a controller whose sensitivity is off by that factor. +//! +//! Our virtual pads used to advance the field by a fixed amount per report: the DualSense by +1 raw +//! unit (0.33 µs, a clock running ~12000× slow — effectively frozen), the DualShock 4 by +188 +//! (~1 ms) regardless of the real 4–8 ms publish cadence. Both now stamp real elapsed time. +//! +//! The value is computed from the pad's first report rather than accumulated per report, so a +//! bursty or throttled publish loop cannot make the clock drift; the caller truncates it to the +//! field's width, which reproduces the wrap real hardware does (and which every consumer's +//! `prev > current` delta check already handles). + +use std::time::Instant; + +/// A monotonic sensor clock in one pad's tick units. Construct per pad — the epoch is that pad's +/// first report, so the field starts at 0 like a freshly enumerated device. +pub struct SensorClock { + epoch: Option, + /// Ticks per microsecond as an exact fraction, `ticks_num / ticks_den`. + ticks_num: u64, + ticks_den: u64, +} + +impl SensorClock { + /// DualSense: the u32 `sensor_timestamp` counts **1/3 µs** ticks — `hid-playstation` converts a + /// delta with `DIV_ROUND_CLOSEST(delta, 3)`. Wraps every ~23.9 minutes. + pub fn dualsense() -> SensorClock { + SensorClock::new(3, 1) + } + + /// DualShock 4: the u16 `sensor_timestamp` counts **16/3 µs** (≈5.33 µs) ticks — + /// `DIV_ROUND_CLOSEST(delta * 16, 3)`. Wraps every ~349 ms, which is normal and expected. + pub fn dualshock4() -> SensorClock { + SensorClock::new(3, 16) + } + + fn new(ticks_num: u64, ticks_den: u64) -> SensorClock { + SensorClock { + epoch: None, + ticks_num, + ticks_den, + } + } + + /// Ticks elapsed since this pad's first report. `now` is a parameter rather than an internal + /// `Instant::now()` so the unit tests below can drive the clock. + pub fn ticks(&mut self, now: Instant) -> u64 { + let epoch = *self.epoch.get_or_insert(now); + let micros = now.saturating_duration_since(epoch).as_micros() as u64; + micros * self.ticks_num / self.ticks_den + } + + /// [`ticks`](Self::ticks) truncated to the DualSense's u32 field. + pub fn ds_ticks(&mut self, now: Instant) -> u32 { + self.ticks(now) as u32 + } + + /// [`ticks`](Self::ticks) truncated to the DualShock 4's u16 field. + pub fn ds4_ticks(&mut self, now: Instant) -> u16 { + self.ticks(now) as u16 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + /// One second of elapsed time must read as one second in each pad's units — the property the + /// old fixed-increment clocks got wrong by 12000× (DualSense) and ~5× (DualShock 4). + #[test] + fn a_second_reads_as_a_second() { + let t0 = Instant::now(); + let after = t0 + Duration::from_secs(1); + + // DualSense: 1 s = 3_000_000 ticks of 1/3 µs. + let mut ds = SensorClock::dualsense(); + assert_eq!(ds.ticks(t0), 0, "the first report is the epoch"); + assert_eq!(ds.ticks(after), 3_000_000); + + // DualShock 4: 1 s = 187_500 ticks of 16/3 µs. + let mut ds4 = SensorClock::dualshock4(); + assert_eq!(ds4.ticks(t0), 0); + assert_eq!(ds4.ticks(after), 187_500); + } + + /// A realistic 4 ms publish interval, which is what the DS4's old `+188` claimed to be (it was + /// ~1 ms) and what the DualSense's old `+1` was off by four orders of magnitude from. + #[test] + fn one_publish_interval() { + let t0 = Instant::now(); + let mut ds = SensorClock::dualsense(); + let mut ds4 = SensorClock::dualshock4(); + ds.ticks(t0); + ds4.ticks(t0); + let after = t0 + Duration::from_millis(4); + assert_eq!(ds.ticks(after), 12_000); // 4000 µs × 3 + assert_eq!(ds4.ticks(after), 750); // 4000 µs × 3 / 16 + } + + /// The value is anchored to the epoch, not accumulated — an irregular cadence stays honest. + #[test] + fn jitter_does_not_drift() { + let t0 = Instant::now(); + let mut ds4 = SensorClock::dualshock4(); + ds4.ticks(t0); // the pad's first report — this, not `t0` itself, is the epoch + let mut t = t0; + for step in [1u64, 17, 3, 40, 9, 2] { + t += Duration::from_millis(step); + ds4.ticks(t); + } + // 72 ms since that first report, regardless of how it was walked. + assert_eq!(ds4.ticks(t), 72_000 * 3 / 16); + } + + /// Both fields wrap, exactly as the hardware's do; consumers handle it with a `prev > current` + /// check, so truncation is the correct way to fill them. + #[test] + fn fields_wrap_like_hardware() { + let t0 = Instant::now(); + + // The DS4's u16 holds 65536 ticks × 16/3 µs = 349_525.33 µs, so 349_525 µs is still the + // last representable tick and the next microsecond rolls over. + let mut ds4 = SensorClock::dualshock4(); + ds4.ticks(t0); + assert_eq!(ds4.ds4_ticks(t0 + Duration::from_micros(349_525)), 65_535); + assert_eq!(ds4.ds4_ticks(t0 + Duration::from_micros(349_526)), 0); + + // The DualSense's u32 takes ~23.9 minutes to get there; 3 ticks per µs means the first + // microsecond past the roll lands on 2. + let mut ds = SensorClock::dualsense(); + ds.ticks(t0); + let past_wrap = Duration::from_micros(u32::MAX as u64 / 3 + 1); + assert_eq!(ds.ds_ticks(t0 + past_wrap), 2); + } +} diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index 792b71a7..e43ab836 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -84,6 +84,30 @@ pub trait PadProto { fn force_heartbeat(&self, _pad: &Self::Pad) -> bool { false } + + /// Zero this state's **angular velocity**, keeping everything else — acceleration included. + /// Returns whether anything actually changed, so a pad already at rest costs no write. + /// + /// Motion is a level-triggered plane: [`merge_frame`](Self::merge_frame) preserves the last + /// sample and the heartbeat re-emits it with a fresh sequence, so a client that stops sending + /// Motion — backgrounded app, a suspended session, a controller swapped for one with no gyro — + /// leaves the virtual pad reporting a constant rotation that anything integrating gyro aim + /// will happily spin on forever. Rumble and the pen plane each have an idle watchdog; this is + /// motion's, driven from [`MOTION_IDLE_TIMEOUT`]. + /// + /// Acceleration is deliberately NOT zeroed: gravity is legitimately persistent, so a still pad + /// reporting 1 g down stays correct while a still pad reporting 200 °/s does not. + /// + /// Backends with no motion plane leave this a no-op and never take the extra write. + fn neutralize_gyro(&self, _st: &mut Self::State) -> bool { + false + } + + /// Reset the rich-plane fields (touchpad contacts + motion) to what a fresh pad carries, + /// leaving buttons, sticks and every feedback cursor alone — for the replug-grace re-claim + /// ([`Sweep::reclaimed`](crate::pad_slots::Sweep::reclaimed)), where a different controller + /// inherits a live virtual pad without passing through the manager's `reset_pad`. + fn clear_rich(&self, _st: &mut Self::State) {} } /// All virtual pads of one stateful backend, driven from decoded controller events — the shared @@ -107,6 +131,10 @@ pub struct UhidManager { /// [`RUMBLE_IDLE_TIMEOUT`] against this is a residual the game abandoned — see /// [`pump`](Self::pump). last_active: Vec, + /// When each pad last received a `RichInput::Motion`. `None` before the first sample and again + /// once the gyro has been neutralized, so a pad with no motion feed costs nothing per tick — + /// see [`MOTION_IDLE_TIMEOUT`]. + last_motion: Vec>, /// Per-pad rate limiter for the ring-overflow WARN — see [`OverflowWarn`]. overflow_warn: Vec, } @@ -182,6 +210,14 @@ impl OverflowWarn { /// titles actually hit; the hatch below exists for exactly that experiment. const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500); +/// How long a pad's motion feed may go quiet before its angular velocity is zeroed — see +/// [`PadProto::neutralize_gyro`]. Wide enough to ride out a hiccup in a 250 Hz feed (~25 missed +/// samples, and the client's own capture floors are ~4 ms), tight enough that a feed which stops +/// for good doesn't hand the game a visible spin. Unlike [`RUMBLE_IDLE_TIMEOUT`] there is no +/// "legitimately held" case to protect: a still controller sends a zero sample, it does not stop +/// sending. +const MOTION_IDLE_TIMEOUT: Duration = Duration::from_millis(100); + /// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides /// [`RUMBLE_IDLE_TIMEOUT`]; `0` disables the watchdog entirely (the pre-watchdog behavior, for /// bisecting field reports). Non-zero overrides are floored just above SDL's ~2 s resend so the @@ -222,6 +258,7 @@ impl UhidManager { hidout_dedup: vec![HidoutDedup::default(); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], last_active: vec![Instant::now(); MAX_PADS], + last_motion: vec![None; MAX_PADS], overflow_warn: vec![OverflowWarn::default(); MAX_PADS], } } @@ -240,8 +277,9 @@ impl UhidManager { } // Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands // on a later `pump` tick — this frame is the only one the producer sends). - let swept = self.slots.sweep(f.active_mask); - self.reset_swept(swept); + let sweep = self.slots.sweep(f.active_mask); + self.reset_swept(sweep.dropped); + self.clear_reclaimed_rich(sweep.reclaimed); if f.active_mask & (1 << idx) == 0 { return; // this event WAS the unplug } @@ -267,6 +305,9 @@ impl UhidManager { if idx >= MAX_PADS || self.slots.get(idx).is_none() { return; } + if matches!(rich, RichInput::Motion { .. }) { + self.last_motion[idx] = Some(Instant::now()); + } self.backend.apply_rich(&mut self.state[idx], rich); self.write(idx); } @@ -281,9 +322,17 @@ impl UhidManager { let Some(pad) = self.slots.get(i) else { continue; }; - if self.backend.force_heartbeat(pad) - || now.duration_since(self.last_write[i]) >= max_gap - { + let forced = self.backend.force_heartbeat(pad); + // A motion feed that stopped must not keep re-emitting its last angular velocity: the + // heartbeat below re-sends the current report forever, and with a real sensor clock + // each re-send carries an honestly larger dt — precisely the shape of phantom + // rotation. Zero the gyro once and stop watching until the feed comes back. + let mut neutralized = false; + if self.last_motion[i].is_some_and(|t| now.duration_since(t) >= MOTION_IDLE_TIMEOUT) { + self.last_motion[i] = None; + neutralized = self.backend.neutralize_gyro(&mut self.state[i]); + } + if neutralized || forced || now.duration_since(self.last_write[i]) >= max_gap { self.write(i); } } @@ -403,6 +452,19 @@ impl UhidManager { } } + /// Clear the rich-plane state of every slot a sweep re-claimed inside its grace window (see + /// [`Sweep::reclaimed`](crate::pad_slots::Sweep::reclaimed)). Rich fields only: rumble and the + /// hidout dedup deliberately survive a removal, and buttons/sticks arrive on the very frame + /// that re-set the mask bit. + fn clear_reclaimed_rich(&mut self, reclaimed: u16) { + for i in 0..MAX_PADS { + if reclaimed & (1 << i) != 0 { + self.backend.clear_rich(&mut self.state[i]); + self.last_motion[i] = None; + } + } + } + /// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a /// (re)connect starts from scratch and is always forwarded. fn reset_pad(&mut self, idx: usize) { @@ -411,6 +473,17 @@ impl UhidManager { self.hidout_dedup[idx].clear(); self.last_write[idx] = Instant::now(); self.last_active[idx] = Instant::now(); + self.last_motion[idx] = None; + } + + /// Backdate every pad's motion clock past [`MOTION_IDLE_TIMEOUT`], so the next + /// [`heartbeat`](Self::heartbeat) neutralizes a stale gyro without a wall-clock sleep — the + /// same test hatch `PadSlots::expire_grace` gives the unplug debounce. Test-only. + #[cfg(test)] + fn expire_motion(&mut self) { + for t in self.last_motion.iter_mut().flatten() { + *t -= MOTION_IDLE_TIMEOUT; + } } } @@ -434,6 +507,10 @@ mod tests { /// Stands in for the rich-plane fields (touch/motion/clicks): set by `apply_rich`, /// must survive `merge_frame`. rich_marker: u16, + /// Stands in for angular velocity — zeroed by the idle-motion watchdog. + gyro: i16, + /// Stands in for acceleration, which the watchdog must NOT zero (gravity is persistent). + accel: i16, } /// Per-pad transport stub recording every state write. @@ -463,14 +540,33 @@ mod tests { fn merge_frame(&self, prev: &MockState, f: &GamepadFrame) -> MockState { MockState { buttons: f.buttons, - rich_marker: prev.rich_marker, // the preserve-rich-fields contract + // The preserve-rich-fields contract — and the reason a stale motion sample lives + // forever without a watchdog. + rich_marker: prev.rich_marker, + gyro: prev.gyro, + accel: prev.accel, } } fn apply_rich(&self, st: &mut MockState, rich: RichInput) { - if let RichInput::Touchpad { x, .. } = rich { - st.rich_marker = x; + match rich { + RichInput::Touchpad { x, .. } => st.rich_marker = x, + RichInput::Motion { gyro, accel, .. } => { + st.gyro = gyro[0]; + st.accel = accel[2]; + } + _ => {} } } + fn neutralize_gyro(&self, st: &mut MockState) -> bool { + let changed = st.gyro != 0; + st.gyro = 0; + changed + } + fn clear_rich(&self, st: &mut MockState) { + st.rich_marker = 0; + st.gyro = 0; + st.accel = 0; + } fn write_state(&self, pad: &mut MockPad, st: &MockState) { pad.writes.borrow_mut().push(*st); } @@ -506,10 +602,97 @@ mod tests { } } + fn motion(pad: u8, gyro_x: i16, accel_z: i16) -> RichInput { + RichInput::Motion { + pad, + gyro: [gyro_x, 0, 0], + accel: [0, 0, accel_z], + } + } + fn mgr() -> UhidManager { UhidManager::new() } + /// A motion feed that stops must not leave the pad rotating forever: past + /// [`MOTION_IDLE_TIMEOUT`] the heartbeat zeroes the angular velocity, keeps the acceleration, + /// and writes the corrected report. `max_gap` is huge here so the ONLY thing that can produce + /// a write is the neutralize. + #[test] + fn a_stalled_motion_feed_has_its_gyro_neutralized() { + let mut m = mgr(); + m.handle(&frame(0, 0b1, 0)); + m.apply_rich(motion(0, 900, 10_000)); + assert_eq!(m.state[0].gyro, 900); + + m.heartbeat(Duration::from_secs(3600)); + assert_eq!(m.state[0].gyro, 900, "neutralized inside the idle window"); + + m.expire_motion(); + m.heartbeat(Duration::from_secs(3600)); + assert_eq!( + m.state[0].gyro, 0, + "stale angular velocity outlived the watchdog" + ); + assert_eq!( + m.state[0].accel, 10_000, + "gravity must survive the neutralize" + ); + let pad = m.slots.get(0).unwrap(); + let writes = pad.writes.borrow(); + assert_eq!( + writes.last().unwrap().gyro, + 0, + "the neutralized state never reached the pad" + ); + } + + /// …and only when there is something to zero: a pad already at rest must not manufacture a + /// write on every tick. + #[test] + fn neutralizing_an_already_still_pad_writes_nothing() { + let mut m = mgr(); + m.handle(&frame(0, 0b1, 0)); + m.apply_rich(motion(0, 0, 10_000)); + let before = m.slots.get(0).unwrap().writes.borrow().len(); + m.expire_motion(); + m.heartbeat(Duration::from_secs(3600)); + m.heartbeat(Duration::from_secs(3600)); + assert_eq!(m.slots.get(0).unwrap().writes.borrow().len(), before); + } + + /// A controller that takes over a live pad inside the replug grace skips the create path, so + /// nothing else resets what the manager persists on the client's behalf. It must not inherit + /// the previous controller's finger or rotation — a pad with no gyro would carry that + /// rotation for the rest of the session, having no sample of its own to correct it with. + #[test] + fn a_grace_reclaim_clears_the_previous_pads_rich_state() { + let mut m = mgr(); + m.handle(&frame(0, 0b1, 0)); + m.apply_rich(touch(0, 4242)); + m.apply_rich(motion(0, 900, 10_000)); + + m.handle(&frame(0, 0b0, 0)); // the unplug frame: arms the grace, drops nothing + assert!(m.slots.get(0).is_some(), "the grace must not drop it here"); + m.handle(&frame(0, 0b1, 0)); // back inside the grace — same pad, new owner + + assert_eq!(m.state[0].rich_marker, 0, "inherited the last pad's touch"); + assert_eq!(m.state[0].gyro, 0, "inherited the last pad's rotation"); + } + + /// The re-claim clear keys off the grace clock, not off presence — a steady mask must never + /// trip it, or the client's touch and motion would be wiped on every state frame. + #[test] + fn a_steady_mask_never_clears_rich_state() { + let mut m = mgr(); + m.handle(&frame(0, 0b1, 0)); + m.apply_rich(touch(0, 4242)); + for _ in 0..5 { + m.handle(&frame(0, 0b1, 0)); + } + assert_eq!(m.state[0].rich_marker, 4242); + } + #[test] fn arrival_eager_creates_the_pad() { // G10 as a generic regression test: Arrival must build the device before the first frame. diff --git a/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs index f640c4bd..acac31a1 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs @@ -68,6 +68,14 @@ impl PadProto for DsEdgeWinProto { st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H); } + fn neutralize_gyro(&self, st: &mut DsState) -> bool { + st.neutralize_gyro() + } + + fn clear_rich(&self, st: &mut DsState) { + st.clear_rich(); + } + fn write_state(&self, pad: &mut DsWinPad, st: &DsState) { pad.write_state(st); } diff --git a/crates/pf-inject/src/inject/windows/dualsense_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_windows.rs index 9366cd96..2c12de72 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_windows.rs @@ -23,12 +23,13 @@ use super::dualsense_proto::{ DS_TOUCH_W, }; use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx}; +use crate::sensor_clock::SensorClock; use crate::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{anyhow, Result}; use punktfunk_core::quic::RichInput; use std::ffi::c_void; use std::sync::atomic::{fence, AtomicU32, Ordering}; -use std::time::Duration; +use std::time::{Duration, Instant}; use windows::core::{w, GUID, PCWSTR}; use windows::Win32::Devices::Enumeration::Pnp::{ SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, @@ -216,7 +217,7 @@ pub struct DsWinPad { /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. attach: super::gamepad_raii::DriverAttach, seq: u8, - ts: u32, + clock: SensorClock, /// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver). drain: OutputDrain, } @@ -511,7 +512,7 @@ impl DsWinPad { instance_id, ), seq: 0, - ts: 0, + clock: SensorClock::dualsense(), drain: OutputDrain::new(), }) } @@ -519,9 +520,9 @@ impl DsWinPad { /// Serialize `st` into report `0x01` and publish it to the section's input slot. pub(super) fn write_state(&mut self, st: &DsState) { self.seq = self.seq.wrapping_add(1); - self.ts = self.ts.wrapping_add(1); + let ts = self.clock.ds_ticks(Instant::now()); let mut r = [0u8; DS_INPUT_REPORT_LEN]; - serialize_state(&mut r, st, self.seq, self.ts); + serialize_state(&mut r, st, self.seq, ts); // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. Unlike the // XUSB `packet` / DualSense `out_seq` fields, the input path has NO driver-polled change-detect // field to publish last: the `pf_gamepad` driver streams the whole `input` region to game @@ -631,6 +632,14 @@ impl PadProto for DsWinProto { st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H); } + fn neutralize_gyro(&self, st: &mut DsState) -> bool { + st.neutralize_gyro() + } + + fn clear_rich(&self, st: &mut DsState) { + st.clear_rich(); + } + fn write_state(&self, pad: &mut DsWinPad, st: &DsState) { pad.write_state(st); } diff --git a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs index 27e16840..b3be4a4e 100644 --- a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs @@ -16,10 +16,11 @@ use super::dualshock4_proto::{ parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W, }; use super::gamepad_raii::PadChannel; +use crate::sensor_clock::SensorClock; use crate::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::Result; use punktfunk_core::quic::{HidOutput, RichInput}; -use std::time::Duration; +use std::time::{Duration, Instant}; /// The hardware id this pad's devnode carries. Must be one `pf_gamepad.inx` declares — a package /// rename must never touch it (`dualsense_windows::tests::hwid_matches_inf` enforces that). @@ -37,7 +38,7 @@ pub struct Ds4WinPad { /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. attach: super::gamepad_raii::DriverAttach, counter: u8, - ts: u16, + clock: SensorClock, /// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver). drain: OutputDrain, } @@ -105,7 +106,7 @@ impl Ds4WinPad { instance_id, ), counter: 0, - ts: 0, + clock: SensorClock::dualshock4(), drain: OutputDrain::new(), }) } @@ -113,9 +114,9 @@ impl Ds4WinPad { /// Serialize `st` into report `0x01` and publish it to the section's input slot. fn write_state(&mut self, st: &DsState) { self.counter = self.counter.wrapping_add(1); - self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units + let ts = self.clock.ds4_ticks(Instant::now()); let mut r = [0u8; DS4_INPUT_REPORT_LEN]; - serialize_state(&mut r, st, self.counter, self.ts); + serialize_state(&mut r, st, self.counter, ts); // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. unsafe { std::ptr::copy_nonoverlapping( @@ -216,6 +217,14 @@ impl PadProto for Ds4WinProto { st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H); } + fn neutralize_gyro(&self, st: &mut DsState) -> bool { + st.neutralize_gyro() + } + + fn clear_rich(&self, st: &mut DsState) { + st.clear_rich(); + } + fn write_state(&self, pad: &mut Ds4WinPad, st: &DsState) { pad.write_state(st); } diff --git a/crates/pf-inject/src/inject/windows/gamepad_windows.rs b/crates/pf-inject/src/inject/windows/gamepad_windows.rs index d0191c72..b6b8ed91 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_windows.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_windows.rs @@ -320,7 +320,8 @@ impl GamepadManager { } // Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands // on a later `pump_rumble` tick — this frame is the only one the producer sends). - let swept = self.slots.sweep(f.active_mask); + // XUSB pads carry no rich plane, so a grace re-claim has nothing to clear. + let swept = self.slots.sweep(f.active_mask).dropped; self.reset_swept(swept); if f.active_mask & (1 << idx) == 0 { return; diff --git a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs index d8e85a73..1373f8f8 100644 --- a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs +++ b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs @@ -210,6 +210,14 @@ impl PadProto for DeckWinProto { st.apply_rich(rich); } + fn neutralize_gyro(&self, st: &mut SteamState) -> bool { + st.neutralize_gyro() + } + + fn clear_rich(&self, st: &mut SteamState) { + st.clear_rich(); + } + fn write_state(&self, pad: &mut DeckWinPad, st: &SteamState) { pad.write_state(st); } diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index ed946eb1..c1f63766 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -398,6 +398,12 @@ pub mod pad_gate; #[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/pad_slots.rs"] pub mod pad_slots; +/// The `sensor_timestamp` every virtual Sony pad stamps into its input reports +/// ([`sensor_clock::SensorClock`]) — real elapsed time in the DualSense's 1/3 µs and the +/// DualShock 4's 5.33 µs units, shared by all four backends. +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[path = "inject/sensor_clock.rs"] +pub mod sensor_clock; /// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck. #[cfg(target_os = "linux")] #[path = "inject/linux/steam_controller.rs"] diff --git a/crates/pf-inject/tests/motion_contract.rs b/crates/pf-inject/tests/motion_contract.rs new file mode 100644 index 00000000..4963e979 --- /dev/null +++ b/crates/pf-inject/tests/motion_contract.rs @@ -0,0 +1,343 @@ +//! The motion **unit contract**, pinned across every side that has an opinion about it. +//! +//! Gyro aim integrates angular velocity over time, so a scale error is not a cosmetic wrongness — +//! it is every rotation being the wrong size, forever. The wire carries raw `i16` LSBs in the +//! DualSense convention ([`MOTION_GYRO_LSB_PER_DEG_S`] / [`MOTION_ACCEL_LSB_PER_G`]), and each host +//! backend re-states that convention in its own dialect: the Sony pads *declare* it in a fixed +//! calibration feature report the consumer reads its scale out of, the Steam Deck and Switch Pro +//! backends *rescale* into their driver's native resolution. +//! +//! Nothing used to check that those re-statements agreed with the wire. They didn't: the DualShock +//! 4 blob declared 0.5 LSB/°·s and 8192 LSB/g against a wire delivering 20 and 10000, so every DS4 +//! session read gyro 40× too fast and accel 1.22× hot — in two byte-identical copies, one of them +//! in a driver that lives in a different cargo workspace. This file is the gate that would have +//! caught it: it applies the *consumer's* arithmetic to each backend's declaration and asserts the +//! result lands back on the wire constants. +//! +//! Adding a motion-capable backend means adding it here. + +#![cfg(any(target_os = "linux", target_os = "windows"))] + +use pf_inject::dualsense_proto::{ + serialize_state as ds_serialize, DsState, DS_FEATURE_CALIBRATION, DS_INPUT_REPORT_LEN, + DS_TOUCH_H, DS_TOUCH_W, +}; +use pf_inject::dualshock4_proto::{ + serialize_state as ds4_serialize, DS4_FEATURE_CALIBRATION, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, + DS4_TOUCH_W, +}; +use pf_inject::steam_proto::SteamState; +use pf_inject::steam_remap::motion_wire_to_deck; +use pf_inject::switch_proto::SwitchState; +use punktfunk_core::input::gamepad::{MOTION_ACCEL_LSB_PER_G, MOTION_GYRO_LSB_PER_DEG_S}; +use punktfunk_core::quic::RichInput; + +/// The Sony IMU-calibration feature report, whose layout is the same for the DualSense (report +/// `0x05`) and the USB DualShock 4 (report `0x02`): report id, three signed bias words, six +/// **interleaved** per-axis `plus`/`minus` words, two gyro `speed` words, then six accel +/// `plus`/`minus` words, all little-endian `i16`. +/// +/// ⚠ Interleaved is the **USB** order. A Bluetooth DualShock 4 groups the three plusses before the +/// three minuses, and consumers switch layout on the transport; our virtual pads declare `BUS_USB`, +/// so interleaved is correct here — this was checked and is not a latent bug. +#[derive(Debug, PartialEq, Eq)] +struct SonyImuCalibration { + gyro_bias: [i16; 3], + gyro_plus: [i16; 3], + gyro_minus: [i16; 3], + /// `speed_plus`, `speed_minus` — the reference rotation rate the plus/minus span was measured + /// at. Consumers only ever use the sum. + gyro_speed: [i16; 2], + accel_plus: [i16; 3], + accel_minus: [i16; 3], +} + +impl SonyImuCalibration { + fn parse(blob: &[u8], report_id: u8, who: &str) -> SonyImuCalibration { + assert_eq!(blob.first().copied(), Some(report_id), "{who}: report id"); + assert!( + blob.len() >= 35, + "{who}: {} bytes, too short to carry the calibration fields (need 35)", + blob.len() + ); + let w = |i: usize| i16::from_le_bytes([blob[i], blob[i + 1]]); + SonyImuCalibration { + gyro_bias: [w(1), w(3), w(5)], + gyro_plus: [w(7), w(11), w(15)], + gyro_minus: [w(9), w(13), w(17)], + gyro_speed: [w(19), w(21)], + accel_plus: [w(23), w(27), w(31)], + accel_minus: [w(25), w(29), w(33)], + } + } + + /// LSB per °/s that the **kernel** derives for axis `i`: `hid-playstation` sets + /// `sens_numer = (speed_plus + speed_minus) * GYRO_RES_PER_DEG_S` and + /// `sens_denom = |plus - bias| + |minus - bias|`, then reports + /// `raw * sens_numer / sens_denom` in units of 1/`GYRO_RES_PER_DEG_S` °/s — so the + /// `GYRO_RES_PER_DEG_S` cancels and the resolution the pad *advertises* is `denom / speed_2x`, + /// independent of the driver's internal fixed-point scale. + /// + /// Returned as an integer because a fractional answer is itself a defect: no consumer can + /// round-trip a resolution it cannot express, and the assert below is where the pre-2026-08 + /// DS4 blob (32/64 = 0.5) fails. + fn kernel_gyro_lsb_per_deg_s(&self, i: usize, who: &str) -> i64 { + let speed_2x = self.gyro_speed[0] as i64 + self.gyro_speed[1] as i64; + assert!( + speed_2x != 0, + "{who}: gyro speed_plus + speed_minus is zero" + ); + let denom = (self.gyro_plus[i] as i64 - self.gyro_bias[i] as i64).abs() + + (self.gyro_minus[i] as i64 - self.gyro_bias[i] as i64).abs(); + assert_eq!( + denom % speed_2x, + 0, + "{who} axis {i}: declares a fractional {denom}/{speed_2x} LSB per °/s" + ); + denom / speed_2x + } + + /// The same number as SDL derives it (`SDL_hidapi_ps4` / `SDL_hidapi_ps5`: `plus - minus` over + /// the speed sum, ignoring the bias). It agrees with the kernel's form only for a symmetric, + /// zero-bias blob — and both consumers read the same virtual pad, so a blob they disagree + /// about is a bug no matter which one is "right". + fn sdl_gyro_lsb_per_deg_s(&self, i: usize) -> f64 { + (self.gyro_plus[i] as f64 - self.gyro_minus[i] as f64) + / (self.gyro_speed[0] as f64 + self.gyro_speed[1] as f64) + } + + /// LSB per g for axis `i`: consumers take `range_2g = plus - minus` as the span of **2 g**, so + /// one g is half of it. + fn accel_lsb_per_g(&self, i: usize, who: &str) -> i64 { + let range_2g = self.accel_plus[i] as i64 - self.accel_minus[i] as i64; + assert_eq!( + range_2g % 2, + 0, + "{who} axis {i}: odd accel range {range_2g} has no exact 1 g" + ); + range_2g / 2 + } + + /// The raw value a consumer treats as zero g (`plus - range_2g / 2`). Our pads pass the wire + /// through unscaled, and the wire's zero is 0, so this must be 0 — a non-zero bias would show + /// up as a constant phantom acceleration. + fn accel_zero_point(&self, i: usize) -> i64 { + let range_2g = self.accel_plus[i] as i64 - self.accel_minus[i] as i64; + self.accel_plus[i] as i64 - range_2g / 2 + } +} + +/// Every Sony-dialect backend declares exactly the wire's units, to both of its consumers. +#[test] +fn sony_calibration_blobs_declare_the_wire_units() { + let wire_gyro = MOTION_GYRO_LSB_PER_DEG_S as i64; + let wire_accel = MOTION_ACCEL_LSB_PER_G as i64; + + for (who, blob, report_id) in [ + ("DualSense 0x05", DS_FEATURE_CALIBRATION, 0x05u8), + ("DualShock 4 0x02", DS4_FEATURE_CALIBRATION, 0x02u8), + ] { + let cal = SonyImuCalibration::parse(blob, report_id, who); + for axis in 0..3 { + assert_eq!( + cal.kernel_gyro_lsb_per_deg_s(axis, who), + wire_gyro, + "{who} axis {axis}: gyro resolution the kernel derives" + ); + assert_eq!( + cal.sdl_gyro_lsb_per_deg_s(axis), + wire_gyro as f64, + "{who} axis {axis}: gyro resolution SDL derives" + ); + assert_eq!( + cal.accel_lsb_per_g(axis, who), + wire_accel, + "{who} axis {axis}: accel resolution" + ); + assert_eq!( + cal.accel_zero_point(axis), + 0, + "{who} axis {axis}: accel zero point must be the wire's 0" + ); + } + } +} + +/// The two rescaling backends land a wire sample on their driver's native resolution. +#[test] +fn rescaling_backends_convert_the_wire_into_their_native_units() { + // One reference sample: 100 °/s and exactly 1 g, expressed on the wire. + let wire_gyro = (100 * MOTION_GYRO_LSB_PER_DEG_S) as i16; + let wire_accel = MOTION_ACCEL_LSB_PER_G as i16; + + // Steam Deck: `hid-steam` fixes STEAM_DECK_GYRO_RES_PER_DPS = 16 and ACCEL_RES_PER_G = 16384. + let (gyro, accel) = motion_wire_to_deck([wire_gyro; 3], [wire_accel; 3]); + assert_eq!(gyro, [100 * 16; 3], "Deck gyro: 100 °/s at 16 LSB/°·s"); + assert_eq!(accel, [16384; 3], "Deck accel: 1 g at 16384 LSB/g"); + + // Switch Pro: `hid-nintendo` fixes JC_IMU_GYRO_RES_PER_DPS = 14.247 and ACCEL_RES_PER_G = 4096, + // and consumes our report 1:1 because the factory-calibration blob we serve is the driver's own + // identity default. 100 °/s × 14.247 = 1424.7, truncated. + let mut st = SwitchState::neutral(); + st.apply_motion([wire_gyro; 3], [wire_accel; 3]); + assert_eq!(st.gyro, [1424; 3], "Switch gyro: 100 °/s at 14.247 LSB/°·s"); + assert_eq!(st.accel, [4096; 3], "Switch accel: 1 g at 4096 LSB/g"); +} + +/// The DualSense / DualShock 4 backends hand the wire sample to the report codec **unscaled** — +/// which is only correct because their calibration blobs declare the wire's own units above. If +/// someone ever adds a rescale here, the blobs have to move with it (or vice versa). +#[test] +fn sony_backends_pass_the_wire_sample_through_unscaled() { + let gyro = [(100 * MOTION_GYRO_LSB_PER_DEG_S) as i16, -640, 7]; + let accel = [0, 0, MOTION_ACCEL_LSB_PER_G as i16]; + let motion = RichInput::Motion { + pad: 0, + gyro, + accel, + }; + + // Both Sony backends share `DsState::apply_rich`, differing only in touchpad extent. + for (who, w, h) in [ + ("DualSense", DS_TOUCH_W, DS_TOUCH_H), + ("DualShock 4", DS4_TOUCH_W, DS4_TOUCH_H), + ] { + let mut st = DsState::neutral(); + st.apply_rich(motion, w, h); + assert_eq!(st.gyro, gyro, "{who} rescaled the wire gyro"); + assert_eq!(st.accel, accel, "{who} rescaled the wire accel"); + } +} + +/// The client→report path end to end, in the units that matter: a wire Motion sample must reach +/// the HID report's motion fields as those exact little-endian values. The proto tests already pin +/// the OFFSETS; nothing pinned that the VALUE arrives unscaled — which is the half the calibration +/// blobs above are a promise about. +#[test] +fn a_wire_motion_sample_reaches_the_report_bytes_unchanged() { + let g = (100 * MOTION_GYRO_LSB_PER_DEG_S) as i16; // 100 °/s = 2000 = 0x07D0 + let a = MOTION_ACCEL_LSB_PER_G as i16; // 1 g = 10000 = 0x2710 + let motion = RichInput::Motion { + pad: 0, + gyro: [g, -g, 0], + accel: [0, 0, a], + }; + let gyro_le = [0xD0, 0x07, 0x30, 0xF8, 0x00, 0x00]; // 2000, −2000, 0 + let accel_le = [0x00, 0x00, 0x00, 0x00, 0x10, 0x27]; // 0, 0, 10000 + + // DualSense report 0x01: gyro at bytes 16..22, accel at 22..28. + let mut st = DsState::neutral(); + st.apply_rich(motion, DS_TOUCH_W, DS_TOUCH_H); + let mut r = [0u8; DS_INPUT_REPORT_LEN]; + ds_serialize(&mut r, &st, 0, 0); + assert_eq!(&r[16..22], &gyro_le, "DualSense report gyro"); + assert_eq!(&r[22..28], &accel_le, "DualSense report accel"); + + // DualShock 4 report 0x01: gyro at 13..19, accel at 19..25. + let mut st = DsState::neutral(); + st.apply_rich(motion, DS4_TOUCH_W, DS4_TOUCH_H); + let mut r = [0u8; DS4_INPUT_REPORT_LEN]; + ds4_serialize(&mut r, &st, 0, 0); + assert_eq!(&r[13..19], &gyro_le, "DualShock 4 report gyro"); + assert_eq!(&r[19..25], &accel_le, "DualShock 4 report accel"); +} + +/// The idle-motion watchdog's semantics, which only make sense in these units: angular velocity +/// goes to zero when the feed stops, acceleration does not — a still controller still measures +/// gravity, and blanking it would read as free-fall. +#[test] +fn neutralizing_motion_keeps_gravity() { + let mut st = DsState::neutral(); + st.gyro = [(100 * MOTION_GYRO_LSB_PER_DEG_S) as i16; 3]; + st.accel = [0, 0, MOTION_ACCEL_LSB_PER_G as i16]; + + assert!(st.neutralize_gyro(), "reported no change while rotating"); + assert_eq!(st.gyro, [0; 3]); + assert_eq!(st.accel, [0, 0, MOTION_ACCEL_LSB_PER_G as i16]); + assert!(!st.neutralize_gyro(), "a still pad must report no change"); + + let mut deck = SteamState::neutral(); + deck.gyro = [(100 * MOTION_GYRO_LSB_PER_DEG_S) as i16; 3]; + deck.accel = [0, 0, 16384]; + assert!(deck.neutralize_gyro()); + assert_eq!(deck.gyro, [0; 3]); + assert_eq!(deck.accel, [0, 0, 16384], "Deck gravity must survive too"); +} + +// ---- the Windows UMDF driver's copies ---- + +/// `packaging/windows/drivers/pf-gamepad` is a separate WDK cargo workspace: it cannot depend on +/// pf-inject, so it carries its own copies of the calibration blobs. That is exactly the shape the +/// DS4 bug shipped in — one wrong table living in two files, where fixing one reads as fixing it. +/// Rather than trust a "keep in sync" comment, derive the units from the driver's own source. +const DRIVER_SRC: &str = include_str!("../../../packaging/windows/drivers/pf-gamepad/src/lib.rs"); + +/// Pull the bytes out of a `static NAME: [u8; N] = [ … ];` (or `const NAME: &[u8] = &[ … ];`) +/// literal in Rust source. Deliberately dumb: the arrays it reads are `#[rustfmt::skip]` tables of +/// `0x..` bytes, and a scan that breaks fails this test loudly rather than passing vacuously. +fn extract_byte_array(src: &str, name: &str) -> Vec { + let decl = src + .find(&format!("{name}:")) + .unwrap_or_else(|| panic!("{name} not found in the driver source")); + let eq = src[decl..] + .find('=') + .unwrap_or_else(|| panic!("{name}: no `=` after the declaration")) + + decl; + let open = src[eq..] + .find('[') + .unwrap_or_else(|| panic!("{name}: no `[` after the `=`")) + + eq; + let close = src[open..] + .find("];") + .unwrap_or_else(|| panic!("{name}: array literal is not closed by `];`")) + + open; + let bytes: Vec = src[open + 1..close] + .lines() + .map(|l| l.split("//").next().unwrap_or("")) // drop trailing comments + .flat_map(|l| l.split(',')) + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(|t| { + let hex = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")); + match hex { + Some(h) => u8::from_str_radix(h, 16), + None => t.parse(), + } + .unwrap_or_else(|_| panic!("{name}: {t:?} is not a byte literal")) + }) + .collect(); + assert!(!bytes.is_empty(), "{name}: extracted no bytes"); + bytes +} + +/// The driver's blobs declare the same calibration as pf-inject's, field for field, and therefore +/// the same units. Trailing padding may differ (the two transports declare different feature +/// lengths), so this compares the parsed fields rather than raw bytes. +#[test] +fn windows_driver_blobs_match_the_canonical_ones() { + let wire_gyro = MOTION_GYRO_LSB_PER_DEG_S as i64; + let wire_accel = MOTION_ACCEL_LSB_PER_G as i64; + + for (who, canonical, report_id) in [ + ("DualSense 0x05", DS_FEATURE_CALIBRATION, 0x05u8), + ("DualShock 4 0x02", DS4_FEATURE_CALIBRATION, 0x02u8), + ] { + let name = if report_id == 0x05 { + "DS_FEATURE_CALIBRATION" + } else { + "DS4_FEATURE_CALIBRATION" + }; + let driver_blob = extract_byte_array(DRIVER_SRC, name); + let driver = SonyImuCalibration::parse(&driver_blob, report_id, &format!("driver {who}")); + assert_eq!( + driver, + SonyImuCalibration::parse(canonical, report_id, who), + "the UMDF driver's {name} has drifted from pf-inject's" + ); + for axis in 0..3 { + let d = format!("driver {who}"); + assert_eq!(driver.kernel_gyro_lsb_per_deg_s(axis, &d), wire_gyro); + assert_eq!(driver.accel_lsb_per_g(axis, &d), wire_accel); + } + } +} diff --git a/crates/punktfunk-core/src/input.rs b/crates/punktfunk-core/src/input.rs index 12a5db94..0faa6f7c 100644 --- a/crates/punktfunk-core/src/input.rs +++ b/crates/punktfunk-core/src/input.rs @@ -180,6 +180,24 @@ pub mod gamepad { /// Triggers: value range 0..255. pub const AXIS_LT: u32 = 4; pub const AXIS_RT: u32 = 5; + + /// Motion wire units — the DualSense convention, raw `i16` LSBs, carried by + /// `RichInput::Motion`. Gyro is angular velocity, accel is proper acceleration. + /// + /// Every capture path scales *into* these units (`pf-client-core::gamepad`, Swift + /// `GamepadWire`, the Android `DeviceGyro`) and every host backend decodes *from* them — + /// but the two sides never meet in one crate, which is how a virtual pad shipped for + /// months telling its consumers to read the same bytes 40× too fast. The host's virtual + /// pads carry fixed calibration blobs, and the resolution a consumer derives from those + /// blobs must land back on exactly these numbers; pf-inject's `motion_contract` test is + /// what pins that, for every backend, against these constants. + /// + /// Gyro saturates at `i16::MAX / 20` ≈ ±1638 °/s, below a real DualSense's ±2000; accel at + /// ±3.28 g against its ±4 g. Lifting those is a wire-v2 question, not a scale to quietly + /// re-tune here. + pub const MOTION_GYRO_LSB_PER_DEG_S: i32 = 20; + /// See [`MOTION_GYRO_LSB_PER_DEG_S`]. + pub const MOTION_ACCEL_LSB_PER_G: i32 = 10_000; } impl InputKind { diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index 96fb6d5e..b24360af 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -172,11 +172,21 @@ static DS4_RDESC: [u8; 507] = [ static DS4_FEATURE_PAIRING: [u8; 16] = [ // 0x12 pairing info (MAC at bytes 1..7) 0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; +// 0x02 IMU calibration. A consumer (SDL's `SDL_hidapi_ps4`, or `hid-playstation` when this pad +// is read on Linux) DERIVES its motion scale from these words rather than assuming one: gyro +// resolution = (|pitch_plus| + |pitch_minus|) / (speed_plus + speed_minus) LSB per °/s, accel +// resolution = (acc_plus - acc_minus) / 2 LSB per g. So this blob is where the wire contract +// (20 LSB/°·s, 10000 LSB/g) is declared on the DS4 device type, and it must state exactly what +// the wire delivers — the pre-2026-08 values (±16 / speed 32 / ±8192) declared 0.5 LSB/°·s and +// 8192 LSB/g, i.e. every DS4 session read gyro 40× too fast and accel 1.22× hot. +// Mirrors inject/proto/dualshock4_proto.rs DS4_FEATURE_CALIBRATION; this WDK workspace can't +// depend on pf-inject, so pf-inject's `motion_contract` test parses THIS file and re-derives the +// units from it. Keep the two in sync. #[rustfmt::skip] -static DS4_FEATURE_CALIBRATION: [u8; 37] = [ // 0x02 IMU calibration - 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0xF0, 0xFF, 0x10, 0x00, 0xF0, 0xFF, 0x10, - 0x00, 0xF0, 0xFF, 0x20, 0x00, 0x20, 0x00, 0x00, 0x20, 0x00, 0xE0, 0x00, 0x20, 0x00, 0xE0, 0x00, - 0x20, 0x00, 0xE0, 0x00, 0x00, +static DS4_FEATURE_CALIBRATION: [u8; 37] = [ + 0x02, 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, 0x00, 0x00, ]; #[rustfmt::skip] static DS4_FEATURE_FIRMWARE: [u8; 49] = [ // 0xa3 firmware/build info