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).
This commit is contained in:
2026-08-07 19:07:30 +02:00
parent efb7f99129
commit d996449a82
4 changed files with 105 additions and 3 deletions
@@ -12,6 +12,7 @@
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
//! same as a real USB DualSense.
use punktfunk_core::input::gamepad as gs;
use punktfunk_core::quic::{HidOutput, RichInput};
// Feature reports the host stack GET_REPORTs during init — without these replies the kernel
@@ -222,7 +223,15 @@ pub struct DsState {
}
impl DsState {
/// A centered, nothing-pressed state (sticks 0x80, dpad neutral).
/// A centered, nothing-pressed state (sticks 0x80, dpad neutral) — and, crucially, a pad that
/// is sitting STILL rather than falling.
///
/// Acceleration is 1 g up ([`gs::MOTION_NEUTRAL_ACCEL`]), not zero. `[0, 0, 0]` reads as free
/// fall to anything that interprets the accelerometer, which is a definite lie about the
/// physical world; a pad that has sent no motion yet — or has none at all — is on a desk or in
/// someone's hands, and both read 1 g up. This is what `switch_proto`'s neutral has always done
/// on its own up axis, and the DualSense family now does on the axis a real DualSense was
/// measured to use. The DS4 reuses this state, so it is covered by the same line.
pub fn neutral() -> DsState {
DsState {
lx: 0x80,
@@ -230,6 +239,7 @@ impl DsState {
rx: 0x80,
ry: 0x80,
dpad: 8,
accel: gs::MOTION_NEUTRAL_ACCEL,
..Default::default()
}
}
@@ -168,8 +168,19 @@ pub struct SteamState {
}
impl SteamState {
/// A fresh pad — and one that is sitting STILL, not falling.
///
/// Acceleration is 1 g up, for the reason spelled out on [`gs::MOTION_NEUTRAL_ACCEL`]: zero is
/// free fall, which is a claim about the world that is never true of a controller. It is put
/// through [`super::steam_remap::motion_wire_to_deck`] rather than written out in Deck units,
/// so the neutral and every real sample can never disagree about what 1 g is — the Deck's
/// `hid-steam` resolution lives in exactly one place.
pub fn neutral() -> SteamState {
SteamState::default()
let (_, accel) = super::steam_remap::motion_wire_to_deck([0; 3], gs::MOTION_NEUTRAL_ACCEL);
SteamState {
accel,
..SteamState::default()
}
}
/// Zero angular velocity, keeping acceleration (gravity is legitimately persistent) and
+63 -1
View File
@@ -29,7 +29,9 @@ use pf_inject::dualshock4_proto::{
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::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
@@ -264,6 +266,66 @@ fn neutralizing_motion_keeps_gravity() {
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
+19
View File
@@ -198,6 +198,25 @@ pub mod gamepad {
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;
/// What a controller sitting still, face up, actually puts on the wire: **1 g along the UP
/// axis** — which is index 1 — and nothing on the other two.
///
/// This is a measured fact, not a convention we chose. On 2026-08-07 a real DualSense was read
/// over raw HID: at rest it reports `+0.997 g` on report axis 1, and the same session pinned
/// the frame as (Right, Up, Backward) — axis 0 carries pitch, 1 yaw, 2 roll. The wire is a unit
/// passthrough into that report, so the wire's up axis is the pad's.
///
/// It exists because the alternative is worse than imprecise. A virtual pad that has never
/// received a motion sample used to report `[0, 0, 0]`, and zero acceleration is not "no
/// information" — it is a controller in **free fall**, which is a claim about the physical
/// world that is never true of a pad on a desk. A game deriving orientation from it gets a
/// definite wrong answer instead of a boring right one. `switch_proto`'s neutral has always
/// done this correctly (1 g on its own up axis); the DualSense family and the Deck did not.
///
/// Backends whose units differ rescale this like any other sample rather than hard-coding
/// their own version of 1 g — see `steam_remap::motion_wire_to_deck`.
pub const MOTION_NEUTRAL_ACCEL: [i16; 3] = [0, MOTION_ACCEL_LSB_PER_G as i16, 0];
}
impl InputKind {