From 4834c2ee5169b166504d281f7e21a4db071e4628 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 12:47:26 +0200 Subject: [PATCH 01/22] =?UTF-8?q?fix(host/pads):=20DualShock=204=20gyro=20?= =?UTF-8?q?ran=2040=C3=97=20fast,=20and=20no=20pad=20ever=20stopped=20turn?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- clients/probe/src/main.rs | 8 +- crates/pf-client-core/src/gamepad.rs | 31 +- .../pf-inject/src/inject/linux/dualsense.rs | 30 +- .../pf-inject/src/inject/linux/dualshock4.rs | 84 ++--- .../src/inject/linux/steam_controller.rs | 16 + .../src/inject/linux/steam_controller2.rs | 6 + .../pf-inject/src/inject/linux/switch_pro.rs | 8 + crates/pf-inject/src/inject/pad_slots.rs | 100 ++++- .../src/inject/proto/dualsense_proto.rs | 20 + .../src/inject/proto/dualshock4_proto.rs | 89 ++++- .../pf-inject/src/inject/proto/steam_proto.rs | 27 ++ .../pf-inject/src/inject/proto/steam_remap.rs | 12 +- .../src/inject/proto/switch_proto.rs | 33 +- crates/pf-inject/src/inject/sensor_clock.rs | 141 +++++++ crates/pf-inject/src/inject/uhid_manager.rs | 199 +++++++++- .../inject/windows/dualsense_edge_windows.rs | 8 + .../src/inject/windows/dualsense_windows.rs | 19 +- .../src/inject/windows/dualshock4_windows.rs | 19 +- .../src/inject/windows/gamepad_windows.rs | 3 +- .../src/inject/windows/steam_deck_windows.rs | 8 + crates/pf-inject/src/lib.rs | 6 + crates/pf-inject/tests/motion_contract.rs | 343 ++++++++++++++++++ crates/punktfunk-core/src/input.rs | 18 + .../windows/drivers/pf-gamepad/src/lib.rs | 18 +- 24 files changed, 1117 insertions(+), 129 deletions(-) create mode 100644 crates/pf-inject/src/inject/sensor_clock.rs create mode 100644 crates/pf-inject/tests/motion_contract.rs diff --git a/clients/probe/src/main.rs b/clients/probe/src/main.rs index 112fd4ce..399704f0 100644 --- a/clients/probe/src/main.rs +++ b/clients/probe/src/main.rs @@ -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(), diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 80a50293..e8a1d826 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -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, diff --git a/crates/pf-inject/src/inject/linux/dualsense.rs b/crates/pf-inject/src/inject/linux/dualsense.rs index 2e90f5f0..b852a523 100644 --- a/crates/pf-inject/src/inject/linux/dualsense.rs +++ b/crates/pf-inject/src/inject/linux/dualsense.rs @@ -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); } diff --git a/crates/pf-inject/src/inject/linux/dualshock4.rs b/crates/pf-inject/src/inject/linux/dualshock4.rs index fd37d9ee..8929b2ec 100644 --- a/crates/pf-inject/src/inject/linux/dualshock4.rs +++ b/crates/pf-inject/src/inject/linux/dualshock4.rs @@ -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; #[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. diff --git a/crates/pf-inject/src/inject/linux/steam_controller.rs b/crates/pf-inject/src/inject/linux/steam_controller.rs index 12e716bb..5bd26231 100644 --- a/crates/pf-inject/src/inject/linux/steam_controller.rs +++ b/crates/pf-inject/src/inject/linux/steam_controller.rs @@ -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); } diff --git a/crates/pf-inject/src/inject/linux/steam_controller2.rs b/crates/pf-inject/src/inject/linux/steam_controller2.rs index ad9c0f9a..acd8efd0 100644 --- a/crates/pf-inject/src/inject/linux/steam_controller2.rs +++ b/crates/pf-inject/src/inject/linux/steam_controller2.rs @@ -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); } diff --git a/crates/pf-inject/src/inject/linux/switch_pro.rs b/crates/pf-inject/src/inject/linux/switch_pro.rs index c6e5e104..015169ee 100644 --- a/crates/pf-inject/src/inject/linux/switch_pro.rs +++ b/crates/pf-inject/src/inject/linux/switch_pro.rs @@ -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); } diff --git a/crates/pf-inject/src/inject/pad_slots.rs b/crates/pf-inject/src/inject/pad_slots.rs index 8538fab7..e84a74a9 100644 --- a/crates/pf-inject/src/inject/pad_slots.rs +++ b/crates/pf-inject/src/inject/pad_slots.rs @@ -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>` 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

PadSlots

{ } /// 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

PadSlots

{ /// [`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"); } diff --git a/crates/pf-inject/src/inject/proto/dualsense_proto.rs b/crates/pf-inject/src/inject/proto/dualsense_proto.rs index c961843e..1f59c880 100644 --- a/crates/pf-inject/src/inject/proto/dualsense_proto.rs +++ b/crates/pf-inject/src/inject/proto/dualsense_proto.rs @@ -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 diff --git a/crates/pf-inject/src/inject/proto/dualshock4_proto.rs b/crates/pf-inject/src/inject/proto/dualshock4_proto.rs index d3955dcb..22e93643 100644 --- a/crates/pf-inject/src/inject/proto/dualshock4_proto.rs +++ b/crates/pf-inject/src/inject/proto/dualshock4_proto.rs @@ -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) { diff --git a/crates/pf-inject/src/inject/proto/steam_proto.rs b/crates/pf-inject/src/inject/proto/steam_proto.rs index 125894c7..fa3ef8bf 100644 --- a/crates/pf-inject/src/inject/proto/steam_proto.rs +++ b/crates/pf-inject/src/inject/proto/steam_proto.rs @@ -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 { diff --git a/crates/pf-inject/src/inject/proto/steam_remap.rs b/crates/pf-inject/src/inject/proto/steam_remap.rs index 6cb5ae97..cc3e62d8 100644 --- a/crates/pf-inject/src/inject/proto/steam_remap.rs +++ b/crates/pf-inject/src/inject/proto/steam_remap.rs @@ -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 diff --git a/crates/pf-inject/src/inject/proto/switch_proto.rs b/crates/pf-inject/src/inject/proto/switch_proto.rs index 8f1aa2b2..56b3734a 100644 --- a/crates/pf-inject/src/inject/proto/switch_proto.rs +++ b/crates/pf-inject/src/inject/proto/switch_proto.rs @@ -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); } } diff --git a/crates/pf-inject/src/inject/sensor_clock.rs b/crates/pf-inject/src/inject/sensor_clock.rs new file mode 100644 index 00000000..3ddea639 --- /dev/null +++ b/crates/pf-inject/src/inject/sensor_clock.rs @@ -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, + /// 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); + } +} diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index 792b71a7..e43ab836 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -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 { /// [`RUMBLE_IDLE_TIMEOUT`] against this is a residual the game abandoned — see /// [`pump`](Self::pump). last_active: Vec, + /// 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>, /// Per-pad rate limiter for the ring-overflow WARN — see [`OverflowWarn`]. overflow_warn: Vec, } @@ -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 UhidManager { 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 UhidManager { } // 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 UhidManager { 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 UhidManager { 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 UhidManager { } } + /// 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 UhidManager { 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 { 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. diff --git a/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs index f640c4bd..acac31a1 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs @@ -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); } diff --git a/crates/pf-inject/src/inject/windows/dualsense_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_windows.rs index 9366cd96..2c12de72 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_windows.rs @@ -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); } diff --git a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs index 27e16840..b3be4a4e 100644 --- a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs @@ -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); } diff --git a/crates/pf-inject/src/inject/windows/gamepad_windows.rs b/crates/pf-inject/src/inject/windows/gamepad_windows.rs index d0191c72..b6b8ed91 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_windows.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_windows.rs @@ -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; diff --git a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs index d8e85a73..1373f8f8 100644 --- a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs +++ b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs @@ -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); } diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index ed946eb1..c1f63766 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -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"] diff --git a/crates/pf-inject/tests/motion_contract.rs b/crates/pf-inject/tests/motion_contract.rs new file mode 100644 index 00000000..4963e979 --- /dev/null +++ b/crates/pf-inject/tests/motion_contract.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 { + 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 = 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); + } + } +} diff --git a/crates/punktfunk-core/src/input.rs b/crates/punktfunk-core/src/input.rs index 12a5db94..0faa6f7c 100644 --- a/crates/punktfunk-core/src/input.rs +++ b/crates/punktfunk-core/src/input.rs @@ -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 { diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index 96fb6d5e..b24360af 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -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 From ce5047f3ad3c096c6653248f73797203c3928f00 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 13:44:54 +0200 Subject: [PATCH 02/22] fix(host/pads): the Windows driver stops halving motion and stops serving torn reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G6 + G15 of the gyro program. G6 — the UMDF gamepad driver's input path. Its timer ran at 8 ms and completed one pended READ_REPORT per tick, so a game could observe at most ~125 Hz while clients stream motion at ~250 Hz: every other sample was overwritten in the slot before anything read it, and the ones that survived carried up to 8 ms of extra latency. For gyro, a dropped sample is not a dropped frame — it is rotation that never reaches the game. The timer now ticks at 2 ms (about a real DualShock 4's Bluetooth cadence). Only the cheap half runs on every tick: read the input slot, complete one pended read. The channel handshake and the health marks stay on their historical ~8 ms, because they cost more, nothing wants them faster, and `driver_heartbeat`'s documented "+1 per ~8 ms tick" is what the host reads as liveness. The same slot is a single unqueued buffer that both sides touch without a lock, so a driver read landing mid-copy handed the game a report that was half the previous frame and half the next. For a button that is a one-tick glitch; for motion it is a spike in angular velocity, which an integrator turns into aim movement. `PadShm` gains an `input_gen` seqlock (v2.3, carved from reserved space inside the v2 legacy region): the host takes it odd, fences, writes the 64 bytes, and stores it even; the driver samples it either side of its read and retries once. The old code's own comment called this out as a known residual — it is now closed rather than documented. Version posture matches the ring's, with one simplification: no capability stamp is needed, because an old host never writes the field and a constant 0 is indistinguishable from "no write in flight", so a new driver against an old host behaves exactly as it does today, and an old driver ignores the field entirely. The Steam Deck write path had neither the seqlock nor even the trailing Release its DualSense sibling carried; all three Windows backends now publish through one `publish_input`. G15 — motion-cadence observability. The host already computed the measurement a "gyro feels floaty" report needs (client inter-arrival percentiles), but kept ONE global accumulator, so two motion-capable pads in a session interleaved into each other's gaps and produced a number describing neither. It also sat at `debug` behind a `tracing::enabled!` check, so a field log arrived with nothing in it and the only way to get the measurement was to ask for a re-run. Now per-pad and always on, summarized at `info` when the session ends — the moment a field report is being written. It costs one subtraction and one array increment per sample: percentiles come from a fixed log2 histogram instead of a growing sorted Vec, so there is no allocation, no per-window sort, and no way for a client streaming as fast as the link allows to make the instrument expensive. Percentiles are reported as bucket upper bounds (`_le`), which is a factor-of-two answer to a question whose answers are orders of magnitude apart. Gaps of 500 ms or more are counted as stalls rather than folded into the percentiles — an interruption is not a cadence, and averaging it in would report a healthy feed as a terrible one. Gates. Windows CI runner .133, the drivers workspace on the real WDK: cargo build, clippy -D warnings (which enforces the unsafe-audit lints), and fmt — all green, against a source whose SHA-256 matches this commit's. Linux CI image: fmt, build, clippy --all-targets -D warnings over pf-inject / punktfunk-core / punktfunk-probe / pf-client-core / pf-driver-proto / punktfunk-host, and the test suites including the 5 new motion-cadence tests — all green. Not measured on glass. G6's stated gate is a sensor-rate reading (SDL testcontroller or Steam's calibration screen) that matches the client's send rate; that is still owed, and a driver change only a compile has seen deserves it before anyone trusts the number. --- crates/pf-driver-proto/src/lib.rs | 25 +- .../src/inject/windows/dualsense_windows.rs | 70 ++++-- .../src/inject/windows/dualshock4_windows.rs | 18 +- .../src/inject/windows/steam_deck_windows.rs | 21 +- crates/punktfunk-host/src/native.rs | 3 + crates/punktfunk-host/src/native/input.rs | 56 ++--- .../src/native/motion_cadence.rs | 235 ++++++++++++++++++ .../windows/drivers/pf-gamepad/src/lib.rs | 94 +++++-- 8 files changed, 422 insertions(+), 100 deletions(-) create mode 100644 crates/punktfunk-host/src/native/motion_cadence.rs diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index d7cd32a8..3ea79836 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -1246,7 +1246,25 @@ pub mod gamepad { /// a pre-v2.2 driver that never writes it = [`OUT_RING_LEN`]. Carved from v2.1 reserved /// space (v2.2). pub out_ring_len: u32, - pub _reserved1: [u8; 88], + /// Seqlock generation over the [`PadShm::input`] slot (host-written): **odd** while a + /// report is mid-copy, **even** when the slot holds a whole one. The host bumps it to odd, + /// `Release`-fences, writes the 64 bytes, then `Release`-stores it even; a driver samples + /// it before and after its read and retries when it caught a write in flight. + /// + /// The input slot is a single unqueued buffer that both sides touch without a lock, so a + /// driver read landing mid-copy hands the game a report that is half the previous frame + /// and half the next. For buttons that is a one-tick glitch; for motion it is a spike in + /// angular velocity, and anything integrating gyro aim turns a spike into real aim + /// movement. (v2.3) + /// + /// Version posture, same as the ring's: an old driver never reads this and behaves exactly + /// as it does today, and against an old HOST the field stays 0 — a constant even value, so + /// a new driver's re-check always passes and it, too, behaves exactly as today. No + /// capability stamp is needed because "never written" and "no write in flight" are the + /// same observation. Carved from v2.2 reserved space, inside the v2 legacy region so even + /// the smallest cross-generation map covers it. + pub input_gen: u32, + pub _reserved1: [u8; 84], /// The lossless output-report ring — [`OUT_RING_LEN`] slots under a v2.1 negotiation, /// [`OUT_RING_LEN_V22`] under v2.2 (slots 8.. overlay what v2.1 called `_reserved2`, /// which no shipped binary ever read or wrote). See the struct docs and [`OutSlot`]. @@ -1295,6 +1313,11 @@ pub mod gamepad { // stays within the v2.1 slots' historical offsets (slot k at 256 + k*68), and the whole // struct is exactly the one page that keeps cross-generation views mappable. assert!(offset_of!(PadShm, out_ring_len) == 164); + // v2.3 input seqlock — 4-aligned (the atomic accessors check it) and inside the v2 legacy + // region, so every driver generation's map covers it whether or not it reads it. + assert!(offset_of!(PadShm, input_gen) == 168); + assert!(offset_of!(PadShm, input_gen) % 4 == 0); + assert!(offset_of!(PadShm, input_gen) < PAD_SHM_LEGACY_SIZE); assert!( PAD_SHM_LEGACY_SIZE + OUT_RING_LEN_USIZE * size_of::() <= PAD_SHM_V21_SIZE ); diff --git a/crates/pf-inject/src/inject/windows/dualsense_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_windows.rs index 2c12de72..72871d63 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_windows.rs @@ -71,6 +71,46 @@ pub(super) const OFF_OUT_RING: usize = pub(super) const OUT_SLOT_SIZE: usize = core::mem::size_of::(); pub(super) const OUT_RING_LEN: u32 = pf_driver_proto::gamepad::OUT_RING_LEN; pub(super) const OUT_RING_LEN_V22: u32 = pf_driver_proto::gamepad::OUT_RING_LEN_V22; +/// v2.3 input seqlock — see [`publish_input`] and the `PadShm` docs. +pub(super) const OFF_INPUT_GEN: usize = + core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, input_gen); + +/// Publish one HID input report into the section's input slot under the v2.3 seqlock, so a driver +/// reading concurrently can tell a whole report from a half-written one. +/// +/// The slot is a single unqueued buffer neither side locks: the driver's timer copies 64 bytes out +/// of it whenever it likes, including in the middle of this write. The result is a frame that is +/// part previous report and part next — a one-tick glitch for a button, but for motion a spike in +/// angular velocity, and a game integrating gyro aim turns that spike into aim it never asked for. +/// +/// `generation` is the pad's own counter, taken to **odd** before the body goes down and back to +/// **even** after; a driver samples it either side of its read and retries when the two disagree. +/// The `Release` fence keeps the body stores from sinking above the odd marker, and the `Release` +/// store publishes them ahead of the even one — both no-ops on x86-TSO and load-bearing on ARM64. +/// +/// # Safety +/// `base` must point at a live mapped pad section of at least `PAD_SHM_SIZE` bytes, and `report` +/// must be no longer than the 64-byte input slot. +pub(super) unsafe fn publish_input(base: *mut u8, generation: &mut u32, report: &[u8]) { + debug_assert!(report.len() <= 64, "report overruns the input slot"); + // Odd: a report is in flight. + *generation = generation.wrapping_add(1); + // SAFETY: the caller guarantees `base` maps the section; `OFF_INPUT_GEN` (== 168) is 4-aligned + // off the page-aligned base and sits in the v2 legacy region every driver generation maps. + unsafe { + (*(base.add(OFF_INPUT_GEN) as *const AtomicU32)).store(*generation, Ordering::Relaxed) + }; + // Ordered, not ordering: keeps the body stores below from being hoisted above the odd marker. + fence(Ordering::Release); + // SAFETY: the caller guarantees the mapping and that `report` fits the slot at OFF_INPUT. + unsafe { std::ptr::copy_nonoverlapping(report.as_ptr(), base.add(OFF_INPUT), report.len()) }; + // Even: the slot holds a whole report again. + *generation = generation.wrapping_add(1); + // SAFETY: as the first store. + unsafe { + (*(base.add(OFF_INPUT_GEN) as *const AtomicU32)).store(*generation, Ordering::Release) + }; +} /// Shared drain over a pad section's output plane — the lossless report ring when the driver /// publishes one (8 slots from a v2.1 driver, [`OUT_RING_LEN_V22`] once both sides negotiated the @@ -218,6 +258,8 @@ pub struct DsWinPad { attach: super::gamepad_raii::DriverAttach, seq: u8, clock: SensorClock, + /// This pad's v2.3 input-seqlock generation — see [`publish_input`]. + input_gen: u32, /// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver). drain: OutputDrain, } @@ -513,6 +555,7 @@ impl DsWinPad { ), seq: 0, clock: SensorClock::dualsense(), + input_gen: 0, drain: OutputDrain::new(), }) } @@ -523,25 +566,14 @@ impl DsWinPad { let ts = self.clock.ds_ticks(Instant::now()); let mut r = [0u8; DS_INPUT_REPORT_LEN]; 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 - // READ_REPORTs on its ~125 Hz timer, and the report's own sequence counter (r[7], mid-report) - // is consumed by the game's HID stack, not the driver — so it cannot serve as a separable - // publish flag without a seqlock generation the driver `Acquire`-reads (a `PadShm` layout + - // driver change, deferred). The `Release` fence after the copy orders the report-body stores - // ahead of this pad's next `Release` publish (the bootstrap/seq stores in `channel.pump()`), - // giving the copy Release visibility on a weakly-ordered core (ARM64); on x86-TSO it is a - // no-op. Residual: absent a driver-side `Acquire` on a per-frame input generation, a torn - // single frame is still theoretically possible but self-heals on the next ~250 Hz write. - unsafe { - std::ptr::copy_nonoverlapping( - r.as_ptr(), - self.channel.data_base().add(OFF_INPUT), - r.len(), - ); - fence(Ordering::Release); - }; + // The input path has no driver-polled change-detect field the way the XUSB `packet` / + // DualSense `out_seq` planes do — the driver streams the whole `input` region to game + // READ_REPORTs on its timer, and the report's own counter (r[7], mid-report) belongs to + // the game's HID stack, not the driver. That used to leave a torn single frame possible. + // The v2.3 seqlock closes it: see `publish_input`. + // SAFETY: `data_base()` points at a live PAD_SHM_SIZE-byte section and `r` is the 64-byte + // input report. + unsafe { publish_input(self.channel.data_base(), &mut self.input_gen, &r) }; } /// Drain the section's output plane; parse every new `0x02` report (rumble / LEDs / triggers) diff --git a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs index b3be4a4e..2d839314 100644 --- a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs @@ -9,8 +9,8 @@ use super::dualsense_proto::DsState; use super::dualsense_windows::{ - create_swdevice, OutputDrain, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, - OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, + create_swdevice, publish_input, OutputDrain, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, + OFF_DRIVER_PROTO, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, }; use super::dualshock4_proto::{ parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W, @@ -39,6 +39,8 @@ pub struct Ds4WinPad { attach: super::gamepad_raii::DriverAttach, counter: u8, clock: SensorClock, + /// This pad's v2.3 input-seqlock generation — see `publish_input`. + input_gen: u32, /// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver). drain: OutputDrain, } @@ -107,6 +109,7 @@ impl Ds4WinPad { ), counter: 0, clock: SensorClock::dualshock4(), + input_gen: 0, drain: OutputDrain::new(), }) } @@ -117,14 +120,9 @@ impl Ds4WinPad { let ts = self.clock.ds4_ticks(Instant::now()); let mut r = [0u8; DS4_INPUT_REPORT_LEN]; 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( - r.as_ptr(), - self.channel.data_base().add(OFF_INPUT), - r.len(), - ) - }; + // SAFETY: `data_base()` points at a live PAD_SHM_SIZE-byte section and `r` is the 64-byte + // input report. Publishes under the v2.3 seqlock — see `publish_input`. + unsafe { publish_input(self.channel.data_base(), &mut self.input_gen, &r) }; } /// Drain the section's output plane; parse every new `0x05` report (rumble / lightbar) into a diff --git a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs index 1373f8f8..5e6e0e15 100644 --- a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs +++ b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs @@ -18,8 +18,8 @@ //! kernel's evdev parser; Steam-on-Windows reads the raw reports directly. use super::dualsense_windows::{ - create_swdevice, OutputDrain, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, - OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, + create_swdevice, publish_input, OutputDrain, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, + OFF_INPUT, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, }; use super::gamepad_raii::PadChannel; use super::steam_proto::{ @@ -45,6 +45,8 @@ pub struct DeckWinPad { /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. attach: super::gamepad_raii::DriverAttach, seq: u32, + /// This pad's v2.3 input-seqlock generation — see `publish_input`. + input_gen: u32, /// Output-plane cursors: ring drain (v2.1 driver) or legacy latest-slot seq (old driver). drain: OutputDrain, } @@ -106,6 +108,7 @@ impl DeckWinPad { instance_id, ), seq: 0, + input_gen: 0, drain: OutputDrain::new(), }) } @@ -115,14 +118,12 @@ impl DeckWinPad { self.seq = self.seq.wrapping_add(1); let mut r = [0u8; STEAM_REPORT_LEN]; serialize_deck_state(&mut r, st, self.seq); - // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. - unsafe { - std::ptr::copy_nonoverlapping( - r.as_ptr(), - self.channel.data_base().add(OFF_INPUT), - r.len(), - ) - }; + // This path had neither the trailing `Release` its DualSense sibling carried nor any + // publish marker, so a driver read could land mid-copy AND the body stores had no ordering + // against the pad's next publish. `publish_input` gives it both (v2.3 seqlock). + // SAFETY: `data_base()` points at a live PAD_SHM_SIZE-byte section and `r` is the 64-byte + // Deck state frame. + unsafe { publish_input(self.channel.data_base(), &mut self.input_gen, &r) }; } /// Poll the section's output slot; parse a newly-published Steam command (`0xEB` rumble / diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index d27d091c..7d4ceb10 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -71,6 +71,9 @@ mod pad_audio; /// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a /// channel of `ClientInput`. The `Pads` router + rumble live there too. mod input; +/// Per-pad motion inter-arrival statistics ([`motion_cadence::MotionCadence`]) — the "gyro feels +/// floaty" measurement, summarized at `info` when a session ends. +mod motion_cadence; use input::{input_thread, ClientInput}; /// The Hello→Welcome→Start negotiation (plan §W1); `serve_session` calls `handshake::negotiate` diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index f0f70d25..377d5037 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -784,12 +784,9 @@ pub(super) fn input_thread( // — read back off the negotiated host_caps). Spawned on DualSense-family arrivals that // declare renderer bits, reaped on remove/teardown below. let mut pad_streams = PadAudioSlots::new(); - // Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window, - // the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad - // is 5000 u32s. - let mut motion_gaps_us: Vec = Vec::new(); - let mut last_motion: Option = None; - let mut motion_window = std::time::Instant::now(); + // Motion-cadence observability, PER PAD and always on — see `motion_cadence`. Summarized at + // `info` when this session ends, which is when a field report is being written. + let mut motion_cadence = super::motion_cadence::MotionCadence::new(); let mut pad_state = [PadState::default(); MAX_WIRE_PADS]; let mut pad_mask = 0u16; // Last applied snapshot seq per pad (`None` until the first one): the reorder gate for @@ -848,42 +845,13 @@ pub(super) fn input_thread( // Rich input (touchpad / motion) is applied the moment it arrives; the single channel // wakes for gyro samples instead of making them wait out the feedback poll interval. Ok(ClientInput::Rich(rich)) => { - // Debug-only instrument: skip the whole thing unless debug logging is actually - // enabled. It used to grow and `sort_unstable()` a Vec in the input hot loop - // regardless, so every session paid for a measurement nobody was reading — and the - // "bounded by a 5 s window at a plausible pad rate" reasoning was an assumption - // about the CLIENT's send rate, not a bound the host enforced (2026-08-05 review - // L-5). The explicit cap below makes it a bound. - if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) - && tracing::enabled!(tracing::Level::DEBUG) - { - let now = std::time::Instant::now(); - if let Some(prev) = last_motion.replace(now) { - let gap = now.duration_since(prev); - // 30k samples is 5 s at 6 kHz — well past any real pad, and a hard stop - // for a client that simply sends motion as fast as the link allows. - if gap < std::time::Duration::from_secs(1) && motion_gaps_us.len() < 30_000 - { - motion_gaps_us.push(gap.as_micros() as u32); - } - } - if motion_window.elapsed() >= std::time::Duration::from_secs(5) - && !motion_gaps_us.is_empty() - { - motion_gaps_us.sort_unstable(); - let p = |q: f64| { - motion_gaps_us[(q * (motion_gaps_us.len() - 1) as f64) as usize] - }; - tracing::debug!( - samples = motion_gaps_us.len() + 1, - gap_p50_us = p(0.5), - gap_p95_us = p(0.95), - gap_max_us = motion_gaps_us.last().copied().unwrap_or(0), - "motion cadence (client gyro inter-arrival, 5 s window)" - ); - motion_gaps_us.clear(); - motion_window = std::time::Instant::now(); - } + // Per-pad inter-arrival, unconditionally: one subtraction and one array increment, + // cheap enough that a session no longer has to be re-run with debug logging on to + // answer "is the gyro feed even arriving evenly". The old instrument grew and + // sorted a Vec, which is why it had to be gated — and it shared ONE accumulator + // across pads, so two motion pads measured each other. + if let punktfunk_core::quic::RichInput::Motion { pad, .. } = rich { + motion_cadence.record(pad, std::time::Instant::now()); } pads.apply_rich(rich); } @@ -1169,6 +1137,10 @@ pub(super) fn input_thread( // Reap the per-pad 0xD1 streamers with the session (after the instant release sends above // — this can block on a quiet pad's capturer timeout, see PadAudioSlots::stop_all). pad_streams.stop_all(); + // One line per pad that carried motion. At `info` deliberately: the question it answers + // ("was the gyro feed even arriving evenly?") is asked from a field log after the fact, and a + // measurement that needs the session re-run with debug logging is a measurement nobody gets. + motion_cadence.log_summary(); } #[cfg(test)] diff --git a/crates/punktfunk-host/src/native/motion_cadence.rs b/crates/punktfunk-host/src/native/motion_cadence.rs new file mode 100644 index 00000000..7647ae8d --- /dev/null +++ b/crates/punktfunk-host/src/native/motion_cadence.rs @@ -0,0 +1,235 @@ +//! Per-pad motion inter-arrival statistics — the measurement a "gyro feels floaty" report needs. +//! +//! Gyro aim integrates angular velocity over time, so what a player feels as floaty or jumpy is +//! usually not the samples' values but their spacing: a 250 Hz feed arriving in 40 ms clumps +//! integrates the same total rotation in visibly worse steps. This is the one number that +//! distinguishes "the client stopped sending" from "the link is clumping them" from "we are fine +//! and the problem is elsewhere", and it is cheap enough to keep on always. +//! +//! Two things were wrong with the version this replaces. It kept ONE global accumulator, so two +//! motion-capable pads in a session interleaved into each other's inter-arrival gaps and produced +//! a number that described neither. And it lived at `debug` behind a `tracing::enabled!` check, so +//! a field report arrived with nothing in it and the only way to get the measurement was to ask +//! the user to reproduce with debug logging on. +//! +//! Cost, since it now runs unconditionally: one `Instant` subtraction and one array increment per +//! motion sample. Percentiles come out of a fixed log2 histogram rather than a growing sorted Vec +//! — no allocation, no per-window sort, and no way for a client that streams motion as fast as the +//! link allows to make the instrument expensive. The price is resolution: a reported percentile is +//! the upper bound of its bucket (hence the `_le` suffixes), which is a factor-of-two answer to a +//! question — 4 ms or 40 ms? — whose answers are orders of magnitude apart. + +use punktfunk_core::input::MAX_PADS; +use std::time::{Duration, Instant}; + +/// Log2 buckets over the inter-arrival gap in microseconds: bucket `k` holds gaps in +/// `[2^(k-1), 2^k)` µs, with bucket 0 holding a gap of 0. 22 buckets reach ~2.1 s, past which a +/// gap says "the feed stopped", not "the feed is uneven", and the top bucket saturates. +const BUCKETS: usize = 22; + +/// Gaps at or above this are not cadence, they are an interruption — a client that backgrounded, +/// a link that stalled, a session that idled. Counting them would drag every percentile toward a +/// number that describes the interruption instead of the stream, so they are tallied separately. +const STALL_GAP: Duration = Duration::from_millis(500); + +/// One pad's cadence accumulator. +#[derive(Clone, Copy, Default)] +struct PadCadence { + last: Option, + hist: [u32; BUCKETS], + /// Gaps folded into `hist` (so `samples = gaps + 1` when the pad sent anything at all). + gaps: u64, + /// Largest gap below [`STALL_GAP`], exactly — the histogram's top bucket is too coarse to + /// answer "how bad was the worst one". + max_us: u32, + /// Gaps at or beyond [`STALL_GAP`]: how many times this pad's feed simply stopped and resumed. + stalls: u32, +} + +impl PadCadence { + fn record(&mut self, now: Instant) { + let Some(prev) = self.last.replace(now) else { + return; // first sample — no gap yet + }; + let gap = now.saturating_duration_since(prev); + if gap >= STALL_GAP { + self.stalls = self.stalls.saturating_add(1); + return; + } + let us = gap.as_micros() as u32; + self.max_us = self.max_us.max(us); + self.gaps = self.gaps.saturating_add(1); + self.hist[bucket(us)] += 1; + } + + /// The upper bound (µs) of the bucket the `q`-quantile falls in, or `None` if this pad never + /// produced a gap. + fn percentile_us_le(&self, q: f64) -> Option { + if self.gaps == 0 { + return None; + } + // The rank of the quantile, 1-based: q=0.5 over 4 gaps is the 2nd. + let want = ((q * self.gaps as f64).ceil() as u64).max(1); + let mut seen = 0u64; + for (k, n) in self.hist.iter().enumerate() { + seen += *n as u64; + if seen >= want { + return Some(bucket_upper_us(k)); + } + } + Some(bucket_upper_us(BUCKETS - 1)) + } +} + +/// The bucket a gap of `us` microseconds falls in — `0` for 0, else `floor(log2(us)) + 1`, capped. +fn bucket(us: u32) -> usize { + if us == 0 { + return 0; + } + ((32 - us.leading_zeros()) as usize).min(BUCKETS - 1) +} + +/// The exclusive upper bound of bucket `k`, in microseconds (bucket 0 is exactly 0). +fn bucket_upper_us(k: usize) -> u32 { + if k == 0 { + return 0; + } + 1u32.checked_shl(k as u32).unwrap_or(u32::MAX) +} + +/// Every pad's cadence, keyed by wire index. +pub(super) struct MotionCadence { + pads: [PadCadence; MAX_PADS], +} + +impl MotionCadence { + pub(super) fn new() -> MotionCadence { + MotionCadence { + pads: [PadCadence::default(); MAX_PADS], + } + } + + /// Note one `RichInput::Motion` for `pad`, arriving now. + pub(super) fn record(&mut self, pad: u8, now: Instant) { + if let Some(p) = self.pads.get_mut(pad as usize) { + p.record(now); + } + } + + /// Log one `info` line per pad that carried motion this session. Called once, when the session + /// ends — the point at which a field report is being written and the numbers still exist. + pub(super) fn log_summary(&self) { + for (i, p) in self.pads.iter().enumerate() { + if p.gaps == 0 && p.stalls == 0 { + continue; + } + tracing::info!( + pad = i, + samples = p.gaps + 1, + // 0 = no gap was recorded at all (a pad that sent one sample and then stalled). + gap_p50_us_le = p.percentile_us_le(0.5).unwrap_or(0), + gap_p95_us_le = p.percentile_us_le(0.95).unwrap_or(0), + gap_max_us = p.max_us, + stalls = p.stalls, + "motion cadence for the session (client gyro inter-arrival; percentiles are \ + log2-bucket upper bounds, stalls are gaps ≥ 500 ms)" + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(t0: Instant, ms: u64) -> Instant { + t0 + Duration::from_millis(ms) + } + + /// The bug this module exists to fix: two motion pads used to share one accumulator, so each + /// one's gaps were measured against the OTHER's arrivals. Here pad 0 arrives every 4 ms and + /// pad 1 every 40 ms, interleaved — and each must report its own cadence, not the ~2 ms the + /// merged stream would show. + #[test] + fn two_pads_do_not_corrupt_each_others_cadence() { + let t0 = Instant::now(); + let mut c = MotionCadence::new(); + for i in 0..100u64 { + c.record(0, at(t0, i * 4)); + if i % 10 == 0 { + c.record(1, at(t0, i * 4)); + } + } + // 4 ms lands in the (2048, 4096] µs bucket; 40 ms in (32768, 65536]. + assert_eq!(c.pads[0].percentile_us_le(0.5), Some(4096)); + assert_eq!(c.pads[1].percentile_us_le(0.5), Some(65536)); + assert_eq!(c.pads[0].gaps, 99); + assert_eq!(c.pads[1].gaps, 9); + } + + /// A pad that never sent motion contributes nothing — no line, no gaps. + #[test] + fn a_silent_pad_records_nothing() { + let c = MotionCadence::new(); + assert_eq!(c.pads[3].gaps, 0); + assert_eq!(c.pads[3].percentile_us_le(0.5), None); + // One sample is not a gap. + let mut c = MotionCadence::new(); + c.record(3, Instant::now()); + assert_eq!(c.pads[3].gaps, 0); + } + + /// An interruption is not cadence: a backgrounded client's multi-second silence must be + /// counted as a stall rather than dragged through the percentiles, which would otherwise + /// report a healthy 250 Hz feed as a terrible one. + #[test] + fn a_stall_is_counted_separately_from_the_cadence() { + let t0 = Instant::now(); + let mut c = MotionCadence::new(); + for i in 0..50u64 { + c.record(0, at(t0, i * 4)); + } + c.record(0, at(t0, 5_000)); // the client came back after five seconds + for i in 0..50u64 { + c.record(0, at(t0, 5_000 + i * 4)); + } + assert_eq!(c.pads[0].stalls, 1); + assert_eq!( + c.pads[0].percentile_us_le(0.95), + Some(4096), + "the stall leaked in" + ); + assert!(c.pads[0].max_us < STALL_GAP.as_micros() as u32); + } + + /// Percentiles track the tail, which is the half that matters: a feed that is mostly 4 ms but + /// clumps every tenth sample is exactly the "floaty" report, and p50 alone would hide it. + #[test] + fn percentiles_separate_the_body_from_the_tail() { + let t0 = Instant::now(); + let mut c = MotionCadence::new(); + let mut t = 0u64; + for i in 0..100u64 { + t += if i % 10 == 9 { 40 } else { 4 }; + c.record(0, at(t0, t)); + } + // 90 gaps of 4 ms, 10 of 40 ms: the body is healthy and the tail is not. + assert_eq!(c.pads[0].percentile_us_le(0.5), Some(4096)); + assert_eq!(c.pads[0].percentile_us_le(0.95), Some(65536)); + assert!((39_000..=41_000).contains(&c.pads[0].max_us)); + } + + /// Bucket `k` holds gaps in `[2^(k-1), 2^k)` µs and reports `2^k`. + #[test] + fn bucket_edges() { + assert_eq!(bucket(0), 0); + assert_eq!(bucket(1), 1); // [1, 2) + assert_eq!(bucket(2), 2); // [2, 4) + assert_eq!(bucket(3), 2); + assert_eq!(bucket(4), 3); // [4, 8) + assert_eq!(bucket_upper_us(0), 0); + assert_eq!(bucket_upper_us(1), 2); + assert_eq!(bucket_upper_us(12), 4096); // 4 ms lands here + assert_eq!(bucket(u32::MAX), BUCKETS - 1); // saturates rather than wrapping + } +} diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index b24360af..15dae40e 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -337,6 +337,44 @@ const OFF_OUT_RING_VER: usize = core::mem::offset_of!(PadShm, out_ring_ver); const OFF_RING_HEAD: usize = core::mem::offset_of!(PadShm, ring_head); const OFF_OUT_RING_LEN: usize = core::mem::offset_of!(PadShm, out_ring_len); const OFF_OUT_RING: usize = core::mem::offset_of!(PadShm, out_ring); +const OFF_INPUT_GEN: usize = core::mem::offset_of!(PadShm, input_gen); + +/// How many timer ticks separate two runs of the channel/health housekeeping. The tick itself is +/// [`TIMER_PERIOD_MS`]; the pump, the `driver_proto` stamp and the heartbeat keep their historical +/// ~8 ms cadence so nothing that watches them changes rate — only the input path got faster. +const PUMP_EVERY_N_TICKS: u32 = 4; +/// Timer period. Was 8 ms, which — with one pended READ_REPORT completed per tick — capped what a +/// game could observe at ~125 Hz and added up to 8 ms of latency, while clients stream motion at +/// ~250 Hz. 2 ms is about a real DualShock 4's Bluetooth cadence and leaves headroom above the +/// client rate; the extra ticks only do the cheap half (read the input slot, complete one pended +/// read), see [`PUMP_EVERY_N_TICKS`]. +const TIMER_PERIOD_MS: u32 = 2; + +/// Read the host's input report out of the section under the v2.3 seqlock, so a report caught +/// mid-copy is retried instead of handed to a game. +/// +/// The host takes `input_gen` odd before writing the 64 bytes and even after, so an odd sample or +/// a changed one means the read straddled a write. One retry: the host publishes in microseconds +/// and this runs on a 2 ms timer, so a second collision is not a thing that happens, and if it did, +/// re-serving the previous whole report beats serving a torn one. +/// +/// Against a pre-v2.3 host the field is never written, so it reads 0 — constant and even — and +/// this accepts on the first pass, exactly as the driver behaved before the seqlock existed. +/// `false` means "no whole report available"; the caller keeps what it had. +fn read_input_report(view: &pf_umdf_util::section::MappedView, buf: &mut [u8; 64]) -> bool { + for _ in 0..2 { + let before = view.load_u32(OFF_INPUT_GEN, Ordering::Acquire); + if !before.is_multiple_of(2) { + continue; // a write is in flight right now + } + view.read_bytes(OFF_INPUT, buf); + // Acquire: the body reads above must not sink below this sample of the generation. + if view.load_u32(OFF_INPUT_GEN, Ordering::Acquire) == before { + return true; + } + } + false +} const OUT_SLOT_SIZE: usize = core::mem::size_of::(); const OUT_RING_LEN: u32 = pf_driver_proto::gamepad::OUT_RING_LEN; const OUT_RING_LEN_V22: u32 = pf_driver_proto::gamepad::OUT_RING_LEN_V22; @@ -423,6 +461,9 @@ static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0); /// The identity resolved from the devnode's PnP hardware ids at `EvtDeviceAdd` ([`devtype_from_hwids`]); /// `u32::MAX` = not resolved. See [`device_type`] for why this exists. static PNP_DEVTYPE: AtomicU32 = AtomicU32::new(u32::MAX); +/// Timer ticks since load — picks the [`PUMP_EVERY_N_TICKS`] ticks that also do the channel +/// handshake and health marks. Wrapping is fine: only its residue matters. +static TICK: AtomicU32 = AtomicU32::new(0); /// Map a devnode's hardware-id list (lowercase, `;`-separated — see /// [`wdf::query_hardware_ids`](pf_umdf_util::wdf::query_hardware_ids)) to the `device_type` the host @@ -659,7 +700,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI let mut tcfg: WDF_TIMER_CONFIG = unsafe { core::mem::zeroed() }; tcfg.Size = core::mem::size_of::() as ULONG; tcfg.EvtTimerFunc = Some(evt_timer); - tcfg.Period = 8; // ms + tcfg.Period = TIMER_PERIOD_MS; tcfg.AutomaticSerialization = 1; // TRUE — UMDF requires a serialized timer (vhidmini2 pattern) // SAFETY: a zeroed WDF_OBJECT_ATTRIBUTES is a valid all-null attributes struct; we set Size + the // fields we use below. @@ -679,8 +720,9 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI dbglog!("[pf-gamepad] WdfTimerCreate failed 0x{:08x}", st as u32); return st; } - // SAFETY: timer valid; -80000 == 8ms relative due time (100ns units, negative = relative). - let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, -80000i64) }; + let due = -(TIMER_PERIOD_MS as i64) * 10_000; + // SAFETY: timer valid; the due time is TIMER_PERIOD_MS in 100 ns units, negative = relative. + let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, due) }; log("[pf-gamepad] device ready (DualSense 054C:0CE6)"); STATUS_SUCCESS @@ -1044,27 +1086,43 @@ fn device_type() -> u8 { } extern "C" fn evt_timer(timer: WDFTIMER) { - // One sealed-channel tick: publish our pid / adopt a delivery / detect host-gone, then pull the - // latest host input report from the attached DATA section (all safe, via pf_umdf_util). - match CHANNEL.pump(&channel_cfg()) { + // Two cadences on one timer. EVERY tick ([`TIMER_PERIOD_MS`]) does the cheap input half — + // read the section's report slot, complete one pended READ_REPORT — because that pair is what + // bounds the rate a game can observe, and at the old 8 ms it halved a 250 Hz motion stream. + // The channel handshake and the health marks stay on their historical ~8 ms + // ([`PUMP_EVERY_N_TICKS`]): they cost more, nothing about them wants to be faster, and the + // heartbeat's documented "+1 per ~8 ms tick" is what the host reads as liveness. + let tick = TICK.fetch_add(1, Ordering::Relaxed); + let housekeeping = tick.is_multiple_of(PUMP_EVERY_N_TICKS); + let view = if housekeeping { + // Publish our pid / adopt a delivery / detect host-gone. + CHANNEL.pump(&channel_cfg()) + } else { + CHANNEL.data() + }; + match view { Some(view) => { - // Keep the fallback identity fresh: `device_type()`'s last resort (channel detached, - // no PnP match) reads LAST_DEVTYPE, and this tick is the one place that always sees - // the attached section. - LAST_DEVTYPE.store(view.read_u8(OFF_DEVICE_TYPE) as u32, Ordering::Relaxed); let mut buf = [0u8; 64]; - view.read_bytes(OFF_INPUT, &mut buf); - if buf[0] == 0x01 + // A torn read is dropped rather than served: `read_input_report` returns false only + // when it caught the host mid-publish, and the previous whole report stays in place. + if read_input_report(view, &mut buf) + && buf[0] == 0x01 && let Ok(mut g) = INPUT_REPORT.lock() { *g = buf; } - // Health marks the host watches: driver_proto (attach signal, idempotent) and - // driver_heartbeat (+1 per ~8 ms tick = liveness). Lets the host tell "driver bound - // and alive" apart from "driver package missing/failed to bind". - view.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION); - let hb = view.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1); - view.write_u32(OFF_DRIVER_HEARTBEAT, hb); + if housekeeping { + // Keep the fallback identity fresh: `device_type()`'s last resort (channel + // detached, no PnP match) reads LAST_DEVTYPE, and this tick is the one place that + // always sees the attached section. + LAST_DEVTYPE.store(view.read_u8(OFF_DEVICE_TYPE) as u32, Ordering::Relaxed); + // Health marks the host watches: driver_proto (attach signal, idempotent) and + // driver_heartbeat (+1 per ~8 ms = liveness). Lets the host tell "driver bound and + // alive" apart from "driver package missing/failed to bind". + view.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION); + let hb = view.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1); + view.write_u32(OFF_DRIVER_HEARTBEAT, hb); + } } None => { // Host gone (mailbox name vanished) or channel not attached yet: feed games the neutral From 77797a9e2056c2d2a74adbe9f5a4cde6e73dbecc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 13:52:27 +0200 Subject: [PATCH 03/22] feat(client/pads): stop streaming gyro into a session that cannot receive it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G8 of the gyro program, SDL-client half. The `Welcome` has always carried the backend the host actually RESOLVED, which is not necessarily the one the client asked for — Auto lands on Xbox 360 for anything not Sony/Valve/Xbox, and a Switch Pro on a Windows host folds to X360 too. No client read the field. So a player with an 8BitDo, or a Switch Pro on Windows, got a controller whose gyro did nothing, with nothing anywhere saying why: the client shipped ~250 Hz of Motion datagrams and the host parsed and discarded every one. `GamepadPref::has_motion()` answers whether a backend has a motion plane at all. The SDL client checks it on the first gyro sample: it logs one line naming the resolved backend and pointing at the fix (pick a DualSense-class controller type), then stops sending. Once per slot, not per sample — this path runs at the pad's sensor rate. `Auto` deliberately answers true. It means "unknown" — an old host that omitted the echo, which may well have resolved a DualSense — and suppressing motion on unknown would silently break working gyro, a worse failure than sending datagrams nobody reads. The predicate is an exhaustive match so a new backend has to state its answer rather than inherit one, and a table test pins both halves: a false negative kills working motion, a false positive keeps the void open, and both are silent. Owed: the plan wants this surfaced as a one-line UI hint, not just a log line. Apple already stores `resolvedGamepad` and Android needs the plumb; neither is done here, and both want their own gate. Gate (Linux CI image): fmt, build, clippy --all-targets -D warnings, and the test suites — green, with the new capability test observed running. --- crates/pf-client-core/src/gamepad.rs | 22 ++++++++++ crates/punktfunk-core/src/config.rs | 60 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index e8a1d826..133ada1d 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -864,6 +864,9 @@ struct Slot { /// 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, + /// The "your gyro can't reach this session" notice fired for this slot (log once, not per + /// sample — this path runs at the pad's sensor rate). + motion_unreachable_logged: bool, /// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's /// `guide_gesture` policy is on. gesture: SelectGesture, @@ -891,6 +894,7 @@ impl Slot { held_clicks: [false; 2], last_accel: [0; 3], sent_motion: false, + motion_unreachable_logged: false, gesture: SelectGesture::default(), audio_caps: 0, rumble_suppressed_logged: false, @@ -2008,6 +2012,24 @@ impl Worker { } } SensorType::Gyroscope => { + // The host echoes the backend it actually RESOLVED, which is not + // necessarily the one we asked for: an X-Box class pad has no motion plane, + // so every sample below would be decoded and dropped. Say so once — the + // player's gyro is silently doing nothing and the fix is the controller-type + // setting — and stop paying to send ~250 Hz of them. + if !c.resolved_gamepad.has_motion() { + if !slot.motion_unreachable_logged { + slot.motion_unreachable_logged = true; + tracing::warn!( + pad = slot.index, + resolved = ?c.resolved_gamepad, + "this controller has a gyro but the host session resolved a \ + backend without one — motion will not reach the game; pick a \ + DualSense-class controller type to get it" + ); + } + return; + } let mut gyro = [0i16; 3]; for (i, v) in data.iter().enumerate() { gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16; diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 8ccd2dff..55438b75 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -189,6 +189,36 @@ pub enum GamepadPref { } impl GamepadPref { + /// Whether this backend has a motion plane at all — i.e. whether a `RichInput::Motion` sample + /// sent to a host running it can reach the game, or is decoded and dropped. + /// + /// The X-Box classes have no gyro in their HID contract, so a client whose local pad HAS one + /// is streaming ~250 Hz of datagrams into a void: the host parses each and discards it, and + /// the player sees a controller whose gyro silently does nothing. Read this off + /// [`Welcome::gamepad`](crate::quic::Welcome::gamepad) — the backend the host actually + /// resolved, which is not necessarily the one the client asked for. + /// + /// `Auto` answers `true` on purpose. It means "unknown": either a host too old to echo the + /// field, or one that hasn't resolved yet. Suppressing motion on unknown would silently break + /// gyro against every old host that did resolve to a DualSense, which is a worse failure than + /// sending datagrams nobody reads. + /// + /// Exhaustive by design — a new backend has to state its answer here rather than inherit one. + pub const fn has_motion(self) -> bool { + match self { + GamepadPref::Auto => true, // unknown; assume it can, see above + GamepadPref::Xbox360 | GamepadPref::XboxOne => false, + GamepadPref::DualSense + | GamepadPref::DualShock4 + | GamepadPref::DualSenseEdge + | GamepadPref::SwitchPro + | GamepadPref::SteamController + | GamepadPref::SteamDeck + | GamepadPref::SteamController2 + | GamepadPref::SteamController2Puck => true, + } + } + /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`, /// `9 = SteamController2`, `10 = SteamController2Puck`. @@ -754,6 +784,36 @@ mod tests { assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto); } + /// Which backends a client may stream motion to. Pinned as a table because the answer decides + /// whether a player's gyro works at all, and getting it wrong in either direction is silent: + /// a false negative kills working motion, a false positive keeps ~250 Hz of datagrams flowing + /// into a host that drops every one. + #[test] + fn only_the_xbox_classes_lack_a_motion_plane() { + for p in [GamepadPref::Xbox360, GamepadPref::XboxOne] { + assert!( + !p.has_motion(), + "{} should have no motion plane", + p.as_str() + ); + } + for p in [ + GamepadPref::DualSense, + GamepadPref::DualShock4, + GamepadPref::DualSenseEdge, + GamepadPref::SwitchPro, + GamepadPref::SteamController, + GamepadPref::SteamDeck, + GamepadPref::SteamController2, + GamepadPref::SteamController2Puck, + ] { + assert!(p.has_motion(), "{} should carry motion", p.as_str()); + } + // Unknown must not suppress: an old host that omitted the echo may well have resolved a + // DualSense, and silently killing its gyro is worse than sending into a void. + assert!(GamepadPref::Auto.has_motion()); + } + #[test] fn gamepad_pref_wire_and_names() { for p in [ From cbfa03b7adee8f8f4463629fe9571d3bee8d04e5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 14:00:30 +0200 Subject: [PATCH 04/22] =?UTF-8?q?docs(host/pads):=20the=20SC2's=20bInterva?= =?UTF-8?q?l=20is=20already=201=20kHz=20=E2=80=94=20don't=20"fix"=20it=20t?= =?UTF-8?q?o=20250=20Hz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working G14/G18 turned up two sweep findings that do not survive contact with the code. Neither is implemented; one is now guarded. The 2026-08-07 sweep read the Triton (Steam Controller 2) usbip endpoint's `bInterval: 1` as 125 µs — an 8 kHz duplicate storm — and the plan's G14 says to raise it to 4 "like the Deck". That reading assumes a high-speed device, where bInterval is the 2^(n-1) × 125 µs exponent. Both Triton devices declare `UsbSpeed::Full`, and on a full-speed device the field is a plain frame count in milliseconds: 1 means 1 ms, which is the 1 kHz the existing comment claims. Raising it to 4 would mean 4 ms — a 4× cut to the motion rate a passed-through SC2 delivers, in the name of fixing a problem it doesn't have. The endpoint now carries the reasoning so the next reader doesn't repeat it. G18's first bullet ("bound/rate-cap the host's rich-input channel; motion is unbounded") is stale rather than wrong — it was true of the tree the sweep read. Current main already routes rich input, motion included, through a 1024-deep `sync_channel` whose `offer()` helper `try_send`s and drops on full, ending the loop only on Disconnected. That is the same bounded-queue pattern the mic plane adopted for security-review S6. Nothing owed. G14's remaining bullet — DS/Deck neutral accel should read 1 g on the up axis instead of 0 g free-fall — is deliberately NOT done here. Which axis is up is precisely what G16's on-glass session measures: `switch_proto` documents the wire as z-up and its neutral ships +Z, but the Deck's kernel negates Z/RZ, so guessing would leave one backend confidently disagreeing with another. A wrong constant is worse than the current obviously-unset 0. Gate: fmt, build, clippy --all-targets -D warnings, and the test suites — green. --- crates/pf-inject/src/inject/linux/triton_usbip.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/pf-inject/src/inject/linux/triton_usbip.rs b/crates/pf-inject/src/inject/linux/triton_usbip.rs index 382f6644..0259d0fb 100644 --- a/crates/pf-inject/src/inject/linux/triton_usbip.rs +++ b/crates/pf-inject/src/inject/linux/triton_usbip.rs @@ -472,7 +472,13 @@ fn build_triton_device( address: addr, attributes: 0x03, // interrupt max_packet_size: 64, // wMaxPacketSize 0x0040 - interval: 1, // bInterval 1 — the real pad's 1 kHz + // bInterval 1 — the real pad's 1 kHz. ⚠ Do NOT "fix" this to 4: bInterval is only the + // 2^(n-1) × 125 µs exponent on a HIGH-speed device, and this one negotiates FULL speed + // (`dev.speed` below), where the field is a plain frame count in milliseconds. So 1 means + // 1 ms = 1 kHz, exactly as intended, and 4 would mean 4 ms = 250 Hz — a 4× cut to the + // motion rate a passed-through SC2 delivers. (A 2026-08-07 sweep read this as high-speed + // and called it an 8 kHz duplicate storm; it is neither.) + interval: 1, }; let mut dev = UsbDevice::new(0); dev.vendor_id = TRITON_VENDOR; From 9e9bb9f4666b27874c7a56af738679574facce7b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 14:56:27 +0200 Subject: [PATCH 05/22] =?UTF-8?q?fix(client/apple):=20acceleration=20was?= =?UTF-8?q?=20upside=20down=20=E2=80=94=20measured=20on=20glass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G16, first result. A DualSense paired to an iPhone, streaming to a Linux host, lying flat and face up: hid-playstation decoded z = −0.99 g where a DualSense owes +1.00. Vector magnitude was 1.006 g, so the scale was already correct — this is purely direction, and it was wrong for every accelerometer sample the Apple client has ever sent. The cause is a convention mismatch, not a sign typo. Apple reports acceleration as the gravity VECTOR, which points down: a device face-up on a table reads z = −1. An accelerometer physically measures proper acceleration, and at rest that is the +1 g normal force pushing UP — which is what a DualSense's report, and therefore our wire, carries. The two are exact negatives. Both branches were affected, because `m.acceleration` follows the same Apple convention as the gravity/userAcceleration split, so reading the "raw vector" was not an escape from it. `rotationRate` is a true angular rate and needs no flip. The same session confirmed that independently: rotating the pad clockwise seen from above produced a negative yaw, which is correct under the right-hand rule about an up-pointing Z. That asymmetry — accel wrong, gyro right — is itself evidence for this diagnosis rather than a blanket frame error, and it is why the fix is three negations at one site instead of a remap. The sweep predicted this ("Apple accel plausibly INVERTED — CoreMotion gravity -1 g vs DS +1 g up at rest") but could not confirm it without hardware. It is now measured, and the mechanism is confirmed in the code rather than inferred from the number. Method, for whoever repeats it: the readout is python-evdev on the host reading the virtual pad's own motion node, dividing by the axis `resolution` the kernel publishes, so it prints deg/s and g. That is downstream of the calibration blob — the same layer a game reads — which is what makes a sign error visible to a human at all. Two things this does NOT establish. The host was a KVM guest, so the DualSense could not be attached natively for a side-by-side reference reading; the test stands on the DualSense convention being a fixed property of the hardware, which is decisive for the at-rest sign but weaker for the gyro axis ORDER. And the fix itself is unverified on glass: confirming it needs a rebuilt client on the device, so someone should re-run the same at-rest reading and see +1.00. Gate: `swiftc -parse` clean. A full typecheck needs the gitignored PunktfunkCore.xcframework assembled first and has not been run. --- .../PunktfunkKit/Gamepad/GamepadCapture.swift | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 4ea7812b..01cd7a47 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -564,18 +564,32 @@ public final class GamepadCapture { let now = DispatchTime.now().uptimeNanoseconds guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return } slot.lastMotionNs = now - // Total acceleration in g: gravity + user when split, else the raw vector. + // Total acceleration in g: gravity + user when split, else the raw vector — then NEGATED + // into the wire's convention. + // + // Apple reports acceleration as the gravity VECTOR: a device lying flat face-up reads + // z = −1, because gravity points down. An accelerometer physically measures proper + // acceleration, which at rest is the +1 g normal force pushing UP, and that is what a + // DualSense's report — the wire's convention — carries. The two are exact negatives, so + // every sample we sent was upside down, on both branches (`m.acceleration` follows the + // same Apple convention as the gravity/user split). + // + // Measured on glass 2026-08-07 (G16): a DualSense flat and face-up, streamed from an + // iPhone to a Linux host, arrived at hid-playstation as z = −0.99 g where +1.00 was owed. + // Magnitude was 1.006 g, so the SCALE was already right — this is purely direction. + // `rotationRate` is a true angular rate and needs no flip; the same session confirmed yaw + // came through with the correct sign. let ax: Float let ay: Float let az: Float if m.hasGravityAndUserAcceleration { - ax = Float(m.gravity.x + m.userAcceleration.x) - ay = Float(m.gravity.y + m.userAcceleration.y) - az = Float(m.gravity.z + m.userAcceleration.z) + ax = -Float(m.gravity.x + m.userAcceleration.x) + ay = -Float(m.gravity.y + m.userAcceleration.y) + az = -Float(m.gravity.z + m.userAcceleration.z) } else { - ax = Float(m.acceleration.x) - ay = Float(m.acceleration.y) - az = Float(m.acceleration.z) + ax = -Float(m.acceleration.x) + ay = -Float(m.acceleration.y) + az = -Float(m.acceleration.z) } let gs = GamepadWire.gyroLSBPerRadS let as_ = GamepadWire.accelLSBPerG From 0e40b374e7ff0e1b2a233430205c311f0e2b03ea Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:23:10 +0200 Subject: [PATCH 06/22] fix(client/android): DualSense acceleration arrived ~18% short MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G16 leg 2. A DualSense over USB to an Android phone, streaming to a Linux host, flat and face up: |accel| = 0.811 g where 1.000 is owed. Magnitude is frame-invariant, so this is unambiguous regardless of the separate axis question below, and it came from a 27-second static average — no sampling error in it. `DsDevice` said so plainly: "Gyro/accel stay in raw device units". It read the i16s out of the pad's report and forwarded them verbatim. But raw device units are not wire units — the wire is fixed at 10000 LSB/g and the pads' native resolution is the 8192 that hid-playstation calls DS_ACC_RES_PER_G. 8192/10000 = 0.819 predicted against 0.811 measured. Acceleration is now rescaled on both the DualSense and DualShock 4 parse paths, clamped because the multiplier is >1 and a real near-full-scale slam would otherwise wrap the i16 into an impossible acceleration in the opposite direction. Two things deliberately NOT done. Gyro is left alone. It is almost certainly low by the same mechanism, but it cannot be corrected with a nominal constant the way acceleration can: the still average shows this pad's accel calibration is near-identity (~1% off), while the gyro's emphatically is not — a near-identity gyro calibration would imply 1024 LSB per deg/s, i.e. ±32 deg/s full scale, which no controller has. Fixing gyro means reading the pad's calibration feature report and applying its own numbers, which also removes acceleration's residual 1% bias. `HidUsbLink` can SET_REPORT but has no GET_REPORT path yet, so that is a real change rather than a constant, and it is owed. I tried to pin the gyro factor by integrating the on-glass rotations instead: a nominal 90 deg yaw integrated to ~88.5 deg through the Apple client (correct) and ~62.7 deg through Android. Directionally consistent, but the readout samples at 5 Hz and a ~1 s rotation is badly undersampled, so that ratio is not a constant anyone should ship. Recorded, not used. The axis frame is also left alone. This leg puts gravity on Y where the Apple leg put it on Z, so at least one client's frame is wrong — but Android forwards the pad's own axis order un-remapped, which makes its reading evidence about the hardware rather than about us, and resolving it needs the bare-metal reference reading G16 step 1 calls for. Every bare-metal Linux box was unreachable (Deck down, HTPC down, .25 is another KVM guest). Rescaling does not touch axis order, so this fix stands however that resolves. Gate: `:kit:compileDebugKotlin` and `:kit:testDebugUnitTest` green, JNI libs built clean at the API-28 floor across 3 ABIs. On-glass re-verification owed: re-run the at-rest reading and expect 0.99-1.00 g. --- .../kotlin/io/unom/punktfunk/kit/DsDevice.kt | 31 +++++++++++++++++-- include/punktfunk_core.h | 19 ++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt index af2f8a31..4aedf25c 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt @@ -17,6 +17,11 @@ package io.unom.punktfunk.kit * reaches this code — an uncaptured pad stays on the ordinary InputDevice path. */ object DsDevice { + /** The pads' native acceleration resolution — `hid-playstation`'s `DS_ACC_RES_PER_G`. */ + private const val DS_RAW_ACCEL_LSB_PER_G = 8192L + /** The wire's, from `punktfunk_core::input::gamepad::MOTION_ACCEL_LSB_PER_G`. */ + private const val WIRE_ACCEL_LSB_PER_G = 10000L + const val VID_SONY = 0x054C const val PID_DUALSENSE = 0x0CE6 const val PID_DUALSENSE_EDGE = 0x0DF2 @@ -153,7 +158,7 @@ object DsDevice { out.buttons = w if (len >= 28) { for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i) - for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i) + for (i in 0 until 3) out.accel[i] = accelToWire(i16(r, 22 + 2 * i)) } if (len >= 41) { unpackTouch(r, 33, out, 0) @@ -189,7 +194,7 @@ object DsDevice { out.buttons = w if (len >= 25) { for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i) - for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i) + for (i in 0 until 3) out.accel[i] = accelToWire(i16(r, 19 + 2 * i)) } if (len >= 43) { unpackTouch(r, 35, out, 0) @@ -227,6 +232,28 @@ object DsDevice { private fun i16(r: ByteArray, o: Int): Int = ((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt() + /** + * Raw DualSense/DualShock 4 acceleration → the wire's units. + * + * The pad reports acceleration in its own device units; the wire is fixed at + * `MOTION_ACCEL_LSB_PER_G` = 10000 LSB per g (`punktfunk_core::input::gamepad`). Forwarding the + * raw value verbatim — which this path did until 2026-08-07 — hands the host a number ~18 % + * short, because the pad's native resolution is the 8192 LSB/g that `hid-playstation` calls + * `DS_ACC_RES_PER_G`. Measured on glass: a DualSense flat and face up arrived as 0.811 g where + * 1.000 was owed, against 8192/10000 = 0.819 predicted. + * + * The residual ~1 % is this unit's factory bias, which only its calibration feature report can + * remove — that read is still owed (it also fixes gyro, whose factory calibration is emphatically + * NOT near-identity and so cannot be corrected by a nominal constant like this one). + * + * Clamped because the rescale is a >1 multiplier: a real ±4 g slam near full scale would + * otherwise wrap the i16 and read as an impossible acceleration in the opposite direction. + */ + private fun accelToWire(raw: Int): Int = + ((raw.toLong() * WIRE_ACCEL_LSB_PER_G) / DS_RAW_ACCEL_LSB_PER_G) + .coerceIn(-32768L, 32767L) + .toInt() + // Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of // the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`). private fun stickX(raw: Int): Int = raw * 257 - 32768 diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 1e6d31c0..8c25b9b9 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -497,6 +497,25 @@ #define PUNKTFUNK_AXIS_RT 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. +#define MOTION_GYRO_LSB_PER_DEG_S 20 + +// See [`MOTION_GYRO_LSB_PER_DEG_S`]. +#define MOTION_ACCEL_LSB_PER_G 10000 + // Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]). #define PUNKTFUNK_MAGIC 201 From f6de620f3469e7a7317bb0d4fd9bfc980639fd94 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:47:33 +0200 Subject: [PATCH 07/22] fix(client/android): a captured Sony pad's gyro turned the wrong amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G14/G16 leg 3. This supersedes the nominal constant 0e40b374 shipped, which was always labelled a stopgap. Measured on glass 2026-08-07: a DualSense over USB into an Android phone, streaming to a Linux host, flat and face up, arrived as |accel| = 0.811 g where 1.000 was owed. The parse forwarded the pad's raw i16s verbatim, and raw device units are not wire units. 0e40b374 rescaled acceleration by the nominal 10000/8192 and deliberately left gyro alone, because a constant provably cannot fix gyro: the same still average showed this unit's accel calibration is near-identity (~1% off) while its gyro's emphatically is not — a near-identity gyro calibration would imply 1024 LSB per deg/s, i.e. ±32 deg/s full scale, which no controller has. That scale is per unit, and the only thing that knows it is the pad. So the client now asks. HidUsbLink grows a GET_REPORT path — EP0, the exact mirror of the SET_REPORT it already had — and DsCapture reads the pad's IMU calibration feature report ONCE, while claiming it: 0x05 / 41 B on a DualSense or Edge, 0x02 / 37 B on a USB DualShock 4. DsDevice.MotionCal then applies hid-playstation's own arithmetic per axis, which is the same math the host's contract test (crates/pf-inject/tests/motion_contract.rs, SonyImuCalibration) reads from the other end: gyro raw × speed_2x × 20 / (|plus−bias| + |minus−bias|), accel (raw − (plus − range/2)) × 20000 / range. Long arithmetic, because the gyro multiplier overflows an Int, and clamped, because both are >1 multipliers and a full-scale flick would otherwise wrap the i16 into a motion in the opposite direction. Reading the blob also removes acceleration's residual ~1% factory bias that the nominal constant left behind. Once at claim and never per report. EP0 is independent of the interrupt endpoints so the read is safe alongside the reader thread, but a blocking control transfer in the report path would wreck capture latency, and the calibration is fixed for the life of the connection anyway. The capture logs the derived resolutions, which is the discriminator for whether a blob was read at all: a real pad declares ≈16 LSB per deg/s, the fallback reads back as exactly 20. A pad that refuses, answers short, or declares zeroes (a clone, a broken unit) keeps today's behaviour per axis — nominal accel, gyro straight through. Nothing here ever zeroes motion: slightly mis-scaled beats silent. Not covered. The axis frame is still untouched: this leg puts gravity on Y where the Apple leg put it on Z, so at least one client's frame is wrong, and settling it needs the bare-metal Linux reference reading G16 step 1 calls for. Rescaling is frame-independent, so it stands however that resolves — remapping is not, so it stays out. Bluetooth's grouped plus/minus layout is not implemented either: this path is USB-only by construction (Android exposes no raw path to a Classic pad), and a half-used generalisation would be a latent bug rather than a feature. Gate: `:kit:compileDebugKotlin` + `:kit:testDebugUnitTest` green, 16 DsDeviceTest cases run 0 failed, and the five new ones were confirmed present in the JUnit XML rather than merely compiled. Non-vacuity checked by mutation — perturbing the gyro conversion fails 6 tests, including all four new ones that assert a number. On-glass re-verification owed, on the rig that measured the defect (DualSense → USB → phone → 192.168.1.21): at rest |a| = 1.00 g exactly via ~/gyroscope.py, and a nominal 90 deg yaw integrating to ~90 deg via ~/integrate.py — the same 90 deg that read ~62.7 deg before this change. --- .../kotlin/io/unom/punktfunk/kit/DsCapture.kt | 49 +++- .../kotlin/io/unom/punktfunk/kit/DsDevice.kt | 223 ++++++++++++++---- .../io/unom/punktfunk/kit/HidUsbLink.kt | 47 +++- .../io/unom/punktfunk/kit/DsDeviceTest.kt | 179 ++++++++++++++ 4 files changed, 440 insertions(+), 58 deletions(-) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index a1c83fa7..5b54ca32 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -22,10 +22,10 @@ import android.view.InputDevice * * Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons * diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch - * normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw - * device units, the wire's contract). The wire slot is claimed when the capture engages, with the - * first parsed report as the fallback for a claim that found no free index, and freed on - * unplug/[stop], so indices never leak. + * normalized to the wire's 0..65535 screen space on-change; motion forwarded per report, rescaled + * into the wire's units by this pad's own calibration, read once at claim). The wire slot is + * claimed when the capture engages, with the first parsed report as the fallback for a claim that + * found no free index, and freed on unplug/[stop], so indices never leak. * * Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player * LED events addressed to this pad's wire index become USB output reports on the physical pad @@ -55,6 +55,10 @@ class DsCapture( @Volatile private var model: DsDevice.Model? = null @Volatile private var pad: GamepadRouter.ExternalPad? = null + /** This pad's factory motion scale, read once per capture (see [readMotionCal]). Written on + * the claiming thread before [model], which is what the link thread's parse reads it under. */ + @Volatile private var motionCal = DsDevice.MotionCal.NOMINAL + // Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only. private val state = DsDevice.State() private var wireButtons = 0 @@ -124,6 +128,9 @@ class DsCapture( if (model != null) return false val m = DsDevice.modelFor(dev.productId) ?: return false if (!usb.start(dev)) return false + // Before `model`, which is what gates the link thread's parse: a report must never be + // scaled by the previous pad's calibration, or by the fallback once the real one is known. + motionCal = readMotionCal(m) model = m for (id in InputDevice.getDeviceIds()) { val d = InputDevice.getDevice(id) ?: continue @@ -138,6 +145,34 @@ class DsCapture( return true } + /** + * Read this pad's IMU calibration, ONCE, while claiming it — the feature report that says how + * many raw counts this individual unit puts on a °/s and on a g ([DsDevice.MotionCal]). + * + * At claim time and nowhere else: the read is a blocking EP0 control transfer (bounded by the + * link's write timeout, answered in about a millisecond by a pad that is there), and the + * calibration is fixed for the life of the connection, so doing it per input report would buy + * nothing and cost the capture its latency. A pad that refuses keeps the nominal scaling + * rather than losing motion altogether. + */ + private fun readMotionCal(m: DsDevice.Model): DsDevice.MotionCal { + val blob = usb.getReport(HidUsbLink.REPORT_TYPE_FEATURE, m.calReportId, m.calReportLen) + val cal = DsDevice.MotionCal.parse(blob, m.calReportId) + // Worth a line either way: this is the number the owed on-glass check reads back — a pad + // whose blob was read declares its own resolution, the fallback declares the wire's. + if (cal === DsDevice.MotionCal.NOMINAL) { + Log.w( + TAG, + "motion calibration 0x%02x unreadable (%d/%d B) — nominal scaling (%s)".format( + m.calReportId, blob?.size ?: 0, m.calReportLen, cal, + ), + ) + } else { + Log.i(TAG, "motion calibration 0x%02x: %s".format(m.calReportId, cal)) + } + return cal + } + /** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */ fun stop() { // Before anything touches the link: the pad-audio renderer borrows this connection's @@ -168,7 +203,7 @@ class DsCapture( private fun onReport(report: ByteArray, len: Int) { val m = model ?: return - if (!DsDevice.parseState(m, report, len, state)) return + if (!DsDevice.parseState(m, report, len, state, motionCal)) return // Normally claimed already, at capture time; this is the retry for a capture that engaged // while every wire index was taken. val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees @@ -310,8 +345,8 @@ class DsCapture( /** * The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded - * on change per slot; motion forwarded every report (raw device units — the wire is a unit - * passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless). + * on change per slot; motion forwarded every report (already in wire units — the parse applies + * this pad's calibration, and sensor noise makes per-report dedup pointless). */ private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) { for (f in 0 until 2) { diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt index 4aedf25c..6274636d 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt @@ -1,5 +1,7 @@ package io.unom.punktfunk.kit +import kotlin.math.abs + /** * Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report * parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's @@ -17,11 +19,6 @@ package io.unom.punktfunk.kit * reaches this code — an uncaptured pad stays on the ordinary InputDevice path. */ object DsDevice { - /** The pads' native acceleration resolution — `hid-playstation`'s `DS_ACC_RES_PER_G`. */ - private const val DS_RAW_ACCEL_LSB_PER_G = 8192L - /** The wire's, from `punktfunk_core::input::gamepad::MOTION_ACCEL_LSB_PER_G`. */ - private const val WIRE_ACCEL_LSB_PER_G = 10000L - const val VID_SONY = 0x054C const val PID_DUALSENSE = 0x0CE6 const val PID_DUALSENSE_EDGE = 0x0DF2 @@ -33,14 +30,159 @@ object DsDevice { /** * One captured model: its `GamepadPref` wire byte (the virtual pad the host builds — matching * the physical one), its output-report size (the descriptor-declared size the firmware - * expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent + * expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), its touchpad extent * (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches - * onto the wire's 0..65535 space. + * onto the wire's 0..65535 space, and the IMU-calibration feature report it answers + * ([MotionCal]): DS5/Edge `0x05` (id + 40 B), DS4 over USB `0x02` (id + 36 B). */ - enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) { - DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080), - DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080), - DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942), + enum class Model( + val pref: Int, + val outputSize: Int, + val touchW: Int, + val touchH: Int, + val calReportId: Int, + val calReportLen: Int, + ) { + DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080, 0x05, 41), + DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080, 0x05, 41), + DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942, 0x02, 37), + } + + /** + * One pad's own IMU calibration: the factory scale factors that turn its raw motion counts + * into the wire's fixed units (`punktfunk_core::input::gamepad` — 20 LSB per °/s, 10000 LSB + * per g), read out of the calibration feature report the pad serves on EP0. + * + * **Why the pad's blob and not a constant.** Measured on glass 2026-08-07: a DualSense flat + * and face up arrived as 0.811 g where 1.000 was owed, because this path forwarded the raw + * i16s verbatim. The nominal ×10000/8192 rescale that first closed that gap ([NOMINAL]) still + * leaves that unit's factory bias — about 1 % — on acceleration, and provably cannot fix gyro + * at all: the same still-average showed this pad's gyro calibration is nowhere near identity, + * and a near-identity one would mean 1024 LSB per °/s, i.e. ±32 °/s full scale, which no + * controller has. The scale is per unit; only the pad knows it. + * + * The arithmetic is `hid-playstation`'s, and the host's contract test + * (`crates/pf-inject/tests/motion_contract.rs`, `SonyImuCalibration`) is the same math read + * from the other end — it applies it to the blobs our *virtual* pads declare and asserts they + * land on the wire constants. Per axis: gyro `raw × speed_2x × 20 / (|plus − bias| + + * |minus − bias|)`, accel `(raw − (plus − range/2)) × 20000 / range`, where `range = plus − + * minus` spans 2 g. + */ + class MotionCal private constructor( + /** Per axis: `speed_2x × 20`, over `|plus − bias| + |minus − bias|`. */ + private val gyroNumer: LongArray, + private val gyroDenom: LongArray, + /** Per axis: the raw count the pad reads at 0 g, and the raw span of 2 g. */ + private val accelBias: LongArray, + private val accelRange: LongArray, + ) { + /** Raw gyro count on [axis] (0 = pitch, 1 = yaw, 2 = roll) → the wire's 20 LSB per °/s. */ + fun gyroToWire(axis: Int, raw: Int): Int = + clampWire(raw.toLong() * gyroNumer[axis] / gyroDenom[axis]) + + /** Raw acceleration count on [axis] → the wire's 10000 LSB per g, zero point removed. */ + fun accelToWire(axis: Int, raw: Int): Int = + clampWire((raw - accelBias[axis]) * ACCEL_NUMER / accelRange[axis]) + + /** + * The derived resolutions, for the capture's one-line claim log — the number that says + * whether a pad's blob was actually read (a real DualSense declares ≈16 LSB/°·s and ≈8192 + * LSB/g; the [NOMINAL] fallback reads back as exactly 20 and 8192). + */ + override fun toString(): String = buildString { + append("gyro ") + for (i in 0 until 3) { + if (i > 0) append('/') + append(gyroDenom[i] * WIRE_GYRO_LSB_PER_DEG_S / gyroNumer[i]) + } + append(" LSB/°·s, accel ") + for (i in 0 until 3) { + if (i > 0) append('/') + append(accelRange[i] / 2) + } + append(" LSB/g at ") + append(accelBias.joinToString("/")) + } + + /** + * Both conversions are a >1 multiplier on every pad measured so far, so a real ±4 g slam + * or a fast flick near full scale would otherwise wrap the i16 and read as an impossible + * motion in the opposite direction. + */ + private fun clampWire(v: Long): Int = v.coerceIn(-32768L, 32767L).toInt() + + companion object { + /** The pads' nominal acceleration resolution — `hid-playstation`'s `DS_ACC_RES_PER_G`. */ + private const val RAW_ACCEL_LSB_PER_G = 8192L + /** `punktfunk_core::input::gamepad::MOTION_GYRO_LSB_PER_DEG_S`. */ + private const val WIRE_GYRO_LSB_PER_DEG_S = 20L + /** `MOTION_ACCEL_LSB_PER_G`, doubled — the declared accel range spans 2 g, not 1. */ + private const val ACCEL_NUMER = 2 * 10000L + /** Bytes the layout below reads; the reports themselves are longer (41 / 37). */ + private const val MIN_LEN = 35 + + /** + * What an unreadable pad gets: gyro straight through and accel on the nominal 8192 + * LSB/g. Wrong by that unit's factory bias, and for gyro wrong by however far its + * scale sits from the wire's 20 — but a pad whose calibration cannot be read is far + * better off slightly mis-scaled than silent, so this never zeroes motion. + */ + val NOMINAL = MotionCal( + LongArray(3) { 1 }, + LongArray(3) { 1 }, + LongArray(3), + LongArray(3) { 2 * RAW_ACCEL_LSB_PER_G }, + ) + + /** + * Parse a calibration feature report ([Model.calReportId]) — all little-endian i16: + * `[0]` report id, `[1..7)` gyro bias (pitch, yaw, roll), `[7..19)` gyro plus/minus + * INTERLEAVED (pitch+, pitch−, yaw+, yaw−, roll+, roll−), `[19..23)` the two speed + * words, `[23..35)` accel plus/minus (x+, x−, y+, y−, z+, z−). + * + * ⚠ Interleaved is the **USB** order. A Bluetooth DualShock 4 groups the three plusses + * before the three minuses and consumers switch layout on the transport — this path is + * USB-only by construction (see the file header), so do not "generalise" it. + * + * Falls back to [NOMINAL] for a failed read (null), a truncated or foreign reply, and + * per axis for a degenerate declaration — a clone or broken pad that declares zeroes + * would otherwise divide by zero (`hid-playstation` guards the same case, for the same + * reason). + */ + fun parse(blob: ByteArray?, reportId: Int): MotionCal { + if (blob == null || blob.size < MIN_LEN) return NOMINAL + if ((blob[0].toInt() and 0xFF) != reportId) return NOMINAL + val w = { o: Int -> + ((blob[o + 1].toInt() shl 8) or (blob[o].toInt() and 0xFF)).toShort().toLong() + } + val speed2x = w(19) + w(21) + val gyroNumer = LongArray(3) + val gyroDenom = LongArray(3) + val accelBias = LongArray(3) + val accelRange = LongArray(3) + for (i in 0 until 3) { + val bias = w(1 + 2 * i) + val denom = abs(w(7 + 4 * i) - bias) + abs(w(9 + 4 * i) - bias) + if (speed2x > 0 && denom > 0) { + gyroNumer[i] = speed2x * WIRE_GYRO_LSB_PER_DEG_S + gyroDenom[i] = denom + } else { + gyroNumer[i] = 1 // passthrough, as before any calibration existed + gyroDenom[i] = 1 + } + val plus = w(23 + 4 * i) + val range = plus - w(25 + 4 * i) + if (range > 0) { + accelBias[i] = plus - range / 2 + accelRange[i] = range + } else { + accelBias[i] = 0 // nominal, as NOMINAL above + accelRange[i] = 2 * RAW_ACCEL_LSB_PER_G + } + } + return MotionCal(gyroNumer, gyroDenom, accelBias, accelRange) + } + } } /** The captured [Model] for a USB PID, or null for anything we don't capture. */ @@ -55,8 +197,9 @@ object DsDevice { * The client-consumed fields of one input report. `buttons` is already the WIRE bitmask * (`Gamepad.BTN_*`) — the parse maps device bits straight to the wire, the exact inverse of * the host's `DsState::from_gamepad` (BTN_A ↔ cross, BTN_B ↔ circle, BTN_X ↔ square, - * BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel stay in raw device units — the - * wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay + * BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel arrive in WIRE units — the wire's + * `Motion` is a unit passthrough into the virtual pad's report, so the pad's raw counts are + * rescaled during the parse by the [MotionCal] handed to [parseState]. Touch coordinates stay * device-raw here; [DsCapture] normalizes against the model's extent when forwarding. */ class State { @@ -64,8 +207,8 @@ object DsDevice { var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse) var rsX = 0; var rsY = 0 var lt = 0; var rt = 0 // 0..255 - val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll) - val accel = IntArray(3) + val gyro = IntArray(3) // wire i16: 20 LSB per °/s (pitch/yaw/roll) + val accel = IntArray(3) // wire i16: 10000 LSB per g val touchActive = BooleanArray(2) val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1) val touchY = IntArray(2) @@ -113,15 +256,25 @@ object DsDevice { * short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 — those never hit * the interrupt endpoint, but be defensive). Motion/touch fields update only when the report * is long enough to carry them (it always is on glass — 64-byte interrupt transfers). + * + * [cal] is this pad's own motion calibration, read once when the capture claims it; the + * default is the nominal fallback, which is all a caller without a live pad (the tests) can + * have. */ - fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean = + fun parseState( + model: Model, + report: ByteArray, + len: Int, + out: State, + cal: MotionCal = MotionCal.NOMINAL, + ): Boolean = if (model == Model.DUALSHOCK4) { - parseDs4(report, len, out) + parseDs4(report, len, out, cal) } else { - parseDs5(model, report, len, out) + parseDs5(model, report, len, out, cal) } - private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean { + private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean { if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false out.lsX = stickX(u8(r, 1)) out.lsY = stickY(u8(r, 2)) @@ -157,8 +310,8 @@ object DsDevice { } out.buttons = w if (len >= 28) { - for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i) - for (i in 0 until 3) out.accel[i] = accelToWire(i16(r, 22 + 2 * i)) + for (i in 0 until 3) out.gyro[i] = cal.gyroToWire(i, i16(r, 16 + 2 * i)) + for (i in 0 until 3) out.accel[i] = cal.accelToWire(i, i16(r, 22 + 2 * i)) } if (len >= 41) { unpackTouch(r, 33, out, 0) @@ -167,7 +320,7 @@ object DsDevice { return true } - private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean { + private fun parseDs4(r: ByteArray, len: Int, out: State, cal: MotionCal): Boolean { if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01 out.lsX = stickX(u8(r, 1)) out.lsY = stickY(u8(r, 2)) @@ -193,8 +346,8 @@ object DsDevice { if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD out.buttons = w if (len >= 25) { - for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i) - for (i in 0 until 3) out.accel[i] = accelToWire(i16(r, 19 + 2 * i)) + for (i in 0 until 3) out.gyro[i] = cal.gyroToWire(i, i16(r, 13 + 2 * i)) + for (i in 0 until 3) out.accel[i] = cal.accelToWire(i, i16(r, 19 + 2 * i)) } if (len >= 43) { unpackTouch(r, 35, out, 0) @@ -232,28 +385,6 @@ object DsDevice { private fun i16(r: ByteArray, o: Int): Int = ((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt() - /** - * Raw DualSense/DualShock 4 acceleration → the wire's units. - * - * The pad reports acceleration in its own device units; the wire is fixed at - * `MOTION_ACCEL_LSB_PER_G` = 10000 LSB per g (`punktfunk_core::input::gamepad`). Forwarding the - * raw value verbatim — which this path did until 2026-08-07 — hands the host a number ~18 % - * short, because the pad's native resolution is the 8192 LSB/g that `hid-playstation` calls - * `DS_ACC_RES_PER_G`. Measured on glass: a DualSense flat and face up arrived as 0.811 g where - * 1.000 was owed, against 8192/10000 = 0.819 predicted. - * - * The residual ~1 % is this unit's factory bias, which only its calibration feature report can - * remove — that read is still owed (it also fixes gyro, whose factory calibration is emphatically - * NOT near-identity and so cannot be corrected by a nominal constant like this one). - * - * Clamped because the rescale is a >1 multiplier: a real ±4 g slam near full scale would - * otherwise wrap the i16 and read as an impossible acceleration in the opposite direction. - */ - private fun accelToWire(raw: Int): Int = - ((raw.toLong() * WIRE_ACCEL_LSB_PER_G) / DS_RAW_ACCEL_LSB_PER_G) - .coerceIn(-32768L, 32767L) - .toInt() - // Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of // the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`). private fun stickX(raw: Int): Int = raw * 257 - 32768 diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt index 254c1bb8..57005e3e 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt @@ -442,6 +442,42 @@ class HidUsbLink( return n >= 0 } + /** + * Read one report back OUT of the device — HID `GET_REPORT`, the EP0 mirror of [sendReport]. + * [type] is [REPORT_TYPE_FEATURE] (or output), [id] the report number, [len] the report's full + * declared size INCLUDING its leading id byte, which a numbered report echoes back in byte 0 + * (hidapi framing). Returns what arrived — truncated if the device answered short — or null + * when the device refuses the request or the link is down. + * + * ⚠ **Once, at claim time; never per input report.** EP0 is independent of the interrupt + * endpoints (see [sendReport]), so this is safe alongside the reader thread — but it BLOCKS the + * calling thread for up to [WRITE_TIMEOUT_MS], and a blocking control transfer in the report + * path would wreck capture latency. The one caller reads a Sony pad's fixed motion calibration + * when the capture engages ([DsCapture]). + */ + fun getReport(type: Int, id: Int, len: Int): ByteArray? { + if (len <= 0) return null + val conn = connection ?: return null + val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return null + val buf = ByteArray(len) + val n = runCatching { + conn.controlTransfer( + 0xA1, // device→host, class, interface + 0x01, // GET_REPORT + (type shl 8) or id, + ifId, + buf, + buf.size, + WRITE_TIMEOUT_MS, + ) + }.getOrDefault(-1) + return when { + n >= len -> buf + n > 0 -> buf.copyOf(n) + else -> null + } + } + /** * Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. * @@ -469,12 +505,13 @@ class HidUsbLink( device = null } - private companion object { - const val READ_TIMEOUT_MS = 100L - const val WRITE_TIMEOUT_MS = 250 + companion object { + private const val READ_TIMEOUT_MS = 100L + private const val WRITE_TIMEOUT_MS = 250 /** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */ - const val ERROR_UNPLUG_MS = 2000L - const val REPORT_TYPE_OUTPUT = 0x02 + private const val ERROR_UNPLUG_MS = 2000L + private const val REPORT_TYPE_OUTPUT = 0x02 + /** HID feature-report type — public for [getReport] callers ([writeRaw] takes a kind). */ const val REPORT_TYPE_FEATURE = 0x03 } } diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DsDeviceTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DsDeviceTest.kt index ca1fb3ca..ed2d5bce 100644 --- a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DsDeviceTest.kt +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DsDeviceTest.kt @@ -151,6 +151,185 @@ class DsDeviceTest { assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s)) } + // ---- IMU calibration (the pad's own scale factors) ---- + + /** + * A calibration feature report in the pads' USB layout: report id, three gyro bias words, six + * INTERLEAVED gyro plus/minus words, the two speed words, six accel plus/minus words — all + * little-endian i16, exactly what [DsDevice.MotionCal.parse] reads and what + * `crates/pf-inject/tests/motion_contract.rs` writes from the other end. + */ + private fun calBlob( + id: Int, + gyroBias: IntArray, + gyroPlus: IntArray, + gyroMinus: IntArray, + speed: Int, + accelPlus: IntArray, + accelMinus: IntArray, + len: Int = 41, + ): ByteArray = ByteArray(len).also { b -> + fun put(o: Int, v: Int) { + b[o] = (v and 0xFF).toByte() + b[o + 1] = ((v shr 8) and 0xFF).toByte() + } + b[0] = id.toByte() + for (i in 0 until 3) { + put(1 + 2 * i, gyroBias[i]) + put(7 + 4 * i, gyroPlus[i]) + put(9 + 4 * i, gyroMinus[i]) + put(23 + 4 * i, accelPlus[i]) + put(25 + 4 * i, accelMinus[i]) + } + put(19, speed) + put(21, speed) + } + + /** + * A realistic DualSense blob: gyro measured at 512 °/s each way over ±8192 counts about a + * small factory bias — 16384/1024 = 16 raw LSB per °/s, the ≈±2000 °/s full scale a real pad + * has — and accel spanning about ±8192 counts (`DS_ACC_RES_PER_G`) about a per-axis zero point + * that is NOT zero. Both are the shape a nominal constant cannot express. + */ + private fun realisticCal(): DsDevice.MotionCal = DsDevice.MotionCal.parse( + calBlob( + id = 0x05, + gyroBias = intArrayOf(10, -6, 3), + gyroPlus = intArrayOf(10 + 8192, -6 + 8192, 3 + 8192), + gyroMinus = intArrayOf(10 - 8192, -6 - 8192, 3 - 8192), + speed = 512, // speed_plus + speed_minus = 1024 + accelPlus = intArrayOf(8300, 8200, 8000), + accelMinus = intArrayOf(-8100, -8192, -8384), + ), + 0x05, + ) + + @Test + fun calibrationRescalesRawCountsOntoTheWireUnits() { + val cal = realisticCal() + // 100 °/s at this pad's 16 LSB per °/s = 1600 raw → the wire's 20 LSB per °/s = 2000. + for (axis in 0 until 3) { + assertEquals(2000, cal.gyroToWire(axis, 1600)) + assertEquals(-2000, cal.gyroToWire(axis, -1600)) + assertEquals(0, cal.gyroToWire(axis, 0)) + } + // 1 g = the axis's zero point plus half its declared 2 g range → 10000 wire units. + val zero = intArrayOf(100, 4, -192) // plus − range/2, per axis + val oneG = intArrayOf(8300, 8200, 8000) // = accelPlus + for (axis in 0 until 3) { + assertEquals(10000, cal.accelToWire(axis, oneG[axis])) + assertEquals(0, cal.accelToWire(axis, zero[axis])) + assertEquals(-10000, cal.accelToWire(axis, zero[axis] - (oneG[axis] - zero[axis]))) + } + // Both rescales are >1 here, so full-scale raw must clamp rather than wrap the i16. + assertEquals(32767, cal.gyroToWire(0, 30000)) + assertEquals(-32768, cal.gyroToWire(0, -30000)) + assertEquals(32767, cal.accelToWire(0, 30000)) + // The capture logs this, and it is the discriminator the owed on-glass check reads: a pad + // whose blob was read declares its own resolution, the fallback declares the wire's. + assertTrue(cal.toString().startsWith("gyro 16/16/16 LSB/°·s")) + assertTrue(DsDevice.MotionCal.NOMINAL.toString().startsWith("gyro 20/20/20 LSB/°·s")) + } + + /** + * The host's own virtual pads declare `DS_FEATURE_CALIBRATION` (`dualsense_proto.rs`) — a blob + * that states the wire's units exactly. Reading it back must therefore be a passthrough: if + * this ever stops holding, the client and the host disagree about what a motion sample means. + */ + @Test + fun theHostsOwnBlobIsAPassthrough() { + val cal = DsDevice.MotionCal.parse( + calBlob( + id = 0x05, + gyroBias = intArrayOf(0, 0, 0), + gyroPlus = intArrayOf(10000, 10000, 10000), + gyroMinus = intArrayOf(-10000, -10000, -10000), + speed = 500, + accelPlus = intArrayOf(10000, 10000, 10000), + accelMinus = intArrayOf(-10000, -10000, -10000), + ), + 0x05, + ) + for (axis in 0 until 3) { + assertEquals(2000, cal.gyroToWire(axis, 2000)) // 100 °/s + assertEquals(10000, cal.accelToWire(axis, 10000)) // 1 g + assertEquals(-1234, cal.gyroToWire(axis, -1234)) + } + } + + /** + * Anything unusable keeps the pre-calibration behaviour — accel on the nominal 8192 LSB/g, + * gyro straight through. A pad with no readable calibration is better off slightly mis-scaled + * than silent, so nothing here may zero motion. + */ + @Test + fun unusableCalibrationFallsBackInsteadOfZeroing() { + val degenerate = calBlob( + id = 0x02, + gyroBias = intArrayOf(0, 0, 0), + gyroPlus = intArrayOf(0, 0, 0), + gyroMinus = intArrayOf(0, 0, 0), + speed = 0, + accelPlus = intArrayOf(0, 0, 0), + accelMinus = intArrayOf(0, 0, 0), + len = 37, + ) + val cals = listOf( + DsDevice.MotionCal.NOMINAL, + DsDevice.MotionCal.parse(null, 0x05), // the GET_REPORT failed + DsDevice.MotionCal.parse(ByteArray(8) { if (it == 0) 0x05 else 0 }, 0x05), // short reply + DsDevice.MotionCal.parse(degenerate, 0x02), // a clone pad's zeroes + DsDevice.MotionCal.parse(degenerate, 0x05), // someone else's report id + ) + for (cal in cals) { + for (axis in 0 until 3) { + assertEquals(1234, cal.gyroToWire(axis, 1234)) // passthrough + assertEquals(10000, cal.accelToWire(axis, 8192)) // 8192 raw LSB = 1 g + assertEquals(-10000, cal.accelToWire(axis, -8192)) + } + } + } + + /** The parse applies the calibration at the motion offsets, per model, and defaults to nominal. */ + @Test + fun parseStateAppliesTheCalibration() { + val cal = realisticCal() + // DS5: gyro at [16..22), accel at [22..28). Pitch = 1600 raw (100 °/s), accel z = 8000 (1 g). + val ds5 = ds5Report { + it[16] = 0x40; it[17] = 0x06 // 1600 + it[26] = 0x40; it[27] = 0x1F // 8000 + } + val five = DsDevice.State() + assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5, 64, five, cal)) + assertEquals(2000, five.gyro[0]) + assertEquals(10000, five.accel[2]) + // DS4: gyro at [13..19), accel at [19..25). Same numbers, same answers. + val ds4 = ds4Report { + it[13] = 0x40; it[14] = 0x06 + it[23] = 0x40; it[24] = 0x1F + } + val four = DsDevice.State() + assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4, 64, four, cal)) + assertEquals(2000, four.gyro[0]) + assertEquals(10000, four.accel[2]) + // No calibration argument = the nominal fallback: gyro through, accel ×10000/8192. + val nominal = DsDevice.State() + assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5, 64, nominal)) + assertEquals(1600, nominal.gyro[0]) + assertEquals(8000L * 10000 / 8192, nominal.accel[2].toLong()) + } + + /** Each model asks for the feature report its firmware actually serves over USB. */ + @Test + fun calibrationReportIdentityPerModel() { + assertEquals(0x05, DsDevice.Model.DUALSENSE.calReportId) + assertEquals(41, DsDevice.Model.DUALSENSE.calReportLen) + assertEquals(0x05, DsDevice.Model.DUALSENSE_EDGE.calReportId) + assertEquals(41, DsDevice.Model.DUALSENSE_EDGE.calReportLen) + assertEquals(0x02, DsDevice.Model.DUALSHOCK4.calReportId) + assertEquals(37, DsDevice.Model.DUALSHOCK4.calReportLen) + } + // ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ---- @Test From 26b0819f5c99556c6f313ce0174ff4998a849912 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:10:22 +0200 Subject: [PATCH 08/22] fix(client/android): plugging in a Sony pad could hitch the interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the synchronous calibration read f6de620f shipped an hour ago. The ordering it protected is kept; the blocking it cost is not. f6de620f read the pad's calibration inline in DsCapture.startUsb, which runs on the main thread — the stream's setup path, and the USB-permission broadcast. The read is a blocking EP0 control transfer: a pad that is there answers in about a millisecond, but a pad that is stalling takes the link's whole 250 ms write timeout, and either way the interface was waiting on a controller. That is the wrong thread for it. It now runs on its own daemon thread, one per claim, named pf-ds-cal — the same shape HidUsbLink already uses for its reader rather than a second style. A pathological stall now delays the pad's motion by a moment instead of freezing the UI. What kept the ordering honest before was "assign the calibration before `model`", since `model` is what lets the link thread into the parse. That reasoning stands, so the gate simply moved: MotionCalHandoff holds the claim's calibration, starts null, and onReport parses nothing until it lands. No report is ever scaled by the last pad's numbers — those are per unit — nor by the nominal fallback the real read is about to replace. Dropping the first millisecond of a capture costs nothing: the reports carry absolute state, so the next one says everything the dropped one would have. The calibration is what got deferred, not `model`, and that is deliberate. Keeping `model` synchronous keeps isActive, the teardown writes, the feedback sinks and the active-changed true/false pairing meaning exactly what they meant yesterday — and, more to the point, it makes a late completion structurally unable to resurrect a dead capture. A straggler can only ever publish a calibration, and nothing is parsed while `model` is null. Teardown, which is where this sort of change actually bites. Both stop() and the unplug path end the claim before they close anything: ending burns the token, so a read that lands afterwards publishes nothing and says so in the log. They then wait, bounded at 500 ms and normally already over, for the read to let go of the connection they are about to close — closing a descriptor with a transfer in flight pulls it out from under the kernel, the same rule the pad-audio borrow follows. It cannot deadlock: the reading thread blocks on the EP0 transfer and on the hand-off's own monitor, never on anything a teardown holds. If a pad has stopped answering entirely the wait elapses and teardown proceeds regardless, which is the same exposure the feedback writes already carry and better than an interface that never comes back. Tested where it is testable. MotionCalHandoff is the piece that carries the hazard and it is pure, so it has its own test: nothing is visible until the read lands, a read that outlived its claim publishes nothing, a re-claim never inherits the previous pad's calibration, and a doubled end still refuses every outstanding token. Mutation-checked both ways — deleting the token check fails 3 of them, deleting begin's clear fails the fourth. Not covered: DsCapture's own claim/teardown ordering is not unit-testable in this module — there is no Robolectric, and the class builds a main-Looper Handler and needs a UsbManager — so it is argued in comments rather than pinned. The on-glass re-verification f6de620f owes is unchanged and still owed. Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` (61 cases across the module, 0 failed) and `:app:compileDebugKotlin` green, with the four new cases confirmed present in the JUnit XML rather than assumed from a green build. --- .../kotlin/io/unom/punktfunk/kit/DsCapture.kt | 105 +++++++++++++++--- .../io/unom/punktfunk/kit/MotionCalHandoff.kt | 55 +++++++++ .../punktfunk/kit/MotionCalHandoffTest.kt | 94 ++++++++++++++++ 3 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/MotionCalHandoff.kt create mode 100644 clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/MotionCalHandoffTest.kt diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index 5b54ca32..b68c1f91 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -23,9 +23,10 @@ import android.view.InputDevice * Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons * diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch * normalized to the wire's 0..65535 screen space on-change; motion forwarded per report, rescaled - * into the wire's units by this pad's own calibration, read once at claim). The wire slot is - * claimed when the capture engages, with the first parsed report as the fallback for a claim that - * found no free index, and freed on unplug/[stop], so indices never leak. + * into the wire's units by this pad's own calibration — read once per claim, off the claiming + * thread, so parsing starts a millisecond in rather than the UI waiting on a control transfer). + * The wire slot is claimed when the capture engages, with the first parsed report as the fallback + * for a claim that found no free index, and freed on unplug/[stop], so indices never leak. * * Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player * LED events addressed to this pad's wire index become USB output reports on the physical pad @@ -55,9 +56,12 @@ class DsCapture( @Volatile private var model: DsDevice.Model? = null @Volatile private var pad: GamepadRouter.ExternalPad? = null - /** This pad's factory motion scale, read once per capture (see [readMotionCal]). Written on - * the claiming thread before [model], which is what the link thread's parse reads it under. */ - @Volatile private var motionCal = DsDevice.MotionCal.NOMINAL + /** This pad's factory motion scale, read once per capture on [calReader] and handed to the + * link thread. Null until that read lands — see [MotionCalHandoff] and [onReport]. */ + private val motionCal = MotionCalHandoff() + + /** The thread doing the claim-time calibration read, kept for the teardown wait. */ + @Volatile private var calReader: Thread? = null // Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only. private val state = DsDevice.State() @@ -128,9 +132,10 @@ class DsCapture( if (model != null) return false val m = DsDevice.modelFor(dev.productId) ?: return false if (!usb.start(dev)) return false - // Before `model`, which is what gates the link thread's parse: a report must never be - // scaled by the previous pad's calibration, or by the fallback once the real one is known. - motionCal = readMotionCal(m) + // Before `model`, which is what lets the link thread into the parse at all: opening the + // claim forgets the last pad's calibration, so no report can be scaled by it while this + // pad's own read (below, off this thread) is in flight. + val claim = motionCal.begin() model = m for (id in InputDevice.getDeviceIds()) { val d = InputDevice.getDevice(id) ?: continue @@ -142,18 +147,68 @@ class DsCapture( Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m)) ensureSlot(m) onActiveChanged?.invoke(true) + readMotionCalAsync(m, claim) return true } /** - * Read this pad's IMU calibration, ONCE, while claiming it — the feature report that says how - * many raw counts this individual unit puts on a °/s and on a g ([DsDevice.MotionCal]). + * Start this claim's calibration read, on its own thread. * - * At claim time and nowhere else: the read is a blocking EP0 control transfer (bounded by the - * link's write timeout, answered in about a millisecond by a pad that is there), and the - * calibration is fixed for the life of the connection, so doing it per input report would buy - * nothing and cost the capture its latency. A pad that refuses keeps the nominal scaling - * rather than losing motion altogether. + * Off the caller's thread because [startUsb] runs on the main one — stream setup, and the + * USB-permission broadcast — and the read is a blocking EP0 control transfer: a pad that is + * there answers in about a millisecond, but one that is stalling takes the link's whole write + * timeout, and the interface must wait for neither. The pad goes live a millisecond later + * instead, because the link thread parses nothing until the calibration lands ([onReport]); + * a pathological stall then delays motion rather than freezing the UI. + * + * One thread per claim, daemon and named, matching how [HidUsbLink] runs its reader; it is + * awaited by [awaitCalRead] before the connection it reads from can be closed. + */ + private fun readMotionCalAsync(m: DsDevice.Model, claim: Int) { + val t = Thread({ + // Never leave the gate shut: a read that fails or throws still has to publish + // something, or this capture would forward no motion at all for its whole life. + val cal = runCatching { readMotionCal(m) }.getOrElse { + Log.w(TAG, "motion calibration read failed — nominal scaling", it) + DsDevice.MotionCal.NOMINAL + } + // Discarded when the claim is already over (unplug, stop, or a re-claim beat us here): + // scaling the NEXT pad by this one's factory numbers would be worse than not reading. + if (!motionCal.publish(claim, cal)) { + Log.i(TAG, "motion calibration arrived after the claim ended — discarded") + } + }, "pf-ds-cal") + calReader = t + t.isDaemon = true + t.start() + } + + /** + * Wait for an in-flight calibration read to let go of the USB connection, before a teardown + * closes it. + * + * Not politeness: the read is a control transfer on the very connection [HidUsbLink.stop] is + * about to close, and closing a descriptor with a transfer in flight pulls it out from under + * the kernel — the same rule the pad-audio borrow follows. Bounded, and in every case but a + * pad that has stopped answering the thread is long gone, so this returns immediately. It can + * never deadlock: the reading thread waits on nothing this one holds ([MotionCalHandoff] has + * its own monitor, and the read itself takes no lock). + */ + private fun awaitCalRead() { + val t = calReader ?: return + calReader = null + if (!t.isAlive) return + runCatching { t.join(CAL_JOIN_MS) } + if (t.isAlive) Log.w(TAG, "calibration read still in flight at teardown") + } + + /** + * Read this pad's IMU calibration — the feature report that says how many raw counts this + * individual unit puts on a °/s and on a g ([DsDevice.MotionCal]). + * + * Once, at claim time, and nowhere else: the calibration is fixed for the life of the + * connection, so doing it per input report would buy nothing and cost the capture its latency. + * A pad that refuses keeps the nominal scaling rather than losing motion altogether. */ private fun readMotionCal(m: DsDevice.Model): DsDevice.MotionCal { val blob = usb.getReport(HidUsbLink.REPORT_TYPE_FEATURE, m.calReportId, m.calReportLen) @@ -192,6 +247,10 @@ class DsCapture( resetRichFeedback(m) } disarmBackstop() + // End the claim before waiting on it: a calibration that lands after this publishes + // nothing, and then the wait makes sure nothing is still reading the connection below. + motionCal.end() + awaitCalRead() usb.stop() val wasActive = model != null model = null @@ -203,7 +262,11 @@ class DsCapture( private fun onReport(report: ByteArray, len: Int) { val m = model ?: return - if (!DsDevice.parseState(m, report, len, state, motionCal)) return + // This claim's calibration read is still in flight. Dropping the report beats parsing it + // with a fallback that is about to be replaced: the reports carry absolute state, so the + // next one (1–4 ms away) says everything this one would have. + val cal = motionCal.current ?: return + if (!DsDevice.parseState(m, report, len, state, cal)) return // Normally claimed already, at capture time; this is the retry for a capture that engaged // while every wire index was taken. val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees @@ -314,6 +377,10 @@ class DsCapture( val wasActive = model != null model = null releaseSlot() + // As in stop(): end the claim so a late calibration publishes nothing, then wait for the + // read to let go of the connection the line below closes. + motionCal.end() + awaitCalRead() // Release the transport too: the link only *signals* the drop, so without this an unplug // left its connection open, its interfaces claimed and its detach receiver registered. usb.stop() @@ -518,5 +585,9 @@ class DsCapture( /** How soon to retry a rumble stop whose write was rejected. Short: the motors are running * and the host has already moved on, so nothing else is coming to silence them. */ const val STOP_RETRY_MS = 100L + + /** Teardown's budget for an in-flight calibration read. Comfortably past the link's own + * EP0 timeout, so it only ever elapses for a pad that has stopped answering entirely. */ + const val CAL_JOIN_MS = 500L } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/MotionCalHandoff.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/MotionCalHandoff.kt new file mode 100644 index 00000000..f8a34351 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/MotionCalHandoff.kt @@ -0,0 +1,55 @@ +package io.unom.punktfunk.kit + +/** + * The hand-off of one claim's motion calibration, from the thread that reads it off the pad to the + * link thread that scales every input report with it. + * + * [DsCapture] reads a captured Sony pad's calibration feature report **off** the claiming thread — + * it is a blocking EP0 control transfer and the claim runs on the UI's thread — so the value lands + * a moment after the capture goes live. Two things have to hold across that gap, and a plain field + * gives neither: + * + * - **No report is ever scaled by the wrong pad's numbers.** [begin] forgets whatever the last + * capture published, so the link thread reads null — "no calibration yet", parse nothing — rather + * than inheriting the previous controller's scale factors, which are per unit and simply wrong + * for this one. It is also why the gap is a *drop* rather than a fallback: the fallback is what + * the read is about to replace, and a millisecond of unparsed reports costs nothing (they carry + * absolute state, and the next one is 1–4 ms behind). + * - **A read that outlived its claim publishes nothing.** An unplug, a [DsCapture.stop] and a fast + * re-claim can all land while a read is in flight; [publish] only accepts a value whose token is + * still the live claim's, so a straggler can never scale a pad it never read. + * + * Thread-safe: claimed and ended by the claiming thread, published by the reading thread, read by + * the link thread. + */ +internal class MotionCalHandoff { + /** Handed out by [begin] and burned by [end] — never reused, so a straggler can't match. */ + private var token = 0 + + @Volatile private var cal: DsDevice.MotionCal? = null + + /** The live claim's calibration, or null while its read is still in flight. */ + val current: DsDevice.MotionCal? get() = cal + + /** Open a claim: forget the previous pad's calibration, and take this claim's token. */ + @Synchronized + fun begin(): Int { + cal = null + return ++token + } + + /** End the live claim. Nothing read under an older token can land after this. */ + @Synchronized + fun end() { + cal = null + token++ + } + + /** Publish [value] if [claim] is still the live claim; returns whether it landed. */ + @Synchronized + fun publish(claim: Int, value: DsDevice.MotionCal): Boolean { + if (claim != token) return false + cal = value + return true + } +} diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/MotionCalHandoffTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/MotionCalHandoffTest.kt new file mode 100644 index 00000000..e8776ee9 --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/MotionCalHandoffTest.kt @@ -0,0 +1,94 @@ +package io.unom.punktfunk.kit + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The claim/read hand-off that lets [DsCapture] read a pad's motion calibration off the claiming + * thread. What is pinned here is what happens when the read does NOT come back inside its claim: + * an unplug, a stop, or a re-claim can all land first, and a straggler that published anyway would + * scale a pad it never read — the one hazard the threading introduces. + */ +class MotionCalHandoffTest { + /** + * A calibration whose gyro scale is [rawLsbPerDegS] raw LSB per °/s, so two of them are told + * apart by what they do and not only by identity. + */ + private fun cal(rawLsbPerDegS: Int): DsDevice.MotionCal { + val speed = 500 // speed_plus = speed_minus, so speed_2x = 1000 + val span = rawLsbPerDegS * 1000 // |plus − bias| + |minus − bias| = span + val blob = ByteArray(41) + fun put(o: Int, v: Int) { + blob[o] = (v and 0xFF).toByte() + blob[o + 1] = ((v shr 8) and 0xFF).toByte() + } + blob[0] = 0x05 + for (i in 0 until 3) { + put(7 + 4 * i, span / 2) // plus + put(9 + 4 * i, -span / 2) // minus + put(23 + 4 * i, 8192) // accel plus / minus: nominal, not what this test is about + put(25 + 4 * i, -8192) + } + put(19, speed) + put(21, speed) + return DsDevice.MotionCal.parse(blob, 0x05) + } + + @Test + fun `a claim's calibration is invisible until its read lands`() { + val h = MotionCalHandoff() + assertNull("nothing is claimed yet", h.current) + val claim = h.begin() + assertNull("the read is still in flight — the parse must not run", h.current) + val read = cal(16) + assertTrue(h.publish(claim, read)) + assertSame(read, h.current) + } + + @Test + fun `a read that outlived its claim publishes nothing`() { + val h = MotionCalHandoff() + val claim = h.begin() + h.end() // unplug, or DsCapture.stop, while the read was in flight + assertFalse("a straggler may not publish into a dead claim", h.publish(claim, cal(16))) + assertNull(h.current) + } + + @Test + fun `a new claim never inherits the previous pad's calibration`() { + val h = MotionCalHandoff() + val first = h.begin() + val hot = cal(4) // a pad whose gyro reads 4 raw LSB per °/s + assertTrue(h.publish(first, hot)) + + // Re-claimed without an end() in between — the pad was swapped while a read was in flight. + val second = h.begin() + assertNotEquals(first, second) + assertNull("the next pad starts with no calibration, not the last one's", h.current) + assertFalse("the first pad's read may not scale the second pad", h.publish(first, hot)) + assertNull(h.current) + + val slow = cal(32) + assertTrue(h.publish(second, slow)) + assertSame(slow, h.current) + // And the two really are different scales, so the assertions above are about a real + // difference rather than two names for the same numbers. + assertNotEquals(hot.gyroToWire(0, 100), slow.gyroToWire(0, 100)) + } + + @Test + fun `ending a claim twice still refuses every outstanding token`() { + val h = MotionCalHandoff() + val claim = h.begin() + h.end() // DsCapture.stop + h.end() // …and the unplug that followed it + assertFalse(h.publish(claim, cal(16))) + val next = h.begin() + assertNotEquals(claim, next) + assertTrue(h.publish(next, cal(16))) + } +} From 8e8d30202c34443df461c26516df82ce7cc7f18a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:23:48 +0200 Subject: [PATCH 09/22] fix(client/android): a Sony pad's buttons no longer wait on its calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the parse gate in 26b0819f. The off-thread read, the claim token, the teardown ordering and its bounded wait all stand — only what happens in the gap changes. 26b0819f held every report back until the calibration read came home, so a pad that stalled on EP0 could feel dead for up to the link's 250 ms timeout: no buttons, no sticks, nothing. Reports are now forwarded immediately and their motion scaled by the nominal calibration until the real one lands. That gap is exactly the behaviour that shipped before f6de620f — acceleration ~18% short, gyro unscaled — for about a millisecond. Nobody can feel that. A controller that ignores a button press for a quarter of a second is not in the same category, and it is the only one of the two a user would ever report. It is also the safer of the two conservatisms available here. The rejected third option, forwarding motion as zeroes until the real numbers arrive, would have the host read a still pad as being in free fall — a lie about the physical world rather than an imprecision about it. The nominal constants are merely a slightly wrong scale. The token is more load-bearing under this, not less. With a gate, an unpublished calibration meant "parse nothing"; now it means "scale nominally", so begin() clearing the previous pad's value is the whole reason a re-claim falls back to the nominal constants instead of silently inheriting factory numbers belonging to a different unit — which are, in general, further off than nominal. The fallback therefore lives in the hand-off itself (MotionCalHandoff.effective) rather than as an elvis at the call site: restoring the gate now means changing the type's API, not deleting three characters in onReport. The tests moved with the contract. They assert the nominal calibration is what is in effect during the gap, rather than merely that the slot is empty — an empty slot is now compatible with either behaviour, so asserting on it would have let a regression pass. Added the case the change exists for: the same raw report, parsed either side of publication, forwards identical buttons and sticks while its gyro and acceleration convert differently. Mutation-checked three ways — dropping the nominal fallback fails all five cases, dropping begin's clear fails the inheritance case, dropping the token check fails three. Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` and `:app:compileDebugKotlin` green on a forced clean rerun, 62 cases across the module, 0 failed, with the five hand-off cases read back out of the JUnit XML. The on-glass re-verification f6de620f owes is still owed and unchanged. --- .../kotlin/io/unom/punktfunk/kit/DsCapture.kt | 34 +++--- .../io/unom/punktfunk/kit/MotionCalHandoff.kt | 30 +++-- .../punktfunk/kit/MotionCalHandoffTest.kt | 108 ++++++++++++++---- 3 files changed, 120 insertions(+), 52 deletions(-) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index b68c1f91..789cb214 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -24,9 +24,10 @@ import android.view.InputDevice * diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch * normalized to the wire's 0..65535 screen space on-change; motion forwarded per report, rescaled * into the wire's units by this pad's own calibration — read once per claim, off the claiming - * thread, so parsing starts a millisecond in rather than the UI waiting on a control transfer). - * The wire slot is claimed when the capture engages, with the first parsed report as the fallback - * for a claim that found no free index, and freed on unplug/[stop], so indices never leak. + * thread, with the nominal scaling standing in for the millisecond that read is in flight rather + * than the UI waiting on a control transfer). The wire slot is claimed when the capture engages, + * with the first parsed report as the fallback for a claim that found no free index, and freed on + * unplug/[stop], so indices never leak. * * Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player * LED events addressed to this pad's wire index become USB output reports on the physical pad @@ -57,7 +58,7 @@ class DsCapture( @Volatile private var pad: GamepadRouter.ExternalPad? = null /** This pad's factory motion scale, read once per capture on [calReader] and handed to the - * link thread. Null until that read lands — see [MotionCalHandoff] and [onReport]. */ + * link thread, which scales nominally until it lands — see [MotionCalHandoff]. */ private val motionCal = MotionCalHandoff() /** The thread doing the claim-time calibration read, kept for the teardown wait. */ @@ -133,8 +134,9 @@ class DsCapture( val m = DsDevice.modelFor(dev.productId) ?: return false if (!usb.start(dev)) return false // Before `model`, which is what lets the link thread into the parse at all: opening the - // claim forgets the last pad's calibration, so no report can be scaled by it while this - // pad's own read (below, off this thread) is in flight. + // claim forgets the last pad's calibration, so reports arriving while this pad's own read + // (below, off this thread) is in flight fall back to the nominal scaling rather than to + // another unit's factory numbers. val claim = motionCal.begin() model = m for (id in InputDevice.getDeviceIds()) { @@ -157,17 +159,18 @@ class DsCapture( * Off the caller's thread because [startUsb] runs on the main one — stream setup, and the * USB-permission broadcast — and the read is a blocking EP0 control transfer: a pad that is * there answers in about a millisecond, but one that is stalling takes the link's whole write - * timeout, and the interface must wait for neither. The pad goes live a millisecond later - * instead, because the link thread parses nothing until the calibration lands ([onReport]); - * a pathological stall then delays motion rather than freezing the UI. + * timeout, and the interface must wait for neither. The pad is live throughout, its motion + * nominally scaled until this lands ([onReport]), so even a pad that never answers costs + * precision rather than the UI or the controller. * * One thread per claim, daemon and named, matching how [HidUsbLink] runs its reader; it is * awaited by [awaitCalRead] before the connection it reads from can be closed. */ private fun readMotionCalAsync(m: DsDevice.Model, claim: Int) { val t = Thread({ - // Never leave the gate shut: a read that fails or throws still has to publish - // something, or this capture would forward no motion at all for its whole life. + // A read that throws would otherwise leave the capture on the nominal scaling with + // nothing in the log to say why — the one outcome that looks identical to a pad whose + // calibration is genuinely nominal. Publish the fallback explicitly, and say so. val cal = runCatching { readMotionCal(m) }.getOrElse { Log.w(TAG, "motion calibration read failed — nominal scaling", it) DsDevice.MotionCal.NOMINAL @@ -262,11 +265,10 @@ class DsCapture( private fun onReport(report: ByteArray, len: Int) { val m = model ?: return - // This claim's calibration read is still in flight. Dropping the report beats parsing it - // with a fallback that is about to be replaced: the reports carry absolute state, so the - // next one (1–4 ms away) says everything this one would have. - val cal = motionCal.current ?: return - if (!DsDevice.parseState(m, report, len, state, cal)) return + // Nominal scaling until this claim's calibration read lands (see MotionCalHandoff): for + // that millisecond the pad behaves as it did before the read existed, which nobody can + // feel — unlike a pad whose buttons wait on a control transfer. + if (!DsDevice.parseState(m, report, len, state, motionCal.effective)) return // Normally claimed already, at capture time; this is the retry for a capture that engaged // while every wire index was taken. val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/MotionCalHandoff.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/MotionCalHandoff.kt index f8a34351..1bad299e 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/MotionCalHandoff.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/MotionCalHandoff.kt @@ -6,16 +6,20 @@ package io.unom.punktfunk.kit * * [DsCapture] reads a captured Sony pad's calibration feature report **off** the claiming thread — * it is a blocking EP0 control transfer and the claim runs on the UI's thread — so the value lands - * a moment after the capture goes live. Two things have to hold across that gap, and a plain field - * gives neither: + * a moment after the capture goes live. Reports in that gap are scaled by + * [DsDevice.MotionCal.NOMINAL] and forwarded like any other ([effective]): for about a millisecond + * the pad behaves exactly as it did before the calibration read existed — acceleration a little + * short, gyro unscaled — which nobody can feel, whereas a pad that ignores its buttons until an + * EP0 read comes back is very obvious. * - * - **No report is ever scaled by the wrong pad's numbers.** [begin] forgets whatever the last - * capture published, so the link thread reads null — "no calibration yet", parse nothing — rather - * than inheriting the previous controller's scale factors, which are per unit and simply wrong - * for this one. It is also why the gap is a *drop* rather than a fallback: the fallback is what - * the read is about to replace, and a millisecond of unparsed reports costs nothing (they carry - * absolute state, and the next one is 1–4 ms behind). - * - **A read that outlived its claim publishes nothing.** An unplug, a [DsCapture.stop] and a fast + * What the hand-off is actually for is the two things that gap must NOT do, neither of which a + * plain field gives: + * + * - **Fall back to the previous pad's numbers instead of the nominal ones.** Calibration is per + * unit, so the last controller's scale factors are simply wrong for this one — more wrong, in + * general, than the nominal constants. [begin] forgets them, which is what makes the gap + * nominal rather than inherited. + * - **Let a read that outlived its claim publish.** An unplug, a [DsCapture.stop] and a fast * re-claim can all land while a read is in flight; [publish] only accepts a value whose token is * still the live claim's, so a straggler can never scale a pad it never read. * @@ -28,8 +32,12 @@ internal class MotionCalHandoff { @Volatile private var cal: DsDevice.MotionCal? = null - /** The live claim's calibration, or null while its read is still in flight. */ - val current: DsDevice.MotionCal? get() = cal + /** + * The calibration to scale the next report with: the live claim's own, or the nominal fallback + * while its read is still in flight. Never null — a report is always forwarded, never held + * back waiting for a control transfer. + */ + val effective: DsDevice.MotionCal get() = cal ?: DsDevice.MotionCal.NOMINAL /** Open a claim: forget the previous pad's calibration, and take this claim's token. */ @Synchronized diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/MotionCalHandoffTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/MotionCalHandoffTest.kt index e8776ee9..c46cb6d6 100644 --- a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/MotionCalHandoffTest.kt +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/MotionCalHandoffTest.kt @@ -1,24 +1,28 @@ package io.unom.punktfunk.kit +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test /** * The claim/read hand-off that lets [DsCapture] read a pad's motion calibration off the claiming - * thread. What is pinned here is what happens when the read does NOT come back inside its claim: - * an unplug, a stop, or a re-claim can all land first, and a straggler that published anyway would - * scale a pad it never read — the one hazard the threading introduces. + * thread. Two things are pinned here, and both are about the gap before the read comes back. + * + * What the gap DOES: the pad streams, scaled by the nominal calibration — the behaviour that + * shipped before the read existed. What it must NOT do: inherit the previous pad's factory numbers + * (calibration is per unit), or accept a read that outlived its claim, which an unplug, a stop, or + * a re-claim can all cause. */ class MotionCalHandoffTest { /** - * A calibration whose gyro scale is [rawLsbPerDegS] raw LSB per °/s, so two of them are told - * apart by what they do and not only by identity. + * A calibration whose gyro reads [rawLsbPerDegS] raw LSB per °/s and whose accel sits at + * [accelZero] raw counts at 0 g, so two of them are told apart by what they DO — identity + * alone would let a regression that returns the wrong instance still look right. */ - private fun cal(rawLsbPerDegS: Int): DsDevice.MotionCal { + private fun cal(rawLsbPerDegS: Int, accelZero: Int = 0): DsDevice.MotionCal { val speed = 500 // speed_plus = speed_minus, so speed_2x = 1000 val span = rawLsbPerDegS * 1000 // |plus − bias| + |minus − bias| = span val blob = ByteArray(41) @@ -28,25 +32,64 @@ class MotionCalHandoffTest { } blob[0] = 0x05 for (i in 0 until 3) { - put(7 + 4 * i, span / 2) // plus - put(9 + 4 * i, -span / 2) // minus - put(23 + 4 * i, 8192) // accel plus / minus: nominal, not what this test is about - put(25 + 4 * i, -8192) + put(7 + 4 * i, span / 2) // gyro plus + put(9 + 4 * i, -span / 2) // gyro minus + put(23 + 4 * i, accelZero + 8192) // accel plus / minus: 8192 raw LSB per g + put(25 + 4 * i, accelZero - 8192) } put(19, speed) put(21, speed) return DsDevice.MotionCal.parse(blob, 0x05) } + /** One DS5 input report: cross held, sticks centred, gyro pitch 1600 raw, accel z 8000 raw. */ + private fun report(): ByteArray = ByteArray(64).also { + it[0] = 0x01 + it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte() + it[8] = (0x08 or 0x20).toByte() // hat neutral | cross + it[16] = 0x40; it[17] = 0x06 // gyro pitch = 1600 + it[26] = 0x40; it[27] = 0x1F // accel z = 8000 + it[33] = 0x80.toByte(); it[37] = 0x80.toByte() // no touch contacts + } + @Test - fun `a claim's calibration is invisible until its read lands`() { + fun `a claim scales nominally until its read lands`() { val h = MotionCalHandoff() - assertNull("nothing is claimed yet", h.current) + assertSame(DsDevice.MotionCal.NOMINAL, h.effective) val claim = h.begin() - assertNull("the read is still in flight — the parse must not run", h.current) + assertSame("the read is in flight — scale nominally, do not wait", DsDevice.MotionCal.NOMINAL, h.effective) val read = cal(16) assertTrue(h.publish(claim, read)) - assertSame(read, h.current) + assertSame(read, h.effective) + } + + /** + * The whole point of scaling nominally instead of holding reports back: a pad answers its + * buttons from the first report, and only its motion changes when the calibration arrives. + */ + @Test + fun `a report in the gap is forwarded, nominally scaled, and rescales once the read lands`() { + val h = MotionCalHandoff() + val claim = h.begin() + val r = report() + + val gap = DsDevice.State() + assertTrue( + "a report must still be parsed while the read is in flight", + DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, gap, h.effective), + ) + assertEquals("buttons reach the wire immediately", Gamepad.BTN_A, gap.buttons) + assertEquals("and so do sticks", 128, gap.lsX) + assertEquals("nominal gyro is the raw count", 1600, gap.gyro[0]) + assertEquals("nominal accel is ×10000/8192", 8000L * 10000 / 8192, gap.accel[2].toLong()) + + assertTrue(h.publish(claim, cal(16, accelZero = 100))) + val live = DsDevice.State() + assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, live, h.effective)) + assertEquals("buttons do not depend on the calibration", gap.buttons, live.buttons) + assertEquals("1600 raw at 16 LSB/°·s = 100 °/s = 2000 wire", 2000, live.gyro[0]) + assertNotEquals("the same raw report must convert differently now", gap.gyro[0], live.gyro[0]) + assertNotEquals(gap.accel[2], live.accel[2]) } @Test @@ -55,29 +98,41 @@ class MotionCalHandoffTest { val claim = h.begin() h.end() // unplug, or DsCapture.stop, while the read was in flight assertFalse("a straggler may not publish into a dead claim", h.publish(claim, cal(16))) - assertNull(h.current) + assertSame(DsDevice.MotionCal.NOMINAL, h.effective) } @Test - fun `a new claim never inherits the previous pad's calibration`() { + fun `a new claim scales nominally rather than inheriting the previous pad's calibration`() { val h = MotionCalHandoff() val first = h.begin() - val hot = cal(4) // a pad whose gyro reads 4 raw LSB per °/s + val hot = cal(4, accelZero = 400) // a pad reading 4 raw LSB per °/s, well off nominal assertTrue(h.publish(first, hot)) + assertSame(hot, h.effective) // Re-claimed without an end() in between — the pad was swapped while a read was in flight. val second = h.begin() assertNotEquals(first, second) - assertNull("the next pad starts with no calibration, not the last one's", h.current) + assertSame( + "the next pad starts on the nominal scaling, NOT the last pad's factory numbers", + DsDevice.MotionCal.NOMINAL, + h.effective, + ) assertFalse("the first pad's read may not scale the second pad", h.publish(first, hot)) - assertNull(h.current) + assertSame(DsDevice.MotionCal.NOMINAL, h.effective) + + // And that fallback is a real difference, not two names for the same numbers: the inherited + // calibration would have turned this pad's motion into something else entirely. + val r = report() + val nominal = DsDevice.State() + val inherited = DsDevice.State() + DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, nominal, h.effective) + DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, inherited, hot) + assertNotEquals(inherited.gyro[0], nominal.gyro[0]) + assertNotEquals(inherited.accel[2], nominal.accel[2]) val slow = cal(32) assertTrue(h.publish(second, slow)) - assertSame(slow, h.current) - // And the two really are different scales, so the assertions above are about a real - // difference rather than two names for the same numbers. - assertNotEquals(hot.gyroToWire(0, 100), slow.gyroToWire(0, 100)) + assertSame(slow, h.effective) } @Test @@ -87,8 +142,11 @@ class MotionCalHandoffTest { h.end() // DsCapture.stop h.end() // …and the unplug that followed it assertFalse(h.publish(claim, cal(16))) + assertSame(DsDevice.MotionCal.NOMINAL, h.effective) val next = h.begin() assertNotEquals(claim, next) - assertTrue(h.publish(next, cal(16))) + val read = cal(16) + assertTrue(h.publish(next, read)) + assertSame(read, h.effective) } } From 5c4969fd6bb1b9e0364a8ab879d7f19a3c31c4a4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:46:06 +0200 Subject: [PATCH 10/22] fix(client/pads): the gyro cut-off asked about the session, not the pad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the check 77797a9e shipped an hour ago. The suppression, the log-once, and the "unknown must not suppress" rule all stand; the field it reads does not. 77797a9e read `Welcome.gamepad` — the backend the host resolved for the SESSION — and stopped sending motion when it had no motion plane. But the host does not build pads from that. It builds each virtual device from that pad's own `GamepadArrival` (`Pads::set_kind`) and falls back to the session default only for a pad that never declares one, which is precisely why `declared_kind` exists and why its doc comment says an explicit setting has to be re-declared per pad. So the check had a false negative, and it is an ordinary living-room setup. Under "Automatic" the Hello carries the ACTIVE pad's kind (`auto_pref`), so a couch with an X-Box pad on slot 0 and a DualSense on slot 1 echoes Xbox360 — while the host, reading pad 1's arrival, builds it a DualSense with a working motion plane. The old check read the echo, saw no motion plane, and killed pad 1's gyro. That is the exact failure 77797a9e's own commit message names as the worse of the two ("a false negative kills working motion"), introduced by the fix for the other one. The question is per pad, so the slot now carries what it declared, beside the physical `pref` it already held. The two are deliberately separate fields answering different questions: `pref` is the controller in the user's hands, which is what the local feedback paths must keep reading, and `declared` is the one the host is pretending to have. Three facts decide the predicate, and they are written out in `pad_motion_reaches` rather than at the call site because all three clients need the same reasoning: - the echo is not this pad's answer when the pad declared something else; - the host FOLDS what it cannot build — a Switch Pro on Windows, any UHID backend on a host whose /dev/uhid is unusable — and nothing client-side can predict it; - but the echo IS one observed sample of that fold, for the kind the Hello asked about, so it is authoritative for a pad that declared exactly that. Hence: trust the echo when declared == asked, else fall back to the declaration. That keeps both motivating cases — a generic pad under Automatic (declares X-Box 360, suppressed, the sweep's H5c) and an explicit Switch Pro folded to X-Box 360 by a Windows host (declared == asked, so the echo catches it, H5d) — where either field alone gets one of them wrong. `requested_gamepad` is kept on the client next to `resolved_gamepad` for this: the pair is what makes the echo usable per pad, and a lone field would only tempt the next reader back into the session-level question. The residual gap is a pad whose declared kind differs from the session's AND gets folded: we keep sending and the host keeps dropping. That is the direction to be wrong in, and it is what the session-level check was worth in the first place — wasted datagrams, not a dead gyro. Non-vacuity proven both directions rather than assumed. Reverting to `resolved.has_motion()` fails on the mixed-pad row; reverting to `declared.has_motion()` (no echo at all) fails on the Switch-Pro-on-Windows row. Each case in the table is a session someone can actually sit down to, and the comment on each says which of the three inputs decides it. Gate (Linux CI image, pf-lxcheck2): fmt, `build -p punktfunk-core`, `build -p pf-client-core`, `clippy --locked --all-targets -D warnings`, and both test suites — green, with the new case observed in the run's own `... ok` line rather than inferred from a green gate, and pf-client-core's 163 unchanged. --- crates/pf-client-core/src/gamepad.rs | 42 +++++++++--- crates/punktfunk-core/src/client/mod.rs | 11 ++++ crates/punktfunk-core/src/config.rs | 88 ++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 13 deletions(-) diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 133ada1d..f2aeec00 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -844,6 +844,12 @@ struct Slot { /// Resolved controller kind (captured at open) — selects the Deck rumble keep-alive and the /// DualSense raw-effect feedback path without re-querying SDL metadata under a `&mut` borrow. pref: GamepadPref, + /// The kind this slot DECLARED to the host in its [`InputKind::GamepadArrival`] + /// ([`declared_kind`] of the setting and `pref`) — what the host actually built this pad from, + /// which under `Auto` differs per pad. Captured at open beside `pref` for the same reason, and + /// kept distinct from it because the two answer different questions: `pref` is the controller + /// in the user's hands (local feedback), this is the one the host is pretending to have. + declared: GamepadPref, /// Wire axis state — zeroed on the wire when this slot closes (detach / unplug). last_axis: [i32; 6], held_buttons: Vec, @@ -881,12 +887,19 @@ struct Slot { } impl Slot { - fn new(id: u32, index: u8, pref: GamepadPref, pad: sdl3::gamepad::Gamepad) -> Slot { + fn new( + id: u32, + index: u8, + pref: GamepadPref, + declared: GamepadPref, + pad: sdl3::gamepad::Gamepad, + ) -> Slot { Slot { id, index, pad, pref, + declared, last_axis: [i32::MIN; 6], held_buttons: Vec::new(), held_touches: std::collections::HashSet::new(), @@ -1242,7 +1255,7 @@ impl Worker { let declared = declared_kind(self.kind_override, pref); match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) { Ok(pad) => { - let mut slot = Slot::new(id, index, pref, pad); + let mut slot = Slot::new(id, index, pref, declared, pad); Self::set_slot_sensors(&mut slot, true); slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad); // Declare this pad's kind BEFORE any of its input, so the host builds a matching @@ -2012,19 +2025,28 @@ impl Worker { } } SensorType::Gyroscope => { - // The host echoes the backend it actually RESOLVED, which is not - // necessarily the one we asked for: an X-Box class pad has no motion plane, - // so every sample below would be decoded and dropped. Say so once — the - // player's gyro is silently doing nothing and the fix is the controller-type - // setting — and stop paying to send ~250 Hz of them. - if !c.resolved_gamepad.has_motion() { + // An X-Box class pad has no motion plane, so every sample below would be + // decoded and dropped. Say so once — the player's gyro is silently doing + // nothing and the fix is the controller-type setting — and stop paying to + // send ~250 Hz of them. + // + // Asked PER PAD, off this slot's own declaration. The session echo alone is + // the wrong question: under `Auto` the Hello carries the active pad's kind, + // so a couch with an X-Box pad on 0 and a DualSense on 1 echoes X-Box 360 + // while the host builds pad 1 a DualSense with a working gyro. + if !punktfunk_core::config::pad_motion_reaches( + slot.declared, + c.requested_gamepad, + c.resolved_gamepad, + ) { if !slot.motion_unreachable_logged { slot.motion_unreachable_logged = true; tracing::warn!( pad = slot.index, + declared = ?slot.declared, resolved = ?c.resolved_gamepad, - "this controller has a gyro but the host session resolved a \ - backend without one — motion will not reach the game; pick a \ + "this controller has a gyro but the host built it a backend \ + without one — motion will not reach the game; pick a \ DualSense-class controller type to get it" ); } diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index 865f6b3f..7cae0619 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -325,6 +325,14 @@ pub struct NativeClient { /// The virtual gamepad backend the host actually resolved ([`Welcome::gamepad`]). /// `Auto` = an older host that didn't say (assume X-Box 360, no DualSense feedback). pub resolved_gamepad: GamepadPref, + /// The session default this client's Hello ASKED for, kept beside the host's answer above. + /// + /// The pair is what makes the echo usable per pad: the host applies the same fold to a pad's + /// own declaration as it did to this, so `resolved` is that pad's answer exactly when the pad + /// declared `requested_gamepad` — and only a guess otherwise. See + /// [`pad_motion_reaches`](crate::config::pad_motion_reaches), which is the one place that + /// reasoning lives. + pub requested_gamepad: GamepadPref, /// The encoder bitrate the host actually configured ([`Welcome::bitrate_kbps`], kbps): our /// requested rate clamped to the host's range, or its default if we requested `0`. `0` = an /// older host that didn't report it. @@ -704,6 +712,9 @@ impl NativeClient { host_fingerprint: negotiated.host_fingerprint, resolved_compositor: negotiated.compositor, resolved_gamepad: negotiated.gamepad, + // What we asked for, not what came back — the two together are what let a client ask + // the motion question per pad (see the field's doc). + requested_gamepad: gamepad, resolved_bitrate_kbps: negotiated.bitrate_kbps, shard_payload: negotiated.shard_payload, clock_offset_ns: negotiated.clock_offset_ns, diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 55438b75..118bf00f 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -194,9 +194,13 @@ impl GamepadPref { /// /// The X-Box classes have no gyro in their HID contract, so a client whose local pad HAS one /// is streaming ~250 Hz of datagrams into a void: the host parses each and discards it, and - /// the player sees a controller whose gyro silently does nothing. Read this off - /// [`Welcome::gamepad`](crate::quic::Welcome::gamepad) — the backend the host actually - /// resolved, which is not necessarily the one the client asked for. + /// the player sees a controller whose gyro silently does nothing. + /// + /// This answers for ONE backend. To ask it of a particular pad, go through + /// [`pad_motion_reaches`] — the session's [`Welcome::gamepad`](crate::quic::Welcome::gamepad) + /// echo is not that pad's answer, because the host builds each virtual device from the pad's + /// own `GamepadArrival` and falls back to the session default only for a pad that never + /// declared one. /// /// `Auto` answers `true` on purpose. It means "unknown": either a host too old to echo the /// field, or one that hasn't resolved yet. Suppressing motion on unknown would silently break @@ -303,6 +307,45 @@ impl GamepadPref { } } +/// Whether motion sent for ONE pad can reach the game: `declared` is the kind that pad announced +/// in its [`InputKind::GamepadArrival`](crate::input::InputKind::GamepadArrival), `asked` is the +/// session default the Hello carried, and `resolved` is the host's +/// [`Welcome::gamepad`](crate::quic::Welcome::gamepad) echo. +/// +/// Three facts make this a per-pad question rather than a session one: +/// +/// 1. The host builds each virtual device from that pad's arrival — `Pads::set_kind` — and uses +/// the session default only for a pad that never declares. So the echo is simply not this +/// pad's answer when the two differ. +/// 2. The host FOLDS what it cannot build (`resolve_gamepad`/`resolve_pad_kind` share one +/// `pick_gamepad`): a Switch Pro on a Windows host, or any UHID backend on a host whose +/// `/dev/uhid` is unusable, lands on X-Box 360 with the motion plane gone. Nothing local can +/// predict that. +/// 3. But the echo IS one observed sample of that fold — for the kind the Hello asked about. When +/// a pad declared exactly that kind, the host ran the same fold on the same input, so the echo +/// is authoritative for it. +/// +/// Hence: trust the echo for a pad that declared what we asked for, and otherwise fall back to +/// what the declaration alone can tell us. That keeps both motivating cases: a generic pad under +/// `Auto` (declares X-Box 360, no motion plane, suppressed) and an explicit Switch Pro folded to +/// X-Box 360 by a Windows host (declared == asked, so the echo catches it). +/// +/// The residual gap is a pad whose declared kind differs from the session's AND gets folded — we +/// keep sending, and the host keeps dropping. That is the direction to be wrong in: the failure +/// is wasted datagrams, where guessing the other way would silently kill a working gyro. +pub const fn pad_motion_reaches( + declared: GamepadPref, + asked: GamepadPref, + resolved: GamepadPref, +) -> bool { + // `==` on a fieldless enum, spelled as a match because PartialEq::eq is not const. + if declared.to_u8() == asked.to_u8() { + resolved.has_motion() + } else { + declared.has_motion() + } +} + /// Per-block FEC parameters. Recovery count is derived from `fec_percent` exactly as /// GameStream does: `m = ceil(k * fec_percent / 100)`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -814,6 +857,45 @@ mod tests { assert!(GamepadPref::Auto.has_motion()); } + /// The per-pad question, case by case. Each row is a session a player can actually sit down + /// to; the comment says which of the three inputs decides it. + #[test] + fn motion_reach_is_answered_per_pad_not_per_session() { + use GamepadPref::*; + // The case this predicate exists for, and the one a session-level check gets WRONG: + // "Automatic" with mixed pads. The Hello carries the active pad's kind (an X-Box pad), so + // the echo says X-Box 360 — but pad 1 declared a DualSense and the host built it one, with + // a motion plane. Reading the echo here kills a gyro that works. + assert!(pad_motion_reaches(DualSense, Xbox360, Xbox360)); + // Its mirror: the pad that DID declare the X-Box kind still has nowhere to put motion. + assert!(!pad_motion_reaches(Xbox360, Xbox360, Xbox360)); + + // A generic pad (8BitDo &c.) under Automatic — the sweep's motivating case. Detection + // lands on X-Box 360, the pad declares it, and its gyro has no plane to reach. + assert!(!pad_motion_reaches(Xbox360, Xbox360, Xbox360)); + + // An explicit Switch Pro against a WINDOWS host, which folds it to X-Box 360. Declared == + // asked, so the echo is this pad's answer and catches a fold nothing local could predict. + assert!(!pad_motion_reaches(SwitchPro, SwitchPro, Xbox360)); + // The same declaration against a Linux host that builds it: unchanged, motion reaches. + assert!(pad_motion_reaches(SwitchPro, SwitchPro, SwitchPro)); + + // A DualSense wish on a host with no usable /dev/uhid degrades the same way. + assert!(!pad_motion_reaches(DualSense, DualSense, Xbox360)); + + // Nobody connected at dial time, so the Hello asked `Auto` and the host resolved it from + // its own env. A pad that shows up later declares its own kind and is judged on that — + // whichever way the session went. + assert!(pad_motion_reaches(DualSense, Auto, Xbox360)); + assert!(!pad_motion_reaches(Xbox360, Auto, DualSense)); + + // An old host that echoes nothing leaves `Auto`, which must not suppress: it may well have + // resolved a DualSense, and silently killing gyro is the worse of the two failures. + assert!(pad_motion_reaches(DualSense, DualSense, Auto)); + // Even then the declaration still speaks when it is the thing without a plane. + assert!(!pad_motion_reaches(Xbox360, DualSense, Auto)); + } + #[test] fn gamepad_pref_wire_and_names() { for p in [ From aaa58ad8178cf1aea27a1a52c392a993fa5dc17c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 16:55:03 +0200 Subject: [PATCH 11/22] feat(client/apple): say when a pad's gyro can't reach the session, and stop powering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G8's Apple half — the UI hint 77797a9e left owed, plus the suppression, which on this client is worth more than it was on the SDL one. The failure being fixed is entirely silent. A controller with a gyro, in a session whose virtual pad has no motion plane, simply does nothing when tilted: nothing in the app says so, and from the couch a session that resolved an X-Box backend is indistinguishable from a broken sensor. The fix is the Controller type setting, so the hint has to name it — a badge that only said "motion unavailable" would leave the player exactly as stuck. Asked per pad, off what the slot declared, via the predicate punktfunk-core now carries. `GamepadCapture` is the one client where this is naturally per pad already: `openSlot` computes `manager.declaredKind(for:)` and puts it in `slot.pref`, so the question is answered where the pad is opened rather than on every sample. `GamepadType.motionReaches(declared:asked:resolved:)` is static and pure so it can be tested without a live session; the connection's instance method fills in the two halves it owns, and `requestedGamepad` is stored beside `resolvedGamepad` for the same reason it exists in the Rust client — the echo is only this pad's answer when the pad declared what we asked for. Where Apple differs from the SDL client, and better: it never powers the IMU. The existing code already declined to activate sensors when forwarding was off, reasoning that with nothing to forward there is no reason to make the pad stream gyro over Bluetooth and burn its battery — `closeSlot` is careful to power them back down for exactly that reason. A host that built this pad a backend without a motion plane is the same situation, so it takes the same branch. No per-sample check, no handler attached, and a DualSense in an X-Box-class session stops paying for a sensor nobody reads. The hint fires only for a pad that really has a gyro (`motion.hasRotationRate`). A gravity-only GCMotion — what an X-Box controller exposes — would otherwise produce a notice about a feature the player never had. That is a narrower condition than the capture path itself uses, deliberately: making the capture gate agree is G13's job and its own change. The badge sits in the bottom-centre stack with the muted-mic badge and the start-of-stream banner, at every stats tier and with the overlay off, because this is not a statistic. Unlike the mic badge it is not a control: the setting is not reachable mid-stream on every platform and applies from the next session anyway. So it states the fact, names the setting, and leaves after the banner's same 6 s. Every platform including tvOS — a DualSense on an Apple TV is an ordinary way to play, and is exactly the pad this happens to. The model owns the expiry rather than the view, so a second pad's hint replaces the first cleanly instead of stacking, and ending the session cancels a pending clear rather than carrying a stale hint into the next stream. Non-vacuity proven by mutation, not assumed: collapsing the predicate to `resolved.hasMotion` fails 4 assertions, including the mixed-pad row that is the whole reason it is not a session-level check. The table mirrors the Rust one row for row — a client that disagrees with the host here either kills a working gyro or streams ~250 Hz into a void, and both are silent. Gate: macOS `swift build` + the FULL suite (210 tests, 5 skipped, 0 failures) with the two new cases observed in the run's own output, and the iOS-triple typecheck green (`arm64-apple-ios17.0`, iOS slices + hand-assembled xcframework per the memory recipe) — the badge and the overlay it joins are on every platform, so the macOS build alone would not have covered them. tvOS remains unverifiable from this Mac; the badge deliberately reuses the neighbouring banner's shape rather than introducing anything tvOS-specific. --- .../Sources/PunktfunkClient/ContentView.swift | 9 +++ .../Session/SessionModel.swift | 37 +++++++++++ .../Session/StreamHUDView.swift | 33 ++++++++++ .../Connection/PunktfunkConnection.swift | 60 ++++++++++++++++++ .../PunktfunkKit/Gamepad/GamepadCapture.swift | 29 ++++++++- .../GamepadMotionReachTests.swift | 62 +++++++++++++++++++ 6 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 clients/apple/Tests/PunktfunkKitTests/GamepadMotionReachTests.swift diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 2b16327b..32b306f2 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -764,6 +764,15 @@ struct ContentView: View { // other in the seconds where they overlap. .overlay(alignment: .bottom) { VStack(spacing: 8) { + // A forwarded pad has a gyro this session's virtual controller cannot + // carry. Shown briefly at every stats tier and with the overlay off: the + // failure is otherwise completely silent — the gyro just does nothing — + // and the fix is a setting, so the hint has to name it. Every platform, + // including tvOS, where a DualSense is an ordinary way to play. + if captureEnabled, model.motionUnreachableKind != nil { + MotionUnreachableBadge() + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } #if !os(tvOS) // Shown for as long as the mic is muted, at every stats tier and with the // overlay off — see MicMutedBadge. tvOS has no microphone to mute. diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 86dbb4cc..58c1813f 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -153,6 +153,20 @@ final class SessionModel: ObservableObject { /// background's privacy mute never clears the user's choice. Local and instant: it gates /// capture on this device, nothing is sent to the host. @Published private(set) var micMuted = false + /// The kind a controller declared when it turned out this session cannot carry its motion — + /// set once per such pad, cleared after `motionHintSeconds`. Nil the rest of the time. + /// + /// It exists because the failure is otherwise entirely silent: the gyro simply does nothing, + /// with no way for the player to tell a dead sensor from a session that resolved a backend + /// without a motion plane. The fix is a settings change, so the hint has to name it. + @Published private(set) var motionUnreachableKind: PunktfunkConnection.GamepadType? + /// Drops `motionUnreachableKind` again — held so a second pad's hint replaces the first + /// cleanly, and so ending the session cancels a pending clear rather than letting it fire + /// into a torn-down model. + private var motionHintTimer: Task? + /// How long the motion hint stays up — the start-of-stream shortcut banner's 6 s, since the + /// two share the bottom-centre stack and a player reads them the same way. + private static let motionHintSeconds: UInt64 = 6 /// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the /// instant a Match-window resize starts steering toward a new size until a frame at that size /// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay @@ -524,6 +538,21 @@ final class SessionModel: ObservableObject { applyMicMute() } + /// A forwarded controller has a gyro this session cannot carry (see + /// `GamepadCapture.onMotionUnreachable`). Show it briefly, then let it go. + /// + /// Last pad wins, and its timer restarts: two such pads are the same one fact to a player, and + /// a second hint appearing under a still-visible first would only read as a stutter. + private func noteMotionUnreachable(_ kind: PunktfunkConnection.GamepadType) { + motionUnreachableKind = kind + motionHintTimer?.cancel() + motionHintTimer = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.motionHintSeconds)) + guard !Task.isCancelled else { return } + self?.motionUnreachableKind = nil + } + } + /// Push the EFFECTIVE mute — the user's choice OR the background keep-alive's privacy mute — /// onto the audio engine. The two reasons are composed here and nowhere else: whichever one /// changed, the other still holds, so returning from the background can't un-mute a user who @@ -573,6 +602,11 @@ final class SessionModel: ObservableObject { // The mic mute is per-session and never persisted: the next stream starts live (if the // mic is enabled), rather than silently carrying a mute nobody remembers making. micMuted = false + // Cancel before clearing: a pending clear firing into a torn-down session would be + // harmless but pointless, and leaving the hint set would carry it into the next stream. + motionHintTimer?.cancel() + motionHintTimer = nil + motionUnreachableKind = nil let audio = self.audio self.audio = nil // Gamepad capture is main-actor (releases held buttons on the wire while the @@ -722,6 +756,9 @@ final class SessionModel: ObservableObject { // The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only // controller way out of a stream (B/Menu is swallowed during sessions; see ContentView). capture.onDisconnectRequest = { [weak self] in self?.disconnect() } + // A pad with a gyro that this session cannot carry — say so once, briefly, and name the + // setting that fixes it. Already main-actor (GamepadCapture fires it there). + capture.onMotionUnreachable = { [weak self] kind in self?.noteMotionUnreachable(kind) } capture.start() gamepadCapture = capture let feedback = GamepadFeedback(connection: conn, manager: .shared) diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index 41606725..4f00da49 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -267,6 +267,39 @@ struct StreamHUDView: View { } } +/// "This pad's gyro can't reach the game" — shown briefly when a forwarded controller with motion +/// meets a session whose virtual controller has no motion plane (an X-Box class pad has no gyro in +/// its HID contract, so every sample would be decoded and dropped). +/// +/// Not a control, unlike `MicMutedBadge`: the fix is the Controller type setting, which is not +/// reachable mid-stream on every platform, and changing it applies from the next session anyway. +/// So this states the fact and names the setting, in the HUD's glass language, and gets out of the +/// way — the alternative is what shipped before, which was a gyro that silently did nothing with +/// no way to tell that from a broken sensor. +/// +/// Every platform: a DualSense on an Apple TV is an ordinary way to play, and it is exactly the +/// pad this can happen to. +struct MotionUnreachableBadge: View { + var body: some View { + HStack(spacing: 7) { + Image(systemName: "gyroscope") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.yellow) + Text("Motion won't reach this session — set Controller type to DualSense") + .font(.geist(12, .medium, relativeTo: .caption)) + .foregroundStyle(.white.opacity(0.9)) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .glassBackground(Capsule()) + .environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay + .accessibilityElement(children: .combine) + .accessibilityLabel( + "This controller's motion will not reach the game. " + + "Set Controller type to DualSense to enable it.") + } +} + #if !os(tvOS) /// The muted-microphone badge — the mute STATE, as opposed to the buttons that flip it. It rides /// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 350b44f6..94e68868 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -311,6 +311,51 @@ public final class PunktfunkConnection { default: return nil } } + + /// Whether this backend has a motion plane at all — whether a `sendMotion` sample to a + /// host running it can reach the game, or is decoded and dropped. Mirrors the host's + /// `GamepadPref::has_motion`; the X-Box classes have no gyro in their HID contract. + /// + /// This answers for ONE backend. To ask it of a particular pad, go through + /// `PunktfunkConnection.motionReaches(declared:)` — `resolvedGamepad` is not that pad's + /// answer, because the host builds each virtual device from the pad's own + /// `gamepadArrival` and falls back to the session default only for a pad that never + /// declared one. + /// + /// `.auto` answers `true` on purpose: it means "unknown" — an older host that omitted the + /// echo, which may well have resolved a DualSense. Suppressing on unknown would silently + /// break a working gyro, which is the worse of the two failures. + public var hasMotion: Bool { + switch self { + case .auto: return true // unknown; assume it can, see above + case .xbox360, .xboxOne: return false + case .dualSense, .dualShock4, .dualSenseEdge, .switchPro, + .steamController, .steamDeck, .steamController2: + return true + } + } + + /// Whether motion sent for ONE pad can reach the game: `declared` is the kind that pad + /// announced in its `gamepadArrival`, `asked` is the session default the handshake carried, + /// and `resolved` is the host's echo. Mirrors punktfunk-core's `pad_motion_reaches`, which + /// carries the full argument; in short: + /// + /// - the host builds each virtual device from that pad's declaration, so the echo is simply + /// not this pad's answer when the two differ (under "Automatic" the handshake carries the + /// ACTIVE pad's kind, so a couch with an X-Box pad and a DualSense echoes X-Box 360 while + /// the host builds the DualSense a working motion plane); + /// - the host FOLDS what it cannot build — a Switch Pro on Windows, a UHID backend on a + /// host whose `/dev/uhid` is unusable — and nothing here can predict that; + /// - but the echo IS one observed sample of that fold, for the kind we asked about, so it + /// is authoritative for a pad that declared exactly that. + /// + /// Static and pure so it can be tested without a live session; the connection's + /// `motionReaches(declared:)` is the call site that fills in the other two. + public static func motionReaches( + declared: GamepadType, asked: GamepadType, resolved: GamepadType + ) -> Bool { + declared == asked ? resolved.hasMotion : declared.hasMotion + } } /// The virtual gamepad backend the host actually resolved (the Welcome's echo of the @@ -318,6 +363,18 @@ public final class PunktfunkConnection { /// DualSense feedback. public private(set) var resolvedGamepad: GamepadType = .auto + /// The session default this connection's handshake ASKED for, kept beside the host's answer + /// above. The pair is what makes the echo usable per pad — see `motionReaches(declared:)`. + public private(set) var requestedGamepad: GamepadType = .auto + + /// Whether motion sent for ONE pad can reach the game, given the kind that pad DECLARED in its + /// `gamepadArrival` (`GamepadManager.declaredKind(for:)`) — this session's two halves of + /// `GamepadType.motionReaches(declared:asked:resolved:)`, which carries the reasoning. + public func motionReaches(declared: GamepadType) -> Bool { + GamepadType.motionReaches( + declared: declared, asked: requestedGamepad, resolved: resolvedGamepad) + } + /// The compositor the host actually resolved for this session's virtual output (the /// Welcome's echo of the requested `compositor`, with `.auto` resolved to a concrete /// backend). `.auto` = an older host that didn't say. Clients use it to decide @@ -572,6 +629,9 @@ public final class PunktfunkConnection { var gp: UInt32 = 0 _ = punktfunk_connection_gamepad(handle, &gp) resolvedGamepad = GamepadType(rawValue: gp) ?? .auto + // What we asked for, straight off the parameter — the echo above only speaks for a pad + // that declared this same kind (see `motionReaches(declared:)`). + requestedGamepad = gamepad var comp: UInt32 = 0 _ = punktfunk_connection_compositor(handle, &comp) resolvedCompositor = Compositor(rawValue: comp) ?? .auto diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 01cd7a47..e655ebaa 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -128,6 +128,15 @@ public final class GamepadCapture { /// gameplay can't end it (see ContentView's tvOS session branch). public var onDisconnectRequest: (() -> Void)? + /// Fired ON MAIN, once per slot at open, when a controller that HAS a gyro was given a host + /// backend without a motion plane — its motion is not being sent, because every sample would + /// be decoded and dropped. The argument is the kind this pad declared, so the UI can name it. + /// + /// It fires at open rather than on the first sample precisely because nothing is sampled: the + /// IMU is never powered in this case (see `openSlot`), which is also what stops the pad + /// burning battery streaming gyro nobody reads. + public var onMotionUnreachable: ((PunktfunkConnection.GamepadType) -> Void)? + /// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`, /// default true). Off is for a couch whose controller reaches the host another way — USB /// passthrough such as VirtualHere, or a pad plugged into the host itself — where @@ -299,10 +308,24 @@ public final class GamepadCapture { // local feature reads it. Powering the IMU anyway costs the pad real battery (it streams // gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power // it back down), so with nothing to forward we simply never turn it on. + // + // A host that built this pad a backend WITHOUT a motion plane is the same situation: every + // sample would be decoded and dropped, so there is equally nothing to forward. Asked per + // pad off what this slot declared, not off the session echo — under "Automatic" a couch + // with an X-Box pad on 0 and a DualSense on 1 echoes X-Box 360 while the host builds pad 1 + // a DualSense whose gyro works. + let motionCanReach = connection.motionReaches(declared: slot.pref) if forwarding, let motion = c.motion { - if motion.sensorsRequireManualActivation { motion.sensorsActive = true } - motion.valueChangedHandler = { [weak self, weak slot] m in - MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } } + if motionCanReach { + if motion.sensorsRequireManualActivation { motion.sensorsActive = true } + motion.valueChangedHandler = { [weak self, weak slot] m in + MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } } + } + } else if motion.hasRotationRate { + // Only for a pad that really has a gyro. A gravity-only pad (an X-Box controller's + // GCMotion) has nothing the player could expect to reach the game, so telling them + // it didn't would be a nag about a feature they never had. + onMotionUnreachable?(slot.pref) } } } diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadMotionReachTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadMotionReachTests.swift new file mode 100644 index 00000000..c979bec8 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadMotionReachTests.swift @@ -0,0 +1,62 @@ +// Whether a given pad's motion can reach the game. The Swift half of punktfunk-core's +// `pad_motion_reaches` — same rows as `config::tests::motion_reach_is_answered_per_pad_not_per_session`, +// because a client that disagrees with the host about this either kills a working gyro or keeps +// streaming ~250 Hz of samples nobody reads, and both failures are silent. + +import PunktfunkCore +import XCTest + +@testable import PunktfunkKit + +final class GamepadMotionReachTests: XCTestCase { + private typealias Pad = PunktfunkConnection.GamepadType + + func testOnlyTheXboxClassesLackAMotionPlane() { + for kind: Pad in [.xbox360, .xboxOne] { + XCTAssertFalse(kind.hasMotion, "\(kind) should have no motion plane") + } + for kind: Pad in [ + .dualSense, .dualShock4, .dualSenseEdge, .switchPro, + .steamController, .steamDeck, .steamController2, + ] { + XCTAssertTrue(kind.hasMotion, "\(kind) should carry motion") + } + // Unknown must not suppress: an older host that omitted the echo may well have resolved a + // DualSense, and silently killing its gyro is worse than sending into a void. + XCTAssertTrue(Pad.auto.hasMotion) + } + + /// The per-pad question, case by case. Each row is a session a player can actually sit down to; + /// the comment says which of the three inputs decides it. + func testMotionReachIsAnsweredPerPadNotPerSession() { + // The case this predicate exists for, and the one a session-level check gets WRONG: + // "Automatic" with mixed pads. The handshake carries the active pad's kind (an X-Box pad), + // so the echo says X-Box 360 — but pad 1 declared a DualSense and the host built it one, + // with a motion plane. Reading the echo here kills a gyro that works. + XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .xbox360, resolved: .xbox360)) + // Its mirror: the pad that DID declare the X-Box kind still has nowhere to put motion. + XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .xbox360, resolved: .xbox360)) + + // An explicit Switch Pro against a WINDOWS host, which folds it to X-Box 360. Declared == + // asked, so the echo is this pad's answer and catches a fold nothing local could predict. + XCTAssertFalse( + Pad.motionReaches(declared: .switchPro, asked: .switchPro, resolved: .xbox360)) + // The same declaration against a Linux host that builds it: unchanged, motion reaches. + XCTAssertTrue( + Pad.motionReaches(declared: .switchPro, asked: .switchPro, resolved: .switchPro)) + + // A DualSense wish on a host with no usable /dev/uhid degrades the same way. + XCTAssertFalse( + Pad.motionReaches(declared: .dualSense, asked: .dualSense, resolved: .xbox360)) + + // Nobody connected at dial time, so the handshake asked `.auto` and the host resolved it + // from its own env. A pad that shows up later declares its own kind and is judged on that. + XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .auto, resolved: .xbox360)) + XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .auto, resolved: .dualSense)) + + // An old host that echoes nothing leaves `.auto`, which must not suppress. + XCTAssertTrue(Pad.motionReaches(declared: .dualSense, asked: .dualSense, resolved: .auto)) + // Even then the declaration still speaks when it is the thing without a plane. + XCTAssertFalse(Pad.motionReaches(declared: .xbox360, asked: .dualSense, resolved: .auto)) + } +} From 7cab7ae6bcd396f5806227ff0be2943ce1cd21b2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 17:02:04 +0200 Subject: [PATCH 12/22] feat(client/android): say when a captured pad's gyro can't reach the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G8's Android half, and the last of the three clients. Same failure as the other two: a controller with a gyro, in a session whose virtual pad has no motion plane, does nothing when tilted — silently, with no way from the couch to tell that apart from a broken sensor. The fix is the Controller type setting, so the notice names it. Android read neither the requested nor the resolved backend, so this needed a plumb. What it did NOT need was a third copy of the rule. `nativePadMotionReaches` takes the kind a pad declared and answers off `pad_motion_reaches` in punktfunk-core, where the argument and the tests already live. The rule is subtler than it looks — the host builds each pad from its OWN declaration and folds what it cannot build, so neither the declaration nor the session echo answers it alone — and every way of getting it wrong is silent. A Kotlin transcription would have been a third thing to keep in step with the host, which is exactly how the SDL half got it wrong the first time. Asked once per pad, at claim, in `openExternal` — where the pad's kind is already being declared to the host — and the answer held for the pad's lifetime on the `ExternalPad`. Not per sample: this runs at a DualSense's full report rate. `hasGyro` gates only the NOTICE, and defaults to false. `DsCapture` passes true — every pad it captures is a Sony one whose IMU is a headline feature, forwarded on the rich plane. `Sc2Capture` keeps the default, because the Steam Controller 2's motion rides inside the opaque passthrough report that `hidReport` carries, which nothing here may second-guess: warning about motion for a pad that never calls `motion()` would be a notice about a feature the player never lost. The suppression itself is on `motion()` regardless, where it costs a dead pad nothing and stops a live one paying to send samples the host will decode and discard. The notice sits at the BOTTOM of the stream overlay, unlike the mic-chord confirmation at the top. The two can coincide — a pad is claimed at roughly the moment someone might be muting — and one landing on the other would cost the user both. It holds 6 s rather than the mic chord's 1.6: that one confirms something the user just did, this one explains something they did not, in a sentence they have to read. Nulled at teardown beside `onExitArmed`/`onMicChord`, for the same reason those are — a slot closing during release must not poke Compose state on the way out. Not covered by tests, and this is a limit of the module rather than a choice: `GamepadRouter` needs Android plus a live JNI handle, there is no Robolectric here, and the predicate it defers to is pure Rust that already has its table. So the parts that carry the reasoning are argued in comments, as `DsCapture`'s claim/teardown ordering already is. What IS mechanically verified is the piece that a compiler cannot catch and a device would fail on: the JNI symbol `Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches` is present and global in the built arm64-v8a `.so`, so the `external fun` resolves rather than throwing `UnsatisfiedLinkError` at the first pad. Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` (62 cases, 0 failed, read out of the JUnit XML rather than inferred from a green build — unchanged from this branch's previous count), `:app:compileDebugKotlin` and `:app:testDebugUnitTest` (67 cases, 0 failed), with `:kit:cargoNdkRelease` rebuilding the JNI crate clean across all three ABIs, plus `cargo fmt --check` on it. On-glass verification is owed on the rig the earlier legs used, and is worth doing as one pass with the two already owed there. --- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 44 +++++++++++++++++++ .../kotlin/io/unom/punktfunk/kit/DsCapture.kt | 4 +- .../io/unom/punktfunk/kit/GamepadRouter.kt | 41 +++++++++++++++-- .../io/unom/punktfunk/kit/NativeBridge.kt | 17 +++++++ clients/android/native/src/session/input.rs | 34 ++++++++++++++ 5 files changed, 135 insertions(+), 5 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 600804b7..b52a8b56 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -137,6 +137,19 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U micHint = null } } + // A captured pad has a gyro this session's virtual controller cannot carry (see + // GamepadRouter.onMotionUnreachable). Shown briefly, then gone: the failure is otherwise + // completely silent — the gyro simply does nothing, which from the couch is indistinguishable + // from a broken sensor — and the fix is a setting, so the notice has to name it. + var motionHint by remember { mutableStateOf(false) } + LaunchedEffect(motionHint) { + if (motionHint) { + // Longer than the mic chord's 1.6 s: that one confirms something the user just did, + // this one explains something they did not, in a sentence they have to read. + delay(6000) + motionHint = false + } + } // The one place mute is toggled — Compose state + the native flag, always together. val setMicMuted = { muted: Boolean -> micMuted = muted @@ -359,6 +372,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U // Select + Y toggles the mic — the couch reach for the on-screen mute button, which a // gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing // to mute, and claiming otherwise would be the lie the control exists to avoid). + // A captured Sony pad whose motion this session cannot carry. Fires once per pad, at the + // moment it is claimed, on the main thread. + router.onMotionUnreachable = { motionHint = true } router.onMicChord = { if (micRunning) { val next = !micMuted @@ -593,6 +609,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down router.onMicChord = null // same: no mute toggle on buttons released during teardown + router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener activity?.gamepadRouter = null // Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor. @@ -853,6 +870,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U } // Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger. micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) } + // Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a + // notice landing on top of one of those would cost the user both. + if (motionHint) { + MotionUnreachableHint(Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp)) + } } } @@ -939,6 +961,28 @@ private fun MicChordHint(text: String, modifier: Modifier = Modifier) { ) } +/** + * "This pad's gyro can't reach the game" — shown briefly when a captured controller with motion + * meets a session whose virtual pad has no motion plane (the X-Box classes have no gyro in their + * HID contract, so every sample would be decoded and dropped host-side). + * + * It names the setting because that is the whole point: without it the player has a gyro that + * silently does nothing and no way to tell that from a broken sensor. Not a control — the setting + * applies from the next session, so offering to change it here would promise something this stream + * cannot deliver. [GamepadRouter.onMotionUnreachable] raises it. + */ +@Composable +private fun MotionUnreachableHint(modifier: Modifier = Modifier) { + Text( + "Motion won't reach this session — set Controller type to DualSense", + modifier = modifier + .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + color = Color.White, + fontSize = 15.sp, + ) +} + /** * The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The * chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index 789cb214..58406de4 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -289,7 +289,9 @@ class DsCapture( @Synchronized private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? { pad?.let { return it } - val p = router.openExternal(m.pref) ?: return null + // hasGyro: every pad this link captures is a Sony one with an IMU, and its motion goes out + // on the rich plane — so a session that cannot carry it is worth saying out loud. + val p = router.openExternal(m.pref, hasGyro = true) ?: return null pad = p Log.i(TAG, "captured $m → wire pad ${p.index}") // The wire index exists from here on, and the host addresses pad audio by it. diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 7fe1f02d..ff4b693f 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -115,6 +115,17 @@ class GamepadRouter( */ var onMicChord: (() -> Unit)? = null + /** + * Invoked (main thread) once per pad when a captured controller WITH a gyro turns out to be in + * a session whose virtual pad has no motion plane — its motion is not being sent, because every + * sample would be decoded and dropped host-side. + * + * It exists because the failure is otherwise completely silent: the gyro just does nothing, and + * from the couch that is indistinguishable from a broken sensor. The fix is the Controller type + * setting, so whatever shows this has to name it. `StreamScreen` wires it to a brief notice. + */ + var onMotionUnreachable: (() -> Unit)? = null + private val mainHandler = Handler(Looper.getMainLooper()) /** The pending exit-chord hold timer, or null when the chord isn't currently armed. */ private var pendingExit: Runnable? = null @@ -326,7 +337,18 @@ class GamepadRouter( * the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state * flush + Remove on [close], and full participation in the emergency exit chord. */ - inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) { + inner class ExternalPad internal constructor( + private val syntheticId: Int, + val index: Int, + /** + * Whether this pad's motion can reach the game at all, asked once at open (see + * [NativeBridge.nativePadMotionReaches]). False means the host built this pad a backend + * without a motion plane, so [motion] drops the sample here instead of paying to send one + * the host will decode and discard — at a controller's full report rate, for the whole + * session. + */ + private val motionReaches: Boolean, + ) { // Live lookup instead of a captured reference: after [close] (or a router release) the // slot is gone from the table and every entry point below degrades to a safe no-op. private val slot get() = slots[syntheticId] @@ -357,7 +379,7 @@ class GamepadRouter( /** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16 * units — the host passes them straight into the virtual pad's report). Per report. */ fun motion(gyro: IntArray, accel: IntArray) { - if (slot != null && forwarding) { + if (slot != null && forwarding && motionReaches) { NativeBridge.nativeSendPadMotion( handle, index, gyro[0], gyro[1], gyro[2], @@ -373,15 +395,26 @@ class GamepadRouter( /** * Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire * indices are taken. Main thread (like the hot-plug callbacks). + * + * [hasGyro] says whether this link forwards motion on the RICH plane ([ExternalPad.motion]) — + * true for the Sony pads, whose IMU is a headline feature, and false for the Steam Controller 2, + * whose motion rides inside the opaque passthrough report that [ExternalPad.hidReport] carries + * and which nothing here may second-guess. It gates only the notice: a pad that never sends + * motion must not produce a warning about motion. */ - fun openExternal(pref: Int): ExternalPad? { + fun openExternal(pref: Int, hasGyro: Boolean = false): ExternalPad? { val index = lowestFreeIndex() ?: return null // Synthetic ids live below any real InputDevice id (those are positive), so they can't // collide and InputDevice.getDevice(id) resolves them to null for the feedback path. val syntheticId = EXTERNAL_ID_BASE - index if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index) + // Asked once, here, off the kind this pad just DECLARED — not off the session's resolved + // backend, which under Automatic answers for whichever pad happened to be active at dial + // time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime. + val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref) + if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke() slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index)) - return ExternalPad(syntheticId, index) + return ExternalPad(syntheticId, index, motionReaches) } /** diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 77513e1f..07ac6c30 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -516,6 +516,23 @@ object NativeBridge { /** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */ external fun nativeSendGamepadRemove(handle: Long, pad: Int) + /** + * Whether motion sent for a pad that declared [declaredPref] (the [Gamepad].PREF_* byte passed + * to [nativeSendGamepadArrival]) can actually reach the game, or would be decoded and dropped + * by a host backend without a motion plane — the X-Box classes have no gyro in their HID + * contract. + * + * Answered natively, off `punktfunk_core::config::pad_motion_reaches`, rather than + * reconstructed here from the session's requested/resolved prefs. The rule is subtler than it + * looks (the host builds each pad from its OWN declaration and folds what it cannot build, so + * neither the declaration nor the session echo answers it alone) and every way of getting it + * wrong is silent, so it lives in one place with one set of tests. + * + * Ask ONCE when a pad opens, not per sample. `true` when the session handle is dead — "don't + * suppress" is the safe answer whenever we cannot tell. + */ + external fun nativePadMotionReaches(handle: Long, declaredPref: Int): Boolean + /** * One raw HID input report from a client-captured controller (the as-is Steam Controller 2 * passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose diff --git a/clients/android/native/src/session/input.rs b/clients/android/native/src/session/input.rs index 4a01acb6..d5804b1f 100644 --- a/clients/android/native/src/session/input.rs +++ b/clients/android/native/src/session/input.rs @@ -361,6 +361,40 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad ); } +/// `NativeBridge.nativePadMotionReaches(handle, declaredPref)` — whether motion sent for a pad that +/// declared `declaredPref` (the `GamepadPref` wire byte it passed to `nativeSendGamepadArrival`) can +/// actually reach the game, or would be decoded and dropped by a host backend with no motion plane. +/// +/// The whole question is answered here rather than in Kotlin so the reasoning lives in exactly one +/// place — [`punktfunk_core::config::pad_motion_reaches`], which carries the argument and the tests. +/// A third transcription of it would be a third thing to get subtly wrong, and every way of getting +/// it wrong is silent: too strict kills a working gyro, too lax keeps ~250 Hz of samples flowing +/// into a host that drops every one. +/// +/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching +/// the `Auto` rule inside the predicate itself. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches( + _env: JNIEnv, + _this: JObject, + handle: jlong, + declared_pref: jint, +) -> jboolean { + if handle == 0 { + return 1; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract; both fields are plain Copy + // values read behind `&self`. + let h = unsafe { &*(handle as *const SessionHandle) }; + let declared = + punktfunk_core::config::GamepadPref::from_u8(declared_pref.clamp(0, u8::MAX as jint) as u8); + u8::from(punktfunk_core::config::pad_motion_reaches( + declared, + h.client.requested_gamepad, + h.client.resolved_gamepad, + )) +} + /// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was /// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the /// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the From efb7f99129e4a1509374fbf54a21c59a128e68db Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 18:41:54 +0200 Subject: [PATCH 13/22] =?UTF-8?q?fix(client/apple):=20motion=20arrived=20i?= =?UTF-8?q?n=20the=20wrong=20frame=20=E2=80=94=20measured=20against=20a=20?= =?UTF-8?q?real=20pad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G16 step 1, and the second half of what 9e9bb9f4 started. That commit fixed the SIGN of acceleration (Apple reports the gravity vector, pointing down; a pad reports proper acceleration, pointing up). This fixes the FRAME, which is a separate defect and was never going to show up as an inverted axis — it shows up as roll where the game reads yaw. The wire is a unit passthrough. `dualsense_proto::write_report` puts gyro[0..3] and accel[0..3] straight into the virtual pad's report bytes 16.. and 22.., in order, with no permutation — the same slots a real DualSense fills. So the frame the wire is DEFINED in is the pad's own report frame, and forwarding GameController's x/y/z unconverted was speaking a different language with the same vocabulary. Both frames measured 2026-08-07 from ONE physical DualSense on one desk, read twice — over raw HID and through GameController — so this is two readings of the same controller in the same orientations rather than two documents: DualSense report frame: (Right, Up, Backward) axis 0 pitch, 1 yaw, 2 roll GameController frame: (Right, Forward, Up) Right is already slot 0; Up is GC's z and moves to slot 1; slot 2 wants Backward, which is GC's y negated. Hence (x, z, -y), applied to gyro AND acceleration because it is a change of basis and both live in that basis. Notable: the wire's documented naming was right all along — gyro[0]=pitch, [1]=yaw, [2]=roll is exactly what the hardware does. And Android needs no remap at all: it forwards the pad's own axis order un-remapped, which is correct. Its old reading was purely the scale bug f6de620f fixed. Only Apple was converting nothing. How the hardware frame was established, since a wrong frame here is invisible. Gravity at rest put +0.997 g on axis 1. Yaw clockwise-from-above drove axis 1 negative (98% of the rotation), pitch nose-down drove axis 0 negative (100%), roll right-side-down drove axis 2 negative (95%) — plain right-hand rule, and (a0 x a1 = a2) confirms the triad is right-handed. The accelerometer then corroborated the gyro's assignment independently: under pitch-down axis 2 rose 0.160 -> +0.339 (nose down raises the back, so world-up gains a Backward component) and under roll-right-down axis 0 went +0.021 -> -0.197, while yaw left acceleration untouched. Two different physical quantities agreeing on one triad. Apple's frame took four attempts, and the failures are worth recording because each was a different way to be confidently wrong: - peak |w| over a window containing BOTH the tip-down and the return stroke can record the return, with the opposite sign. Yaw (a continuous one-way spin) was unaffected; pitch and roll were exactly the two that disagreed with everything else. - reading `gravity + userAcceleration` when `hasGravityAndUserAcceleration` is FALSE yields a constant (0,0,1) in every orientation. It looks like data. The tell is that it never moves. The client's own else-branch on `m.acceleration` is the correct read and is what the instrument now mirrors. - `da/dt = -w x a` holds only for gravity, so testing it during vigorous waving — when `m.acceleration` carries inseparable linear acceleration — fits nothing. The frame that survived all of that: static poses, three of them, three repetitions each. Nose-down moved axis 1 by -0.635 (so axis 1 is Forward), right-side-down moved axis 0 by -0.686 (so axis 0 is Right), flat put +0.99 on axis 2 (Up). That conclusion holds whether or not the acceleration negation is right, because negating flips the measured vector and the physical direction it represents together. Confidence, stated honestly. The accelerometer half is solid: nine pose measurements, and mapping the flat pose through gives (+0.005, +0.992, +0.192) against the hardware's own (+0.021, +0.997, +0.160) — all three components, including the small tilt term that is what distinguishes this mapping from the five other permutations that also put gravity on slot 1. That the gyro shares the frame unmodified rests on a weaker measurement: a gravity-dominated consistency test that preferred (+x,+y,+z) by 1.22x, which is a margin, not a landslide. It is corroborated by the yaw reading (the one rotation measured without the return-stroke ambiguity) agreeing with right-hand rule in that frame, and by the peak-vs-return mechanism explaining the two that did not. A device-side confirmation is still owed and is listed below. The tests carry the measurements, not just the conclusion. Resting gravity is asserted against BOTH readings of that pose; each rotation is asserted to reach the slot the wire reads it from; and two properties guard the shape rather than the numbers — that the conversion is an isometry (a basis change may not stretch anything) and that it preserves handedness. That last one matters most: a permutation with the wrong number of sign flips is a REFLECTION, which looks plausible axis by axis and inverts every rotation. Mutation-checked: dropping only the negation fails 6 assertions across 4 of the 5 cases, the handedness test among them. Owed, and not claimed done: on-glass re-verification through a real iOS device, together with the two already owed on that rig (the 9e9bb9f4 sign fix and the Android calibration read) — one pass covers all three. G14's DualSense neutral acceleration is now unblocked by this measurement (1 g on slot 1, not the z-up the notes assumed) but is deliberately left to its own change; and that constant must NOT be propagated to switch_proto, which is a different device whose frame nobody has measured. Gate: macOS `swift build` + the full suite (215 tests, 5 skipped, 0 failures) with the five new cases observed in the run's own output, and the iOS-triple typecheck green. --- .../PunktfunkKit/Gamepad/GamepadCapture.swift | 19 ++-- .../PunktfunkKit/Gamepad/GamepadWire.swift | 24 +++++ .../GamepadMotionFrameTests.swift | 91 +++++++++++++++++++ 3 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 clients/apple/Tests/PunktfunkKitTests/GamepadMotionFrameTests.swift diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index e655ebaa..9907a5cc 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -616,17 +616,24 @@ public final class GamepadCapture { } let gs = GamepadWire.gyroLSBPerRadS let as_ = GamepadWire.accelLSBPerG + // Into the DualSense report frame. GameController and the pad's own report do not agree + // about which slot is which axis — measured, both from the same controller, on 2026-08-07 + // — so forwarding GC's x/y/z straight through sent yaw where the game reads roll. See + // `GamepadWire.appleMotionToWire`. One change of basis, applied to both planes. + let g = GamepadWire.appleMotionToWire( + (Float(m.rotationRate.x), Float(m.rotationRate.y), Float(m.rotationRate.z))) + let a = GamepadWire.appleMotionToWire((ax, ay, az)) wire?.sendMotion( pad: UInt8(slot.pad), gyro: ( - GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs), - GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs), - GamepadWire.motionRaw(Float(m.rotationRate.z), scale: gs) + GamepadWire.motionRaw(g.0, scale: gs), + GamepadWire.motionRaw(g.1, scale: gs), + GamepadWire.motionRaw(g.2, scale: gs) ), accel: ( - GamepadWire.motionRaw(ax, scale: as_), - GamepadWire.motionRaw(ay, scale: as_), - GamepadWire.motionRaw(az, scale: as_) + GamepadWire.motionRaw(a.0, scale: as_), + GamepadWire.motionRaw(a.1, scale: as_), + GamepadWire.motionRaw(a.2, scale: as_) )) } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift index b2fae0d0..1c2a282c 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift @@ -73,6 +73,30 @@ public enum GamepadWire { public static func motionRaw(_ value: Float, scale: Float) -> Int16 { Int16((value * scale).rounded().clamped(to: Float(Int16.min)...Float(Int16.max))) } + + /// GameController's motion frame → the DualSense report frame the wire is defined in. + /// + /// The wire is a unit passthrough: the host writes these three components, in order, into the + /// virtual DualSense's report bytes 16../22.. — the same slots a real pad fills. So the frame + /// the wire is defined in is the pad's OWN report frame, and a client that forwards its + /// platform's axes unconverted is simply speaking a different language. + /// + /// Both frames were measured on 2026-08-07 from ONE physical DualSense on one desk — the pad + /// read twice, over raw HID and through GameController: + /// + /// DualSense report frame: (Right, Up, Backward) — axis 0 carries pitch, 1 yaw, 2 roll + /// GameController frame: (Right, Forward, Up) + /// + /// Matching them up: Right is already slot 0; Up is GC's z, so it moves to slot 1; and slot 2 + /// wants Backward, which is GC's y negated. Hence `(x, z, -y)`. + /// + /// Applied to gyro AND acceleration, because it is a change of basis and both are expressed in + /// that basis. The negation `forwardMotion` already does for acceleration is a separate matter + /// — that one converts Apple's gravity-VECTOR convention into the proper acceleration a real + /// pad reports, and it composes with this rather than replacing it. + public static func appleMotionToWire(_ v: (Float, Float, Float)) -> (Float, Float, Float) { + (v.0, v.2, -v.1) + } } extension Float { diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadMotionFrameTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadMotionFrameTests.swift new file mode 100644 index 00000000..9dcfbf50 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadMotionFrameTests.swift @@ -0,0 +1,91 @@ +// The motion frame conversion, pinned against the readings it was derived from. +// +// On 2026-08-07 one physical DualSense was read twice on one desk — over raw HID (the pad's own +// report) and through GameController — so both frames come from the same controller in the same +// orientations rather than from two documents: +// +// DualSense report frame: (Right, Up, Backward) axis 0 pitch, 1 yaw, 2 roll +// GameController frame: (Right, Forward, Up) +// +// The numbers below are those measurements. They are the reason the conversion is `(x, z, -y)` and +// not one of the five other permutations that also move gravity to slot 1, so they belong in a test +// rather than only in a commit message. + +import XCTest + +@testable import PunktfunkKit + +final class GamepadMotionFrameTests: XCTestCase { + private func wire(_ v: (Float, Float, Float)) -> (Float, Float, Float) { + GamepadWire.appleMotionToWire(v) + } + + /// Gravity at rest, face up. MEASURED: GameController read (+0.005, -0.192, +0.992) g while raw + /// HID on the same pad read (+0.021, +0.997, +0.160). The conversion has to carry one into the + /// other — including the small tilt term, which is what distinguishes this mapping from the one + /// that merely gets gravity onto the right slot. + func testRestingGravityLandsInTheDualSenseFrame() { + let apple: (Float, Float, Float) = (0.005, -0.192, 0.992) + let w = wire(apple) + XCTAssertEqual(w.0, 0.005, accuracy: 0.001, "right stays on slot 0") + XCTAssertEqual(w.1, 0.992, accuracy: 0.001, "up moves to slot 1 — the pad reads +1 g here") + XCTAssertEqual(w.2, 0.192, accuracy: 0.001, "slot 2 is Backward, so GC's Forward negates") + // The hardware's own reading of the same pose, to the precision two sessions of holding a + // controller by hand can agree to. + XCTAssertEqual(w.1, 0.997, accuracy: 0.02) + XCTAssertEqual(w.2, 0.160, accuracy: 0.05) + } + + /// The tilt term's SIGN is the whole point: before this conversion the client sent Apple's y + /// straight through, so a pad tilted nose-up reported itself tilted nose-down. + func testTheForeAftAxisIsNegatedNotJustMoved() { + XCTAssertEqual(wire((0, 1, 0)).2, -1, "GC +y (Forward) is the wire's -Backward") + XCTAssertEqual(wire((0, -1, 0)).2, 1) + XCTAssertEqual(wire((0, 1, 0)).0, 0, "and it must not leak into the other slots") + XCTAssertEqual(wire((0, 1, 0)).1, 0) + } + + /// Each rotation, as measured, must reach the slot the wire reads it from: the wire's gyro is + /// documented pitch/yaw/roll in slots 0/1/2, and the raw-HID run confirmed the pad agrees. + func testEachRotationReachesItsWireSlot() { + // Yaw is the reliable direct measurement — a continuous one-way spin, clockwise from above, + // read as NEGATIVE on GC's z. It must arrive negative on slot 1, where the pad puts yaw. + let yaw = wire((-0.2, 21.7, -122.2)) + XCTAssertEqual(yaw.1, -122.2, accuracy: 0.01) + XCTAssertLessThan(yaw.1, 0, "clockwise-from-above is negative about +Up, both frames agree") + + // Pitch: nose-down about Right stays on slot 0 and keeps its sign. + let pitch = wire((-79.4, 0, 0)) + XCTAssertEqual(pitch.0, -79.4, accuracy: 0.01) + + // Roll: about the fore-aft axis, which moves to slot 2 AND flips. + let roll = wire((0, 61.8, 0)) + XCTAssertEqual(roll.2, -61.8, accuracy: 0.01) + } + + /// A change of basis is linear and orthonormal: it may not stretch a vector, and applying it to + /// gyro and to acceleration must be the same operation. Both are asserted because the capture + /// path calls it twice, on two different quantities. + func testConversionIsAnIsometry() { + for v in [(1, 2, 3), (-4, 5, -6), (0, 0, 1), (7, 0, 0)] as [(Float, Float, Float)] { + let w = wire(v) + let before = (v.0 * v.0 + v.1 * v.1 + v.2 * v.2).squareRoot() + let after = (w.0 * w.0 + w.1 * w.1 + w.2 * w.2).squareRoot() + XCTAssertEqual(before, after, accuracy: 1e-4, "must not change magnitude") + } + } + + /// Right-handed in, right-handed out. A permutation with the wrong number of sign flips is a + /// REFLECTION, which reads as plausible on every single axis and inverts every rotation — the + /// exact failure this measurement exists to prevent. + func testHandednessIsPreserved() { + let x = wire((1, 0, 0)) + let y = wire((0, 1, 0)) + // x cross y must equal the image of z, not its negative. + let cx = (x.1 * y.2 - x.2 * y.1, x.2 * y.0 - x.0 * y.2, x.0 * y.1 - x.1 * y.0) + let z = wire((0, 0, 1)) + XCTAssertEqual(cx.0, z.0, accuracy: 1e-5) + XCTAssertEqual(cx.1, z.1, accuracy: 1e-5) + XCTAssertEqual(cx.2, z.2, accuracy: 1e-5) + } +} From d996449a82da821575a6edbf5131fc27a79250f6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 19:07:30 +0200 Subject: [PATCH 14/22] 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 { From 0170da2a5f49cd9fe799c1efb28a8f69c58ac960 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 19:12:02 +0200 Subject: [PATCH 15/22] fix(client/apple): stop dropping rotation, and stop inventing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G13 — the three capture-fidelity findings from the gyro sweep, two fixed and one argued. **The 4 ms floor was a DROP, and it was shedding real rotation.** A sample arriving 3.9 ms after the last one was discarded outright. That is the wrong shape for this signal: buttons and sticks are absolute state, so a dropped frame costs nothing — the next one says everything it would have. Angular velocity is a RATE, and a consumer integrates it into an angle, so a dropped sample is rotation that happened and can never be recovered. GameController's delivery jitters around the pad's own ~250 Hz, so a floor set AT that rate does not shed a rare extra sample; it sheds a steady fraction of every turn. And the error is one-signed, so it accumulates — aim drifting short, which reads as bad sensitivity rather than as a bug. Nothing needed the ceiling. GC delivers at the sensor's rate rather than faster, the SDL client has always forwarded every sample, and the host's idle watchdog is a 100 ms timeout this cannot outpace. The throttle's two fields went with it: `lastMotionNs` was left set-but-never-read once the guard was gone, and `motionIntervalNs` had no other consumer. (Notes elsewhere say `flush` parks motion and reads it — that is PR #88's branch, not this one. Checked rather than assumed.) **An X-Box pad was streaming gyro it does not have.** Capture attached to any `GCMotion`, and an X-Box controller exposes one that reports gravity and NOTHING else. So the client sent a permanently-zero `rotationRate` to the host as authoritative gyro, under a declaration saying this pad has one. That is worse than having no motion plane at all: a game sees a controller being held perfectly still forever, and there is nothing to fall back to and nothing to notice. Now gated on `hasRotationRate`, which is GameController's own answer to the question we actually mean. The settings badge had the same bug from the same cause — `hasMotion` was `motion != nil`, so an X-Box pad got a gyroscope icon. It now reads `hasRotationRate` too. One wrong predicate was driving both the UI promise and the wire behaviour, which is why they were wrong together. That also simplifies G8's "your gyro can't reach this session" notice, which had to test `hasRotationRate` itself to avoid nagging about a gyro the pad never had. With the attach gated on it, the notice is just the else-branch. **Motion stays on the main queue, and this is the argument for why.** GameController's `handlerQueue` is a property of the CONTROLLER, not of an element, so moving motion off main moves buttons, sticks, the touchpad and the escape chord with it. This class is `@MainActor` throughout — eight `assumeIsolated` sites, the slot table, the gesture timers — so that is a rewrite of the isolation model rather than a queue assignment, and it would put the tvOS escape chord (the only controller way out of a stream there) on a background queue. That is a real risk for a speculative gain. The comment says so at the call site, and names the measurement to make first if it ever does bite: the host's per-pad motion inter-arrival histogram already reports exactly this and would say whether the delay is client-side or on the wire. Gate: macOS `swift build` + the full suite (215 tests, 5 skipped, 0 failures) and the iOS-triple typecheck green. No test pins the throttle removal or the capability gate: both are properties of live `GCMotion` delivery, which this module cannot fake — there is no injectable seam, and inventing one to assert "we called sendMotion twice" would test the mock. They are argued at the call sites instead, in the same spirit as the parts of `DsCapture` that are not unit-testable in their module either. On-glass verification is owed with the two already outstanding on that rig. --- .../PunktfunkKit/Gamepad/GamepadCapture.swift | 51 ++++++++++++++----- .../PunktfunkKit/Gamepad/GamepadManager.swift | 9 +++- include/punktfunk_core.h | 19 +++++++ 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 9907a5cc..50f0dc1d 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -66,7 +66,6 @@ public final class GamepadCapture { var buttons: UInt32 = 0 var axes: [Int32] = [0, 0, 0, 0, 0, 0] var fingerActive: [Bool] = [false, false] - var lastMotionNs: UInt64 = 0 // Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to // this class's mask-diff model): a Select pressed ALONE is held out of the mask // until it resolves into a tap (delivered on release) or — past `guideHold` — a @@ -89,9 +88,6 @@ public final class GamepadCapture { /// against `manager.forwarded` (empty until a session's `start`, cleared by `stop`). private var slots: [Slot] = [] - /// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate). - private static let motionIntervalNs: UInt64 = 4_000_000 - /// The cross-client controller escape chord (pf-client-core's `ESCAPE_CHORD`): /// L1+R1+Start+Select held together — four simultaneous buttons no game uses, so normal /// play can't trip it. Held for `disconnectHold` it ends the session via @@ -314,17 +310,36 @@ public final class GamepadCapture { // pad off what this slot declared, not off the session echo — under "Automatic" a couch // with an X-Box pad on 0 and a DualSense on 1 echoes X-Box 360 while the host builds pad 1 // a DualSense whose gyro works. + // + // Gated on `hasRotationRate`, not on `motion != nil`. An X-Box controller exposes a + // `GCMotion` that reports gravity and NOTHING else — attaching to it streamed a + // permanently-zero `rotationRate` to the host as authoritative gyro, under a declaration + // that says this pad has one. A game reading it sees a controller being held perfectly + // still forever, which is worse than seeing no motion plane at all: there is nothing to + // fall back to and nothing to notice. let motionCanReach = connection.motionReaches(declared: slot.pref) - if forwarding, let motion = c.motion { + if forwarding, let motion = c.motion, motion.hasRotationRate { if motionCanReach { if motion.sensorsRequireManualActivation { motion.sensorsActive = true } + // Delivered on the MAIN queue, like every other handler here, and deliberately so + // even though ~250 Hz of samples on main is not free. + // + // GameController's `handlerQueue` is a property of the CONTROLLER, not of an + // element, so there is no way to move motion off main without moving buttons, + // sticks, the touchpad and the escape chord with it. This whole class is + // `@MainActor` — eight `assumeIsolated` sites, the slot table, the gesture timers + // — so that is a rewrite of the isolation model, not a queue assignment. It would + // also put the tvOS escape chord (the ONLY controller way out of a stream there) + // on a background queue, which is a real risk taken for a speculative gain. + // + // If main-queue contention ever shows up as motion jitter, the measurement to make + // first is `motion_cadence`'s per-pad inter-arrival histogram on the host — it + // already reports exactly this, and would say whether the delay is here or on the + // wire before anyone restructures the class for it. motion.valueChangedHandler = { [weak self, weak slot] m in MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } } } - } else if motion.hasRotationRate { - // Only for a pad that really has a gyro. A gravity-only pad (an X-Box controller's - // GCMotion) has nothing the player could expect to reach the game, so telling them - // it didn't would be a nag about a feature they never had. + } else { onMotionUnreachable?(slot.pref) } } @@ -584,9 +599,21 @@ public final class GamepadCapture { private func forwardMotion(_ slot: Slot, _ m: GCMotion) { guard !suspended else { return } - let now = DispatchTime.now().uptimeNanoseconds - guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return } - slot.lastMotionNs = now + // Every sample goes out. There used to be a 4 ms floor here, and it was a DROP: a sample + // arriving 3.9 ms after the last one was discarded outright. + // + // That is the wrong shape for this signal. Buttons and sticks are absolute state, so a + // dropped frame costs nothing — the next one says everything it would have. Angular + // velocity is a RATE, and a consumer integrates it into an angle; a dropped sample is + // rotation that happened and can never be recovered. GameController's delivery is jittery + // around the pad's own ~250 Hz, so a floor set AT that rate does not shed a rare extra + // sample, it sheds a steady fraction of every turn — and the error is one-signed, so it + // accumulates into aim drifting short rather than into noise. + // + // Nothing needed the ceiling: GC delivers at the sensor's rate rather than faster, the SDL + // client has always forwarded every sample, and the host's own idle watchdog runs on a + // 100 ms timeout this cannot outpace. The throttle's `lastMotionNs`/`motionIntervalNs` went + // with it rather than being left set-but-unread — nothing else consumed either. // Total acceleration in g: gravity + user when split, else the raw vector — then NEGATED // into the wire's convention. // diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift index 0e13eb96..3d5c1687 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift @@ -41,6 +41,10 @@ public final class GamepadManager: ObservableObject { public let kind: PunktfunkConnection.GamepadType public let hasLight: Bool public let hasHaptics: Bool + /// This controller has a GYROSCOPE — not merely a `GCMotion`. The distinction is the whole + /// point: an X-Box pad exposes a `GCMotion` that reports gravity and nothing else, so + /// `motion != nil` is true for a controller with no angular rate to give. Read + /// `hasRotationRate`, which is GameController's own answer to the question we mean. public let hasMotion: Bool public let hasAdaptiveTriggers: Bool /// Specifically a DualSense (incl. the Edge — same feedback surface) — gates the @@ -265,7 +269,10 @@ public final class GamepadManager: ObservableObject { kind: kind, hasLight: c.light != nil, hasHaptics: c.haptics != nil, - hasMotion: c.motion != nil, + // `hasRotationRate`, not `motion != nil` — see the property. The settings row shows a + // gyroscope badge off this, and promising a gyro an X-Box pad does not have is the + // same lie as streaming its non-existent rotation to the host. + hasMotion: c.motion?.hasRotationRate ?? false, // GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the // Edge included); the DualShock 4 has none. hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge, diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 8c25b9b9..079fccd8 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -2133,6 +2133,25 @@ typedef struct { +// 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`. +#define MOTION_NEUTRAL_ACCEL { 0, (int16_t)MOTION_ACCEL_LSB_PER_G, 0, } + From 1eab4b6626c091389a4fe98a2acb9bd76620a198 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 19:34:51 +0200 Subject: [PATCH 16/22] fix(client/apple): the phone-gyro mirror was left on the old motion convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced by the merge. `DeviceGyro`'s header states the contract plainly — "units and axis semantics match `GamepadCapture.forwardMotion` exactly … the same convention, so a future sign/scale correction lands in one place for both sources" — and this branch made two such corrections in only one of the two places. That is a promise the code stopped keeping the moment the controller path was fixed. Both were true parity when #88 was written; both broke here. **The negation.** `GamepadCapture` sends `-(gravity + userAcceleration)` because Apple reports the gravity VECTOR, pointing down, while an accelerometer measures proper acceleration, pointing up at rest — and the wire carries the latter. The mirror sent it un-negated, so a phone lying still told the host it was accelerating downward at 1 g. The comment above that line even claimed the convention matched. **The frame.** The mirror's remap targets the controller frame its own header describes — x right, y up, z out of the screen — which is exactly GameController's frame, and that is not the DualSense report frame the wire is defined in. So the same change of basis the controller path now takes applies here, after the orientation remap rather than instead of it: the remap resolves which way the phone is being held, and the basis change translates the result into the pad's language. Two different jobs that happen to compose. Order matters for the closing sample too. `stop` replays `lastAccel` beside a zero gyro so "rotation stopped" does not also read as free fall; `lastAccel` is recorded after both conversions, so what gets parked is what was actually sent. Left alone deliberately: `DeviceGyroRemap` itself and `DeviceGyroRemapTests`. The orientation matrices answer a different question — which way is the phone being held — and nothing measured this evening bears on them. They remain derived-not-verified, as their own doc says, and the on-glass pass that owes the controller path a check owes them one too, in all four orientations. Gate: macOS `swift build` + full suite (215 tests, 5 skipped, 0 failures) and the iOS-triple typecheck green — the latter is what actually compiles this file, since the whole thing is `#if os(iOS)`. --- .../PunktfunkKit/Gamepad/DeviceGyro.swift | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift index 442c6542..53a09354 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift @@ -174,22 +174,37 @@ public final class DeviceGyro { state.lock.unlock() let rot = r.apply( x: Float(m.rotationRate.x), y: Float(m.rotationRate.y), z: Float(m.rotationRate.z)) - // Same total-acceleration convention as GamepadCapture.forwardMotion. + // Total acceleration, NEGATED — the same convention as GamepadCapture.forwardMotion, which + // this file's header promises to track. Apple reports the gravity VECTOR (pointing down); + // an accelerometer measures proper acceleration (pointing up at rest), and the wire carries + // the latter. Without the minus a still phone told the host it was accelerating downward at + // 1 g. let acc = r.apply( - x: Float(m.gravity.x + m.userAcceleration.x), - y: Float(m.gravity.y + m.userAcceleration.y), - z: Float(m.gravity.z + m.userAcceleration.z)) + x: -Float(m.gravity.x + m.userAcceleration.x), + y: -Float(m.gravity.y + m.userAcceleration.y), + z: -Float(m.gravity.z + m.userAcceleration.z)) + // Then the SAME change of basis the controller path takes. `r` puts the sample in the + // controller frame this file's header describes — x right, y up, z out of the screen — + // which is exactly GameController's frame, and that is not the DualSense report frame the + // wire is defined in. Measured 2026-08-07; see `GamepadWire.appleMotionToWire`. + // + // Both corrections are here because the header states the intent plainly: units and axis + // semantics match `GamepadCapture.forwardMotion` so a sign/scale fix lands in one place for + // both sources. Fixing only the controller path would have left this one silently on the + // old convention — a mirror that disagrees with the thing it mirrors. + let g = GamepadWire.appleMotionToWire((rot.x, rot.y, rot.z)) + let a = GamepadWire.appleMotionToWire((acc.x, acc.y, acc.z)) let gs = GamepadWire.gyroLSBPerRadS let as_ = GamepadWire.accelLSBPerG let gyro = ( - GamepadWire.motionRaw(rot.x, scale: gs), - GamepadWire.motionRaw(rot.y, scale: gs), - GamepadWire.motionRaw(rot.z, scale: gs) + GamepadWire.motionRaw(g.0, scale: gs), + GamepadWire.motionRaw(g.1, scale: gs), + GamepadWire.motionRaw(g.2, scale: gs) ) let accel = ( - GamepadWire.motionRaw(acc.x, scale: as_), - GamepadWire.motionRaw(acc.y, scale: as_), - GamepadWire.motionRaw(acc.z, scale: as_) + GamepadWire.motionRaw(a.0, scale: as_), + GamepadWire.motionRaw(a.1, scale: as_), + GamepadWire.motionRaw(a.2, scale: as_) ) state.lock.lock() state.lastAccel = accel From 979ed8f426804e8d222f1cc38d02bd256fe46f16 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 20:11:28 +0200 Subject: [PATCH 17/22] docs(clients/pads): say that an X-Box virtual pad has nowhere to put motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G17's motion half. The docs described what the CLIENT sends and stopped there, which made a promise the host does not always keep. The support matrix said a desktop client forwards motion from any pad SDL exposes a gyro on "and the host injects it into the matching virtual pad". The first clause is true; the second is only true when the virtual pad has a motion plane. The X-Box 360 and One backends do not — no gyro in their HID contract — so the host parses every sample and discards it. That is where *Automatic* lands anything it does not recognise as Sony or Valve, an 8BitDo with a perfectly good gyro included, and where a Switch Pro lands on a Windows host with no `hid-nintendo` backend to fold it into. A reader following the old text would conclude their gyro was broken. The failure has no other symptom: motion just does nothing. So both pages now say what to do about it — pick a DualSense-class type — and the client-settings page says it where the choice is actually made, next to the degrade paragraph that explains why a session ends up on an X-Box pad in the first place. The Deck's Steam-Input requirement moves out of Decky's settings blurb, which is the one place a Deck user streaming FROM the Deck would never look. With Steam Input on, Steam hands the app its own virtual X-Box pad, so no controller-type choice can help: there is no gyro on the pad the client can see. The picker help text now mentions motion on GTK and Android, which is where it was missing — Windows already said it and Apple says it in its own words. One sentence, the same sentence, so the four clients answer the question the same way. This is the doc side of the on-screen notice that shipped earlier in this branch. The two exist for the same reason and now agree: the client says it when it detects the case, the docs say it when someone goes looking. Not covered: the preset COUNTS in note 1 ("Android and the console home offer six … Windows and Apple offer five") are still unverified against the four pickers, and the Apple picker's missing Steam Deck entry is a code gap rather than a doc one. Both are noted in the plan and left for their own change rather than guessed at here. Gate: Linux CI image fmt + `clippy --locked --all-targets -D warnings` on punktfunk-client-linux (the GTK string is compiled) plus the core crates and their tests; Android `:app:compileDebugKotlin` + `:app:testDebugUnitTest`. Green. --- .../kotlin/io/unom/punktfunk/SettingsScreen.kt | 3 ++- clients/linux/src/ui_settings.rs | 3 ++- docs-site/content/docs/client-settings.md | 14 ++++++++++++++ docs-site/content/docs/support-matrix.md | 9 +++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 86bb00b9..1a737062 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -850,7 +850,8 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo field = "gamepad", enabled = s.gamepadForwarding, caption = "The virtual pad the host creates. Automatic matches your controller; " + - "every connected one is forwarded as its own player.", + "every connected one is forwarded as its own player. An X-Box type has no " + + "gyroscope, so pick a DualSense-class one if you want motion.", ) { g -> update(s.copy(gamepad = g)) } SettingDropdown( label = "Guide button", diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index ed35fd69..8a9884bd 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -1575,7 +1575,8 @@ pub fn show_scoped( &dialog, inline, "Gamepad type", - "The virtual pad on the host — Automatic matches your controller", + "The virtual pad on the host — Automatic matches your controller. An X-Box type has no \ + gyroscope, so pick a DualSense-class one if you want motion.", &[ "Automatic", "Xbox 360", diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index d2686f3e..f44b9598 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -189,6 +189,20 @@ explicit choice declares your choice — and the host builds each virtual pad fr host has no backend for degrades to an Xbox 360 pad rather than failing: Xbox One on a Windows host, for instance, or any Sony pad on a Linux host that can't open `/dev/uhid`. +That degrade is the one thing worth knowing about **motion**. An Xbox-class virtual pad has no +gyroscope in its HID contract, so a session that ends up on one throws every motion sample away — +your controller's gyro simply does nothing, which from the couch is indistinguishable from a broken +sensor. Automatic lands there for any controller punktfunk doesn't recognise as Sony or Valve (an +8BitDo with a gyro, say), and so does a Switch Pro streaming to a Windows host, which has no +Nintendo backend to build. **If you want motion, pick a DualSense-class type** — DualSense, +DualSense Edge, DualShock 4, Switch Pro or Steam Deck all carry a motion plane. The clients detect +this case and say so on-screen for a few seconds when it happens; the setting applies from the next +session, not the one you are in. + +On a **Steam Deck as the client**, motion also needs Steam Input switched off for punktfunk — with +it on, Steam hands the app its own virtual Xbox pad, which has no gyro to forward no matter which +type you pick. + **Forwarded controller** (*Use controller* on Apple and the console home) — *default: Automatic*, which forwards *every* connected controller, each as its own player, on Linux, Windows, Apple and the console home. Pinning one restricts the session to that controller alone — single-player. The Android diff --git a/docs-site/content/docs/support-matrix.md b/docs-site/content/docs/support-matrix.md index 7a2f16bd..362ec95a 100644 --- a/docs-site/content/docs/support-matrix.md +++ b/docs-site/content/docs/support-matrix.md @@ -450,6 +450,15 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a host injects it into the matching virtual pad; the Deck's trackpads ride the same touchpad surface. On the Apple clients rich capture is gated to the DualSense/DualShock 4 family, so other pads there really do get rumble only. + + **But motion only lands if the virtual pad the host builds has somewhere to put it.** The Xbox + 360 and Xbox One backends have no gyro in their HID contract, so a session that resolves to one + parses every motion sample and discards it. That is what *Automatic* does for any controller it + doesn't recognise as Sony or Valve — an 8BitDo with a perfectly good gyro included — and it is + also where a Switch Pro lands on a Windows host, which has no `hid-nintendo` backend to fold it + into. Set **Controller type** to a DualSense-class preset to get motion in those cases; the + clients now say so on-screen when they detect it, rather than leaving you to guess why tilting + does nothing. See [Gamepad type](/docs/client-settings#gamepad-type). 3. No desktop client sends pen input, even though the desktop hosts can inject it. 4. All three touch modes exist in the shared code and the picker is there, but nobody has confirmed them on a Windows 2-in-1. Only meaningful on a touchscreen anyway. From 8f1081719fd829eefe1cab815dd60f91bdcc7506 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 20:11:54 +0200 Subject: [PATCH 18/22] feat(client/android): a Bluetooth controller's gyro stops going nowhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android had two motion sources and both of them are USB claims. DsCapture takes a Sony pad's HID interface away from the kernel; Sc2Capture does the same for a Steam Controller 2. Everything else — a DualSense, a DualShock 4, a Switch Pro, an 8BitDo, paired over Bluetooth — arrives as an ordinary InputDevice. Its buttons worked, its sticks worked, and its gyro was dead, silently, with no log line and nothing in the UI to suggest the pad had a sensor at all. That is not one controller, it is the whole class of controllers people actually pair to a phone. The platform has had the answer since Android 12: InputDevice.getSensorManager hands back a SensorManager scoped to that one controller, carrying its TYPE_GYROSCOPE and TYPE_ACCELEROMETER. PadSensors registers a listener per forwarded pad that has a gyroscope and sends the samples on that pad's wire index. Below API 31 it registers nothing and the pads behave exactly as they did. It is built on DeviceGyro's shape, because the phone mirror had already paid for these lessons. One dedicated HandlerThread, never the main one. Batching off (maxReportLatencyUs = 0) — batching would trade away precisely the latency gyro aim exists to avoid. 200 Hz requested, which is also the ceiling the framework grants an app without HIGH_SAMPLING_RATE_SENSORS, so asking for more would only be capped. And a feed that lets go of a pad still alive parks its rotation at zero first: the host holds motion as state and re-emits it in every virtual-pad report, so an angular velocity left behind is a pad that rotates forever. Two writers on one pad's motion is the failure this program has spent the day unpicking, so the coordination is explicit in three places. A USB capture wins: DsCapture.startUsb already calls releaseDevice at claim time, that closes the slot, and the close now also takes the sensor listeners off — the claim makes the InputDevice vanish anyway, but going through the explicit teardown is what makes the ordering deterministic instead of a race against the platform's own removal callback. The phone-gyro mirror stands down: registering flips a bit the router reports through padHasOwnMotion, which DeviceGyro re-reads on every sample and answers with its own zero park. And a pad with an accelerometer but no gyroscope is deliberately NOT taken — it could only send gravity while pinning rotation at zero, on a pad the mirror is otherwise entitled to speak for, which is the same fight in a quieter costume. The wire units are measured fact (punktfunk_core::input::gamepad: 20 LSB/deg·s, 10000 LSB/g), and they now live in exactly one place on this client: Gamepad.motionGyroWire / motionAccelWire, which DeviceGyro was hand-inlining a second copy of. The gyro program's first finding was a client sending 40x hot because a second copy of a number had drifted, and the merge that followed found a sender nobody remembered to correct. One function, both callers. THE AXIS FRAME ON THIS PATH IS NOT VERIFIED, and the mapping is deliberately straight through rather than guessed at. What is known: the wire is a unit passthrough into a virtual DualSense report, and that report's frame was measured over raw HID on 2026-08-07 as (Right, Up, Backward-toward-the-player) carrying (pitch, yaw, roll), right-handed — which is why the USB path forwards the pad's own order un-remapped and is correct to. Android documents its sensor frame for a handheld device as +x right, +y up, +z out of the face, the same frame once "the face" is read as the one the player looks at. So straight through is what the documentation implies. What nobody has done is put a Bluetooth DualSense in front of the platform sensor framework and compare — those numbers come through a HID driver and InputFlinger's sensor mapper, either of which could permute or negate without saying so. A plausible-looking wrong remap is exactly the bug this program keeps finding, so the code says unverified and names the measurement that settles it, and each feed logs its first converted sample so the cheapest half of that measurement — which slot gravity lands on with the pad flat and still — costs a logcat line. PadSensorsTest pins the scale, the clamp, the rounding and the straight-through order, mutation-checked four ways: 20 to 16 fails gyroScaleFromRadiansPerSecond and straightThroughFrame, reversing the axis order fails straightThroughFrame, truncating instead of rounding fails roundsToNearestNotTowardZero, and negating the accel fails restingPadIsTheHostNeutral. Its frame expectations are written to change together with any remap that lands, not to be edited around one. GamepadRouter needs Android and a live JNI handle and there is no Robolectric here, so its half is argued in comments beside the code, as DsCapture's claim ordering already is. Gates: kit 65 tests (58 before, plus 7), app 67 unchanged, 0 failures, read out of the JUnit XML rather than off a green build. --- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 32 ++- .../io/unom/punktfunk/kit/DeviceGyro.kt | 39 ++- .../kotlin/io/unom/punktfunk/kit/Gamepad.kt | 26 ++ .../io/unom/punktfunk/kit/GamepadRouter.kt | 80 +++++- .../io/unom/punktfunk/kit/PadSensors.kt | 248 ++++++++++++++++++ .../io/unom/punktfunk/kit/DeviceGyroTest.kt | 11 +- .../io/unom/punktfunk/kit/PadSensorsTest.kt | 92 +++++++ 7 files changed, 485 insertions(+), 43 deletions(-) create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/PadSensors.kt create mode 100644 clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadSensorsTest.kt diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 517662b5..3c739ea0 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -73,6 +73,7 @@ import io.unom.punktfunk.kit.GamepadFeedback import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.deviceBodyVibrator import io.unom.punktfunk.kit.NativeBridge +import io.unom.punktfunk.kit.PadSensors import io.unom.punktfunk.kit.Sc2Capture import io.unom.punktfunk.kit.SessionEndReason import io.unom.punktfunk.kit.VideoDecoders @@ -459,16 +460,36 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U // "Gyro from this phone" (opt-in): this device's IMU speaks for controller 1's motion // while wire pad 0 is a controller without a gyro of its own — the rumble mirror's // sibling, data flowing the other way. The mirror gates itself per sample (it stands - // down whenever a capture link — USB DualSense / SC2, pads with a real IMU — holds - // pad 0), so it composes with the captures below without coordination here. + // down whenever pad 0's controller has motion of its own — a capture link below, or a + // pad whose own sensors PadSensors is reading), so it composes without coordination here. val phoneGyro = if (initialSettings.gyroOnPhone && initialSettings.gamepadForwarding) { DeviceGyro(context, handle, router).also { it.start() } } else { null } + // A Bluetooth controller's OWN gyro, through the platform sensor framework (API 31+): + // a BT DualSense / DS4 / Switch Pro / 8BitDo is an ordinary InputDevice, so none of the + // capture links below ever sees it and its motion used to go nowhere at all. No separate + // setting — this is the pad's own IMU doing what the pad is for, and unlike the USB + // captures it claims nothing; forwarding being off is the only thing that silences it. + val padSensors = if (initialSettings.gamepadForwarding) { + PadSensors(router).also { it.start() } + } else { + null + } // Free a disconnected controller's rumble/lights bindings promptly (else the open lights - // session leaks until the session ends). The router owns hot-plug; the feedback owns the binds. - router.onSlotClosed = feedback::onDeviceRemoved + // session leaks until the session ends), and take its sensor listeners off with it — the + // same callback also fires when a USB capture below CLAIMS the pad, which is what keeps + // the claimed pad from being fed motion twice. The router owns hot-plug; the feedback owns + // the binds. Assigned before the captures are constructed, so their claims land on it. + router.onSlotClosed = { deviceId -> + feedback.onDeviceRemoved(deviceId) + padSensors?.onSlotClosed(deviceId) + } + // The other edge: a controller that arrives (or first speaks) mid-session gets its sensors + // read too. The pads already connected were swept by PadSensors.start() above — both run + // on the main thread with nothing between them, so no controller falls through the gap. + router.onSlotOpened = { deviceId -> padSensors?.onSlotOpened(deviceId) } // Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an // already-paired BLE one — and forward its raw reports; the host mirrors a real // 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back @@ -599,6 +620,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U feedback.sink = null feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed phoneGyro?.stop() // join the sensor thread + park pad 0's rotation at zero, same ordering rule + // After the mirror, so it cannot resume writing pad 0 in the gap when a pad's own + // sensors let go of it; before the router is released, so the parks still find slots. + padSensors?.stop() sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } } sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down) dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt index 6f19cb14..f7e17be7 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt @@ -11,7 +11,6 @@ import android.os.HandlerThread import android.view.Display import android.view.Surface import android.view.WindowManager -import kotlin.math.roundToInt /** * The opt-in phone-gyro mirror ("Gyro from this phone", off by default): while wire pad 0 is a @@ -33,10 +32,10 @@ import kotlin.math.roundToInt * host's virtual pad never keeps integrating an angular velocity this device stopped * producing (the gyro-sweep "stale angular velocity re-sent forever" failure mode). * - * Units are the wire contract (mirrors `pf-client-core`'s constants): gyro rad/s → 20 LSB/°·s, - * accel m/s² → g → 10000 LSB/g. Android's accelerometer reads specific force (+1 g on the up - * axis at rest), which is the DualSense report's own convention — no sign flip. The one thing - * the phone adds is a frame remap: sensors report in the device's natural-portrait frame, while + * Units are the wire contract, converted by [Gamepad.motionGyroWire] / [Gamepad.motionAccelWire] — + * the same two functions [PadSensors] uses, so a scale this client ever has to correct is corrected + * once for every sender rather than once per sender that someone remembers. The one thing the + * phone adds is a frame remap: sensors report in the device's natural-portrait frame, while * the wire wants the controller frame the player sees (x right, y up, z out of the screen), so * each sample is rotated by the current display rotation — a phone clipped landscape must yaw * when the player yaws, not roll. The matrix is derived and pinned by `DeviceGyroTest`; @@ -64,7 +63,7 @@ class DeviceGyro( private val thread = HandlerThread("pf-phone-gyro") /** Latest converted accel, paired with each gyro send (the wire fuses both per sample). */ - private val lastAccel = intArrayOf(0, ACCEL_LSB_PER_G, 0) + private val lastAccel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0) /** Whether the last gyro event actually went to pad 0 — the stand-down zero-send edge. */ private var wasWriting = false @@ -103,10 +102,7 @@ class DeviceGyro( when (event.sensor.type) { Sensor.TYPE_ACCELEROMETER -> { val v = remap(rotation, event.values[0], event.values[1], event.values[2]) - for (i in 0..2) { - lastAccel[i] = (v[i] / GRAVITY * ACCEL_LSB_PER_G) - .roundToInt().coerceIn(-32768, 32767) - } + for (i in 0..2) lastAccel[i] = Gamepad.motionAccelWire(v[i]) } Sensor.TYPE_GYROSCOPE -> { // The write gate, per sample: pad 0 must exist (motion never creates a pad) @@ -124,9 +120,9 @@ class DeviceGyro( val v = remap(rotation, event.values[0], event.values[1], event.values[2]) NativeBridge.nativeSendPadMotion( handle, 0, - (v[0] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767), - (v[1] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767), - (v[2] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767), + Gamepad.motionGyroWire(v[0]), + Gamepad.motionGyroWire(v[1]), + Gamepad.motionGyroWire(v[2]), lastAccel[0], lastAccel[1], lastAccel[2], ) } @@ -149,17 +145,12 @@ class DeviceGyro( context.getSystemService(SensorManager::class.java) ?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null - /** ~200 Hz — between the sensor's usual FASTEST (~250-500 Hz) and GAME (~50 Hz). */ - private const val SAMPLING_PERIOD_US = 5000 - - /** The wire contract (pf-client-core `GYRO_LSB_PER_RAD_S`): 20 LSB/°·s from rad/s. */ - const val GYRO_LSB_PER_RAD_S = 20f * 180f / Math.PI.toFloat() - - /** The wire contract (pf-client-core `ACCEL_LSB_PER_G`). */ - const val ACCEL_LSB_PER_G = 10_000 - - /** pf-client-core's `G`. */ - const val GRAVITY = 9.80665f + /** + * ~200 Hz — between the sensor's usual FASTEST (~250-500 Hz) and GAME (~50 Hz), and also + * the ceiling the framework grants an app without `HIGH_SAMPLING_RATE_SENSORS` (API 31+), + * so asking for more would only be silently capped. Shared with [PadSensors]. + */ + internal const val SAMPLING_PERIOD_US = 5000 /** * Rotate one device-frame vector (rotation rate or acceleration — both transform the diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index 08cdc313..08b94137 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -3,6 +3,7 @@ package io.unom.punktfunk.kit import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent +import kotlin.math.roundToInt /** * Android gamepad capture → punktfunk/1 gamepad wire (the `input.rs::gamepad` contract; the host @@ -54,6 +55,31 @@ object Gamepad { const val AXIS_LT = 4 const val AXIS_RT = 5 + // Motion wire units — must equal punktfunk-core `input.rs::gamepad::MOTION_*`. Every motion + // sender on this client goes through the two converters below, so a scale that ever has to + // change changes in ONE place: the gyro program's first finding was a client sending 40× hot + // because a second copy of the number had drifted. + const val MOTION_GYRO_LSB_PER_DEG_S = 20 + const val MOTION_ACCEL_LSB_PER_G = 10_000 + + /** Standard gravity, `punktfunk-core`'s `G` — the divisor that turns m/s² into g. */ + const val GRAVITY = 9.80665f + + /** [MOTION_GYRO_LSB_PER_DEG_S] restated for Android's rad/s sensors: 1 rad/s ⇒ ~1145.9 raw. */ + const val MOTION_GYRO_LSB_PER_RAD_S = MOTION_GYRO_LSB_PER_DEG_S * 180f / Math.PI.toFloat() + + /** One angular-rate component, Android's rad/s → the wire's signed-16 raw units. */ + fun motionGyroWire(radPerSec: Float): Int = + (radPerSec * MOTION_GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767) + + /** + * One acceleration component, Android's m/s² → the wire's signed-16 raw units. Android reports + * specific force (the axis pointing up reads +1 g at rest), which is the DualSense report's own + * convention — no sign flip, and a pad lying flat lands on the host's neutral +1 g exactly. + */ + fun motionAccelWire(mPerSecSq: Float): Int = + (mPerSecSq / GRAVITY * MOTION_ACCEL_LSB_PER_G).roundToInt().coerceIn(-32768, 32767) + // GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`. const val PREF_AUTO = 0 const val PREF_XBOX360 = 1 diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 8eaa9cd2..162286ff 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -7,6 +7,7 @@ import android.os.Looper import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent +import java.util.Collections import java.util.concurrent.ConcurrentHashMap /** @@ -31,7 +32,8 @@ import java.util.concurrent.ConcurrentHashMap * * Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the * InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll - * threads, so the slot table is a [ConcurrentHashMap]. + * threads, [padPresent]/[padHasOwnMotion] from the phone-gyro thread and [deviceMotion] from the + * pad-sensor thread, so the slot table is a [ConcurrentHashMap]. */ class GamepadRouter( context: Context, @@ -85,12 +87,33 @@ class GamepadRouter( private val slots = ConcurrentHashMap() /** - * Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown. - * `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble / - * lights bindings are released promptly instead of leaking until the feedback threads stop. + * deviceIds whose own gyro [PadSensors] is currently reading — see [setDeviceHasSensorMotion]. + * Written on the main thread, read from the phone-gyro thread, hence a concurrent set. + */ + private val sensorDevices: MutableSet = + Collections.newSetFromMap(ConcurrentHashMap()) + + /** + * Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug, a capture link's + * [releaseDevice] claim, or session teardown. `StreamScreen` wires this to + * `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble / lights bindings are + * released promptly instead of leaking until the feedback threads stop, and to + * [PadSensors.onSlotClosed] so the controller's own sensor listeners come off with it. */ var onSlotClosed: ((deviceId: Int) -> Unit)? = null + /** + * Invoked (main thread) with the deviceId whenever a slot opens for a REAL controller — the + * hot-plug callback or the first input from a pad the session started without. Not fired for + * [openExternal]: a capture link's pad has no [InputDevice] behind it and streams motion from + * its own IMU already. `StreamScreen` wires this to [PadSensors.onSlotOpened]. + * + * Slots opened in `init` (every controller already connected) predate any assignment here, so + * a listener must sweep [forwardedDevices] once when it starts. Both happen on the main thread + * inside one composition block, so nothing can slip between the sweep and the assignment. + */ + var onSlotOpened: ((deviceId: Int) -> Unit)? = null + /** * Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller * leaves the stream. `StreamScreen` wires this to the deliberate-quit exit. @@ -324,14 +347,48 @@ class GamepadRouter( fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad } /** - * Whether wire pad [pad] is held by a capture-link slot ([ExternalPad] — USB DualSense / - * SC2), whose motion arrives from the pad's OWN IMU. The phone-gyro mirror stands down for - * those: two motion writers on one wire pad would fight. Synthetic ids are negative - * ([EXTERNAL_ID_BASE]); real [InputDevice] ids are positive. Read from the phone-gyro thread - * (the slot table is concurrent). + * Whether wire pad [pad]'s motion already comes from the controller's OWN IMU — either a + * capture-link slot ([ExternalPad] — USB DualSense / SC2; synthetic ids are negative + * ([EXTERNAL_ID_BASE]), real [InputDevice] ids positive), or a real controller whose gyro + * [PadSensors] is reading through the platform sensor framework (a Bluetooth DualSense / + * Switch Pro / 8BitDo). The phone-gyro mirror stands down for both: two motion writers on one + * wire pad would fight, and the pad's own IMU is the one attached to the player's hands. + * Read from the phone-gyro thread (both tables are concurrent). */ fun padHasOwnMotion(pad: Int): Boolean = - slots.any { (id, slot) -> slot.index == pad && id < 0 } + slots.any { (id, slot) -> slot.index == pad && (id < 0 || id in sensorDevices) } + + /** + * Declare (or withdraw) that real controller [deviceId] is sourcing its own rotation — see + * [padHasOwnMotion]. Called by [PadSensors] as it registers and unregisters listeners, on the + * main thread; read from the phone-gyro thread, hence the concurrent set. Keyed by device + * rather than by pad index so a controller that changes wire index (a lower one freed up while + * it was captured) carries the fact with it. + */ + fun setDeviceHasSensorMotion(deviceId: Int, has: Boolean) { + if (has) sensorDevices.add(deviceId) else sensorDevices.remove(deviceId) + } + + /** + * One motion sample from real controller [deviceId]'s own sensors, on whatever wire index its + * slot currently holds — [ExternalPad.motion] for pads the input stack still owns. Silently + * drops when the slot is gone (unplugged, or claimed by a capture link between the sensor + * callback and here) rather than writing to an index that may already belong to someone else. + * Called from [PadSensors]' sensor thread. + */ + fun deviceMotion(deviceId: Int, gyro: IntArray, accel: IntArray) { + val slot = slots[deviceId] ?: return + if (!forwarding) return + NativeBridge.nativeSendPadMotion( + handle, slot.index, + gyro[0], gyro[1], gyro[2], + accel[0], accel[1], accel[2], + ) + } + + /** Snapshot of the REAL controllers currently forwarded, as deviceIds — the set [PadSensors] + * sweeps at start for the pads that were already connected when the session opened. */ + fun forwardedDevices(): List = slots.keys.filter { it >= 0 } /** * A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam @@ -452,6 +509,9 @@ class GamepadRouter( if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index) val slot = Slot(index, Gamepad.AxisMapper(handle, index)) slots[dev.id] = slot + // After the table holds the slot, so a listener that sends on this device the moment it is + // told ([PadSensors]) finds an index to send on rather than dropping its first samples. + onSlotOpened?.invoke(dev.id) return slot } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/PadSensors.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/PadSensors.kt new file mode 100644 index 00000000..568d934b --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/PadSensors.kt @@ -0,0 +1,248 @@ +package io.unom.punktfunk.kit + +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import android.view.InputDevice +import java.util.concurrent.ConcurrentHashMap + +/** + * Motion from a controller the Android input stack owns — the Bluetooth pads. + * + * Before this, the only motion sources on Android were the capture links: [DsCapture] (a Sony pad + * claimed over USB, raw HID) and [Sc2Capture] (Steam Controller 2 passthrough). A DualSense, a + * DualShock 4, a Switch Pro or an 8BitDo paired over BLUETOOTH is neither — it arrives as an + * ordinary [InputDevice], its buttons and sticks work, and its gyro was silently dead. That is a + * whole class of controller with no motion at all. + * + * Android 12 (API 31) exposes those sensors: [InputDevice.getSensorManager] hands back a + * [android.hardware.SensorManager] scoped to that one controller, carrying the usual + * TYPE_GYROSCOPE / TYPE_ACCELEROMETER. This class registers a listener per forwarded controller + * that has a gyroscope, converts each sample to wire units, and sends it on that pad's wire index + * through [GamepadRouter.deviceMotion]. Below API 31 nothing is registered and the class is inert — + * those pads keep working, minus motion, exactly as they did. + * + * It follows [DeviceGyro] (the phone-gyro mirror) wherever the two solve the same problem: + * - samples ride ONE dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`) + * — sensor batching would trade away the exact latency gyro aim exists to avoid, and the main + * thread is where Compose recomposition lives; + * - a feed torn down while its wire pad is still alive parks the rotation at zero first, because + * the host holds motion as STATE and re-emits it in every virtual-pad report: an angular + * velocity left behind reads as a pad rotating forever (the gyro sweep's "stale rate re-sent + * forever" finding). + * + * One writer per pad, three ways: + * 1. A USB capture claims the physical device away from the input stack; [DsCapture.startUsb] + * calls [GamepadRouter.releaseDevice] at claim time, which closes the slot, which fires + * `onSlotClosed`, which lands on [onSlotClosed] here and unregisters. The claim also makes the + * controller's [InputDevice] vanish outright, so even a reopened slot would find nothing to + * register — but the explicit teardown is what makes the ordering deterministic instead of a + * race against the platform's own removal callback. + * 2. The phone-gyro mirror stands down: registering flips + * [GamepadRouter.setDeviceHasSensorMotion], [GamepadRouter.padHasOwnMotion] reports it, and + * [DeviceGyro] re-reads that gate on every sample (sending its own zero park on the edge). + * 3. Exactly one feed exists per deviceId — [onSlotOpened] is idempotent, and it is the only + * thing that ever constructs one. + * + * Frame: see [gyroToWire] — the mapping is straight through, and NOT yet verified on hardware. + */ +class PadSensors(private val router: GamepadRouter) { + + /** One controller's live sensor feed: its listener state and the accel it pairs with each + * rotation. Its arrays belong to the sensor thread; [stop] reads them only after the join. */ + private inner class Feed(private val deviceId: Int) : SensorEventListener { + /** Latest converted accel, paired with each gyro send (the wire fuses both per sample). + * Starts at the host's neutral — 1 g on the up axis, NOT [0,0,0], which is free fall. */ + private val accel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0) + private val gyro = IntArray(3) + + /** Whether any rotation has gone out on this pad — gates the park on teardown, so a pad + * that never sent motion is not handed a sample it did not earn. */ + @Volatile + var wroteMotion = false + private set + + override fun onSensorChanged(event: SensorEvent) { + when (event.sensor.type) { + Sensor.TYPE_ACCELEROMETER -> accelToWire(event.values, accel) + Sensor.TYPE_GYROSCOPE -> { + gyroToWire(event.values, gyro) + // One line per controller per session, on the first sample that carries both + // planes: it is the cheapest possible version of the frame measurement + // [gyroToWire] asks for. Hold the pad flat and still while a stream starts and + // the accel triple says which slot gravity lands on — the one thing that + // settles whether the straight-through mapping is right. + if (!wroteMotion) { + Log.i( + TAG, + "controller $deviceId first motion sample: " + + "gyro ${gyro.joinToString()} accel ${accel.joinToString()}", + ) + } + wroteMotion = true + router.deviceMotion(deviceId, gyro, accel) + } + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + + /** Zero rotation, last-known accel — "at rest", not free fall. */ + fun park() { + gyro.fill(0) + router.deviceMotion(deviceId, gyro, accel) + } + } + + /** deviceId → live feed. Concurrent: the main thread mutates it while the sensor thread is + * running (hot-plug, a capture link's claim). */ + private val feeds = ConcurrentHashMap() + + private val thread = HandlerThread("pf-pad-sensors") + private var handler: Handler? = null + + /** + * Start the sensor thread and attach to every controller the router already forwards — the + * pads connected before the session opened, which will never fire a hot-plug callback. + * Everything after that arrives through [onSlotOpened]. Main thread. + */ + fun start() { + if (!supported()) return + thread.start() + handler = Handler(thread.looper) + for (deviceId in router.forwardedDevices()) onSlotOpened(deviceId) + } + + /** + * A slot opened for real controller [deviceId] — attach if it has a gyroscope of its own. + * Idempotent, and a no-op before [start] or on a platform without the API. Main thread, from + * [GamepadRouter.onSlotOpened]. + */ + fun onSlotOpened(deviceId: Int) { + val h = handler ?: return + if (feeds.containsKey(deviceId)) return + // API 31+ only — getSensorManager does not exist below it. Re-checked here rather than + // relying on start()'s gate, so the entry point is safe on its own terms. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + val dev = InputDevice.getDevice(deviceId) ?: return + // Declared non-null: a controller with no sensors gets an empty manager, not a null one. + val sm = dev.sensorManager + // A gyroscope is the entry price; the accelerometer alone does not buy a feed. The rotation + // is what gyro aim is for, and an accel-only feed would send gravity while pinning rotation + // at zero on a pad the phone-gyro mirror is otherwise entitled to speak for — precisely the + // two-writers-on-one-pad fight this program has spent its day unpicking. Such a pad stays + // on the mirror's terms instead, where at least the accel agrees with the gyro beside it. + // Nothing found here is not proof the pad has no IMU. A DualSense's motion arrives on its + // own evdev node, and whether InputReader merges that node onto the gamepad InputDevice + // (shared descriptor) or leaves it standing alone is the platform's business, not ours — + // and a standalone one is exactly what GamepadRouter.isForwardable filters out, so this + // would never see it. Android 12's own controller-sensor documentation cites the DualShock + // 4 and DualSense, which says the merge happens; it is not something this code can assert. + // If a Bluetooth Sony pad ever turns up here with no gyroscope, THAT is the thing to check. + val gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return + val feed = Feed(deviceId) + feeds[deviceId] = feed + // ~200 Hz requested, zero report latency: batching is poison for gyro aim, and 200 Hz is + // what the framework grants an app without HIGH_SAMPLING_RATE_SENSORS anyway. + sm.registerListener(feed, gyroSensor, DeviceGyro.SAMPLING_PERIOD_US, 0, h) + sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let { + sm.registerListener(feed, it, DeviceGyro.SAMPLING_PERIOD_US, 0, h) + } + // The pad sources its own rotation from here on → the phone-gyro mirror stands down for it. + router.setDeviceHasSensorMotion(deviceId, true) + Log.i(TAG, "controller $deviceId (${dev.name}) has a gyro — forwarding its motion") + } + + /** + * The slot for [deviceId] closed — unplug, session teardown, or a capture link claiming the + * device. Unregister and hand the pad back to the phone-gyro mirror. Main thread, from + * [GamepadRouter.onSlotClosed]. + * + * No park-at-zero here, on purpose: the router removed the slot BEFORE invoking the callback + * and has already sent that pad's Remove, so the host tore the virtual pad down and there is no + * latched rotation left to clear — while writing to a wire index that is free again would be + * addressing whoever claims it next. [stop] is the case where the pad outlives the feed. + */ + fun onSlotClosed(deviceId: Int) { + unregister(deviceId) + router.setDeviceHasSensorMotion(deviceId, false) + } + + /** + * Unregister every listener, join the sensor thread, then park at zero each pad that was + * rotating. Call BEFORE the router is released and the session handle freed — the same + * teardown ordering rule as the feedback poll threads and [DeviceGyro.stop]. The parks come + * AFTER the join for two reasons: a sample still in flight would re-latch the rotation just + * cleared, and the join is what publishes the sensor thread's writes to this one. + */ + fun stop() { + val parked = feeds.keys.toList().mapNotNull { id -> unregister(id)?.let { id to it } } + for ((deviceId, _) in parked) router.setDeviceHasSensorMotion(deviceId, false) + thread.quitSafely() + runCatching { thread.join() } + handler = null + for ((_, feed) in parked) if (feed.wroteMotion) feed.park() + } + + /** + * Drop [deviceId]'s listeners, returning the feed that held them (null if there was none). + * Safe for a controller that is already gone: the sensor manager is reached through the + * [InputDevice], and a vanished device simply leaves nothing to unregister — the platform has + * stopped calling the listener either way. + */ + private fun unregister(deviceId: Int): Feed? { + val feed = feeds.remove(deviceId) ?: return null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + InputDevice.getDevice(deviceId)?.sensorManager?.unregisterListener(feed) + } + return feed + } + + companion object { + private const val TAG = "PadSensors" + + /** Whether this platform can read a controller's own sensors at all (API 31+). */ + fun supported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + /** + * One gyroscope sample (Android: rad/s) → the wire's three signed-16 components, in place. + * + * ⚠ **THE AXIS FRAME IS NOT VERIFIED ON THIS PATH.** The wire is a unit passthrough into a + * virtual DualSense report, and that report's frame was MEASURED on 2026-08-07 over raw + * HID: slot 0 = Right (pitch), slot 1 = Up (yaw), slot 2 = Backward, toward the player + * (roll), right-handed. Android documents its sensor frame for a handheld device as +x + * right, +y up, +z out of the face — the same frame once "the face" is read as the one the + * player looks at, which is what the platform means by a controller's own sensor. So + * straight through is the mapping the documentation implies, and it is what this does. + * What nobody has DONE is put a Bluetooth DualSense in front of the platform sensor + * framework and compare: those numbers come through a HID driver and InputFlinger's sensor + * mapper, either of which could permute or negate without saying so. + * + * The measurement that settles it is the DualSense one repeated on this path — pad flat + * and still, then three labelled rotations: + * - at rest, gravity must land as +1 g on ACCEL slot 1 (not 0, not 2); + * - yaw clockwise seen from above ⇒ GYRO slot 1 negative; + * - pitch the far edge down ⇒ slot 0 negative; + * - roll right-side down ⇒ slot 2 negative. + * Any disagreement is a remap, and it belongs HERE with its own expectations in + * `PadSensorsTest` — not spread across callers, and not guessed at in advance. + */ + fun gyroToWire(values: FloatArray, out: IntArray) { + for (i in 0..2) out[i] = Gamepad.motionGyroWire(values.getOrElse(i) { 0f }) + } + + /** + * One accelerometer sample (Android: m/s², specific force) → the wire's three signed-16 + * components, in place. Same unverified frame as [gyroToWire] and the same straight-through + * mapping; the sign needs no flip, because Android and the DualSense report agree that the + * axis pointing up reads +1 g at rest (see [Gamepad.motionAccelWire]). + */ + fun accelToWire(values: FloatArray, out: IntArray) { + for (i in 0..2) out[i] = Gamepad.motionAccelWire(values.getOrElse(i) { 0f }) + } + } +} diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt index b7468a82..092ec914 100644 --- a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt @@ -42,12 +42,13 @@ class DeviceGyroTest { } } - /** The wire contract, shared with pf-client-core / the Swift client: 20 LSB/°·s means - * 1 rad/s ⇒ ~1145.9 raw; 1 g ⇒ 10000 raw. */ + /** The wire contract, shared with pf-client-core / the Swift client and now with every other + * Android motion sender ([Gamepad.motionGyroWire]): 20 LSB/°·s means 1 rad/s ⇒ ~1145.9 raw; + * 1 g ⇒ 10000 raw. */ @Test fun wireUnitConstants() { - assertEquals(20f * 180f / Math.PI.toFloat(), DeviceGyro.GYRO_LSB_PER_RAD_S, 0f) - assertEquals(1145.9156f, DeviceGyro.GYRO_LSB_PER_RAD_S, 0.001f) - assertEquals(10_000, DeviceGyro.ACCEL_LSB_PER_G) + assertEquals(20f * 180f / Math.PI.toFloat(), Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0f) + assertEquals(1145.9156f, Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0.001f) + assertEquals(10_000, Gamepad.MOTION_ACCEL_LSB_PER_G) } } diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadSensorsTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadSensorsTest.kt new file mode 100644 index 00000000..12867e75 --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadSensorsTest.kt @@ -0,0 +1,92 @@ +package io.unom.punktfunk.kit + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pins the unit scaling and the axis mapping of the controller-sensor path ([PadSensors]) and the + * shared converters it goes through ([Gamepad.motionGyroWire] / [Gamepad.motionAccelWire]). Pure + * JVM — the two `*ToWire` functions take plain float arrays and touch no Android class. + * + * The scale is MEASURED FACT (`punktfunk_core::input::gamepad`: 20 LSB/°·s, 10000 LSB/g) and must + * not drift. The axis mapping is straight through and NOT yet verified against hardware — see + * [PadSensors.gyroToWire] for the measurement that would settle it. [straightThroughFrame] exists + * to make a future remap a deliberate, visible edit rather than a quiet one. + * Run: `./gradlew :kit:testDebugUnitTest`. + */ +class PadSensorsTest { + private fun gyro(x: Float, y: Float, z: Float) = + IntArray(3).also { PadSensors.gyroToWire(floatArrayOf(x, y, z), it) } + + private fun accel(x: Float, y: Float, z: Float) = + IntArray(3).also { PadSensors.accelToWire(floatArrayOf(x, y, z), it) } + + /** 20 LSB/°·s from Android's rad/s: π rad/s is exactly 180 °/s, so exactly 3600 raw. */ + @Test + fun gyroScaleFromRadiansPerSecond() { + assertEquals(3600, gyro(Math.PI.toFloat(), 0f, 0f)[0]) + assertEquals(-3600, gyro(-Math.PI.toFloat(), 0f, 0f)[0]) + assertEquals(1146, gyro(1f, 0f, 0f)[0]) // 1 rad/s ⇒ 1145.9156, rounded + assertEquals(0, gyro(0f, 0f, 0f)[0]) + } + + /** 10000 LSB/g from Android's m/s²: standard gravity is exactly 1 g. Android reports specific + * force, so a pad at rest reads +1 g on the axis pointing up — no sign flip anywhere. */ + @Test + fun accelScaleFromMetresPerSecondSquared() { + assertEquals(10_000, accel(0f, Gamepad.GRAVITY, 0f)[1]) + assertEquals(-10_000, accel(0f, -Gamepad.GRAVITY, 0f)[1]) + assertEquals(0, accel(0f, 0f, 0f)[1]) + } + + /** A controller lying flat and still lands exactly on the host's neutral for a virtual + * DualSense — 1 g on wire slot 1 (`punktfunk-core` `MOTION_NEUTRAL_ACCEL = [0, 10000, 0]`), + * not the [0,0,0] that means free fall. */ + @Test + fun restingPadIsTheHostNeutral() { + assertArrayEquals(intArrayOf(0, 10_000, 0), accel(0f, Gamepad.GRAVITY, 0f)) + } + + /** + * The frame: component i of the sensor sample becomes component i of the wire triple, for both + * planes, with no permutation and no negation. UNVERIFIED against hardware — if a Bluetooth + * DualSense says otherwise, the remap goes into [PadSensors.gyroToWire] and this test changes + * with it. Distinct magnitudes per axis so a swap or a flip cannot cancel out. + */ + @Test + fun straightThroughFrame() { + assertArrayEquals(intArrayOf(1146, 2292, 3438), gyro(1f, 2f, 3f)) + assertArrayEquals( + intArrayOf(10_000, 20_000, -30_000), + accel(Gamepad.GRAVITY, 2f * Gamepad.GRAVITY, -3f * Gamepad.GRAVITY), + ) + } + + /** Both planes clamp to signed 16 bits rather than wrapping — a flick past 1638 °/s or a knock + * past 3.27 g saturates, where a wrap would send a full-speed rotation the other way. */ + @Test + fun clampsToSigned16() { + assertArrayEquals(intArrayOf(32767, -32768, 32767), gyro(100f, -100f, 1e9f)) + assertArrayEquals(intArrayOf(32767, -32768, 32767), accel(1000f, -1000f, 1e9f)) + } + + /** Rounds to nearest rather than truncating: a truncating converter loses up to a whole LSB + * off every sample, always toward zero, and a gyro whose every sample is biased the same way + * is a gyro that drifts. */ + @Test + fun roundsToNearestNotTowardZero() { + assertEquals(1, gyro(0.0006f, 0f, 0f)[0]) // 0.688 raw — truncation would say 0 + assertEquals(-1, gyro(-0.0006f, 0f, 0f)[0]) + assertEquals(1, accel(0.0007f, 0f, 0f)[0]) // 0.714 raw + } + + /** A sensor that hands back fewer than three components (or none — the framework reuses one + * array across types) contributes zero rather than throwing on the sensor thread. */ + @Test + fun shortSampleIsZeroFilled() { + val out = IntArray(3) { 7 } + PadSensors.gyroToWire(floatArrayOf(Math.PI.toFloat()), out) + assertArrayEquals(intArrayOf(3600, 0, 0), out) + } +} From 7a4cdac5b7fd76006fe2894404d8ebb578cdcca5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 20:18:39 +0200 Subject: [PATCH 19/22] fix(client/android): a Bluetooth pad's gyro obeys the same reachability gate as the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the G10 merge. The new sensor path was written against main, which does not carry this branch's G8 work, so it forwarded motion unconditionally — the one thing G8 exists to stop. `deviceMotion` checked `forwarding` and nothing else. A Bluetooth DualSense in a session that resolved to an X-Box backend would stream ~200 Hz of samples the host parses and discards, for the whole session, exactly as the USB capture path did before G8. Not a regression against shipped behaviour — the path is new — but it would have shipped the defect back into a client that had just been taught not to have it. `Slot` now carries `motionReaches`, asked once at open off the kind that pad DECLARED, in the same shape `ExternalPad` already used. Per pad, not per session: under Automatic the handshake carries the active pad's kind, so a couch with an X-Box pad on slot 0 and a DualSense on slot 1 must not have slot 1's working gyro suppressed by slot 0's answer. The notice moved to where the truth is known. `openSlot` knows only what kind a pad declared, not whether it physically has a gyro — that is discovered later, when `PadSensors` finds a gyroscope and calls `setDeviceHasSensorMotion`. Raising it there is the only placement that both tells a player whose gyro is being dropped and stays silent for the pads that never had one. Also unified the last duplicate scale in the module. G10 hoisted the wire units into `Gamepad` and pointed `DeviceGyro` at them, but `DsDevice` kept its own `20L` / `10000L` — and `Gamepad`'s new comment claims every sender goes through one place, which was not yet true. Two copies of a unit constant in one module is precisely the defect this program opened with (a DualShock 4 blob 40× hot because a second copy had drifted), so the claim and the code now agree. `val` rather than `const val` only because widening to Long is not a constant expression; Long is deliberate, since the calibration arithmetic overflows an Int before it divides. Proven non-vacuous rather than assumed: changing `Gamepad.MOTION_GYRO_LSB_PER_DEG_S` from 20 to 16 now fails four named cases across three classes — `DsDeviceTest.calibrationRescalesRawCountsOntoTheWireUnits`, `.theHostsOwnBlobIsAPassthrough`, `.parseStateAppliesTheCalibration` and `DeviceGyroTest.wireUnitConstants`. Before this change `DsDevice` would not have noticed. The gate itself has no test, for the reason the surrounding code already documents: `GamepadRouter` needs Android plus a live JNI handle, there is no Robolectric in this module, and a mock would test the mock. It is argued at the call sites instead. Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest`, `:app:compileDebugKotlin`, `:app:testDebugUnitTest` — kit 75 / app 67, 0 failures, counts read out of the JUnit XML. The merge reconciles: 62 on this branch, plus 6 from main's DeviceGyroTest, plus G10's 7. --- .../kotlin/io/unom/punktfunk/kit/DsDevice.kt | 15 +++++++-- .../io/unom/punktfunk/kit/GamepadRouter.kt | 33 +++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt index 6274636d..3a091fba 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt @@ -114,10 +114,19 @@ object DsDevice { companion object { /** The pads' nominal acceleration resolution — `hid-playstation`'s `DS_ACC_RES_PER_G`. */ private const val RAW_ACCEL_LSB_PER_G = 8192L - /** `punktfunk_core::input::gamepad::MOTION_GYRO_LSB_PER_DEG_S`. */ - private const val WIRE_GYRO_LSB_PER_DEG_S = 20L + /** + * The wire's gyro scale, taken from [Gamepad] rather than restated. These were literal + * `20L` / `10000L` until the sensor path hoisted the same numbers into one place; a + * second copy of a unit constant is precisely the defect this whole program opened + * with, and two of them in one module would be worse than the original. + * + * `val`, not `const val`, only because the widening to Long is not a compile-time + * constant expression. Long here on purpose: the arithmetic below multiplies raw counts + * by the calibration's speed term before dividing, which overflows an Int. + */ + private val WIRE_GYRO_LSB_PER_DEG_S = Gamepad.MOTION_GYRO_LSB_PER_DEG_S.toLong() /** `MOTION_ACCEL_LSB_PER_G`, doubled — the declared accel range spans 2 g, not 1. */ - private const val ACCEL_NUMER = 2 * 10000L + private val ACCEL_NUMER = 2L * Gamepad.MOTION_ACCEL_LSB_PER_G /** Bytes the layout below reads; the reports themselves are longer (41 / 37). */ private const val MIN_LEN = 35 diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 7e06b0d2..158cb2af 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -71,7 +71,18 @@ class GamepadRouter( ) { /** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */ - private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) { + private class Slot( + val index: Int, + val mapper: Gamepad.AxisMapper, + /** + * Whether motion sent for this pad can reach the game at all, asked once at open off the + * kind it declared ([NativeBridge.nativePadMotionReaches]). False means the host built it a + * backend with no motion plane, so [deviceMotion] drops the sample here rather than paying + * to send one the host will decode and discard — at a controller's full sensor rate, for + * the whole session. The capture-link pads carry the same flag on [ExternalPad]. + */ + val motionReaches: Boolean = true, + ) { /** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */ var held = 0 @@ -378,6 +389,12 @@ class GamepadRouter( */ fun setDeviceHasSensorMotion(deviceId: Int, has: Boolean) { if (has) sensorDevices.add(deviceId) else sensorDevices.remove(deviceId) + // This is the first moment we know a Bluetooth pad actually HAS a gyro — `openSlot` only + // knows what kind it declared. So it is the honest place to raise the notice when that + // gyro has nowhere to go, and the only one that cannot nag about a pad that never had one. + if (has && forwarding && slots[deviceId]?.motionReaches == false) { + onMotionUnreachable?.invoke() + } } /** @@ -390,6 +407,11 @@ class GamepadRouter( fun deviceMotion(deviceId: Int, gyro: IntArray, accel: IntArray) { val slot = slots[deviceId] ?: return if (!forwarding) return + // The same gate the USB capture path takes: a backend with no motion plane decodes every + // sample and discards it, so sending is pure cost. Notified once per pad by + // [setDeviceHasSensorMotion], which is where we first know the controller HAS a gyro to + // lose — a pad without one must not produce a warning about motion. + if (!slot.motionReaches) return NativeBridge.nativeSendPadMotion( handle, slot.index, gyro[0], gyro[1], gyro[2], @@ -540,7 +562,14 @@ class GamepadRouter( // to that type (a single global choice — matches the handshake's session-default pref). val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index) - val slot = Slot(index, Gamepad.AxisMapper(handle, index)) + // Asked here, off the kind this pad just DECLARED — not off the session's resolved backend, + // which under Automatic answers for whichever pad happened to be active at dial time. Held + // for the slot's life; the sensor path reads it on every sample. + val slot = Slot( + index, + Gamepad.AxisMapper(handle, index), + NativeBridge.nativePadMotionReaches(handle, pref), + ) slots[dev.id] = slot // After the table holds the slot, so a listener that sends on this device the moment it is // told ([PadSensors]) finds an index to send on rather than dropping its first samples. From ee61e8c9ba2f22daf2822d77fbe39da9327ea735 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 21:28:13 +0200 Subject: [PATCH 20/22] fix(clients/pads): the phone mirror never needed the controller path's frame change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts half of 1eab4b66 and closes G10's open frame question, both settled by the same measurement. 1eab4b66 made two corrections to the Apple phone-gyro mirror. The negation was right and stays: Apple reports the gravity VECTOR, pointing down, while an accelerometer measures proper acceleration, pointing up at rest, and the wire carries the latter. The frame change was wrong, and this removes it. The mistake was a name collision. Two different frames are both called "the controller frame". GCMotion reports a CONTROLLER in (Right, Forward, Up) — measured on a real DualSense — which is not the wire's frame, which is why `GamepadCapture.forwardMotion` converts. The mirror's orientation remap resolves THIS DEVICE into the frame its header describes, x right, y up, z out of the screen. For the pose that mirror exists to serve — a phone clipped upright with the screen facing the player — "out of the screen" points at the player, so that frame is (Right, Up, Backward), which IS the wire's. It was already correct. Applying the controller path's conversion on top rotated it out of true: a phone sitting still would have reported gravity as −1 g on the roll axis rather than +1 g up, i.e. claimed to be lying on its edge. Reasoning by analogy is what produced it — "the mirror says controller frame, the capture path says controller frame, so the same fix applies". Both files say it; they mean different things. What caught it was measuring the Android twin, which does the same thing straight through. On glass: a DualSense on Bluetooth to a phone, streaming to a Linux host, reads +1 g on the up axis end to end. Had the Apple mirror needed a conversion, the Android one would have needed the same one and would have been visibly wrong. It is not. The same run settles G10's frame, which shipped straight-through and explicitly unverified because nobody had put a Bluetooth pad in front of the platform sensor framework. Now somebody has. `PadSensors`' own first-sample log read `accel 0, 10000, 0` — exactly 1 g on slot 1 — and at the far end hid-playstation published gravity as +0.991 g on ABS_Y, with every rotation driving its correctly-named axis and the signs agreeing with gravity's independent witness on 95 of 100 rotating samples. Android hands a controller's sensors over in the pad's own frame, as documented. No remap, and the comment now says measured instead of assumed. Worth recording why the earlier suspicion was wrong, since it is the same trap in the other direction: Android's DEVICE sensor frame really does put +z out of the screen, so a flat phone puts gravity on z — but a CONTROLLER's sensors are reported in the controller's frame, not the phone's. One platform, two conventions, chosen by what the sensor is attached to. Gate: Apple macOS `swift build` + full suite (215 tests, 5 skipped, 0 failures) and the iOS-triple typecheck, which is what actually compiles `DeviceGyro.swift`; Android `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest`, `:app:compileDebugKotlin`. Green. Still owed: `DeviceGyroRemap`'s four orientation matrices remain derived — this run used a controller's own sensors, not the mirror, so it says nothing about them. They need a gyro-less pad on wire index 0 and a phone turned through all four orientations. --- .../io/unom/punktfunk/kit/PadSensors.kt | 39 +++++++++---------- .../PunktfunkKit/Gamepad/DeviceGyro.swift | 37 ++++++++++-------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/PadSensors.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/PadSensors.kt index 568d934b..8f1d22ed 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/PadSensors.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/PadSensors.kt @@ -211,25 +211,23 @@ class PadSensors(private val router: GamepadRouter) { /** * One gyroscope sample (Android: rad/s) → the wire's three signed-16 components, in place. * - * ⚠ **THE AXIS FRAME IS NOT VERIFIED ON THIS PATH.** The wire is a unit passthrough into a - * virtual DualSense report, and that report's frame was MEASURED on 2026-08-07 over raw - * HID: slot 0 = Right (pitch), slot 1 = Up (yaw), slot 2 = Backward, toward the player - * (roll), right-handed. Android documents its sensor frame for a handheld device as +x - * right, +y up, +z out of the face — the same frame once "the face" is read as the one the - * player looks at, which is what the platform means by a controller's own sensor. So - * straight through is the mapping the documentation implies, and it is what this does. - * What nobody has DONE is put a Bluetooth DualSense in front of the platform sensor - * framework and compare: those numbers come through a HID driver and InputFlinger's sensor - * mapper, either of which could permute or negate without saying so. + * The axis frame is straight through, and that is now MEASURED rather than assumed. * - * The measurement that settles it is the DualSense one repeated on this path — pad flat - * and still, then three labelled rotations: - * - at rest, gravity must land as +1 g on ACCEL slot 1 (not 0, not 2); - * - yaw clockwise seen from above ⇒ GYRO slot 1 negative; - * - pitch the far edge down ⇒ slot 0 negative; - * - roll right-side down ⇒ slot 2 negative. - * Any disagreement is a remap, and it belongs HERE with its own expectations in - * `PadSensorsTest` — not spread across callers, and not guessed at in advance. + * The wire is a unit passthrough into a virtual DualSense report, whose frame was measured + * over raw HID on 2026-08-07: slot 0 = Right (pitch), slot 1 = Up (yaw), slot 2 = Backward + * toward the player (roll), right-handed. Android hands a controller's own sensors over in + * that same frame — which was the documented expectation, but the numbers pass through a + * HID driver and InputFlinger's sensor mapper, either of which could have permuted or + * negated without saying so. + * + * Verified 2026-08-07 end to end: a DualSense on Bluetooth to an Android phone, streaming + * to a Linux host. This path's own first-sample log read `accel 0, 10000, 0` — exactly 1 g + * on slot 1 — and at the far end `hid-playstation` published gravity as +0.991 g on ABS_Y + * with every rotation driving its correctly-named axis (yaw→RY, pitch→RX, roll→RZ) and the + * signs agreeing with gravity's independent witness on 95 of 100 rotating samples. + * + * So: no remap. If a future device disagrees, the remap belongs HERE with its own + * expectations in `PadSensorsTest` — not spread across callers. */ fun gyroToWire(values: FloatArray, out: IntArray) { for (i in 0..2) out[i] = Gamepad.motionGyroWire(values.getOrElse(i) { 0f }) @@ -237,9 +235,10 @@ class PadSensors(private val router: GamepadRouter) { /** * One accelerometer sample (Android: m/s², specific force) → the wire's three signed-16 - * components, in place. Same unverified frame as [gyroToWire] and the same straight-through + * components, in place. Same measured frame as [gyroToWire] and the same straight-through * mapping; the sign needs no flip, because Android and the DualSense report agree that the - * axis pointing up reads +1 g at rest (see [Gamepad.motionAccelWire]). + * axis pointing up reads +1 g at rest (see [Gamepad.motionAccelWire]) — which is precisely + * what the on-glass run read back, `accel 0, 10000, 0` with the pad lying flat. */ fun accelToWire(values: FloatArray, out: IntArray) { for (i in 0..2) out[i] = Gamepad.motionAccelWire(values.getOrElse(i) { 0f }) diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift index 53a09354..b3e88cde 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/DeviceGyro.swift @@ -183,28 +183,33 @@ public final class DeviceGyro { x: -Float(m.gravity.x + m.userAcceleration.x), y: -Float(m.gravity.y + m.userAcceleration.y), z: -Float(m.gravity.z + m.userAcceleration.z)) - // Then the SAME change of basis the controller path takes. `r` puts the sample in the - // controller frame this file's header describes — x right, y up, z out of the screen — - // which is exactly GameController's frame, and that is not the DualSense report frame the - // wire is defined in. Measured 2026-08-07; see `GamepadWire.appleMotionToWire`. + // NO frame conversion here, and that is not an oversight — `GamepadCapture.forwardMotion` + // applies `GamepadWire.appleMotionToWire` and this deliberately does not. // - // Both corrections are here because the header states the intent plainly: units and axis - // semantics match `GamepadCapture.forwardMotion` so a sign/scale fix lands in one place for - // both sources. Fixing only the controller path would have left this one silently on the - // old convention — a mirror that disagrees with the thing it mirrors. - let g = GamepadWire.appleMotionToWire((rot.x, rot.y, rot.z)) - let a = GamepadWire.appleMotionToWire((acc.x, acc.y, acc.z)) + // The trap is that two different frames are both called "the controller frame". GCMotion + // reports a CONTROLLER in (Right, Forward, Up) — measured on a real DualSense — which is + // not the wire's frame, hence the conversion over there. `r` above resolves THIS DEVICE + // into the frame the header describes: x right, y up, z out of the screen. For the pose + // this mirror exists to serve — a phone clipped upright, screen facing the player — "out of + // the screen" points AT the player, so that frame is (Right, Up, Backward), which IS the + // wire's frame. Straight through is already correct. + // + // Applying the controller path's conversion here was tried and was WRONG: a phone at rest + // would have reported gravity as −1 g on the roll axis instead of +1 g up, i.e. lying on + // its edge. Caught by measuring the Android twin, which does the same thing straight + // through and reads +1 g on the up axis end to end. If a future capture path needs a + // conversion, decide it from that source's OWN measured frame rather than by analogy. let gs = GamepadWire.gyroLSBPerRadS let as_ = GamepadWire.accelLSBPerG let gyro = ( - GamepadWire.motionRaw(g.0, scale: gs), - GamepadWire.motionRaw(g.1, scale: gs), - GamepadWire.motionRaw(g.2, scale: gs) + GamepadWire.motionRaw(rot.x, scale: gs), + GamepadWire.motionRaw(rot.y, scale: gs), + GamepadWire.motionRaw(rot.z, scale: gs) ) let accel = ( - GamepadWire.motionRaw(a.0, scale: as_), - GamepadWire.motionRaw(a.1, scale: as_), - GamepadWire.motionRaw(a.2, scale: as_) + GamepadWire.motionRaw(acc.x, scale: as_), + GamepadWire.motionRaw(acc.y, scale: as_), + GamepadWire.motionRaw(acc.z, scale: as_) ) state.lock.lock() state.lastAccel = accel From a85e8452558e31c22ef13585b47d83df723eb568 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 22:34:24 +0200 Subject: [PATCH 21/22] fix(web): the console stops falling out of its own design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pre-release sweep of the management console for two things that no type check and no diff can catch: primitives that were never @unom/ui's, and animation that a nested motion parent quietly cancelled. THE PRESET TILES ALL LANDED ON THE SAME FRAME. @unom/ui's

sets `delayChildren: stagger(...)`, so a page whose cards are direct descendants of it staggers for free — which is why every page but one looked right. An is ALSO a motion element and sets no `delayChildren`, and the Virtual displays preset tiles are cards nested INSIDE that page's config card, so that card became their timing group. Measured in a headless browser: the opacity spread between the first and last tile was 0.00 across the whole animation (six tiles in lockstep), and is 0.98 now — a ~100 ms cascade matching the rest of the console. The four hand-rolled copies of the stagger container collapse into one `` that carries the explanation. FIVE FILES IMPORTED THE WRONG BUTTON. `@unom/ui/button` exports both a plain `Button` and the `AnimatedButton` that this console's wrapper re-exports under the same name — so `import { Button } from "@unom/ui/button"` compiles, renders, and silently opts out of the mount animation and the hover/tap response. Displays, SessionGame, GPU, Update and PendingDevices had dead buttons sitting next to live ones. THREE PRIMITIVES HAD NO WRAPPER, SO NOBODY REACHED FOR THEM. @unom/ui ships form/select, form/textarea and form/checkbox; components/ui did not, and the gap was filled with browser-chrome `