Files
punktfunk/crates/pf-inject/tests/motion_contract.rs
T
enricobuehler d996449a82 fix(host/pads): a virtual pad at rest said it was in free fall
G14, unblocked by the frame measurement in efb7f991 — the plan deliberately left this
one alone until the up axis was known, on the grounds that a confidently wrong constant
would be worse than an obviously wrong zero. It is known now.

A virtual DualSense, DualShock 4 or Steam Deck that had received no motion reported
acceleration `[0, 0, 0]`. That is not "no data": zero proper acceleration means free
fall, which is a definite claim about the physical world and one that is never true of
a controller sitting on a desk or held in someone's hands — both read 1 g up. Anything
that interprets the accelerometer gets a confident wrong answer rather than a boring
right one.

It is worst exactly where it is least visible. A pad with no gyro at all — an X-Box
controller forwarded as a DualSense, which is what "Automatic" does for anything not
Sony or Valve — never sends motion, so it sits on that neutral for the entire session,
telling every game that reads it that the controller is falling. `switch_proto` has
always done this correctly on its own up axis, which is what made the gap visible in the
first place.

Which axis, and why it took a measurement. The wire is a unit passthrough into the
virtual pad's report, so the wire's up axis is the pad's own, and on 2026-08-07 a real
DualSense read over raw HID put `+0.997 g` on report axis 1 at rest, in a frame pinned
the same session as (Right, Up, Backward). So `MOTION_NEUTRAL_ACCEL` is `[0, 10000, 0]`
— NOT the z-up the notes had assumed from `switch_proto`'s documentation, which is why
guessing would have shipped a backend confidently disagreeing with the hardware.

The constant lives in punktfunk-core beside the units it is expressed in, and every
backend derives from it rather than restating it. The Deck's neutral in particular goes
through `steam_remap::motion_wire_to_deck`, the same rescale a real sample takes, so the
neutral and the live path can never end up with two opinions about what 1 g is — its
`hid-steam` resolution stays in exactly one place. The DS4 needs no separate change: it
reuses `DsState`.

`switch_proto` is deliberately NOT touched, and the test says so. It is a different
device on a different driver, its up axis is its own, and nobody has measured its frame
— aligning it to the DualSense for consistency would be the same unmeasured guess this
commit exists to avoid, just in the other direction.

Non-vacuity proven both ways rather than assumed. Moving the up axis to slot 2 (the old
z-up assumption) fails on the wire constant itself, which is what makes the measurement
load-bearing rather than decorative; reverting both neutrals to `[0, 0, 0]` fails on the
DualSense assertion with the message naming the defect. Each backend is checked in ITS
OWN units, because hard-coding "1 g" three times is how the halves of a unit contract
drift apart.

Gate (Linux CI image): fmt, build, `clippy --locked --all-targets -D warnings` across
punktfunk-core / pf-inject / pf-client-core, and both test suites — green, with
`Running tests/motion_contract.rs` and the new case's own `... ok` line observed in the
log rather than inferred from a green exit (`cargo test` stops after the first failing
binary, so a green-looking run can mean the contract test never executed at all).
2026-08-07 19:07:30 +02:00

406 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, MOTION_NEUTRAL_ACCEL,
};
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");
}
/// A virtual pad that has received no motion must read as STILL, not as falling.
///
/// `[0, 0, 0]` is not "no information": zero proper acceleration is free fall, a claim about the
/// physical world that is never true of a controller on a desk. Anything deriving orientation from
/// the accelerometer gets a confident wrong answer rather than a boring right one — and the pads
/// this affects most are the ones with no gyro at all, which sit on that neutral for the whole
/// session.
///
/// Each backend is checked in ITS OWN units, because the value differs per backend and hard-coding
/// "1 g" three times is how the two halves of a unit contract drift apart.
#[test]
fn every_backend_neutral_reads_as_a_still_pad_not_a_falling_one() {
// The wire's own answer, measured from a real DualSense on 2026-08-07: axis 1 is UP.
assert_eq!(MOTION_NEUTRAL_ACCEL, [0, MOTION_ACCEL_LSB_PER_G as i16, 0]);
let ds = DsState::neutral();
assert_eq!(
ds.accel, MOTION_NEUTRAL_ACCEL,
"a fresh DualSense/DS4 must report 1 g up, not free fall"
);
assert_eq!(ds.gyro, [0; 3], "and it must not be turning");
// The Deck rescales, so its neutral is the wire's put through the same conversion a real
// sample takes — asserted against the resolution `hid-steam` actually fixes (16384 LSB/g),
// so a change to either side has to face this line.
let deck = SteamState::neutral();
assert_eq!(
deck.accel,
motion_wire_to_deck([0; 3], MOTION_NEUTRAL_ACCEL).1,
"the Deck neutral must be the wire neutral, rescaled — not a second opinion about 1 g"
);
assert_eq!(deck.accel, [0, 16384, 0]);
assert_eq!(deck.gyro, [0; 3]);
// The Switch Pro already did this correctly and is deliberately NOT touched: it is a different
// device (hid-nintendo), its up axis is its own, and nobody has measured its frame. Pinned so
// that a well-meaning sweep does not "make it consistent" with the DualSense on no evidence.
let sw = SwitchState::neutral();
assert_eq!(
sw.accel,
[0, 0, 4096],
"switch_proto's neutral is its own device's; do not align it to the DualSense unmeasured"
);
// The property that actually matters, stated once per backend: none of them is in free fall.
for (what, accel) in [
("dualsense", ds.accel),
("deck", deck.accel),
("switch", sw.accel),
] {
assert_ne!(accel, [0; 3], "{what} neutral reads as free fall");
let mag = accel
.iter()
.map(|&v| (v as f64).powi(2))
.sum::<f64>()
.sqrt();
assert!(mag > 0.0, "{what} neutral has no gravity at all");
}
}
// ---- 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);
}
}
}