fix(host/pads): DualShock 4 gyro ran 40× fast, and no pad ever stopped turning
Phase 1 of the gyro program (design/gyro-program.md, G1-G5) — the five correctness fixes under it. Gyro aim integrates angular velocity over time, so each of these is not a cosmetic wrongness: a wrong scale is every rotation being the wrong size, a wrong clock is every rotation being integrated against a fictional dt, and a stale sample is rotation that never happened. G1 — the DualShock 4 calibration blob. A Sony pad does not assume a motion scale, it reads one out of a fixed calibration feature report. Ours declared 0.5 LSB per °/s and 8192 LSB/g while the wire delivers 20 and 10000, so every DS4-type session decoded gyro 40× too fast and acceleration 1.22× hot — since the backend shipped. The blob now states the wire's own units (the DualSense blob's numbers, deliberately: both pads consume the identical wire sample). Its interleaved per-axis order is NOT a bug and stays: the virtual pad declares BUS_USB, where interleaved is the correct layout; grouped is Bluetooth's. The same blob lives a second time in the UMDF driver, which is a separate WDK workspace that cannot depend on pf-inject — one wrong table in two files, where fixing one reads as fixing it. Both are fixed, and the DS4 feature reports now live in dualshock4_proto beside the DualSense's rather than in the Linux backend, so there is one canonical copy to point at. Field hosts keep the old blob until they update the host package. G2 — the gate that would have caught it. Nothing pinned any backend's declaration against the wire, so tests/motion_contract.rs now applies the CONSUMER's arithmetic (the kernel's, and SDL's, which differ) to each backend and asserts the result lands back on the wire constants — for the DualSense and DS4 blobs, and for the Deck and Switch Pro rescales. It also parses the driver's Rust source and re-derives the units from THAT, so the two copies cannot drift. Verified non-vacuous both ways: re-introducing the old blob fails with "declares a fractional 32/64 LSB per °/s", and reverting only the driver's copy fails with "the UMDF driver's DS4_FEATURE_CALIBRATION has drifted from pf-inject's". The wire units themselves move to punktfunk_core::input::gamepad, referenced by the client's capture scale, the Deck/Switch rescales, and the probe — whose at-rest vector said 16384 (a driver's number, not the wire's) and now says 1 g. G3 — real sensor clocks. The DualSense advanced its sensor timestamp by +1 raw unit per report (0.33 µs — a frozen clock) and the DS4 by a flat +188 (~1 ms) regardless of the real 4-8 ms cadence. Anything integrating rate × dt off that field got nonsense. All four backends now stamp elapsed monotonic time in their own units via a shared SensorClock, anchored to the pad's first report so an irregular publish loop cannot make it drift, and truncated to the field width — which reproduces the wrap real hardware does. G4 — motion is level-triggered and had no watchdog. merge_frame preserves the last sample and the heartbeat re-emits it, so a feed that stops leaves the pad rotating forever — and with G3's honest clock, at a dt that keeps growing. Rumble and the pen plane each have an idle timeout; motion now has one too, at 100 ms. Angular velocity only: acceleration is kept, because gravity is legitimately persistent and blanking it reads as free-fall. The SDL client parks its gyro at zero when a slot closes, which is the case we can flush rather than wait out. (The Apple half of this rides in PR #88.) G5 — a pad returning inside the 300 ms replug grace keeps the same device and skips the create path, so a different controller inherits the previous one's touch contact and rotation — and a pad with no gyro never sends a sample to correct it. sweep() now reports re-claims separately from drops, and the manager clears the rich plane on one. Rich fields only: rumble and hidout dedup deliberately survive a removal. Gates (Linux, CI image): fmt, build, clippy --all-targets -D warnings over pf-inject/punktfunk-core/punktfunk-probe/pf-client-core, and the test suites — 110 pf-inject unit + 6 contract + 29 pf-client-core gamepad, all green. Not yet verified on glass; the on-glass sign/scale session is G16.
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Ds4LinuxProto>;
|
||||
#[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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Option<P>>` 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<P> PadSlots<P> {
|
||||
}
|
||||
|
||||
/// 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<P> PadSlots<P> {
|
||||
|
||||
/// [`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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Instant>,
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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<B: PadProto> {
|
||||
/// [`RUMBLE_IDLE_TIMEOUT`] against this is a residual the game abandoned — see
|
||||
/// [`pump`](Self::pump).
|
||||
last_active: Vec<Instant>,
|
||||
/// 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<Option<Instant>>,
|
||||
/// Per-pad rate limiter for the ring-overflow WARN — see [`OverflowWarn`].
|
||||
overflow_warn: Vec<OverflowWarn>,
|
||||
}
|
||||
@@ -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<B: PadProto> UhidManager<B> {
|
||||
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<B: PadProto> UhidManager<B> {
|
||||
}
|
||||
// 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<B: PadProto> UhidManager<B> {
|
||||
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<B: PadProto> UhidManager<B> {
|
||||
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<B: PadProto> UhidManager<B> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<B: PadProto> UhidManager<B> {
|
||||
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<MockProto> {
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user