From d996449a82da821575a6edbf5131fc27a79250f6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 19:07:30 +0200 Subject: [PATCH] fix(host/pads): a virtual pad at rest said it was in free fall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../src/inject/proto/dualsense_proto.rs | 12 +++- .../pf-inject/src/inject/proto/steam_proto.rs | 13 +++- crates/pf-inject/tests/motion_contract.rs | 64 ++++++++++++++++++- crates/punktfunk-core/src/input.rs | 19 ++++++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/crates/pf-inject/src/inject/proto/dualsense_proto.rs b/crates/pf-inject/src/inject/proto/dualsense_proto.rs index 1f59c880..190f46ca 100644 --- a/crates/pf-inject/src/inject/proto/dualsense_proto.rs +++ b/crates/pf-inject/src/inject/proto/dualsense_proto.rs @@ -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() } } diff --git a/crates/pf-inject/src/inject/proto/steam_proto.rs b/crates/pf-inject/src/inject/proto/steam_proto.rs index fa3ef8bf..dd3f1d72 100644 --- a/crates/pf-inject/src/inject/proto/steam_proto.rs +++ b/crates/pf-inject/src/inject/proto/steam_proto.rs @@ -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 diff --git a/crates/pf-inject/tests/motion_contract.rs b/crates/pf-inject/tests/motion_contract.rs index 4963e979..9041d9b5 100644 --- a/crates/pf-inject/tests/motion_contract.rs +++ b/crates/pf-inject/tests/motion_contract.rs @@ -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::() + .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 diff --git a/crates/punktfunk-core/src/input.rs b/crates/punktfunk-core/src/input.rs index 0faa6f7c..f94d5875 100644 --- a/crates/punktfunk-core/src/input.rs +++ b/crates/punktfunk-core/src/input.rs @@ -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 {