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 {