From f266636392ce51830169a11ca998171e742bd659 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 12:11:04 +0200 Subject: [PATCH 01/16] feat(host/pads): an Xbox pad on Windows becomes a real HID device, so Steam can see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pf-xusb` registers only GUID_DEVINTERFACE_XUSB and exposes no HID collection, so Steam's hidapi enumeration, DirectInput, joy.cpl and WGI/GameInput cannot see the pad at all — only classic XInputGetState via xinput1_4's interface walk ever does. A reporter spent two weeks on a dead controller for exactly that reason; switching the client to DualSense, a real HID pad through the pf-gamepad UMDF driver, fixed it in seconds. This gives the Xbox pad that same footing: a new device_type 4 on the existing HID minidriver, identified as a Bluetooth Xbox Wireless Controller (045E:0B13). The wired ids the tree already uses (045E:028E, 045E:02EA) are vendor-class XUSB/GIP devices with no HID interface on real hardware, so a HID child claiming one is a device that has never existed and has nothing for Windows to promote. Driver: identity, a constructed 132-byte Game Pad report descriptor, neutral report, strings and the pf_xboxwireless hardware id. Host: `xbox_proto`, the byte-exact codec mirroring that descriptor, with 11 layout tests. One shared-path fix falls out. The timer completed every pended READ_REPORT with the full 64-byte slot, and `copy_to_output` REFUSES a source longer than hidclass's buffer rather than truncating it — so a pad declaring a shorter report would have failed every read and looked dead. Report length is now per-identity; it returns 64 for all four pre-existing pads, so their behaviour is provably unchanged. NOT BUILT AND NOT RUN ON WINDOWS — no box was reachable. The Rust codec and its tests pass on macOS; the driver, the INF and the report descriptor have never been compiled, infverif'd, or seen by a real pad. The descriptor is constructed rather than captured, which matters because we claim a real Microsoft VID/PID and SDL/Steam/Windows carry stock mappings keyed off it — diff it against a capture before shipping. --- crates/pf-driver-proto/src/lib.rs | 13 + .../pf-inject/src/inject/proto/xbox_proto.rs | 386 ++++++++++++++++++ crates/pf-inject/src/lib.rs | 10 + .../windows/drivers/pf-gamepad/pf_gamepad.inx | 2 + .../windows/drivers/pf-gamepad/src/lib.rs | 200 ++++++++- 5 files changed, 599 insertions(+), 12 deletions(-) create mode 100644 crates/pf-inject/src/inject/proto/xbox_proto.rs diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 3ea79836..4e2c8cc9 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -791,6 +791,19 @@ pub mod gamepad { /// Steam Input on Windows when the devnode's synthesized USB hardware ids carry `&MI_02` /// (the wired controller interface — the N4-spike finding). pub const DEVTYPE_STEAMDECK: u8 = 3; + /// `device_type` = Xbox Wireless Controller (`VID_045E&PID_0B13` HID identity — a Bluetooth + /// Xbox pad, which unlike the wired `045E:028E`/`045E:02EA` ids IS a real HID device). + /// + /// This exists because the OTHER Windows Xbox backend, `pf-xusb`, registers only + /// `GUID_DEVINTERFACE_XUSB` and has no HID collection — so Steam, WGI, GameInput, DirectInput + /// and `joy.cpl` cannot enumerate it at all, and only classic `XInputGetState` ever sees it + /// (field 2026-08-09). Routing an Xbox pad through this identity instead puts it on the same + /// HID footing the PlayStation pads have always had. + /// + /// ⚠️ Unlike its siblings the Xbox input report is NOT 64 bytes — it is + /// `XBOX_INPUT_REPORT_LEN` (16). The driver serves per-identity report lengths because + /// hidclass sizes its buffer from the descriptor and refuses an over-long source. + pub const DEVTYPE_XBOX: u8 = 4; /// The value a gamepad driver writes into its section's `driver_proto` field once it attaches — /// the host's positive "driver is alive on this section" signal (health check + version audit). diff --git a/crates/pf-inject/src/inject/proto/xbox_proto.rs b/crates/pf-inject/src/inject/proto/xbox_proto.rs new file mode 100644 index 00000000..6cbe48de --- /dev/null +++ b/crates/pf-inject/src/inject/proto/xbox_proto.rs @@ -0,0 +1,386 @@ +//! Xbox Wireless Controller HID codec — the byte-exact input report the `pf-gamepad` driver serves +//! under `device_type = 4` ([`pf_driver_proto::gamepad::DEVTYPE_XBOX`]). +//! +//! **Why an Xbox pad speaks HID at all.** The other Windows Xbox backend, `pf-xusb`, registers only +//! `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi enumeration, +//! DirectInput, `joy.cpl` and WGI/GameInput cannot see it — only classic `XInputGetState` via +//! xinput1_4's interface walk ever does. A field report (2026-08-09) burned two weeks on a dead +//! controller for exactly that reason, and switching the client to DualSense — a real HID pad +//! through the same UMDF driver — fixed it instantly. This codec puts the Xbox pad on that footing. +//! +//! **The report is the descriptor's mirror image.** `pf-gamepad`'s `XBOX_RDESC` declares, in order: +//! two 16-bit stick pairs (`X`/`Y`, then `Rx`/`Ry`, logical 0..65535), two 16-bit triggers on the +//! Simulation page (`Brake`/`Accelerator`, logical 0..1023), a 4-bit null-state hat plus 4 bits of +//! padding, and 15 buttons plus 1 bit of padding. [`serialize_xbox_state`] writes exactly that, and +//! [`tests`] pins every field position — change one side and the tests fail. +//! +//! ⚠️⚠️ **The button numbering below is the REAL Xbox-Bluetooth layout, gaps included, and that is +//! load-bearing.** We enumerate as a genuine Microsoft `045E:0B13`, and SDL / Steam / Windows all +//! carry built-in mappings keyed off that VID/PID. Renumber these to something "tidier" and every +//! consumer with a stock mapping silently lands each control on the wrong action — the exact class +//! of bug this module exists to end. The reserved slots (3, 6, 9, 10) are Microsoft's; leave them +//! empty. +//! +//! ⚠️ **Never validated against real hardware.** No Windows box was reachable when this was written +//! (`punktfunk-field-windows-pad-dead-0260`), so the layout is from the documented Xbox One S / Series +//! Bluetooth report and has not been diffed against a capture. Do that before shipping: dump a real +//! pad's descriptor + a few reports and compare against `XBOX_RDESC` and the tests here. + +use punktfunk_core::input::gamepad as gs; + +/// Bytes an Xbox input report occupies on the wire, report id included. Must equal the driver's +/// `XBOX_INPUT_REPORT_LEN` — hidclass sizes its READ_REPORT buffer from the descriptor and the +/// driver's `copy_to_output` refuses a longer source rather than truncating. +pub const XBOX_REPORT_LEN: usize = 16; + +/// The report id the descriptor declares for the input report. +const REPORT_ID: u8 = 0x01; + +/// Stick centre on the descriptor's 0..65535 axis. +const STICK_CENTRE: u16 = 0x8000; + +/// Trigger full scale on the descriptor's 0..1023 (10-bit) axis. +const TRIGGER_MAX: u32 = 1023; + +// ---- Button bit positions, LSB-first across report bytes 14..16 ---- +// +// HID button N lands on bit (N-1). These are the REAL Xbox-Bluetooth assignments; slots 3, 6, 9 +// and 10 are reserved by Microsoft and stay empty (see the module note). +const BIT_A: u8 = 0; // button 1 +const BIT_B: u8 = 1; // button 2 +const BIT_X: u8 = 3; // button 4 +const BIT_Y: u8 = 4; // button 5 +const BIT_LB: u8 = 6; // button 7 +const BIT_RB: u8 = 7; // button 8 +const BIT_VIEW: u8 = 10; // button 11 (Back/Select) +const BIT_MENU: u8 = 11; // button 12 (Start) +const BIT_GUIDE: u8 = 12; // button 13 (Xbox button) +const BIT_LS: u8 = 13; // button 14 (left stick click) +const BIT_RS: u8 = 14; // button 15 (right stick click) + +/// One Xbox pad's state, in the wire's own conventions (sticks −32768..32767 with **+y = up**, +/// triggers 0..255, buttons the [`gs`] `BTN_*` bitmask) — converted to the HID report's +/// conventions by [`serialize_xbox_state`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct XboxState { + pub buttons: u32, + pub left_trigger: u8, + pub right_trigger: u8, + pub ls_x: i16, + pub ls_y: i16, + pub rs_x: i16, + pub rs_y: i16, +} + +impl XboxState { + /// Build from the wire's per-pad frame fields (`punktfunk_core::input::GamepadFrame`). + #[allow(clippy::too_many_arguments)] + pub fn from_gamepad( + buttons: u32, + left_trigger: u8, + right_trigger: u8, + ls_x: i16, + ls_y: i16, + rs_x: i16, + rs_y: i16, + ) -> XboxState { + XboxState { + buttons, + left_trigger, + right_trigger, + ls_x, + ls_y, + rs_x, + rs_y, + } + } +} + +/// Wire stick axis (−32768..32767) → the descriptor's unsigned 0..65535 X/Rx axis. +fn axis_x(v: i16) -> u16 { + (v as i32 + 32768) as u16 +} + +/// Wire stick axis → the descriptor's 0..65535 Y/Ry axis, **inverted**. +/// +/// The wire follows the XInput/Moonlight convention where **+y is UP**; HID's `Y`/`Ry` grow +/// DOWNWARD. Forwarding the wire value unconverted is how a pad ends up with an inverted look +/// stick that nobody notices until they aim. +/// +/// ⚠️ A signed 16-bit range has no exact midpoint, so the inverted axis centres one unit lower +/// than the upright one: `axis_x(0)` is 32768 and `axis_y(0)` is 32767. Both endpoints are exact +/// (full up → 0, full down → 65535), which is what matters; the 1/65536 offset at rest is below +/// any deadzone. Do NOT "fix" it by centring on 32768 — that costs an endpoint instead. +fn axis_y(v: i16) -> u16 { + 65535 - axis_x(v) +} + +/// Wire trigger (0..255) → the descriptor's 10-bit 0..1023 axis, rounded rather than truncated so +/// a fully-held trigger reads exactly full scale. +fn trigger(v: u8) -> u16 { + ((v as u32 * TRIGGER_MAX + 127) / 255) as u16 +} + +/// The d-pad bits → the descriptor's hat value: `0` is the NULL state (the logical range starts at +/// 1), then 1..8 clockwise from North. Opposing presses cancel, matching a physical hat. +fn hat(buttons: u32) -> u8 { + let up = buttons & gs::BTN_DPAD_UP != 0; + let down = buttons & gs::BTN_DPAD_DOWN != 0; + let left = buttons & gs::BTN_DPAD_LEFT != 0; + let right = buttons & gs::BTN_DPAD_RIGHT != 0; + // Cancel opposing pairs first so up+down reads centred rather than picking one. + let (up, down) = if up && down { + (false, false) + } else { + (up, down) + }; + let (left, right) = if left && right { + (false, false) + } else { + (left, right) + }; + match (up, right, down, left) { + (true, false, false, false) => 1, // N + (true, true, false, false) => 2, // NE + (false, true, false, false) => 3, // E + (false, true, true, false) => 4, // SE + (false, false, true, false) => 5, // S + (false, false, true, true) => 6, // SW + (false, false, false, true) => 7, // W + (true, false, false, true) => 8, // NW + _ => 0, // nothing held → NULL + } +} + +/// The 15 face/shoulder/system buttons packed into the report's last two bytes. +fn button_bits(buttons: u32) -> (u8, u8) { + let mut bits: u16 = 0; + for (mask, bit) in [ + (gs::BTN_A, BIT_A), + (gs::BTN_B, BIT_B), + (gs::BTN_X, BIT_X), + (gs::BTN_Y, BIT_Y), + (gs::BTN_LB, BIT_LB), + (gs::BTN_RB, BIT_RB), + (gs::BTN_BACK, BIT_VIEW), + (gs::BTN_START, BIT_MENU), + (gs::BTN_GUIDE, BIT_GUIDE), + (gs::BTN_LS_CLICK, BIT_LS), + (gs::BTN_RS_CLICK, BIT_RS), + ] { + if buttons & mask != 0 { + bits |= 1 << bit; + } + } + (bits as u8, (bits >> 8) as u8) +} + +/// Serialize one [`XboxState`] into the driver's input report. +pub fn serialize_xbox_state(s: &XboxState) -> [u8; XBOX_REPORT_LEN] { + let mut r = [0u8; XBOX_REPORT_LEN]; + r[0] = REPORT_ID; + r[1..3].copy_from_slice(&axis_x(s.ls_x).to_le_bytes()); + r[3..5].copy_from_slice(&axis_y(s.ls_y).to_le_bytes()); + r[5..7].copy_from_slice(&axis_x(s.rs_x).to_le_bytes()); + r[7..9].copy_from_slice(&axis_y(s.rs_y).to_le_bytes()); + r[9..11].copy_from_slice(&trigger(s.left_trigger).to_le_bytes()); + r[11..13].copy_from_slice(&trigger(s.right_trigger).to_le_bytes()); + r[13] = hat(s.buttons); // low nibble; the high nibble is descriptor padding + let (lo, hi) = button_bits(s.buttons); + r[14] = lo; + r[15] = hi; + r +} + +/// The at-rest report: sticks centred, triggers released, hat NULL, nothing held. Must agree with +/// the driver's `XBOX_NEUTRAL_REPORT` — [`tests::neutral_matches_a_zeroed_state`] pins that. +pub fn neutral_xbox_report() -> [u8; XBOX_REPORT_LEN] { + serialize_xbox_state(&XboxState::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn le(b: &[u8]) -> u16 { + u16::from_le_bytes([b[0], b[1]]) + } + + /// The field offsets the driver's `XBOX_RDESC` declares. If this fails, one side moved. + #[test] + fn the_report_matches_the_descriptor_layout() { + let s = XboxState::from_gamepad(0, 0, 0, 0, 0, 0, 0); + let r = serialize_xbox_state(&s); + assert_eq!(r.len(), XBOX_REPORT_LEN, "16 bytes: id + 8 + 4 + 1 + 2"); + assert_eq!(r[0], 0x01, "report id"); + } + + #[test] + fn sticks_span_the_full_unsigned_axis() { + let full = XboxState::from_gamepad(0, 0, 0, i16::MIN, i16::MIN, i16::MAX, i16::MAX); + let r = serialize_xbox_state(&full); + assert_eq!(le(&r[1..3]), 0, "LX at hard left = 0"); + assert_eq!(le(&r[5..7]), 65535, "RX at hard right = 65535"); + } + + /// +y is UP on the wire and DOWN in HID — the conversion has to flip, or aiming is inverted. + #[test] + fn the_y_axes_are_inverted_into_hid_convention() { + let up = XboxState::from_gamepad(0, 0, 0, 0, i16::MAX, 0, i16::MAX); + let r = serialize_xbox_state(&up); + assert_eq!(le(&r[3..5]), 0, "stick fully UP is 0 in HID"); + assert_eq!(le(&r[7..9]), 0, "right stick too"); + + let down = XboxState::from_gamepad(0, 0, 0, 0, i16::MIN, 0, i16::MIN); + let r = serialize_xbox_state(&down); + assert_eq!(le(&r[3..5]), 65535, "stick fully DOWN is full scale"); + assert_eq!(le(&r[7..9]), 65535); + } + + /// At rest the upright axes sit on `STICK_CENTRE` and the inverted ones one unit below — the + /// unavoidable consequence of mirroring a range with an even number of steps (see `axis_y`). + #[test] + fn a_centred_stick_reads_centred() { + let r = neutral_xbox_report(); + assert_eq!(le(&r[1..3]), STICK_CENTRE, "LX"); + assert_eq!(le(&r[5..7]), STICK_CENTRE, "RX"); + assert_eq!(le(&r[3..5]), STICK_CENTRE - 1, "LY (inverted)"); + assert_eq!(le(&r[7..9]), STICK_CENTRE - 1, "RY (inverted)"); + } + + /// A fully-held trigger must reach exactly full scale — truncating division stops at 1020 and + /// games with a "trigger fully pressed" threshold never fire. + #[test] + fn triggers_scale_to_full_ten_bit_range() { + let none = serialize_xbox_state(&XboxState::default()); + assert_eq!(le(&none[9..11]), 0); + assert_eq!(le(&none[11..13]), 0); + + let held = XboxState::from_gamepad(0, 255, 255, 0, 0, 0, 0); + let r = serialize_xbox_state(&held); + assert_eq!(le(&r[9..11]), 1023, "LT fully held = full scale"); + assert_eq!(le(&r[11..13]), 1023, "RT fully held = full scale"); + + let half = XboxState::from_gamepad(0, 128, 0, 0, 0, 0, 0); + let r = serialize_xbox_state(&half); + assert_eq!(le(&r[9..11]), 514, "128/255 rounds to 514, not 513"); + } + + #[test] + fn the_hat_walks_clockwise_from_north() { + let cases = [ + (0, 0u8), + (gs::BTN_DPAD_UP, 1), + (gs::BTN_DPAD_UP | gs::BTN_DPAD_RIGHT, 2), + (gs::BTN_DPAD_RIGHT, 3), + (gs::BTN_DPAD_RIGHT | gs::BTN_DPAD_DOWN, 4), + (gs::BTN_DPAD_DOWN, 5), + (gs::BTN_DPAD_DOWN | gs::BTN_DPAD_LEFT, 6), + (gs::BTN_DPAD_LEFT, 7), + (gs::BTN_DPAD_UP | gs::BTN_DPAD_LEFT, 8), + ]; + for (buttons, want) in cases { + let r = serialize_xbox_state(&XboxState::from_gamepad(buttons, 0, 0, 0, 0, 0, 0)); + assert_eq!(r[13] & 0x0F, want, "buttons {buttons:#x}"); + } + } + + /// Opposing presses cancel to NULL rather than resolving to one direction — a physical hat + /// cannot report both, and a game that sees "up" while the player holds up+down drifts. + #[test] + fn opposing_dpad_presses_cancel() { + let ud = gs::BTN_DPAD_UP | gs::BTN_DPAD_DOWN; + let r = serialize_xbox_state(&XboxState::from_gamepad(ud, 0, 0, 0, 0, 0, 0)); + assert_eq!(r[13] & 0x0F, 0); + let lr = gs::BTN_DPAD_LEFT | gs::BTN_DPAD_RIGHT; + let r = serialize_xbox_state(&XboxState::from_gamepad(lr, 0, 0, 0, 0, 0, 0)); + assert_eq!(r[13] & 0x0F, 0); + } + + /// The real Xbox-Bluetooth button numbering, gaps included. SDL/Steam/Windows key their stock + /// mappings off our claimed `045E:0B13`, so these positions are a compatibility contract. + #[test] + fn buttons_land_on_the_real_xbox_bluetooth_positions() { + let cases: [(u32, usize, u8); 11] = [ + (gs::BTN_A, 14, 0), + (gs::BTN_B, 14, 1), + (gs::BTN_X, 14, 3), + (gs::BTN_Y, 14, 4), + (gs::BTN_LB, 14, 6), + (gs::BTN_RB, 14, 7), + (gs::BTN_BACK, 15, 2), + (gs::BTN_START, 15, 3), + (gs::BTN_GUIDE, 15, 4), + (gs::BTN_LS_CLICK, 15, 5), + (gs::BTN_RS_CLICK, 15, 6), + ]; + for (mask, byte, bit) in cases { + let r = serialize_xbox_state(&XboxState::from_gamepad(mask, 0, 0, 0, 0, 0, 0)); + assert_eq!( + r[byte] & (1u8 << bit), + 1u8 << bit, + "mask {mask:#x} should set byte {byte} bit {bit}" + ); + // and nothing else in the button bytes + let shift = bit as u16 + (byte as u16 - 14) * 8; + let others = (r[14] as u16 | (r[15] as u16) << 8) & !(1u16 << shift); + assert_eq!(others, 0, "mask {mask:#x} set a second button bit"); + } + } + + /// Microsoft's reserved slots (buttons 3, 6, 9, 10) and the descriptor's trailing pad bit must + /// stay clear — a stray bit there reads as a button the real pad does not have. + #[test] + fn reserved_button_slots_stay_empty() { + let all = gs::BTN_A + | gs::BTN_B + | gs::BTN_X + | gs::BTN_Y + | gs::BTN_LB + | gs::BTN_RB + | gs::BTN_BACK + | gs::BTN_START + | gs::BTN_GUIDE + | gs::BTN_LS_CLICK + | gs::BTN_RS_CLICK; + let r = serialize_xbox_state(&XboxState::from_gamepad(all, 0, 0, 0, 0, 0, 0)); + let bits = r[14] as u16 | (r[15] as u16) << 8; + for reserved_bit in [2u8, 5, 8, 9, 15] { + assert_eq!( + bits & (1 << reserved_bit), + 0, + "bit {reserved_bit} is reserved/padding and must stay clear" + ); + } + } + + /// Extended wire buttons the Xbox HID profile has no slot for (touchpad, capture, paddles) must + /// be dropped silently rather than colliding with a real button. + #[test] + fn unmappable_wire_buttons_are_dropped() { + let extra = gs::BTN_TOUCHPAD | gs::BTN_MISC1 | gs::BTN_PADDLE1; + let r = serialize_xbox_state(&XboxState::from_gamepad(extra, 0, 0, 0, 0, 0, 0)); + assert_eq!(r[14], 0); + assert_eq!(r[15], 0); + assert_eq!(r[13] & 0x0F, 0); + } + + #[test] + fn neutral_matches_a_zeroed_state() { + assert_eq!( + neutral_xbox_report(), + serialize_xbox_state(&XboxState::default()) + ); + let r = neutral_xbox_report(); + assert_eq!(r[13], 0, "hat NULL"); + assert_eq!(r[14], 0); + assert_eq!(r[15], 0); + // Mirrors the driver's XBOX_NEUTRAL_REPORT byte for byte — if these drift, a game reads a + // different at-rest pose before the host's first frame lands than after it. + assert_eq!(r[0], 0x01); + assert_eq!([r[1], r[2]], [0x00, 0x80], "LX = 0x8000"); + assert_eq!([r[3], r[4]], [0xFF, 0x7F], "LY = 0x7FFF (inverted centre)"); + assert_eq!([r[5], r[6]], [0x00, 0x80], "RX = 0x8000"); + assert_eq!([r[7], r[8]], [0xFF, 0x7F], "RY = 0x7FFF (inverted centre)"); + } +} diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index c1f63766..8ca7deab 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -474,6 +474,16 @@ pub mod uhid_abi; #[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/uhid_manager.rs"] pub mod uhid_manager; +/// Transport-independent Xbox Wireless Controller HID codec — the report the `pf-gamepad` UMDF +/// driver serves under device-type 4, giving an Xbox pad the HID footing `pf-xusb` never had +/// (Steam / WGI / GameInput / DirectInput cannot see an XUSB-interface-only device). +/// +/// Deliberately NOT cfg-gated to linux/windows like its siblings: it is pure byte-packing with no +/// OS surface, so its layout tests compile and run on any host — including the macOS dev machines +/// where the Windows backends cannot be built at all. That is the only automated check this codec +/// has until a Windows box is reachable. +#[path = "inject/proto/xbox_proto.rs"] +pub mod xbox_proto; /// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere. #[cfg(not(any(target_os = "linux", target_os = "windows")))] pub mod gamepad { diff --git a/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx index a861c3b1..e7d876e7 100644 --- a/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx +++ b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx @@ -47,6 +47,7 @@ pf_gamepad.dll=1 %DeviceDescDS4%=pfGamepad, pf_dualshock4 %DeviceDescEdge%=pfGamepad, pf_dualsenseedge %DeviceDescDeck%=pfGamepad, pf_steamdeck +%DeviceDescXbox%=pfGamepad, pf_xboxwireless [pfGamepad.NT] CopyFiles=UMDriverCopy @@ -100,3 +101,4 @@ DeviceDesc ="Punktfunk Virtual DualSense" DeviceDescDS4 ="Punktfunk Virtual DualShock 4" DeviceDescEdge ="Punktfunk Virtual DualSense Edge" DeviceDescDeck ="Punktfunk Virtual Steam Deck Controller" +DeviceDescXbox ="Punktfunk Virtual Xbox Wireless Controller" diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index 15dae40e..763d51f0 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -1,7 +1,8 @@ // punktfunk virtual DualSense / DualShock 4 / DualSense Edge — UMDF2 HID minidriver. // // A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense -// (VID 054C / PID 0CE6), DualShock 4 (device_type=1) or DualSense Edge (device_type=2) using the +// (VID 054C / PID 0CE6), DualShock 4 (device_type=1), DualSense Edge (device_type=2), Steam Deck +// (device_type=3) or Xbox Wireless Controller (device_type=4, VID 045E / PID 0B13) using the // report descriptors + feature blobs punktfunk already ships in `inject/`. Games see a genuine // HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back. // @@ -72,6 +73,35 @@ const DS_EDGE_PID: u16 = 0x0DF2; const DECK_VID: u16 = 0x28DE; const DECK_PID: u16 = 0x1205; +// ---- Xbox Wireless Controller identity (device_type=4) ---- +// +// WHY THIS EXISTS (field 2026-08-09, `punktfunk-field-windows-pad-dead-0260`): the OTHER Windows +// Xbox backend — `pf-xusb` — registers ONLY `GUID_DEVINTERFACE_XUSB` and has no HID collection at +// all, so it is invisible to Steam's hidapi enumeration, to DirectInput, to `joy.cpl`, and to +// WGI/GameInput. Only classic `XInputGetState` via xinput1_4's interface walk ever sees it. A +// reporter spent two weeks on a dead controller for exactly that reason, and switching the client +// to DualSense — a REAL HID pad through this driver — fixed it instantly. This identity gives the +// Xbox pad the same HID footing the PlayStation ones have always had. +// +// ⚠️⚠️ **The VID/PID is a BLUETOOTH Xbox controller on purpose.** The wired ids the rest of the +// tree uses (`045E:028E` X-Box 360, `045E:02EA` Xbox One S USB) are vendor-class XUSB/GIP devices — +// they expose NO HID interface on real hardware, so a HID child claiming one is a device that has +// never existed and inbox promotion has nothing to match. The Xbox pads that genuinely ARE HID are +// the Bluetooth ones, which Windows binds through HIDCLASS. +const XBOX_VID: u16 = 0x045E; +/// Xbox Wireless Controller (Series X|S), Bluetooth. Chosen over the Xbox One S BT id `0x02FD` +/// because the host's OS floor is Windows 11 22H2, where this is the current-generation identity +/// (so glyphs read "Xbox Series") and SDL's mapping database covers it. +/// +/// ⚠️ **If on-glass shows Windows does not promote this to an Xbox-profile pad, try `0x02FD` +/// (Xbox One S BT) — it has the broadest inbox coverage.** Deliberately one named constant so that +/// experiment is a one-line change. +const XBOX_PID: u16 = 0x0B13; +/// Alternate identity for the promotion experiment above — Xbox One S controller over Bluetooth. +#[allow(dead_code)] +const XBOX_PID_ONE_S: u16 = 0x02FD; +const XBOX_VER: u16 = 0x0407; + // Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs). // NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273. #[rustfmt::skip] @@ -241,6 +271,99 @@ static DECK_RDESC: [u8; 38] = [ 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0, ]; +// ---- Xbox Wireless Controller assets (served when the host stamps device_type=4) ---- +// +// A standards-clean Game Pad collection matching the Bluetooth Xbox layout: two 16-bit stick pairs, +// two 10-bit triggers on the Simulation page, a null-state hat, and 15 buttons. Report `0x01`, +// [`XBOX_INPUT_REPORT_LEN`] bytes on the wire including the id. `inject/proto/xbox_proto.rs` packs +// the matching bytes host-side; `xbox_proto`'s tests pin the two together. +// +// ⚠️⚠️⚠️ **PROVENANCE: this descriptor is CONSTRUCTED, not captured — unlike every sibling here +// (`DUALSENSE_RDESC` verbatim from inputtino, `DS4_RDESC` verbatim from `inject/dualshock4.rs`, +// `DECK_RDESC` captured off a real `28DE:1205`). It has never been compared against a real pad.** +// That matters more than usual: we claim a REAL Microsoft VID/PID, and SDL / Steam / Windows keep +// built-in mappings keyed off that VID/PID. If a consumer applies its stock `045E:0B13` mapping to a +// report laid out differently from the real device, every control silently lands on the wrong +// action — the same class of bug this whole change exists to kill. +// +// **Before shipping: capture the report descriptor from a real Xbox Wireless Controller over +// Bluetooth and diff it against this.** Recipe: pair the pad, then read +// `HKLM\SYSTEM\CurrentControlSet\Enum\BTHENUM\...\Device Parameters` or use a HID monitor; +// `hidapi`'s `hidapi-hidtest` and Linux `/sys/class/hidraw/hidrawN/device/report_descriptor` both +// dump it directly. Replace this blob and re-run the `xbox_proto` layout tests. +#[rustfmt::skip] +static XBOX_RDESC: [u8; 132] = [ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x05, // Usage (Game Pad) + 0xA1, 0x01, // Collection (Application) + 0x85, 0x01, // Report ID (1) + 0x09, 0x01, // Usage (Pointer) + 0xA1, 0x00, // Collection (Physical) + 0x09, 0x30, // Usage (X) — left stick X + 0x09, 0x31, // Usage (Y) — left stick Y + 0x15, 0x00, // Logical Minimum (0) + 0x27, 0xFF, 0xFF, 0x00, 0x00, // Logical Maximum (65535) + 0x95, 0x02, // Report Count (2) + 0x75, 0x10, // Report Size (16) + 0x81, 0x02, // Input (Data,Var,Abs) + 0xC0, // End Collection + 0x09, 0x01, // Usage (Pointer) + 0xA1, 0x00, // Collection (Physical) + 0x09, 0x33, // Usage (Rx) — right stick X + 0x09, 0x34, // Usage (Ry) — right stick Y + 0x15, 0x00, // Logical Minimum (0) + 0x27, 0xFF, 0xFF, 0x00, 0x00, // Logical Maximum (65535) + 0x95, 0x02, // Report Count (2) + 0x75, 0x10, // Report Size (16) + 0x81, 0x02, // Input (Data,Var,Abs) + 0xC0, // End Collection + 0x05, 0x02, // Usage Page (Simulation Controls) + 0x09, 0xC5, // Usage (Brake) — left trigger + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x03, // Logical Maximum (1023) + 0x95, 0x01, // Report Count (1) + 0x75, 0x10, // Report Size (16) + 0x81, 0x02, // Input (Data,Var,Abs) + 0x09, 0xC4, // Usage (Accelerator) — right trigger + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x03, // Logical Maximum (1023) + 0x95, 0x01, // Report Count (1) + 0x75, 0x10, // Report Size (16) + 0x81, 0x02, // Input (Data,Var,Abs) + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x39, // Usage (Hat switch) + 0x15, 0x01, // Logical Minimum (1) + 0x25, 0x08, // Logical Maximum (8) + 0x35, 0x00, // Physical Minimum (0) + 0x46, 0x3B, 0x01, // Physical Maximum (315) + 0x65, 0x14, // Unit (Eng Rot: Degrees) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x81, 0x42, // Input (Data,Var,Abs,Null State) + 0x65, 0x00, // Unit (None) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x81, 0x03, // Input (Cnst,Var,Abs) — pad the hat byte + 0x05, 0x09, // Usage Page (Button) + 0x19, 0x01, // Usage Minimum (Button 1) + 0x29, 0x0F, // Usage Maximum (Button 15) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x01, // Report Size (1) + 0x95, 0x0F, // Report Count (15) + 0x81, 0x02, // Input (Data,Var,Abs) + 0x75, 0x01, // Report Size (1) + 0x95, 0x01, // Report Count (1) + 0x81, 0x03, // Input (Cnst,Var,Abs) — pad to a byte boundary + 0xC0, // End Collection +]; + +/// Bytes the Xbox input report occupies on the wire, report id included — 1 id + 8 sticks + +/// 4 triggers + 1 hat + 2 buttons. hidclass sizes its READ_REPORT buffer from the descriptor, and +/// [`Request::copy_to_output`] REFUSES a source longer than that buffer (it does not truncate), so +/// the completion path must serve exactly this many bytes. See [`input_report_len`]. +const XBOX_INPUT_REPORT_LEN: usize = 16; + // HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then // {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB); // DualSense Edge = 389 (0x0185). @@ -248,24 +371,43 @@ static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01 static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01]; static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01]; static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes +static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x84, 0x00]; // 132 bytes // HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11]. // `devtype` selects the identity: PS family (same Sony VID/version) or the N4-spike Deck. fn hid_attrs(devtype: u8) -> [u8; 32] { - let (vid, pid) = match devtype { - 1 => (DS_VID, DS4_PID), - 2 => (DS_VID, DS_EDGE_PID), - 3 => (DECK_VID, DECK_PID), - _ => (DS_VID, DS_PID), + let (vid, pid, ver) = match devtype { + 1 => (DS_VID, DS4_PID, DS_VER), + 2 => (DS_VID, DS_EDGE_PID, DS_VER), + 3 => (DECK_VID, DECK_PID, DS_VER), + 4 => (XBOX_VID, XBOX_PID, XBOX_VER), + _ => (DS_VID, DS_PID, DS_VER), }; let mut a = [0u8; 32]; a[0..4].copy_from_slice(&32u32.to_le_bytes()); a[4..6].copy_from_slice(&vid.to_le_bytes()); a[6..8].copy_from_slice(&pid.to_le_bytes()); - a[8..10].copy_from_slice(&DS_VER.to_le_bytes()); + a[8..10].copy_from_slice(&ver.to_le_bytes()); a } +/// Bytes to hand a pended `IOCTL_HID_READ_REPORT`, per identity. +/// +/// The PlayStation/Deck identities all declare 64-byte input reports, which is why the report slot +/// and [`INPUT_REPORT`] are 64 bytes wide and the completion path could hand the whole buffer over +/// unconditionally. The Xbox identity declares a [`XBOX_INPUT_REPORT_LEN`]-byte report, and +/// [`Request::copy_to_output`] returns `STATUS_INVALID_BUFFER_SIZE` when the source is LONGER than +/// the caller's buffer rather than truncating — so handing hidclass 64 bytes for a 16-byte report +/// fails every single read and the pad looks dead. +/// +/// Returns 64 for every pre-existing identity, so this is provably a no-op for them. +fn input_report_len(devtype: u8) -> usize { + match devtype { + 4 => XBOX_INPUT_REPORT_LEN, + _ => 64, + } +} + // Neutral DualSense input report 0x01 (64 bytes): sticks centered (0x80), triggers 0, dpad neutral (8). const NEUTRAL_REPORT: [u8; 64] = { let mut r = [0u8; 64]; @@ -299,10 +441,26 @@ const DECK_NEUTRAL_REPORT: [u8; 64] = { r[3] = 0x3C; r }; +// Neutral Xbox input report 0x01: both sticks centred (0x8000 on a 0..65535 axis), triggers 0, +// hat 0 (the descriptor's NULL state — the logical range starts at 1), no buttons held. Only the +// first [`XBOX_INPUT_REPORT_LEN`] bytes are ever served; the rest of the 64-byte slot stays zero so +// the shared [`INPUT_REPORT`] type is unchanged. +const XBOX_NEUTRAL_REPORT: [u8; 64] = { + let mut r = [0u8; 64]; + r[0] = 0x01; // report id + r[2] = 0x80; // LX = 0x8000 (little-endian) + r[3] = 0xFF; // LY = 0x7FFF — the Y axes are INVERTED (+y is up on the wire, down in HID), + r[4] = 0x7F; // and mirroring an even-sized range centres one unit low. See `xbox_proto`. + r[6] = 0x80; // RX = 0x8000 + r[7] = 0xFF; // RY = 0x7FFF + r[8] = 0x7F; + r +}; fn neutral_report(devtype: u8) -> [u8; 64] { match devtype { 1 => DS4_NEUTRAL_REPORT, 3 => DECK_NEUTRAL_REPORT, + 4 => XBOX_NEUTRAL_REPORT, _ => NEUTRAL_REPORT, // DualSense and Edge share the report 0x01 shape } } @@ -474,7 +632,8 @@ static TICK: AtomicU32 = AtomicU32::new(0); /// Order matters: `pf_dualsense` is a prefix of `pf_dualsenseedge`, so the Edge is tested first. fn devtype_from_hwids(ids: &str) -> Option { for (token, devtype) in [ - ("pf_steamdeck", 3u8), + ("pf_xboxwireless", 4u8), + ("pf_steamdeck", 3), ("pf_dualsenseedge", 2), ("pf_dualshock4", 1), ("pf_dualsense", 0), @@ -724,7 +883,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI // 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)"); + log("[pf-gamepad] device ready"); STATUS_SUCCESS } @@ -762,6 +921,7 @@ extern "C" fn evt_io_device_control( 1 => &DS4_HID_DESC, 2 => &EDGE_HID_DESC, 3 => &DECK_HID_DESC, + 4 => &XBOX_HID_DESC, _ => &HID_DESC, }), IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())), @@ -769,6 +929,7 @@ extern "C" fn evt_io_device_control( 1 => &DS4_RDESC[..], 2 => &DS_EDGE_RDESC[..], 3 => &DECK_RDESC[..], + 4 => &XBOX_RDESC[..], _ => &DUALSENSE_RDESC[..], }), IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => { @@ -776,7 +937,13 @@ extern "C" fn evt_io_device_control( } IOCTL_UMDF_HID_SET_FEATURE => on_set_feature(&request), IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request), - IOCTL_UMDF_HID_GET_INPUT_REPORT => request.copy_to_output(&neutral_report(device_type())), + // Sliced to the identity's declared report length for the same reason the timer's + // completion is (see `input_report_len`): a source longer than the caller's buffer is + // refused outright, not truncated. + IOCTL_UMDF_HID_GET_INPUT_REPORT => { + let dt = device_type(); + request.copy_to_output(&neutral_report(dt)[..input_report_len(dt)]) + } IOCTL_HID_GET_STRING => on_get_string(&request), // The channel proof (see `pf_umdf_util::hid`): the host asks THIS devnode which process // serves it, and duplicates the DATA section into the answer — so it never has to trust the @@ -1024,6 +1191,7 @@ fn on_get_string(request: &Request) -> NTSTATUS { 0 | 0x000e => match devtype { 1 => "Sony Computer Entertainment".into(), 3 => "Valve Software".into(), + 4 => "Microsoft".into(), _ => "Sony Interactive Entertainment".into(), }, // Per-pad serials (see `pad_index`): SDL reads this via HidD_GetSerialNumberString and @@ -1035,12 +1203,16 @@ fn on_get_string(request: &Request) -> NTSTATUS { 1 => format!("DEADBEEF00{:02X}", 0x01u8.wrapping_add(pad_index())), 2 => format!("35533AD6E7{:02X}", 0x75u8.wrapping_add(pad_index())), 3 => format!("FVPF{:08X}", 0x5046_0000u32 | pad_index() as u32), + // Xbox pads report a Bluetooth MAC-shaped serial; the low octet carries the pad index + // so Steam dedups multiple forwarded pads, exactly like the PS identities above. + 4 => format!("F4B0FC2A6C{:02X}", 0x10u8.wrapping_add(pad_index())), _ => format!("35533AD6E7{:02X}", 0x74u8.wrapping_add(pad_index())), }, _ => match devtype { 1 => "Wireless Controller".into(), 2 => "DualSense Edge Wireless Controller".into(), 3 => "Steam Deck Controller".into(), + 4 => "Xbox Wireless Controller".into(), _ => "DualSense Wireless Controller".into(), }, }; @@ -1052,7 +1224,8 @@ fn on_get_string(request: &Request) -> NTSTATUS { request.copy_to_output(&wide) } -/// The device-type selector: 0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck. +/// The device-type selector: 0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck, +/// 4 = Xbox Wireless Controller. /// Read fresh on each enumeration query — cheap. /// /// ⚠️ **The sealed section cannot answer the enumeration queries.** hidclass asks for @@ -1142,7 +1315,10 @@ extern "C" fn evt_timer(timer: WDFTIMER) { // SAFETY: `queue` is that live manual queue — the exact contract `retrieve_next_request` needs. if let Some(request) = unsafe { wdf::retrieve_next_request(queue) } { let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT); - let st = request.copy_to_output(&report); + // Serve exactly what this identity's descriptor declares — `copy_to_output` REFUSES a + // source longer than hidclass's buffer instead of truncating, so a 64-byte hand-over for + // the Xbox pad's 16-byte report would fail every read and the pad would look dead. + let st = request.copy_to_output(&report[..input_report_len(device_type())]); request.complete(st); } } -- 2.54.0 From 99f2130b287adf27b8f84c34bbf8510fb7ad0dfb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 12:24:14 +0200 Subject: [PATCH 02/16] feat(host/pads): the Windows Xbox backend, compiled and tested on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `xbox_windows` — the host half of the HID Xbox pad: the sealed-channel open under the Bluetooth identity (SwDeviceCreate `pf_xboxwireless` + `USB\VID_045E&PID_0B13`, so hidclass derives the real-pad `HID\VID_045E&PID_0B13` child ids), device_type 4 stamped before the magic, and the `PadProto` impl that publishes through `xbox_proto`. No rich plane: an Xbox pad has no touchpad, lightbar, adaptive triggers or IMU in its HID contract, so apply_rich/clear_rich/neutralize_gyro are deliberately no-ops. Rumble comes back off the driver's republished output reports. The Bluetooth rumble report carries magnitudes on a 0..100 scale, not 0..255 — assuming otherwise silently costs 60% of the range — and the enable mask gates each motor independently. The two INF/driver guard tests now cover the new identity. `hwid_devtype_table_matches _the_driver` caught the addition on its vacuity count, which is exactly what it is for. Verified on the Arc laptop (.221, Win11 26200): `cargo test -p pf-inject --lib` 100/100 green, `cargo clippy --lib --profile test -- -D warnings` clean, fmt clean. Note `clippy --all-targets` fails there on a PRE-EXISTING issue unrelated to this change — tests/motion_contract.rs imports the linux-gated `switch_proto`. Still unbuilt: the driver itself (.221 has no WDK) and the host routing that would send an Xbox pad here instead of to XUSB. The report descriptor remains constructed rather than captured — diff it against a real pad before shipping. --- .../src/inject/windows/dualsense_windows.rs | 7 +- .../src/inject/windows/xbox_windows.rs | 288 ++++++++++++++++++ crates/pf-inject/src/lib.rs | 6 + 3 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 crates/pf-inject/src/inject/windows/xbox_windows.rs diff --git a/crates/pf-inject/src/inject/windows/dualsense_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_windows.rs index 72871d63..ac7325d9 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_windows.rs @@ -1018,6 +1018,7 @@ mod drain_tests { WinDsIdentity::dualsense_edge().hwid, super::super::dualshock4_windows::DS4_HWID, super::super::steam_deck_windows::DECK_HWID, + super::super::xbox_windows::XBOX_HWID, ] { let want = hwid.to_ascii_lowercase(); let rooted = format!("root\\{want}"); @@ -1071,7 +1072,7 @@ mod drain_tests { .collect(); assert_eq!( entries.len(), - 4, + 5, "parsed {entries:?} out of the driver's table — the shape changed and this test went \ vacuous; fix the parse rather than deleting the assert" ); @@ -1098,6 +1099,10 @@ mod drain_tests { super::super::steam_deck_windows::DECK_HWID, pf_driver_proto::gamepad::DEVTYPE_STEAMDECK, ), + ( + super::super::xbox_windows::XBOX_HWID, + pf_driver_proto::gamepad::DEVTYPE_XBOX, + ), ] { let want = hwid.to_ascii_lowercase(); let got = entries.iter().find(|(id, _)| *id == want); diff --git a/crates/pf-inject/src/inject/windows/xbox_windows.rs b/crates/pf-inject/src/inject/windows/xbox_windows.rs new file mode 100644 index 00000000..21c063c2 --- /dev/null +++ b/crates/pf-inject/src/inject/windows/xbox_windows.rs @@ -0,0 +1,288 @@ +//! Virtual Xbox Wireless Controller on Windows via the UMDF HID minidriver (device-type 4) — the +//! HID-visible alternative to [`super::gamepad_windows`]'s XUSB companion. +//! +//! **Why this exists.** `pf-xusb` registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID +//! collection, so Steam's hidapi enumeration, DirectInput, `joy.cpl` and WGI/GameInput cannot see +//! the pad at all — only classic `XInputGetState` via xinput1_4's interface walk ever does. A field +//! report (2026-08-09) spent two weeks on a dead controller for exactly that reason; switching the +//! client to DualSense — a real HID pad through this very driver — fixed it in seconds. This +//! backend gives the Xbox pad the same footing, reusing the driver, sealed channel, INF, signing +//! and install path the PlayStation pads already ship on. +//! +//! Transport is identical to the PS/Deck pads: a `SwDeviceCreate` devnode plus the sealed +//! shared-memory channel, with `device_type = 4` stamped before the magic so the driver resolves +//! the Xbox identity before hidclass asks it for descriptors. The codec is +//! [`super::xbox_proto`]; the report it writes mirrors the driver's `XBOX_RDESC` byte for byte. +//! +//! ⚠️ **The synthesized USB identity is a BLUETOOTH Xbox pad (`045E:0B13`) on purpose.** The wired +//! ids the rest of the tree uses (`045E:028E` X-Box 360, `045E:02EA` Xbox One S USB) are +//! vendor-class XUSB/GIP devices that expose no HID interface on real hardware — a HID child +//! claiming one is a device that has never existed, and Windows' inbox promotion would have nothing +//! to match. See `pf-gamepad`'s `XBOX_PID` for the alternate to try if `0B13` is not promoted. +//! +//! ⚠️ **No rich plane.** An Xbox pad has no touchpad, no lightbar, no adaptive triggers and no +//! IMU in its HID contract, so `apply_rich` / `clear_rich` / `neutralize_gyro` are deliberately +//! no-ops — same shape as the Linux xpad backend. Motion sent toward this backend is decoded and +//! dropped, which is what `GamepadPref::motion_reaches` already tells clients. + +use super::dualsense_windows::{ + 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::xbox_proto::{neutral_xbox_report, serialize_xbox_state, XboxState, XBOX_REPORT_LEN}; +use crate::uhid_manager::{PadFeedback, PadProto, UhidManager}; +use anyhow::Result; +use punktfunk_core::quic::RichInput; +use std::time::Duration; + +/// 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). +pub(super) const XBOX_HWID: &str = "pf_xboxwireless"; + +/// The USB VID&PID token synthesized onto the devnode so hidclass derives the real-pad HID child +/// ids (`HID\VID_045E&PID_0B13`) — the identity SDL/RawInput/WGI read, and the one Windows' own +/// Xbox INFs match when they decide whether to promote a HID gamepad to an Xbox-profile pad. +const XBOX_USB_VID_PID: &str = "VID_045E&PID_0B13"; + +/// A single virtual Xbox pad: the `SwDeviceCreate`'d `pf_xbox_` devnode plus the sealed +/// shared-memory channel. Dropping it removes the devnode and closes both sections. +pub struct XboxWinPad { + /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop). + _sw: Option, + /// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery. + channel: PadChannel, + /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. + attach: super::gamepad_raii::DriverAttach, + /// This pad's v2.3 input-seqlock generation — see `publish_input`. + input_gen: u32, + /// Output-plane cursor: ring drain (v2.1+ driver) or legacy latest-slot seq (old driver). + drain: OutputDrain, +} + +impl XboxWinPad { + /// Create the sealed channel, stamp `device_type = Xbox` FIRST + the pad index + the neutral + /// report + the magic LAST, then spawn the devnode under the Bluetooth Xbox identity. + fn open(index: u8) -> Result { + let boot_name = pf_driver_proto::gamepad::pad_boot_name(index); + let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?; + let base = channel.data_base(); + // SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. The + // device_type MUST land before the magic — the driver reads it the moment it attaches, and + // a late stamp enumerates the pad with the default DualSense identity (the Deck's bug). + unsafe { + *base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_XBOX; + std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); + // Ring capability `2` = "this host drains the v2.2 long ring" (see the DualSense open). + std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2); + std::ptr::write_unaligned( + base.add(OFF_INPUT) as *mut [u8; XBOX_REPORT_LEN], + neutral_xbox_report(), + ); + std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); + } + let inst = format!("pf_xbox_{index}"); + let (hsw, instance_id) = create_swdevice(&SwDeviceProfile { + instance: &inst, + container_tag: 0x5046_5842, // "PFXB" + container_index: index, + hwid: XBOX_HWID, + usb_vid_pid: XBOX_USB_VID_PID, + // A Bluetooth pad is not a USB composite device, so there is no interface number to + // synthesize — unlike the Deck, whose Steam promotion gate needs `&MI_02`. + usb_mi: None, + description: "Punktfunk Virtual Xbox Wireless Controller", + })?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin). + channel.bind_devnode( + index as u32, + instance_id.clone(), + super::gamepad_raii::ProofTransport::HidFeatureReport, + ); + let _sw = Some(super::gamepad_raii::SwDevice::new(hsw)); + // Bounded eager delivery — the driver must read `device_type = 4` before hidclass asks it + // for descriptors, or the pad enumerates as a DualSense. + channel.deliver_eager(Duration::from_millis(1500)); + Ok(XboxWinPad { + _sw, + channel, + attach: super::gamepad_raii::DriverAttach::new( + "pf_xboxwireless", + "pf_gamepad.inf", // one driver package serves every identity + "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log", + boot_name, + instance_id, + ), + input_gen: 0, + drain: OutputDrain::new(), + }) + } + + /// Serialize `st` and publish it to the section's input slot under the v2.3 seqlock, so a + /// driver read can never land mid-copy. + fn write_state(&mut self, st: &XboxState) { + let r = serialize_xbox_state(st); + // SAFETY: `data_base()` points at a live SHM_SIZE-byte section and `r` is the codec's + // fixed-size report. + unsafe { publish_input(self.channel.data_base(), &mut self.input_gen, &r) }; + } + + /// Poll the section's output slot for a game's rumble, tick the sealed-channel delivery and + /// feed the driver-attach health watcher. + fn service(&mut self) -> (Option<(u16, u16)>, bool) { + self.channel.pump(); + // SAFETY: base points at SHM_SIZE bytes. + let proto = unsafe { + std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) + }; + self.attach.observe(proto); + let mut rumble = None; + let base = self.channel.data_base(); + let resync = self.drain.drain(base, |bytes| { + if let Some(r) = parse_xbox_output(bytes) { + rumble = Some(r); // oldest → newest: the last rumble-carrying report wins + } + }); + (rumble, resync) + } +} + +/// Parse an Xbox output report into `(low, high)` motor levels on the wire's 0..65535 scale. +/// +/// The Bluetooth Xbox rumble report is id `0x03`: `[id, enable, left_trigger, right_trigger, +/// left, right, duration, delay, loop]`, with magnitudes on a **0..100** scale (not 0..255 — a +/// detail that silently costs 60 % of the rumble range if you assume otherwise). The `enable` +/// mask picks which motors the values apply to; bit 2 is the left (low-frequency) motor and bit 3 +/// the right (high-frequency) one, matching how the wire's `low`/`high` pair is used elsewhere. +/// The two trigger motors have no wire representation and are ignored. +/// +/// ⚠️ Never seen a real report — this shape is from the documented protocol, not a capture. +fn parse_xbox_output(bytes: &[u8]) -> Option<(u16, u16)> { + // The driver republishes output reports report-id-prefixed, like the PS backends. + if bytes.len() < 6 || bytes[0] != 0x03 { + return None; + } + let enable = bytes[1]; + let scale = |v: u8| -> u16 { (v.min(100) as u32 * 65535 / 100) as u16 }; + let low = if enable & 0x04 != 0 { + scale(bytes[4]) + } else { + 0 + }; + let high = if enable & 0x08 != 0 { + scale(bytes[5]) + } else { + 0 + }; + Some((low, high)) +} + +/// The Windows-Xbox half of the shared stateful manager (see [`PadProto`]). Lifecycle (slot table, +/// unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`], exactly as for the PS pads. +#[derive(Default)] +pub struct XboxWinProto; + +impl PadProto for XboxWinProto { + type Pad = XboxWinPad; + type State = XboxState; + const LABEL: &'static str = "Xbox Wireless/Windows"; + const DEVICE: &'static str = "Xbox Wireless Controller"; + const CREATE_HINT: &'static str = + " (install/repair: punktfunk-host.exe driver install --gamepad)"; + + fn open(&mut self, idx: u8) -> Result { + let p = XboxWinPad::open(idx)?; + tracing::info!( + index = idx, + "virtual Xbox Wireless Controller created (Windows UMDF HID identity 045E:0B13)" + ); + Ok(p) + } + + fn neutral(&self) -> XboxState { + XboxState::default() + } + + /// Every control this pad has arrives in the frame, so a frame fully replaces the state — + /// there are no rich-plane fields to preserve (contrast the Deck's trackpads/motion). + fn merge_frame(&self, _prev: &XboxState, f: &punktfunk_core::input::GamepadFrame) -> XboxState { + XboxState::from_gamepad( + f.buttons, + f.left_trigger, + f.right_trigger, + f.ls_x, + f.ls_y, + f.rs_x, + f.rs_y, + ) + } + + /// No rich plane on an Xbox pad — see the module note. + fn apply_rich(&self, _st: &mut XboxState, _rich: RichInput) {} + + /// No motion plane, so there is never stale gyro to neutralize. + fn neutralize_gyro(&self, _st: &mut XboxState) -> bool { + false + } + + fn clear_rich(&self, _st: &mut XboxState) {} + + fn write_state(&self, pad: &mut XboxWinPad, st: &XboxState) { + pad.write_state(st); + } + + /// Motor rumble on the universal 0xCA plane. No rich host→client feedback (no lightbar or + /// adaptive triggers), so `hidout` stays empty — parity with the Linux xpad backend. + fn service(&self, pad: &mut XboxWinPad, _idx: u8) -> PadFeedback { + let (rumble, resync) = pad.service(); + PadFeedback { + rumble, + hidout: Vec::new(), + rumble_drove: Some(rumble.is_some()), + resync, + } + } +} + +/// All virtual Xbox pads of a Windows session, with the same method surface (via the shared +/// [`UhidManager`]) as the other Windows pad managers. +pub type XboxWindowsManager = UhidManager; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rumble_scales_off_the_zero_to_hundred_protocol_range() { + // Both motors enabled, full scale. + let full = [0x03, 0x0F, 0, 0, 100, 100, 0, 0, 1]; + assert_eq!(parse_xbox_output(&full), Some((65535, 65535))); + // Half on the left motor only. + let half = [0x03, 0x04, 0, 0, 50, 100, 0, 0, 1]; + assert_eq!(parse_xbox_output(&half), Some((32767, 0))); + } + + /// A value above the protocol's 0..100 range must clamp, not wrap past full scale. + #[test] + fn out_of_range_magnitudes_clamp() { + let over = [0x03, 0x0F, 0, 0, 255, 255, 0, 0, 1]; + assert_eq!(parse_xbox_output(&over), Some((65535, 65535))); + } + + /// The enable mask gates each motor independently — a report that enables neither is a stop. + #[test] + fn the_enable_mask_gates_each_motor() { + let none = [0x03, 0x00, 0, 0, 100, 100, 0, 0, 1]; + assert_eq!(parse_xbox_output(&none), Some((0, 0))); + let right_only = [0x03, 0x08, 0, 0, 100, 100, 0, 0, 1]; + assert_eq!(parse_xbox_output(&right_only), Some((0, 65535))); + } + + /// Anything that is not the rumble report — or is truncated — is ignored rather than parsed + /// out of whatever bytes happen to be there. + #[test] + fn non_rumble_reports_are_ignored() { + assert_eq!(parse_xbox_output(&[0x01, 0x0F, 0, 0, 100, 100]), None); + assert_eq!(parse_xbox_output(&[0x03, 0x0F, 0]), None); + assert_eq!(parse_xbox_output(&[]), None); + } +} diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index 8ca7deab..ac91d2e8 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -484,6 +484,12 @@ pub mod uhid_manager; /// has until a Windows box is reachable. #[path = "inject/proto/xbox_proto.rs"] pub mod xbox_proto; +/// Windows: virtual Xbox Wireless Controller via the same UMDF minidriver (device-type 4) — the +/// HID-visible alternative to [`gamepad_windows`]'s XUSB companion, which Steam / WGI / GameInput / +/// DirectInput cannot enumerate at all because it registers only the XUSB device interface. +#[cfg(target_os = "windows")] +#[path = "inject/windows/xbox_windows.rs"] +pub mod xbox_windows; /// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere. #[cfg(not(any(target_os = "linux", target_os = "windows")))] pub mod gamepad { -- 2.54.0 From d498ff4a607c8108c6aace3a2f12c8d799c0ba70 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 16:54:00 +0200 Subject: [PATCH 03/16] test(drivers): give the Xbox identity a root-enumerated id, and verify the whole thing on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `root\pf_xboxwireless` alongside the plain id, mirroring the DualSense model line — the INF already documents that variant as the one devgen/devcon tests bind, and without it the Xbox identity could only be exercised through a running host. Verified end to end on .173 (Windows 11 26200, WDK 10.0.26100.0): - build-gamepad-drivers.ps1 builds + signs + catalogs the driver, exit 0 - infverif /v /w on the generated pf_gamepad.inf: "INF is VALID" - pnputil stages the package; devgen creates the devnode; it starts clean: Status OK, Class HIDClass, "Punktfunk Virtual Xbox Wireless Controller" - it enumerates a HID child, Status OK, carrying HID_DEVICE_SYSTEM_GAME and HID_DEVICE_UP:0001_U:0005 — Windows parsed the constructed report descriptor and classified the pad as a Game Pad (usage page 0x01, usage 0x05), which is precisely what pf-xusb could never do Test devnode, phantom child, driver package and both certs were removed afterwards. Two build gotchas worth knowing, both already handled inside build-gamepad-drivers.ps1 and both of which cost a cycle here: CARGO_TARGET_DIR pointing outside the workspace breaks wdk-sys (wdk-build walks up from OUT_DIR looking for a Cargo.lock and finds none), and the WDK version must be pinned via Version_Number=10.0.26100.0 or bindgen picks SDK 10.0.28000.0, which ships no km/crt headers. Still open: the SwDeviceCreate USB identity (HID\VID_045E&PID_0B13) cannot be checked through a devgen node, which has no USB hardware ids — that needs the host path. So the WGI-promotion question is still unanswered, and host routing is still unwritten. --- packaging/windows/drivers/pf-gamepad/pf_gamepad.inx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx index e7d876e7..b0dd2735 100644 --- a/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx +++ b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx @@ -47,7 +47,7 @@ pf_gamepad.dll=1 %DeviceDescDS4%=pfGamepad, pf_dualshock4 %DeviceDescEdge%=pfGamepad, pf_dualsenseedge %DeviceDescDeck%=pfGamepad, pf_steamdeck -%DeviceDescXbox%=pfGamepad, pf_xboxwireless +%DeviceDescXbox%=pfGamepad, root\pf_xboxwireless, pf_xboxwireless [pfGamepad.NT] CopyFiles=UMDriverCopy -- 2.54.0 From 4e04c2bbf8f608593b82b8e504a0727150150cec Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 17:03:25 +0200 Subject: [PATCH 04/16] feat(host/pads): route the Xbox pad to the HID backend behind PUNKTFUNK_XBOX_BACKEND=hid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires `xbox_windows` into the per-pad router so an Xbox-family pad can be built as a real HID device instead of the XUSB companion, and adds the knob that selects between them. Opt-in rather than the new default, deliberately. XUSB is what classic-XInput games read today; the HID pad buys the Steam / WGI / GameInput / DirectInput visibility XUSB can never have, but whether Windows promotes it into an Xbox-profile device that XInput and WGI Gamepad accept is still the open question. Flipping the default before that is settled would trade a known-working path for an unproven one. The two backends are mutually exclusive per pad by construction — one match arm or the other — because presenting both hands a game two controllers for one pair of hands. Verified on .173: cargo check -p punktfunk-host exit 0, clippy -D warnings clean, `cargo test -p punktfunk-host gamepad` 8/8 green, fmt clean. --- crates/punktfunk-host/src/native/gamepad.rs | 24 +++++++++++++++++++++ crates/punktfunk-host/src/native/input.rs | 22 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/crates/punktfunk-host/src/native/gamepad.rs b/crates/punktfunk-host/src/native/gamepad.rs index ba8b16ad..12c9ec15 100644 --- a/crates/punktfunk-host/src/native/gamepad.rs +++ b/crates/punktfunk-host/src/native/gamepad.rs @@ -221,6 +221,30 @@ fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref { chosen } +/// Whether an Xbox-family pad should be built as a real **HID** device +/// ([`crate::inject::xbox_windows`]) instead of the **XUSB** companion +/// ([`crate::inject::gamepad`]). Windows only; `PUNKTFUNK_XBOX_BACKEND=hid` opts in. +/// +/// **Why this is a knob and not simply the new default.** The XUSB companion registers only +/// `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi enumeration, +/// DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all — only classic `XInputGetState` +/// via xinput1_4's interface walk does. That is what left a reporter with a dead controller for two +/// weeks (2026-08-09) until they switched the client to DualSense, a real HID pad. +/// +/// But the converse is not yet proven: classic-XInput games DO read the XUSB pad today, and whether +/// Windows promotes our HID pad into an Xbox-profile device that XInput and WGI `Gamepad` accept is +/// exactly the open question. Until that is settled on glass, flipping the default would trade a +/// known-working path for an unproven one. Opt in, measure, then decide. +/// +/// The two backends are mutually exclusive per pad by construction (one match arm or the other) — +/// presenting both would hand a game two controllers for one pair of hands. +#[cfg(target_os = "windows")] +pub(super) fn windows_xbox_hid() -> bool { + std::env::var("PUNKTFUNK_XBOX_BACKEND") + .map(|v| v.trim().eq_ignore_ascii_case("hid")) + .unwrap_or(false) +} + /// Resolve the client's gamepad-backend preference (the env/logging shell around /// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive. pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index 377d5037..3a1a56bd 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -123,6 +123,11 @@ struct Pads { steamctrl2_puck: Option, #[cfg(target_os = "windows")] dualsense_win: Option, + /// The HID-visible Xbox pad ([`crate::inject::xbox_windows`]) — used INSTEAD of `xbox360`'s + /// XUSB companion when [`super::gamepad::windows_xbox_hid`] says so. Never both at once: two + /// devices for one wire pad is the "the game sees two controllers" bug. + #[cfg(target_os = "windows")] + xbox_hid: Option, #[cfg(target_os = "windows")] dualsense_edge_win: Option, #[cfg(target_os = "windows")] @@ -165,6 +170,8 @@ impl Pads { #[cfg(target_os = "windows")] dualsense_win: None, #[cfg(target_os = "windows")] + xbox_hid: None, + #[cfg(target_os = "windows")] dualsense_edge_win: None, #[cfg(target_os = "windows")] dualshock4_win: None, @@ -291,6 +298,16 @@ impl Pads { .steamdeck_win .get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new) .handle(ev), + // The Xbox pad, as a real HID device rather than the XUSB companion. Opt-in for now + // (see `windows_xbox_hid`): XUSB is what classic-XInput games read today, and this + // trades that for the Steam / WGI / GameInput / DirectInput visibility XUSB can never + // have — a swap that has to be proven on glass before it becomes the default. + #[cfg(target_os = "windows")] + GamepadPref::Xbox360 | GamepadPref::XboxOne if super::gamepad::windows_xbox_hid() => { + self.xbox_hid + .get_or_insert_with(crate::inject::xbox_windows::XboxWindowsManager::new) + .handle(ev) + } _ => self .xbox360 .get_or_insert_with(crate::inject::gamepad::GamepadManager::new) @@ -451,6 +468,11 @@ impl Pads { } #[cfg(target_os = "windows")] { + if let Some(m) = &mut self.xbox_hid { + // Rumble only — an Xbox pad has no rich-feedback plane (no lightbar / adaptive + // triggers), same as its XUSB sibling above. + m.pump(&mut rumble, &mut hidout); + } if let Some(m) = &mut self.dualsense_win { m.pump(&mut rumble, &mut hidout); } -- 2.54.0 From d2a2bcc25d2bf99edd8300204c546fc8ecb9d85c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 17:29:10 +0200 Subject: [PATCH 05/16] feat(drivers/pf-xusb): answer the async input wait, and put xinputhid on the stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two things this driver's README has always listed as the missing WGI/GameInput work, both user-mode, neither needing a bus driver: `IOCTL_XUSB_WAIT_FOR_INPUT` is now pended on a manual queue and completed by the periodic timer on a dwPacketNumber edge, answering with the same 29-byte GET_STATE payload the synchronous path serves. Declining it was enough for classic xinput1_4, which just falls back to sync GET_STATE polling — that is why the pad has always worked there. It is not enough for WGI/GameInput, which poll asynchronously: to them a decline is a refusal, not a fallback. Completion is edge-gated because releasing a waiter on an unchanged packet spins its caller at timer rate. WAIT_GUIDE_BUTTON stays declined — we have no state to signal on. The INF adds UpperFilters=xinputhid on the XUSB devnode. Note the earlier attempt put that filter on the HID child of the *other* backend, which was simply the wrong devnode: XInput does not read HID at all, it enumerates GUID_DEVINTERFACE_XUSB, which is what this driver registers. Verified on .173: build + sign + catalog exit 0; infverif "INF is VALID"; the devnode starts Status OK with UpperFilters=xinputhid readable back from its enum key; and XInput still sees the pad (slot 1 live alongside the box's real Elite in slot 0), so the async queue is no regression to the path that already worked. NOT yet measured: whether WGI/GameInput now admit the pad. `IG_` is the wrong probe for this driver — it is a HID-path artifact and pf-xusb is System-class with no HID child, so its absence says nothing either way. That needs a real WinRT/GameInput enumeration test. --- crates/punktfunk-host/src/devtest.rs | 12 +++ packaging/windows/drivers/pf-xusb/pf_xusb.inx | 11 +++ packaging/windows/drivers/pf-xusb/src/lib.rs | 86 ++++++++++++++++++- 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 2fff4e6c..47509241 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -364,6 +364,8 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { .unwrap_or(0); let ds4 = args.iter().any(|a| a == "--ds4"); let xbox = args.iter().any(|a| a == "--xbox"); + // `--xboxhid` drives the HID Xbox backend (device-type 4) instead of `--xbox`'s XUSB companion. + let xboxhid = args.iter().any(|a| a == "--xboxhid"); // `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds // the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in // report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend @@ -463,6 +465,16 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { })); std::thread::sleep(Duration::from_millis(15)); } + } else if xboxhid { + // The HID Xbox pad (device-type 4) — the SHIPPING SwDeviceCreate identity, not a devgen + // node. That distinction is the whole point of this leg: a devgen devnode carries no USB + // hardware ids, so its HID child comes up `HID\VID_045E&UP:0001_U:0005` with no PID token, + // and the question this exists to answer — does Windows promote our pad to an Xbox-profile + // device (an `IG_` token, XInput, WGI `Gamepad`) — turns on exactly that PID being present. + drive!( + crate::inject::xbox_windows::XboxWindowsManager::new(), + "Xbox Wireless Controller (HID)" + ); } else if ds4 { drive!( crate::inject::dualshock4_windows::DualShock4WindowsManager::new(), diff --git a/packaging/windows/drivers/pf-xusb/pf_xusb.inx b/packaging/windows/drivers/pf-xusb/pf_xusb.inx index 82300a1b..83ce2281 100644 --- a/packaging/windows/drivers/pf-xusb/pf_xusb.inx +++ b/packaging/windows/drivers/pf-xusb/pf_xusb.inx @@ -39,6 +39,17 @@ pf_xusb.dll [pfXusb.NT.HW] Include=WUDFRD.inf Needs=WUDFRD.NT.HW +AddReg=pfXusb_HW_AddReg + +; The WGI/GameInput admission tripwire. Classic `xinput1_4` needs nothing here — it finds us by the +; XUSB device-interface GUID and polls GET_STATE, which is why the pad has always worked there +; (verified on .173 2026-08-09: our pad takes XInput slot 1 with live state). WGI and GameInput +; instead expect the in-box `xinputhid` filter on the stack, and without it they never admit the +; device however correct its IOCTL surface is. Pairs with the async WAIT_FOR_INPUT pump in +; src/lib.rs — the filter and the async wait are the two halves this driver's README has always +; listed as the missing WGI work; neither needs kernel-mode code. +[pfXusb_HW_AddReg] +HKR,,"UpperFilters",0x00010000,"xinputhid" [pfXusb.NT.Services] Include=WUDFRD.inf diff --git a/packaging/windows/drivers/pf-xusb/src/lib.rs b/packaging/windows/drivers/pf-xusb/src/lib.rs index 5343a8fb..4a8ce4c0 100644 --- a/packaging/windows/drivers/pf-xusb/src/lib.rs +++ b/packaging/windows/drivers/pf-xusb/src/lib.rs @@ -25,7 +25,7 @@ #![deny(unsafe_op_in_unsafe_fn)] #![deny(clippy::undocumented_unsafe_blocks)] -use core::sync::atomic::{AtomicBool, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering}; use pf_driver_proto::gamepad::XusbShm; use pf_umdf_util::channel::{ChannelClient, ChannelConfig}; use pf_umdf_util::nt_success; @@ -34,7 +34,7 @@ use pf_umdf_util::wdf::{self, Request}; use wdk_sys::{ GUID, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES, - WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFQUEUE, WDFREQUEST, WDFTIMER, + WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER, call_unsafe_wdf_function_binding, windows::OutputDebugStringA, }; @@ -78,6 +78,15 @@ const XUSB_VERSION: u16 = 0x0103; // ---- WDF enum values ---- const WdfIoQueueDispatchParallel: i32 = 2; +const WdfIoQueueDispatchManual: i32 = 3; + +/// Manual queue holding pended [`IOCTL_XUSB_WAIT_FOR_INPUT`] requests; the periodic timer completes +/// them when the host publishes a new packet. See [`evt_timer`]. +static WAIT_QUEUE: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); +/// The `dwPacketNumber` the last completed wait reported — the edge the timer compares against, so +/// a waiter is only released when the state actually MOVED (that is the contract of an async wait; +/// completing it unconditionally would spin the caller at timer rate). +static WAIT_LAST_PACKET: AtomicU32 = AtomicU32::new(0); const WdfUseDefault: i32 = 2; // WDF_TRI_STATE const WdfExecutionLevelInheritFromParent: i32 = 1; // WDF_EXECUTION_LEVEL const WdfSynchronizationScopeInheritFromParent: i32 = 1; // WDF_SYNCHRONIZATION_SCOPE @@ -272,6 +281,35 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI return st; } + // Manual queue for the ASYNC input wait (`IOCTL_XUSB_WAIT_FOR_INPUT`), completed by the timer. + // + // Declining that IOCTL is enough for CLASSIC XInput — `xinput1_4` just falls back to synchronous + // GET_STATE polling, which is why the pad has always worked there. It is NOT enough for + // WGI/GameInput: those poll asynchronously, so to them the decline is not a fallback but a + // refusal, and the device is never admitted. Measured 2026-08-09 on .173 — the pad reaches + // XInput slot 1 with live data while WGI/GameInput never see it at all. + // SAFETY: a zeroed WDF_IO_QUEUE_CONFIG is valid; we then set Size + the fields we use. + let mut wcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() }; + wcfg.Size = core::mem::size_of::() as ULONG; + wcfg.DispatchType = WdfIoQueueDispatchManual; + wcfg.PowerManaged = WdfUseDefault; + let mut wait_queue: WDFQUEUE = core::ptr::null_mut(); + // SAFETY: `device` + `wcfg` are valid; attributes null; `wait_queue` receives the handle. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueCreate, + device, + &mut wcfg, + WDF_NO_OBJECT_ATTRIBUTES, + &mut wait_queue + ) + }; + if !nt_success(st) { + dbglog!("[pf-xusb] wait WdfIoQueueCreate failed 0x{:08x}", st as u32); + return st; + } + WAIT_QUEUE.store(wait_queue, Ordering::SeqCst); + // Run the sealed-channel handshake on a worker (must NOT block EvtDeviceAdd): publish our pid in // the bootstrap mailbox and poll for the host's delivered DATA handle, so the pad attaches (and // the host's driver-attach health check goes green) even before any game polls XInput. Bounded; @@ -333,6 +371,28 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI extern "C" fn evt_timer(_timer: WDFTIMER) { let live = CHANNEL.pump(&channel_cfg()).is_some(); HOST_LIVE.store(live, Ordering::Relaxed); + + // Release one pended `WAIT_FOR_INPUT` per tick, but only on a real edge — the host bumps + // `dwPacketNumber` whenever it publishes new state, so an unchanged packet means nothing moved + // and a waiter that is completed anyway would just spin its caller at timer rate. + let data = CHANNEL.data(); + let (packet, ..) = read_state(data); + if packet == WAIT_LAST_PACKET.load(Ordering::Relaxed) { + return; + } + let wq: WDFQUEUE = WAIT_QUEUE.load(Ordering::SeqCst); + if wq.is_null() { + return; + } + // SAFETY: `wq` is the live manual queue created in EvtDeviceAdd — the contract + // `retrieve_next_request` requires. `None` simply means nobody is waiting. + if let Some(request) = unsafe { wdf::retrieve_next_request(wq) } { + WAIT_LAST_PACKET.store(packet, Ordering::Relaxed); + // Answer with the same 29-byte GET_STATE payload the synchronous path serves, so a caller + // that waits and a caller that polls observe byte-identical state. + let st = request.copy_to_output(&build_get_state(data)); + request.complete(st); + } } /// The current controller state from the attached DATA section (zeros / neutral when unattached). @@ -504,8 +564,26 @@ extern "C" fn evt_io_device_control( IOCTL_XUSB_GET_BATTERY_INFORMATION => request.copy_to_output(&[0x00, 0x01, 0x03, 0x00]), IOCTL_XUSB_SET_STATE => on_set_state(&request, data), IOCTL_XUSB_POWER_DOWN | IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER => STATUS_SUCCESS, - // Decline the async waits → xinput1_4 falls back to synchronous GET_STATE polling. - IOCTL_XUSB_WAIT_GUIDE_BUTTON | IOCTL_XUSB_WAIT_FOR_INPUT => STATUS_INVALID_DEVICE_REQUEST, + // The async input wait is PENDED on the manual queue and completed by the timer when the + // packet number moves (see `evt_timer`) — WGI/GameInput poll this way and will not admit a + // device that refuses it. Classic `xinput1_4` never issues it (it polls GET_STATE), so this + // costs the working path nothing. A forward failure completes the request with its error. + IOCTL_XUSB_WAIT_FOR_INPUT => { + let wq: WDFQUEUE = WAIT_QUEUE.load(Ordering::SeqCst); + if wq.is_null() { + STATUS_INVALID_DEVICE_REQUEST + } else { + // SAFETY: `wq` is the live manual queue created in EvtDeviceAdd; `request` is this + // dispatch's request and is CONSUMED by the forward (hence the early return). + match unsafe { request.forward_to_queue(wq) } { + Ok(()) => return, + Err((req, st)) => req.complete(st), + } + return; + } + } + // Still declined: the guide-button wait has no state of ours to signal on. + IOCTL_XUSB_WAIT_GUIDE_BUTTON => STATUS_INVALID_DEVICE_REQUEST, other => { dbglog!("[pf-xusb] unhandled IOCTL 0x{other:08x} in={input_len} out={output_len}"); STATUS_INVALID_DEVICE_REQUEST -- 2.54.0 From f34acf1d73b1cdc4144b4a2186b412c0ffba9bfb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 18:16:24 +0200 Subject: [PATCH 06/16] fix(drivers/pf-gamepad): the Xbox descriptor never declared the channel-proof report, so the pad served neutral forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `XBOX_RDESC` declared only Input report 1. The sealed pad channel delivers its DATA section over a vendor Feature report `0x85` (`ProofTransport::HidFeatureReport`), and the proof handler's own comment records the assumption that made this invisible — "0x85 is already declared as a Feature report in all three captured descriptors". True of the captured PlayStation blobs; false of this hand-constructed one. So hidclass rejected the host's `HidD_GetFeature` before the driver ever saw it, the host refused to hand over the section, and the pad answered every read with its neutral report. The HID Xbox pad had never delivered a single input report since it was written. Declaring `0x85` with a 63-byte payload (1 id + 63 = 64 = FeatureReportByteLength) fixes it. Verified on glass on .173: `gamepad driver attached to the shared section proto=3 late=false`, and WGI's RawGameController path then reads the pad live — advancing timestamps, the devtest's left-stick sweep, buttons toggling. Before the fix: 12 consecutive samples, one frozen timestamp, every axis at dead centre. This is the descriptor-provenance warning in this file coming true. It is still CONSTRUCTED rather than captured, and that remains the open risk — `xinputhid` appears to validate the descriptor and refuses ours, and a real Elite is a multi-collection device where ours has one. Codec layout tests still 11/11; fmt clean. Only device_type 4 is affected, which nothing shipping uses yet. --- .../windows/drivers/pf-gamepad/src/lib.rs | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index 763d51f0..0db9911d 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -291,8 +291,19 @@ static DECK_RDESC: [u8; 38] = [ // `HKLM\SYSTEM\CurrentControlSet\Enum\BTHENUM\...\Device Parameters` or use a HID monitor; // `hidapi`'s `hidapi-hidtest` and Linux `/sys/class/hidraw/hidrawN/device/report_descriptor` both // dump it directly. Replace this blob and re-run the `xbox_proto` layout tests. +// +// ⚠️ The trailing vendor-defined Feature report `0x85` is NOT cosmetic and must not be trimmed as +// "unused": it is the CHANNEL PROOF transport (`ProofTransport::HidFeatureReport`). The captured +// PlayStation descriptors already declared `0x85`, which is why the proof "costs no descriptor +// change" there — but this descriptor is constructed, so it has to declare the report itself. Built +// without it the pad enumerates perfectly and then delivers NOTHING: hidclass rejects the host's +// `HidD_GetFeature` before the driver sees it, the host refuses to hand over the DATA section +// (measured on .173 2026-08-09 — WGI `RawGameController` saw `045E:0B13` with every axis pinned at +// 0.5000 and a timestamp frozen for 12 consecutive samples), and the pad serves only its neutral +// report forever. `0x3F` payload bytes so `FeatureReportByteLength` lands on 64, the buffer size +// `channel_proof::query` asks with; the proof itself needs 17. #[rustfmt::skip] -static XBOX_RDESC: [u8; 132] = [ +static XBOX_RDESC: [u8; 150] = [ 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x05, // Usage (Game Pad) 0xA1, 0x01, // Collection (Application) @@ -355,6 +366,17 @@ static XBOX_RDESC: [u8; 132] = [ 0x75, 0x01, // Report Size (1) 0x95, 0x01, // Report Count (1) 0x81, 0x03, // Input (Cnst,Var,Abs) — pad to a byte boundary + // The channel-proof feature report — see the ⚠️ above. Declared last so it cannot disturb the + // INPUT layout `xbox_proto` packs against: every global item here (Report Size/Count, Logical + // Min/Max) is re-stated after the final Input item, so nothing above is retroactively changed. + 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) + 0x85, 0x85, // Report ID (0x85) + 0x09, 0x2D, // Usage (0x2D) — the id the PS descriptors use for it + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x3F, // Report Count (63) — 1 id + 63 = 64 = FeatureReportByteLength + 0xB1, 0x02, // Feature (Data,Var,Abs) 0xC0, // End Collection ]; @@ -371,7 +393,7 @@ static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01 static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01]; static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01]; static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes -static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x84, 0x00]; // 132 bytes +static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x96, 0x00]; // 150 bytes // HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11]. // `devtype` selects the identity: PS family (same Sony VID/version) or the N4-spike Deck. -- 2.54.0 From ae35e8b4d763f59a95e954e2aefd31a319d5875a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 19:02:13 +0200 Subject: [PATCH 07/16] test(tools): capture the real Xbox descriptor, because ours was invented and disagrees with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `XBOX_RDESC` is the only report descriptor in `pf-gamepad` that was hand-written rather than captured off hardware, and its own provenance warning has now come true three times. The fix for that class of bug is not another careful reading — it is a tool that goes and asks the device. `tools/hid-descriptor-dump` does that: it dumps a real HID device's report descriptor, decodes it into an annotated item listing plus a bit-offset LAYOUT TABLE, and can decode a blob we already ship through the same decoder (`--rust-source --symbol `) so the two are diffable line for line. `--read N` pulls live wire bytes, which is the only ground truth a reconstructed descriptor cannot give you. Deliberately NOT a workspace member — it pulls `hidapi`, a C library wanting libudev on Linux, which has no business in `cargo build --workspace` or on a CI leg with no pad attached. It is a bring-your-own-hardware tool and it is excluded in the root manifest, so CI never sees it. The captured Elite disagrees with our blob in four ways, and the dangerous one is field ORDER: the real pad reports sticks, ONE combined 16-bit Z trigger, then BUTTONS, then the hat, in an UNNUMBERED 15-byte report; ours declares Report ID 1, two Simulation-page trigger axes, then the hat, then 15 buttons. Since we claim a genuine Microsoft VID/PID and SDL/Steam/Windows all apply stock mappings keyed on it, that ordering difference is exactly how every control silently lands on the wrong action. The driver comment now records the diff and the two blockers that stop the capture from simply being pasted in. VERIFIED * `cargo fmt --check` clean, `cargo clippy --all-targets -- -D warnings` clean (macOS). * The tool builds and runs on macOS and on .173 (Windows 11 26200, cargo 1.96, MSVC, no WDK). * TOOL VALIDATED AGAINST A KNOWN-GOOD CONTROL: pointed at the live DualSense on .173, it reproduces the real `DUALSENSE_RDESC` layout exactly (input 0x01, 64 B, X,Y,Z,Rz,Rx,Ry at bytes 1..6, hat 8.0, 15 buttons 8.4, output 0x02, the full feature ladder), and `--read` returned live len=64 reports with sticks centred at 80 80 80 80 and the counter incrementing. * `cargo metadata` on the root workspace still resolves and does NOT list this crate. * The Elite capture is reproducible: `--vid 045E --pid 0B22`. NOT VERIFIED * That the capture equals the pad's NATIVE report map. Windows exposes no API for a device's literal descriptor bytes, so hidapi reconstructs from `HidD_GetPreparsedData` — faithful in structure, item order and bit offsets, not byte-exact (measured: the DualSense's real 273-byte descriptor reconstructs to 467). `xinputhid` also filters that pad, and the captured shape is the legacy DirectInput view. A byte-exact answer needs Linux hidraw. * Why the Elite returned ZERO input reports across two runs (72 s and 90 s) while the DualSense streamed fine on the same code path — untouched pad, or exclusive claim by the XInput translator. Unresolved. * Nothing here was built on Windows as a driver: `XBOX_RDESC` itself is UNCHANGED, so no behaviour changes. The only edit to the driver is its provenance comment. --- Cargo.toml | 3 + .../windows/drivers/pf-gamepad/src/lib.rs | 30 +- tools/hid-descriptor-dump/Cargo.lock | 78 +++ tools/hid-descriptor-dump/Cargo.toml | 27 + .../dualsense-054C-0CE6-usb-windows.txt | 425 +++++++++++++ ...ox-elite-series2-045E-0B22-ble-windows.txt | 204 ++++++ tools/hid-descriptor-dump/src/decode.rs | 581 ++++++++++++++++++ tools/hid-descriptor-dump/src/main.rs | 426 +++++++++++++ 8 files changed, 1769 insertions(+), 5 deletions(-) create mode 100644 tools/hid-descriptor-dump/Cargo.lock create mode 100644 tools/hid-descriptor-dump/Cargo.toml create mode 100644 tools/hid-descriptor-dump/captures/dualsense-054C-0CE6-usb-windows.txt create mode 100644 tools/hid-descriptor-dump/captures/xbox-elite-series2-045E-0B22-ble-windows.txt create mode 100644 tools/hid-descriptor-dump/src/decode.rs create mode 100644 tools/hid-descriptor-dump/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 8a6b99d4..e9f14efd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,9 @@ members = [ exclude = [ "packaging/linux/steam-deck-gadget/usbip-poc", "clients/android/native/vendor/ndk", + # Bring-your-own-hardware measurement tool: pulls `hidapi`, a C library wanting libudev on + # Linux, which has no place in `cargo build --workspace` or on a CI leg with no pad attached. + "tools/hid-descriptor-dump", ] # ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index 0db9911d..2e7a483c 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -286,11 +286,31 @@ static DECK_RDESC: [u8; 38] = [ // report laid out differently from the real device, every control silently lands on the wrong // action — the same class of bug this whole change exists to kill. // -// **Before shipping: capture the report descriptor from a real Xbox Wireless Controller over -// Bluetooth and diff it against this.** Recipe: pair the pad, then read -// `HKLM\SYSTEM\CurrentControlSet\Enum\BTHENUM\...\Device Parameters` or use a HID monitor; -// `hidapi`'s `hidapi-hidtest` and Linux `/sys/class/hidraw/hidrawN/device/report_descriptor` both -// dump it directly. Replace this blob and re-run the `xbox_proto` layout tests. +// ⭐ **2026-08-09 — THE CAPTURE NOW EXISTS AND THIS BLOB DISAGREES WITH IT.** A real Xbox Elite +// Series 2 (`045E:0B22`, Bluetooth LE) was captured on `.173` with `tools/hid-descriptor-dump`; the +// dump, its provenance and the DualSense control that validates the tool are in +// `tools/hid-descriptor-dump/captures/`. Re-take it any time with `--vid 045E --pid 0B22`, and +// decode THIS array through the same decoder — no hardware needed — with: +// +// hid-descriptor-dump --rust-source packaging/windows/drivers/pf-gamepad/src/lib.rs \ +// --symbol XBOX_RDESC +// +// Four differences, and the ORDER one is the dangerous one: +// * the real pad's game-controller report is **UNNUMBERED** (15 bytes of fields, no report id); +// this one declares Report ID 1; +// * it carries **ONE combined 16-bit `Z`** trigger axis at byte 8, not two Simulation-page axes; +// * it declares **16 buttons at byte 10, BEFORE the hat** — this one puts 15 buttons AFTER it; +// * neither has an OUTPUT collection, so the rumble gap is real on both. +// +// 🛑 **Do NOT simply paste the capture over this array.** Two blockers, recorded in +// `design/xbox-pad-windows-handoff.md` §3.3: (1) it is unverified whether Windows' view equals the +// pad's NATIVE report map — `xinputhid` filters that pad and the captured shape is the legacy +// DirectInput view, so cross-check on Linux hidraw first; (2) **the real descriptor has no Feature +// report, and we cannot ship without one** — `0x85` is the sealed channel's proof transport, and +// report ids are all-or-nothing, so declaring it forces a numbered input report the real pad does +// not have. Matching the hardware byte for byte and keeping the sealed channel as it stands are +// mutually exclusive; that needs a decision, not a paste. Whatever lands, re-run `xbox_proto`'s +// layout tests — they pin these offsets on the host side. // // ⚠️ The trailing vendor-defined Feature report `0x85` is NOT cosmetic and must not be trimmed as // "unused": it is the CHANNEL PROOF transport (`ProofTransport::HidFeatureReport`). The captured diff --git a/tools/hid-descriptor-dump/Cargo.lock b/tools/hid-descriptor-dump/Cargo.lock new file mode 100644 index 00000000..acc08855 --- /dev/null +++ b/tools/hid-descriptor-dump/Cargo.lock @@ -0,0 +1,78 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "hid-descriptor-dump" +version = "0.26.0" +dependencies = [ + "hidapi", +] + +[[package]] +name = "hidapi" +version = "2.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c78dadfc12f865bc3fcac3897e64533b930737ceb9ef245c8277de98d0b010e9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/tools/hid-descriptor-dump/Cargo.toml b/tools/hid-descriptor-dump/Cargo.toml new file mode 100644 index 00000000..304f49fe --- /dev/null +++ b/tools/hid-descriptor-dump/Cargo.toml @@ -0,0 +1,27 @@ +# Capture a real HID device's report descriptor and decode it into something diffable against the +# blobs `packaging/windows/drivers/pf-gamepad/src/lib.rs` serves. Every descriptor we ship must be +# CAPTURED, not constructed (see that file's provenance warning, and the three bugs a constructed +# one already cost us) — this is the tool that captures them. +# +# Deliberately NOT a workspace member (see the root `Cargo.toml` `exclude` list): it pulls `hidapi`, +# a C library needing libudev on Linux, which we do not want in `cargo build --workspace` or on any +# CI leg. It is a bring-your-own-hardware measurement tool — build it standalone on the box that has +# the pad: +# +# cargo run --manifest-path tools/hid-descriptor-dump/Cargo.toml -- --list +# +# Stands alone. Without this, cargo walks up, finds the repo's `[workspace]` and refuses to build a +# package that root does not list as a member. +[workspace] + +[package] +name = "hid-descriptor-dump" +description = "Capture and decode a real HID device's report descriptor, for diffing against the ones we synthesize" +version = "0.26.0" +edition = "2024" +rust-version = "1.96.0" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +hidapi = "2.6" diff --git a/tools/hid-descriptor-dump/captures/dualsense-054C-0CE6-usb-windows.txt b/tools/hid-descriptor-dump/captures/dualsense-054C-0CE6-usb-windows.txt new file mode 100644 index 00000000..7bb40ad2 --- /dev/null +++ b/tools/hid-descriptor-dump/captures/dualsense-054C-0CE6-usb-windows.txt @@ -0,0 +1,425 @@ +DualSense Wireless Controller — report descriptor, captured 2026-08-09 on .173 over USB. + +WHY THIS FILE EXISTS: it is the CONTROL that makes the Elite capture next to it trustworthy. +`DUALSENSE_RDESC` in packaging/windows/drivers/pf-gamepad/src/lib.rs is verbatim from real hardware +(via inputtino), so pointing the tool at a real DualSense on the same box, in the same session, +tests the tool against a known-good answer. + +RESULT — PASS, on both halves of the tool: + * descriptor: the reconstruction reproduces the real DualSense layout exactly — input report 0x01, + 64 bytes, axes X,Y,Z,Rz,Rx,Ry packed 8-bit at bytes 1..6, hat at 8.0, 15 buttons at 8.4, vendor + bulk to byte 63, output report 0x02, and the feature-report ladder 0x05/0x08/0x09/0x0A/0x0B/ + 0x0C/0x20/0x21/0x22/0x80..0x85/0xA0/0xE0/0xF0..0xF5. It came back 467 bytes against the real + 273 — same layout, more verbose encoding. That single number is the evidence for the "diff the + layout, not the bytes" rule stated in the Elite capture's header. + * live reads: `--read 4` returned len=64 reports whose first byte is 0x01 (the report id), sticks + centred at 80 80 80 80 with the triggers at 00 00, byte 7 a monotonic counter, and the IMU and + trailing CRC bytes moving every frame. Exactly the documented report. + + +================================================================================================ +COLLECTION 1/1 — 054C:0CE6 usage_page 0x0001 (Generic Desktop) usage 0x0005 + manufacturer : Sony Interactive Entertainment + product : DualSense Wireless Controller + serial : + release : 0x0100 + interface : 3 + path : \\?\HID#VID_054C&PID_0CE6&MI_03#9&2429cc0c&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030} +================================================================================================ + +-- RAW (467 bytes) -- + 0000 05 01 09 05 A1 01 85 01 09 30 09 31 09 32 09 35 + 0010 09 33 09 34 15 00 26 FF 00 75 08 95 06 81 02 06 + 0020 00 FF 09 20 15 00 26 FF 00 75 08 95 01 81 02 05 + 0030 01 09 39 15 00 25 07 35 00 46 3B 01 65 14 75 04 + 0040 95 01 81 42 05 09 19 01 29 0F 15 00 25 01 75 01 + 0050 95 0F 45 00 65 00 81 02 06 00 FF 09 21 15 00 25 + 0060 01 75 01 95 0D 81 02 09 22 15 00 26 FF 00 35 00 + 0070 46 3B 01 75 08 95 34 81 02 85 02 09 23 15 00 26 + 0080 FF 00 75 08 95 2F 91 02 85 05 09 33 15 00 26 FF + 0090 00 75 08 95 28 B1 02 85 08 09 34 15 00 26 FF 00 + 00A0 75 08 95 2F B1 02 85 09 09 24 15 00 26 FF 00 75 + 00B0 08 95 13 B1 02 85 0A 09 25 15 00 26 FF 00 75 08 + 00C0 95 1A B1 02 85 0B 09 41 15 00 26 FF 00 75 08 95 + 00D0 29 B1 02 85 0C 09 42 15 00 26 FF 00 75 08 95 29 + 00E0 B1 02 85 20 09 26 15 00 26 FF 00 75 08 95 3F B1 + 00F0 02 85 21 09 27 15 00 26 FF 00 75 08 95 04 B1 02 + 0100 85 22 09 40 15 00 26 FF 00 75 08 95 3F B1 02 85 + 0110 80 09 28 15 00 26 FF 00 75 08 95 3F B1 02 85 81 + 0120 09 29 15 00 26 FF 00 75 08 95 3F B1 02 85 82 09 + 0130 2A 15 00 26 FF 00 75 08 95 09 B1 02 85 83 09 2B + 0140 15 00 26 FF 00 75 08 95 3F B1 02 85 84 09 2C 15 + 0150 00 26 FF 00 75 08 95 3F B1 02 85 85 09 2D 15 00 + 0160 26 FF 00 75 08 95 02 B1 02 85 A0 09 2E 15 00 26 + 0170 FF 00 75 08 95 01 B1 02 85 E0 09 2F 15 00 26 FF + 0180 00 75 08 95 3F B1 02 85 F0 09 30 15 00 26 FF 00 + 0190 75 08 95 3F B1 02 85 F1 09 31 15 00 26 FF 00 75 + 01A0 08 95 3F B1 02 85 F2 09 32 15 00 26 FF 00 75 08 + 01B0 95 0F B1 02 85 F4 09 35 15 00 26 FF 00 75 08 95 + 01C0 3F B1 02 85 F5 09 36 15 00 26 FF 00 75 08 95 03 + 01D0 B1 02 C0 + +-- ITEMS -- +0x05, 0x01, // Usage Page (Generic Desktop) +0x09, 0x05, // Usage (Game Pad) +0xA1, 0x01, // Collection (Application) +0x85, 0x01, // Report ID (1) +0x09, 0x30, // Usage (X) +0x09, 0x31, // Usage (Y) +0x09, 0x32, // Usage (Z) +0x09, 0x35, // Usage (Rz) +0x09, 0x33, // Usage (Rx) +0x09, 0x34, // Usage (Ry) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x06, // Report Count (6) +0x81, 0x02, // Input (Data,Var,Abs) +0x06, 0x00, 0xFF, // Usage Page (Vendor Defined) +0x09, 0x20, // Usage (0x20) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x01, // Report Count (1) +0x81, 0x02, // Input (Data,Var,Abs) +0x05, 0x01, // Usage Page (Generic Desktop) +0x09, 0x39, // Usage (Hat switch) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x07, // Logical Maximum (7) +0x35, 0x00, // Physical Minimum (0) +0x46, 0x3B, 0x01, // Physical Maximum (315) +0x65, 0x14, // Unit (Eng Rot: Degrees) +0x75, 0x04, // Report Size (4) +0x95, 0x01, // Report Count (1) +0x81, 0x42, // Input (Data,Var,Abs,Null State) +0x05, 0x09, // Usage Page (Button) +0x19, 0x01, // Usage Minimum (1) +0x29, 0x0F, // Usage Maximum (15) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x01, // Logical Maximum (1) +0x75, 0x01, // Report Size (1) +0x95, 0x0F, // Report Count (15) +0x45, 0x00, // Physical Maximum (0) +0x65, 0x00, // Unit (None) +0x81, 0x02, // Input (Data,Var,Abs) +0x06, 0x00, 0xFF, // Usage Page (Vendor Defined) +0x09, 0x21, // Usage (0x21) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x01, // Logical Maximum (1) +0x75, 0x01, // Report Size (1) +0x95, 0x0D, // Report Count (13) +0x81, 0x02, // Input (Data,Var,Abs) +0x09, 0x22, // Usage (0x22) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x35, 0x00, // Physical Minimum (0) +0x46, 0x3B, 0x01, // Physical Maximum (315) +0x75, 0x08, // Report Size (8) +0x95, 0x34, // Report Count (52) +0x81, 0x02, // Input (Data,Var,Abs) +0x85, 0x02, // Report ID (2) +0x09, 0x23, // Usage (0x23) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x2F, // Report Count (47) +0x91, 0x02, // Output (Data,Var,Abs) +0x85, 0x05, // Report ID (5) +0x09, 0x33, // Usage (0x33) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x28, // Report Count (40) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x08, // Report ID (8) +0x09, 0x34, // Usage (0x34) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x2F, // Report Count (47) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x09, // Report ID (9) +0x09, 0x24, // Usage (0x24) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x13, // Report Count (19) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x0A, // Report ID (10) +0x09, 0x25, // Usage (0x25) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x1A, // Report Count (26) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x0B, // Report ID (11) +0x09, 0x41, // Usage (0x41) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x29, // Report Count (41) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x0C, // Report ID (12) +0x09, 0x42, // Usage (0x42) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x29, // Report Count (41) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x20, // Report ID (32) +0x09, 0x26, // Usage (0x26) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x21, // Report ID (33) +0x09, 0x27, // Usage (0x27) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x04, // Report Count (4) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x22, // Report ID (34) +0x09, 0x40, // Usage (0x40) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x80, // Report ID (128) +0x09, 0x28, // Usage (0x28) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x81, // Report ID (129) +0x09, 0x29, // Usage (0x29) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x82, // Report ID (130) +0x09, 0x2A, // Usage (0x2A) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x09, // Report Count (9) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x83, // Report ID (131) +0x09, 0x2B, // Usage (0x2B) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x84, // Report ID (132) +0x09, 0x2C, // Usage (0x2C) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x85, // Report ID (133) +0x09, 0x2D, // Usage (0x2D) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x02, // Report Count (2) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xA0, // Report ID (160) +0x09, 0x2E, // Usage (0x2E) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x01, // Report Count (1) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xE0, // Report ID (224) +0x09, 0x2F, // Usage (0x2F) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xF0, // Report ID (240) +0x09, 0x30, // Usage (0x30) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xF1, // Report ID (241) +0x09, 0x31, // Usage (0x31) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xF2, // Report ID (242) +0x09, 0x32, // Usage (0x32) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x0F, // Report Count (15) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xF4, // Report ID (244) +0x09, 0x35, // Usage (0x35) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xF5, // Report ID (245) +0x09, 0x36, // Usage (0x36) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x03, // Report Count (3) +0xB1, 0x02, // Feature (Data,Var,Abs) +0xC0, // End Collection + +-- LAYOUT -- + + Input report 0x01 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×6 X, Y, Z, Rz, Rx, Ry 0..255 Data,Var,Abs + 7.0 8×1 0x20 0..255 Data,Var,Abs + 8.0 4×1 Hat switch 0..7 Data,Var,Abs,Null State + 8.4 1×15 Button 1..15 0..1 Data,Var,Abs + 10.3 1×13 0x21 0..1 Data,Var,Abs + 12.0 8×52 0x22 0..255 Data,Var,Abs + + Output report 0x02 — 376 bits, 48 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×47 0x23 0..255 Data,Var,Abs + + Feature report 0x05 — 320 bits, 41 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×40 0x33 0..255 Data,Var,Abs + + Feature report 0x08 — 376 bits, 48 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×47 0x34 0..255 Data,Var,Abs + + Feature report 0x09 — 152 bits, 20 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×19 0x24 0..255 Data,Var,Abs + + Feature report 0x0A — 208 bits, 27 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×26 0x25 0..255 Data,Var,Abs + + Feature report 0x0B — 328 bits, 42 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×41 0x41 0..255 Data,Var,Abs + + Feature report 0x0C — 328 bits, 42 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×41 0x42 0..255 Data,Var,Abs + + Feature report 0x20 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x26 0..255 Data,Var,Abs + + Feature report 0x21 — 32 bits, 5 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×4 0x27 0..255 Data,Var,Abs + + Feature report 0x22 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x40 0..255 Data,Var,Abs + + Feature report 0x80 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x28 0..255 Data,Var,Abs + + Feature report 0x81 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x29 0..255 Data,Var,Abs + + Feature report 0x82 — 72 bits, 10 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×9 0x2A 0..255 Data,Var,Abs + + Feature report 0x83 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x2B 0..255 Data,Var,Abs + + Feature report 0x84 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x2C 0..255 Data,Var,Abs + + Feature report 0x85 — 16 bits, 3 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×2 0x2D 0..255 Data,Var,Abs + + Feature report 0xA0 — 8 bits, 2 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×1 0x2E 0..255 Data,Var,Abs + + Feature report 0xE0 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x2F 0..255 Data,Var,Abs + + Feature report 0xF0 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x30 0..255 Data,Var,Abs + + Feature report 0xF1 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x31 0..255 Data,Var,Abs + + Feature report 0xF2 — 120 bits, 16 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×15 0x32 0..255 Data,Var,Abs + + Feature report 0xF4 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x35 0..255 Data,Var,Abs + + Feature report 0xF5 — 24 bits, 4 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×3 0x36 0..255 Data,Var,Abs + +-- SUMMARY -- + INPUT items: 6 + OUTPUT items: 1 + FEATURE items: 22 + structure: OK + +-- RUST -- +#[rustfmt::skip] +static DUALSENSE_CAPTURED: [u8; 467] = [ + 0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35, + 0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06, + 0x00, 0xFF, 0x09, 0x20, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01, 0x81, 0x02, 0x05, + 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, + 0x95, 0x01, 0x81, 0x42, 0x05, 0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, + 0x95, 0x0F, 0x45, 0x00, 0x65, 0x00, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x21, 0x15, 0x00, 0x25, + 0x01, 0x75, 0x01, 0x95, 0x0D, 0x81, 0x02, 0x09, 0x22, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x35, 0x00, + 0x46, 0x3B, 0x01, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x15, 0x00, 0x26, + 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2F, 0x91, 0x02, 0x85, 0x05, 0x09, 0x33, 0x15, 0x00, 0x26, 0xFF, + 0x00, 0x75, 0x08, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, + 0x75, 0x08, 0x95, 0x2F, 0xB1, 0x02, 0x85, 0x09, 0x09, 0x24, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, + 0x08, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, + 0x95, 0x1A, 0xB1, 0x02, 0x85, 0x0B, 0x09, 0x41, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, + 0x29, 0xB1, 0x02, 0x85, 0x0C, 0x09, 0x42, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x29, + 0xB1, 0x02, 0x85, 0x20, 0x09, 0x26, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, + 0x02, 0x85, 0x21, 0x09, 0x27, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x04, 0xB1, 0x02, + 0x85, 0x22, 0x09, 0x40, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, + 0x80, 0x09, 0x28, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x81, + 0x09, 0x29, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, + 0x2A, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0xB1, 0x02, 0x85, 0x83, 0x09, 0x2B, + 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x15, + 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x2D, 0x15, 0x00, + 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x15, 0x00, 0x26, + 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xE0, 0x09, 0x2F, 0x15, 0x00, 0x26, 0xFF, + 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x15, 0x00, 0x26, 0xFF, 0x00, + 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF1, 0x09, 0x31, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, + 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, + 0x95, 0x0F, 0xB1, 0x02, 0x85, 0xF4, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, + 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, + 0xB1, 0x02, 0xC0, +]; diff --git a/tools/hid-descriptor-dump/captures/xbox-elite-series2-045E-0B22-ble-windows.txt b/tools/hid-descriptor-dump/captures/xbox-elite-series2-045E-0B22-ble-windows.txt new file mode 100644 index 00000000..e03a4327 --- /dev/null +++ b/tools/hid-descriptor-dump/captures/xbox-elite-series2-045E-0B22-ble-windows.txt @@ -0,0 +1,204 @@ +Xbox Elite Wireless Controller Series 2 — report descriptor, as captured 2026-08-09. + +HOW THIS WAS TAKEN + box .173, Windows 11 26200, German locale + pad Xbox Elite Wireless Controller Series 2, VID 045E PID 0B22, HID rev 0x0521, + BD_ADDR 686CE647F191, paired and connected over BLUETOOTH LOW ENERGY (HID-over-GATT). + Windows enumerates it as BTHLEDEVICE\{00001812-...}, NOT classic BTHENUM. + command hid-descriptor-dump --vid 045E --pid 0B22 --name XBOX_ELITE2_RDESC + tool tools/hid-descriptor-dump (this directory) + +⚠️ WHAT THIS IS AND IS NOT — READ BEFORE COPYING BYTES OUT OF IT. +Windows exposes no API returning a device's literal report-descriptor bytes: the HID class driver +keeps only the parsed form, so hidapi RECONSTRUCTS a descriptor from HidD_GetPreparsedData. The +reconstruction is faithful in STRUCTURE, ITEM ORDER and every field's BIT OFFSET; the byte encoding +is not the wire encoding. Measured proof, from the same run against the DualSense on the same box: +its real descriptor is 273 bytes and the reconstruction came back 467, because the reconstructor +re-states global items (Logical Min/Max, Report Size) before every report instead of letting them +persist. Same layout, different bytes. +⇒ DIFF THE LAYOUT TABLE, NOT THE RAW BYTES. A byte-exact capture needs Linux + /sys/class/hidraw/hidrawN/device/report_descriptor. + +⚠️ UNVERIFIED: whether this equals the pad's NATIVE report map. `xinputhid` is attached as an +UpperFilter on this pad's BLE transport node (DevicePropertyFlags=0x1 "BusDevice"), and the shape +below — one combined 16-bit `Z` trigger axis, 16 buttons, no report id, no OUTPUT collection — is +the classic legacy/DirectInput view rather than the two-separate-triggers layout documented for +Xbox pads over classic Bluetooth. The absence of ANY output collection is the tell: a real Xbox BT +pad does accept rumble output reports, and this view offers nowhere to send them. Cross-check on +Linux hidraw before treating this as the native map. + + +================================================================================================ +COLLECTION 1/2 — 045E:0B22 usage_page 0x0001 (Generic Desktop) usage 0x0005 + manufacturer : Microsoft + product : Xbox Wireless Controller + serial : 686ce647f191 + release : 0x0521 + interface : -1 + path : \\?\HID#{00001812-0000-1000-8000-00805f9b34fb}&Dev&VID_045e&PID_0b22&REV_0521&686ce647f191&Col01&IG_00#c&7384879&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030} +================================================================================================ + +-- RAW (117 bytes) -- + 0000 05 01 09 05 A1 01 09 00 A1 00 09 30 09 31 15 00 + 0010 25 FF 35 00 45 FF 75 10 95 02 81 02 C0 09 00 A1 + 0020 00 09 33 09 34 15 00 25 FF 75 10 95 02 81 02 C0 + 0030 09 00 A1 00 09 32 15 00 25 FF 75 10 95 01 81 02 + 0040 C0 05 09 19 01 29 10 15 00 25 01 75 01 95 10 45 + 0050 00 81 02 05 01 09 39 15 01 25 08 35 00 46 3B 10 + 0060 65 0E 75 04 95 01 81 42 75 04 95 01 81 03 75 08 + 0070 95 02 81 03 C0 + +-- ITEMS -- +0x05, 0x01, // Usage Page (Generic Desktop) +0x09, 0x05, // Usage (Game Pad) +0xA1, 0x01, // Collection (Application) +0x09, 0x00, // Usage (0x00) +0xA1, 0x00, // Collection (Physical) +0x09, 0x30, // Usage (X) +0x09, 0x31, // Usage (Y) +0x15, 0x00, // Logical Minimum (0) +0x25, 0xFF, // Logical Maximum (-1 — unsigned reading: 255) +0x35, 0x00, // Physical Minimum (0) +0x45, 0xFF, // Physical Maximum (-1) +0x75, 0x10, // Report Size (16) +0x95, 0x02, // Report Count (2) +0x81, 0x02, // Input (Data,Var,Abs) +0xC0, // End Collection +0x09, 0x00, // Usage (0x00) +0xA1, 0x00, // Collection (Physical) +0x09, 0x33, // Usage (Rx) +0x09, 0x34, // Usage (Ry) +0x15, 0x00, // Logical Minimum (0) +0x25, 0xFF, // Logical Maximum (-1 — unsigned reading: 255) +0x75, 0x10, // Report Size (16) +0x95, 0x02, // Report Count (2) +0x81, 0x02, // Input (Data,Var,Abs) +0xC0, // End Collection +0x09, 0x00, // Usage (0x00) +0xA1, 0x00, // Collection (Physical) +0x09, 0x32, // Usage (Z) +0x15, 0x00, // Logical Minimum (0) +0x25, 0xFF, // Logical Maximum (-1 — unsigned reading: 255) +0x75, 0x10, // Report Size (16) +0x95, 0x01, // Report Count (1) +0x81, 0x02, // Input (Data,Var,Abs) +0xC0, // End Collection +0x05, 0x09, // Usage Page (Button) +0x19, 0x01, // Usage Minimum (1) +0x29, 0x10, // Usage Maximum (16) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x01, // Logical Maximum (1) +0x75, 0x01, // Report Size (1) +0x95, 0x10, // Report Count (16) +0x45, 0x00, // Physical Maximum (0) +0x81, 0x02, // Input (Data,Var,Abs) +0x05, 0x01, // Usage Page (Generic Desktop) +0x09, 0x39, // Usage (Hat switch) +0x15, 0x01, // Logical Minimum (1) +0x25, 0x08, // Logical Maximum (8) +0x35, 0x00, // Physical Minimum (0) +0x46, 0x3B, 0x10, // Physical Maximum (4155) +0x65, 0x0E, // Unit (0xE) +0x75, 0x04, // Report Size (4) +0x95, 0x01, // Report Count (1) +0x81, 0x42, // Input (Data,Var,Abs,Null State) +0x75, 0x04, // Report Size (4) +0x95, 0x01, // Report Count (1) +0x81, 0x03, // Input (Cnst,Var,Abs) +0x75, 0x08, // Report Size (8) +0x95, 0x02, // Report Count (2) +0x81, 0x03, // Input (Cnst,Var,Abs) +0xC0, // End Collection + +-- LAYOUT -- + + Input report 0x00 — 120 bits, 15 bytes on the wire (unnumbered) + byte.bit size×cnt usage logical range flags + 0.0 16×2 X, Y 0..-1 Data,Var,Abs + 4.0 16×2 Rx, Ry 0..-1 Data,Var,Abs + 8.0 16×1 Z 0..-1 Data,Var,Abs + 10.0 1×16 Button 1..16 0..1 Data,Var,Abs + 12.0 4×1 Hat switch 1..8 Data,Var,Abs,Null State + 12.4 4×1 — (padding) 1..8 Cnst,Var,Abs + 13.0 8×2 — (padding) 1..8 Cnst,Var,Abs + +-- SUMMARY -- + INPUT items: 7 + OUTPUT items: 0 <-- NONE + FEATURE items: 0 <-- NONE + structure: OK + +-- RUST -- +#[rustfmt::skip] +static XBOX_ELITE2_RDESC_COL01: [u8; 117] = [ + 0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x09, 0x00, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x00, + 0x25, 0xFF, 0x35, 0x00, 0x45, 0xFF, 0x75, 0x10, 0x95, 0x02, 0x81, 0x02, 0xC0, 0x09, 0x00, 0xA1, + 0x00, 0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x25, 0xFF, 0x75, 0x10, 0x95, 0x02, 0x81, 0x02, 0xC0, + 0x09, 0x00, 0xA1, 0x00, 0x09, 0x32, 0x15, 0x00, 0x25, 0xFF, 0x75, 0x10, 0x95, 0x01, 0x81, 0x02, + 0xC0, 0x05, 0x09, 0x19, 0x01, 0x29, 0x10, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x10, 0x45, + 0x00, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x01, 0x25, 0x08, 0x35, 0x00, 0x46, 0x3B, 0x10, + 0x65, 0x0E, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x75, 0x04, 0x95, 0x01, 0x81, 0x03, 0x75, 0x08, + 0x95, 0x02, 0x81, 0x03, 0xC0, +]; + +================================================================================================ +COLLECTION 2/2 — 045E:0B22 usage_page 0x0001 (Generic Desktop) usage 0x0006 + manufacturer : Microsoft + product : Xbox Wireless Controller + serial : 686ce647f191 + release : 0x0521 + interface : -1 + path : \\?\HID#{00001812-0000-1000-8000-00805f9b34fb}&Dev&VID_045e&PID_0b22&REV_0521&686ce647f191&Col02&IG_00#c&7384879&0&0001#{4d1e55b2-f16f-11cf-88cb-001111000030}\KBD +================================================================================================ + +-- RAW (45 bytes) -- + 0000 05 01 09 06 A1 01 85 05 05 07 19 E0 29 E7 15 00 + 0010 25 01 75 01 95 08 81 02 75 08 95 01 81 03 19 00 + 0020 29 65 15 00 25 65 75 08 95 06 81 00 C0 + +-- ITEMS -- +0x05, 0x01, // Usage Page (Generic Desktop) +0x09, 0x06, // Usage (Keyboard) +0xA1, 0x01, // Collection (Application) +0x85, 0x05, // Report ID (5) +0x05, 0x07, // Usage Page (Keyboard/Keypad) +0x19, 0xE0, // Usage Minimum (224) +0x29, 0xE7, // Usage Maximum (231) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x01, // Logical Maximum (1) +0x75, 0x01, // Report Size (1) +0x95, 0x08, // Report Count (8) +0x81, 0x02, // Input (Data,Var,Abs) +0x75, 0x08, // Report Size (8) +0x95, 0x01, // Report Count (1) +0x81, 0x03, // Input (Cnst,Var,Abs) +0x19, 0x00, // Usage Minimum (0) +0x29, 0x65, // Usage Maximum (101) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x65, // Logical Maximum (101) +0x75, 0x08, // Report Size (8) +0x95, 0x06, // Report Count (6) +0x81, 0x00, // Input (Data,Arr,Abs) +0xC0, // End Collection + +-- LAYOUT -- + + Input report 0x05 — 64 bits, 9 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 1×8 Keyboard/Keypad 224..231 0..1 Data,Var,Abs + 2.0 8×1 — (padding) 0..1 Cnst,Var,Abs + 3.0 8×6 Keyboard/Keypad 0..101 0..101 Data,Arr,Abs + +-- SUMMARY -- + INPUT items: 3 + OUTPUT items: 0 <-- NONE + FEATURE items: 0 <-- NONE + structure: OK + +-- RUST -- +#[rustfmt::skip] +static XBOX_ELITE2_RDESC_COL02: [u8; 45] = [ + 0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x85, 0x05, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, + 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x75, 0x08, 0x95, 0x01, 0x81, 0x03, 0x19, 0x00, + 0x29, 0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0, +]; diff --git a/tools/hid-descriptor-dump/src/decode.rs b/tools/hid-descriptor-dump/src/decode.rs new file mode 100644 index 00000000..f2824240 --- /dev/null +++ b/tools/hid-descriptor-dump/src/decode.rs @@ -0,0 +1,581 @@ +//! A HID 1.11 report-descriptor decoder, written for ONE job: making a captured descriptor +//! diffable, by eye, against the hand-annotated blobs in +//! `packaging/windows/drivers/pf-gamepad/src/lib.rs`. +//! +//! Two outputs matter, and they answer different questions: +//! +//! * the **item listing** — one line per HID item, formatted exactly like the annotated `static +//! XBOX_RDESC` arrays, so a capture can be pasted straight in and read side by side; +//! * the **layout map** — the running bit offset of every field, per report id and per report +//! kind. This is the one that catches the bugs that actually bite: `xbox_proto`'s layout tests +//! pin byte offsets, and a descriptor that declares the same usages in a different ORDER lands +//! every control on the wrong byte while looking correct item for item. + +use std::fmt::Write as _; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum MainKind { + Input, + Output, + Feature, +} + +impl MainKind { + pub fn as_str(self) -> &'static str { + match self { + MainKind::Input => "Input", + MainKind::Output => "Output", + MainKind::Feature => "Feature", + } + } +} + +/// One `Input`/`Output`/`Feature` main item, resolved against the global/local state in force. +pub struct Field { + pub kind: MainKind, + pub report_id: u8, + /// Bit offset within the report, report id byte NOT included (it is offset 0 of the wire + /// bytes, so add 8 when comparing against a wire layout that carries the id). + pub bit_offset: u32, + pub bit_size: u32, + pub count: u32, + pub usage_page: u16, + pub usages: Vec, + pub usage_range: Option<(u32, u32)>, + pub logical_min: i64, + pub logical_max: i64, + pub flags: u32, +} + +impl Field { + fn is_constant(&self) -> bool { + self.flags & 1 != 0 + } + + /// How the field would be written in an `Input (...)` annotation. + fn flags_str(&self) -> String { + let mut parts: Vec<&str> = Vec::new(); + parts.push(if self.flags & 0x01 != 0 { + "Cnst" + } else { + "Data" + }); + parts.push(if self.flags & 0x02 != 0 { "Var" } else { "Arr" }); + parts.push(if self.flags & 0x04 != 0 { "Rel" } else { "Abs" }); + if self.flags & 0x08 != 0 { + parts.push("Wrap"); + } + if self.flags & 0x10 != 0 { + parts.push("NonLin"); + } + if self.flags & 0x20 != 0 { + parts.push("NoPref"); + } + if self.flags & 0x40 != 0 { + parts.push("Null State"); + } + if self.flags & 0x80 != 0 { + parts.push("Volatile"); + } + if self.flags & 0x100 != 0 { + parts.push("Buff"); + } + parts.join(",") + } +} + +pub struct Decoded { + /// The annotated item listing. + pub listing: String, + pub fields: Vec, + /// Anything structurally wrong — trailing bytes, unbalanced collections, a truncated item. + pub problems: Vec, +} + +#[derive(Clone, Default)] +struct GlobalState { + usage_page: u16, + logical_min: i64, + logical_max: i64, + physical_min: i64, + physical_max: i64, + unit: u32, + unit_exp: u32, + report_size: u32, + report_id: u8, + report_count: u32, +} + +/// Running bit cursor, keyed by (report id, kind) — each report kind numbers its bits from zero. +#[derive(Default)] +struct Cursors { + input: Vec<(u8, u32)>, + output: Vec<(u8, u32)>, + feature: Vec<(u8, u32)>, +} + +impl Cursors { + fn take(&mut self, kind: MainKind, id: u8, bits: u32) -> u32 { + let v = match kind { + MainKind::Input => &mut self.input, + MainKind::Output => &mut self.output, + MainKind::Feature => &mut self.feature, + }; + match v.iter_mut().find(|(rid, _)| *rid == id) { + Some((_, at)) => { + let start = *at; + *at += bits; + start + } + None => { + v.push((id, bits)); + 0 + } + } + } +} + +/// Sign-extend `value`, which came off the wire in `size` bytes. +fn sign_extend(value: u32, size: usize) -> i64 { + match size { + 1 => value as u8 as i8 as i64, + 2 => value as u16 as i16 as i64, + 4 => value as i32 as i64, + _ => value as i64, + } +} + +pub fn usage_page_name(page: u16) -> &'static str { + match page { + 0x01 => "Generic Desktop", + 0x02 => "Simulation Controls", + 0x03 => "VR Controls", + 0x04 => "Sport Controls", + 0x05 => "Game Controls", + 0x06 => "Generic Device Controls", + 0x07 => "Keyboard/Keypad", + 0x08 => "LED", + 0x09 => "Button", + 0x0A => "Ordinal", + 0x0C => "Consumer", + 0x0D => "Digitizer", + 0x0F => "Physical Input Device (PID)", + 0xFF00..=0xFFFF => "Vendor Defined", + _ => "", + } +} + +fn usage_name(page: u16, usage: u32) -> &'static str { + match (page, usage) { + (0x01, 0x01) => "Pointer", + (0x01, 0x02) => "Mouse", + (0x01, 0x04) => "Joystick", + (0x01, 0x05) => "Game Pad", + (0x01, 0x06) => "Keyboard", + (0x01, 0x30) => "X", + (0x01, 0x31) => "Y", + (0x01, 0x32) => "Z", + (0x01, 0x33) => "Rx", + (0x01, 0x34) => "Ry", + (0x01, 0x35) => "Rz", + (0x01, 0x36) => "Slider", + (0x01, 0x37) => "Dial", + (0x01, 0x38) => "Wheel", + (0x01, 0x39) => "Hat switch", + (0x01, 0x3A) => "Counted Buffer", + (0x01, 0x80) => "System Control", + (0x01, 0x85) => "System Main Menu", + (0x02, 0xC4) => "Accelerator", + (0x02, 0xC5) => "Brake", + (0x02, 0xBB) => "Throttle", + (0x02, 0xBA) => "Rudder", + (0x06, 0x20) => "Battery Strength", + (0x0C, 0x01) => "Consumer Control", + (0x0C, 0x223) => "AC Home", + (0x0C, 0x224) => "AC Back", + _ => "", + } +} + +fn collection_name(v: u32) -> &'static str { + match v { + 0x00 => "Physical", + 0x01 => "Application", + 0x02 => "Logical", + 0x03 => "Report", + 0x04 => "Named Array", + 0x05 => "Usage Switch", + 0x06 => "Usage Modifier", + _ => "Vendor", + } +} + +pub fn decode(desc: &[u8]) -> Decoded { + let mut listing = String::new(); + let mut problems = Vec::new(); + let mut fields = Vec::new(); + + let mut g = GlobalState::default(); + let mut stack: Vec = Vec::new(); + let mut usages: Vec = Vec::new(); + let mut usage_min: Option = None; + let mut usage_max: Option = None; + let mut cursors = Cursors::default(); + let mut depth: usize = 0; + + let mut i = 0usize; + while i < desc.len() { + let prefix = desc[i]; + let start = i; + + // Long items (prefix 0xFE) exist in the spec and in no gamepad we have ever seen; carry + // them through so an unexpected one is reported rather than silently desynchronising the + // rest of the parse. + if prefix == 0xFE { + if i + 2 >= desc.len() { + problems.push(format!("truncated long item at byte {start}")); + break; + } + let data_size = desc[i + 1] as usize; + let tag = desc[i + 2]; + let end = i + 3 + data_size; + if end > desc.len() { + problems.push(format!("long item at byte {start} runs past the end")); + break; + } + let _ = writeln!( + listing, + "{:pad$}0xFE, /* long item, tag 0x{tag:02X}, {data_size} bytes */", + "", + pad = depth * 2 + ); + i = end; + continue; + } + + let size_code = (prefix & 0x03) as usize; + let data_size = if size_code == 3 { 4 } else { size_code }; + let ty = (prefix >> 2) & 0x03; + let tag = prefix >> 4; + if i + 1 + data_size > desc.len() { + problems.push(format!( + "truncated item at byte {start}: prefix 0x{prefix:02X} wants {data_size} data bytes, \ + {} remain", + desc.len() - i - 1 + )); + break; + } + let mut raw: u32 = 0; + for b in 0..data_size { + raw |= (desc[i + 1 + b] as u32) << (8 * b); + } + let signed = sign_extend(raw, data_size); + i += 1 + data_size; + + let bytes_hex = desc[start..i] + .iter() + .map(|b| format!("0x{b:02X},")) + .collect::>() + .join(" "); + + // Indentation mirrors the annotated arrays in the driver: collections indent their body. + let mut emit = |depth: usize, text: String| { + let _ = writeln!( + listing, + "{:<38} // {:pad$}{text}", + bytes_hex, + "", + pad = depth * 2 + ); + }; + + match ty { + // ---- Main ---- + 0 => match tag { + 0x08 | 0x09 | 0x0B => { + let kind = match tag { + 0x08 => MainKind::Input, + 0x09 => MainKind::Output, + _ => MainKind::Feature, + }; + let bits = g.report_size * g.report_count; + let bit_offset = cursors.take(kind, g.report_id, bits); + let f = Field { + kind, + report_id: g.report_id, + bit_offset, + bit_size: g.report_size, + count: g.report_count, + usage_page: g.usage_page, + usages: usages.clone(), + usage_range: match (usage_min, usage_max) { + (Some(a), Some(b)) => Some((a, b)), + _ => None, + }, + logical_min: g.logical_min, + logical_max: g.logical_max, + flags: raw, + }; + emit(depth, format!("{} ({})", kind.as_str(), f.flags_str())); + fields.push(f); + usages.clear(); + usage_min = None; + usage_max = None; + } + 0x0A => { + emit(depth, format!("Collection ({})", collection_name(raw))); + depth += 1; + usages.clear(); + usage_min = None; + usage_max = None; + } + 0x0C => { + depth = depth.saturating_sub(1); + emit(depth, "End Collection".to_string()); + usages.clear(); + usage_min = None; + usage_max = None; + } + _ => { + problems.push(format!("unknown Main tag 0x{tag:X} at byte {start}")); + emit(depth, format!("")); + } + }, + // ---- Global ---- + 1 => match tag { + 0x0 => { + g.usage_page = raw as u16; + let n = usage_page_name(g.usage_page); + emit( + depth, + if n.is_empty() { + format!("Usage Page (0x{:04X})", g.usage_page) + } else { + format!("Usage Page ({n})") + }, + ); + } + 0x1 => { + g.logical_min = signed; + emit(depth, format!("Logical Minimum ({signed})")); + } + 0x2 => { + g.logical_max = signed; + emit( + depth, + // A maximum is only signed when the minimum was; showing both readings + // keeps a `0x25 0xFF` (255 or -1) from being silently misread. + if g.logical_min < 0 || signed >= 0 { + format!("Logical Maximum ({signed})") + } else { + format!("Logical Maximum ({signed} — unsigned reading: {raw})") + }, + ); + } + 0x3 => { + g.physical_min = signed; + emit(depth, format!("Physical Minimum ({signed})")); + } + 0x4 => { + g.physical_max = signed; + emit(depth, format!("Physical Maximum ({signed})")); + } + 0x5 => { + g.unit_exp = raw; + emit(depth, format!("Unit Exponent (0x{raw:X})")); + } + 0x6 => { + g.unit = raw; + emit( + depth, + match raw { + 0x14 => "Unit (Eng Rot: Degrees)".to_string(), + 0x00 => "Unit (None)".to_string(), + _ => format!("Unit (0x{raw:X})"), + }, + ); + } + 0x7 => { + g.report_size = raw; + emit(depth, format!("Report Size ({raw})")); + } + 0x8 => { + g.report_id = raw as u8; + emit(depth, format!("Report ID ({raw})")); + } + 0x9 => { + g.report_count = raw; + emit(depth, format!("Report Count ({raw})")); + } + 0xA => { + stack.push(g.clone()); + emit(depth, "Push".to_string()); + } + 0xB => { + match stack.pop() { + Some(prev) => g = prev, + None => problems.push(format!("Pop with an empty stack at byte {start}")), + } + emit(depth, "Pop".to_string()); + } + _ => { + problems.push(format!("unknown Global tag 0x{tag:X} at byte {start}")); + emit(depth, format!("")); + } + }, + // ---- Local ---- + 2 => match tag { + 0x0 => { + // A 4-byte Usage carries its page in the high half. + let (page, u) = if data_size == 4 { + ((raw >> 16) as u16, raw & 0xFFFF) + } else { + (g.usage_page, raw) + }; + usages.push(u); + let n = usage_name(page, u); + emit( + depth, + if n.is_empty() { + format!("Usage (0x{u:02X})") + } else { + format!("Usage ({n})") + }, + ); + } + 0x1 => { + usage_min = Some(raw); + emit(depth, format!("Usage Minimum ({raw})")); + } + 0x2 => { + usage_max = Some(raw); + emit(depth, format!("Usage Maximum ({raw})")); + } + 0x3 => emit(depth, format!("Designator Index ({raw})")), + 0x4 => emit(depth, format!("Designator Minimum ({raw})")), + 0x5 => emit(depth, format!("Designator Maximum ({raw})")), + 0x7 => emit(depth, format!("String Index ({raw})")), + 0x8 => emit(depth, format!("String Minimum ({raw})")), + 0x9 => emit(depth, format!("String Maximum ({raw})")), + 0xA => emit(depth, format!("Delimiter ({raw})")), + _ => { + problems.push(format!("unknown Local tag 0x{tag:X} at byte {start}")); + emit(depth, format!("")); + } + }, + _ => { + problems.push(format!("reserved item type at byte {start}")); + emit(depth, "".to_string()); + } + } + } + + if depth != 0 { + problems.push(format!("{depth} collection(s) never closed")); + } + if !stack.is_empty() { + problems.push(format!("{} Push(es) never popped", stack.len())); + } + + Decoded { + listing, + fields, + problems, + } +} + +/// The bit-offset table. This is what a layout diff should be read off — item order, not item +/// presence, is what silently lands a control on the wrong byte. +pub fn layout_map(fields: &[Field]) -> String { + let mut out = String::new(); + for kind in [MainKind::Input, MainKind::Output, MainKind::Feature] { + let mut ids: Vec = fields + .iter() + .filter(|f| f.kind == kind) + .map(|f| f.report_id) + .collect(); + ids.sort_unstable(); + ids.dedup(); + for id in ids { + let of_report: Vec<&Field> = fields + .iter() + .filter(|f| f.kind == kind && f.report_id == id) + .collect(); + let bits: u32 = of_report.iter().map(|f| f.bit_size * f.count).sum(); + // The id byte is on the wire whenever the descriptor numbers its reports at all. + let wire = if id == 0 { + bits.div_ceil(8) as usize + } else { + bits.div_ceil(8) as usize + 1 + }; + let _ = writeln!( + out, + "\n {} report 0x{id:02X} — {bits} bits, {wire} bytes on the wire{}", + kind.as_str(), + if id == 0 { + " (unnumbered)" + } else { + " (id included)" + } + ); + let _ = writeln!( + out, + " {:<12} {:<9} {:<26} {:<20} flags", + "byte.bit", "size×cnt", "usage", "logical range" + ); + for f in of_report { + let id_shift = if id == 0 { 0 } else { 8 }; + let abs = f.bit_offset + id_shift; + let usage = if let Some((a, b)) = f.usage_range { + format!("{} {a}..{b}", usage_page_name(f.usage_page)) + } else if f.usages.is_empty() { + if f.is_constant() { + "— (padding)".to_string() + } else { + "— (none declared)".to_string() + } + } else { + f.usages + .iter() + .map(|u| { + let n = usage_name(f.usage_page, *u); + if n.is_empty() { + format!("0x{u:02X}") + } else { + n.to_string() + } + }) + .collect::>() + .join(", ") + }; + let _ = writeln!( + out, + " {:<12} {:<9} {:<26} {:<20} {}", + format!("{}.{}", abs / 8, abs % 8), + format!("{}×{}", f.bit_size, f.count), + usage, + format!("{}..{}", f.logical_min, f.logical_max), + f.flags_str() + ); + } + } + } + out +} + +/// Emit the blob as a `static` ready to paste into the driver. +pub fn rust_array(name: &str, desc: &[u8]) -> String { + let mut out = format!( + "#[rustfmt::skip]\nstatic {name}: [u8; {}] = [\n", + desc.len() + ); + for chunk in desc.chunks(16) { + out.push_str(" "); + for b in chunk { + let _ = write!(out, "0x{b:02X}, "); + } + out.push('\n'); + } + out.push_str("];\n"); + out +} diff --git a/tools/hid-descriptor-dump/src/main.rs b/tools/hid-descriptor-dump/src/main.rs new file mode 100644 index 00000000..cf14a2d0 --- /dev/null +++ b/tools/hid-descriptor-dump/src/main.rs @@ -0,0 +1,426 @@ +//! Capture a real HID device's report descriptor, decode it, and print it in the shape the +//! `pf-gamepad` driver keeps its blobs in. +//! +//! WHY THIS EXISTS. `packaging/windows/drivers/pf-gamepad/src/lib.rs` serves a report descriptor +//! per emulated pad. Three of the four are verbatim captures off real hardware; `XBOX_RDESC` was +//! hand-constructed, and its own provenance warning came true three separate times (a missing +//! channel-proof Feature report meant the pad never delivered a single input report; there is no +//! OUTPUT collection at all, so rumble cannot arrive; `xinputhid` appears to validate the +//! descriptor and rejects ours). We claim a genuine Microsoft VID/PID, and SDL, Steam and Windows +//! all apply stock mappings keyed on it — so a layout that differs from the real pad lands every +//! control on the wrong action. Captures, not constructions. +//! +//! USAGE +//! ```text +//! hid-descriptor-dump --list # every HID device, with vid/pid and usage +//! hid-descriptor-dump --vid 045E --pid 0B22 # dump every collection of that device +//! hid-descriptor-dump --vid 054C --pid 0CE6 --name DUALSENSE_RDESC +//! hid-descriptor-dump --path '\\?\HID#...' # one exact collection +//! ``` +//! +//! WHAT THE DESCRIPTOR COMES FROM, PER PLATFORM. On Linux hidapi reads +//! `/sys/class/hidraw/hidrawN/device/report_descriptor` — the literal bytes the device sent. On +//! Windows there is no API that returns those bytes: the HID class driver keeps only the parsed +//! form, so hidapi RECONSTRUCTS a descriptor from `HidD_GetPreparsedData`. The reconstruction is +//! faithful in structure, item order and every field's bit offset — which is what a layout diff +//! needs — but the byte encoding may differ from the wire (an item the device sent as one byte can +//! come back as two, and hidapi emits collections it inferred). ⇒ **Diff the LAYOUT MAP and the +//! item listing, not the raw bytes, when the capture came off Windows.** A byte-exact capture +//! needs Linux hidraw. +//! +//! This tool is deliberately not a workspace member; see its `Cargo.toml`. + +mod decode; + +use std::process::ExitCode; + +struct Args { + list: bool, + vid: Option, + pid: Option, + path: Option, + name: Option, + read: Option, + rust_source: Option, + symbol: Option, +} + +/// Pull a `static NAME: [u8; N] = [ 0x.., ... ];` out of a Rust source file. +/// +/// This is what makes the diff exact rather than eyeballed: our own shipped blobs get decoded by +/// the same decoder, into the same listing and the same layout table, as a capture off real +/// hardware. No hardware needed for this mode. +fn extract_rust_array(src: &str, symbol: &str) -> Result, String> { + let at = src + .find(&format!("static {symbol}:")) + .ok_or_else(|| format!("no `static {symbol}:` in that file"))?; + let open = src[at..] + .find('[') + .and_then(|i| src[at + i + 1..].find('[').map(|j| at + i + 1 + j + 1)) + .ok_or("could not find the array literal")?; + let close = src[open..] + .find(']') + .ok_or("array literal is never closed")? + + open; + // Strip trailing `// ...` comments LINE BY LINE before splitting on commas — the annotations in + // these arrays contain commas themselves (`// Input (Data,Var,Abs)`), so comma-splitting first + // scatters comment text into the byte stream. + let body: String = src[open..close] + .lines() + .map(|l| l.split("//").next().unwrap_or("")) + .collect::>() + .join(" "); + let mut out = Vec::new(); + for tok in body.split(',') { + let tok = tok.trim(); + if tok.is_empty() { + continue; + } + let hex = tok.trim_start_matches("0x").trim_start_matches("0X"); + out.push(u8::from_str_radix(hex, 16).map_err(|_| format!("`{tok}` is not a hex byte"))?); + } + Ok(out) +} + +fn parse_u16(s: &str) -> Option { + let s = s.trim_start_matches("0x").trim_start_matches("0X"); + u16::from_str_radix(s, 16).ok() +} + +fn parse_args() -> Result { + let mut a = Args { + list: false, + vid: None, + pid: None, + path: None, + name: None, + read: None, + rust_source: None, + symbol: None, + }; + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "--list" | "-l" => a.list = true, + "--vid" => { + let v = it.next().ok_or("--vid wants a hex value")?; + a.vid = Some(parse_u16(&v).ok_or_else(|| format!("--vid: {v} is not hex"))?); + } + "--pid" => { + let v = it.next().ok_or("--pid wants a hex value")?; + a.pid = Some(parse_u16(&v).ok_or_else(|| format!("--pid: {v} is not hex"))?); + } + "--path" => a.path = Some(it.next().ok_or("--path wants a device path")?), + "--name" => a.name = Some(it.next().ok_or("--name wants an identifier")?), + "--read" => { + let v = it.next().ok_or("--read wants a count")?; + a.read = Some( + v.parse() + .map_err(|_| format!("--read: {v} is not a count"))?, + ); + } + "--rust-source" => a.rust_source = Some(it.next().ok_or("--rust-source wants a path")?), + "--symbol" => a.symbol = Some(it.next().ok_or("--symbol wants an identifier")?), + "--help" | "-h" => { + println!("{}", HELP); + std::process::exit(0); + } + other => return Err(format!("unknown argument {other}")), + } + } + if !a.list && a.vid.is_none() && a.path.is_none() && a.rust_source.is_none() { + a.list = true; + } + if a.rust_source.is_some() != a.symbol.is_some() { + return Err("--rust-source and --symbol go together".into()); + } + Ok(a) +} + +const HELP: &str = "\ +hid-descriptor-dump — capture a real HID device's report descriptor + + --list list every HID device this box can open + --vid select by vendor id (e.g. 045E) + --pid select by product id (e.g. 0B22) + --path select one exact collection by its device path + --name also emit a `static IDENT: [u8; N]` ready to paste into the driver + --read after dumping, read n live input reports and show which bytes move + --rust-source decode a blob we already ship instead of a device (no hardware needed) + --symbol which `static IDENT: [u8; N]` in that file to decode + +With --vid/--pid every matching collection is dumped: a real Xbox pad presents two (a game +controller and a keyboard), and they are separate devices to hidapi. + +--read is the ground truth a reconstructed descriptor cannot give you: it is the literal wire +bytes. Use it to settle report length, whether reports are numbered, and which byte a control +actually lives in — wiggle one control at a time and watch the changed-byte mask."; + +/// Print a descriptor every way that is useful for a diff: raw, item listing, layout table, +/// a presence summary, and optionally a paste-ready Rust `static`. +fn report(desc: &[u8], emit_as: Option<&str>) { + println!("\n-- RAW ({} bytes) --", desc.len()); + for (off, chunk) in desc.chunks(16).enumerate() { + let hex = chunk + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" "); + println!(" {:04X} {hex}", off * 16); + } + + let decoded = decode::decode(desc); + println!("\n-- ITEMS --"); + print!("{}", decoded.listing); + + println!("\n-- LAYOUT --"); + print!("{}", decode::layout_map(&decoded.fields)); + + println!("\n-- SUMMARY --"); + for (k, label) in [ + (decode::MainKind::Input, "INPUT"), + (decode::MainKind::Output, "OUTPUT"), + (decode::MainKind::Feature, "FEATURE"), + ] { + let count = decoded.fields.iter().filter(|f| f.kind == k).count(); + println!( + " {label:<8} items: {count}{}", + if count == 0 { " <-- NONE" } else { "" } + ); + } + if decoded.problems.is_empty() { + println!(" structure: OK"); + } else { + println!(" structure: {} PROBLEM(S)", decoded.problems.len()); + for p in &decoded.problems { + println!(" - {p}"); + } + } + + if let Some(name) = emit_as { + println!("\n-- RUST --"); + print!("{}", decode::rust_array(name, desc)); + } +} + +/// Read live input reports and show which bytes ever move. The descriptor says where a control +/// SHOULD be; this says where it IS. +fn watch(dev: &hidapi::HidDevice, count: usize) { + println!("\n-- LIVE REPORTS ({count} requested, 3 s each) --"); + println!(" (move ONE control at a time and read the changed-byte mask)"); + let mut buf = [0u8; 256]; + let mut first: Option> = None; + let mut ever_changed = vec![false; 256]; + let mut got = 0usize; + for _ in 0..count { + match dev.read_timeout(&mut buf, 3000) { + Ok(0) => { + println!(" (timeout — no report; the pad may be idle)"); + continue; + } + Ok(n) => { + got += 1; + let sample = &buf[..n]; + let hex = sample + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" "); + match &first { + None => { + println!(" len={n} {hex} <-- baseline"); + first = Some(sample.to_vec()); + } + Some(base) => { + let mut marks = String::new(); + for i in 0..n { + let differs = base.get(i) != Some(&sample[i]); + if differs { + ever_changed[i] = true; + } + marks.push_str(if differs { "^^ " } else { ".. " }); + } + println!(" len={n} {hex}"); + println!(" {marks}"); + } + } + } + Err(e) => { + println!(" read error: {e}"); + break; + } + } + } + if let Some(base) = &first { + let moved: Vec = (0..base.len()) + .filter(|i| ever_changed[*i]) + .map(|i| i.to_string()) + .collect(); + println!( + " {got} report(s); report length {}; bytes that ever moved: {}", + base.len(), + if moved.is_empty() { + "none".to_string() + } else { + moved.join(", ") + } + ); + println!( + " first byte of every report was 0x{:02X} — {}", + base[0], + if base[0] == 0x01 { + "consistent with a numbered report id 1" + } else { + "note this when deciding whether reports are numbered" + } + ); + } +} + +fn main() -> ExitCode { + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + eprintln!("error: {e}\n\n{HELP}"); + return ExitCode::FAILURE; + } + }; + + // Decoding one of our own blobs needs no hardware, so it runs before hidapi is even opened — + // this mode works on any box, including CI and a Mac. + if let (Some(file), Some(symbol)) = (&args.rust_source, &args.symbol) { + let src = match std::fs::read_to_string(file) { + Ok(s) => s, + Err(e) => { + eprintln!("error: {file}: {e}"); + return ExitCode::FAILURE; + } + }; + let desc = match extract_rust_array(&src, symbol) { + Ok(d) => d, + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::FAILURE; + } + }; + println!("{}", "=".repeat(96)); + println!("SHIPPED BLOB — {symbol} from {file}"); + println!("{}", "=".repeat(96)); + report(&desc, args.name.as_deref()); + return ExitCode::SUCCESS; + } + + let api = match hidapi::HidApi::new() { + Ok(a) => a, + Err(e) => { + eprintln!("error: hidapi init failed: {e}"); + return ExitCode::FAILURE; + } + }; + + let devices: Vec<_> = api.device_list().collect(); + if args.list { + println!( + "{:<6} {:<6} {:<5} {:<5} {:<34} path", + "vid", "pid", "page", "usage", "product" + ); + for d in &devices { + println!( + "{:04X} {:04X} {:04X} {:04X} {:<34} {}", + d.vendor_id(), + d.product_id(), + d.usage_page(), + d.usage(), + d.product_string().unwrap_or("—"), + d.path().to_string_lossy() + ); + } + println!("\n{} device(s).", devices.len()); + if args.vid.is_none() && args.path.is_none() { + return ExitCode::SUCCESS; + } + } + + let selected: Vec<_> = devices + .iter() + .filter(|d| { + if let Some(p) = &args.path { + return d.path().to_string_lossy() == p.as_str(); + } + args.vid.is_none_or(|v| d.vendor_id() == v) + && args.pid.is_none_or(|p| d.product_id() == p) + }) + .collect(); + + if selected.is_empty() { + eprintln!( + "error: nothing matched. If this is a Bluetooth pad, POWER IT ON — a disconnected BLE \ + device leaves its devnodes behind but has no HID interface to open." + ); + return ExitCode::FAILURE; + } + + let mut failures = 0usize; + for (n, d) in selected.iter().enumerate() { + println!("\n{}", "=".repeat(96)); + println!( + "COLLECTION {}/{} — {:04X}:{:04X} usage_page 0x{:04X} ({}) usage 0x{:04X}", + n + 1, + selected.len(), + d.vendor_id(), + d.product_id(), + d.usage_page(), + decode::usage_page_name(d.usage_page()), + d.usage() + ); + println!( + " manufacturer : {}", + d.manufacturer_string().unwrap_or("—") + ); + println!(" product : {}", d.product_string().unwrap_or("—")); + println!(" serial : {}", d.serial_number().unwrap_or("—")); + println!(" release : 0x{:04X}", d.release_number()); + println!(" interface : {}", d.interface_number()); + println!(" path : {}", d.path().to_string_lossy()); + println!("{}", "=".repeat(96)); + + let dev = match api.open_path(d.path()) { + Ok(dev) => dev, + Err(e) => { + eprintln!(" !! could not open: {e}"); + failures += 1; + continue; + } + }; + // 4 KiB is the HID class driver's own ceiling for a report descriptor. + let mut buf = vec![0u8; 4096]; + let len = match dev.get_report_descriptor(&mut buf) { + Ok(n) => n, + Err(e) => { + eprintln!(" !! could not read the report descriptor: {e}"); + failures += 1; + continue; + } + }; + buf.truncate(len); + + let emit_as = args.name.as_ref().map(|name| { + if selected.len() > 1 { + format!("{name}_COL{:02}", n + 1) + } else { + name.clone() + } + }); + report(&buf, emit_as.as_deref()); + + if let Some(count) = args.read { + watch(&dev, count); + } + } + + if failures > 0 { + eprintln!("\n{failures} collection(s) could not be read."); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} -- 2.54.0 From 13438b128782392f630c4b00fcf1d87cc62914bd Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 19:24:28 +0200 Subject: [PATCH 08/16] test(tools): ask Windows which input APIs can see the pad, and find what promotes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Xbox-pad-on-Windows programme is a five-row matrix — classic XInput, WGI `Gamepad`, WGI `RawGameController`, GameInput, and the HID/DirectInput/Steam family — and nothing in this tree measured any of it. Every reading in the handoff came from ad-hoc off-tree tools, which is why several could not be reproduced later and why one was a false positive. `win-input-matrix` makes the matrix a command you can run twice and diff. Two traps are baked into it because both have already cost this programme a wrong conclusion. `--watch` samples repeatedly and reports LIVE vs MUTE per device, because an API listing a pad that never reports is the exact failure mode here — worse than not listing it, since a title that binds the first gamepad latches a dead one. And the doc comment insists on a baseline with the virtual pad STOPPED: a real Xbox pad owns XInput slot 0, which is how `rc=0 LX=-885` was once read as success with our pad already killed. ⭐ `wake_wgi()` is not optional and is commented as such. `Gamepad::Gamepads()` and `RawGameController::RawGameControllers()` return a cache filled by WGI's device-watcher, which a GUI app has already started and a console app has not. Without subscribing to the Added events first, BOTH collections come back empty with real controllers attached — measured here: a DualSense sitting in the HID interface class, `RawGameControllers` count=0. A probe missing this reports "WGI cannot see the pad" when WGI could not see anything. WHAT IT FOUND (full record in measurements/2026-08-09-xbox-hid-xinputhid-busfilter.md): with `UpperFilters=xinputhid` on the pad's PARENT devnode AND `DevicePropertyFlags=1` in that parent's SOFTWARE key, the HID Xbox pad is promoted for the first time — the child gains the `IG_00` token, an XUSB interface appears, classic XInput admits it, and WGI `Gamepad` lists it. All four had never happened on this backend. A one-value A/B proves `DevicePropertyFlags` is the decisive half: removing it alone reverts all four. That retro-explains the earlier "the filter installs fine and produces nothing" result — the filter was loading without ever being put in bus-filter mode, which is what `BusDevice = 0x1` means in Microsoft's own comment in `xinputhid.inf`. Not a workspace member, for the same reason as `hid-descriptor-dump`: it is a Windows-only bring-your-own-hardware tool with no business on a CI leg. VERIFIED * `cargo fmt --check` clean; `cargo clippy --target x86_64-pc-windows-msvc --all-targets -- -D warnings` clean (cross-checked from macOS; the target is installed). * Builds and runs on .173 (Win11 26200). * Self-checked against known-good hardware before any conclusion was drawn from it: baseline reads the USB DualSense as LIVE in both WGI collections and the resting 8BitDo as MUTE. * The A/B was run in both directions on the same box in one session. * `cargo metadata` on the root workspace resolves and does NOT list this crate. * .173 fully reverted: registry values removed, devnodes removed, oem100.inf deleted, both certs delstored, 6 pre-existing pf_gamepad packages and the production service untouched. NOT VERIFIED * GameInput — no binding in the `windows` crate, needs hand-written COM vtables. Not covered; the doc comment says so. * That the promotion survives a reboot or a devnode re-create from a shipped INF `AddReg` rather than a hand-written registry value. Nothing is shipped: `pf_gamepad.inx` is UNCHANGED and still contains no AddReg of any kind. * WHY the promoted pad still translates no data. Enumeration is fixed; translation is not. The evidence points at the report descriptor, which is gated on the §3.3 decision. --- Cargo.toml | 6 +- tools/win-input-matrix/Cargo.lock | 155 +++++++ tools/win-input-matrix/Cargo.toml | 34 ++ ...2026-08-09-xbox-hid-xinputhid-busfilter.md | 102 +++++ tools/win-input-matrix/src/main.rs | 422 ++++++++++++++++++ 5 files changed, 717 insertions(+), 2 deletions(-) create mode 100644 tools/win-input-matrix/Cargo.lock create mode 100644 tools/win-input-matrix/Cargo.toml create mode 100644 tools/win-input-matrix/measurements/2026-08-09-xbox-hid-xinputhid-busfilter.md create mode 100644 tools/win-input-matrix/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index e9f14efd..16f1369e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,9 +46,11 @@ members = [ exclude = [ "packaging/linux/steam-deck-gadget/usbip-poc", "clients/android/native/vendor/ndk", - # Bring-your-own-hardware measurement tool: pulls `hidapi`, a C library wanting libudev on - # Linux, which has no place in `cargo build --workspace` or on a CI leg with no pad attached. + # Bring-your-own-hardware measurement tools. `hid-descriptor-dump` pulls `hidapi`, a C library + # wanting libudev on Linux; `win-input-matrix` is Windows-only and asks the live input stacks + # what they can see. Neither belongs in `cargo build --workspace` or on a CI leg with no pad. "tools/hid-descriptor-dump", + "tools/win-input-matrix", ] # ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an diff --git a/tools/win-input-matrix/Cargo.lock b/tools/win-input-matrix/Cargo.lock new file mode 100644 index 00000000..c5c2db4b --- /dev/null +++ b/tools/win-input-matrix/Cargo.lock @@ -0,0 +1,155 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "win-input-matrix" +version = "0.26.0" +dependencies = [ + "windows", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] diff --git a/tools/win-input-matrix/Cargo.toml b/tools/win-input-matrix/Cargo.toml new file mode 100644 index 00000000..ad719ba8 --- /dev/null +++ b/tools/win-input-matrix/Cargo.toml @@ -0,0 +1,34 @@ +# Ask every Windows input API, in one shot, whether it can see a given gamepad. +# +# The whole Xbox-pad-on-Windows programme is a matrix of five rows — classic XInput, WGI `Gamepad`, +# WGI `RawGameController`, GameInput, and the HID/DirectInput/Steam family — and until this crate +# existed NOTHING in the tree measured any of it. Every reading in +# `design/xbox-pad-windows-handoff.md` came from ad-hoc off-tree tools, which is why several of them +# could not be reproduced or A/B'd later. This makes the matrix a command. +# +# Deliberately NOT a workspace member (see the root `Cargo.toml` `exclude` list): it is a +# bring-your-own-hardware measurement tool, Windows-only, and has no business on a CI leg. +# +# cargo run --release -- --watch 20 +# +[workspace] + +[package] +name = "win-input-matrix" +description = "Which Windows input APIs can see this gamepad? XInput / WGI Gamepad / WGI RawGameController / XUSB" +version = "0.26.0" +edition = "2024" +rust-version = "1.96.0" +license = "MIT OR Apache-2.0" +publish = false + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_UI_Input_XboxController", + "Win32_Devices_DeviceAndDriverInstallation", + "Win32_System_Com", + "Gaming_Input", + "Foundation", + "Foundation_Collections", +] } diff --git a/tools/win-input-matrix/measurements/2026-08-09-xbox-hid-xinputhid-busfilter.md b/tools/win-input-matrix/measurements/2026-08-09-xbox-hid-xinputhid-busfilter.md new file mode 100644 index 00000000..c892f2db --- /dev/null +++ b/tools/win-input-matrix/measurements/2026-08-09-xbox-hid-xinputhid-busfilter.md @@ -0,0 +1,102 @@ +# `xinputhid` as a BUS FILTER promotes our HID Xbox pad — 2026-08-09, `.173` + +Three arms, same box, same session, ~15 minutes apart, all with `win-input-matrix --watch`. +Box: `.173`, Win11 26200. Pad stood up with +`punktfunk-host.exe dualsense-windows-test --xboxhid` (the shipping `SwDeviceCreate` path, so a real +`045E:0B13` identity — **not** a `devgen` node, which has no PID token). + +Devnodes involved: + +| role | instance | +|---|---| +| parent / transport | `SWD\PUNKTFUNK\PF_XBOX_0` — `Service=MsHidUmdf`, software key `{745a17a0-…}\0072` | +| HID child | `HID\PUNKTFUNK\1&1F9456C7&3&0000` — software key `…\0073` | + +## The result + +| | **baseline** (no virtual pad) | **A — control** (pad up, no change) | **B — filter ONLY** | **C — filter + `DevicePropertyFlags=1`** | +|---|---|---|---|---| +| `IG_` token on the child | — | ❌ `HID\PUNKTFUNK\…` | ❌ `HID\PUNKTFUNK\…` | ✅ **`HID\PUNKTFUNK&IG_00\…`** | +| XUSB interface registered | none | ❌ none | ❌ none | ✅ **`\\?\hid#punktfunk&ig_00#…#{ec87f1e3…}`** | +| classic XInput | all 4 slots `1167` | ❌ all `1167` | ❌ all `1167` | ⚠️ **slot 0 `rc=0`** — admitted, data wrong | +| WGI `Gamepad` | 1 (PS5 only) | ❌ absent | ❌ absent | ⚠️ **present**, `ts=0` MUTE | +| WGI `RawGameController` | 2 | ✅ **LIVE** `[045E:0B13]` | ✅ LIVE | 🛑 **MUTE** (regressed) | +| HID class (Steam/SDL/DirectInput) | — | ✅ | ✅ | ✅ | + +**Arm C is the first time the HID backend has ever reached classic XInput or WGI `Gamepad` at all.** + +## What the A/B proves + +Arm B is arm C minus one registry value. Removing **only** `DevicePropertyFlags` reverts *all three* +structural wins at once — the `IG_` token, the XUSB interface and XInput admission. Restoring it +brings them all back. + +⇒ **`DevicePropertyFlags = 1` (`BusDevice`) is the decisive ingredient, and `UpperFilters` alone does +nothing.** Microsoft's own comment in `xinputhid.inf` says exactly this and we had read past it: +`BusDevice = 0x1` — *"we're a focused bus filter driver **for the IG_ problem**"*. + +This retro-explains the earlier "🛑 MEASURED REGRESSION — never ship it" result, where the filter was +installed and the device came up `CM_PROB_NONE` while "no XUSB interface [was] registered". That was +arm B. The filter was loading and then sitting inert because nothing had put it in bus-filter mode. + +⚠️ Placement matters and is easy to get wrong, because the two values live in **different keys** — +exactly as `xinputhid.inf` writes them: +* `UpperFilters` (REG_MULTI_SZ) → the **hardware/instance** key, `…\Enum\SWD\PUNKTFUNK\PF_XBOX_0` + (an INF `[X.HW]` section); +* `DevicePropertyFlags` (REG_DWORD) → the **software/driver** key, + `…\Control\Class\{745a17a0-…}\0072` (an INF `[X]` DDInstall section). + +Both go on the **PARENT**, not on the HID child. Confirmed against the real Elite, whose BTLE +transport node carries `InfSection=Btle_Bus`, `DevicePropertyFlags=1`, `ConfigFlags=1` while its HID +child carries plain `input.inf`/`HID_Raw_Inst.NT` and no filter at all. + +## What is still broken, and the evidence pointing at why + +Everything **enumerates**; nothing **translates**. + +* XInput slot 0 reads `packet=34 buttons=0x0000 LT=0 RT=0 LX=1024 LY=0 RX=0 RY=-1`. The devtest + sweeps LX across ±32700 — `LX=1024, RY=-1` is not that sweep, it is a misparse. +* WGI `Gamepad` lists our pad with `ts=0` for every sample. +* Our `RawGameController` entry went from LIVE to MUTE: `xinputhid` claims the HID collection + exclusively, so the reports that used to reach WGI Raw now go into a translator that drops them. + (A real connected Elite behaves the same way — it yields nothing to a user-mode HID reader.) +* In arm C a **second** `[045E:0B13]` entry appears with a different shape, `buttons=14 switches=0` + against our descriptor's `buttons=15 switches=1`. That is `xinputhid`'s synthesized view, and its + shape does not match what we declare. + +⇒ **The blocker is now the report descriptor, which is WP-A's subject.** `xinputhid` is translating +a HID report into XUSB and expects the real Xbox layout. Ours differs in exactly the ways +`tools/hid-descriptor-dump` measured against a real Elite: we number our input report (the real pad +does not), we carry two Simulation-page trigger axes (the real pad carries one combined `Z`), and we +put 15 buttons *after* the hat (the real pad puts 16 *before* it). + +**Next experiment:** rebuild `pf-gamepad` with the captured layout and re-run arm C. That is the +one change that would confirm or kill the descriptor theory, and it is gated on the +descriptor-vs-sealed-channel decision in `design/xbox-pad-windows-handoff.md` §3.3 — the real pad +declares no Feature report, and `0x85` is our channel-proof transport. + +## Reproducing + +```powershell +# baseline FIRST, with no virtual pad — a real Xbox pad owns XInput slot 0 and will fake a pass +win-input-matrix --watch 8 + +Start-Process punktfunk-host.exe -ArgumentList 'dualsense-windows-test','--xboxhid','--seconds','90' +# arm C: +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Enum\SWD\PUNKTFUNK\PF_XBOX_0' ` + -Name UpperFilters -PropertyType MultiString -Value @('xinputhid') -Force +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{745a17a0-74d3-11d0-b6fe-00a0c90f57da}\0072' ` + -Name DevicePropertyFlags -PropertyType DWord -Value 1 -Force +# restart the devtest so PnP rebuilds the stack, then measure again +``` + +⚠️ The software-key index (`\0072`) is assigned at install and will differ on another box — read it +from the parent's `Driver` value, do not hardcode it. + +⚠️ Everything above was applied **by hand to a live devnode**. Shipping it means an `AddReg` in +`pf_gamepad.inx` (`[pfGamepad.NT.hw]` for `UpperFilters`, `[pfGamepad.NT]` for +`DevicePropertyFlags`) — and that INF today contains no `AddReg` of any kind. + +All changes on `.173` were reverted: registry values removed, devnodes removed, `oem100.inf` +deleted, both certs delstored, the 6 pre-existing `pf_gamepad` packages and the production service +left untouched. diff --git a/tools/win-input-matrix/src/main.rs b/tools/win-input-matrix/src/main.rs new file mode 100644 index 00000000..3d8c999d --- /dev/null +++ b/tools/win-input-matrix/src/main.rs @@ -0,0 +1,422 @@ +//! Which Windows input APIs can see this gamepad? +//! +//! WHY THIS EXISTS. The Xbox-pad-on-Windows programme +//! (`punktfunk-planning/design/xbox-pad-windows-handoff.md`) is a five-row matrix — classic +//! XInput, WGI `Gamepad`, WGI `RawGameController`, GameInput, and the HID/DirectInput/Steam +//! family — and **nothing in this tree measured any of it**. Every reading in that document came +//! from ad-hoc off-tree tools, which is why several of them could not be reproduced or A/B'd +//! afterwards, and why one of them turned out to be a false positive. This makes the matrix a +//! command you can run twice and diff. +//! +//! ⚠️⚠️ **THE FALSE-POSITIVE TRAP, and why `--watch` exists.** A test box usually has REAL pads on +//! it. A real Xbox pad owns XInput slot 0 and appears in WGI, so "I can see a pad" proves nothing. +//! This already burned one session: `XInputGetState(0)` read `rc=0 LX=-885` with the virtual pad +//! live *and* with it killed — slot 0 was always the real Elite. +//! ⇒ **ALWAYS take a baseline with your pad STOPPED and diff it**, and identify entries by name and +//! vendor/product id, never by slot index alone. +//! `--watch N` is the second half of that discipline: it samples repeatedly and reports whether a +//! device's timestamps ADVANCE. An entry that enumerates but never moves is the exact failure mode +//! this programme is chasing — WGI listing a gamepad that reports nothing is arguably worse than +//! not listing it, because a title that binds the first gamepad latches a dead one. +//! +//! GAP: **GameInput is not covered here.** It has no binding in the `windows` crate and needs +//! hand-written COM vtables; it is measured separately for now. Everything else is. + +#[cfg(not(windows))] +fn main() { + eprintln!("win-input-matrix is Windows-only."); + std::process::exit(2); +} + +#[cfg(windows)] +mod imp { + use std::time::Duration; + + use windows::Foundation::EventHandler; + use windows::Gaming::Input::{Gamepad, IGameController, RawGameController}; + use windows::Win32::Devices::DeviceAndDriverInstallation::{ + DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, HDEVINFO, SP_DEVICE_INTERFACE_DATA, + SP_DEVICE_INTERFACE_DETAIL_DATA_W, SetupDiDestroyDeviceInfoList, + SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW, SetupDiGetDeviceInterfaceDetailW, + }; + use windows::Win32::Foundation::ERROR_NO_MORE_ITEMS; + use windows::Win32::System::Com::CoIncrementMTAUsage; + use windows::Win32::UI::Input::XboxController::{XINPUT_STATE, XInputGetState}; + // `Interface` brings `cast()` into scope, which is how a WinRT `Gamepad` is correlated to the + // `RawGameController` that knows its name. + use windows::core::{GUID, Interface}; + + /// `GUID_DEVINTERFACE_XUSB` — the interface class `xinput1_4` enumerates. This is the one that + /// matters: XInput does not read HID at all, it walks this class. + const GUID_DEVINTERFACE_XUSB: GUID = GUID::from_u128(0xec87f1e3_c13b_4100_b5f7_8b84d54260cb); + /// `GUID_DEVINTERFACE_HID` — what Steam, SDL/hidapi, RawInput, DirectInput and joy.cpl walk. + const GUID_DEVINTERFACE_HID: GUID = GUID::from_u128(0x4d1e55b2_f16f_11cf_88cb_001111000030); + + /// Every PRESENT device interface in `class`. Present-only on purpose: the registry lists + /// long-dead devnodes too, and "is it there right now" is the whole question. + fn interfaces(class: GUID) -> Vec { + let mut out = Vec::new(); + // SAFETY: `class` is a valid GUID; we pass no enumerator and no owner window. The returned + // handle is destroyed unconditionally below. + let set: HDEVINFO = match unsafe { + SetupDiGetClassDevsW( + Some(&class), + None, + None, + DIGCF_PRESENT | DIGCF_DEVICEINTERFACE, + ) + } { + Ok(h) => h, + Err(_) => return out, + }; + + let mut index = 0u32; + loop { + let mut ifdata = SP_DEVICE_INTERFACE_DATA { + cbSize: size_of::() as u32, + ..Default::default() + }; + // SAFETY: `set` is a live device-info set; `ifdata.cbSize` is initialised as the API + // requires. A failure here means "no more items", which ends the loop. + let ok = unsafe { SetupDiEnumDeviceInterfaces(set, None, &class, index, &mut ifdata) } + .is_ok(); + if !ok { + break; + } + index += 1; + + // Two-call dance: ask for the required byte count, then fetch into a buffer of that + // size. The detail struct is variable-length (a trailing WCHAR path), so it cannot be + // stack-allocated by type alone. + let mut needed = 0u32; + // SAFETY: passing a null detail pointer with a null size is the documented way to + // query the required length; it always "fails" with ERROR_INSUFFICIENT_BUFFER. + let _ = unsafe { + SetupDiGetDeviceInterfaceDetailW(set, &ifdata, None, 0, Some(&mut needed), None) + }; + if needed == 0 { + continue; + } + let mut buf = vec![0u8; needed as usize]; + let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W; + // SAFETY: `buf` is `needed` bytes, the size the API just asked for. `cbSize` must be + // the size of the FIXED part of the struct (not the buffer) — 8 on x64. + unsafe { + (*detail).cbSize = 8; + } + // SAFETY: `detail` points into `buf`, which lives until the end of this iteration and + // is exactly the length the API requested. + if unsafe { + SetupDiGetDeviceInterfaceDetailW(set, &ifdata, Some(detail), needed, None, None) + } + .is_err() + { + continue; + } + // SAFETY: on success the API wrote a NUL-terminated wide string into `DevicePath`. + let path = unsafe { + let p = (*detail).DevicePath.as_ptr(); + let mut len = 0usize; + while *p.add(len) != 0 { + len += 1; + } + String::from_utf16_lossy(std::slice::from_raw_parts(p, len)) + }; + out.push(path); + } + + // SAFETY: `set` came from SetupDiGetClassDevsW and is not used again. + let _ = unsafe { SetupDiDestroyDeviceInfoList(set) }; + let _ = ERROR_NO_MORE_ITEMS; + out + } + + /// 🛑 **DO NOT DELETE THIS — without it the whole WGI half of the matrix reads zero.** + /// + /// `Gamepad::Gamepads()` and `RawGameController::RawGameControllers()` are not queries; they + /// return a cache that WGI's device-watcher fills in. In a GUI app something else has already + /// started that watcher, so the cache looks like a query and everyone writes code as if it + /// were one. In a bare console process nothing has, and both collections come back **EMPTY + /// even with real controllers attached** — measured here on 2026-08-09: a DualSense sitting in + /// the HID interface class, `RawGameControllers` count=0. + /// + /// Subscribing to the Added events is what starts the watcher. The handlers deliberately do + /// nothing; registering them is the entire point. The sleep gives the watcher a beat to + /// enumerate before the first read. + /// + /// ⚠️ This is a live trap for the readings in `design/xbox-pad-windows-handoff.md`: an + /// off-tree probe without this would report "WGI cannot see the pad" when WGI could not see + /// ANYTHING, which is a very different conclusion. + fn wake_wgi() { + let gp_tok = Gamepad::GamepadAdded(&EventHandler::::new(|_, _| Ok(()))); + let raw_tok = RawGameController::RawGameControllerAdded( + &EventHandler::::new(|_, _| Ok(())), + ); + if gp_tok.is_err() || raw_tok.is_err() { + eprintln!("warning: could not subscribe to WGI Added events; counts may read zero"); + } + std::thread::sleep(Duration::from_millis(1500)); + } + + fn xinput() { + println!("== classic XInput (xinput1_4 walks GUID_DEVINTERFACE_XUSB) =="); + for slot in 0..4u32 { + let mut st = XINPUT_STATE::default(); + // SAFETY: `st` is a valid, fully-initialised XINPUT_STATE for the call to fill in. + let rc = unsafe { XInputGetState(slot, &mut st) }; + if rc == 0 { + let g = st.Gamepad; + println!( + " slot {slot}: rc=0 packet={} buttons=0x{:04X} LT={} RT={} LX={} LY={} RX={} RY={}", + st.dwPacketNumber, + g.wButtons.0, + g.bLeftTrigger, + g.bRightTrigger, + g.sThumbLX, + g.sThumbLY, + g.sThumbRX, + g.sThumbRY + ); + } else { + println!( + " slot {slot}: rc={rc}{}", + if rc == 1167 { + " (ERROR_DEVICE_NOT_CONNECTED)" + } else { + "" + } + ); + } + } + } + + /// One WGI sample, for the mute detector. + struct Sample { + label: String, + ts: u64, + axes: Vec, + } + + fn wgi_gamepads() -> Vec { + let mut out = Vec::new(); + let Ok(list) = Gamepad::Gamepads() else { + return out; + }; + let n = list.Size().unwrap_or(0); + for i in 0..n { + let Ok(gp) = list.GetAt(i) else { continue }; + // Correlate to a RawGameController purely to get a human-readable name — a bare + // `Gamepad` has none, and identifying entries by index is how false positives happen. + let label = gp + .cast::() + .ok() + .and_then(|c| RawGameController::FromGameController(&c).ok()) + .and_then(|r| r.DisplayName().ok()) + .map(|h| h.to_string()) + .unwrap_or_else(|| format!("")); + let (ts, axes) = match gp.GetCurrentReading() { + Ok(r) => ( + r.Timestamp, + vec![ + r.LeftThumbstickX, + r.LeftThumbstickY, + r.RightThumbstickX, + r.RightThumbstickY, + r.LeftTrigger, + r.RightTrigger, + ], + ), + Err(_) => (0, Vec::new()), + }; + out.push(Sample { label, ts, axes }); + } + out + } + + fn wgi_raw() -> Vec { + let mut out = Vec::new(); + let Ok(list) = RawGameController::RawGameControllers() else { + return out; + }; + let n = list.Size().unwrap_or(0); + for i in 0..n { + let Ok(rc) = list.GetAt(i) else { continue }; + let name = rc + .DisplayName() + .map(|h| h.to_string()) + .unwrap_or_else(|_| "".into()); + let vid = rc.HardwareVendorId().unwrap_or(0); + let pid = rc.HardwareProductId().unwrap_or(0); + let nb = rc.ButtonCount().unwrap_or(0).max(0) as usize; + let ns = rc.SwitchCount().unwrap_or(0).max(0) as usize; + let na = rc.AxisCount().unwrap_or(0).max(0) as usize; + let mut buttons = vec![false; nb]; + let mut switches = vec![Default::default(); ns]; + let mut axes = vec![0f64; na]; + let ts = rc + .GetCurrentReading(&mut buttons, &mut switches, &mut axes) + .unwrap_or(0); + out.push(Sample { + label: format!("{name} [{vid:04X}:{pid:04X}] buttons={nb} switches={ns} axes={na}"), + ts, + axes, + }); + } + out + } + + fn print_samples(title: &str, s: &[Sample]) { + println!("== {title} == count={}", s.len()); + for (i, e) in s.iter().enumerate() { + let axes = e + .axes + .iter() + .map(|v| format!("{v:.4}")) + .collect::>() + .join(","); + println!(" [{i}] ts={} {} axes=[{axes}]", e.ts, e.label); + } + if s.is_empty() { + println!(" (none)"); + } + } + + /// Flag every device whose current reading differs from the baseline one. Once a device has + /// moved it stays flagged — a pad that twitches once in twenty samples is still LIVE. + fn mark_moved(base: &[Sample], now: &[Sample], moved: &mut [bool]) { + for (i, e) in now.iter().enumerate() { + if let Some(b) = base.get(i) + && (b.ts != e.ts || b.axes != e.axes) + && let Some(m) = moved.get_mut(i) + { + *m = true; + } + } + } + + /// Sample repeatedly and report, per device, whether anything ever MOVED. This is the + /// enumerated-but-mute detector: `ts` frozen across every sample means the API lists a pad + /// that is not reporting. + fn watch(rounds: usize) { + println!("\n== WATCH ({rounds} rounds, 200 ms apart) — does anything actually MOVE? =="); + let mut first_gp: Option> = None; + let mut first_raw: Option> = None; + let mut moved_gp: Vec = Vec::new(); + let mut moved_raw: Vec = Vec::new(); + + for _ in 0..rounds { + let gp = wgi_gamepads(); + let raw = wgi_raw(); + match &first_gp { + None => { + moved_gp = vec![false; gp.len()]; + first_gp = Some(gp); + } + Some(base) => mark_moved(base, &gp, &mut moved_gp), + } + match &first_raw { + None => { + moved_raw = vec![false; raw.len()]; + first_raw = Some(raw); + } + Some(base) => mark_moved(base, &raw, &mut moved_raw), + } + std::thread::sleep(Duration::from_millis(200)); + } + + for (label, base, moved) in [ + ("WGI Gamepad", first_gp, moved_gp), + ("WGI RawGameController", first_raw, moved_raw), + ] { + println!(" {label}:"); + let Some(base) = base else { continue }; + if base.is_empty() { + println!(" (none)"); + } + for (i, e) in base.iter().enumerate() { + println!( + " [{i}] {} — {}", + e.label, + if *moved.get(i).unwrap_or(&false) { + "LIVE (readings changed)" + } else { + "MUTE (ts and axes frozen for every sample)" + } + ); + } + } + } + + pub fn run() { + let args: Vec = std::env::args().skip(1).collect(); + let mut rounds = 0usize; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--watch" => { + rounds = args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(20); + i += 1; + } + "--help" | "-h" => { + println!( + "win-input-matrix [--watch N]\n\n \ + --watch N sample WGI N times and report LIVE vs MUTE per device\n\n\ + ALWAYS take a baseline with your virtual pad STOPPED and diff it: a real\n\ + pad on the box owns XInput slot 0 and shows up in WGI." + ); + return; + } + other => eprintln!("(ignoring unknown argument {other})"), + } + i += 1; + } + + // WinRT needs an initialised apartment. CoIncrementMTAUsage keeps an MTA alive for the + // life of the process without committing this thread to a specific apartment. + // SAFETY: no arguments to get wrong. The cookie is a plain handle value and is dropped on + // purpose — decrementing would tear the MTA down again, and we want it up for the whole + // process. + match unsafe { CoIncrementMTAUsage() } { + Ok(_cookie) => {} + Err(e) => eprintln!("warning: could not start an MTA, WGI calls may fail: {e}"), + } + wake_wgi(); + + xinput(); + println!(); + print_samples("WGI Gamepad", &wgi_gamepads()); + println!(); + print_samples("WGI RawGameController", &wgi_raw()); + + println!("\n== XUSB device interfaces (GUID_DEVINTERFACE_XUSB, present only) =="); + let xusb = interfaces(GUID_DEVINTERFACE_XUSB); + if xusb.is_empty() { + println!(" (none)"); + } + for p in &xusb { + println!(" {p}"); + } + + println!("\n== HID device interfaces (what Steam/SDL/DirectInput/joy.cpl walk) =="); + let hid = interfaces(GUID_DEVINTERFACE_HID); + println!(" {} present; those matching a gamepad vendor:", hid.len()); + for p in &hid { + let lower = p.to_ascii_lowercase(); + if lower.contains("vid_045e") + || lower.contains("vid_054c") + || lower.contains("punktfunk") + { + println!(" {p}"); + } + } + + if rounds > 0 { + watch(rounds); + } + } +} + +#[cfg(windows)] +fn main() { + imp::run(); +} -- 2.54.0 From f9fe496dbc42bec78eedea509ee3e32c51d8c771 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 20:07:34 +0200 Subject: [PATCH 09/16] feat(drivers/pf-gamepad): declare the rumble output report, and the Xbox pad gets rumble at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `XBOX_RDESC` declared no OUTPUT item — zero `0x91` bytes. hidclass routes an output report only if the descriptor declares one, so `on_output_report` never fired, `publish_output` never wrote the out-ring, and `parse_xbox_output` in `inject/windows/xbox_windows.rs` was unreachable code. The entire host-side rumble plane was already built, wired and tested, and was simply never fed. The HID Xbox pad therefore had NO rumble whatsoever, not merely no trigger rumble. This appends the PID-page `Set Effect Report` collection, report id `0x03`, 8 payload bytes, sized to exactly the layout `parse_xbox_output` and `design/trigger-rumble-plane.md` §2.1 already specify. It is declared AFTER the final Input item and re-states every global it uses, so the 16-byte input layout `xbox_proto`'s tests pin is untouched. ⚠️ PROVENANCE: hand-written, and it could not be otherwise. The Elite capture taken for WP-A reports `OUTPUT items: 0` — Windows exposes no literal descriptor bytes and the reconstruction carries no output collection for that pad — so there was nothing to copy. The comment says so and asks for a Linux hidraw capture to replace it. Also adds a compile-time assert pairing every descriptor with its HID-descriptor `wReportLength`. Those are two copies of one length, edited in different places, and a mismatch fails SILENTLY: hidclass asks for `wReportLength` bytes, parses whatever it got, and the pad either enumerates truncated or not at all with nothing naming the cause. It now cannot build out of step. This caught nothing today because I updated both by hand, but it is exactly the trap this descriptor has already sprung twice in other forms. MEASURED ON .173 (Win11 26200), with the pad promoted via the WP-B0 xinputhid bus-filter config: * `XInputSetState(0xFFFF, 0x8000)` produced, on the host side, `rumble from game: pad=0 low=65535 high=32767` `rumble from game: pad=0 low=0 high=0` i.e. XInputSetState -> xinputhid -> HID output report 0x03 -> on_output_report -> out-ring -> parse_xbox_output -> PadFeedback. First rumble this backend has ever delivered. * The round-trip values confirm the descriptor's `Logical Maximum (100)` percent domain is right: 0x8000 -> 50% -> 32767. A 0..255 domain would have produced different numbers. * This also answers `trigger-rumble-plane.md`'s WP0 gate — YES, Windows writes output reports to a synthesized 045E:0B13 — which was blocking the whole trigger plane. * classic XInput reads the pad fully: packets advancing, `buttons=0x1000` (the devtest's A), and `LX [-32768..31744]`, the complete sweep. LY/RX/RY frozen is correct; the devtest drives only LS-X and A. VERIFIED * `cargo test -p pf-inject --lib xbox` 11/11 — the input layout is byte-identical, as intended. * `hid-descriptor-dump --rust-source ... --symbol XBOX_RDESC` decodes it clean: input report 0x01 unchanged at 16 bytes and the same offsets, new output report 0x03 at 9 bytes on the wire, feature 0x85 unchanged, `structure: OK`. * Driver builds and signs on .173 with the WDK; the new const asserts compile, so all five descriptor/wReportLength pairs agree. * fmt clean on both tools; .173 fully reverted afterwards. NOT VERIFIED * The enable-mask bit assignments for the two TRIGGER actuators. `XINPUT_VIBRATION` has only two members, so XInput can never drive them and this run could not exercise them. Still open, as trigger-rumble-plane.md WP0 says. * That this equals the real pad's output collection, byte for byte. Needs Linux hidraw. * Nothing about the INF is changed: `pf_gamepad.inx` still has no AddReg, so none of the promotion config ships. The rumble descriptor is inert until something drives it. --- .../windows/drivers/pf-gamepad/src/lib.rs | 80 ++++++++- tools/hid-descriptor-dump/src/main.rs | 32 ++-- tools/win-input-matrix/src/main.rs | 153 +++++++++++++++++- 3 files changed, 247 insertions(+), 18 deletions(-) diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index 2e7a483c..da93f94b 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -323,7 +323,7 @@ static DECK_RDESC: [u8; 38] = [ // report forever. `0x3F` payload bytes so `FeatureReportByteLength` lands on 64, the buffer size // `channel_proof::query` asks with; the proof itself needs 17. #[rustfmt::skip] -static XBOX_RDESC: [u8; 150] = [ +static XBOX_RDESC: [u8; 223] = [ 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x05, // Usage (Game Pad) 0xA1, 0x01, // Collection (Application) @@ -386,6 +386,67 @@ static XBOX_RDESC: [u8; 150] = [ 0x75, 0x01, // Report Size (1) 0x95, 0x01, // Report Count (1) 0x81, 0x03, // Input (Cnst,Var,Abs) — pad to a byte boundary + // ---- Rumble OUTPUT report `0x03` (Physical Interface Device page) ---- + // + // Without this the pad can receive NOTHING. hidclass routes an output report only if the + // descriptor declares one, so with no `0x91` item `on_output_report` never fires, + // `publish_output` never writes the ring, and `parse_xbox_output` + // (`inject/windows/xbox_windows.rs`) is unreachable code — the whole host-side rumble plane is + // already built and was simply never fed. That is why the HID Xbox pad had no rumble at all, + // not merely no trigger rumble. + // + // ⚠️ PROVENANCE — HAND-WRITTEN, and it could not be otherwise. Every other output collection in + // this file is a capture, and §3 of `design/xbox-pad-windows-handoff.md` insists on captures. + // But the Elite capture taken for that work reports `OUTPUT items: 0` (Windows exposes no + // literal report-descriptor bytes; hidapi reconstructs from `HidD_GetPreparsedData`, and that + // reconstruction carries no output collection for this pad). So there was nothing to copy. + // This block is the documented Xbox One S / Elite Bluetooth rumble report — PID-page + // `Set Effect Report`, id `0x03`, 8 payload bytes — chosen because it is exactly the layout + // `parse_xbox_output` and `design/trigger-rumble-plane.md` §2.1 already specify: + // [0x03][enable][left_trigger][right_trigger][left][right][duration][delay][loop] + // with magnitudes 0..100 (hence `Logical Maximum (100)`, not 255). + // **Replace it with a Linux hidraw capture when one can be taken** — that is the only route to + // byte-exact truth here, and the enable-bit assignments for the two TRIGGER actuators remain + // unverified (see trigger-rumble-plane.md WP0). + // + // Declared AFTER the final Input item and re-stating every global it uses, so it cannot + // retroactively alter the 16-byte input layout `xbox_proto`'s tests pin. + 0x05, 0x0F, // Usage Page (Physical Interface Device) + 0x09, 0x21, // Usage (Set Effect Report) + 0x85, 0x03, // Report ID (3) + 0xA1, 0x02, // Collection (Logical) + 0x09, 0x97, // Usage (DC Enable Actuators) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x91, 0x02, // Output (Data,Var,Abs) — the enable mask, low nibble + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x00, // Logical Maximum (0) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x91, 0x03, // Output (Cnst,Var,Abs) — pad the enable byte + 0x09, 0x70, // Usage (Magnitude) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x64, // Logical Maximum (100) — percent, NOT 255 + 0x75, 0x08, // Report Size (8) + 0x95, 0x04, // Report Count (4) — LT, RT, left handle, right handle + 0x91, 0x02, // Output (Data,Var,Abs) + 0x09, 0x50, // Usage (Duration) + 0x66, 0x01, 0x10, // Unit (SI Linear: seconds) + 0x55, 0x0E, // Unit Exponent (-2) — centiseconds + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x91, 0x02, // Output (Data,Var,Abs) + 0x09, 0xA7, // Usage (Start Delay) — same unit and range as Duration + 0x91, 0x02, // Output (Data,Var,Abs) + 0x65, 0x00, // Unit (None) + 0x55, 0x00, // Unit Exponent (0) + 0x09, 0x7C, // Usage (Loop Count) + 0x91, 0x02, // Output (Data,Var,Abs) + 0xC0, // End Collection // The channel-proof feature report — see the ⚠️ above. Declared last so it cannot disturb the // INPUT layout `xbox_proto` packs against: every global item here (Report Size/Count, Logical // Min/Max) is re-stated after the final Input item, so nothing above is retroactively changed. @@ -413,7 +474,22 @@ static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01 static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01]; static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01]; static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes -static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x96, 0x00]; // 150 bytes +static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xDF, 0x00]; // 223 bytes + +// Each `wReportLength` above is a SECOND copy of a length that already exists as its descriptor's +// array size, and the two are edited in different places. Getting them out of step does not fail +// loudly — hidclass asks for `wReportLength` bytes and then parses whatever it got, so the pad +// either enumerates with a truncated descriptor or fails to enumerate at all, with nothing naming +// the cause. Assert the pairing at compile time instead; adding an item to a descriptor now cannot +// build until its length is updated too. +const fn declared_len(hid_desc: &[u8; 9]) -> usize { + (hid_desc[7] as usize) | ((hid_desc[8] as usize) << 8) +} +const _: () = assert!(declared_len(&HID_DESC) == DUALSENSE_RDESC.len()); +const _: () = assert!(declared_len(&DS4_HID_DESC) == DS4_RDESC.len()); +const _: () = assert!(declared_len(&EDGE_HID_DESC) == DS_EDGE_RDESC.len()); +const _: () = assert!(declared_len(&DECK_HID_DESC) == DECK_RDESC.len()); +const _: () = assert!(declared_len(&XBOX_HID_DESC) == XBOX_RDESC.len()); // HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11]. // `devtype` selects the identity: PS family (same Sony VID/version) or the N4-spike Deck. diff --git a/tools/hid-descriptor-dump/src/main.rs b/tools/hid-descriptor-dump/src/main.rs index cf14a2d0..2b293b98 100644 --- a/tools/hid-descriptor-dump/src/main.rs +++ b/tools/hid-descriptor-dump/src/main.rs @@ -54,22 +54,28 @@ fn extract_rust_array(src: &str, symbol: &str) -> Result, String> { let at = src .find(&format!("static {symbol}:")) .ok_or_else(|| format!("no `static {symbol}:` in that file"))?; - let open = src[at..] - .find('[') - .and_then(|i| src[at + i + 1..].find('[').map(|j| at + i + 1 + j + 1)) - .ok_or("could not find the array literal")?; - let close = src[open..] - .find(']') - .ok_or("array literal is never closed")? - + open; - // Strip trailing `// ...` comments LINE BY LINE before splitting on commas — the annotations in - // these arrays contain commas themselves (`// Input (Data,Var,Abs)`), so comma-splitting first - // scatters comment text into the byte stream. - let body: String = src[open..close] + + // 🛑 STRIP COMMENTS FIRST, then find the brackets — not the other way round. These arrays are + // heavily annotated and the annotations contain both `,` and `]` (a comment documenting a wire + // layout as `[0x03][enable][left]…` is real, and it appears inside `XBOX_RDESC`). Locating the + // closing bracket on the raw text stops at the first `]` in a COMMENT and silently truncates + // the array — which reads as a corrupt descriptor rather than a parse bug. + let tail: String = src[at..] .lines() .map(|l| l.split("//").next().unwrap_or("")) .collect::>() - .join(" "); + .join("\n"); + + // First `[` is the type's `[u8; N]`; the next one opens the literal. + let open = tail + .find('[') + .and_then(|i| tail[i + 1..].find('[').map(|j| i + 1 + j + 1)) + .ok_or("could not find the array literal")?; + let close = tail[open..] + .find(']') + .ok_or("array literal is never closed")? + + open; + let body = &tail[open..close]; let mut out = Vec::new(); for tok in body.split(',') { let tok = tok.trim(); diff --git a/tools/win-input-matrix/src/main.rs b/tools/win-input-matrix/src/main.rs index 3d8c999d..78ba28a7 100644 --- a/tools/win-input-matrix/src/main.rs +++ b/tools/win-input-matrix/src/main.rs @@ -41,7 +41,9 @@ mod imp { }; use windows::Win32::Foundation::ERROR_NO_MORE_ITEMS; use windows::Win32::System::Com::CoIncrementMTAUsage; - use windows::Win32::UI::Input::XboxController::{XINPUT_STATE, XInputGetState}; + use windows::Win32::UI::Input::XboxController::{ + XINPUT_STATE, XINPUT_VIBRATION, XInputGetState, XInputSetState, + }; // `Interface` brings `cast()` into scope, which is how a WinRT `Gamepad` is correlated to the // `RawGameController` that knows its name. use windows::core::{GUID, Interface}; @@ -158,6 +160,45 @@ mod imp { std::thread::sleep(Duration::from_millis(1500)); } + /// Drive rumble into an XInput slot and hold it, so the other end of the pipe can be watched. + /// + /// This is the WP0 probe from `design/trigger-rumble-plane.md`: does anything Windows-side ever + /// write an output report back to a synthesized `045E:0B13`? For the HID backend the chain + /// under test is `XInputSetState` → `xinputhid` → a HID output report on our collection → + /// `on_output_report` → the shm out-ring → `parse_xbox_output`, and the observable is the + /// devtest printing `rumble from game`. Run this with the devtest live and watch its stdout. + /// + /// ⚠️ `XINPUT_VIBRATION` has exactly TWO members, so this can only ever drive the two handle + /// motors — it can never source TRIGGER rumble. That is a property of the API, not of our + /// plumbing, and it is why the trigger plane needs its own transport. + fn rumble(slot: u32, seconds: u64) { + println!("== RUMBLE PROBE: XInputSetState(slot {slot}) for {seconds}s =="); + let v = XINPUT_VIBRATION { + wLeftMotorSpeed: 0xFFFF, + wRightMotorSpeed: 0x8000, + }; + // SAFETY: `v` is a valid, fully-initialised XINPUT_VIBRATION. + let rc = unsafe { XInputSetState(slot, &v) }; + println!( + " set low=0xFFFF high=0x8000 -> rc={rc}{}", + if rc == 0 { + " (accepted)" + } else { + " (REJECTED)" + } + ); + if rc != 0 { + println!(" (slot not connected — nothing downstream can be concluded)"); + return; + } + std::thread::sleep(Duration::from_secs(seconds)); + let off = XINPUT_VIBRATION::default(); + // SAFETY: as above. + let rc2 = unsafe { XInputSetState(slot, &off) }; + println!(" clear low=0 high=0 -> rc={rc2}"); + println!(" ⇒ now check the devtest stdout for `rumble from game`."); + } + fn xinput() { println!("== classic XInput (xinput1_4 walks GUID_DEVINTERFACE_XUSB) =="); for slot in 0..4u32 { @@ -281,6 +322,100 @@ mod imp { } } + /// Sample XInput over the whole watch window and report the RANGE each axis covered. + /// + /// A single `XInputGetState` call cannot tell "translated correctly" from "stuck at zero" — + /// a sweeping stick reads 0 every time it crosses centre. `dwPacketNumber` advancing proves + /// the state is changing at all; the min/max spread proves the AXES specifically are, which is + /// the half that can fail on its own while buttons work. + struct XiTrack { + first_packet: u32, + last_packet: u32, + lx: (i16, i16), + ly: (i16, i16), + rx: (i16, i16), + ry: (i16, i16), + buttons: u16, + lt: (u8, u8), + rt: (u8, u8), + } + + fn xinput_watch(rounds: usize) { + println!("\n== XINPUT WATCH ({rounds} samples) — do PACKETS advance and AXES move? =="); + for slot in 0..4u32 { + let mut t: Option = None; + for _ in 0..rounds { + let mut st = XINPUT_STATE::default(); + // SAFETY: `st` is a valid, fully-initialised XINPUT_STATE. + if unsafe { XInputGetState(slot, &mut st) } != 0 { + break; + } + let g = st.Gamepad; + match &mut t { + None => { + t = Some(XiTrack { + first_packet: st.dwPacketNumber, + last_packet: st.dwPacketNumber, + lx: (g.sThumbLX, g.sThumbLX), + ly: (g.sThumbLY, g.sThumbLY), + rx: (g.sThumbRX, g.sThumbRX), + ry: (g.sThumbRY, g.sThumbRY), + buttons: g.wButtons.0, + lt: (g.bLeftTrigger, g.bLeftTrigger), + rt: (g.bRightTrigger, g.bRightTrigger), + }); + } + Some(t) => { + t.last_packet = st.dwPacketNumber; + t.lx = (t.lx.0.min(g.sThumbLX), t.lx.1.max(g.sThumbLX)); + t.ly = (t.ly.0.min(g.sThumbLY), t.ly.1.max(g.sThumbLY)); + t.rx = (t.rx.0.min(g.sThumbRX), t.rx.1.max(g.sThumbRX)); + t.ry = (t.ry.0.min(g.sThumbRY), t.ry.1.max(g.sThumbRY)); + t.buttons |= g.wButtons.0; + t.lt = (t.lt.0.min(g.bLeftTrigger), t.lt.1.max(g.bLeftTrigger)); + t.rt = (t.rt.0.min(g.bRightTrigger), t.rt.1.max(g.bRightTrigger)); + } + } + std::thread::sleep(Duration::from_millis(120)); + } + match t { + None => println!(" slot {slot}: not connected"), + Some(t) => { + let moved = t.last_packet != t.first_packet; + let axes_moved = t.lx.0 != t.lx.1 + || t.ly.0 != t.ly.1 + || t.rx.0 != t.rx.1 + || t.ry.0 != t.ry.1 + || t.lt.0 != t.lt.1 + || t.rt.0 != t.rt.1; + println!( + " slot {slot}: packets {}..{} ({}), buttons seen 0x{:04X}", + t.first_packet, + t.last_packet, + if moved { "ADVANCING" } else { "FROZEN" }, + t.buttons + ); + println!( + " LX [{}..{}] LY [{}..{}] RX [{}..{}] RY [{}..{}] LT [{}..{}] RT [{}..{}] -> axes {}", + t.lx.0, + t.lx.1, + t.ly.0, + t.ly.1, + t.rx.0, + t.rx.1, + t.ry.0, + t.ry.1, + t.lt.0, + t.lt.1, + t.rt.0, + t.rt.1, + if axes_moved { "MOVING" } else { "STUCK" } + ); + } + } + } + } + /// Flag every device whose current reading differs from the baseline one. Once a device has /// moved it stays flagged — a pad that twitches once in twenty samples is still LIVE. fn mark_moved(base: &[Sample], now: &[Sample], moved: &mut [bool]) { @@ -350,6 +485,7 @@ mod imp { pub fn run() { let args: Vec = std::env::args().skip(1).collect(); let mut rounds = 0usize; + let mut rumble_slot: Option = None; let mut i = 0; while i < args.len() { match args[i].as_str() { @@ -357,10 +493,16 @@ mod imp { rounds = args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(20); i += 1; } + "--rumble" => { + rumble_slot = Some(args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(0)); + i += 1; + } "--help" | "-h" => { println!( - "win-input-matrix [--watch N]\n\n \ - --watch N sample WGI N times and report LIVE vs MUTE per device\n\n\ + "win-input-matrix [--watch N] [--rumble SLOT]\n\n \ + --watch N sample WGI N times and report LIVE vs MUTE per device\n \ + --rumble SLOT drive XInputSetState into that slot for 3 s (WP0 probe:\n \ + does anything write an output report back to our pad?)\n\n\ ALWAYS take a baseline with your virtual pad STOPPED and diff it: a real\n\ pad on the box owns XInput slot 0 and shows up in WGI." ); @@ -412,6 +554,11 @@ mod imp { if rounds > 0 { watch(rounds); + xinput_watch(rounds); + } + if let Some(slot) = rumble_slot { + println!(); + rumble(slot, 3); } } } -- 2.54.0 From 77f0a25d18d1a462bb2003f0f049487e4555686b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 20:18:49 +0200 Subject: [PATCH 10/16] feat(drivers/pf-gamepad): ship the xinputhid bus filter, so Windows finally promotes our Xbox pad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field report that started this work was an Xbox controller that no game could see on a Windows host for two weeks. Root cause was that our Xbox pad reaches no Windows input API a modern title uses. This is the fix, and it is two registry values. Windows promotes Xbox pads with `xinputhid`, whose INF is an explicit hardware-id ALLOW-LIST — its own comment says "we can not use a Compatability ID for the loading of this driver, and so rely on individual hardware IDs". A software-enumerated devnode can never match those ids, so we write what the matching install sections would have written. `045E:0B13`, the PID this identity already claimed, is on that allow-list twice, so the identity choice turned out to be exactly right. 🛑 THE PAIRING IS THE WHOLE FINDING, AND THE TWO VALUES GO IN DIFFERENT KEYS. `UpperFilters` is a `.HW` AddReg (hardware key); `DevicePropertyFlags` is a DDInstall AddReg (software key). A live A/B on .173: removing `DevicePropertyFlags` alone reverts EVERYTHING — no `IG_00`, no XUSB interface, no XInput, no WGI entry — while `UpperFilters` alone is completely inert. `1` = `BusDevice`, which Microsoft glosses as "a focused bus filter driver for the IG_ problem". It is not a description of the device, it is the switch. An earlier session installed the filter WITHOUT it, measured a device that produced nothing, and recorded "never ship it". The filter was never broken; it had never been switched on. That conclusion is now retracted. ⚠️ The Xbox line gets its OWN DDInstall section, `pfGamepadXbox`. All five identities previously shared `pfGamepad`, so an AddReg there would have handed a DualSense, DualShock 4, Edge and Steam Deck to Microsoft's Xbox translator. The regression check below exists for exactly that. MEASURED ON .173 (Win11 26200), INF-SHIPPED — no hand-written registry values: * `UpperFilters=xinputhid` lands on the hardware key and `DevicePropertyFlags=1` on the software key, applied by the INF at install. * The HID child gains the `IG_00` token: `HID\PUNKTFUNK&IG_00\...`. * An XUSB interface appears: `\\?\hid#punktfunk&ig_00#...#{ec87f1e3-...}`. * classic XInput reads it live — packets ADVANCING, `buttons=0x1000` (the devtest's A), and the stick sweeping. XInput had NEVER seen this backend before. * `XInputSetState` rumble round-trips: `rumble from game: pad=0 low=65535 high=32767`. * REGRESSION CHECK PASSED: with the DualSense identity up, its devnode has an EMPTY `UpperFilters` and no `DevicePropertyFlags`. The PlayStation pads are untouched. WGI `Gamepad` lists the pad but reads `ts=0`. That is NOT ours: a real Xbox Elite Series 2, promoted by Microsoft's own driver on the same box, reads `ts=0` in WGI at the very moment classic XInput is reading live data from it (`buttons=0x1000 LY=-32768`). Our pad is behaviourally indistinguishable from real hardware here; the row is a property of the non-interactive session. NOT VERIFIED * On-glass in a console session. Everything above ran over ssh, which is what makes the WGI row unreadable; the real-Elite control is what settles it, not a clean WGI reading. * GameInput — no binding in the `windows` crate, still unmeasured for this backend. * `PUNKTFUNK_XBOX_BACKEND` still defaults to XUSB. This changes what the HID backend CAN do; it does not change which backend is chosen. That is WP-E and it is a separate decision. * Trigger-actuator enable bits, still conjecture — `XINPUT_VIBRATION` has two members and cannot exercise them. --- .../windows/drivers/pf-gamepad/pf_gamepad.inx | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx index b0dd2735..a7cd26be 100644 --- a/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx +++ b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx @@ -47,7 +47,11 @@ pf_gamepad.dll=1 %DeviceDescDS4%=pfGamepad, pf_dualshock4 %DeviceDescEdge%=pfGamepad, pf_dualsenseedge %DeviceDescDeck%=pfGamepad, pf_steamdeck -%DeviceDescXbox%=pfGamepad, root\pf_xboxwireless, pf_xboxwireless +; ⚠️ The Xbox line installs its OWN section, `pfGamepadXbox`, and must keep doing so. Every other +; identity shares `pfGamepad`; the Xbox one additionally attaches the `xinputhid` bus filter, and +; putting that on a DualSense / DualShock 4 / Edge / Steam Deck would hand a PlayStation pad to +; Microsoft's Xbox translator. The two sections are otherwise identical — keep them in step. +%DeviceDescXbox%=pfGamepadXbox, root\pf_xboxwireless, pf_xboxwireless [pfGamepad.NT] CopyFiles=UMDriverCopy @@ -83,6 +87,77 @@ UmdfFsContextUsePolicy=CanUseFsContext2 ; across multiple simultaneous controllers (multi-pad). UmdfHostProcessSharing=ProcessSharingDisabled +; --------------------------------------------------------------------------------------------- +; The Xbox identity: `pfGamepad` plus the two registry values that make Windows PROMOTE the pad. +; +; Measured on .173, 2026-08-09. Without these, our HID Xbox pad is invisible to classic XInput and +; to WGI `Gamepad`, and gets no rumble — the exact field symptom that started this work. With them +; the HID child gains the `IG_00` token, an XUSB interface appears, XInput reads it (full stick +; range and buttons) and `XInputSetState` rumble arrives back as HID output report 0x03. +; +; ⭐ Both values come straight out of Microsoft's own `xinputhid.inf`, which promotes Xbox pads by +; an explicit hardware-id ALLOW-LIST (its own comment: "we can not use a Compatability ID … and so +; rely on individual hardware IDs"). A software-enumerated devnode can never match those ids, so we +; write what the matching install sections would have written. `045E:0B13`, the PID this identity +; claims, is on that allow-list — twice. +; +; 🛑 THE PAIRING IS LOAD-BEARING AND THE TWO VALUES GO IN DIFFERENT KEYS. An A/B on the live box: +; removing `DevicePropertyFlags` alone reverts ALL of it — no `IG_00`, no XUSB interface, no XInput, +; no WGI entry — while `UpperFilters` alone is completely inert. `DevicePropertyFlags = 1` is +; `BusDevice` in `xinputhid.h`, which Microsoft's comment glosses as "a focused bus filter driver +; for the IG_ problem". It is not a description of the device; it is the switch that tells the +; filter what job to do. An earlier session installed the filter WITHOUT it, measured a device that +; produced nothing, and concluded the filter was broken and must never ship. It was not broken; it +; had never been switched on. +; +; ⚠️ Both go on THIS node — the parent/transport devnode — not on the HID child. That is where a +; real Xbox pad carries them: the Elite's Bluetooth transport node has `DevicePropertyFlags=1` and +; the filter, while its HID child has plain `input.inf` and neither. +[pfGamepadXbox.NT] +CopyFiles=UMDriverCopy +Include=MsHidUmdf.inf +Needs=MsHidUmdf.NT +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT +; HKR in a DDInstall section is the SOFTWARE (driver) key — Control\Class\{...}\. +AddReg=pfGamepadXbox_SW_AddReg + +[pfGamepadXbox.NT.hw] +Include=MsHidUmdf.inf +Needs=MsHidUmdf.NT.hw +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT.hw +; HKR in a .HW section is the HARDWARE (device) key — Enum\. +AddReg=pfGamepadXbox_HW_AddReg + +[pfGamepadXbox.NT.Services] +Include=MsHidUmdf.inf +Needs=MsHidUmdf.NT.Services +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT.Services + +[pfGamepadXbox.NT.Filters] +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT.Filters + +[pfGamepadXbox.NT.Wdf] +UmdfService="pf_gamepad", pf_gamepad_Install +UmdfServiceOrder=pf_gamepad +UmdfKernelModeClientPolicy=AllowKernelModeClients +UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects +UmdfMethodNeitherAction=Copy +UmdfFsContextUsePolicy=CanUseFsContext2 +UmdfHostProcessSharing=ProcessSharingDisabled + +[pfGamepadXbox_SW_AddReg] +; 1 = BusDevice. See the block above — this is the half that actually does the work. +HKR,,"DevicePropertyFlags",0x00010001,1 + +[pfGamepadXbox_HW_AddReg] +; 0x00010008 = REG_MULTI_SZ | APPEND, matching xinputhid.inf: append rather than replace, so we +; never clobber a filter someone else put on the stack. +HKR,,"UpperFilters",0x00010008,"xinputhid" + [pf_gamepad_Install] UmdfLibraryVersion=$UMDFVERSION$ ServiceBinary="%13%\pf_gamepad.dll" -- 2.54.0 From bd5735b80390870c96edb90bb57bf0aa19e52d4f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 21:10:08 +0200 Subject: [PATCH 11/16] feat(pads/windows): make the HID Xbox pad the default, and carry the trigger motors on the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that only make sense together: the HID backend becomes the default now that it is a superset of the XUSB one, the rumble datagram grows the two Xbox impulse-trigger motors, and the INF-shape tests learn about the Xbox identity's own install section. WP-E — `PUNKTFUNK_XBOX_BACKEND` now defaults to `hid`; `=xusb` is the escape hatch. The knob existed for exactly one reason, recorded in its own doc comment: the HID pad could not reach classic XInput, so defaulting to it would trade a known-working path for an unproven one. That objection is gone — with the `xinputhid` bus filter the INF now attaches, the HID pad is promoted like real hardware and keeps classic XInput while gaining everything XUSB never had (Steam, SDL, RawInput, DirectInput, joy.cpl, WGI) plus rumble, which XUSB could not source at all. The escape hatch stays because promotion leans on Microsoft's inbox `xinputhid.inf`; if a servicing update changes it, one env var restores the old behaviour with no reinstall. An unrecognised value takes the DEFAULT rather than the opt-out, so a typo cannot silently drop a user onto the path with no HID collection. WP-D — the `0xCA` rumble datagram gains a v3 form: v1 7 B: [0xCA][u16 pad][u16 low][u16 high] v2 10 B: … [u8 seq][u16 ttl_ms] v3 14 B: … [u16 lt][u16 rt] v3 is built FROM v2's bytes rather than restating the layout, so the prefix relationship is structural instead of a convention two encoders have to keep agreeing on, and every reader gates with `>=`. The four levels share one seq and one ttl on purpose: they are one statement of the pad's feedback at one instant, and sharing means the whole v2 apparatus — renewal cadence, stop burst, the client's seq gate, the lease clamp — governs the triggers with no new code. The new `RumbleUpdate` fields are plain `u16`, not `Option`: on a level-triggered plane "absent" must mean zero, because "absent → keep the previous value" is the stuck-rumble bug in a new costume. Only one backend can ever source them — the Windows HID Xbox pad, whose output report 0x03 carries them. `XINPUT_VIBRATION` and evdev `FF_RUMBLE` have two members and no third, so every other producer sends `lt = rt = 0`. ⚠️ The two TRIGGER `enable`-mask bits remain CONJECTURE. Bits 2/3 = left/right handle are measured; bit 0/1 = the triggers are inferred from field order and nothing else. `parse_xbox_output` says so inline, and no test asserts them — every test vector uses masks (0xFF, 0x00, 0x0C, 0xF3) whose expectations hold whichever bits turn out to be right. XInput cannot settle this: it has two motors. The INF tests — `hwid_matches_inf` matched the install section by the exact string `=pfGamepad,` and so stopped seeing the Xbox hardware ids the moment that identity moved to its own `pfGamepadXbox` section. It failed loudly, which is the good outcome; it is now prefix-matched and tolerant of further per-identity sections. Added `only_the_xbox_identity_installs_the_xinputhid_section`, which asserts the split in BOTH directions: the Xbox line must not install the shared section, and no other line may install the Xbox one. Merging them back is a one-line edit that looks like tidying and would hand a DualSense to Microsoft's Xbox translator. VERIFIED * ON WINDOWS (.173, the only place this code compiles): `cargo test -p pf-inject --lib` 104/104, including the new trigger tests and both INF tests; `cargo check -p punktfunk-host` clean. * macOS: `cargo fmt --all --check` clean; `cargo test -p punktfunk-core --features quic` rumble suite 22/22, including v3 round-trip and v3<->v2 cross-version parsing. * The pre-existing `c_abi_harness_round_trips` failure on macOS is `ld: library 'opus' not found` and reproduces with these changes stashed. NOT VERIFIED * No trigger rumble has ever been observed end to end — nothing can drive it yet (see the conjecture note above), and no client renders it. * The default flip has NOT been exercised in a real streaming session; every measurement so far came from the devtest harness. That is the on-glass run. * Non-Rust clients do not decode v3. They are blocked on a C ABI entry point first (`punktfunk_connection_next_rumble_cmd` has fixed out-params, ABI_VERSION 17); Apple could render it via GCHapticsLocality.leftTrigger/.rightTrigger, Android structurally cannot (its packed jlong is full) and has no trigger actuators anyway. --- clients/probe/src/main.rs | 7 +- .../pf-inject/src/inject/linux/dualsense.rs | 6 +- .../pf-inject/src/inject/linux/dualshock4.rs | 3 +- crates/pf-inject/src/inject/linux/gamepad.rs | 13 +- .../src/inject/linux/steam_controller.rs | 6 +- .../src/inject/linux/steam_controller2.rs | 3 +- .../pf-inject/src/inject/linux/switch_pro.rs | 5 +- crates/pf-inject/src/inject/uhid_manager.rs | 152 +++++++++---- .../inject/windows/dualsense_edge_windows.rs | 3 +- .../src/inject/windows/dualsense_windows.rs | 84 ++++++- .../src/inject/windows/dualshock4_windows.rs | 3 +- .../src/inject/windows/gamepad_windows.rs | 11 +- .../src/inject/windows/steam_deck_windows.rs | 3 +- .../src/inject/windows/xbox_windows.rs | 97 +++++--- crates/pf-inject/src/lib.rs | 2 +- crates/punktfunk-core/cbindgen.toml | 1 + .../src/client/pump/datagram_task.rs | 9 + crates/punktfunk-core/src/quic/datagram.rs | 163 +++++++++++++- crates/punktfunk-host/src/devtest.rs | 16 +- .../punktfunk-host/src/gamestream/control.rs | 10 +- crates/punktfunk-host/src/native.rs | 11 +- crates/punktfunk-host/src/native/gamepad.rs | 41 ++-- crates/punktfunk-host/src/native/input.rs | 212 +++++++++++++----- include/punktfunk_core.h | 9 + 24 files changed, 686 insertions(+), 184 deletions(-) diff --git a/clients/probe/src/main.rs b/clients/probe/src/main.rs index 399704f0..bb38de21 100644 --- a/clients/probe/src/main.rs +++ b/clients/probe/src/main.rs @@ -1274,13 +1274,18 @@ async fn session(args: Args) -> Result<()> { } } else if let Some(u) = punktfunk_core::quic::decode_rumble_envelope(&d) { // Log the first rumble so a loopback test can see the self-terminating v2 - // envelope tail (seq + TTL) arrived, not just the level. + // envelope tail (seq + TTL) arrived, not just the level. `lt`/`rt` are the v3 + // impulse-trigger levels: printed beside the envelope because the wire-leg + // check for trigger rumble is exactly "non-zero lt/rt AND the envelope still + // present" — i.e. the trigger tail did not displace the seq/TTL tail. if !rumble_logged { rumble_logged = true; tracing::info!( pad = u.pad, low = u.low, high = u.high, + lt = u.left_trigger, + rt = u.right_trigger, envelope = ?u.envelope, "rumble (0xCA)" ); diff --git a/crates/pf-inject/src/inject/linux/dualsense.rs b/crates/pf-inject/src/inject/linux/dualsense.rs index b852a523..39af7025 100644 --- a/crates/pf-inject/src/inject/linux/dualsense.rs +++ b/crates/pf-inject/src/inject/linux/dualsense.rs @@ -299,7 +299,8 @@ impl PadProto for DsLinuxProto { fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback { let fb = pad.service(idx); PadFeedback { - rumble: fb.rumble, + // No trigger motors on this protocol — see `PadFeedback::rumble`. + rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)), hidout: fb.hidout, // Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games // going through hid-playstation get their stops surfaced reliably, but Steam Input @@ -401,7 +402,8 @@ impl PadProto for DsEdgeLinuxProto { fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback { let fb = pad.service(idx); PadFeedback { - rumble: fb.rumble, + // No trigger motors on this protocol — see `PadFeedback::rumble`. + rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)), hidout: fb.hidout, // Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games // going through hid-playstation get their stops surfaced reliably, but Steam Input diff --git a/crates/pf-inject/src/inject/linux/dualshock4.rs b/crates/pf-inject/src/inject/linux/dualshock4.rs index 8929b2ec..aa4575de 100644 --- a/crates/pf-inject/src/inject/linux/dualshock4.rs +++ b/crates/pf-inject/src/inject/linux/dualshock4.rs @@ -314,7 +314,8 @@ impl PadProto for Ds4LinuxProto { fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback { let fb = pad.service(idx); PadFeedback { - rumble: fb.rumble, + // No trigger motors on this protocol — see `PadFeedback::rumble`. + rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)), hidout: fb .led .map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b }) diff --git a/crates/pf-inject/src/inject/linux/gamepad.rs b/crates/pf-inject/src/inject/linux/gamepad.rs index 4b87784b..0f557a4c 100644 --- a/crates/pf-inject/src/inject/linux/gamepad.rs +++ b/crates/pf-inject/src/inject/linux/gamepad.rs @@ -705,9 +705,14 @@ impl GamepadManager { .ensure(idx, |i| VirtualPad::create(i as usize, identity)); } - /// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose - /// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered). - pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { + /// Service every pad's FF protocol; `send(index, low, high, left_trigger, right_trigger)` is + /// invoked for each pad whose mixed rumble level changed. Call frequently (games block in + /// `EVIOCSFF` until answered). + /// + /// The two trigger levels are always zero here and always will be: evdev's `FF_RUMBLE` effect + /// is `{ u16 strong_magnitude, u16 weak_magnitude }` and has no third field, so impulse-trigger + /// rumble is unreachable through this backend no matter what the client can render. + pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16, u16, u16)) { // Finish any unplug whose removal frame only armed the grace — the producer sends that // frame once, so without this the uinput node would outlive the controller. The swept // mask is discarded because this manager keeps no per-index sibling state (the pads mix @@ -715,7 +720,7 @@ impl GamepadManager { self.slots.reap(); for (i, pad) in self.slots.iter_mut() { if let Some((low, high)) = pad.pump_ff() { - send(i as u16, low, high); + send(i as u16, low, high, 0, 0); } } } diff --git a/crates/pf-inject/src/inject/linux/steam_controller.rs b/crates/pf-inject/src/inject/linux/steam_controller.rs index 5bd26231..a58da15c 100644 --- a/crates/pf-inject/src/inject/linux/steam_controller.rs +++ b/crates/pf-inject/src/inject/linux/steam_controller.rs @@ -440,7 +440,8 @@ impl PadProto for SteamProto { fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback { let rumble = pad.service(); PadFeedback { - rumble, + // No trigger motors on this protocol — see `PadFeedback::rumble`. + rumble: rumble.map(|(low, high)| (low, high, 0, 0)), hidout: Vec::new(), // Rumble-plane liveness: a `0xEB` rumble command this poll. Steam Input drives this // pad over hidraw (the same abandonment semantics as the Windows Deck backend), so @@ -570,7 +571,8 @@ impl PadProto for ScProto { fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback { let rumble = pad.service(); PadFeedback { - rumble, + // No trigger motors on this protocol — see `PadFeedback::rumble`. + rumble: rumble.map(|(low, high)| (low, high, 0, 0)), hidout: Vec::new(), // Rumble-plane liveness: the kernel registers no FF device for the classic SC, so // rumble only ever arrives from a hidraw writer (`0xEB`) — which is exactly the diff --git a/crates/pf-inject/src/inject/linux/steam_controller2.rs b/crates/pf-inject/src/inject/linux/steam_controller2.rs index acd8efd0..3b7d559f 100644 --- a/crates/pf-inject/src/inject/linux/steam_controller2.rs +++ b/crates/pf-inject/src/inject/linux/steam_controller2.rs @@ -369,7 +369,8 @@ impl PadProto for TritonProto { }) .collect(); PadFeedback { - rumble, + // No trigger motors on this protocol — see `PadFeedback::rumble`. + rumble: rumble.map(|(low, high)| (low, high, 0, 0)), hidout, // Rumble-plane liveness: Steam is a hidraw writer here too, so the shared // abandoned-rumble force-off applies (the raw 0xCD passthrough plane is unaffected). diff --git a/crates/pf-inject/src/inject/linux/switch_pro.rs b/crates/pf-inject/src/inject/linux/switch_pro.rs index 015169ee..1dce0936 100644 --- a/crates/pf-inject/src/inject/linux/switch_pro.rs +++ b/crates/pf-inject/src/inject/linux/switch_pro.rs @@ -173,7 +173,8 @@ impl SwitchProPad { let _ = self.write_report(&build_usb_ack(cmd)); } Some(SwitchOutput::Subcmd { id, args, rumble }) => { - fb.rumble = Some(rumble); + // No trigger motors on this protocol — see `PadFeedback::rumble`. + fb.rumble = Some((rumble.0, rumble.1, 0, 0)); if id == 0x30 { // Player lights ride the subcommand itself; still ack it. if let Some(&arg) = args.first() { @@ -185,7 +186,7 @@ impl SwitchProPad { } self.answer_subcmd(id, &args); } - Some(SwitchOutput::Rumble(r)) => fb.rumble = Some(r), + Some(SwitchOutput::Rumble(r)) => fb.rumble = Some((r.0, r.1, 0, 0)), None => {} } } diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index e43ab836..9ceb5a41 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -18,13 +18,21 @@ use std::time::{Duration, Instant}; /// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`]. #[derive(Default)] pub struct PadFeedback { - /// `(low, high)` motor levels, if the pass saw a rumble report. + /// `(low, high, left_trigger, right_trigger)` motor levels, if the pass saw a rumble report. /// /// Range is `0..=0xFFFF` — this said `0..=0xFF00`, which is only true of the backends that /// widen the device's 8-bit motor byte by `<< 8` (the UHID/DualSense path). The Windows /// backend widens by `× 257` and does reach 0xFFFF, and this type carries both. Neither is a /// defect: consumers narrow with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255. - pub rumble: Option<(u16, u16)>, + /// + /// The two trailing fields are the Xbox impulse-trigger motors, which ride the 0xCA plane's + /// v3 tail (design/trigger-rumble-plane.md). **Exactly one backend can ever set them non-zero** + /// — the Windows HID Xbox pad, whose output report `0x03` has fields for them. Every other + /// backend reports `(low, high, 0, 0)` because the packet it parses has nowhere to carry them: + /// XUSB's `SET_STATE` is `rumble_large`/`rumble_small`, evdev's `FF_RUMBLE` is strong/weak, + /// and a DualSense's trigger actuators are *adaptive* (force resistance, on the 0xCD plane) + /// rather than motors. That is a permanent property of those protocols, not a gap to fill. + pub rumble: Option<(u16, u16, u16, u16)>, pub hidout: Vec, /// Whether the game drove this pad's RUMBLE plane this poll — at least one output report /// asserted the vibration fields (valid-flag set, including an explicit zero), not merely any @@ -119,8 +127,11 @@ pub struct UhidManager { slots: PadSlots, /// Each pad's current full report — buttons/sticks merged with persisted rich-plane fields. state: Vec, - /// Last rumble forwarded per pad, so a report that only changes rich feedback doesn't re-send it. - last_rumble: Vec<(u16, u16)>, + /// Last rumble forwarded per pad, so a report that only changes rich feedback doesn't re-send + /// it. All FOUR levels, deliberately: dedup on the handle pair alone would swallow a + /// trigger-only change — a racing title's impulse-trigger stream against silent handles — and + /// the pad would never rumble, with nothing logged anywhere. + last_rumble: Vec<(u16, u16, u16, u16)>, /// Last rich feedback forwarded per pad, so an output report that only changed the rumble /// doesn't re-send unchanged lightbar/LED/trigger state. hidout_dedup: Vec, @@ -254,7 +265,7 @@ impl UhidManager { backend, slots: PadSlots::new(B::LABEL, B::DEVICE, B::CREATE_HINT), state, - last_rumble: vec![(0, 0); MAX_PADS], + last_rumble: vec![(0, 0, 0, 0); MAX_PADS], hidout_dedup: vec![HidoutDedup::default(); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], last_active: vec![Instant::now(); MAX_PADS], @@ -339,13 +350,14 @@ impl UhidManager { } /// Service every pad: answer any pending driver/kernel handshake and route a game's feedback - /// back out. `rumble` is invoked `(index, low, high)` only when the motor level *changes* (the - /// universal 0xCA plane); `hidout` is invoked per rich feedback event that isn't an exact - /// repeat of the last-forwarded value (the 0xCD plane). Call frequently — kernel/driver init - /// handshakes block until answered. + /// back out. `rumble` is invoked `(index, low, high, left_trigger, right_trigger)` only when + /// the motor level *changes* (the universal 0xCA plane — the trigger pair is non-zero only on + /// the Windows HID Xbox pad, see [`PadFeedback::rumble`]); `hidout` is invoked per rich + /// feedback event that isn't an exact repeat of the last-forwarded value (the 0xCD plane). + /// Call frequently — kernel/driver init handshakes block until answered. pub fn pump( &mut self, - mut rumble: impl FnMut(u16, u16, u16), + mut rumble: impl FnMut(u16, u16, u16, u16, u16), mut hidout: impl FnMut(HidOutput), ) { let now = Instant::now(); @@ -369,9 +381,9 @@ impl UhidManager { // the next LED/trigger state re-forwards. WARN through the per-pad rate limiter — // a storm overflows every poll and the raw line once flooded a whole log export. self.overflow_warn[i].note(now, B::LABEL, i); - if self.last_rumble[i] != (0, 0) { - self.last_rumble[i] = (0, 0); - rumble(i as u16, 0, 0); + if self.last_rumble[i] != (0, 0, 0, 0) { + self.last_rumble[i] = (0, 0, 0, 0); + rumble(i as u16, 0, 0, 0, 0); } self.hidout_dedup[i] = HidoutDedup::default(); } @@ -385,9 +397,9 @@ impl UhidManager { if let Some(r) = fb.rumble { if self.last_rumble[i] != r { self.last_rumble[i] = r; - rumble(i as u16, r.0, r.1); + rumble(i as u16, r.0, r.1, r.2, r.3); } - } else if self.last_rumble[i] != (0, 0) + } else if self.last_rumble[i] != (0, 0, 0, 0) && rumble_idle_timeout() .is_some_and(|t| now.duration_since(self.last_active[i]) >= t) { @@ -400,10 +412,12 @@ impl UhidManager { index = i, prev_low = self.last_rumble[i].0, prev_high = self.last_rumble[i].1, + prev_lt = self.last_rumble[i].2, + prev_rt = self.last_rumble[i].3, "rumble: stale residual (game stopped driving the rumble plane) — forcing off" ); - self.last_rumble[i] = (0, 0); - rumble(i as u16, 0, 0); + self.last_rumble[i] = (0, 0, 0, 0); + rumble(i as u16, 0, 0, 0, 0); } for h in fb.hidout { // Skip rich feedback that repeats the last-forwarded value (a game's output report @@ -469,7 +483,7 @@ impl UhidManager { /// (re)connect starts from scratch and is always forwarded. fn reset_pad(&mut self, idx: usize) { self.state[idx] = self.backend.neutral(); - self.last_rumble[idx] = (0, 0); + self.last_rumble[idx] = (0, 0, 0, 0); self.hidout_dedup[idx].clear(); self.last_write[idx] = Instant::now(); self.last_active[idx] = Instant::now(); @@ -733,14 +747,14 @@ mod tests { m.handle(&frame(1, 0b00, 0)); assert!(m.slots.get(1).is_some(), "inside the grace — not yet swept"); // A tick inside the grace must NOT flap the devnode (pad_slots::SWEEP_GRACE). - m.pump(|_, _, _| {}, |_| {}); + m.pump(|_, _, _, _, _| {}, |_| {}); assert!( m.slots.get(1).is_some(), "a tick inside the grace dropped it" ); // Grace elapsed: the next tick completes the unplug, with no further frame. m.slots.expire_grace(); - m.pump(|_, _, _| {}, |_| {}); + m.pump(|_, _, _, _, _| {}, |_| {}); assert!( m.slots.get(1).is_none(), "the pump tick never completed the unplug" @@ -783,7 +797,10 @@ mod tests { m.handle(&frame(0, 0b1, 0)); let collect = |m: &mut UhidManager| { let out = RefCell::new(Vec::new()); - m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {}); + m.pump( + |i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)), + |_| {}, + ); out.into_inner() }; let rumble = |r| PadFeedback { @@ -792,12 +809,16 @@ mod tests { rumble_drove: Some(true), resync: false, }; - *m.backend.feedback.borrow_mut() = vec![rumble((100, 0)), rumble((100, 0)), rumble((7, 7))]; - assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards + *m.backend.feedback.borrow_mut() = vec![ + rumble((100, 0, 0, 0)), + rumble((100, 0, 0, 0)), + rumble((7, 7, 0, 0)), + ]; + assert_eq!(collect(&mut m), vec![(0, 100, 0, 0, 0)]); // first value forwards assert_eq!(collect(&mut m), vec![]); // exact repeat deduped - assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards - // Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes - // on a PUMP tick, not on a second frame — that is all production ever sends. + assert_eq!(collect(&mut m), vec![(0, 7, 7, 0, 0)]); // change forwards + // Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes + // on a PUMP tick, not on a second frame — that is all production ever sends. m.handle(&frame(0, 0b0, 0)); // the one removal frame — arms the grace m.slots.expire_grace(); assert_eq!(collect(&mut m), vec![]); // this tick reaps; nothing queued to forward @@ -806,8 +827,43 @@ mod tests { "the pump tick completed the unplug" ); m.handle(&frame(0, 0b1, 0)); - *m.backend.feedback.borrow_mut() = vec![rumble((7, 7))]; - assert_eq!(collect(&mut m), vec![(0, 7, 7)]); + *m.backend.feedback.borrow_mut() = vec![rumble((7, 7, 0, 0))]; + assert_eq!(collect(&mut m), vec![(0, 7, 7, 0, 0)]); + } + + /// The dedup compares all FOUR levels. Comparing only the handle pair would swallow a + /// trigger-only change — which is the *normal* shape of impulse-trigger content, since racing + /// titles drive the triggers continuously against near-silent handles — and the pad would + /// simply never rumble, with nothing logged and nothing on the wire to look at. + #[test] + fn a_trigger_only_change_is_forwarded_not_deduped_away() { + let mut m = mgr(); + m.handle(&frame(0, 0b1, 0)); + let collect = |m: &mut UhidManager| { + let out = RefCell::new(Vec::new()); + m.pump( + |i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)), + |_| {}, + ); + out.into_inner() + }; + let rumble = |r| PadFeedback { + rumble: Some(r), + hidout: Vec::new(), + rumble_drove: Some(true), + resync: false, + }; + // Handles silent throughout; only the trigger motors move. + *m.backend.feedback.borrow_mut() = vec![ + rumble((0, 0, 0x8000, 0)), + rumble((0, 0, 0x8000, 0)), + rumble((0, 0, 0x8000, 0x4000)), + rumble((0, 0, 0, 0)), + ]; + assert_eq!(collect(&mut m), vec![(0, 0, 0, 0x8000, 0)]); + assert_eq!(collect(&mut m), vec![], "exact repeat still dedups"); + assert_eq!(collect(&mut m), vec![(0, 0, 0, 0x8000, 0x4000)]); + assert_eq!(collect(&mut m), vec![(0, 0, 0, 0, 0)], "the stop forwards"); } #[test] @@ -816,17 +872,20 @@ mod tests { m.handle(&frame(0, 0b1, 0)); let collect = |m: &mut UhidManager| { let out = RefCell::new(Vec::new()); - m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {}); + m.pump( + |i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)), + |_| {}, + ); out.into_inner() }; // The game latches a non-zero rumble (a fresh report drove the pad). *m.backend.feedback.borrow_mut() = vec![PadFeedback { - rumble: Some((200, 0)), + rumble: Some((200, 0, 0, 0)), hidout: Vec::new(), rumble_drove: Some(true), resync: false, }]; - assert_eq!(collect(&mut m), vec![(0, 200, 0)]); + assert_eq!(collect(&mut m), vec![(0, 200, 0, 0, 0)]); // The game stops driving the RUMBLE plane — no output report at all, or (equivalently, the // confirmed stuck-ON case) a stream of LED/adaptive-trigger reports that never assert the @@ -845,7 +904,7 @@ mod tests { // exactly once, then stays off (no repeated zero spam). m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50)); *m.backend.feedback.borrow_mut() = vec![idle(), idle()]; - assert_eq!(collect(&mut m), vec![(0, 0, 0)]); // forced off + assert_eq!(collect(&mut m), vec![(0, 0, 0, 0, 0)]); // forced off assert_eq!(collect(&mut m), vec![]); // already zero — no repeat } @@ -855,16 +914,19 @@ mod tests { m.handle(&frame(0, 0b1, 0)); let collect = |m: &mut UhidManager| { let out = RefCell::new(Vec::new()); - m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {}); + m.pump( + |i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)), + |_| {}, + ); out.into_inner() }; *m.backend.feedback.borrow_mut() = vec![PadFeedback { - rumble: Some((200, 0)), + rumble: Some((200, 0, 0, 0)), hidout: Vec::new(), rumble_drove: Some(true), resync: false, }]; - assert_eq!(collect(&mut m), vec![(0, 200, 0)]); + assert_eq!(collect(&mut m), vec![(0, 200, 0, 0, 0)]); // Even with a stale clock, a poll where the game drove the rumble plane refreshes // activity, so the held rumble is NOT cut. Backends report that as @@ -872,7 +934,7 @@ mod tests { // the manager also honors the bare `rumble_drove: Some(true)` shape defensively. m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50)); *m.backend.feedback.borrow_mut() = vec![PadFeedback { - rumble: Some((200, 0)), + rumble: Some((200, 0, 0, 0)), hidout: Vec::new(), rumble_drove: Some(true), resync: false, @@ -906,7 +968,7 @@ mod tests { }]; let out = RefCell::new(0u32); m.pump( - |_, _, _| {}, + |_, _, _, _, _| {}, |_| { *out.borrow_mut() += 1; }, @@ -976,7 +1038,7 @@ mod tests { let rumbles = RefCell::new(Vec::new()); let hidouts = RefCell::new(0u32); m.pump( - |i, lo, hi| rumbles.borrow_mut().push((i, lo, hi)), + |i, lo, hi, lt, rt| rumbles.borrow_mut().push((i, lo, hi, lt, rt)), |_| *hidouts.borrow_mut() += 1, ); (rumbles.into_inner(), hidouts.into_inner()) @@ -984,12 +1046,12 @@ mod tests { // Latch a rumble + an LED. *m.backend.feedback.borrow_mut() = vec![PadFeedback { - rumble: Some((100, 0)), + rumble: Some((100, 0, 0, 0)), hidout: vec![led(10)], rumble_drove: Some(true), resync: false, }]; - assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1)); + assert_eq!(collect(&mut m), (vec![(0, 100, 0, 0, 0)], 1)); // Overflow poll: no reports survived, resync flagged → forced stop, exactly once. *m.backend.feedback.borrow_mut() = vec![PadFeedback { @@ -998,22 +1060,22 @@ mod tests { rumble_drove: Some(false), resync: true, }]; - assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0)); + assert_eq!(collect(&mut m), (vec![(0, 0, 0, 0, 0)], 0)); // The game re-asserts the SAME rumble + LED state: both must re-forward (the rumble // because the forced stop reset `last_rumble`, the LED because the dedup was re-armed). *m.backend.feedback.borrow_mut() = vec![PadFeedback { - rumble: Some((100, 0)), + rumble: Some((100, 0, 0, 0)), hidout: vec![led(10)], rumble_drove: Some(true), resync: false, }]; - assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1)); + assert_eq!(collect(&mut m), (vec![(0, 100, 0, 0, 0)], 1)); // A resync with nothing latched forwards no spurious stop. *m.backend.feedback.borrow_mut() = vec![ PadFeedback { - rumble: Some((0, 0)), + rumble: Some((0, 0, 0, 0)), hidout: Vec::new(), rumble_drove: Some(true), resync: false, @@ -1025,7 +1087,7 @@ mod tests { resync: true, }, ]; - assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0)); // the explicit stop + assert_eq!(collect(&mut m), (vec![(0, 0, 0, 0, 0)], 0)); // the explicit stop assert_eq!(collect(&mut m), (vec![], 0)); // resync at zero — silent } } 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 acac31a1..1947f4d6 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_edge_windows.rs @@ -85,7 +85,8 @@ impl PadProto for DsEdgeWinProto { fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback { let fb = pad.service(idx); PadFeedback { - rumble: fb.rumble, + // No trigger motors on this protocol — see `PadFeedback::rumble`. + rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)), hidout: fb.hidout, // Rumble-plane liveness, not any-report liveness — see the plain DualSense backend. rumble_drove: Some(fb.rumble.is_some()), diff --git a/crates/pf-inject/src/inject/windows/dualsense_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_windows.rs index ac7325d9..7ae6e69c 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_windows.rs @@ -686,7 +686,8 @@ impl PadProto for DsWinProto { // feed the abandoned-rumble force-off's activity clock (the historical unbounded // stuck-ON path, now doubly closed by the lossless report ring). rumble_drove: Some(fb.rumble.is_some()), - rumble: fb.rumble, + // No trigger motors on this protocol — see `PadFeedback::rumble`. + rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)), hidout: fb.hidout, resync: fb.resync, } @@ -995,14 +996,26 @@ mod drain_tests { "/../../packaging/windows/drivers/pf-gamepad/pf_gamepad.inx" ); let inf = std::fs::read_to_string(inx).expect("read pf_gamepad.inx"); - // The [Models] lines: `%DeviceDesc…%=pfGamepad, [, …]`. + // The [Models] lines: `%DeviceDesc…%=, [, …]`. + // + // ⚠️ Match the install section by PREFIX, not by the exact string `pfGamepad,`. The Xbox + // line installs `pfGamepadXbox` — a section of its own, because that identity additionally + // attaches the `xinputhid` bus filter and the four PlayStation/Deck identities must not get + // it. An exact match silently stopped seeing the Xbox ids the moment that split happened, + // which is precisely the "this test went vacuous" failure the assert below guards against, + // except it failed loudly instead. Keep this tolerant of further per-identity sections. let declared: Vec = inf .lines() .map(str::trim) .filter(|l| !l.starts_with(';')) - .filter_map(|l| l.split_once("=pfGamepad,")) - .flat_map(|(_, ids)| { - ids.split(',') + .filter_map(|l| l.split_once('=')) + .filter(|(_, rhs)| rhs.trim_start().starts_with("pfGamepad")) + .flat_map(|(_, rhs)| { + // `pfGamepad[Suffix], [, …]` — drop the section name, keep the ids. + // `AddReg=pfGamepadXbox_HW_AddReg` reaches here too and contributes nothing, + // because it has no comma. + rhs.split(',') + .skip(1) .map(|id| id.trim().to_ascii_lowercase()) .collect::>() }) @@ -1033,6 +1046,67 @@ mod drain_tests { } } + /// The Xbox identity must install its OWN section, and the PlayStation/Deck identities must + /// not install that one. + /// + /// `pfGamepadXbox` attaches Microsoft's `xinputhid` as an upper filter and sets + /// `DevicePropertyFlags=1` (`BusDevice`), which is what makes Windows promote our Xbox pad — + /// it mints the `IG_00` token, registers an XUSB interface, and lets classic XInput and rumble + /// through. Applied to a DualSense, DualShock 4, Edge or Steam Deck it would hand a + /// PlayStation pad to Microsoft's **Xbox** translator, which claims the HID collection + /// exclusively and would take a working pad away from Steam and SDL. + /// + /// Merging the two sections back together is a one-line edit that looks like tidying and is + /// not, so assert the split rather than trusting a comment to survive. + #[test] + fn only_the_xbox_identity_installs_the_xinputhid_section() { + let inx = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../packaging/windows/drivers/pf-gamepad/pf_gamepad.inx" + ); + let inf = std::fs::read_to_string(inx).expect("read pf_gamepad.inx"); + let xbox = super::super::xbox_windows::XBOX_HWID.to_ascii_lowercase(); + + let mut saw_xbox_model = false; + for line in inf.lines().map(str::trim).filter(|l| !l.starts_with(';')) { + let Some((_, rhs)) = line.split_once('=') else { + continue; + }; + let rhs = rhs.trim_start(); + let Some((section, ids)) = rhs.split_once(',') else { + continue; + }; + if !section.starts_with("pfGamepad") { + continue; + } + let ids: Vec = ids + .split(',') + .map(|i| i.trim().to_ascii_lowercase()) + .collect(); + let mentions_xbox = ids.iter().any(|i| i.contains(&xbox)); + if mentions_xbox { + saw_xbox_model = true; + assert_ne!( + section, "pfGamepad", + "the Xbox model line installs the SHARED section, so the xinputhid filter \ + would be attached to every PlayStation and Deck pad too" + ); + } else { + assert_eq!( + section, "pfGamepad", + "a non-Xbox model line ({ids:?}) installs {section:?}; if that section carries \ + the xinputhid filter, this pad is about to be handed to Microsoft's Xbox \ + translator" + ); + } + } + assert!( + saw_xbox_model, + "no [Models] line mentions {xbox:?} — the parse went vacuous; fix it rather than \ + deleting the assert" + ); + } + /// The driver reads its HID identity back off the same hardware id — that mapping is what /// decides which report descriptor and which VID/PID a pad enumerates with, and it is settled /// at `EvtDeviceAdd`, before the sealed channel can possibly say anything (its delivery goes diff --git a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs index 1418291a..6055901e 100644 --- a/crates/pf-inject/src/inject/windows/dualshock4_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualshock4_windows.rs @@ -232,7 +232,8 @@ impl PadProto for Ds4WinProto { fn service(&self, pad: &mut Ds4WinPad, idx: u8) -> PadFeedback { let fb = pad.service(); PadFeedback { - rumble: fb.rumble, + // No trigger motors on this protocol — see `PadFeedback::rumble`. + rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)), hidout: fb .led .map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b }) diff --git a/crates/pf-inject/src/inject/windows/gamepad_windows.rs b/crates/pf-inject/src/inject/windows/gamepad_windows.rs index b6b8ed91..22c54bbb 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_windows.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_windows.rs @@ -356,7 +356,12 @@ impl GamepadManager { /// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries /// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small` /// (high-frequency) → `high` — matching the other backends. - pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { + /// + /// The two trigger levels `send` also takes are always zero here and always will be: the XUSB + /// `SET_STATE` packet this backend parses carries `rumble_large`/`rumble_small` and nothing + /// else, mirroring `XINPUT_VIBRATION`'s two members. Impulse-trigger rumble is only reachable + /// through the HID-visible Xbox identity (WGI / GameInput), never through the XUSB companion. + pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16, u16, u16)) { // Finish any unplug whose removal frame only armed the grace — the producer sends that // frame once, so without this the XUSB devnode would outlive the controller. let swept = self.slots.reap(); @@ -369,7 +374,7 @@ impl GamepadManager { self.last_active[i] = Instant::now(); if self.last_rumble[i] != (large, small) { self.last_rumble[i] = (large, small); - send(i as u16, large as u16 * 257, small as u16 * 257); + send(i as u16, large as u16 * 257, small as u16 * 257, 0, 0); } } else if self.last_rumble[i] != (0, 0) && crate::uhid_manager::rumble_idle_timeout() @@ -386,7 +391,7 @@ impl GamepadManager { "rumble: stale residual (game stopped driving the pad) — forcing off" ); self.last_rumble[i] = (0, 0); - send(i as u16, 0, 0); + send(i as u16, 0, 0, 0, 0); } } } 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 5e6e0e15..1f56ccbc 100644 --- a/crates/pf-inject/src/inject/windows/steam_deck_windows.rs +++ b/crates/pf-inject/src/inject/windows/steam_deck_windows.rs @@ -231,7 +231,8 @@ impl PadProto for DeckWinProto { // presence is the rumble-plane activity signal, even at an unchanged level. let (rumble, resync) = pad.service(); PadFeedback { - rumble, + // No trigger motors on this protocol — see `PadFeedback::rumble`. + rumble: rumble.map(|(low, high)| (low, high, 0, 0)), hidout: Vec::new(), rumble_drove: Some(rumble.is_some()), resync, diff --git a/crates/pf-inject/src/inject/windows/xbox_windows.rs b/crates/pf-inject/src/inject/windows/xbox_windows.rs index 21c063c2..ef347e48 100644 --- a/crates/pf-inject/src/inject/windows/xbox_windows.rs +++ b/crates/pf-inject/src/inject/windows/xbox_windows.rs @@ -128,7 +128,7 @@ impl XboxWinPad { /// Poll the section's output slot for a game's rumble, tick the sealed-channel delivery and /// feed the driver-attach health watcher. - fn service(&mut self) -> (Option<(u16, u16)>, bool) { + fn service(&mut self) -> (Option<(u16, u16, u16, u16)>, bool) { self.channel.pump(); // SAFETY: base points at SHM_SIZE bytes. let proto = unsafe { @@ -146,34 +146,46 @@ impl XboxWinPad { } } -/// Parse an Xbox output report into `(low, high)` motor levels on the wire's 0..65535 scale. +/// Parse an Xbox output report into `(low, high, left_trigger, right_trigger)` motor levels on the +/// wire's 0..65535 scale. /// /// The Bluetooth Xbox rumble report is id `0x03`: `[id, enable, left_trigger, right_trigger, /// left, right, duration, delay, loop]`, with magnitudes on a **0..100** scale (not 0..255 — a /// detail that silently costs 60 % of the rumble range if you assume otherwise). The `enable` /// mask picks which motors the values apply to; bit 2 is the left (low-frequency) motor and bit 3 /// the right (high-frequency) one, matching how the wire's `low`/`high` pair is used elsewhere. -/// The two trigger motors have no wire representation and are ignored. +/// +/// Bytes 2/3 are the two impulse-trigger motors, which ride the 0xCA plane's v3 tail +/// (design/trigger-rumble-plane.md). This pad is the only backend in the tree that can ever source +/// them: XUSB's `SET_STATE` carries `rumble_large`/`rumble_small` and evdev's `FF_RUMBLE` carries +/// strong/weak, so neither packet has a field to lose. They are scaled by the same 0..100 closure +/// as the handles rather than a copy of it — assuming 0..255 here would read a full-scale `100` as +/// ~39 %, which on a real pad reads as "trigger rumble works but is weirdly weak", the hardest +/// class of bug to attribute. /// /// ⚠️ Never seen a real report — this shape is from the documented protocol, not a capture. -fn parse_xbox_output(bytes: &[u8]) -> Option<(u16, u16)> { +/// +/// ⚠️ **The two TRIGGER `enable` bits are conjecture, not measurement.** Bits 2/3 = left/right +/// handle are known; bit 0 = left trigger and bit 1 = right trigger are inferred from the report's +/// field order (triggers first, handles second) and from nothing else. A live capture (design WP0) +/// settles it. Getting it wrong yields "the triggers buzz when the game asked for the handles", +/// so nothing downstream may treat this assignment as established — and the tests below are +/// deliberately written with mask vectors that hold whichever bits turn out to be right. +fn parse_xbox_output(bytes: &[u8]) -> Option<(u16, u16, u16, u16)> { // The driver republishes output reports report-id-prefixed, like the PS backends. if bytes.len() < 6 || bytes[0] != 0x03 { return None; } let enable = bytes[1]; let scale = |v: u8| -> u16 { (v.min(100) as u32 * 65535 / 100) as u16 }; - let low = if enable & 0x04 != 0 { - scale(bytes[4]) - } else { - 0 - }; - let high = if enable & 0x08 != 0 { - scale(bytes[5]) - } else { - 0 - }; - Some((low, high)) + let gated = |bit: u8, v: u8| if enable & bit != 0 { scale(v) } else { 0 }; + Some(( + gated(0x04, bytes[4]), + gated(0x08, bytes[5]), + // UNVERIFIED bit assignment — see the second ⚠️ above before trusting either of these. + gated(0x01, bytes[2]), + gated(0x02, bytes[3]), + )) } /// The Windows-Xbox half of the shared stateful manager (see [`PadProto`]). Lifecycle (slot table, @@ -251,30 +263,65 @@ pub type XboxWindowsManager = UhidManager; mod tests { use super::*; + // Every `enable` vector in this module is chosen so its assertion holds whichever bits the + // TRIGGER actuators turn out to use — the assignment is conjecture (see the ⚠️ on + // `parse_xbox_output`) and a test asserting it would pin a guess as if it were the contract. + // The safe masks: `0xFF` enables everything that exists, `0x00` enables nothing, and + // `0x0C` / `0xF3` split the two MEASURED handle bits from every other bit. No vector below + // names a trigger enable bit. + + /// Both handle motors at full scale, with the triggers idle. #[test] fn rumble_scales_off_the_zero_to_hundred_protocol_range() { - // Both motors enabled, full scale. let full = [0x03, 0x0F, 0, 0, 100, 100, 0, 0, 1]; - assert_eq!(parse_xbox_output(&full), Some((65535, 65535))); + assert_eq!(parse_xbox_output(&full), Some((65535, 65535, 0, 0))); // Half on the left motor only. let half = [0x03, 0x04, 0, 0, 50, 100, 0, 0, 1]; - assert_eq!(parse_xbox_output(&half), Some((32767, 0))); + assert_eq!(parse_xbox_output(&half), Some((32767, 0, 0, 0))); } - /// A value above the protocol's 0..100 range must clamp, not wrap past full scale. + /// The trigger magnitudes are on the SAME 0..100 protocol range as the handles, so a + /// full-scale `100` is `65535` — not `25700`, which is what reading them as 0..255 would give + /// and which reads on a real pad as "trigger rumble works but is weirdly weak". Named for the + /// regression so it cannot be "fixed" the wrong way later. + #[test] + fn trigger_magnitudes_are_not_a_zero_to_255_range() { + let full = [0x03, 0xFF, 100, 100, 0, 0, 0, 0, 1]; + assert_eq!(parse_xbox_output(&full), Some((0, 0, 65535, 65535))); + let half = [0x03, 0xFF, 50, 25, 0, 0, 0, 0, 1]; + assert_eq!(parse_xbox_output(&half), Some((0, 0, 32767, 16383))); + } + + /// A value above the protocol's 0..100 range must clamp, not wrap past full scale — on all + /// four actuators, since the triggers reuse the handles' scale closure. #[test] fn out_of_range_magnitudes_clamp() { - let over = [0x03, 0x0F, 0, 0, 255, 255, 0, 0, 1]; - assert_eq!(parse_xbox_output(&over), Some((65535, 65535))); + let over = [0x03, 0xFF, 255, 255, 255, 255, 0, 0, 1]; + assert_eq!(parse_xbox_output(&over), Some((65535, 65535, 65535, 65535))); } - /// The enable mask gates each motor independently — a report that enables neither is a stop. + /// The enable mask gates each motor independently — a report that enables nothing is a stop. #[test] fn the_enable_mask_gates_each_motor() { - let none = [0x03, 0x00, 0, 0, 100, 100, 0, 0, 1]; - assert_eq!(parse_xbox_output(&none), Some((0, 0))); + let none = [0x03, 0x00, 100, 100, 100, 100, 0, 0, 1]; + assert_eq!(parse_xbox_output(&none), Some((0, 0, 0, 0))); let right_only = [0x03, 0x08, 0, 0, 100, 100, 0, 0, 1]; - assert_eq!(parse_xbox_output(&right_only), Some((0, 65535))); + assert_eq!(parse_xbox_output(&right_only), Some((0, 65535, 0, 0))); + } + + /// The case the whole trigger-rumble plane exists for, and the one nothing else in the tree + /// can produce: a racing title driving the impulse triggers hard while the handles stay + /// silent. `0x0C` is the two measured handle bits; `0xF3` is every OTHER bit, so this pair + /// isolates the handles from the triggers without claiming which bits the triggers are. + #[test] + fn a_trigger_only_report_leaves_the_handles_silent() { + let triggers_only = [0x03, 0xF3, 100, 40, 100, 100, 0, 0, 1]; + assert_eq!( + parse_xbox_output(&triggers_only), + Some((0, 0, 65535, 26214)) + ); + let handles_only = [0x03, 0x0C, 100, 100, 100, 100, 0, 0, 1]; + assert_eq!(parse_xbox_output(&handles_only), Some((65535, 65535, 0, 0))); } /// Anything that is not the rumble report — or is truncated — is ignored rather than parsed diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index ac91d2e8..01260fd0 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -500,7 +500,7 @@ pub mod gamepad { GamepadManager } pub fn handle(&mut self, _ev: &punktfunk_core::input::GamepadEvent) {} - pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {} + pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16, u16, u16)) {} } } /// Linux: the "Punktfunk Pen" uinput virtual tablet (design/pen-tablet-input.md §5) — the diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index 428cd854..11de6b6d 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -204,6 +204,7 @@ include = ["PunktfunkEndReason"] "RICH_INPUT_MAGIC" = "PUNKTFUNK_RICH_INPUT_MAGIC" "RUMBLE_V1_LEN" = "PUNKTFUNK_RUMBLE_V1_LEN" "RUMBLE_V2_LEN" = "PUNKTFUNK_RUMBLE_V2_LEN" +"RUMBLE_V3_LEN" = "PUNKTFUNK_RUMBLE_V3_LEN" "SETUP_FAILED_CLOSE_CODE" = "PUNKTFUNK_SETUP_FAILED_CLOSE_CODE" "TAG_LEN" = "PUNKTFUNK_TAG_LEN" "TRIGGER_EFFECT_MAX" = "PUNKTFUNK_TRIGGER_EFFECT_MAX" diff --git a/crates/punktfunk-core/src/client/pump/datagram_task.rs b/crates/punktfunk-core/src/client/pump/datagram_task.rs index eceb6474..a08ea68b 100644 --- a/crates/punktfunk-core/src/client/pump/datagram_task.rs +++ b/crates/punktfunk-core/src/client/pump/datagram_task.rs @@ -91,6 +91,15 @@ pub(super) async fn run( let ttl = u.envelope.map(|e| e.ttl_ms); // Both consumers are fed; an embedder drains exactly one of them // (the legacy queue, or the policy engine's command API). + // + // `u.left_trigger`/`u.right_trigger` (the v3 tail) are decoded and + // deliberately NOT forwarded yet: neither consumer has a slot for them. + // Widening them is the client-engine work package — `RumbleCommand` grows + // two fields, `ActuatorQuirks` learns whether the physical pad has trigger + // motors, and the C ABI gains a `next_rumble_cmd2` beside the existing + // fixed-out-param puller. Dropping them here is exactly what the §5 + // compatibility table calls "new host, old client": the handle motors + // behave identically and the trigger levels are silently discarded. let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl)); rumble_feed.wire_update(u.pad, u.low, u.high, ttl); } diff --git a/crates/punktfunk-core/src/quic/datagram.rs b/crates/punktfunk-core/src/quic/datagram.rs index c73243a8..8e907651 100644 --- a/crates/punktfunk-core/src/quic/datagram.rs +++ b/crates/punktfunk-core/src/quic/datagram.rs @@ -122,8 +122,9 @@ pub fn decode_audio_red_datagram(b: &[u8]) -> Option<(u32, u64, &[u8], Option<&[ /// Legacy rumble datagram (v1), host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`. /// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop) as *level-triggered* state /// — it persists until superseded, which is why the host re-sends it periodically as its loss -/// heal. New hosts emit the self-terminating [`encode_rumble_datagram_v2`] instead; this is kept -/// for the loopback tests and as the wire an old host still speaks (a new client decodes both via +/// heal. New hosts emit the self-terminating [`encode_rumble_datagram_v3`] instead; this is kept +/// for the loopback tests, as the wire an old host still speaks, and as what the +/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch drops to (a new client decodes every form via /// [`decode_rumble_envelope`]). pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] { let mut b = [0u8; 7]; @@ -141,6 +142,12 @@ pub const RUMBLE_V1_LEN: usize = 7; /// first 7 bytes as a plain level and ignores the tail, so no wire-version bump is needed — the /// same dual-size idiom the HDR-luminance `AddRequest` tail uses. pub const RUMBLE_V2_LEN: usize = 10; +/// Wire length of a v3 (envelope + impulse-trigger motors) rumble datagram — the v2 form plus a +/// `[u16 left_trigger LE][u16 right_trigger LE]` tail (see [`encode_rumble_datagram_v3`]). Second +/// use of the same append-extension the v2 tail introduced, and for the same reason: every reader +/// on this plane gates with `>=`, so a 14-byte datagram satisfies the v1 predicate (level only), +/// the v2 predicate (level + envelope) and this one, and each peer takes the prefix it knows. +pub const RUMBLE_V3_LEN: usize = 14; /// Rumble envelope datagram (v2), host → client: /// `[0xCA][u16 pad LE][u16 low LE][u16 high LE][u8 seq][u16 ttl_ms LE]`. @@ -163,6 +170,41 @@ pub fn encode_rumble_datagram_v2(pad: u16, low: u16, high: u16, seq: u8, ttl_ms: b } +/// Rumble envelope datagram with the impulse-trigger motors (v3), host → client: +/// `[0xCA][u16 pad LE][u16 low LE][u16 high LE][u8 seq][u16 ttl_ms LE][u16 lt LE][u16 rt LE]`. +/// +/// The [`encode_rumble_datagram_v2`] envelope with the Xbox trigger motors appended, on the same +/// `0..=0xFFFF` scale as `low`/`high` (design/trigger-rumble-plane.md §4). +/// +/// **The four levels share ONE `seq` and ONE `ttl_ms`, deliberately.** They are a single statement +/// of the pad's feedback state at one instant; a second sequence space would let a reordered +/// datagram apply the handles from moment *t* and the triggers from *t−1*, a glitch nothing else +/// in the system can currently produce. Sharing also means the whole v2 apparatus — the renewal +/// cadence, the post-stop burst, the client's wrapping half-space `seq` gate, the receiver-side +/// lease clamp — governs the trigger motors with no new code, so a trigger rumble whose host dies +/// self-silences on the same lease as the handles. +/// +/// Exactly one backend can ever source non-zero trigger levels: the Windows HID Xbox pad, whose +/// output report `0x03` carries them. Classic XInput's `XINPUT_VIBRATION` and evdev's `FF_RUMBLE` +/// have two members and no third, so every other producer passes `lt = rt = 0` — for those this is +/// a v2 datagram with four zero bytes on the end, which is exactly what the length tolerance is +/// for. +pub fn encode_rumble_datagram_v3( + pad: u16, + low: u16, + high: u16, + seq: u8, + ttl_ms: u16, + lt: u16, + rt: u16, +) -> [u8; RUMBLE_V3_LEN] { + let mut b = [0u8; RUMBLE_V3_LEN]; + b[..RUMBLE_V2_LEN].copy_from_slice(&encode_rumble_datagram_v2(pad, low, high, seq, ttl_ms)); + b[10..12].copy_from_slice(<.to_le_bytes()); + b[12..14].copy_from_slice(&rt.to_le_bytes()); + b +} + /// The self-termination tail of a v2 rumble envelope (see [`encode_rumble_datagram_v2`]). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RumbleEnvelope { @@ -174,17 +216,28 @@ pub struct RumbleEnvelope { /// A decoded rumble update. `envelope` is `None` for a legacy 7-byte datagram (an old host, which /// has no seq/ttl — the client applies its own staleness policy), `Some` for a v2 envelope. +/// +/// `left_trigger`/`right_trigger` are the Xbox impulse-trigger motors from a v3 datagram, on the +/// same `0..=0xFFFF` scale as `low`/`high`, and they are **plain fields, not `Option`** even though +/// only a v3 datagram carries them. A v1/v2 datagram decodes to `left_trigger = right_trigger = 0`. +/// The temptation is to mirror `envelope` so a consumer could tell "old host" from "new host, +/// triggers idle", but `Option` invites "absent → keep the previous value", and on a +/// level-triggered plane that is the stuck-rumble bug in a new costume: `0xCA` means *these are the +/// levels now*, so an absent field is zero. (`envelope` is genuinely optional because its absence +/// selects a different *policy*, not a different level.) #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RumbleUpdate { pub pad: u16, pub low: u16, pub high: u16, + pub left_trigger: u16, + pub right_trigger: u16, pub envelope: Option, } -/// Parse a rumble datagram → `(pad, low, high)`, tolerating (and ignoring) a v2 envelope tail. -/// `None` on bad tag/length. Kept for callers that only need the level (the probe, the loopback -/// assertions); clients that honor TTL use [`decode_rumble_envelope`]. +/// Parse a rumble datagram → `(pad, low, high)`, tolerating (and ignoring) the v2 envelope and v3 +/// trigger tails. `None` on bad tag/length. Kept for callers that only need the handle level (the +/// probe, the loopback assertions); clients that honor TTL use [`decode_rumble_envelope`]. pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> { if b.len() < RUMBLE_V1_LEN || b[0] != RUMBLE_MAGIC { return None; @@ -193,10 +246,15 @@ pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> { Some((u16at(1), u16at(3), u16at(5))) } -/// Parse a rumble datagram → [`RumbleUpdate`], detecting the v2 envelope tail by length. A -/// `>= RUMBLE_V2_LEN` buffer carries `seq`/`ttl_ms`; a 7..RUMBLE_V2_LEN buffer is a legacy level +/// Parse a rumble datagram → [`RumbleUpdate`], detecting each appended tail by length. A +/// `>= RUMBLE_V2_LEN` buffer carries `seq`/`ttl_ms`; a `>= RUMBLE_V3_LEN` buffer additionally +/// carries the two impulse-trigger levels; a 7..RUMBLE_V2_LEN buffer is a legacy level /// (`envelope: None`) — the same tolerance as an old client would apply, so a torn/short tail /// degrades to a level rather than dropping. `None` on bad tag/length. +/// +/// The one decoder for all three forms: v3 is not a separate wire, it is the same wire with more +/// of it present. Absent trigger bytes read as zero rather than "unchanged" — see +/// [`RumbleUpdate`] for why that is not negotiable on a level-triggered plane. pub fn decode_rumble_envelope(b: &[u8]) -> Option { if b.len() < RUMBLE_V1_LEN || b[0] != RUMBLE_MAGIC { return None; @@ -206,10 +264,13 @@ pub fn decode_rumble_envelope(b: &[u8]) -> Option { seq: b[7], ttl_ms: u16::from_le_bytes([b[8], b[9]]), }); + let triggers = b.len() >= RUMBLE_V3_LEN; Some(RumbleUpdate { pad: u16at(1), low: u16at(3), high: u16at(5), + left_trigger: if triggers { u16at(10) } else { 0 }, + right_trigger: if triggers { u16at(12) } else { 0 }, envelope, }) } @@ -1196,6 +1257,8 @@ mod tests { pad: 2, low: 0x4000, high: 0x8000, + left_trigger: 0, + right_trigger: 0, envelope: Some(RumbleEnvelope { seq: 7, ttl_ms: 400 @@ -1215,6 +1278,8 @@ mod tests { pad: 3, low: 0x1111, high: 0x2222, + left_trigger: 0, + right_trigger: 0, envelope: None, }) ); @@ -1237,6 +1302,90 @@ mod tests { assert!(decode_rumble_envelope(&wrong_tag).is_none()); } + /// v3 (design/trigger-rumble-plane.md §4) is the v2 envelope with the two impulse-trigger + /// levels appended, and the prefix discipline the 0xCF plane uses three times over holds here + /// too: the first 10 bytes must be byte-identical to what v2 would have produced, or the + /// envelope a v2-era client reads is displaced and every TTL/seq guarantee on this plane + /// silently changes meaning. + #[test] + fn rumble_v3_roundtrips_and_keeps_the_v2_envelope_in_place() { + let v2 = encode_rumble_datagram_v2(2, 0x4000, 0x8000, 7, 400); + let v3 = encode_rumble_datagram_v3(2, 0x4000, 0x8000, 7, 400, 0x1234, 0xFFFF); + assert_eq!(v3.len(), RUMBLE_V3_LEN); + assert_eq!(&v3[..RUMBLE_V2_LEN], &v2[..], "v2 is a strict prefix of v3"); + // The exact tail layout, LE, pinned as bytes: an endianness slip here reads a 0x1234 + // trigger as 0x3412 and is invisible in a round-trip that uses the same encoder both ways. + assert_eq!(&v3[10..14], &[0x34, 0x12, 0xFF, 0xFF]); + assert_eq!( + decode_rumble_envelope(&v3), + Some(RumbleUpdate { + pad: 2, + low: 0x4000, + high: 0x8000, + left_trigger: 0x1234, + right_trigger: 0xFFFF, + envelope: Some(RumbleEnvelope { + seq: 7, + ttl_ms: 400 + }), + }) + ); + // A trigger-only rumble (racing titles drive the triggers hard and the handles not at all) + // is expressible and survives the trip with the handles at rest. + let trig_only = encode_rumble_datagram_v3(0, 0, 0, 3, 400, 0x8000, 0); + let u = decode_rumble_envelope(&trig_only).unwrap(); + assert_eq!((u.low, u.high), (0, 0)); + assert_eq!((u.left_trigger, u.right_trigger), (0x8000, 0)); + assert_eq!(u.envelope.unwrap().ttl_ms, 400); + } + + /// Cross-version tolerance, both directions — the compatibility table in + /// design/trigger-rumble-plane.md §5, as code. + #[test] + fn rumble_v3_and_v2_parse_each_others_datagrams() { + let v3 = encode_rumble_datagram_v3(1, 0x1111, 0x2222, 9, 250, 0xAAAA, 0xBBBB); + + // NEW host → OLD client: the v2-era readers see exactly what they saw before. The level + // decoder ignores both tails; the envelope decoder reads the same seq/ttl off bytes 7..10. + assert_eq!(decode_rumble_datagram(&v3), Some((1, 0x1111, 0x2222))); + assert_eq!( + decode_rumble_envelope(&v3).unwrap().envelope, + Some(RumbleEnvelope { + seq: 9, + ttl_ms: 250 + }) + ); + + // OLD host → NEW client: v1 and v2 decode with the triggers SILENT, not "unchanged". + for (form, d) in [ + ("v1", encode_rumble_datagram(1, 0x1111, 0x2222).to_vec()), + ( + "v2", + encode_rumble_datagram_v2(1, 0x1111, 0x2222, 9, 250).to_vec(), + ), + ] { + let u = decode_rumble_envelope(&d).unwrap(); + assert_eq!( + (u.left_trigger, u.right_trigger), + (0, 0), + "{form} must decode to idle triggers" + ); + assert_eq!((u.pad, u.low, u.high), (1, 0x1111, 0x2222)); + } + + // A torn trigger tail (11..14 bytes — the host never emits these, a truncating middlebox + // might) degrades to the v2 decode rather than reading half a level: a 13-byte buffer must + // not surface `rt` from one byte of it. + let v2 = decode_rumble_envelope(&encode_rumble_datagram_v2(1, 0x1111, 0x2222, 9, 250)); + for n in RUMBLE_V2_LEN..RUMBLE_V3_LEN { + assert_eq!( + decode_rumble_envelope(&v3[..n]), + v2, + "partial trigger tail ({n} B) must degrade to the v2 decode" + ); + } + } + #[test] fn rumble_envelope_seq_gate_drops_reordered_stale_start() { use crate::input::GamepadSnapshot; diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 47509241..269c76ea 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -270,8 +270,10 @@ pub fn switchpro_test(args: &[String]) -> Result<()> { let (mut i, mut last_write) = (0i32, Instant::now()); while Instant::now() < deadline { let fb = pad.service(0); - if let Some((low, high)) = fb.rumble { - println!(" rumble from kernel/game: low={low} high={high}"); + // `lt`/`rt` are structurally always zero here — a Switch Pro has no trigger motors — + // but this harness reads the shared `PadFeedback`, so it prints all four levels. + if let Some((low, high, lt, rt)) = fb.rumble { + println!(" rumble from kernel/game: low={low} high={high} lt={lt} rt={rt}"); } for o in fb.hidout { println!(" hid output from kernel/game: {o:?}"); @@ -397,7 +399,9 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { let (mut i, mut last) = (0i32, Instant::now()); while Instant::now() < deadline { mgr.pump( - |pad, lo, hi| println!(" rumble from game: pad={pad} low={lo} high={hi}"), + |pad, lo, hi, lt, rt| println!( + " rumble from game: pad={pad} low={lo} high={hi} lt={lt} rt={rt}" + ), |o| println!(" hid output from game: {o:?}"), ); if last.elapsed() >= Duration::from_millis(400) { @@ -442,8 +446,10 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { let deadline = Instant::now() + Duration::from_secs(secs); let mut t = 0i32; while Instant::now() < deadline { - mgr.pump_rumble(|pad, lo, hi| { - println!(" rumble from game: pad={pad} low={lo} high={hi}") + // `lt`/`rt` are structurally always zero on XUSB (see `pump_rumble`); printed so + // the harness output is comparable line-for-line with the HID Xbox backend's. + mgr.pump_rumble(|pad, lo, hi, lt, rt| { + println!(" rumble from game: pad={pad} low={lo} high={hi} lt={lt} rt={rt}") }); t += 1; let lx = (((t % 200) - 100) * 327).clamp(-32768, 32767) as i16; // sweep ±32700 diff --git a/crates/punktfunk-host/src/gamestream/control.rs b/crates/punktfunk-host/src/gamestream/control.rs index a2206fa1..bd23f550 100644 --- a/crates/punktfunk-host/src/gamestream/control.rs +++ b/crates/punktfunk-host/src/gamestream/control.rs @@ -255,7 +255,13 @@ pub fn spawn(state: Arc) -> Result<()> { hdr_sent = true; } } - pads.pump_rumble(|index, low, high| { + // The GameStream leg carries the handle motors only: Moonlight's + // trigger-rumble message (`ConnListenerRumbleTriggers`) is a separate + // control-stream id we have not read out of moonlight-common-c yet, and + // `low`/`high` here are already what `rumble_plaintext` (0x010B) encodes. + // The uinput backend cannot source triggers anyway (evdev `FF_RUMBLE` has + // two fields), so nothing is dropped today. + pads.pump_rumble(|index, low, high, _lt, _rt| { let pt = super::gamepad::rumble_plaintext(index, low, high); out.push(encrypt_control(&key, &scheme, host_seq, &pt)); host_seq = host_seq.wrapping_add(1); @@ -269,7 +275,7 @@ pub fn spawn(state: Arc) -> Result<()> { } } else { // No client/scheme yet: still answer FF uploads so games don't block. - pads.pump_rumble(|_, _, _| {}); + pads.pump_rumble(|_, _, _, _, _| {}); } // ENet needs frequent servicing for handshake/keepalive/retransmit. std::thread::sleep(Duration::from_millis(2)); diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index be01a1f9..578e6f51 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1452,9 +1452,14 @@ async fn serve_session( && std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1") { use punktfunk_core::quic::HidOutput; - // v2 envelope (seq 0, 400 ms TTL) so the loopback/probe assertion covers the self- - // terminating tail, not just the level. - let d = punktfunk_core::quic::encode_rumble_datagram_v2(0, 0x4000, 0x8000, 0, 400); + // v3 envelope (seq 0, 400 ms TTL, both impulse-trigger motors asserted) so the + // loopback/probe assertion covers the self-terminating tail AND the trigger tail behind + // it, not just the level. The trigger levels are deliberately DIFFERENT from each other + // and from the handles: a decoder that reads the wrong offset produces a plausible-looking + // number rather than a zero, so identical values would hide the mistake. + let d = punktfunk_core::quic::encode_rumble_datagram_v3( + 0, 0x4000, 0x8000, 0, 400, 0x2000, 0x6000, + ); let _ = conn.send_datagram(d.to_vec().into()); for h in [ HidOutput::Led { diff --git a/crates/punktfunk-host/src/native/gamepad.rs b/crates/punktfunk-host/src/native/gamepad.rs index 12c9ec15..b990a507 100644 --- a/crates/punktfunk-host/src/native/gamepad.rs +++ b/crates/punktfunk-host/src/native/gamepad.rs @@ -223,26 +223,41 @@ fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref { /// Whether an Xbox-family pad should be built as a real **HID** device /// ([`crate::inject::xbox_windows`]) instead of the **XUSB** companion -/// ([`crate::inject::gamepad`]). Windows only; `PUNKTFUNK_XBOX_BACKEND=hid` opts in. +/// ([`crate::inject::gamepad`]). Windows only. **HID is the default**; set +/// `PUNKTFUNK_XBOX_BACKEND=xusb` to go back to the companion. /// -/// **Why this is a knob and not simply the new default.** The XUSB companion registers only -/// `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi enumeration, -/// DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all — only classic `XInputGetState` -/// via xinput1_4's interface walk does. That is what left a reporter with a dead controller for two -/// weeks (2026-08-09) until they switched the client to DualSense, a real HID pad. +/// **Why HID is now the default.** The XUSB companion registers only `GUID_DEVINTERFACE_XUSB` and +/// exposes no HID collection, so Steam's hidapi enumeration, DirectInput, `joy.cpl` and +/// WGI/GameInput cannot see it at all — only classic `XInputGetState` via xinput1_4's interface walk +/// does. That is what left a reporter with a dead controller for two weeks (2026-08-09) until they +/// switched the client to DualSense, a real HID pad. /// -/// But the converse is not yet proven: classic-XInput games DO read the XUSB pad today, and whether -/// Windows promotes our HID pad into an Xbox-profile device that XInput and WGI `Gamepad` accept is -/// exactly the open question. Until that is settled on glass, flipping the default would trade a -/// known-working path for an unproven one. Opt in, measure, then decide. +/// This was an opt-in knob for exactly one reason: the HID pad could not reach classic XInput, so +/// defaulting to it would have traded a known-working path for an unproven one. **That objection is +/// gone.** `pf_gamepad.inx`'s `pfGamepadXbox` section now attaches the `xinputhid` bus filter +/// (`UpperFilters` + `DevicePropertyFlags=1`), and with it the HID pad is promoted exactly like real +/// hardware: measured on `.173` 2026-08-09 it gains the `IG_00` token and an XUSB interface, classic +/// XInput reads it live (full stick range and buttons), `XInputSetState` rumble round-trips, and it +/// keeps everything the XUSB companion never had — Steam, SDL, RawInput, DirectInput, `joy.cpl`. +/// ⇒ the HID backend is now a **superset** of the XUSB one, which is the condition the old comment +/// set for flipping. +/// +/// ⚠️ `xusb` stays as an escape hatch because the promotion depends on Microsoft's inbox +/// `xinputhid.inf` and its hardware-id allow-list. If a Windows servicing update changes that, or a +/// box has a third-party filter on the stack, one env var restores the previous behaviour without a +/// reinstall. /// /// The two backends are mutually exclusive per pad by construction (one match arm or the other) — /// presenting both would hand a game two controllers for one pair of hands. #[cfg(target_os = "windows")] pub(super) fn windows_xbox_hid() -> bool { - std::env::var("PUNKTFUNK_XBOX_BACKEND") - .map(|v| v.trim().eq_ignore_ascii_case("hid")) - .unwrap_or(false) + match std::env::var("PUNKTFUNK_XBOX_BACKEND") { + Ok(v) if v.trim().eq_ignore_ascii_case("xusb") => false, + // Anything else — unset, empty, "hid", or a typo — takes the default. A misspelled opt-out + // silently landing on the OLD path is the worse failure: it is invisible, and it is the + // path with no HID collection. + _ => true, + } } /// Resolve the client's gamepad-backend preference (the env/logging shell around diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index 3a1a56bd..bf0d4fc7 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -298,10 +298,11 @@ impl Pads { .steamdeck_win .get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new) .handle(ev), - // The Xbox pad, as a real HID device rather than the XUSB companion. Opt-in for now - // (see `windows_xbox_hid`): XUSB is what classic-XInput games read today, and this - // trades that for the Steam / WGI / GameInput / DirectInput visibility XUSB can never - // have — a swap that has to be proven on glass before it becomes the default. + // The Xbox pad, as a real HID device rather than the XUSB companion. This is now the + // DEFAULT (see `windows_xbox_hid`; `PUNKTFUNK_XBOX_BACKEND=xusb` reverts it). It is no + // longer a trade: with the `xinputhid` bus filter the INF attaches, the HID pad keeps + // classic XInput AND gains everything XUSB never had — Steam, SDL, RawInput, + // DirectInput, `joy.cpl`, WGI — plus rumble, which the XUSB path could not source. #[cfg(target_os = "windows")] GamepadPref::Xbox360 | GamepadPref::XboxOne if super::gamepad::windows_xbox_hid() => { self.xbox_hid @@ -425,12 +426,17 @@ impl Pads { } /// Service feedback for every instantiated backend each cycle. `rumble` carries motor - /// force-feedback on the universal plane (every backend, tagged with its own pad index); - /// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF - /// pads. The `&mut` closure re-borrows satisfy `FnMut` for each backend. + /// force-feedback on the universal plane (every backend, tagged with its own pad index) as + /// `(pad, low, high, left_trigger, right_trigger)`; `hidout` carries rich feedback (lightbar / + /// player LEDs / adaptive triggers) for the UHID/UMDF pads. The `&mut` closure re-borrows + /// satisfy `FnMut` for each backend. + /// + /// Only the Windows HID Xbox backend (`xbox_hid`) can ever report non-zero trigger levels — no + /// other backend's source packet has a field for them (see `PadFeedback::rumble`), so they pass + /// zeros and the v3 datagram they produce is a v2 datagram with a zero tail. fn pump( &mut self, - mut rumble: impl FnMut(u16, u16, u16), + mut rumble: impl FnMut(u16, u16, u16, u16, u16), mut hidout: impl FnMut(punktfunk_core::quic::HidOutput), ) { if let Some(m) = &mut self.xbox360 { @@ -753,26 +759,58 @@ const RUMBLE_STOP_BURST: u8 = 2; /// life of the connection because the client gates on it with a wrapping half-space compare and /// never resets its side (`punktfunk-core/src/client/pump/datagram_task.rs`). Resetting it here is /// the bug pinned by [`tests::rumble_seq_survives_a_removal_so_the_client_gate_accepts`]. -fn clear_pad_feedback(state: &mut (u16, u16), seen: &mut bool, stop_burst: &mut u8) { - *state = (0, 0); +fn clear_pad_feedback(state: &mut RumbleLevels, seen: &mut bool, stop_burst: &mut u8) { + *state = (0, 0, 0, 0); *seen = false; *stop_burst = 0; } +/// One pad's four motor levels as the 0xCA plane orders them: +/// `(low, high, left_trigger, right_trigger)`, all `0..=0xFFFF`. Kept as one value rather than four +/// parallel arrays because they are a single statement of the pad's feedback state at one instant — +/// the same reason they share one `seq` and one TTL on the wire. +type RumbleLevels = (u16, u16, u16, u16); + +/// Is this pad's feedback fully silent? **All four** motors, and that is the whole point of it +/// being a named predicate rather than an inline comparison repeated at each site. +/// +/// Every "is this pad quiet?" decision in the rumble path routes through here: whether to log the +/// silent→active transition, whether to arm the post-stop burst, and — the one that decides +/// whether the feature works at all — whether the envelope gets a live TTL or the `0` that means +/// *stop*. Written as a two-field test, a trigger-only rumble (the normal shape of +/// impulse-trigger content: racing titles drive the triggers continuously while the handles stay +/// near-silent) is stamped `ttl = 0`, the client reads an already-expired lease and silences on +/// arrival, and nothing anywhere logs an error. See +/// [`tests::a_trigger_only_rumble_gets_a_live_ttl`]. +fn rumble_silent(lv: RumbleLevels) -> bool { + lv == (0, 0, 0, 0) +} + /// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating -/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the -/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram. +/// v3 form (`[level][seq][ttl_ms][trigger levels]`, the default) or the legacy v1 level datagram +/// (the `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram. +/// +/// v3 goes out **unconditionally** while the envelope is on — not "only when a trigger level is +/// non-zero". A wire form that depends on history is how you get a bug that reproduces only after a +/// specific sequence of events, and the four extra bytes cost nothing: a client that predates v3 +/// reads the 10-byte prefix and ignores them. +/// +/// ⚠️ The bisect hatch drops to v1, which takes trigger rumble down with it (v1 has no tail at +/// all). That is correct for a hatch whose job is to reproduce the pre-envelope wire, but it means +/// "trigger rumble stopped working" is an expected symptom of setting it — do not bisect a trigger +/// bug into this hatch and conclude the hatch fixed it. fn send_rumble( conn: &quinn::Connection, envelope_on: bool, pad: u16, - low: u16, - high: u16, + lv: RumbleLevels, seq: u8, ttl_ms: u16, ) { + let (low, high, lt, rt) = lv; let d: Vec = if envelope_on { - punktfunk_core::quic::encode_rumble_datagram_v2(pad, low, high, seq, ttl_ms).to_vec() + punktfunk_core::quic::encode_rumble_datagram_v3(pad, low, high, seq, ttl_ms, lt, rt) + .to_vec() } else { punktfunk_core::quic::encode_rumble_datagram(pad, low, high).to_vec() }; @@ -789,11 +827,14 @@ fn send_rumble( /// the session; the pointer/keyboard injector (and its portal grant) lives in the service, /// across sessions. /// -/// Rumble is emitted as self-terminating 0xCA v2 envelopes (`[level][seq][ttl_ms]`): the host owns -/// the timeline, renewing an active level every ~`RUMBLE_TTL_MS × 3/10` ms and letting an -/// abandoned one expire client-side, so "stuck rumble" is inexpressible on the wire (see -/// `punktfunk-planning/design/rumble-envelope-plan.md`). `PUNKTFUNK_RUMBLE_ENVELOPE=0` reverts to -/// legacy v1 level datagrams + the flat 500 ms refresh (bisect hatch). +/// Rumble is emitted as self-terminating 0xCA v3 envelopes +/// (`[level][seq][ttl_ms][trigger levels]`): the host owns the timeline, renewing an active level +/// every ~`RUMBLE_TTL_MS × 3/10` ms and letting an abandoned one expire client-side, so "stuck +/// rumble" is inexpressible on the wire (see `punktfunk-planning/design/rumble-envelope-plan.md` +/// and `design/trigger-rumble-plane.md`). The four motors share one `seq` and one TTL, so the +/// trigger pair inherits the whole envelope apparatus unchanged. +/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` reverts to legacy v1 level datagrams + the flat 500 ms refresh +/// (bisect hatch — which drops trigger rumble with it, see [`send_rumble`]). pub(super) fn input_thread( rx: std::sync::mpsc::Receiver, conn: quinn::Connection, @@ -814,14 +855,20 @@ pub(super) fn input_thread( // Last applied snapshot seq per pad (`None` until the first one): the reorder gate for // `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back. let mut pad_seq: [Option; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS]; - // Rumble self-terminating envelopes (0xCA v2). Each non-zero level is authorized for + // Rumble self-terminating envelopes (0xCA v3). Each non-zero level is authorized for // `rumble_ttl_ms`; the host renews an active pad every `rumble_renew` and lets an abandoned // one expire on the client, so a dropped transition heals on the next renewal and a stop that // is lost heals via the stop burst (or the client's own TTL expiry). `rumble_seq` is the // per-pad wrapping reorder counter (bumped on changes AND renewals) the client gates on; // `rumble_stop_burst` counts the post-stop zero re-sends still owed. `PUNKTFUNK_RUMBLE_ENVELOPE=0` // reverts to legacy v1 datagrams re-sent flat every 500 ms. - let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS]; + // + // `rumble_state` holds ALL FOUR levels (see `RumbleLevels`), and every "is this pad silent?" + // test below is an all-four-zero test for one specific reason: a trigger-only rumble — the + // normal shape of impulse-trigger content, since racing titles drive the triggers continuously + // against near-silent handles — would otherwise be stamped `ttl = 0`, which the client reads as + // an instantly-expired lease. That is trigger rumble that never plays, with no error anywhere. + let mut rumble_state = [(0u16, 0u16, 0u16, 0u16); MAX_WIRE_PADS]; let mut rumble_seen = [false; MAX_WIRE_PADS]; let mut rumble_seq = [0u8; MAX_WIRE_PADS]; let mut rumble_stop_burst = [0u8; MAX_WIRE_PADS]; @@ -1036,43 +1083,50 @@ pub(super) fn input_thread( // EVIOCSFF, and HID handshakes must be answered promptly). Rumble → the universal 0xCA // plane; rich/raw HID feedback → 0xCD. pads.pump( - |pad, low, high| { + |pad, low, high, lt, rt| { + let lv: RumbleLevels = (low, high, lt, rt); + let silent = rumble_silent(lv); let idx = pad as usize; if idx < MAX_WIRE_PADS { let prev = rumble_state[idx]; // Log the silent→active transition (once per buzz) so a live test can tell // "host never gets rumble from the game" apart from "client doesn't render it". - if prev == (0, 0) && (low != 0 || high != 0) { - tracing::debug!(pad, low, high, "rumble: forwarding to client (0xCA)"); + // It carries `lt`/`rt` because it is the attribution line for exactly the + // trigger case too — without them a "triggers never buzzed" report cannot be + // split into "the host never saw them" and "the client never rendered them". + if rumble_silent(prev) && !silent { + tracing::debug!( + pad, + low, + high, + lt, + rt, + "rumble: forwarding to client (0xCA)" + ); } - rumble_state[idx] = (low, high); + rumble_state[idx] = lv; rumble_seen[idx] = true; // Bump the reorder counter on every change, then arm the stop burst on a // transition to zero (so a lost stop still reaches a legacy client) and clear // it when the game re-asserts a non-zero level. rumble_seq[idx] = rumble_seq[idx].wrapping_add(1); - if (low, high) == (0, 0) { - rumble_stop_burst[idx] = if prev != (0, 0) { RUMBLE_STOP_BURST } else { 0 }; + if silent { + rumble_stop_burst[idx] = if !rumble_silent(prev) { + RUMBLE_STOP_BURST + } else { + 0 + }; } else { rumble_stop_burst[idx] = 0; } - let ttl = if (low, high) == (0, 0) { - 0 - } else { - rumble_ttl_ms - }; - send_rumble( - &conn, - rumble_envelope_on, - pad, - low, - high, - rumble_seq[idx], - ttl, - ); + // A pad with ANY of its four motors asserted gets a live lease. Testing only + // `(low, high)` here would stamp a trigger-only rumble `ttl = 0` — an + // already-expired lease the client silences on arrival. + let ttl = if silent { 0 } else { rumble_ttl_ms }; + send_rumble(&conn, rumble_envelope_on, pad, lv, rumble_seq[idx], ttl); } else { // Out-of-range pad (a backend never produces these) — forward without gating. - send_rumble(&conn, rumble_envelope_on, pad, low, high, 0, rumble_ttl_ms); + send_rumble(&conn, rumble_envelope_on, pad, lv, 0, rumble_ttl_ms); } }, |h| { @@ -1092,27 +1146,21 @@ pub(super) fn input_thread( if !rumble_seen[i] { continue; } - let (low, high) = rumble_state[i]; - if (low, high) != (0, 0) { + let lv = rumble_state[i]; + if !rumble_silent(lv) { rumble_seq[i] = rumble_seq[i].wrapping_add(1); - send_rumble( - &conn, - true, - i as u16, - low, - high, - rumble_seq[i], - rumble_ttl_ms, - ); + send_rumble(&conn, true, i as u16, lv, rumble_seq[i], rumble_ttl_ms); } else if rumble_stop_burst[i] > 0 { rumble_stop_burst[i] -= 1; rumble_seq[i] = rumble_seq[i].wrapping_add(1); - send_rumble(&conn, true, i as u16, 0, 0, rumble_seq[i], 0); + send_rumble(&conn, true, i as u16, (0, 0, 0, 0), rumble_seq[i], 0); } } } else { - // Legacy: re-send the current level of every seen pad every 500 ms (v1). - for (i, &(low, high)) in rumble_state.iter().enumerate() { + // Legacy: re-send the current level of every seen pad every 500 ms (v1). The + // trigger levels are dropped here by construction — v1 has no tail (see + // `send_rumble`). + for (i, &(low, high, _, _)) in rumble_state.iter().enumerate() { if rumble_seen[i] { let d = punktfunk_core::quic::encode_rumble_datagram(i as u16, low, high); let _ = conn.send_datagram(d.to_vec().into()); @@ -1281,11 +1329,12 @@ mod tests { assert_eq!(gate, Some(100)); // The pad is unplugged mid-buzz: the lease is cleared, the counter is not. - let (mut state, mut seen, mut burst) = ((0x1234u16, 0x5678u16), true, RUMBLE_STOP_BURST); + let (mut state, mut seen, mut burst) = + ((0x1234, 0x5678, 0x9ABC, 0xDEF0), true, RUMBLE_STOP_BURST); clear_pad_feedback(&mut state, &mut seen, &mut burst); assert_eq!( (state, seen, burst), - ((0, 0), false, 0), + ((0, 0, 0, 0), false, 0), "lease not cleared" ); @@ -1327,4 +1376,49 @@ mod tests { assert_eq!(s.left_trigger, 255); assert!(!s.apply(&gp(InputKind::GamepadAxis, 42, 1, 0))); } + + /// The single most likely way to ship trigger rumble broken (design/trigger-rumble-plane.md + /// §5): a rumble that drives ONLY the impulse triggers must still get a live lease. + /// + /// The pre-existing silence test was `(low, high) == (0, 0)`, and a trigger-only level passes + /// it. Stamped `ttl = 0`, the envelope reaches the client as an already-expired lease, which + /// it silences on arrival — trigger rumble that never plays, with no error on either side. + /// Drives the real predicate and the real encoder/decoder pair, so it fails if either moves. + #[test] + fn a_trigger_only_rumble_gets_a_live_ttl() { + use punktfunk_core::quic::{decode_rumble_envelope, encode_rumble_datagram_v3}; + + // What a racing title's impulse-trigger stream looks like: handles at rest throughout. + let trigger_only: RumbleLevels = (0, 0, 0x8000, 0); + assert!( + !rumble_silent(trigger_only), + "a trigger-only level was read as silence — the ttl=0 trap" + ); + let ttl = if rumble_silent(trigger_only) { + 0 + } else { + RUMBLE_TTL_MS + }; + let d = encode_rumble_datagram_v3(0, 0, 0, 1, ttl, trigger_only.2, trigger_only.3); + let u = decode_rumble_envelope(&d).expect("v3 envelope decodes"); + assert_eq!( + u.envelope.expect("v3 carries the v2 tail").ttl_ms, + RUMBLE_TTL_MS, + "trigger-only rumble was stamped with a dead lease" + ); + assert_eq!((u.left_trigger, u.right_trigger), (0x8000, 0)); + assert_eq!((u.low, u.high), (0, 0), "handles stay at rest"); + + // The reserved stop is still expressible, and is still the ONLY thing that gets ttl = 0. + assert!(rumble_silent((0, 0, 0, 0))); + for lv in [ + (1, 0, 0, 0), + (0, 1, 0, 0), + (0, 0, 1, 0), + (0, 0, 0, 1), + (0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF), + ] { + assert!(!rumble_silent(lv), "{lv:?} must not read as a stop"); + } + } } diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 079fccd8..8aea2f81 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -1196,6 +1196,15 @@ #define PUNKTFUNK_RUMBLE_V2_LEN 10 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Wire length of a v3 (envelope + impulse-trigger motors) rumble datagram — the v2 form plus a +// `[u16 left_trigger LE][u16 right_trigger LE]` tail (see [`encode_rumble_datagram_v3`]). Second +// use of the same append-extension the v2 tail introduced, and for the same reason: every reader +// on this plane gates with `>=`, so a 14-byte datagram satisfies the v1 predicate (level only), +// the v2 predicate (level + envelope) and this one, and each peer takes the prefix it knows. +#define PUNKTFUNK_RUMBLE_V3_LEN 14 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Longest raw HID report a [`RichInput::HidReport`] / [`HidOutput::HidRaw`] can carry — the // 64-byte interrupt/feature report size every Valve controller uses (Triton input reports are -- 2.54.0 From 4f9071b9800ab58a5cc30d7bd3ffe7b17abf4281 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 21:41:53 +0200 Subject: [PATCH 12/16] =?UTF-8?q?feat(pads/windows):=20three=20Xbox=20iden?= =?UTF-8?q?tities=20=E2=80=94=20Wireless,=20One=20S=20and=20Elite=20Series?= =?UTF-8?q?=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now there was one Xbox identity, `device_type = 4` / `045E:0B13`, and Windows folded a client's `XboxOne` request onto it because the only Windows Xbox backend was the XUSB companion, which presents one fixed 360 identity and cannot vary it. The HID backend can, so the fold goes and two identities join it: devtype 4 045E:0B13 pf_xboxwireless Xbox Wireless Controller devtype 5 045E:02FD pf_xboxones Xbox Wireless Controller (One S) devtype 6 045E:0B22 pf_xboxelite Xbox Elite Wireless Controller Series 2 `GamepadPref::XboxElite` takes wire byte 11 — the first unassigned one, and the round-trip test previously asserted `from_u8(11) == Auto` with a comment saying assigning it must update that; the sentinel moved to 12. The C ABI mirror and the generated header moved with it. ⭐ ALL THREE SHARE ONE REPORT DESCRIPTOR, deliberately. In HID terms they are the same pad; the descriptor is the report shape, not the identity. §3 of the handoff records that our single hand-written descriptor already cost three separate bugs, and inventing two more would multiply that debt for no measured gain. They differ in VID/PID, product string, hardware id and Device Manager description only. ⚠️ All three install `pfGamepadXbox`, the section that attaches the `xinputhid` bus filter. That was the open risk: Microsoft's `xinputhid.inf` promotes by an explicit hardware-id allow-list containing `02D1, 02DD, 02E3, 02EA, 0B00, 0B0A, 0B13, 02FF` — and NEITHER `02FD` NOR `0B22` is on it. Measured on .173: promotion does not care, because it comes from our own AddReg rather than from matching Microsoft's ids. All three gain `IG_00`, register an XUSB interface, and are read live by classic XInput. Had this gone the other way the two new identities would have been strictly worse than the one they joined. The XUSB escape hatch needed a runtime degrade to stay honest. `pick_gamepad` is compile-time only, so with `PUNKTFUNK_XBOX_BACKEND=xusb` the host would have resolved and echoed `xboxelite` in its `Welcome` while actually building a 360 pad. `degrade_xbox_identity` folds the identity back at runtime, mirroring `degrade_if_no_uhid`. VERIFIED ON WINDOWS (.173 — none of this compiles on macOS; the driver needs the WDK and the rest is `cfg(windows)`): * `cargo test -p pf-inject --lib` 104/104 — including `hwid_matches_inf`, `hwid_devtype_table_matches_the_driver` and `only_the_xbox_identity_installs_the_xinputhid_section`, all now sweeping the whole identity set and asserting the section split in both directions. * `cargo test -p punktfunk-core --lib gamepad` 7/7; `cargo check -p punktfunk-host` clean. * Driver builds and signs; the descriptor/`wReportLength` const asserts still hold with the descriptor shared three ways. * ON GLASS, per identity, via the new `--xboxones` / `--xboxelite` devtest legs: each gets its own devnode (`PF_XBOX_0` / `PF_XBOX_ONES_0` / `PF_XBOX_ELITE_0`), each HID child gains `IG_00`, each registers an XUSB interface, and XInput reads each live (packets advancing, `buttons=0x1000`). * macOS: `cargo fmt --all --check` clean in both workspaces. NOT VERIFIED / NOT DONE * **Elite paddles are NOT implemented.** `BTN_PADDLE1..4` would need descriptor buttons, and once `xinputhid` promotes the pad it claims the HID collection exclusively — XInput has no paddle fields and the HID consumers that do may be locked out, so the buttons would likely reach nobody. The decisive measurement is cheap and named in the code: hold a paddle bit set and see whether a user-mode HID reader still gets reports. Until then the Edge remains the only virtual pad with native back-button slots and nothing should be advertised otherwise. * **No client picker offers the Elite**, and none can auto-detect it — SDL3's `GamepadType` has no Elite variant. It is reachable today only via `PUNKTFUNK_GAMEPAD=xboxelite` or a hand-edited client setting. All five clients ship the same curated six options by deliberate parity, so adding one is a cross-client UX change, not part of this. * Nothing here has run in a real streaming session; every measurement came from the devtest. --- crates/pf-client-core/src/gamepad.rs | 3 + crates/pf-driver-proto/src/lib.rs | 18 ++ .../src/inject/windows/dualsense_windows.rs | 78 ++++--- .../src/inject/windows/xbox_windows.rs | 192 +++++++++++++++--- crates/pf-inject/src/lib.rs | 13 +- crates/punktfunk-core/src/abi.rs | 6 + crates/punktfunk-core/src/config.rs | 71 ++++++- crates/punktfunk-host/src/devtest.rs | 22 ++ crates/punktfunk-host/src/native/gamepad.rs | 74 ++++++- crates/punktfunk-host/src/native/input.rs | 64 +++++- include/punktfunk_core.h | 6 + .../windows/drivers/pf-gamepad/pf_gamepad.inx | 33 ++- .../windows/drivers/pf-gamepad/src/lib.rs | 116 ++++++++--- 13 files changed, 572 insertions(+), 124 deletions(-) diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index aa6b4ad7..78200f10 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -276,6 +276,9 @@ impl PadInfo { GamepadPref::DualSenseEdge => "DualSense Edge", GamepadPref::DualShock4 => "DualShock 4", GamepadPref::XboxOne => "Xbox One", + // Unreachable from `pref_for_type` today — SDL has no Elite `GamepadType` — but a + // pinned setting can carry it, and an empty label there reads as a plain Xbox pad. + GamepadPref::XboxElite => "Xbox Elite Series 2", GamepadPref::SteamDeck => "Steam Deck", GamepadPref::SteamController => "Steam Controller", GamepadPref::SteamController2 => "Steam Controller 2", diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 4e2c8cc9..48d3813b 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -804,6 +804,24 @@ pub mod gamepad { /// `XBOX_INPUT_REPORT_LEN` (16). The driver serves per-identity report lengths because /// hidclass sizes its buffer from the descriptor and refuses an over-long source. pub const DEVTYPE_XBOX: u8 = 4; + /// `device_type` = Xbox One S controller over Bluetooth (`VID_045E&PID_02FD`). + /// + /// ⭐ **Shares [`DEVTYPE_XBOX`]'s report descriptor, byte for byte.** All three Xbox identities + /// are the same pad in HID terms — same axes, same trigger pair, same hat, same 15 buttons, + /// same rumble output report — and differ ONLY in VID/PID, product string and INF model line. + /// The descriptor is the report SHAPE; the identity is what the OS keys mappings off. Giving + /// each identity its own hand-written descriptor would triple a debt that has already cost + /// three separate bugs (see the `XBOX_RDESC` provenance block in the driver). + pub const DEVTYPE_XBOX_ONE_S: u8 = 5; + /// `device_type` = Xbox Elite Wireless Controller Series 2 (`VID_045E&PID_0B22`) — the + /// hardware `tools/hid-descriptor-dump` captured on `.173`. + /// + /// ⚠️ The four paddles are NOT in this identity's report yet. See [`DEVTYPE_XBOX_ONE_S`] for + /// why the descriptor is shared, and `design/xbox-pad-windows-handoff.md` §4 WP-C for the + /// unresolved tension: once the pad is promoted, `xinputhid` claims the HID collection + /// exclusively, so extra buttons declared here may be invisible to every consumer anyway. + /// That needs measuring before it is built. + pub const DEVTYPE_XBOX_ELITE: u8 = 6; /// The value a gamepad driver writes into its section's `driver_proto` field once it attaches — /// the host's positive "driver is alive on this section" signal (health check + version audit). diff --git a/crates/pf-inject/src/inject/windows/dualsense_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_windows.rs index 7ae6e69c..340c1f5a 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_windows.rs @@ -1031,8 +1031,15 @@ mod drain_tests { WinDsIdentity::dualsense_edge().hwid, super::super::dualshock4_windows::DS4_HWID, super::super::steam_deck_windows::DECK_HWID, - super::super::xbox_windows::XBOX_HWID, - ] { + ] + .into_iter() + // Every Xbox identity, not just the first — a new one added to the table without its INF + // model line is exactly the "pad exists, never starts, never answers a proof" failure. + .chain( + super::super::xbox_windows::XBOX_IDENTITIES + .iter() + .map(|i| i.hwid), + ) { let want = hwid.to_ascii_lowercase(); let rooted = format!("root\\{want}"); assert!( @@ -1046,7 +1053,7 @@ mod drain_tests { } } - /// The Xbox identity must install its OWN section, and the PlayStation/Deck identities must + /// EVERY Xbox identity must install its OWN section, and the PlayStation/Deck identities must /// not install that one. /// /// `pfGamepadXbox` attaches Microsoft's `xinputhid` as an upper filter and sets @@ -1057,7 +1064,10 @@ mod drain_tests { /// exclusively and would take a working pad away from Steam and SDL. /// /// Merging the two sections back together is a one-line edit that looks like tidying and is - /// not, so assert the split rather than trusting a comment to survive. + /// not, so assert the split rather than trusting a comment to survive. Both directions matter, + /// and so does the count: a new Xbox identity whose model line was pasted from a PlayStation + /// one installs `pfGamepad`, enumerates perfectly, and is simply never promoted — a silent + /// half-failure that reads on glass as "XInput doesn't see it", the original field symptom. #[test] fn only_the_xbox_identity_installs_the_xinputhid_section() { let inx = concat!( @@ -1065,9 +1075,12 @@ mod drain_tests { "/../../packaging/windows/drivers/pf-gamepad/pf_gamepad.inx" ); let inf = std::fs::read_to_string(inx).expect("read pf_gamepad.inx"); - let xbox = super::super::xbox_windows::XBOX_HWID.to_ascii_lowercase(); + let xbox: Vec = super::super::xbox_windows::XBOX_IDENTITIES + .iter() + .map(|i| i.hwid.to_ascii_lowercase()) + .collect(); - let mut saw_xbox_model = false; + let mut seen: Vec<&str> = Vec::new(); for line in inf.lines().map(str::trim).filter(|l| !l.starts_with(';')) { let Some((_, rhs)) = line.split_once('=') else { continue; @@ -1083,28 +1096,36 @@ mod drain_tests { .split(',') .map(|i| i.trim().to_ascii_lowercase()) .collect(); - let mentions_xbox = ids.iter().any(|i| i.contains(&xbox)); - if mentions_xbox { - saw_xbox_model = true; - assert_ne!( - section, "pfGamepad", - "the Xbox model line installs the SHARED section, so the xinputhid filter \ - would be attached to every PlayStation and Deck pad too" - ); - } else { + // `contains`, not `==`: the model lines carry both the bare id and its `root\` twin. + let matched: Vec<&str> = xbox + .iter() + .filter(|x| ids.iter().any(|i| i.contains(x.as_str()))) + .map(String::as_str) + .collect(); + if matched.is_empty() { assert_eq!( section, "pfGamepad", "a non-Xbox model line ({ids:?}) installs {section:?}; if that section carries \ the xinputhid filter, this pad is about to be handed to Microsoft's Xbox \ translator" ); + } else { + seen.extend(matched); + assert_ne!( + section, "pfGamepad", + "an Xbox model line ({ids:?}) installs the SHARED section, so either the \ + xinputhid filter would be attached to every PlayStation and Deck pad too, or \ + this Xbox pad silently never gets promoted" + ); } } - assert!( - saw_xbox_model, - "no [Models] line mentions {xbox:?} — the parse went vacuous; fix it rather than \ - deleting the assert" - ); + for want in &xbox { + assert!( + seen.contains(&want.as_str()), + "no [Models] line mentions {want:?} — either the identity has no INF line at all, \ + or the parse went vacuous; fix that rather than deleting the assert" + ); + } } /// The driver reads its HID identity back off the same hardware id — that mapping is what @@ -1146,7 +1167,7 @@ mod drain_tests { .collect(); assert_eq!( entries.len(), - 5, + 7, "parsed {entries:?} out of the driver's table — the shape changed and this test went \ vacuous; fix the parse rather than deleting the assert" ); @@ -1173,11 +1194,16 @@ mod drain_tests { super::super::steam_deck_windows::DECK_HWID, pf_driver_proto::gamepad::DEVTYPE_STEAMDECK, ), - ( - super::super::xbox_windows::XBOX_HWID, - pf_driver_proto::gamepad::DEVTYPE_XBOX, - ), - ] { + ] + .into_iter() + // All three Xbox identities: they share a report descriptor, so a hwid→devtype slip does + // NOT show up as a mangled report the way the Deck's did — it shows up as the wrong PID and + // the wrong product string, i.e. an Elite that Steam maps as a Series X|S pad. + .chain( + super::super::xbox_windows::XBOX_IDENTITIES + .iter() + .map(|i| (i.hwid, i.devtype)), + ) { let want = hwid.to_ascii_lowercase(); let got = entries.iter().find(|(id, _)| *id == want); assert_eq!( diff --git a/crates/pf-inject/src/inject/windows/xbox_windows.rs b/crates/pf-inject/src/inject/windows/xbox_windows.rs index ef347e48..8098d45c 100644 --- a/crates/pf-inject/src/inject/windows/xbox_windows.rs +++ b/crates/pf-inject/src/inject/windows/xbox_windows.rs @@ -1,5 +1,6 @@ -//! Virtual Xbox Wireless Controller on Windows via the UMDF HID minidriver (device-type 4) — the -//! HID-visible alternative to [`super::gamepad_windows`]'s XUSB companion. +//! Virtual Xbox pads on Windows via the UMDF HID minidriver — Xbox Wireless (device-type 4), +//! Xbox One S (5) and Xbox Elite Series 2 (6), the HID-visible alternative to +//! [`super::gamepad_windows`]'s XUSB companion. //! //! **Why this exists.** `pf-xusb` registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID //! collection, so Steam's hidapi enumeration, DirectInput, `joy.cpl` and WGI/GameInput cannot see @@ -10,15 +11,16 @@ //! and install path the PlayStation pads already ship on. //! //! Transport is identical to the PS/Deck pads: a `SwDeviceCreate` devnode plus the sealed -//! shared-memory channel, with `device_type = 4` stamped before the magic so the driver resolves -//! the Xbox identity before hidclass asks it for descriptors. The codec is -//! [`super::xbox_proto`]; the report it writes mirrors the driver's `XBOX_RDESC` byte for byte. +//! shared-memory channel, with the identity's `device_type` stamped before the magic so the driver +//! resolves it before hidclass asks for descriptors. The codec is +//! [`super::xbox_proto`]; the report it writes mirrors the driver's `XBOX_RDESC` byte for byte — +//! **one descriptor, all three identities** (see `WinXboxIdentity` below). //! -//! ⚠️ **The synthesized USB identity is a BLUETOOTH Xbox pad (`045E:0B13`) on purpose.** The wired -//! ids the rest of the tree uses (`045E:028E` X-Box 360, `045E:02EA` Xbox One S USB) are -//! vendor-class XUSB/GIP devices that expose no HID interface on real hardware — a HID child -//! claiming one is a device that has never existed, and Windows' inbox promotion would have nothing -//! to match. See `pf-gamepad`'s `XBOX_PID` for the alternate to try if `0B13` is not promoted. +//! ⚠️ **Every synthesized USB identity here is a BLUETOOTH Xbox pad on purpose.** The wired ids the +//! rest of the tree uses (`045E:028E` X-Box 360, `045E:02EA` Xbox One S USB) are vendor-class +//! XUSB/GIP devices that expose no HID interface on real hardware — a HID child claiming one is a +//! device that has never existed, and Windows' inbox promotion would have nothing to match. The +//! Bluetooth ids (`0B13` / `02FD` / `0B22`) are the Xbox pads that genuinely ARE HID. //! //! ⚠️ **No rich plane.** An Xbox pad has no touchpad, no lightbar, no adaptive triggers and no //! IMU in its HID contract, so `apply_rich` / `clear_rich` / `neutralize_gyro` are deliberately @@ -36,14 +38,106 @@ use anyhow::Result; use punktfunk_core::quic::RichInput; use std::time::Duration; -/// 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). -pub(super) const XBOX_HWID: &str = "pf_xboxwireless"; +/// One of the Xbox identities this backend can present. Mirrors `WinDsIdentity` +/// (`super::dualsense_windows`) for the PlayStation family: the whole transport (section layout, +/// report codec, output parse, INF install section) is shared, and only the PnP identity plus the +/// `device_type` stamp differ. +/// +/// ⭐ **All three share ONE report descriptor in the driver** — `XBOX_RDESC`. In HID terms they are +/// the same pad: same stick pairs, same trigger pair, same hat, same 15 buttons, same rumble output +/// report. A descriptor is the report SHAPE; the identity is what SDL/Steam/Windows key their stock +/// mappings off, and that travels in the VID/PID below. See the `XBOX_RDESC` provenance block in +/// `packaging/windows/drivers/pf-gamepad/src/lib.rs` for why inventing two more hand-written +/// descriptors would be a net loss. +pub(super) struct WinXboxIdentity { + /// `device_type` stamped into the section — the driver picks its VID/PID and product string + /// off it, before hidclass asks anything. + pub devtype: u8, + /// PnP instance-id prefix — distinct namespaces per identity, so two Xbox models never reuse + /// the same devnode shell. + pub instance_prefix: &'static str, + /// The INF-matched hardware id. Must be one `pf_gamepad.inx` declares, on a model line that + /// installs `pfGamepadXbox` — a package rename must never touch it + /// (`dualsense_windows::tests::hwid_matches_inf` and + /// `only_the_xbox_identity_installs_the_xinputhid_section` enforce both halves). + pub hwid: &'static str, + /// The USB VID&PID token synthesized onto the devnode so hidclass derives the real-pad HID + /// child ids (`HID\VID_045E&PID_xxxx`) — the identity SDL/RawInput/WGI read, and the one + /// Windows' own Xbox INFs match when they decide whether to promote a HID gamepad. + pub usb_vid_pid: &'static str, + /// Device Manager description. + pub description: &'static str, +} -/// The USB VID&PID token synthesized onto the devnode so hidclass derives the real-pad HID child -/// ids (`HID\VID_045E&PID_0B13`) — the identity SDL/RawInput/WGI read, and the one Windows' own -/// Xbox INFs match when they decide whether to promote a HID gamepad to an Xbox-profile pad. -const XBOX_USB_VID_PID: &str = "VID_045E&PID_0B13"; +impl WinXboxIdentity { + /// Xbox Wireless Controller (Series X|S) over Bluetooth, `045E:0B13` — the default. + /// + /// ⭐ Its PID is on Microsoft's `xinputhid.inf` allow-list twice (measured on `.173`, + /// 2026-08-09). That is not what promotes OUR pad — a software devnode matches no allow-list + /// entry, so `pfGamepadXbox`'s `AddReg` writes the two registry values those sections would + /// have written — but it is why this identity, not one of the two below, stays the default. + pub(super) const fn wireless() -> WinXboxIdentity { + WinXboxIdentity { + devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX, + instance_prefix: "pf_xbox", + hwid: "pf_xboxwireless", + usb_vid_pid: "VID_045E&PID_0B13", + description: "Punktfunk Virtual Xbox Wireless Controller", + } + } + + /// Xbox One S controller over Bluetooth, `045E:02FD`. + /// + /// ⚠️ `02FD` appears in `xinputhid.inf` only as a `BTHENUM` (classic-BT bus) id — it has **no** + /// stage-2 `HID\…&IG_00` model line, unlike `0B13`. Promotion here rides entirely on our own + /// `AddReg`, so it should behave identically; but if a servicing update ever makes promotion + /// depend on Microsoft's list again, this is the identity that loses it first. UNVERIFIED on + /// glass. + pub(super) const fn one_s() -> WinXboxIdentity { + WinXboxIdentity { + devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX_ONE_S, + instance_prefix: "pf_xbox_ones", + hwid: "pf_xboxones", + usb_vid_pid: "VID_045E&PID_02FD", + description: "Punktfunk Virtual Xbox One S Controller", + } + } + + /// Xbox Elite Wireless Controller Series 2, `045E:0B22` — the pad + /// `tools/hid-descriptor-dump` captured on `.173`, so the one identity here whose real hardware + /// has been measured directly. + /// + /// ⚠️ **No paddles yet.** `BTN_PADDLE1..4` still fold/drop for this identity exactly as for the + /// other two; the Elite is merely the first Xbox pad that *could* carry them natively. Adding + /// them is blocked on a measurement, not on effort — once `xinputhid` promotes the pad it + /// claims the HID collection exclusively, so extra buttons declared in the descriptor may be + /// invisible to every consumer anyway (handoff §3.6). Measure before building. + pub(super) const fn elite() -> WinXboxIdentity { + WinXboxIdentity { + devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX_ELITE, + instance_prefix: "pf_xbox_elite", + hwid: "pf_xboxelite", + usb_vid_pid: "VID_045E&PID_0B22", + description: "Punktfunk Virtual Xbox Elite Wireless Controller Series 2", + } + } +} + +/// Every Xbox identity this backend can build, in wire order (`device_type` 4, 5, 6). +/// +/// A table rather than three loose constructors because the INF tests sweep it: each entry's +/// `hwid` must appear in `pf_gamepad.inx` **on a `pfGamepadXbox` model line**, and every non-Xbox +/// model line must NOT be on that section. A new identity added here without its INF line fails +/// those tests instead of failing on a user's box. +/// +/// `static`, not `const`, on purpose: [`XboxWinProto`] holds a `&'static WinXboxIdentity`, and a +/// `const` is inlined at each use site — `&CONST[i]` would depend on rvalue static promotion to +/// come out `'static` at all. +pub(super) static XBOX_IDENTITIES: [WinXboxIdentity; 3] = [ + WinXboxIdentity::wireless(), + WinXboxIdentity::one_s(), + WinXboxIdentity::elite(), +]; /// A single virtual Xbox pad: the `SwDeviceCreate`'d `pf_xbox_` devnode plus the sealed /// shared-memory channel. Dropping it removes the devnode and closes both sections. @@ -61,9 +155,9 @@ pub struct XboxWinPad { } impl XboxWinPad { - /// Create the sealed channel, stamp `device_type = Xbox` FIRST + the pad index + the neutral - /// report + the magic LAST, then spawn the devnode under the Bluetooth Xbox identity. - fn open(index: u8) -> Result { + /// Create the sealed channel, stamp `device_type` FIRST + the pad index + the neutral report + + /// the magic LAST, then spawn the devnode under `id`'s Bluetooth Xbox identity. + fn open(index: u8, id: &WinXboxIdentity) -> Result { let boot_name = pf_driver_proto::gamepad::pad_boot_name(index); let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?; let base = channel.data_base(); @@ -71,7 +165,7 @@ impl XboxWinPad { // device_type MUST land before the magic — the driver reads it the moment it attaches, and // a late stamp enumerates the pad with the default DualSense identity (the Deck's bug). unsafe { - *base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_XBOX; + *base.add(OFF_DEVTYPE) = id.devtype; std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); // Ring capability `2` = "this host drains the v2.2 long ring" (see the DualSense open). std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2); @@ -81,17 +175,20 @@ impl XboxWinPad { ); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); } - let inst = format!("pf_xbox_{index}"); + let inst = format!("{}_{index}", id.instance_prefix); let (hsw, instance_id) = create_swdevice(&SwDeviceProfile { instance: &inst, + // Per-FAMILY tag, like "PFDS" for the whole PlayStation family: the three Xbox + // identities share it because only one of them can ever hold a given pad index (the + // router keeps a live device in its owning manager), so their containers never collide. container_tag: 0x5046_5842, // "PFXB" container_index: index, - hwid: XBOX_HWID, - usb_vid_pid: XBOX_USB_VID_PID, + hwid: id.hwid, + usb_vid_pid: id.usb_vid_pid, // A Bluetooth pad is not a USB composite device, so there is no interface number to // synthesize — unlike the Deck, whose Steam promotion gate needs `&MI_02`. usb_mi: None, - description: "Punktfunk Virtual Xbox Wireless Controller", + description: id.description, })?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin). channel.bind_devnode( index as u32, @@ -99,14 +196,14 @@ impl XboxWinPad { super::gamepad_raii::ProofTransport::HidFeatureReport, ); let _sw = Some(super::gamepad_raii::SwDevice::new(hsw)); - // Bounded eager delivery — the driver must read `device_type = 4` before hidclass asks it - // for descriptors, or the pad enumerates as a DualSense. + // Bounded eager delivery — the driver must read the `device_type` stamp before hidclass + // asks it for descriptors, or the pad enumerates as a DualSense. channel.deliver_eager(Duration::from_millis(1500)); Ok(XboxWinPad { _sw, channel, attach: super::gamepad_raii::DriverAttach::new( - "pf_xboxwireless", + id.hwid, "pf_gamepad.inf", // one driver package serves every identity "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log", boot_name, @@ -190,8 +287,37 @@ fn parse_xbox_output(bytes: &[u8]) -> Option<(u16, u16, u16, u16)> { /// The Windows-Xbox half of the shared stateful manager (see [`PadProto`]). Lifecycle (slot table, /// unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`], exactly as for the PS pads. -#[derive(Default)] -pub struct XboxWinProto; +/// +/// The identity is a field rather than three separate proto types because nothing else about the +/// backend varies: same codec, same output parse, same rumble plane. `Default` is the Xbox Wireless +/// Controller, so `XboxWindowsManager::new()` keeps its previous meaning exactly. +pub struct XboxWinProto { + identity: &'static WinXboxIdentity, +} + +impl Default for XboxWinProto { + fn default() -> XboxWinProto { + XboxWinProto { + identity: &XBOX_IDENTITIES[0], + } + } +} + +impl XboxWinProto { + /// The Xbox One S identity (`045E:02FD`) — `UhidManager::with_backend(XboxWinProto::one_s())`. + pub fn one_s() -> XboxWinProto { + XboxWinProto { + identity: &XBOX_IDENTITIES[1], + } + } + + /// The Xbox Elite Series 2 identity (`045E:0B22`). + pub fn elite() -> XboxWinProto { + XboxWinProto { + identity: &XBOX_IDENTITIES[2], + } + } +} impl PadProto for XboxWinProto { type Pad = XboxWinPad; @@ -202,10 +328,12 @@ impl PadProto for XboxWinProto { " (install/repair: punktfunk-host.exe driver install --gamepad)"; fn open(&mut self, idx: u8) -> Result { - let p = XboxWinPad::open(idx)?; + let p = XboxWinPad::open(idx, self.identity)?; tracing::info!( index = idx, - "virtual Xbox Wireless Controller created (Windows UMDF HID identity 045E:0B13)" + identity = self.identity.usb_vid_pid, + description = self.identity.description, + "virtual Xbox pad created (Windows UMDF HID)" ); Ok(p) } diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index 01260fd0..0bdfdd28 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -474,8 +474,9 @@ pub mod uhid_abi; #[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/uhid_manager.rs"] pub mod uhid_manager; -/// Transport-independent Xbox Wireless Controller HID codec — the report the `pf-gamepad` UMDF -/// driver serves under device-type 4, giving an Xbox pad the HID footing `pf-xusb` never had +/// Transport-independent Xbox HID codec — the report the `pf-gamepad` UMDF driver serves under +/// device-types 4, 5 and 6 (Xbox Wireless / One S / Elite Series 2, which share one descriptor and +/// differ only in VID/PID), giving an Xbox pad the HID footing `pf-xusb` never had /// (Steam / WGI / GameInput / DirectInput cannot see an XUSB-interface-only device). /// /// Deliberately NOT cfg-gated to linux/windows like its siblings: it is pure byte-packing with no @@ -484,9 +485,11 @@ pub mod uhid_manager; /// has until a Windows box is reachable. #[path = "inject/proto/xbox_proto.rs"] pub mod xbox_proto; -/// Windows: virtual Xbox Wireless Controller via the same UMDF minidriver (device-type 4) — the -/// HID-visible alternative to [`gamepad_windows`]'s XUSB companion, which Steam / WGI / GameInput / -/// DirectInput cannot enumerate at all because it registers only the XUSB device interface. +/// Windows: virtual Xbox pads via the same UMDF minidriver — Xbox Wireless (device-type 4), +/// Xbox One S (5) and Xbox Elite Series 2 (6), the HID-visible alternative to +/// [`gamepad_windows`]'s XUSB companion, which Steam / WGI / GameInput / DirectInput cannot +/// enumerate at all because it registers only the XUSB device interface. The three identities +/// share one report descriptor and differ only in VID/PID, product string and INF model line. #[cfg(target_os = "windows")] #[path = "inject/windows/xbox_windows.rs"] pub mod xbox_windows; diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 82a6b008..f9c67603 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -1219,6 +1219,11 @@ pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2: u32 = 9; /// topology and four controller slots. Used by capture clients that own the physical Puck; /// ordinary wired/BLE SC2 capture remains `STEAMCONTROLLER2`. pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK: u32 = 10; +/// Xbox Elite Wireless Controller Series 2 (`045E:0B22`, Bluetooth): a Windows-only HID identity +/// through the UMDF minidriver, so glyphs and the device name read Elite. Folds to X-Box 360 +/// elsewhere. ⚠️ Identity only — the four paddles still fold/drop exactly as on the other X-Box +/// classes (`DUALSENSEEDGE` is the pad with native back-button slots). +pub const PUNKTFUNK_GAMEPAD_XBOXELITE: u32 = 11; /// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips /// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's @@ -1344,6 +1349,7 @@ const _: () = { assert!( PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK == GamepadPref::SteamController2Puck.to_u8() as u32 ); + assert!(PUNKTFUNK_GAMEPAD_XBOXELITE == GamepadPref::XboxElite.to_u8() as u32); // Extended button bits mirror the wire `input::gamepad` constants. assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1); assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2); diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 118bf00f..1fd11577 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -140,8 +140,8 @@ impl CompositorPref { /// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single /// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`, -/// `9 = SteamController2`, `10 = SteamController2Puck`), appended to `Hello`/`Welcome` — older -/// peers simply omit/ignore it (an unknown byte degrades to `Auto`). +/// `9 = SteamController2`, `10 = SteamController2Puck`, `11 = XboxElite`), appended to +/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum GamepadPref { /// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). @@ -151,9 +151,11 @@ pub enum GamepadPref { Xbox360, /// UHID DualSense (kernel `hid-playstation`) — adaptive triggers, lightbar, touchpad, motion. DualSense, - /// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity - /// (VID/PID/name), so games show One/Series glyphs. XInput-identical otherwise (impulse-trigger - /// rumble is unreachable through any virtual pad, so there's no game-visible gain over `Xbox360`). + /// X-Box One / Series pad. On Linux, the X-Box 360 uinput backend with the One/Series USB + /// identity (VID/PID/name), so games show One/Series glyphs — XInput-identical otherwise. On + /// Windows it is a distinct HID identity (`045E:02FD`, Bluetooth Xbox One S) through the UMDF + /// minidriver; it used to fold to `Xbox360` there, because the only Windows Xbox backend was + /// the XUSB companion, which presents one fixed 360 identity and cannot vary it. XboxOne, /// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like /// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host. @@ -186,6 +188,21 @@ pub enum GamepadPref { /// native seven-interface Puck topology (CDC pair, four controller slots, management HID) /// rather than relabelling its reports as a wired `1302`. SteamController2Puck, + /// Xbox Elite Wireless Controller Series 2 (Microsoft `045E:0B22`, Bluetooth) — a Windows-only + /// HID identity through the UMDF minidriver, so glyphs and the Device Manager name read Elite. + /// + /// ⚠️ **Glyphs and identity only, today.** The four paddles (`BTN_PADDLE1..4`) still fold or + /// drop exactly as on the other Xbox classes; the Elite is merely the first Xbox identity that + /// *could* carry them natively. Wiring them up is blocked on a measurement, not on effort — + /// once Windows promotes the pad, `xinputhid` claims its HID collection exclusively, so extra + /// buttons declared in the report descriptor may reach no consumer at all + /// (`design/xbox-pad-windows-handoff.md` §3.6). Do not advertise paddle support off this + /// variant until that is measured; `DualSenseEdge` stays the only virtual pad with native + /// back-button slots. + /// + /// Folds to `Xbox360` everywhere but Windows: there is no Linux uinput Elite identity + /// (`PadIdentity` has 360 and One S only). + XboxElite, } impl GamepadPref { @@ -211,7 +228,8 @@ impl GamepadPref { pub const fn has_motion(self) -> bool { match self { GamepadPref::Auto => true, // unknown; assume it can, see above - GamepadPref::Xbox360 | GamepadPref::XboxOne => false, + // No Xbox pad has a gyro in its HID contract — Elite Series 2 included. + GamepadPref::Xbox360 | GamepadPref::XboxOne | GamepadPref::XboxElite => false, GamepadPref::DualSense | GamepadPref::DualShock4 | GamepadPref::DualSenseEdge @@ -225,7 +243,7 @@ impl GamepadPref { /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`, - /// `9 = SteamController2`, `10 = SteamController2Puck`. + /// `9 = SteamController2`, `10 = SteamController2Puck`, `11 = XboxElite`. pub const fn to_u8(self) -> u8 { match self { GamepadPref::Auto => 0, @@ -239,6 +257,7 @@ impl GamepadPref { GamepadPref::SwitchPro => 8, GamepadPref::SteamController2 => 9, GamepadPref::SteamController2Puck => 10, + GamepadPref::XboxElite => 11, } } @@ -256,6 +275,7 @@ impl GamepadPref { 8 => GamepadPref::SwitchPro, 9 => GamepadPref::SteamController2, 10 => GamepadPref::SteamController2Puck, + 11 => GamepadPref::XboxElite, _ => GamepadPref::Auto, } } @@ -270,6 +290,10 @@ impl GamepadPref { "xboxone" | "xbox-one" | "xone" | "xbox1" | "series" | "xboxseries" => { GamepadPref::XboxOne } + // "elite" is unambiguous here — the DualSense Edge answers to "edge", never "elite". + "xboxelite" | "xbox-elite" | "elite" | "xboxelite2" | "elite2" => { + GamepadPref::XboxElite + } "dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4, "steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck, "steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController, @@ -289,7 +313,7 @@ impl GamepadPref { /// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`, /// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`, - /// `"steamcontroller2"`, `"steamcontroller2puck"`). + /// `"steamcontroller2"`, `"steamcontroller2puck"`, `"xboxelite"`). pub fn as_str(self) -> &'static str { match self { GamepadPref::Auto => "auto", @@ -303,6 +327,7 @@ impl GamepadPref { GamepadPref::SwitchPro => "switchpro", GamepadPref::SteamController2 => "steamcontroller2", GamepadPref::SteamController2Puck => "steamcontroller2puck", + GamepadPref::XboxElite => "xboxelite", } } } @@ -833,7 +858,11 @@ mod tests { /// into a host that drops every one. #[test] fn only_the_xbox_classes_lack_a_motion_plane() { - for p in [GamepadPref::Xbox360, GamepadPref::XboxOne] { + for p in [ + GamepadPref::Xbox360, + GamepadPref::XboxOne, + GamepadPref::XboxElite, + ] { assert!( !p.has_motion(), "{} should have no motion plane", @@ -910,11 +939,12 @@ mod tests { GamepadPref::SwitchPro, GamepadPref::SteamController2, GamepadPref::SteamController2Puck, + GamepadPref::XboxElite, ] { assert_eq!(GamepadPref::from_u8(p.to_u8()), p); assert_eq!(GamepadPref::from_name(p.as_str()), Some(p)); } - // Every wire byte 0..=10 is assigned, distinct, and pinned (forward-compat with peers + // Every wire byte 0..=11 is assigned, distinct, and pinned (forward-compat with peers // that only know a prefix of the range). for (v, p) in [ (0, GamepadPref::Auto), @@ -928,12 +958,13 @@ mod tests { (8, GamepadPref::SwitchPro), (9, GamepadPref::SteamController2), (10, GamepadPref::SteamController2Puck), + (11, GamepadPref::XboxElite), ] { assert_eq!(p.to_u8(), v); assert_eq!(GamepadPref::from_u8(v), p); } // The next unassigned byte degrades to Auto today; assigning it later must update this. - assert_eq!(GamepadPref::from_u8(11), GamepadPref::Auto); + assert_eq!(GamepadPref::from_u8(12), GamepadPref::Auto); // Aliases + unknowns. assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense)); assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360)); @@ -964,6 +995,24 @@ mod tests { Some(GamepadPref::XboxOne) ); assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne)); + // The Elite's aliases, and the one that could plausibly have been stolen: "edge" is the + // DualSense Edge and must stay so — the two are different pads on different vendors. + assert_eq!( + GamepadPref::from_name("Elite"), + Some(GamepadPref::XboxElite) + ); + assert_eq!( + GamepadPref::from_name("xbox-elite"), + Some(GamepadPref::XboxElite) + ); + assert_eq!( + GamepadPref::from_name("elite2"), + Some(GamepadPref::XboxElite) + ); + assert_eq!( + GamepadPref::from_name("edge"), + Some(GamepadPref::DualSenseEdge) + ); assert_eq!(GamepadPref::from_name("nope"), None); // Unknown wire byte degrades to Auto (forward-compatible). assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto); diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 269c76ea..b719cbab 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -368,6 +368,14 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { let xbox = args.iter().any(|a| a == "--xbox"); // `--xboxhid` drives the HID Xbox backend (device-type 4) instead of `--xbox`'s XUSB companion. let xboxhid = args.iter().any(|a| a == "--xboxhid"); + // The other two HID Xbox identities (device-types 5 and 6). Same backend, same report + // descriptor — only VID/PID, product string and hardware id differ — so these legs exist for + // exactly one question each: does Windows PROMOTE that PID the way it promotes `0B13`? + // `02FD` in particular has no stage-2 `HID\…&IG_00` line in Microsoft's `xinputhid.inf`, so + // it is the one worth watching. Check for the `IG_00` token, the XUSB interface, an XInput + // slot and rumble, exactly as the `--xboxhid` run did. + let xboxones = args.iter().any(|a| a == "--xboxones"); + let xboxelite = args.iter().any(|a| a == "--xboxelite"); // `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds // the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in // report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend @@ -481,6 +489,20 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { crate::inject::xbox_windows::XboxWindowsManager::new(), "Xbox Wireless Controller (HID)" ); + } else if xboxones { + drive!( + crate::inject::xbox_windows::XboxWindowsManager::with_backend( + crate::inject::xbox_windows::XboxWinProto::one_s() + ), + "Xbox One S Controller (HID, 045E:02FD)" + ); + } else if xboxelite { + drive!( + crate::inject::xbox_windows::XboxWindowsManager::with_backend( + crate::inject::xbox_windows::XboxWinProto::elite() + ), + "Xbox Elite Wireless Controller Series 2 (HID, 045E:0B22)" + ); } else if ds4 { drive!( crate::inject::dualshock4_windows::DualShock4WindowsManager::new(), diff --git a/crates/punktfunk-host/src/native/gamepad.rs b/crates/punktfunk-host/src/native/gamepad.rs index b990a507..bdc72b2a 100644 --- a/crates/punktfunk-host/src/native/gamepad.rs +++ b/crates/punktfunk-host/src/native/gamepad.rs @@ -41,7 +41,7 @@ pub(super) fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref { cfg!(target_os = "linux"), cfg!(target_os = "windows"), ); - degrade_steam_on_conflict(degrade_if_no_uhid(chosen)) + degrade_xbox_identity(degrade_steam_on_conflict(degrade_if_no_uhid(chosen))) } /// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins, @@ -49,9 +49,18 @@ pub(super) fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref { /// /// `linux`/`windows` flag the host platform. DualSense and DualShock 4 each have both a Linux (UHID /// hid-playstation) and a Windows (UMDF minidriver) backend; on any other platform such a wish degrades -/// to X-Box 360 (never an error: a session without rich pads still streams). X-Box One/Series is a -/// distinct uinput *identity* on Linux, but XInput-identical to the 360 pad on Windows (the XUSB -/// companion presents a 360 identity), so it degrades to `Xbox360` there. +/// to X-Box 360 (never an error: a session without rich pads still streams). +/// +/// The X-Box identities are now distinct on BOTH platforms: a uinput identity on Linux (360 / +/// One S), and a UMDF HID identity on Windows (360 → `045E:0B13`, One → `045E:02FD`, Elite → +/// `045E:0B22`). The Windows fold of One/Series into the 360 pad is gone with the reason for it — +/// it existed because the only Windows X-Box backend was the XUSB companion, which presents one +/// fixed 360 identity and cannot vary it. The Elite has no Linux identity (`PadIdentity` stops at +/// One S), so it folds there. +/// +/// ⚠️ **This is compile-time only.** `PUNKTFUNK_XBOX_BACKEND=xusb` puts Windows back on the +/// companion at RUNTIME, which un-varies the identity again — that is [`degrade_xbox_identity`]'s +/// job, not this function's. fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool) -> GamepadPref { let want = match pref { GamepadPref::Auto => env @@ -63,9 +72,12 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool // DualSense / DualShock 4: Linux UHID hid-playstation, or the Windows UMDF minidriver backend. GamepadPref::DualSense if linux || windows => GamepadPref::DualSense, GamepadPref::DualShock4 if linux || windows => GamepadPref::DualShock4, - // One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on - // Windows (XInput can't tell them apart anyway). - GamepadPref::XboxOne if linux => GamepadPref::XboxOne, + // One/Series: a real, distinct uinput identity on Linux, and — since the HID X-Box backend + // became the default — a distinct UMDF HID identity (`045E:02FD`) on Windows too. + GamepadPref::XboxOne if linux || windows => GamepadPref::XboxOne, + // Elite Series 2: Windows-only (UMDF device-type 6, `045E:0B22`). There is no Linux uinput + // Elite identity to fold onto, so it takes the `_` arm and lands on the 360 pad there. + GamepadPref::XboxElite if windows => GamepadPref::XboxElite, // Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices // are the N4 spike). GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck, @@ -221,6 +233,36 @@ fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref { chosen } +/// Runtime degrade for the two non-default Windows X-Box identities (One S / Elite Series 2): with +/// `PUNKTFUNK_XBOX_BACKEND=xusb` the session runs the XUSB companion, which presents ONE fixed +/// X-Box 360 identity and has no way to vary VID/PID — so the pad a player gets is a 360 pad no +/// matter what was asked for. Fold here so the `Welcome` echo says so. +/// +/// This is a runtime check and [`pick_gamepad`] is a compile-time one, which is exactly the split +/// [`degrade_if_no_uhid`] already draws. Without it, asking for an Elite under the escape hatch +/// resolves to `xboxelite`, echoes `xboxelite`, and builds a 360 pad — the class of silent lie +/// `pad_motion_reaches` and the fold-logging in [`resolve_gamepad`] exist to prevent. +/// +/// A no-op on every non-Windows host: `XboxElite` never survives `pick_gamepad` there, and +/// `XboxOne` is a genuine uinput identity on Linux. +#[cfg(target_os = "windows")] +fn degrade_xbox_identity(chosen: GamepadPref) -> GamepadPref { + if matches!(chosen, GamepadPref::XboxOne | GamepadPref::XboxElite) && !windows_xbox_hid() { + tracing::warn!( + wanted = chosen.as_str(), + "PUNKTFUNK_XBOX_BACKEND=xusb selects the XUSB companion, which has one fixed X-Box 360 \ + identity — falling back to the 360 pad" + ); + return GamepadPref::Xbox360; + } + chosen +} + +#[cfg(not(target_os = "windows"))] +fn degrade_xbox_identity(chosen: GamepadPref) -> GamepadPref { + chosen +} + /// Whether an Xbox-family pad should be built as a real **HID** device /// ([`crate::inject::xbox_windows`]) instead of the **XUSB** companion /// ([`crate::inject::gamepad`]). Windows only. **HID is the default**; set @@ -278,6 +320,9 @@ pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { // Steam controller — its own Steam Input would then manage two Decks (confirmed conflict-prone on // a Deck-as-host). `PUNKTFUNK_STEAM_FORCE=1` overrides. let chosen = degrade_steam_on_conflict(chosen); + // The XUSB escape hatch can only present a 360 identity, so the One S / Elite wishes fold when + // `PUNKTFUNK_XBOX_BACKEND=xusb` is set. + let chosen = degrade_xbox_identity(chosen); match pref { GamepadPref::Auto => { // The operator's env knob deserves a diagnostic when it didn't drive the @@ -374,10 +419,21 @@ mod tests { assert_eq!(pick_gamepad(Auto, Some("ps4"), true, false), DualShock4); assert_eq!(pick_gamepad(DualShock4, None, false, true), DualShock4); assert_eq!(pick_gamepad(DualShock4, None, false, false), Xbox360); - // X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows. + // X-Box One: a distinct uinput identity on Linux AND a distinct UMDF HID identity + // (`045E:02FD`) on Windows. The old Windows fold to Xbox360 is deliberately gone — it + // existed only because the XUSB companion has one fixed 360 identity, and the HID backend + // is the default now. `degrade_xbox_identity` puts the fold back when the escape hatch + // `PUNKTFUNK_XBOX_BACKEND=xusb` is set; that is a runtime check this pure one can't make. assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne); assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne); - assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360); + assert_eq!(pick_gamepad(XboxOne, None, false, true), XboxOne); + assert_eq!(pick_gamepad(XboxOne, None, false, false), Xbox360); + // X-Box Elite Series 2: Windows-only (UMDF device-type 6). No Linux uinput Elite identity + // exists, so it folds to the 360 pad there rather than pretending. + assert_eq!(pick_gamepad(XboxElite, None, false, true), XboxElite); + assert_eq!(pick_gamepad(Auto, Some("elite"), false, true), XboxElite); + assert_eq!(pick_gamepad(XboxElite, None, true, false), Xbox360); + assert_eq!(pick_gamepad(XboxElite, None, false, false), Xbox360); // Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3, // Steam-Input-promoted via MI_02 — gamepad-new-types N4); Xbox360 elsewhere. diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index bf0d4fc7..179881e3 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -126,9 +126,19 @@ struct Pads { /// The HID-visible Xbox pad ([`crate::inject::xbox_windows`]) — used INSTEAD of `xbox360`'s /// XUSB companion when [`super::gamepad::windows_xbox_hid`] says so. Never both at once: two /// devices for one wire pad is the "the game sees two controllers" bug. + /// + /// Three managers because the HID backend now has three IDENTITIES (Xbox Wireless `045E:0B13`, + /// Xbox One S `045E:02FD`, Elite Series 2 `045E:0B22`) and a manager is bound to one at + /// construction. They are otherwise the same backend — same codec, same report descriptor, + /// same rumble plane — so the split is purely so a mixed session can present, say, a Series + /// pad on slot 0 and an Elite on slot 1. #[cfg(target_os = "windows")] xbox_hid: Option, #[cfg(target_os = "windows")] + xbox_one_hid: Option, + #[cfg(target_os = "windows")] + xbox_elite_hid: Option, + #[cfg(target_os = "windows")] dualsense_edge_win: Option, #[cfg(target_os = "windows")] dualshock4_win: Option, @@ -172,6 +182,10 @@ impl Pads { #[cfg(target_os = "windows")] xbox_hid: None, #[cfg(target_os = "windows")] + xbox_one_hid: None, + #[cfg(target_os = "windows")] + xbox_elite_hid: None, + #[cfg(target_os = "windows")] dualsense_edge_win: None, #[cfg(target_os = "windows")] dualshock4_win: None, @@ -298,17 +312,38 @@ impl Pads { .steamdeck_win .get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new) .handle(ev), - // The Xbox pad, as a real HID device rather than the XUSB companion. This is now the + // The Xbox pads, as real HID devices rather than the XUSB companion. This is now the // DEFAULT (see `windows_xbox_hid`; `PUNKTFUNK_XBOX_BACKEND=xusb` reverts it). It is no // longer a trade: with the `xinputhid` bus filter the INF attaches, the HID pad keeps // classic XInput AND gains everything XUSB never had — Steam, SDL, RawInput, // DirectInput, `joy.cpl`, WGI — plus rumble, which the XUSB path could not source. + // + // Three arms, one per identity. The `windows_xbox_hid()` guard stays on each: with the + // escape hatch set, `degrade_xbox_identity` has already folded One/Elite to Xbox360, so + // only Xbox360 can reach here and it must fall through to the XUSB companion below. #[cfg(target_os = "windows")] - GamepadPref::Xbox360 | GamepadPref::XboxOne if super::gamepad::windows_xbox_hid() => { - self.xbox_hid - .get_or_insert_with(crate::inject::xbox_windows::XboxWindowsManager::new) - .handle(ev) - } + GamepadPref::Xbox360 if super::gamepad::windows_xbox_hid() => self + .xbox_hid + .get_or_insert_with(crate::inject::xbox_windows::XboxWindowsManager::new) + .handle(ev), + #[cfg(target_os = "windows")] + GamepadPref::XboxOne if super::gamepad::windows_xbox_hid() => self + .xbox_one_hid + .get_or_insert_with(|| { + crate::inject::xbox_windows::XboxWindowsManager::with_backend( + crate::inject::xbox_windows::XboxWinProto::one_s(), + ) + }) + .handle(ev), + #[cfg(target_os = "windows")] + GamepadPref::XboxElite if super::gamepad::windows_xbox_hid() => self + .xbox_elite_hid + .get_or_insert_with(|| { + crate::inject::xbox_windows::XboxWindowsManager::with_backend( + crate::inject::xbox_windows::XboxWinProto::elite(), + ) + }) + .handle(ev), _ => self .xbox360 .get_or_insert_with(crate::inject::gamepad::GamepadManager::new) @@ -431,7 +466,8 @@ impl Pads { /// player LEDs / adaptive triggers) for the UHID/UMDF pads. The `&mut` closure re-borrows /// satisfy `FnMut` for each backend. /// - /// Only the Windows HID Xbox backend (`xbox_hid`) can ever report non-zero trigger levels — no + /// Only the Windows HID Xbox backends (`xbox_hid` and its two identity siblings) can ever + /// report non-zero trigger levels — no /// other backend's source packet has a field for them (see `PadFeedback::rumble`), so they pass /// zeros and the v3 datagram they produce is a v2 datagram with a zero tail. fn pump( @@ -474,9 +510,17 @@ impl Pads { } #[cfg(target_os = "windows")] { - if let Some(m) = &mut self.xbox_hid { - // Rumble only — an Xbox pad has no rich-feedback plane (no lightbar / adaptive - // triggers), same as its XUSB sibling above. + // All three HID Xbox identities. Rumble only — an Xbox pad has no rich-feedback plane + // (no lightbar / adaptive triggers), same as its XUSB sibling above. Missing one of + // these is silent: the pad works and simply never rumbles. + for m in [ + &mut self.xbox_hid, + &mut self.xbox_one_hid, + &mut self.xbox_elite_hid, + ] + .into_iter() + .flatten() + { m.pump(&mut rumble, &mut hidout); } if let Some(m) = &mut self.dualsense_win { diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 8aea2f81..d7a44760 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -236,6 +236,12 @@ // ordinary wired/BLE SC2 capture remains `STEAMCONTROLLER2`. #define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK 10 +// Xbox Elite Wireless Controller Series 2 (`045E:0B22`, Bluetooth): a Windows-only HID identity +// through the UMDF minidriver, so glyphs and the device name read Elite. Folds to X-Box 360 +// elsewhere. ⚠️ Identity only — the four paddles still fold/drop exactly as on the other X-Box +// classes (`DUALSENSEEDGE` is the pad with native back-button slots). +#define PUNKTFUNK_GAMEPAD_XBOXELITE 11 + // Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips // (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's // `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`. diff --git a/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx index a7cd26be..001431d9 100644 --- a/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx +++ b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx @@ -1,7 +1,8 @@ ;/*++ ; punktfunk virtual gamepads — UMDF2 HID minidriver INF. -; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck — which is why -; the package is called pf_gamepad and not pf_dualsense (it never was one identity). +; One package, seven hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck, and three +; Xbox pads (Wireless / One S / Elite Series 2) — which is why the package is called pf_gamepad and +; not pf_dualsense (it never was one identity). ; ; ⚠️ The HARDWARE IDS below deliberately keep their old names (`pf_dualsense`, `pf_dualshock4`, ; `pf_dualsenseedge`, `pf_steamdeck`). They are the binding contract with every devnode the host @@ -34,10 +35,12 @@ pf_gamepad.dll=1 [pf.NT$ARCH$.10.0...22000] ; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense` ; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so -; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck` -; for the host's other virtual pads — ONE driver binds all of them (every model line below installs -; the same `pfGamepad` section) and serves the matching HID identity per the device_type byte the -; host stamps into shared memory. +; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / +; `pf_steamdeck` / `pf_xboxwireless` / `pf_xboxones` / `pf_xboxelite` for the host's other virtual +; pads — ONE driver binds all of them and serves the matching HID identity per the device_type byte +; the host stamps into shared memory. TWO install sections, though: the PlayStation/Deck ids share +; `pfGamepad`, and the three Xbox ids install `pfGamepadXbox`, which additionally attaches the +; `xinputhid` bus filter (see the ⚠️ below the Deck line). ; ; Each id carries its OWN description: Device Manager reads this string, and a single shared ; "Virtual DualSense" made an emulated DualShock 4 look like the controller-type setting had been @@ -47,11 +50,19 @@ pf_gamepad.dll=1 %DeviceDescDS4%=pfGamepad, pf_dualshock4 %DeviceDescEdge%=pfGamepad, pf_dualsenseedge %DeviceDescDeck%=pfGamepad, pf_steamdeck -; ⚠️ The Xbox line installs its OWN section, `pfGamepadXbox`, and must keep doing so. Every other -; identity shares `pfGamepad`; the Xbox one additionally attaches the `xinputhid` bus filter, and +; ⚠️ The Xbox lines install their OWN section, `pfGamepadXbox`, and must keep doing so. Every other +; identity shares `pfGamepad`; the Xbox ones additionally attach the `xinputhid` bus filter, and ; putting that on a DualSense / DualShock 4 / Edge / Steam Deck would hand a PlayStation pad to ; Microsoft's Xbox translator. The two sections are otherwise identical — keep them in step. +; (`only_the_xbox_identity_installs_the_xinputhid_section`, in pf-inject, asserts both directions.) +; +; The three Xbox identities differ ONLY in hardware id, Device Manager description and the VID/PID +; + product string the driver serves off the resulting device_type — they share one report +; descriptor and one install section, because in HID terms they are the same pad. See the +; `XBOX_RDESC` header in src/lib.rs for why that sharing is deliberate. %DeviceDescXbox%=pfGamepadXbox, root\pf_xboxwireless, pf_xboxwireless +%DeviceDescXboxOneS%=pfGamepadXbox, root\pf_xboxones, pf_xboxones +%DeviceDescXboxElite%=pfGamepadXbox, root\pf_xboxelite, pf_xboxelite [pfGamepad.NT] CopyFiles=UMDriverCopy @@ -177,3 +188,9 @@ DeviceDescDS4 ="Punktfunk Virtual DualShock 4" DeviceDescEdge ="Punktfunk Virtual DualSense Edge" DeviceDescDeck ="Punktfunk Virtual Steam Deck Controller" DeviceDescXbox ="Punktfunk Virtual Xbox Wireless Controller" +; ⚠️ This one deliberately does NOT match the product string the driver serves for device_type 5. +; A real Xbox One S pad reports "Xbox Wireless Controller" over Bluetooth, exactly like the Series +; X|S pad above — the PID is the only thing that separates them on the wire. Device Manager, +; however, has to let a human tell our two virtual pads apart, and this string is ours to choose. +DeviceDescXboxOneS ="Punktfunk Virtual Xbox One S Controller" +DeviceDescXboxElite="Punktfunk Virtual Xbox Elite Wireless Controller Series 2" diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index da93f94b..3411826d 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -2,7 +2,9 @@ // // A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense // (VID 054C / PID 0CE6), DualShock 4 (device_type=1), DualSense Edge (device_type=2), Steam Deck -// (device_type=3) or Xbox Wireless Controller (device_type=4, VID 045E / PID 0B13) using the +// (device_type=3), Xbox Wireless Controller (device_type=4, VID 045E / PID 0B13), Xbox One S +// (device_type=5, 045E / 02FD) or Xbox Elite Wireless Controller Series 2 (device_type=6, +// 045E / 0B22) using the // report descriptors + feature blobs punktfunk already ships in `inject/`. Games see a genuine // HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back. // @@ -73,7 +75,7 @@ const DS_EDGE_PID: u16 = 0x0DF2; const DECK_VID: u16 = 0x28DE; const DECK_PID: u16 = 0x1205; -// ---- Xbox Wireless Controller identity (device_type=4) ---- +// ---- Xbox identities (device_type = 4 Wireless / 5 One S / 6 Elite Series 2) ---- // // WHY THIS EXISTS (field 2026-08-09, `punktfunk-field-windows-pad-dead-0260`): the OTHER Windows // Xbox backend — `pf-xusb` — registers ONLY `GUID_DEVINTERFACE_XUSB` and has no HID collection at @@ -89,17 +91,37 @@ const DECK_PID: u16 = 0x1205; // never existed and inbox promotion has nothing to match. The Xbox pads that genuinely ARE HID are // the Bluetooth ones, which Windows binds through HIDCLASS. const XBOX_VID: u16 = 0x045E; -/// Xbox Wireless Controller (Series X|S), Bluetooth. Chosen over the Xbox One S BT id `0x02FD` -/// because the host's OS floor is Windows 11 22H2, where this is the current-generation identity -/// (so glyphs read "Xbox Series") and SDL's mapping database covers it. +/// Xbox Wireless Controller (Series X|S), Bluetooth — `device_type = 4`, the default Xbox identity. +/// Chosen over the Xbox One S BT id `0x02FD` because the host's OS floor is Windows 11 22H2, where +/// this is the current-generation identity (so glyphs read "Xbox Series") and SDL's mapping +/// database covers it. /// -/// ⚠️ **If on-glass shows Windows does not promote this to an Xbox-profile pad, try `0x02FD` -/// (Xbox One S BT) — it has the broadest inbox coverage.** Deliberately one named constant so that -/// experiment is a one-line change. +/// ⭐ It is also the PID Microsoft's own `xinputhid.inf` allow-lists **twice** (once as a +/// `BTHLEDevice` stage-1 id, once as a plain `HID\…&IG_00` stage-2 id) — measured off `.173`, +/// 2026-08-09. That is not what promotes OUR pad (a software devnode matches no allow-list entry; +/// `pfGamepadXbox`'s `AddReg` writes what the matching sections would have written), but it is why +/// this stays the default of the three. const XBOX_PID: u16 = 0x0B13; -/// Alternate identity for the promotion experiment above — Xbox One S controller over Bluetooth. -#[allow(dead_code)] +/// Xbox One S controller over Bluetooth — `device_type = 5`. +/// +/// ⚠️ **`02FD` appears in `xinputhid.inf` only as a `BTHENUM` (classic-BT bus) id — it has NO +/// stage-2 `HID\…&IG_00` model line.** That killed it as a "try another PID" lever for the +/// promotion work (handoff §4 B1). It does not block it as an IDENTITY, because our promotion +/// comes from the INF's own `AddReg` rather than from matching Microsoft's list — but if a future +/// Windows servicing update makes promotion depend on the allow-list again, this identity is the +/// one that loses it first. Worth re-measuring on glass before recommending it to anyone. const XBOX_PID_ONE_S: u16 = 0x02FD; +/// Xbox Elite Wireless Controller Series 2 — `device_type = 6`. This is the pad +/// `tools/hid-descriptor-dump` captured on `.173` (`BTHLE\DEV_686CE647F191`, `REV_0521`), so it is +/// the one identity here whose real hardware we have measured directly. +const XBOX_PID_ELITE2: u16 = 0x0B22; +/// bcdDevice for every Xbox identity. +/// +/// Deliberately ONE value rather than per-identity: the real Elite reports `REV_0521` (measured on +/// `.173`) but `create_swdevice` synthesizes the devnode's USB ids with a hardcoded `&REV_0100` +/// regardless, and SDL folds the version into its joystick GUID — so a version that disagrees with +/// the devnode buys nothing and risks missing a stock mapping. Revisit only with a measurement +/// that shows a consumer keying on it. const XBOX_VER: u16 = 0x0407; // Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs). @@ -271,7 +293,23 @@ static DECK_RDESC: [u8; 38] = [ 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0, ]; -// ---- Xbox Wireless Controller assets (served when the host stamps device_type=4) ---- +// ---- Xbox assets (served when the host stamps device_type = 4, 5 or 6) ---- +// +// ⭐⭐ **ONE DESCRIPTOR SERVES ALL THREE XBOX IDENTITIES, DELIBERATELY.** Xbox Wireless (4), +// Xbox One S (5) and Xbox Elite Series 2 (6) differ ONLY in VID/PID, product string and INF model +// line — in HID terms they are the same pad: same two 16-bit stick pairs, same trigger pair, same +// hat, same 15 buttons, same rumble output report. A report descriptor is the report SHAPE, not +// the identity; the identity is what SDL/Steam/Windows key their stock mappings off, and that +// travels in `hid_attrs`. +// +// This is load-bearing, not laziness. The ⚠️ block below is the record of what ONE hand-written +// descriptor has already cost: three separate bugs (no Feature report ⇒ the sealed channel never +// opened and the pad served neutral forever; no OUTPUT item ⇒ no rumble of any kind and dead +// host-side code; a layout that provably disagrees with the captured hardware). Two more +// hand-written descriptors would multiply that debt by three for no measured gain, and each would +// need its own capture, its own `wReportLength`, its own `xbox_proto` layout tests and its own +// on-glass verification. When a Linux-hidraw capture settles the real layout (handoff §3.3), it +// lands here ONCE and all three identities get it. // // A standards-clean Game Pad collection matching the Bluetooth Xbox layout: two 16-bit stick pairs, // two 10-bit triggers on the Simulation page, a null-state hat, and 15 buttons. Report `0x01`, @@ -474,6 +512,7 @@ static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01 static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01]; static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01]; static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes +// Serves device_type 4, 5 AND 6 — one descriptor, three identities (see the XBOX_RDESC header). static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xDF, 0x00]; // 223 bytes // Each `wReportLength` above is a SECOND copy of a length that already exists as its descriptor's @@ -492,13 +531,21 @@ const _: () = assert!(declared_len(&DECK_HID_DESC) == DECK_RDESC.len()); const _: () = assert!(declared_len(&XBOX_HID_DESC) == XBOX_RDESC.len()); // HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11]. -// `devtype` selects the identity: PS family (same Sony VID/version) or the N4-spike Deck. +// `devtype` selects the identity: PS family (same Sony VID/version), the N4-spike Deck, or one of +// the three Xbox pads (same Microsoft VID/version — only the PID differs, which is the entire +// difference between them; they share a report descriptor). +// +// ⚠️ THIS is where an Xbox identity is actually decided. Everything else in the Xbox path — +// descriptor, HID descriptor, report length, neutral report — is shared, so a new Xbox model is a +// PID here, a product string in `on_get_string`, an INF model line and nothing else. fn hid_attrs(devtype: u8) -> [u8; 32] { let (vid, pid, ver) = match devtype { 1 => (DS_VID, DS4_PID, DS_VER), 2 => (DS_VID, DS_EDGE_PID, DS_VER), 3 => (DECK_VID, DECK_PID, DS_VER), 4 => (XBOX_VID, XBOX_PID, XBOX_VER), + 5 => (XBOX_VID, XBOX_PID_ONE_S, XBOX_VER), + 6 => (XBOX_VID, XBOX_PID_ELITE2, XBOX_VER), _ => (DS_VID, DS_PID, DS_VER), }; let mut a = [0u8; 32]; @@ -518,10 +565,11 @@ fn hid_attrs(devtype: u8) -> [u8; 32] { /// the caller's buffer rather than truncating — so handing hidclass 64 bytes for a 16-byte report /// fails every single read and the pad looks dead. /// -/// Returns 64 for every pre-existing identity, so this is provably a no-op for them. +/// Returns 64 for every pre-existing identity, so this is provably a no-op for them. All three +/// Xbox identities share one descriptor, hence one report length. fn input_report_len(devtype: u8) -> usize { match devtype { - 4 => XBOX_INPUT_REPORT_LEN, + 4 | 5 | 6 => XBOX_INPUT_REPORT_LEN, _ => 64, } } @@ -578,7 +626,8 @@ fn neutral_report(devtype: u8) -> [u8; 64] { match devtype { 1 => DS4_NEUTRAL_REPORT, 3 => DECK_NEUTRAL_REPORT, - 4 => XBOX_NEUTRAL_REPORT, + // Wireless / One S / Elite Series 2 — one report shape, three identities. + 4 | 5 | 6 => XBOX_NEUTRAL_REPORT, _ => NEUTRAL_REPORT, // DualSense and Edge share the report 0x01 shape } } @@ -731,8 +780,8 @@ static RING_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`. static CHANNEL: ChannelClient = ChannelClient::new(); /// The last observed `device_type` (0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge, -/// 3 = Steam Deck) — the neutral-report shape when the channel detaches, and the fallback identity -/// while unattached. +/// 3 = Steam Deck, 4 = Xbox Wireless, 5 = Xbox One S, 6 = Xbox Elite Series 2) — the +/// neutral-report shape when the channel detaches, and the fallback identity while unattached. 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. @@ -748,9 +797,14 @@ static TICK: AtomicU32 = AtomicU32::new(0); /// can never disagree. /// /// Order matters: `pf_dualsense` is a prefix of `pf_dualsenseedge`, so the Edge is tested first. +/// (No Xbox token is a prefix of another — `pf_xboxwireless` / `pf_xboxones` / `pf_xboxelite` +/// diverge at the 8th character — but `hwid_devtype_table_matches_the_driver` re-checks that for +/// every pair rather than trusting this note.) fn devtype_from_hwids(ids: &str) -> Option { for (token, devtype) in [ ("pf_xboxwireless", 4u8), + ("pf_xboxones", 5), + ("pf_xboxelite", 6), ("pf_steamdeck", 3), ("pf_dualsenseedge", 2), ("pf_dualshock4", 1), @@ -1039,15 +1093,17 @@ extern "C" fn evt_io_device_control( 1 => &DS4_HID_DESC, 2 => &EDGE_HID_DESC, 3 => &DECK_HID_DESC, - 4 => &XBOX_HID_DESC, + 4 | 5 | 6 => &XBOX_HID_DESC, _ => &HID_DESC, }), IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())), + // The three Xbox identities share ONE report descriptor on purpose — see the XBOX_RDESC + // header. Only `hid_attrs` (VID/PID) and `on_get_string` (product string) tell them apart. IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(match device_type() { 1 => &DS4_RDESC[..], 2 => &DS_EDGE_RDESC[..], 3 => &DECK_RDESC[..], - 4 => &XBOX_RDESC[..], + 4 | 5 | 6 => &XBOX_RDESC[..], _ => &DUALSENSE_RDESC[..], }), IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => { @@ -1309,7 +1365,7 @@ fn on_get_string(request: &Request) -> NTSTATUS { 0 | 0x000e => match devtype { 1 => "Sony Computer Entertainment".into(), 3 => "Valve Software".into(), - 4 => "Microsoft".into(), + 4 | 5 | 6 => "Microsoft".into(), _ => "Sony Interactive Entertainment".into(), }, // Per-pad serials (see `pad_index`): SDL reads this via HidD_GetSerialNumberString and @@ -1322,15 +1378,29 @@ fn on_get_string(request: &Request) -> NTSTATUS { 2 => format!("35533AD6E7{:02X}", 0x75u8.wrapping_add(pad_index())), 3 => format!("FVPF{:08X}", 0x5046_0000u32 | pad_index() as u32), // Xbox pads report a Bluetooth MAC-shaped serial; the low octet carries the pad index - // so Steam dedups multiple forwarded pads, exactly like the PS identities above. + // so Steam dedups multiple forwarded pads, exactly like the PS identities above. Each + // Xbox identity gets its OWN base octet (0x10 / 0x30 / 0x50) rather than sharing one: + // a mixed session can present a Wireless pad and an Elite at once, and two identities + // whose serials differ only by pad index are one off-by-one away from colliding — the + // failure being Steam silently treating two live pads as one device. 4 => format!("F4B0FC2A6C{:02X}", 0x10u8.wrapping_add(pad_index())), + 5 => format!("F4B0FC2A6C{:02X}", 0x30u8.wrapping_add(pad_index())), + 6 => format!("F4B0FC2A6C{:02X}", 0x50u8.wrapping_add(pad_index())), _ => format!("35533AD6E7{:02X}", 0x74u8.wrapping_add(pad_index())), }, _ => match devtype { 1 => "Wireless Controller".into(), 2 => "DualSense Edge Wireless Controller".into(), 3 => "Steam Deck Controller".into(), - 4 => "Xbox Wireless Controller".into(), + // ⚠️ 4 and 5 share a product string ON PURPOSE — a real Xbox Wireless Controller + // (Series X|S, `0B13`) and a real Xbox One S pad (`02FD`) BOTH report exactly + // "Xbox Wireless Controller" over Bluetooth. The PID is what tells them apart, and + // that is what SDL/Steam/Windows key their stock mappings off. Do not "fix" this by + // inventing a distinguishing string; it would make the One S identity a device that + // has never existed. (The INF's Device Manager descriptions DO differ — that string + // is ours, not the pad's.) + 4 | 5 => "Xbox Wireless Controller".into(), + 6 => "Xbox Elite Wireless Controller Series 2".into(), _ => "DualSense Wireless Controller".into(), }, }; @@ -1343,7 +1413,7 @@ fn on_get_string(request: &Request) -> NTSTATUS { } /// The device-type selector: 0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck, -/// 4 = Xbox Wireless Controller. +/// 4 = Xbox Wireless Controller, 5 = Xbox One S, 6 = Xbox Elite Wireless Controller Series 2. /// Read fresh on each enumeration query — cheap. /// /// ⚠️ **The sealed section cannot answer the enumeration queries.** hidclass asks for -- 2.54.0 From 2b1843ed1c7d9fde76c0aa2a99d310a6dd0d9949 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 22:19:25 +0200 Subject: [PATCH 13/16] =?UTF-8?q?fix(drivers/pf-gamepad):=20the=20right=20?= =?UTF-8?q?stick=20is=20Z/Rz=20=E2=80=94=20as=20declared,=20it=20was=20dea?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on glass, first real streaming session: everything worked except the right stick, and Steam correctly showed "Xbox One S Controller". `XBOX_RDESC` declared the right stick as `Rx`/`Ry`. `xinputhid`, which translates our HID collection into XUSB, maps `Z`/`Rz` to the right stick and does not treat `Rx`/`Ry` as one, so those two axes reached nothing. Two usage bytes. Left and right were declared identically here — same collection, same globals, same size and count — so the usages were the entire difference, which is what makes the diagnosis airtight rather than plausible. Note `DUALSENSE_RDESC`, a real capture, also uses `Z`/`Rz` for its right stick and puts the TRIGGERS on `Rx`/`Ry`; that is most likely where the original mistake came from. ⚠️ Byte offsets are unchanged — still 16×2 at bit 5.0 — so `xbox_proto`'s layout tests and the host-side packing are untouched. This is a pure relabelling. 🛑 THE REAL LESSON IS THE HARNESS, AND IT IS FIXED HERE TOO. This survived every bench measurement because `dualsense-windows-test` drove LS-X and the A button and left the other five analogue axes at zero. `XInputGetState` read `RX [0..0]`, which I read as "the devtest doesn't move it" — true, and useless: a harness that exercises one axis cannot tell "this axis is not mapped" from "nothing is driving it", and the two are indistinguishable in every consumer. The devtest now sweeps all six axes on distinct phases and ramps both triggers, so one run shows which axes arrive AND that they are not crosstalking onto each other's bytes. MEASURED ON .173, same run shape before and after, devtest sweeping all six axes: before: LX [-11264..24576] LY [-32768..31744] RX [0..0] RY [-1..-1] LT [0..248] RT [7..255] after: LX [-8192..26624] LY [-32768..31744] RX [-32768..31744] RY [-24576..10240] LT [0..248] RT [7..255] VERIFIED * `cargo test -p pf-inject --lib` 104/104 on Windows; `xbox` subset 11/11 on macOS — the layout tests still pass because nothing moved. * Driver rebuilds and signs; the descriptor is still 223 bytes so the `wReportLength` const assert is undisturbed. * `cargo fmt --all --check` clean. NOT VERIFIED * Not yet re-tested in a real streaming session — that is the next on-glass run. * ⚠️ A leftover finding from the same session, unrelated to this fix and NOT investigated: the session's pad devnode SURVIVES client disconnect and keeps the `Global\pfds-boot-0` bootstrap mailbox, so a devtest run afterwards fails with `Zugriff verweigert (0x80070005)` and silently measures the stale pad instead. Restarting the service releases it. Worth its own look. --- crates/punktfunk-host/src/devtest.rs | 25 +++++++++++++------ .../windows/drivers/pf-gamepad/src/lib.rs | 15 +++++++++-- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index b719cbab..525bbb57 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -420,17 +420,28 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { } else { 0 }; - let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X + // 🛑 Sweep EVERY analogue axis, each on its own phase, and ramp both triggers. + // + // This used to drive LS-X alone and leave the other five at zero, which makes + // the harness unable to tell "this axis is not mapped" from "nothing is driving + // it" — the two look identical in any consumer. That is exactly how a DEAD + // RIGHT STICK survived every bench measurement of the Windows HID Xbox pad and + // was found only on glass (2026-08-09): `XInputGetState` read `RX [0..0]` and it + // was written off as "the devtest doesn't move it", which was true and useless. + // Distinct phases mean one run tells you which axes arrive AND that they are not + // crosstalking onto each other's bytes. + let phase = |off: i32| ((((i + off) % 64) - 32) * 1024) as i16; + let trig = ((i % 32) * 8).clamp(0, 255) as u8; mgr.handle(&GamepadEvent::State(GamepadFrame { index: idx as i16, active_mask: 1 << idx, buttons, - left_trigger: 0, - right_trigger: 0, - ls_x: lx, - ls_y: 0, - rs_x: 0, - rs_y: 0, + left_trigger: trig, + right_trigger: 255 - trig, + ls_x: phase(0), + ls_y: phase(16), + rs_x: phase(32), + rs_y: phase(48), })); } std::thread::sleep(Duration::from_millis(15)); diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index 3411826d..86a06166 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -376,10 +376,21 @@ static XBOX_RDESC: [u8; 223] = [ 0x75, 0x10, // Report Size (16) 0x81, 0x02, // Input (Data,Var,Abs) 0xC0, // End Collection + // 🛑 THE RIGHT STICK IS `Z`/`Rz`, NOT `Rx`/`Ry`. This declared `Rx`/`Ry` until 2026-08-09 and + // the right stick was DEAD: measured on `.173`, with every axis sweeping on its own phase, + // `LX`/`LY`/`LT`/`RT` all reached XInput and `RX [0..0] RY [-1..-1]` never moved. Left and right + // were declared identically here apart from these two usage bytes, so the usages are the whole + // difference — `xinputhid`, which translates this collection into XUSB, maps `Z`/`Rz` to the + // right stick and does not treat `Rx`/`Ry` as one. `DUALSENSE_RDESC` above (a real capture) uses + // `Z`/`Rz` for its right stick too; the PS pads put the TRIGGERS on `Rx`/`Ry`, which is probably + // where the original mistake came from. + // ⚠️ This survived every bench measurement because the devtest only ever swept LS-X — the axis + // that worked — so `RX [0..0]` read as "nothing is driving it". It was found on glass. The + // devtest now sweeps all six axes on distinct phases so the harness can tell those two apart. 0x09, 0x01, // Usage (Pointer) 0xA1, 0x00, // Collection (Physical) - 0x09, 0x33, // Usage (Rx) — right stick X - 0x09, 0x34, // Usage (Ry) — right stick Y + 0x09, 0x32, // Usage (Z) — right stick X + 0x09, 0x35, // Usage (Rz) — right stick Y 0x15, 0x00, // Logical Minimum (0) 0x27, 0xFF, 0xFF, 0x00, 0x00, // Logical Maximum (65535) 0x95, 0x02, // Report Count (2) -- 2.54.0 From 94c2f62490f35d78d9e704fe420b03aef402b15d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 22:55:17 +0200 Subject: [PATCH 14/16] =?UTF-8?q?test(tools):=20a=20GameInput=20probe=20?= =?UTF-8?q?=E2=80=94=20and=20it=20cannot=20see=20our=20promoted=20Xbox=20p?= =?UTF-8?q?ad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `win-input-matrix` covered four of the five rows and said so; GameInput was the gap, because it has no binding in the `windows` crate and needs hand-written COM. This adds it: `--gameinput` reports whether GameInput has a reading, and `--gi-rumble l,h,lt,rt [--gi-pid PID]` drives `SetRumbleState`. Every vtable slot is taken from the SDK header, not guessed — a COM vtable is positional, so a wrong slot calls a different method with the wrong signature. WHY RUMBLE AND NOT JUST ENUMERATION. `XINPUT_VIBRATION` has two members, so classic XInput can never exercise an Xbox pad's two IMPULSE-TRIGGER motors. `GameInputRumbleParams` has four (`lowFrequency`, `highFrequency`, `leftTrigger`, `rightTrigger`), which makes GameInput the only API that can settle `design/trigger-rumble-plane.md` §2.1's open question — the `enable`-mask bit assignment for the two trigger actuators, where bits 2/3 (the handles) are measured and bits 0/1 (the triggers) are inferred from field order and nothing else. TWO THINGS MEASURED ON .173, 2026-08-09: 1. ⭐ GameInput's device enumeration is ASYNCHRONOUS, and the first `GetCurrentReading` reliably returns nothing even with pads actively reporting. This is the GameInput analogue of `wake_wgi`: the API looks like a query and is really a cache someone else fills. A bounded poll fixes it. ⚠️ Focus is NOT the cause, and the header rules it out rather than my guessing: `GameInputDefaultFocusPolicy` is 0 and every `GameInputFocusPolicy` flag is a RESTRICTION, so the default already admits background input. Do not "fix" this with `SetFocusPolicy`. 2. 🛑 **GameInput never sees our pad.** Hunting by product id for six seconds with the pad live and sweeping, it enumerated `054C:0CE6` (DualSense) and `3434:D031` (8BitDo) — both plain HID pads — and never `045E:02FD`, ours, while classic XInput was reading ours live in the same moment. ⇒ THE TRIGGER ENABLE BITS REMAIN CONJECTURE, but for a better reason than before: it is not that nobody has tried, it is that on this box NOTHING CAN DELIVER a four-motor rumble to our pad. XInput structurally cannot; GameInput can but does not see it. ⚠️ The obvious suspicion is that `xinputhid` claiming the HID collection exclusively is what hides the pad from GameInput — which would mean promotion costs us the API most Game-Pass-era titles use, a trade we have shipped by default. **That is NOT established here.** The decisive control is cheap and has not been run: power on the REAL Xbox Elite, which Microsoft's own driver promotes the same way, and see whether GameInput enumerates it. If a real promoted Xbox pad is also absent, this is a property of GameInput in a non-interactive session and not our defect — the same shape as the WGI `ts=0` row, which a real Elite reproduced. VERIFIED * `cargo fmt --check` clean; `cargo clippy --target x86_64-pc-windows-msvc --all-targets -- -D warnings` clean (cross-checked from macOS). * Builds and runs on .173; `GameInputCreate` succeeds, readings arrive after the poll, and `SetRumbleState` is accepted. * The runtime is loaded by name, so a box without GameInput reports "unavailable" rather than failing to link or crashing. NOT VERIFIED * That `SetRumbleState` reaches ANY pad's motors — it was accepted for the DualSense but nothing observable was checked on that device, and it never reached ours. * `GameInputDeviceInfo` is read only for `vendorId`/`productId` (offsets 4 and 6). The rest of the struct has variable-size members whose layout would have to be mirrored exactly; nothing here needs them. `supportedRumbleMotors` is in there and would answer "does GameInput think this pad has trigger motors" — worth adding if this line of enquiry continues. --- tools/win-input-matrix/Cargo.toml | 1 + tools/win-input-matrix/src/gameinput.rs | 331 ++++++++++++++++++++++++ tools/win-input-matrix/src/main.rs | 39 +++ 3 files changed, 371 insertions(+) create mode 100644 tools/win-input-matrix/src/gameinput.rs diff --git a/tools/win-input-matrix/Cargo.toml b/tools/win-input-matrix/Cargo.toml index ad719ba8..7d94386e 100644 --- a/tools/win-input-matrix/Cargo.toml +++ b/tools/win-input-matrix/Cargo.toml @@ -30,5 +30,6 @@ windows = { version = "0.62", features = [ "Win32_System_Com", "Gaming_Input", "Foundation", + "Win32_System_LibraryLoader", "Foundation_Collections", ] } diff --git a/tools/win-input-matrix/src/gameinput.rs b/tools/win-input-matrix/src/gameinput.rs new file mode 100644 index 00000000..21955a19 --- /dev/null +++ b/tools/win-input-matrix/src/gameinput.rs @@ -0,0 +1,331 @@ +//! The GameInput row of the matrix, and the only API that can drive TRIGGER rumble. +//! +//! WHY THIS IS HAND-WRITTEN COM. GameInput has no binding in the `windows` crate, so the vtables +//! below are declared by hand. Every slot index is taken from the SDK header +//! `Windows Kits\10\Include\10.0.26100.0\um\GameInput.h`, not from guesswork — a COM vtable is +//! positional, so a wrong slot calls a different method with the wrong signature and corrupts the +//! stack. Only the slots up to the ones actually called are declared; anything past them is simply +//! absent from the struct, which is sound because a vtable is only ever read through the offsets we +//! name and we never call beyond the last declared entry. +//! +//! ⭐ WHY IT MATTERS BEYOND ENUMERATION. `XINPUT_VIBRATION` has exactly two members, so classic +//! XInput can never exercise an Xbox pad's two IMPULSE-TRIGGER motors. `GameInputRumbleParams` has +//! four — `lowFrequency`, `highFrequency`, `leftTrigger`, `rightTrigger` — which makes this the one +//! path that can settle the open question in `design/trigger-rumble-plane.md` §2.1: the `enable`- +//! mask bit assignment for the two trigger actuators in the pad's HID output report `0x03` is +//! CONJECTURE (bits 2/3 = the handles are measured; bits 0/1 = the triggers are inferred from field +//! order and nothing else). +//! +//! The experiment `--gi-rumble` exists for: drive four DISTINCT magnitudes, then read what the pad +//! actually decoded. Four distinct values make the mapping self-identifying — a channel that comes +//! back zero had its enable bit guessed wrong, and a channel that comes back holding another's +//! value is a swap. +//! +//! The runtime is loaded by name rather than linked, so this builds with no import library and +//! degrades to a clean "GameInput not present" on a box without it. + +#![allow(non_snake_case)] + +use std::ffi::c_void; + +use windows::Win32::Foundation::{FreeLibrary, HMODULE}; +use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; +use windows::core::{HRESULT, PCSTR, PCWSTR}; + +/// `GameInputKind` values we use (GameInput.h). +const GAME_INPUT_KIND_GAMEPAD: u32 = 0x0004_0000; +const GAME_INPUT_KIND_CONTROLLER: u32 = 0x0000_000E; + +/// The four rumble channels, 0.0..=1.0 each. Layout verbatim from GameInput.h. +#[repr(C)] +#[derive(Clone, Copy, Default, Debug)] +pub struct GameInputRumbleParams { + pub lowFrequency: f32, + pub highFrequency: f32, + pub leftTrigger: f32, + pub rightTrigger: f32, +} + +/// `IGameInput`, declared only as far as `GetCurrentReading` (slot 4). +#[repr(C)] +struct IGameInputVtbl { + QueryInterface: unsafe extern "system" fn(*mut c_void, *const u8, *mut *mut c_void) -> HRESULT, + AddRef: unsafe extern "system" fn(*mut c_void) -> u32, + Release: unsafe extern "system" fn(*mut c_void) -> u32, + GetCurrentTimestamp: unsafe extern "system" fn(*mut c_void) -> u64, + GetCurrentReading: + unsafe extern "system" fn(*mut c_void, u32, *mut c_void, *mut *mut c_void) -> HRESULT, +} + +/// `IGameInputReading`, declared only as far as `GetDevice` (slot 6). +#[repr(C)] +struct IGameInputReadingVtbl { + QueryInterface: unsafe extern "system" fn(*mut c_void, *const u8, *mut *mut c_void) -> HRESULT, + AddRef: unsafe extern "system" fn(*mut c_void) -> u32, + Release: unsafe extern "system" fn(*mut c_void) -> u32, + GetInputKind: unsafe extern "system" fn(*mut c_void) -> u32, + GetSequenceNumber: unsafe extern "system" fn(*mut c_void, u32) -> u64, + GetTimestamp: unsafe extern "system" fn(*mut c_void) -> u64, + GetDevice: unsafe extern "system" fn(*mut c_void, *mut *mut c_void), +} + +/// `IGameInputDevice`, declared only as far as `SetRumbleState` (slot 10). +#[repr(C)] +struct IGameInputDeviceVtbl { + QueryInterface: unsafe extern "system" fn(*mut c_void, *const u8, *mut *mut c_void) -> HRESULT, + AddRef: unsafe extern "system" fn(*mut c_void) -> u32, + Release: unsafe extern "system" fn(*mut c_void) -> u32, + GetDeviceInfo: unsafe extern "system" fn(*mut c_void) -> *const c_void, + GetDeviceStatus: unsafe extern "system" fn(*mut c_void) -> u32, + GetBatteryState: unsafe extern "system" fn(*mut c_void, *mut c_void), + CreateForceFeedbackEffect: + unsafe extern "system" fn(*mut c_void, u32, *const c_void, *mut *mut c_void) -> HRESULT, + IsForceFeedbackMotorPoweredOn: unsafe extern "system" fn(*mut c_void, u32) -> i32, + SetForceFeedbackMotorGain: unsafe extern "system" fn(*mut c_void, u32, f32), + SetHapticMotorState: unsafe extern "system" fn(*mut c_void, u32, *const c_void), + SetRumbleState: unsafe extern "system" fn(*mut c_void, *const GameInputRumbleParams), +} + +/// A loaded GameInput runtime plus the root object. Dropping it releases both. +pub struct GameInput { + module: HMODULE, + root: *mut c_void, +} + +impl Drop for GameInput { + fn drop(&mut self) { + // SAFETY: `root` came from GameInputCreate and is released exactly once; `module` came from + // LoadLibraryW. Freeing the module after the object is the required order. + unsafe { + if !self.root.is_null() { + let vtbl = *(self.root as *mut *const IGameInputVtbl); + ((*vtbl).Release)(self.root); + } + let _ = FreeLibrary(self.module); + } + } +} + +impl GameInput { + /// Load `gameinput.dll` and create the root object. `Err` carries a human reason — a box + /// without the runtime is a legitimate outcome, not a crash. + pub fn create() -> Result { + let name: Vec = "gameinput.dll\0".encode_utf16().collect(); + // SAFETY: `name` is a NUL-terminated wide string that outlives the call. + let module = unsafe { LoadLibraryW(PCWSTR(name.as_ptr())) } + .map_err(|e| format!("gameinput.dll not loadable: {e}"))?; + // SAFETY: `module` is live; the name is a NUL-terminated byte string. + let proc = + unsafe { GetProcAddress(module, PCSTR(c"GameInputCreate".as_ptr() as *const u8)) } + .ok_or_else(|| "gameinput.dll has no GameInputCreate export".to_string())?; + // SAFETY: the export's documented signature is + // `HRESULT GameInputCreate(IGameInput**)` — GameInput.h, `STDAPI GameInputCreate`. + let create: unsafe extern "system" fn(*mut *mut c_void) -> HRESULT = + unsafe { std::mem::transmute(proc) }; + let mut root: *mut c_void = std::ptr::null_mut(); + // SAFETY: `root` is a valid out-param slot. + let hr = unsafe { create(&mut root) }; + if hr.is_err() || root.is_null() { + // SAFETY: nothing was created; drop the module by hand since we have no object yet. + unsafe { + let _ = FreeLibrary(module); + } + return Err(format!("GameInputCreate failed: {hr:?}")); + } + Ok(Self { module, root }) + } + + /// The current reading for `kind`, if any device is producing one. + fn reading(&self, kind: u32) -> Option<*mut c_void> { + let mut reading: *mut c_void = std::ptr::null_mut(); + // SAFETY: `self.root` is a live IGameInput; slot 4 is GetCurrentReading with this exact + // signature (GameInput.h). A null `device` means "any device", which is what we want. + let hr = unsafe { + let vtbl = *(self.root as *mut *const IGameInputVtbl); + ((*vtbl).GetCurrentReading)(self.root, kind, std::ptr::null_mut(), &mut reading) + }; + (hr.is_ok() && !reading.is_null()).then_some(reading) + } + + /// Poll for a reading, because GameInput's device enumeration is ASYNCHRONOUS. + /// + /// A freshly created `IGameInput` has not finished enumerating yet, so the first + /// `GetCurrentReading` reliably returns nothing even with a pad actively reporting — measured + /// on `.173` 2026-08-09, where a sweeping devtest pad and a live DualSense both read "no + /// reading" on the first call. This is the GameInput analogue of `wake_wgi`: the API looks like + /// a query and is really a cache someone else fills. + /// + /// ⚠️ Focus is NOT the cause and was ruled out from the header: `GameInputDefaultFocusPolicy` + /// is 0 and every `GameInputFocusPolicy` flag is a RESTRICTION + /// (`GameInputDisableBackgroundInput`, `GameInputExclusiveForegroundInput`, …), so the default + /// already admits background input. Do not "fix" this by calling `SetFocusPolicy`. + fn reading_wait(&self, kind: u32, timeout: std::time::Duration) -> Option<*mut c_void> { + let deadline = std::time::Instant::now() + timeout; + loop { + if let Some(r) = self.reading(kind) { + return Some(r); + } + if std::time::Instant::now() >= deadline { + return None; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + } + + /// `(vendorId, productId)` for a device, read straight off `GameInputDeviceInfo`. + /// + /// The struct begins `uint32_t infoSize; uint16_t vendorId; uint16_t productId; …` + /// (GameInput.h), so the two ids sit at byte offsets 4 and 6. Only those two are read — the + /// rest of the struct carries variable-size members whose layout we would have to mirror + /// exactly, and nothing here needs them. + fn device_ids(dev: *mut c_void) -> (u16, u16) { + // SAFETY: `dev` is a live IGameInputDevice; slot 3 is GetDeviceInfo, which returns a + // pointer to a struct owned by the runtime and valid for the device's lifetime. + unsafe { + let vtbl = *(dev as *mut *const IGameInputDeviceVtbl); + let info = ((*vtbl).GetDeviceInfo)(dev) as *const u8; + if info.is_null() { + return (0, 0); + } + ( + u16::from_le_bytes([*info.add(4), *info.add(5)]), + u16::from_le_bytes([*info.add(6), *info.add(7)]), + ) + } + } + + /// Take the device off a reading (AddRef'd), releasing the reading. + fn device_of(r: *mut c_void) -> *mut c_void { + let mut dev: *mut c_void = std::ptr::null_mut(); + // SAFETY: `r` is a live IGameInputReading; slot 6 is GetDevice (returns void, hands back + // an AddRef'd device), slot 2 is Release. + unsafe { + let vtbl = *(r as *mut *const IGameInputReadingVtbl); + ((*vtbl).GetDevice)(r, &mut dev); + ((*vtbl).Release)(r); + } + dev + } + + /// Hunt for a device with `pid`, polling because several devices take turns reporting and + /// `GetCurrentReading(kind, null, …)` hands back whichever one spoke most recently. A box with + /// a chatty pad on it (a DualSense streams continuously) will otherwise never yield ours. + fn find_device(&self, pid: u16, timeout: std::time::Duration) -> Option<*mut c_void> { + let deadline = std::time::Instant::now() + timeout; + let mut seen: Vec<(u16, u16)> = Vec::new(); + loop { + for kind in [GAME_INPUT_KIND_GAMEPAD, GAME_INPUT_KIND_CONTROLLER] { + if let Some(r) = self.reading(kind) { + let dev = Self::device_of(r); + if !dev.is_null() { + let ids = Self::device_ids(dev); + if !seen.contains(&ids) { + seen.push(ids); + println!(" saw device {:04X}:{:04X}", ids.0, ids.1); + } + if ids.1 == pid { + return Some(dev); + } + // SAFETY: not our target; drop our reference. + unsafe { + let v = *(dev as *mut *const IGameInputDeviceVtbl); + ((*v).Release)(dev); + } + } + } + } + if std::time::Instant::now() >= deadline { + return None; + } + std::thread::sleep(std::time::Duration::from_millis(60)); + } + } + + /// Does GameInput see a gamepad at all? This is the matrix row. + pub fn report(&self) { + for (kind, label) in [ + (GAME_INPUT_KIND_GAMEPAD, "Gamepad"), + (GAME_INPUT_KIND_CONTROLLER, "Controller"), + ] { + match self.reading_wait(kind, std::time::Duration::from_secs(3)) { + Some(r) => { + println!(" {label:<11}: reading available (a device is producing input)"); + // SAFETY: `r` is a live IGameInputReading we own a reference to. + unsafe { + let vtbl = *(r as *mut *const IGameInputReadingVtbl); + ((*vtbl).Release)(r); + } + } + None => println!(" {label:<11}: no reading"), + } + } + } + + /// Drive `SetRumbleState` on whatever device is currently reporting. + /// + /// Returns whether a device was found and driven. The values are deliberately the caller's to + /// choose: the whole point of the probe is sending four DISTINCT magnitudes so the pad's decoded + /// output identifies the channel mapping by itself. + pub fn rumble( + &self, + params: GameInputRumbleParams, + hold: std::time::Duration, + target_pid: Option, + ) -> bool { + // A gamepad reading is the right one to hang this off: it is the kind an Xbox pad produces, + // and the device it names is the one a game would rumble. + let wait = std::time::Duration::from_secs(6); + let dev = match target_pid { + Some(pid) => { + println!(" hunting for PID {pid:04X} …"); + match self.find_device(pid, wait) { + Some(d) => d, + None => { + println!(" never saw PID {pid:04X} — nothing to rumble"); + return false; + } + } + } + None => { + let Some(r) = self + .reading_wait(GAME_INPUT_KIND_GAMEPAD, wait) + .or_else(|| self.reading_wait(GAME_INPUT_KIND_CONTROLLER, wait)) + else { + println!(" no GameInput reading — nothing to rumble"); + return false; + }; + Self::device_of(r) + } + }; + if dev.is_null() { + println!(" reading had no device"); + return false; + } + let ids = Self::device_ids(dev); + println!(" driving {:04X}:{:04X}", ids.0, ids.1); + println!( + " SetRumbleState(low={:.2} high={:.2} lt={:.2} rt={:.2}) for {:?}", + params.lowFrequency, + params.highFrequency, + params.leftTrigger, + params.rightTrigger, + hold + ); + // SAFETY: `dev` is a live IGameInputDevice; slot 10 is SetRumbleState, which returns void + // and takes a const pointer to the four-float struct above. + unsafe { + let vtbl = *(dev as *mut *const IGameInputDeviceVtbl); + ((*vtbl).SetRumbleState)(dev, ¶ms); + } + std::thread::sleep(hold); + let off = GameInputRumbleParams::default(); + // SAFETY: as above; stopping is the same call with zeroes. + unsafe { + let vtbl = *(dev as *mut *const IGameInputDeviceVtbl); + ((*vtbl).SetRumbleState)(dev, &off); + ((*vtbl).Release)(dev); + } + println!(" cleared."); + true + } +} diff --git a/tools/win-input-matrix/src/main.rs b/tools/win-input-matrix/src/main.rs index 78ba28a7..545ff65c 100644 --- a/tools/win-input-matrix/src/main.rs +++ b/tools/win-input-matrix/src/main.rs @@ -28,6 +28,9 @@ fn main() { std::process::exit(2); } +#[cfg(windows)] +mod gameinput; + #[cfg(windows)] mod imp { use std::time::Duration; @@ -486,6 +489,9 @@ mod imp { let args: Vec = std::env::args().skip(1).collect(); let mut rounds = 0usize; let mut rumble_slot: Option = None; + let mut gameinput_report = false; + let mut gi_rumble: Option = None; + let mut gi_pid: Option = None; let mut i = 0; while i < args.len() { match args[i].as_str() { @@ -493,6 +499,17 @@ mod imp { rounds = args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(20); i += 1; } + "--gameinput" => gameinput_report = true, + "--gi-pid" => { + gi_pid = args + .get(i + 1) + .and_then(|v| u16::from_str_radix(v.trim_start_matches("0x"), 16).ok()); + i += 1; + } + "--gi-rumble" => { + gi_rumble = args.get(i + 1).cloned(); + i += 1; + } "--rumble" => { rumble_slot = Some(args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(0)); i += 1; @@ -560,6 +577,28 @@ mod imp { println!(); rumble(slot, 3); } + if gameinput_report || gi_rumble.is_some() { + println!("\n== GameInput =="); + match crate::gameinput::GameInput::create() { + Err(e) => println!(" unavailable: {e}"), + Ok(gi) => { + gi.report(); + if let Some(spec) = &gi_rumble { + let v: Vec = spec + .split(',') + .map(|p| p.trim().parse().unwrap_or(0.0)) + .collect(); + let p = crate::gameinput::GameInputRumbleParams { + lowFrequency: v.first().copied().unwrap_or(0.0), + highFrequency: v.get(1).copied().unwrap_or(0.0), + leftTrigger: v.get(2).copied().unwrap_or(0.0), + rightTrigger: v.get(3).copied().unwrap_or(0.0), + }; + gi.rumble(p, std::time::Duration::from_secs(3), gi_pid); + } + } + } + } } } -- 2.54.0 From 7f1f7ba87c3702c0ddf578662abc893e20b96f41 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 23:29:30 +0200 Subject: [PATCH 15/16] fix(pads/windows): say WHY a pad index is taken, and stop the devtest lying when it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debugging the on-glass session, a devtest run died with error=create gamepad bootstrap mailbox Global\pfds-boot-0: Zugriff verweigert (0x80070005) (install/repair: punktfunk-host.exe driver install --gamepad) and then — this is the part that cost real time — kept printing "virtual Xbox One S Controller up", streamed frames into nothing, and let the operator measure the INCUMBENT pad on that index. The XInput packet count sat frozen and read as "the pad is dead", which was a wrong conclusion drawn from a harness that had already failed and not said so. WHAT IT ACTUALLY WAS. Pad lifetime is deliberately tied to the SESSION (native/input.rs: "the gamepads are created and torn down with the session"), and a live session's pad legitimately owns `Global\pfds-boot-0`. The mailbox's SDDL is `D:P(A;;GA;;;SY)(A;;GA;;;LS)` — SYSTEM and LocalService only — and the host service runs as LocalSystem while a hand-run devtest runs as an elevated Administrator, which is in neither ACE. `CreateFileMappingW` over an existing name is really an OPEN, access-checked against the incumbent's DACL, so it returned ACCESS_DENIED and bailed at the `?` BEFORE reaching the `ERROR_ALREADY_EXISTS` branch that already had the right sentence. That branch only ever fires when both processes run as the same account. The name is per-index on purpose and stays that way: `Global\pfds-boot-{index}` is the rendezvous the driver polls, and its existence doubles as host-liveness. Making it per-process would let two hosts build two devices on one wire index — the "the game sees two controllers" bug. The collision is correct; only the diagnosis was wrong. * `gamepad_raii.rs` classifies the failure: on ACCESS_DENIED it probes with `OpenFileMappingW`, which separates what the OS collapsed — object-manager lookup precedes the access check, so absent gives FILE_NOT_FOUND and present-but-forbidden gives ACCESS_DENIED. It now says the mailbox belongs to a live session's pad and that nothing is wrong with the drivers. * `pad_slots.rs` carries that as a typed `PadCreateFault` through the anyhow chain, so `ensure` prints the fault's remedy instead of the per-backend reinstall hint, plus the pad index. * `devtest.rs` now BAILS when no pad was actually built, instead of announcing success. This is the fix that matters: every probe an operator runs next will still find a device on that index. * `native.rs` names what a detached input thread still holds, since that is one of the ways a pad can outlive its session. DELIBERATELY NOT CHANGED, with reasons: the session-scoped pad lifetime (intentional and documented); the mailbox naming (load-bearing, above); the retry/backoff (latching would resurrect the `broken` flag `PadGate` exists to kill); the 10 s thread-detach in `serve_session` and the service's `TerminateProcess` shutdown — both are real ways a devnode can outlive its owner, but neither is evidenced in the field case and inventing a fix for an unobserved path is how you get a regression instead of a bugfix. `pf-inject/lib.rs` drops the `cfg(any(linux, windows))` gate on `pad_gate`/`pad_slots`. Neither touches an OS pad API, and the gate meant a classification whose entire subject is a `cfg(windows)` failure could not be tested on a dev machine at all. VERIFIED * ON WINDOWS (.173): `cargo test -p pf-inject --lib` 109/109; `cargo build -p punktfunk-host` clean. Both agents' Windows code was compile-UNVERIFIED before this run. * macOS: 5 new tests, including one that pins the anyhow downcast through the exact three-layer context chain the Windows code builds — the assumption that could not otherwise be checked. * `cargo fmt --all --check` clean. NOT VERIFIED * That a LocalSystem-owned mailbox really answers `OpenFileMappingW` with ACCESS_DENIED rather than FILE_NOT_FOUND from an Administrator token. That is reasoned from the object manager's lookup-then-access-check order, not measured. Repro on .173: hold a session pad on index 0, run the devtest from an elevated console, and check the new sentence appears. --- crates/pf-inject/src/inject/pad_slots.rs | 186 +++++++++++++++++- crates/pf-inject/src/inject/uhid_manager.rs | 13 ++ .../src/inject/windows/gamepad_raii.rs | 107 +++++++++- .../src/inject/windows/gamepad_windows.rs | 7 + crates/pf-inject/src/lib.rs | 13 +- crates/punktfunk-host/src/devtest.rs | 24 +++ crates/punktfunk-host/src/native.rs | 13 +- 7 files changed, 350 insertions(+), 13 deletions(-) diff --git a/crates/pf-inject/src/inject/pad_slots.rs b/crates/pf-inject/src/inject/pad_slots.rs index e84a74a9..2b69235d 100644 --- a/crates/pf-inject/src/inject/pad_slots.rs +++ b/crates/pf-inject/src/inject/pad_slots.rs @@ -16,6 +16,78 @@ const _: () = assert!(MAX_PADS <= 16); /// quiet. const SWEEP_GRACE: Duration = Duration::from_millis(300); +/// A create failure whose CAUSE the backend was able to identify, attached to the `anyhow` error +/// it returns (`err.context(PadCreateFault::…)`) so [`PadSlots::ensure`] can print the matching +/// remedy instead of the backend's default one. +/// +/// Why this exists. The create-failure line's remedy is a per-backend constant (`PadSlots`'s +/// `hint`, from [`PadSlots::new`]), and on Windows that constant says "install/repair: punktfunk-host.exe +/// driver install --gamepad", because a pad create that fails there has nearly always failed for +/// want of the UMDF driver package. Nearly. On 2026-08-09 a `.173` devtest hit a create that +/// failed for the opposite reason — the drivers were fine and a LIVE SIBLING PROCESS already owned +/// the pad index's OS-level name — and the line told the operator to repair a driver that was +/// working. Worse, the run carried on: the retry could not succeed while the other process held +/// the index, and everything measured afterwards was that other process's pad (a frozen XInput +/// packet count read as a real measurement). A wrong remedy is worse than no remedy, so a backend +/// that can name the cause now says so and the line follows it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PadCreateFault { + /// The OS-level name this pad index needs — on Windows the `Global\pf…-boot-` bootstrap + /// mailbox — is already held by another LIVE process. + /// + /// Retrying stays right and is deliberately left alone: the name frees itself the moment the + /// owner releases it (a session ending, a service restart), and that is exactly how the field + /// case recovered. What retrying can never do is *hurry* it, and no driver install affects it + /// at all — which is the whole content of [`Self::hint`]. + IndexOwnedElsewhere, +} + +impl PadCreateFault { + /// Short tag for the structured `fault` log field — greppable; the prose lives in + /// [`Self::hint`]. + pub fn as_str(self) -> &'static str { + match self { + PadCreateFault::IndexOwnedElsewhere => "index-owned-elsewhere", + } + } + + /// The remedy this fault gets INSTEAD of the backend's default hint. + pub fn hint(self) -> &'static str { + match self { + PadCreateFault::IndexOwnedElsewhere => { + " — this pad index is already owned by another LIVE process (on a Windows host \ + that is the LocalSystem PunktfunkHost service, whose session still holds the \ + pad). The drivers are not the problem and reinstalling them will not help: the \ + retry succeeds on its own once that process releases the index (end its session, \ + or Restart-Service PunktfunkHost), or run against a pad index it does not hold." + } + } + } +} + +impl std::fmt::Display for PadCreateFault { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PadCreateFault::IndexOwnedElsewhere => f.write_str( + "the OS name this pad index needs is already owned by another live process", + ), + } + } +} + +/// The fault a backend attached to a create error, if any. +/// +/// An `anyhow` context downcast, which is what makes this usable from a backend: the fault is +/// found however many further `.context()` layers were wrapped around it on the way up, so a +/// backend can attach it at the exact call that failed and still describe the failure in its own +/// words afterwards. Split out of [`PadSlots::ensure`] so the choice is testable without standing +/// up a tracing subscriber — and so the downcast-through-context behaviour this depends on is +/// pinned by a test rather than assumed (the attaching code is `cfg(windows)` and cannot be +/// compiled, let alone run, on a developer machine). +fn create_fault(err: &anyhow::Error) -> Option { + err.downcast_ref::().copied() +} + /// What one [`PadSlots::sweep`] changed, as bitmasks over the wire pad indices. #[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] pub struct Sweep { @@ -172,11 +244,24 @@ impl

PadSlots

{ true } Err(e) => { + // Which remedy to print. The backend's default `hint` assumes the failure is the + // one that dominates the field — on Windows an absent or stale driver package — + // and sends the operator to reinstall. For a create that failed because a live + // sibling owns this index that advice is not merely useless, it is a wrong lead + // that costs a debugging session (2026-08-09, `.173`), so a named fault overrides + // it. Anonymous failures keep the previous wording byte for byte. + // + // `index` is new and unconditional: the line used to name the backend and the + // device but never the SLOT, so a multi-pad session's failure could not be told + // from any other pad's. + let fault = create_fault(&e); tracing::error!( + index = idx, error = %format!("{e:#}"), + fault = fault.map_or("unclassified", PadCreateFault::as_str), "virtual {} creation failed — retrying with backoff{}", self.device, - self.hint + fault.map_or(self.hint, PadCreateFault::hint) ); self.gate.on_failure(Instant::now()); false @@ -184,6 +269,18 @@ impl

PadSlots

{ } } + /// How many pads this table currently holds. + /// + /// The question a bring-up harness has to ask before it believes anything it measures: a + /// create that failed leaves the slot empty and [`Self::ensure`] only logs, so a devtest that + /// pushes frames regardless is measuring whatever OTHER process's pad is answering on that + /// index — which is exactly how a stale pad's frozen packet count was once read as a result + /// (2026-08-09). Not `len` (and so not paired with `is_empty`): it counts LIVE pads, not the + /// fixed [`MAX_PADS`] slots the table always has. + pub fn live(&self) -> usize { + self.pads.iter().flatten().count() + } + /// The live pad at `idx`, if any (out-of-range → `None`). pub fn get(&self, idx: usize) -> Option<&P> { self.pads.get(idx).and_then(|s| s.as_ref()) @@ -350,6 +447,93 @@ mod tests { assert_eq!(s.get(1), Some(&7), "the glitch never reached the drop"); } + /// The mechanism the Windows backend's diagnosis rests on, and the one thing about it that + /// could quietly stop working: [`create_fault`] must find the fault through however many + /// `.context()` layers wrapped it. The real chain is built in `gamepad_raii::create_named` + /// (`cfg(windows)`, so neither compiled nor run here) and has exactly this shape — the OS + /// error at the bottom, the fault, then the human sentence on top — so reproduce it verbatim. + #[test] + fn a_named_fault_survives_the_context_layers_wrapped_around_it() { + let err = anyhow::Error::msg("Zugriff verweigert (0x80070005)") + .context(PadCreateFault::IndexOwnedElsewhere) + .context("bootstrap mailbox Global\\pfds-boot-0 already exists"); + assert_eq!( + create_fault(&err), + Some(PadCreateFault::IndexOwnedElsewhere) + ); + // …and the operator-facing rendering still carries every layer, newest first, so the + // underlying OS error is never traded away for the diagnosis. + let shown = format!("{err:#}"); + assert!(shown.contains("Global\\pfds-boot-0"), "{shown}"); + assert!( + shown.contains("already owned by another live process"), + "{shown}" + ); + assert!(shown.contains("0x80070005"), "{shown}"); + } + + #[test] + fn an_unclassified_failure_carries_no_fault() { + // Every other backend failure — a missing driver, a wedged PnP, an EBUSY on /dev/uinput — + // must keep the backend's own hint, so the absence of a fault has to read as absence. + assert_eq!( + create_fault(&anyhow::Error::msg("SwDeviceCreate failed")), + None + ); + } + + /// THE regression this classification exists for: the contended remedy must not send an + /// operator to reinstall a driver that is working fine, and must name what actually has to + /// happen. Asserted on the text because the text is the whole deliverable. + #[test] + fn the_contended_hint_never_tells_the_operator_to_reinstall_drivers() { + let hint = PadCreateFault::IndexOwnedElsewhere.hint(); + assert!( + !hint.contains("driver install"), + "the contended hint must not repeat the driver-repair advice: {hint}" + ); + assert!(hint.contains("already owned"), "{hint}"); + assert!(hint.contains("Restart-Service"), "{hint}"); + } + + /// A named fault must not turn the create into a permanent latch — that latch is the exact + /// `broken: bool` behaviour [`PadGate`] was built to remove, and the field case healed by + /// itself precisely because the retry was still running when the owning service restarted. + #[test] + fn a_contended_create_still_backs_off_and_retries_rather_than_latching() { + let mut s = slots(); + let contended = || { + Err(anyhow::Error::msg("Zugriff verweigert") + .context(PadCreateFault::IndexOwnedElsewhere)) + }; + assert!(!s.ensure(0, |_| contended())); + assert_eq!(s.live(), 0); + // Backed off, not latched: once the window elapses the closure runs again. `ensure` reads + // the wall clock, so clear the backoff directly rather than sleeping through it — the + // window's own arithmetic is pinned by `pad_gate`'s tests. + s.gate.on_success(); + let mut ran = false; + assert!(s.ensure(0, |i| { + ran = true; + Ok(i as u32) + })); + assert!(ran, "the create was never re-attempted"); + assert_eq!(s.live(), 1); + } + + #[test] + fn live_counts_built_pads_not_slots() { + let mut s = slots(); + assert_eq!(s.live(), 0, "an empty table has no pads, only slots"); + assert!(s.ensure(0, |_| Ok(0))); + assert!(s.ensure(4, |_| Ok(4))); + assert_eq!(s.live(), 2); + let t0 = Instant::now(); + s.sweep_at(0, t0); + s.sweep_at(0, t0 + SWEEP_GRACE); + assert_eq!(s.live(), 0); + } + #[test] fn create_failure_arms_the_gate_and_success_heals_it() { let mut s = slots(); diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index 9ceb5a41..715dc9f2 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -274,6 +274,19 @@ impl UhidManager { } } + /// How many virtual pads this manager has actually BUILT + /// ([`PadSlots::live`](crate::pad_slots::PadSlots::live)). + /// + /// For bring-up harnesses, which are the only callers that can act on it: a create failure + /// leaves the slot empty and only logs, so a harness that pushes frames regardless still + /// "works" — it just drives nothing, while whatever OTHER process owns that pad index keeps + /// answering every probe the operator then runs. That is how a stale pad's frozen XInput + /// packet count was once read as a measurement (2026-08-09, `.173`). A session has no use for + /// this: its pads come and go with the client's `active_mask` and zero is a normal state. + pub fn live_pads(&self) -> usize { + self.slots.live() + } + /// Handle one decoded controller event (create/destroy by mask, then merge button/stick state). pub fn handle(&mut self, ev: &GamepadEvent) { match ev { diff --git a/crates/pf-inject/src/inject/windows/gamepad_raii.rs b/crates/pf-inject/src/inject/windows/gamepad_raii.rs index dded23fc..8ae738cb 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_raii.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_raii.rs @@ -53,7 +53,8 @@ use super::channel_proof; /// Re-exported so a pad backend needs only one `use` to wire up its channel. pub(super) use super::channel_proof::ProofTransport; -use anyhow::{anyhow, bail, Context, Result}; +use crate::pad_slots::PadCreateFault; +use anyhow::{anyhow, Context, Result}; use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION}; use std::ffi::c_void; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; @@ -67,16 +68,17 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{ }; use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE}; use windows::Win32::Foundation::{ - DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS, - ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WIN32_ERROR, + CloseHandle, DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS, + ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, + WIN32_ERROR, }; use windows::Win32::Security::Authorization::{ ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, }; use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; use windows::Win32::System::Memory::{ - CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, - MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, + CreateFileMappingW, MapViewOfFile, OpenFileMappingW, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, + FILE_MAP_READ, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, }; use windows::Win32::System::Threading::{ GetCurrentProcess, OpenProcess, SetEvent, WaitForSingleObject, PROCESS_DUP_HANDLE, @@ -171,6 +173,16 @@ impl Shm { /// don't control — we close and retry briefly (our own driver holds the name for microseconds per /// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or /// another host instance's) mailbox. + /// + /// ⚠️ That squat check only ever sees the collisions we are ALLOWED to see. `CreateFileMappingW` + /// opens a pre-existing object with full access, so a caller the incumbent's DACL excludes is + /// refused with `ERROR_ACCESS_DENIED` and never reaches the `ERROR_ALREADY_EXISTS` branch at + /// all — and that is the collision the field actually produces, because this SDDL grants SYSTEM + /// and LocalService only, while the host service runs as LocalSystem and a hand-run devtest + /// runs as an elevated Administrator. So the "another punktfunk-host instance is serving this + /// pad index" diagnosis below was unreachable for the one pairing that happens: on `.173` + /// (2026-08-09) it surfaced as a bare `Zugriff verweigert (0x80070005)` under a line telling the + /// operator to reinstall the drivers. [`classify_named_create_failure`] is what restores it. pub(super) fn create_named(name: &HSTRING, size: usize) -> Result { // Build the descriptor ONCE and reuse it across the squat-retry loop — it (and the OS // allocation it owns) lives to the end of this fn, so it outlives every create below. @@ -183,8 +195,10 @@ impl Shm { } // SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous. unsafe { SetLastError(WIN32_ERROR(0)) }; - let shm = Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size) - .with_context(|| format!("create gamepad bootstrap mailbox {name}"))?; + let shm = match Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size) { + Ok(shm) => shm, + Err(e) => return Err(classify_named_create_failure(name, e)), + }; // SAFETY: read immediately after the create; windows-rs only touches the error slot on // failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal. if unsafe { GetLastError() } != ERROR_ALREADY_EXISTS { @@ -192,11 +206,16 @@ impl Shm { } // `shm` drops here → unmap + close our handle to the foreign object, then retry. } - bail!( + // Reached only when we COULD open the incumbent (same account — two hosts both as SYSTEM, + // or a LocalService squatter). The cross-account case exits through + // `classify_named_create_failure` above; both carry the same fault, because to everything + // downstream they are the same event: this index is taken. + Err(anyhow!( "bootstrap mailbox {name} already exists and stayed alive across retries — another \ punktfunk-host instance is serving this pad index, or a local service is squatting the \ name (gamepad DoS attempt?)" - ); + ) + .context(PadCreateFault::IndexOwnedElsewhere)) } fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result { @@ -250,6 +269,76 @@ impl Drop for Shm { } } +/// Turn a failed NAMED-section create into an error that names the cause, because +/// `CreateFileMappingW` collapses two OPPOSITE situations into one `ERROR_ACCESS_DENIED` +/// (`0x80070005`, and on a German box the entirely unsearchable "Zugriff verweigert" the field +/// report carried): +/// +/// * **the name is TAKEN, by someone whose object we may not open.** Creating over an existing name +/// is really an open, and an open is access-checked against the incumbent's DACL. The mailbox +/// SDDL grants SYSTEM + LocalService only, so the exact pairing that occurs on a dev box — the +/// LocalSystem host service holding pad 0 for a live session while an operator runs +/// `punktfunk-host.exe dualsense-windows-test` from an elevated Administrator console — is +/// refused here rather than reported as the squat it is. +/// * **the name is FREE and we may not create it.** `Global\` names need `SeCreateGlobalPrivilege`, +/// which SYSTEM and services hold and an ordinary (even elevated) user token does not. +/// +/// `OpenFileMappingW` separates them, because the object-manager lookup happens BEFORE the access +/// check: an absent name is `ERROR_FILE_NOT_FOUND`, a present one we are not in the DACL of is +/// `ERROR_ACCESS_DENIED`. Everything else keeps the original wording. +/// +/// The contended case additionally carries a [`PadCreateFault`], which is what stops the pad +/// manager's failure line from telling the operator to reinstall a driver that is working +/// perfectly (see [`crate::pad_slots::PadCreateFault`]). +fn classify_named_create_failure(name: &HSTRING, e: anyhow::Error) -> anyhow::Error { + let denied = e + .downcast_ref::() + .is_some_and(|w| w.code() == HRESULT::from_win32(ERROR_ACCESS_DENIED.0)); + if !denied { + return e.context(format!("create gamepad bootstrap mailbox {name}")); + } + if named_section_exists(name) { + return e + .context(PadCreateFault::IndexOwnedElsewhere) + .context(format!( + "bootstrap mailbox {name} exists and belongs to a process this one may not open — a \ + live session's pad, held by the LocalSystem host service (its mailboxes grant SYSTEM \ + + LocalService only, so an Administrator console sees ACCESS_DENIED, not \ + ALREADY_EXISTS). Nothing is wrong with the drivers" + )); + } + e.context(format!( + "create gamepad bootstrap mailbox {name}: access denied although the name is FREE — this \ + process may not create Global\\ objects at all (that needs SeCreateGlobalPrivilege, which \ + SYSTEM and services hold and a user token does not)" + )) +} + +/// Whether a section with this name exists right now, as seen from THIS process — the +/// disambiguation [`classify_named_create_failure`] runs on. `true` also when the object is there +/// but closed to us, which is the case that matters: ACCESS_DENIED from an OPEN means the name +/// resolved and only the access check failed. +/// +/// Deliberately not a security decision — a hostile squatter can make this say either thing. It +/// only ever chooses which sentence to print. +fn named_section_exists(name: &HSTRING) -> bool { + // SAFETY: `name` is a live NUL-terminated UTF-16 string for the duration of the call. Ask for + // the least access there is (`FILE_MAP_READ`): the handle is closed immediately and never + // mapped — we want the lookup's verdict, not the object. + let opened = unsafe { OpenFileMappingW(FILE_MAP_READ.0, false, PCWSTR(name.as_ptr())) }; + match opened { + Ok(h) => { + // SAFETY: `h` is the handle just opened here and referenced nowhere else. + unsafe { + let _ = CloseHandle(h); + } + true + } + // ERROR_FILE_NOT_FOUND (and anything else) reads as absent; ACCESS_DENIED is presence. + Err(e) => e.code() == HRESULT::from_win32(ERROR_ACCESS_DENIED.0), + } +} + // ── The sealed-channel bootstrap broker ───────────────────────────────────────────────────────── /// Global delivery sequence for [`PadBootstrap::handle_seq`] — host-wide monotonic and never 0, so two diff --git a/crates/pf-inject/src/inject/windows/gamepad_windows.rs b/crates/pf-inject/src/inject/windows/gamepad_windows.rs index 22c54bbb..8b74de48 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_windows.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_windows.rs @@ -296,6 +296,13 @@ impl GamepadManager { } } + /// How many virtual pads this manager has actually BUILT — the bring-up harness's + /// "did the create happen?" check; see [`crate::uhid_manager::UhidManager::live_pads`] for why + /// only a harness should ask. + pub fn live_pads(&self) -> usize { + self.slots.live() + } + fn ensure(&mut self, idx: usize) { if self.slots.ensure(idx, XusbWinPad::open) { tracing::info!( diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index 0bdfdd28..dd1b3eb0 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -389,13 +389,22 @@ pub mod mouse_windows; /// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]), driven by [`pad_slots`] for /// every backend manager — replaces the per-backend permanent `broken` latch with capped-backoff /// retry. -#[cfg(any(target_os = "linux", target_os = "windows"))] +/// +/// Built on every target, not just the two that have pad backends: it is pure timing arithmetic +/// over `std::time`, and gating it meant its tests — and [`pad_slots`]', which need it — could not +/// run on a developer machine at all. See [`pad_slots`]. #[path = "inject/pad_gate.rs"] pub mod pad_gate; /// Shared virtual-pad slot table + creation lifecycle ([`pad_slots::PadSlots`]) — the /// `Vec>` table, `active_mask` unplug sweep, and gate-checked create every backend /// manager used to copy-paste (G12). -#[cfg(any(target_os = "linux", target_os = "windows"))] +/// +/// Built on every target for the same reason as [`pad_gate`]: nothing in it touches an OS pad API +/// (the backend supplies the pad type and the `open` closure), so the platform gate bought +/// nothing and cost the ability to run the table's tests off a host box. That matters most for +/// [`pad_slots::PadCreateFault`], whose whole job is to describe a `cfg(windows)` failure that +/// only a Windows box can produce — the classification either has tests that run everywhere, or +/// it has none that anyone runs. #[path = "inject/pad_slots.rs"] pub mod pad_slots; /// The `sensor_timestamp` every virtual Sony pad stamps into its input reports diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 525bbb57..5b47a4ce 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -398,6 +398,23 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { capabilities: 0, audio_caps: 0, }); + // 🛑 Never announce a pad that was not built. The arrival above only ASKS for one; a + // failed create logs an ERROR and leaves the slot empty, and this harness would then + // cheerfully print "virtual X up" and stream frames into nothing for `secs` seconds. + // Every probe the operator runs next (joy.cpl, XInputGetState, a WGI enumeration) still + // finds a device on this index — the one the OTHER process owns — so the run produces a + // plausible, wrong measurement instead of a failure. That happened on `.173` + // (2026-08-09): the host service held pad 0, the create was denied, and a frozen XInput + // packet count off the incumbent pad was read as a result. A harness that cannot build + // its own device has nothing to measure, so stop. + if mgr.live_pads() == 0 { + anyhow::bail!( + "no virtual {} was created at index {idx} — see the ERROR above for the \ + cause. NOT measuring: any device answering on this index belongs to another \ + process (a live session's pad), and reading it would look like a result.", + $label + ); + } println!( "virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \ it in joy.cpl / Steam / a game; any feedback the game sends prints below.", @@ -458,6 +475,13 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { capabilities: 0, audio_caps: 0, }); + // Same guard as the `drive!` macro's — see the long note there. + if mgr.live_pads() == 0 { + anyhow::bail!( + "no virtual Xbox 360 (XUSB) was created at index {idx} — see the ERROR above. NOT \ + measuring: a device answering on this index belongs to another process." + ); + } println!( "virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \ an XInput game or xinputtest.exe." diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 578e6f51..6f205080 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1824,9 +1824,20 @@ async fn serve_session( .await .is_err() { + // Name what is still held, not just that a thread was let go. The input thread OWNS this + // session's virtual gamepads (`input_thread`'s `Pads`, dropped only when that fn returns), + // and on Windows each one holds a `SwDeviceCreate` devnode plus the `Global\pf…-boot-` + // bootstrap mailbox for its pad index. Detaching therefore leaves the pads plugged in and + // the index taken: the next session — or a bring-up run beside this host — is denied that + // index until this thread finally returns, and *that* failure surfaces somewhere else + // entirely (see `pf_inject::pad_slots::PadCreateFault::IndexOwnedElsewhere`). An operator + // reading only the later error has no way back to this line unless it says so here. tracing::warn!( grace_s = SIDE_THREAD_JOIN_GRACE.as_secs(), - "audio/input threads did not exit after the connection closed — detaching them" + "audio/input threads did not exit after the connection closed — detaching them. This \ + session's virtual gamepads are STILL HELD by the detached input thread (devnode + \ + pad-index mailbox on Windows), so a pad create on the same index will be refused as \ + already-owned until it returns" ); } // The capture (and our gamescope session's VirtualOutput) are gone by here. If this was the -- 2.54.0 From e19f11bb0d73f44136d426daad2262168e8c7579 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 23:30:02 +0200 Subject: [PATCH 16/16] =?UTF-8?q?feat(abi/apple):=20carry=20the=20trigger?= =?UTF-8?q?=20motors=20to=20non-Rust=20clients=20=E2=80=94=20ABI=2018,=20n?= =?UTF-8?q?ext=5Frumble=5Fcmd2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `0xCA` wire already carries the two Xbox impulse-trigger motors (v3), and the Rust decode path already parses them; `datagram_task.rs` dropped them on the floor with a comment naming exactly this work as what remained. The blocker was the C ABI: every non-Rust client pulls rumble through `punktfunk_connection_next_rumble_cmd`, whose out-params cannot carry two more channels. PunktfunkStatus punktfunk_connection_next_rumble_cmd2( PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high, uint16_t *left_trigger, uint16_t *right_trigger, uint32_t *backstop_ms, uint32_t timeout_ms); ⚠️ ADDED, not widened. `_cmd` keeps its signature and its values bit-identical for handle-only traffic — out-of-tree embedders depend on it and `docs/embedding-the-c-abi.md` documents it, so silently changing an exported symbol would break every consumer at once. `nm` on the staticlib shows all four rumble entry points still exported. `ABI_VERSION` 17 → 18; every other site reads it dynamically, so there are no hardcoded mirrors to drift. ⚠️ ONE HONEST BEHAVIOURAL DELTA, documented in `abi.rs` and pinned by a test: against a trigger-driving host a `_cmd` caller now receives commands with `low == high == 0` where the demux previously dropped the update entirely. They are idempotent handle stops, and the redundant-stop suppression cannot fold them because the command as a whole is not silent. Zero cost today — nothing sources non-zero triggers. The dedupe-jitter proof was RE-DERIVED rather than widened, which is the kind of thing that quietly rots when a tuple grows: the nudge touches only `low` by ±1 LSB and `emit` is only reached with a non-silent level, so the nudged tuple can collide with the four-field stop sentinel only at `(1,0,0,0)`. A test pins both directions — refuse at `(1,0,0,0)`, flip freely at `(1,0,lt,0)`. Apple renders them: `RumbleRenderer` gains `Motor?` slots at `GCHapticsLocality.leftTrigger` / `.rightTrigger` beside the existing handles. A controller without trigger actuators degrades silently — a nil engine yields a nil slot and `reconcile` no-ops — and absent localities are never logged, because on most pads that is the normal case rather than a fault. The macOS DualSense raw-HID branch stays a deliberate no-op: a DualSense has ADAPTIVE triggers, not trigger rumble motors, and inventing a mapping there would buzz the wrong thing. 🛑 BUILT AHEAD OF A PRODUCER, DELIBERATELY, AND NOTHING HERE CLAIMS OTHERWISE. Nothing can currently source trigger rumble on Windows and that is measured, not assumed: `XINPUT_VIBRATION` has two members, and GameInput — the only four-motor API — does not enumerate an xinputhid-promoted Xbox pad at all, verified against a REAL Microsoft Elite which is equally invisible to it while classic XInput reads it live. So this path has never been exercised end to end and the comments say so. VERIFIED * `cargo test -p punktfunk-core --features quic --lib` 378 passed on macOS, 203 on Windows; clippy `-D warnings` clean with and without default features; `cargo fmt --all --check` clean. * The generated header is regenerated and idempotent on re-run (CI diffs it). * SWIFT ACTUALLY COMPILES AND RUNS: `swift build` clean and `swift test` 262 passed / 0 failures in `clients/apple`, against a locally built xcframework. (Editor SourceKit errors about `PunktfunkCore`/`DualSenseHID` are index noise from that gitignored artifact — a real build resolves both, and the `DualSenseHID` references are untouched by this change.) * `cargo build -p punktfunk-host` clean on Windows. NOT VERIFIED * End to end — see above; there is no producer. * Whether a real Xbox pad on Apple actually reports the two trigger localities. The degrade needs no code, but the positive case is untested. * `pf-client-core` (the SDL renderer) does not build on macOS at baseline and is unbuilt here. It only reads `RumbleCommand` fields and never constructs one, so added fields cannot break it, but it still calls `_cmd`; wiring `SDL_RumbleGamepadTriggers` is separate work. ANDROID: NOT DONE, and it should stay that way for now. `pack_rumble` packs pad/backstop/low/high into bits 0..52 of a `jlong` with `-1` reserved as a sentinel — two more `u16` do not fit. The right fix if ever wanted is the direct-`ByteBuffer` shape `nativeNextHidout` already uses in the same file (zero-allocation, caller-owned, the established idiom), not a second `jlong` (racy across two calls) nor `long[]` (an allocation per pull). But no Android device exposes trigger actuators at all, so there is nothing to render. Separately stale and also not fixed: `NativeBridge.kt`'s KDoc still documents the v2 `ttl_ms` layout rather than `backstop_ms`. --- .../Connection/PunktfunkConnection.swift | 20 +- .../Gamepad/GamepadFeedback.swift | 16 +- .../PunktfunkKit/Gamepad/RumbleRenderer.swift | 104 +++++- crates/punktfunk-core/src/abi.rs | 110 +++++- crates/punktfunk-core/src/client/mod.rs | 7 +- .../src/client/pump/datagram_task.rs | 25 +- crates/punktfunk-core/src/client/rumble.rs | 328 ++++++++++++++---- crates/punktfunk-core/src/lib.rs | 13 +- docs/embedding-the-c-abi.md | 28 +- include/punktfunk_core.h | 80 ++++- 10 files changed, 626 insertions(+), 105 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 94e68868..e209cbae 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -1002,22 +1002,34 @@ public final class PunktfunkConnection { /// Pull the next EFFECTIVE rumble command from the core's shared rumble policy engine — the /// uniform replacement for per-platform rumble policy. The engine owns every decision /// (v2 lease expiry, legacy-host staleness at a uniform 1 s, connection-close drain zeros), - /// so apply commands verbatim: `(0, 0)` = stop now, non-zero = run at this level. + /// so apply commands verbatim: all-zero = stop now, non-zero = run at this level. /// `backstopMs` is a safety-net duration for duration-parameterized platform APIs — the /// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net). /// Drain from the (single) feedback thread, alongside `nextHidOutput`. + /// + /// A command carries FOUR motor levels: the two handles plus the two Xbox impulse-trigger + /// motors (`leftTrigger`/`rightTrigger`, same 0...0xFFFF scale), which arrive on the 0xCA + /// plane's v3 tail. This calls the core's `_cmd2` entry point — `_cmd` is the frozen + /// two-handle form kept for out-of-tree embedders, and there is no reason for this client to + /// stay on it: a pad that reports no `GCHapticsLocality.leftTrigger`/`.rightTrigger` simply + /// has no engine for those levels and they go nowhere, which is the normal case. public func nextRumbleCommand(timeoutMs: UInt32 = 0) throws - -> (pad: UInt16, low: UInt16, high: UInt16, backstopMs: UInt32)? + -> ( + pad: UInt16, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16, + backstopMs: UInt32 + )? { feedbackLock.lock() defer { feedbackLock.unlock() } guard let h = liveHandle() else { throw PunktfunkClientError.closed } var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, backstop: UInt32 = 0 - let rc = punktfunk_connection_next_rumble_cmd(h, &pad, &low, &high, &backstop, timeoutMs) + var lt: UInt16 = 0, rt: UInt16 = 0 + let rc = punktfunk_connection_next_rumble_cmd2( + h, &pad, &low, &high, <, &rt, &backstop, timeoutMs) switch rc { case statusOK: - return (pad, low, high, backstop) + return (pad, low, high, lt, rt, backstop) case statusNoFrame: return nil case statusClosed: diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift index 83bdc73e..e36c279a 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift @@ -172,7 +172,8 @@ public final class GamepadFeedback { while rumbleBurst < 64, !flag.isStopped, let c = try connection.nextRumbleCommand(timeoutMs: 0) { self?.routeRumble( - pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high) + pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high, + leftTrigger: c.leftTrigger, rightTrigger: c.rightTrigger) rumbleBurst += 1 } // Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing @@ -225,12 +226,21 @@ public final class GamepadFeedback { /// Route one engine command to its pad's renderer (drain thread). A command for a pad with no /// live renderer — one that just left the forwarded set — is dropped. - private func routeRumble(pad: UInt8, low: UInt16, high: UInt16) { + private func routeRumble( + pad: UInt8, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16 + ) { let renderer = withRouting { rumbleByPad[pad] } - renderer?.apply(low: low, high: high) + renderer?.apply(low: low, high: high, leftTrigger: leftTrigger, rightTrigger: rightTrigger) // The opt-in device mirror follows controller 1 unconditionally — the pads it exists for // have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on // that: capability probing can't see a motor-less MFi pad, and the user opted in. + // + // HANDLES ONLY, deliberately. A phone body is one actuator with no trigger analogue, so + // the trigger levels would have to be folded to arrive at all — and folding continuous + // impulse-trigger content (a racing title's engine RPM / tyre slip) onto the one motor + // this mirror has would buzz the phone flat-out for the whole race at a level the game + // never requested. Dropping them matches the core engine's policy for every pad without + // trigger motors. if pad == 0 { deviceRumble?.apply(low: low, high: high) } } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift index 8dd45af7..1e8386d1 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift @@ -36,7 +36,9 @@ enum RumbleTuning { /// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at /// distinct frequencies mirroring the real hardware they emulate — low/left ≈ the heavy /// low-frequency rotor, high/right ≈ the light buzzer; a single combined actuator keeps the - /// proven mid value. + /// proven mid value. The impulse-trigger motors are small and light — the same character as + /// the high/right buzzer — so they reuse `sharpnessHigh` rather than introduce a number + /// nobody has measured on real trigger hardware. static let sharpnessLow: Float = 0.3 static let sharpnessHigh: Float = 0.7 static let sharpnessCombined: Float = 0.5 @@ -140,9 +142,21 @@ final class RumbleRenderer: @unchecked Sendable { private var controller: GCController? private var low: Motor? private var high: Motor? - /// Wire-truth target (raw wire units) — the engine command's level, applied verbatim; the - /// core policy engine owns when it ends (explicit zero commands), so no deadline lives here. - private var target: (low: UInt16, high: UInt16) = (0, 0) + /// The two Xbox impulse-trigger motors, when the pad offers + /// `GCHapticsLocality.leftTrigger`/`.rightTrigger`. **Nil is the normal case** — every pad but + /// an Xbox One/Series/Elite has no such actuator, and the tree has already observed Xbox pads + /// on Apple exposing no haptics engine at all — so their absence is never logged and never + /// counts as a setup failure. Independent of the handle split: a pad may offer trigger + /// localities with or without split handles, and losing one does not implicate the other. + private var leftTrigger: Motor? + private var rightTrigger: Motor? + /// Wire-truth target (raw wire units) — the engine command's four levels, applied verbatim; + /// the core policy engine owns when it ends (explicit zero commands), so no deadline lives + /// here. The trigger levels are only ever non-zero against a Windows HID Xbox host pad; every + /// other backend on every OS lacks the channel entirely (XInput's `XINPUT_VIBRATION` and + /// evdev's `FF_RUMBLE` each carry exactly two magnitudes). + private var target: (low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16) = + (0, 0, 0, 0) /// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm, /// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent, /// so an idle controller costs no timer wakeups and no radio traffic. @@ -216,22 +230,28 @@ final class RumbleRenderer: @unchecked Sendable { } } - /// Set the wire-truth target. Called with every 0xCA state the host sends — level changes AND - /// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the - /// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`] - /// against a legacy host (no lease → the staleness watchdog is the backstop). Renewals at an - /// unchanged level extend the deadline before the idempotence guard, so a held rumble never - /// lapses mid-effect. - func apply(low lowAmp: UInt16, high highAmp: UInt16) { + /// Set the wire-truth target: one policy-engine command's four motor levels, applied verbatim. + /// Called with every 0xCA state the host sends — level changes AND renewals — and the core + /// engine owns when a level ends (it emits explicit zero commands), so nothing here decides. + /// + /// `leftTrigger`/`rightTrigger` are the Xbox impulse-trigger motors. They default to zero so + /// handle-only callers (the debug test panel, the tuning tests) read unchanged, which is also + /// the wire's own rule: on a level-triggered plane an absent level is off, never "keep what + /// you had". + func apply( + low lowAmp: UInt16, high highAmp: UInt16, leftTrigger ltAmp: UInt16 = 0, + rightTrigger rtAmp: UInt16 = 0 + ) { queue.async { - let active = lowAmp != 0 || highAmp != 0 + let next = (lowAmp, highAmp, ltAmp, rtAmp) + let active = next != (0, 0, 0, 0) if active != self.wasActive { self.wasActive = active log.debug( - "rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)") + "rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public) lt=\(ltAmp, privacy: .public) rt=\(rtAmp, privacy: .public)") } - guard (lowAmp, highAmp) != self.target else { return } - self.target = (lowAmp, highAmp) + guard next != self.target else { return } + self.target = next self.render() } } @@ -241,7 +261,7 @@ final class RumbleRenderer: @unchecked Sendable { queue.sync { self.ticker?.cancel() self.ticker = nil - self.target = (0, 0) + self.target = (0, 0, 0, 0) self.wasActive = false self.teardown() self.closeHID() @@ -256,7 +276,7 @@ final class RumbleRenderer: @unchecked Sendable { defer { updateTicker() } if renderHID() { return } guard !broken else { return } - let audible = target.low != 0 || target.high != 0 + let audible = target != (0, 0, 0, 0) if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter { setup() } @@ -274,6 +294,18 @@ final class RumbleRenderer: @unchecked Sendable { let mixed = RumbleTuning.combined(low: target.low, high: target.high) ok = reconcile(&low, to: RumbleTuning.amplitude(mixed)) } + // Impulse triggers: rendered ONLY where the hardware has the actuators, never folded into + // the handles. `reconcile` on a nil slot is a no-op returning true, so a pad without them + // silently drops the levels — which is the correct degrade and the common case. + // + // Their outcome is deliberately kept OUT of `ok`: a trigger engine erroring must not tear + // down the handle engines (which are what the pad's rumble mostly is) nor flip + // `preferCombined`, which is a statement about the handle split and nothing else. Nothing + // is orphaned by that — a failed reconcile leaves the slot's Motor in place, so the next + // tick simply retries it, and an engine that is genuinely dead fires its + // stopped/reset handler, which tears down all four slots for a lazy rebuild. + _ = reconcile(&leftTrigger, to: RumbleTuning.amplitude(target.leftTrigger)) + _ = reconcile(&rightTrigger, to: RumbleTuning.amplitude(target.rightTrigger)) if !ok { let wasSplit = high != nil teardown() @@ -410,9 +442,11 @@ final class RumbleRenderer: @unchecked Sendable { /// The ticker runs only while something needs tending — any nonzero target (watchdog, /// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive. private func updateTicker() { - let needed = target != (0, 0) + let needed = target != (0, 0, 0, 0) || low?.current != nil || low?.retiring != nil || high?.current != nil || high?.retiring != nil + || leftTrigger?.current != nil || leftTrigger?.retiring != nil + || rightTrigger?.current != nil || rightTrigger?.retiring != nil if needed, ticker == nil { let t = DispatchSource.makeTimerSource(queue: queue) t.schedule( @@ -477,6 +511,26 @@ final class RumbleRenderer: @unchecked Sendable { preferCombined = true log.info("rumble: split-handle engines failing — will retry with one combined engine") } + // Return before the trigger engines: the retry path re-enters setup() on the same + // `low == nil, high == nil` condition, so building them here would leak a fresh pair + // on every attempt (teardown() only runs on the failure paths above, and this is not + // one of them). + return + } + // Impulse-trigger motors, built last and best-effort. Independent of the handle split — + // the localities are separate and a pad can offer either, both or neither — and NOT part + // of the failure test above: nil here is the ordinary state of every pad that is not an + // Xbox One/Series/Elite, so it must not read as "engine setup failed", back off the handle + // engines, or produce a log line on a path that runs per controller attach. + // + // Whether a given pad + OS pair actually reports these localities is UNVERIFIED on glass. + // The degrade needs no code: `createEngine(withLocality:)` returns nil, the slots stay nil, + // and `reconcile` no-ops on them. + if localities.contains(.leftTrigger) { + leftTrigger = makeMotor(haptics, .leftTrigger, sharpness: RumbleTuning.sharpnessHigh) + } + if localities.contains(.rightTrigger) { + rightTrigger = makeMotor(haptics, .rightTrigger, sharpness: RumbleTuning.sharpnessHigh) } } @@ -563,7 +617,7 @@ final class RumbleRenderer: @unchecked Sendable { } private func teardown() { - for m in [low, high].compactMap({ $0 }) { + for m in [low, high, leftTrigger, rightTrigger].compactMap({ $0 }) { // Disarm the handlers before stopping so stop() can't re-enter teardown via them. // (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.) m.engine.stoppedHandler = { _ in } @@ -577,6 +631,8 @@ final class RumbleRenderer: @unchecked Sendable { } low = nil high = nil + leftTrigger = nil + rightTrigger = nil } private func seconds(since t: DispatchTime) -> TimeInterval { @@ -624,6 +680,16 @@ final class RumbleRenderer: @unchecked Sendable { /// Write the target to the DualSense over HID if that's the active backend; false → not a /// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution, /// with a periodic keepalive re-write while nonzero (the ticker calls back in here). + /// + /// **The impulse-trigger levels are deliberately dropped here, and there is no mapping to + /// invent.** A DualSense has *adaptive* triggers — force resistance on a trigger you press, + /// driven by the separate 0xCD `HidOutput.Trigger` plane — and no trigger *motors*. The two + /// features are unrelated hardware that only share a word: an Xbox Series pad has trigger + /// motors and no adaptive triggers, a DualSense has the reverse. Routing wire trigger rumble + /// into either the DS5 rumble bytes (which are the two handles) or the adaptive-trigger + /// parameter block would fabricate feedback the game never asked for. This path returning + /// `true` also means a macOS DualSense never reaches the CoreHaptics trigger localities above, + /// which is correct for the same reason. private func renderHID() -> Bool { #if os(macOS) guard let hid = dualSenseHID else { return false } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index f9c67603..1723a8be 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -1186,7 +1186,10 @@ pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1; pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2; /// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so /// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain; -/// impulse-trigger rumble is unreachable through a virtual pad). Useful for glyph-matching a +/// impulse-trigger rumble is unreachable through THIS pad — evdev's `FF_RUMBLE` is two +/// magnitudes and has no third, so a uinput backend can never source it. The Windows HID Xbox +/// backend can, off its output report `0x03`; see +/// [`punktfunk_connection_next_rumble_cmd2`]). Useful for glyph-matching a /// physical X-Box One/Series controller on the client. pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3; /// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the @@ -2723,8 +2726,20 @@ pub const PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER: u32 = 1; /// [`PunktfunkStatus::NoFrame`] on timeout; [`PunktfunkStatus::Closed`] once the session ended AND /// every close-drain stop was delivered — silence all actuators on it. /// -/// An embedder uses EITHER this or `next_rumble`/`next_rumble2` for a connection's lifetime, -/// never both (they consume the same wire plane). +/// **Handle motors only.** A pad also carries two Xbox impulse-trigger levels, which this entry +/// point has no out-params for and never will — +/// [`punktfunk_connection_next_rumble_cmd2`] is the four-motor pull. Staying here is a supported +/// choice, not a deprecation: for a controller with no trigger motors — every pad but an Xbox +/// One/Series/Elite — the two views are identical, and where they differ, "the handles are silent" +/// is exactly the right instruction for the motors this API owns. +/// +/// The one observable difference against a trigger-driving host: a rumble that moves only the +/// triggers still produces commands here, carrying `low == high == 0`. They are idempotent stops +/// for the handles; the engine's redundant-stop suppression cannot fold them away, because the +/// command is not silent — some motor on that pad is running. +/// +/// An embedder uses EITHER this (or its `2` form) or `next_rumble`/`next_rumble2` for a +/// connection's lifetime, never both (they consume the same wire plane). /// /// # Safety /// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one @@ -2775,6 +2790,95 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd( }) } +/// [`punktfunk_connection_next_rumble_cmd`] with the two Xbox impulse-trigger motors: the same +/// command, all four of its levels. `*left_trigger` / `*right_trigger` are on the same +/// `0..=0xFFFF` scale as `low`/`high`, and a stop is all four at zero. +/// +/// A NEW symbol rather than a wider signature on the old one, following the +/// `next_rumble` → `next_rumble2` precedent in this file: an exported entry point's parameter list +/// is part of the contract, and silently growing one breaks every out-of-tree embedder at once, +/// with a stack-corruption signature rather than a link error. Old callers keep the old symbol and +/// simply never see the trigger levels. +/// +/// **Render the trigger levels only on a pad that actually has trigger motors, and drop them +/// otherwise** — do not fold them into the handles. Impulse-trigger content is continuous +/// (a racing title drives engine RPM and tyre slip into the triggers while the handles stay near +/// silent), so folding it produces a handle motor droning flat-out for the whole race at a level +/// the game never asked for. Query the hardware: SDL's +/// `SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN`, Apple's `GCDeviceHaptics.supportedLocalities` +/// (`GCHapticsLocalityLeftTrigger`/`…RightTrigger`). A pad without them is the common case and not +/// an error — do not log per command. +/// +/// **Nothing has driven these levels non-zero end to end yet, and that is structural, not an +/// oversight.** Exactly one producer can ever source them — the Windows HID Xbox pad's output +/// report `0x03` — because classic XInput's `XINPUT_VIBRATION` has two members and evdev's +/// `FF_RUMBLE` has two, so no other host backend on any OS has the channel. That producer is +/// reachable only through GameInput/WGI, and an xinputhid-promoted Xbox pad is not enumerated by +/// GameInput at all (measured against a real Microsoft Elite, which is equally invisible there +/// while XInput reads it live). So this delivery path is deliberately built ahead of its producer: +/// the wire, the engine and this entry point are exercised only by synthetic levels. +/// +/// Same threading, timeout and close semantics as +/// [`punktfunk_connection_next_rumble_cmd`]; the two share one wire plane and one policy engine, +/// so an embedder calls exactly one of them. +/// +/// # Safety +/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one +/// thread pulls rumble — it may run concurrently with the video/audio pullers. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd2( + c: *mut PunktfunkConnection, + pad: *mut u16, + low: *mut u16, + high: *mut u16, + left_trigger: *mut u16, + right_trigger: *mut u16, + backstop_ms: *mut u32, + timeout_ms: u32, +) -> PunktfunkStatus { + guard(|| { + // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller + // has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` + // here handles. + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + match c + .inner + .next_rumble_command(std::time::Duration::from_millis(timeout_ms as u64)) + { + Ok(cmd) => { + // SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null- + // checked before it is written; a non-null one is a caller-owned writable slot. + unsafe { + if !pad.is_null() { + *pad = cmd.pad; + } + if !low.is_null() { + *low = cmd.low; + } + if !high.is_null() { + *high = cmd.high; + } + if !left_trigger.is_null() { + *left_trigger = cmd.left_trigger; + } + if !right_trigger.is_null() { + *right_trigger = cmd.right_trigger; + } + if !backstop_ms.is_null() { + *backstop_ms = cmd.backstop_ms; + } + } + PunktfunkStatus::Ok + } + Err(e) => e.status(), + } + }) +} + /// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the /// shared rumble policy engine instead of forking it (typically called at controller attach). /// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index aff3892c..258df579 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -1232,10 +1232,15 @@ impl NativeClient { /// the engine emits the level on every wire update (renewals re-arm duration-parameterized /// APIs), an explicit zero at lease expiry / legacy staleness / connection close, and /// quirk-declared keepalives ([`NativeClient::set_rumble_quirks`]). Apply commands verbatim: - /// `(0, 0)` = stop now; non-zero = run at this level, with `backstop_ms` as the safety-net + /// all-zero = stop now; non-zero = run at this level, with `backstop_ms` as the safety-net /// duration for APIs that take one. [`PunktfunkError::NoFrame`] on timeout; /// [`PunktfunkError::Closed`] once the session ended AND every close-drain stop was delivered. /// + /// A command carries FOUR levels: the two handle motors plus the two Xbox impulse-trigger + /// motors ([`RumbleCommand`]). Render the trigger pair only on a pad that has trigger motors + /// (SDL: `has_rumble_triggers()`); dropping them otherwise is the correct degrade, and folding + /// them into a handle is specifically not — see [`RumbleCommand`] for why. + /// /// One puller thread, and one API: an embedder uses EITHER this or /// `next_rumble`/`next_rumble_ttl` for a connection's lifetime, never both (both consume the /// same wire plane; the raw queue keeps filling harmlessly while this API is used). diff --git a/crates/punktfunk-core/src/client/pump/datagram_task.rs b/crates/punktfunk-core/src/client/pump/datagram_task.rs index a08ea68b..02c38bf7 100644 --- a/crates/punktfunk-core/src/client/pump/datagram_task.rs +++ b/crates/punktfunk-core/src/client/pump/datagram_task.rs @@ -92,16 +92,23 @@ pub(super) async fn run( // Both consumers are fed; an embedder drains exactly one of them // (the legacy queue, or the policy engine's command API). // - // `u.left_trigger`/`u.right_trigger` (the v3 tail) are decoded and - // deliberately NOT forwarded yet: neither consumer has a slot for them. - // Widening them is the client-engine work package — `RumbleCommand` grows - // two fields, `ActuatorQuirks` learns whether the physical pad has trigger - // motors, and the C ABI gains a `next_rumble_cmd2` beside the existing - // fixed-out-param puller. Dropping them here is exactly what the §5 - // compatibility table calls "new host, old client": the handle motors - // behave identically and the trigger levels are silently discarded. + // Only the policy engine carries `u.left_trigger`/`u.right_trigger` (the + // v3 impulse-trigger tail). The legacy queue's tuple is the shape two + // frozen C entry points read through fixed out-params + // (`punktfunk_connection_next_rumble`/`_next_rumble2`), so it stays at the + // two handle levels forever: an out-of-tree embedder on those symbols must + // keep behaving exactly as it did. That is the §5 compatibility table's + // "new host, old client" cell, and it is now a per-API property rather + // than a per-client one — the same session can serve both. let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl)); - rumble_feed.wire_update(u.pad, u.low, u.high, ttl); + rumble_feed.wire_update( + u.pad, + u.low, + u.high, + u.left_trigger, + u.right_trigger, + ttl, + ); } } } diff --git a/crates/punktfunk-core/src/client/rumble.rs b/crates/punktfunk-core/src/client/rumble.rs index fa028876..056a9e3f 100644 --- a/crates/punktfunk-core/src/client/rumble.rs +++ b/crates/punktfunk-core/src/client/rumble.rs @@ -22,6 +22,14 @@ //! a per-pad mailbox and commands are generated on demand, so a stalled embedder wakes to ONE //! current-level command instead of a backlog — and a stop can never be the update that an //! overflowing queue drops. +//! +//! A pad carries FOUR motor levels ([`Levels`]): the two handles plus the two Xbox impulse-trigger +//! motors off the 0xCA v3 tail (`design/trigger-rumble-plane.md`). They deliberately share one +//! lease, one seq and one policy — they are a single statement of the pad's feedback state at one +//! instant, so the whole apparatus above (expiry, staleness, keepalives, close drain) governs the +//! trigger motors with no second timeline. Every liveness test is therefore against all four +//! levels, not the handles: a trigger-only rumble is the *normal* shape of impulse-trigger +//! content, and a two-field test would silence it on arrival. use crate::input::MAX_PADS; use std::sync::{Condvar, Mutex}; @@ -52,18 +60,41 @@ const BACKSTOP_LEGACY_MS: u32 = 2000; /// header already has ~170 instances of, and one this has no reason to add to. const MAX_LEASE_MS: u16 = 5_000; -/// One effective actuator command. `(0, 0)` means stop now. `backstop_ms` is a safety-net -/// duration for platform APIs that take one (SDL rumble, Android one-shots): the engine emits -/// explicit zeros at every policy stop, so the backstop only matters if the embedder thread itself -/// stalls; platforms with explicit-stop APIs ignore it. Zero commands carry `backstop_ms == 0`. +/// One effective actuator command: four motor levels for one pad at one instant. All-zero means +/// stop now. `backstop_ms` is a safety-net duration for platform APIs that take one (SDL rumble, +/// Android one-shots): the engine emits explicit zeros at every policy stop, so the backstop only +/// matters if the embedder thread itself stalls; platforms with explicit-stop APIs ignore it. Zero +/// commands carry `backstop_ms == 0`. +/// +/// `left_trigger`/`right_trigger` are the Xbox impulse-trigger motors off the 0xCA v3 tail +/// (`design/trigger-rumble-plane.md`), on the same `0..=0xFFFF` scale as `low`/`high`. A renderer +/// on a pad without trigger motors ignores them — that is the *normal* case, not an error, and the +/// engine deliberately does not fold them into the handles (folding a racing title's continuous +/// trigger stream onto a handle motor drones flat-out for the whole race; §8 of the design). +/// +/// A pre-trigger embedder reading only `(low, high)` stays correct: the four levels are one +/// statement of the pad's state, so a trigger-only rumble reads as "handles silent", which is what +/// its actuator should do. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RumbleCommand { pub pad: u16, pub low: u16, pub high: u16, + pub left_trigger: u16, + pub right_trigger: u16, pub backstop_ms: u32, } +/// One pad's four motor levels, in wire order: `(low, high, left_trigger, right_trigger)`. The two +/// handle motors first so the pre-trigger `(low, high)` reading is a literal prefix of this one. +type Levels = (u16, u16, u16, u16); + +/// The reserved "this actuator group is silent" value. Every liveness test in the engine is +/// against ALL FOUR levels: a rumble that drives only the impulse triggers — the normal shape of +/// racing-title content, where the handles stay at rest — must read as LIVE, or it would be +/// silenced on arrival by a two-field test that never saw its levels. +const SILENT: Levels = (0, 0, 0, 0); + /// A physical actuator's declared quirks — how a platform parameterizes the shared policy instead /// of forking it. Defaults (all zero/false) describe a well-behaved actuator. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -96,7 +127,7 @@ pub struct ActuatorQuirks { #[derive(Clone, Copy)] struct PadState { - level: (u16, u16), + level: Levels, /// v2 lease expiry — `None` for a zero level or a legacy pad. deadline: Option, /// Last v2 TTL (drives the backstop); 0 ⇔ legacy. @@ -106,23 +137,23 @@ struct PadState { /// A wire update landed since the last emit (level change OR renewal — renewals re-emit). dirty: bool, next_keepalive: Option, - /// The exact value last handed to an embedder. `(0, 0)` ⇔ the engine believes this actuator is - /// silent. It replaces a free-running jitter phase because one field answers all three live - /// questions: would re-sending this be a no-op device write (the dedupe nudge), is a stop - /// redundant, and would the nudge synthesize the reserved stop. - last_emit: (u16, u16), + /// The exact value last handed to an embedder. [`SILENT`] ⇔ the engine believes this pad's + /// actuators are all silent. It replaces a free-running jitter phase because one field answers + /// all three live questions: would re-sending this be a no-op device write (the dedupe nudge), + /// is a stop redundant, and would the nudge synthesize the reserved stop. + last_emit: Levels, quirks: ActuatorQuirks, } impl PadState { const NEUTRAL: PadState = PadState { - level: (0, 0), + level: SILENT, deadline: None, ttl_ms: 0, legacy_wire: None, dirty: false, next_keepalive: None, - last_emit: (0, 0), + last_emit: SILENT, quirks: ActuatorQuirks { keepalive_ms: 0, min_pulse_ms: 0, @@ -139,18 +170,22 @@ impl PadState { b.max(self.quirks.min_pulse_ms as u32) } - /// Zero the pad's level + timers and produce the stop command. + /// Zero the pad's levels + timers and produce the stop command — all four motors, so a policy + /// stop silences the impulse triggers on the same event as the handles (which is the whole + /// reason they share one lease and one seq). fn silence(&mut self, pad: u16) -> RumbleCommand { - self.level = (0, 0); + self.level = SILENT; self.deadline = None; self.legacy_wire = None; self.next_keepalive = None; self.dirty = false; - self.last_emit = (0, 0); + self.last_emit = SILENT; RumbleCommand { pad, low: 0, high: 0, + left_trigger: 0, + right_trigger: 0, backstop_ms: 0, } } @@ -166,25 +201,39 @@ impl PadState { /// gap between *distinct* device writes to 80 ms at the default cadence and 100 ms at the /// floor, on an actuator whose quirk declares 40. /// - /// The nudge is refused when it would synthesize the reserved `(0, 0)` stop. That is level - /// `(1, 0)` and only that: `high` must already be 0, and `low ^ 1 == 0` implies `low == 1`. + /// The nudge is refused when it would synthesize the reserved all-zero stop. **Re-derived for + /// four levels, not mechanically widened** — the old proof reasoned about exactly two fields. + /// `emit` is only ever reached with `level != SILENT` (every caller in [`RumbleEngine::poll`] + /// guards on it), the nudge touches `low` alone, and it changes `low` by ±1 in the LSB. So the + /// nudged tuple can equal [`SILENT`] only when the three untouched levels are already zero AND + /// `low ^ 1 == 0`, i.e. exactly level `(1, 0, 0, 0)` — the same single case as before, now + /// conditioned on `high`, `left_trigger` and `right_trigger` together instead of `high` alone. /// There the LSB steps up instead, so the phase still alternates (1 ↔ 3, two parts in 65535) /// and the pad never receives a stop the policy did not order. + /// + /// The nudge stays on `low` even for a trigger-only level, where it lifts a resting handle + /// motor from 0 to 1. That is not new behaviour in kind — a `(0, high)` level has always been + /// nudged to `(1, high)` — and one part in 65535 is below any actuator's threshold. Moving it + /// to whichever level is non-zero would make the dedupe phase depend on which motors a + /// particular command happens to drive, which is exactly the free-running-phase failure + /// `last_emit` was introduced to remove. fn emit(&mut self, pad: u16) -> RumbleCommand { - let (mut low, high) = self.level; - if self.quirks.dedup_jitter && (low, high) == self.last_emit { + let (mut low, high, lt, rt) = self.level; + if self.quirks.dedup_jitter && self.level == self.last_emit { let alt = low ^ 1; - low = if (alt, high) == (0, 0) { + low = if (alt, high, lt, rt) == SILENT { low | 0b10 } else { alt }; } - self.last_emit = (low, high); + self.last_emit = (low, high, lt, rt); RumbleCommand { pad, low, high, + left_trigger: lt, + right_trigger: rt, backstop_ms: self.backstop(), } } @@ -210,18 +259,26 @@ impl RumbleEngine { /// Fold one seq-gated wire update in. Every update dirties the pad (renewals re-emit so /// platform duration timers re-arm); a v2 update replaces the lease deadline, a legacy update /// refreshes the staleness clock. + /// + /// `lt`/`rt` are the v3 impulse-trigger levels — zero for a v1/v2 datagram, because on a + /// level-triggered plane an absent field means "off now", never "keep what you had". + // Four levels, a pad index, a clock and a lease: grouping them would move the field list one + // hop from the two call sites (the demux feed and the tests) for nothing. + #[allow(clippy::too_many_arguments)] pub(crate) fn wire_update( &mut self, now: Instant, pad: u16, low: u16, high: u16, + lt: u16, + rt: u16, ttl_ms: Option, ) { let Some(p) = self.pads.get_mut(pad as usize) else { return; }; - p.level = (low, high); + p.level = (low, high, lt, rt); p.dirty = true; match ttl_ms { Some(t) => { @@ -229,7 +286,10 @@ impl RumbleEngine { let t = t.min(MAX_LEASE_MS); p.ttl_ms = t; p.legacy_wire = None; - p.deadline = if (low, high) != (0, 0) { + // All four levels decide whether there is a lease to run: a trigger-only rumble + // against silent handles is a LIVE level and must get a deadline, not the + // instantly-expired `None` a two-field test would have handed it. + p.deadline = if p.level != SILENT { Some(now + Duration::from_millis(t as u64)) } else { None @@ -261,7 +321,7 @@ impl RumbleEngine { for i in 0..MAX_PADS { let p = &mut self.pads[i]; let pad = i as u16; - if p.level != (0, 0) { + if p.level != SILENT { // 1) v2 lease expiry — the host stopped renewing (died / stopped caring). This // firing in the wild is the signature of a host-side bug: worth a log line. if let Some(d) = p.deadline { @@ -284,7 +344,7 @@ impl RumbleEngine { // 3) a wire update to relay (level change or renewal re-arm). if p.dirty { p.dirty = false; - if p.level == (0, 0) { + if p.level == SILENT { // Relay a stop only if the actuator is, as far as the engine knows, still // buzzing. A zero on an already-silent pad heals nothing and costs every // embedder a command — Android an unconditional log line plus a binder @@ -293,8 +353,8 @@ impl RumbleEngine { // `PUNKTFUNK_RUMBLE_ENVELOPE=0`) the legacy flat 500 ms refresh, which re-sends // zeros for every latched pad for the rest of the session. The burst still // heals the case it exists for: a LOST first stop leaves the pad buzzing, so - // `last_emit != (0, 0)` and the re-send does emit. - if p.last_emit != (0, 0) { + // `last_emit != SILENT` and the re-send does emit. + if p.last_emit != SILENT { return (Some(p.silence(pad)), None); } continue; @@ -308,7 +368,7 @@ impl RumbleEngine { // 4) actuator-decay keepalive, bounded by (1)/(2) above by construction: an expired // or stale pad was silenced before reaching here, so a keepalive can never sustain a // level the policy has ended. - if p.level != (0, 0) && p.quirks.keepalive_ms > 0 { + if p.level != SILENT && p.quirks.keepalive_ms > 0 { let ka = Duration::from_millis(p.quirks.keepalive_ms as u64); let due = *p.next_keepalive.get_or_insert(now + ka); if now >= due { @@ -325,7 +385,7 @@ impl RumbleEngine { /// silences every platform by contract instead of by per-client accident. pub(crate) fn close_drain(&mut self) -> Option { for i in 0..MAX_PADS { - if self.pads[i].level != (0, 0) { + if self.pads[i].level != SILENT { return Some(self.pads[i].silence(i as u16)); } } @@ -349,9 +409,18 @@ struct SharedState { pub(crate) struct RumbleFeed(pub(crate) std::sync::Arc); impl RumbleFeed { - pub(crate) fn wire_update(&self, pad: u16, low: u16, high: u16, ttl_ms: Option) { + pub(crate) fn wire_update( + &self, + pad: u16, + low: u16, + high: u16, + lt: u16, + rt: u16, + ttl_ms: Option, + ) { let mut g = self.0.inner.lock().unwrap(); - g.engine.wire_update(Instant::now(), pad, low, high, ttl_ms); + g.engine + .wire_update(Instant::now(), pad, low, high, lt, rt, ttl_ms); drop(g); self.0.cv.notify_all(); } @@ -425,7 +494,30 @@ mod tests { dedup_jitter: true, }; - /// Drain the engine the way an embedder does: poll until nothing is due. + /// Feed a HANDLE-ONLY wire update — what every producer but the Windows HID Xbox pad emits + /// (XInput's `XINPUT_VIBRATION` and evdev's `FF_RUMBLE` have two members and no third), so it + /// is also what the pre-v3 tests below are all about. Trigger cases call `wire4` instead. + fn wire(e: &mut RumbleEngine, t: Instant, pad: u16, low: u16, high: u16, ttl: Option) { + e.wire_update(t, pad, low, high, 0, 0, ttl); + } + + /// Feed a full v3 wire update, all four levels. + #[allow(clippy::too_many_arguments)] + fn wire4( + e: &mut RumbleEngine, + t: Instant, + pad: u16, + low: u16, + high: u16, + lt: u16, + rt: u16, + ttl: Option, + ) { + e.wire_update(t, pad, low, high, lt, rt, ttl); + } + + /// Drain the engine the way an embedder does: poll until nothing is due. Handle levels only — + /// `drain4` is the four-level view. fn drain(e: &mut RumbleEngine, t: Instant) -> Vec<(u16, u16)> { let mut out = Vec::new(); while let (Some(c), _) = e.poll(t) { @@ -434,15 +526,30 @@ mod tests { out } + fn drain4(e: &mut RumbleEngine, t: Instant) -> Vec { + let mut out = Vec::new(); + while let (Some(c), _) = e.poll(t) { + out.push((c.low, c.high, c.left_trigger, c.right_trigger)); + } + out + } + fn ms(v: u64) -> Duration { Duration::from_millis(v) } + /// A handle-only expected command — the shape every pre-v3 assertion below is written in. fn cmd(pad: u16, low: u16, high: u16, backstop_ms: u32) -> RumbleCommand { + cmd4(pad, low, high, 0, 0, backstop_ms) + } + + fn cmd4(pad: u16, low: u16, high: u16, lt: u16, rt: u16, backstop_ms: u32) -> RumbleCommand { RumbleCommand { pad, low, high, + left_trigger: lt, + right_trigger: rt, backstop_ms, } } @@ -451,7 +558,7 @@ mod tests { fn v2_level_emits_and_expires_at_the_lease() { let mut e = RumbleEngine::new(); let t0 = Instant::now(); - e.wire_update(t0, 0, 0x4000, 0x8000, Some(400)); + wire(&mut e, t0, 0, 0x4000, 0x8000, Some(400)); assert_eq!(e.poll(t0).0, Some(cmd(0, 0x4000, 0x8000, 800))); // backstop = 2×ttl // No renewal: at the deadline the engine self-silences — the host-died safety net. let (c, wake) = e.poll(t0 + ms(200)); @@ -465,11 +572,11 @@ mod tests { fn renewal_re_emits_and_extends_the_deadline() { let mut e = RumbleEngine::new(); let t0 = Instant::now(); - e.wire_update(t0, 0, 100, 0, Some(400)); + wire(&mut e, t0, 0, 100, 0, Some(400)); assert!(e.poll(t0).0.is_some()); // A same-level renewal at t+300 re-emits (platform duration timers re-arm) and pushes the // deadline to t+700 — so t+500 (past the ORIGINAL deadline) still rumbles. - e.wire_update(t0 + ms(300), 0, 100, 0, Some(400)); + wire(&mut e, t0 + ms(300), 0, 100, 0, Some(400)); assert_eq!(e.poll(t0 + ms(300)).0, Some(cmd(0, 100, 0, 800))); assert_eq!(e.poll(t0 + ms(500)).0, None); assert_eq!(e.poll(t0 + ms(700)).0, Some(cmd(0, 0, 0, 0))); @@ -479,9 +586,9 @@ mod tests { fn explicit_stop_is_immediate_and_cancels_the_lease() { let mut e = RumbleEngine::new(); let t0 = Instant::now(); - e.wire_update(t0, 2, 500, 500, Some(400)); + wire(&mut e, t0, 2, 500, 500, Some(400)); assert!(e.poll(t0).0.is_some()); - e.wire_update(t0 + ms(50), 2, 0, 0, Some(0)); + wire(&mut e, t0 + ms(50), 2, 0, 0, Some(0)); assert_eq!(e.poll(t0 + ms(50)).0, Some(cmd(2, 0, 0, 0))); assert_eq!(e.poll(t0 + ms(600)), (None, None)); // no phantom expiry later } @@ -490,10 +597,10 @@ mod tests { fn legacy_host_gets_the_uniform_staleness_bound() { let mut e = RumbleEngine::new(); let t0 = Instant::now(); - e.wire_update(t0, 0, 300, 0, None); // legacy: no TTL + wire(&mut e, t0, 0, 300, 0, None); // legacy: no TTL assert_eq!(e.poll(t0).0, Some(cmd(0, 300, 0, 2000))); // The legacy 500 ms refresh keeps it alive… - e.wire_update(t0 + ms(500), 0, 300, 0, None); + wire(&mut e, t0 + ms(500), 0, 300, 0, None); assert_eq!(e.poll(t0 + ms(500)).0, Some(cmd(0, 300, 0, 2000))); assert_eq!(e.poll(t0 + ms(1400)).0, None); // 900 ms since last wire — inside the bound // …and one second of silence cuts it, on every platform alike. @@ -512,7 +619,7 @@ mod tests { }, ); let t0 = Instant::now(); - e.wire_update(t0, 0, 100, 200, Some(400)); + wire(&mut e, t0, 0, 100, 200, Some(400)); assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800))); // Keepalives at the quirk cadence, alternating the low LSB to defeat SDL's dedupe. assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 101, 200, 800))); @@ -526,7 +633,7 @@ mod tests { fn quirk_registered_mid_rumble_starts_keepalives() { let mut e = RumbleEngine::new(); let t0 = Instant::now(); - e.wire_update(t0, 0, 100, 0, Some(400)); + wire(&mut e, t0, 0, 100, 0, Some(400)); assert!(e.poll(t0).0.is_some()); e.set_quirks( 0, @@ -555,7 +662,7 @@ mod tests { }, ); let t0 = Instant::now(); - e.wire_update(t0, 0, 100, 0, Some(100)); + wire(&mut e, t0, 0, 100, 0, Some(100)); assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 0, 5000))); } @@ -563,8 +670,8 @@ mod tests { fn close_drain_silences_every_buzzing_pad_once() { let mut e = RumbleEngine::new(); let t0 = Instant::now(); - e.wire_update(t0, 0, 100, 0, Some(400)); - e.wire_update(t0, 3, 0, 900, Some(400)); + wire(&mut e, t0, 0, 100, 0, Some(400)); + wire(&mut e, t0, 3, 0, 900, Some(400)); let _ = e.poll(t0); let _ = e.poll(t0); let a = e.close_drain().unwrap(); @@ -581,7 +688,7 @@ mod tests { // 20 renewals landed while the embedder was stalled — state, not a queue: exactly one // command comes out, carrying the latest level. for k in 0..20u64 { - e.wire_update(t0 + ms(k * 120), 0, 100 + k as u16, 0, Some(400)); + wire(&mut e, t0 + ms(k * 120), 0, 100 + k as u16, 0, Some(400)); } let t = t0 + ms(20 * 120); assert_eq!(e.poll(t).0, Some(cmd(0, 119, 0, 800))); @@ -592,7 +699,7 @@ mod tests { fn shared_close_delivers_drain_zero_then_closed() { let shared = std::sync::Arc::new(RumbleShared::new()); let feed = RumbleFeed(shared.clone()); - feed.wire_update(1, 100, 0, Some(400)); + feed.wire_update(1, 100, 0, 0, 0, Some(400)); assert_eq!( shared.next_command(ms(100)).unwrap().unwrap(), cmd(1, 100, 0, 800) @@ -613,12 +720,12 @@ mod tests { let mut e = RumbleEngine::new(); e.set_quirks(0, DECK); let t0 = Instant::now(); - e.wire_update(t0, 0, 100, 200, Some(400)); + wire(&mut e, t0, 0, 100, 200, Some(400)); assert_eq!(drain(&mut e, t0), vec![(100, 200)]); assert_eq!(drain(&mut e, t0 + ms(40)), vec![(101, 200)]); assert_eq!(drain(&mut e, t0 + ms(80)), vec![(100, 200)]); // The renewal at the 120 ms default cadence: same level, must still be a distinct write. - e.wire_update(t0 + ms(120), 0, 100, 200, Some(400)); + wire(&mut e, t0 + ms(120), 0, 100, 200, Some(400)); assert_eq!(drain(&mut e, t0 + ms(120)), vec![(101, 200)]); assert_eq!(drain(&mut e, t0 + ms(160)), vec![(100, 200)]); } @@ -634,7 +741,7 @@ mod tests { for tick in 0..=360u64 { let t = t0 + ms(tick); if tick % 60 == 0 { - e.wire_update(t, 0, 100, 200, Some(400)); + wire(&mut e, t, 0, 100, 200, Some(400)); } for v in drain(&mut e, t) { assert_ne!(v, (0, 0), "a live lease must never emit the stop sentinel"); @@ -657,9 +764,9 @@ mod tests { fn default_quirks_pads_get_the_level_verbatim_on_every_renewal() { let mut e = RumbleEngine::new(); // Apple / Android / plain SDL let t0 = Instant::now(); - e.wire_update(t0, 0, 100, 200, Some(400)); + wire(&mut e, t0, 0, 100, 200, Some(400)); assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800))); - e.wire_update(t0 + ms(120), 0, 100, 200, Some(400)); + wire(&mut e, t0 + ms(120), 0, 100, 200, Some(400)); assert_eq!(e.poll(t0 + ms(120)).0, Some(cmd(0, 100, 200, 800))); } @@ -670,7 +777,7 @@ mod tests { let mut e = RumbleEngine::new(); e.set_quirks(0, DECK); let t0 = Instant::now(); - e.wire_update(t0, 0, 1, 0, Some(400)); + wire(&mut e, t0, 0, 1, 0, Some(400)); assert_eq!(e.poll(t0).0, Some(cmd(0, 1, 0, 800))); assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 3, 0, 800))); assert_eq!(e.poll(t0 + ms(80)).0, Some(cmd(0, 1, 0, 800))); @@ -683,20 +790,20 @@ mod tests { fn a_redundant_stop_is_dropped_but_the_burst_still_heals_a_lost_one() { let mut e = RumbleEngine::new(); let t0 = Instant::now(); - e.wire_update(t0, 0, 100, 200, Some(400)); + wire(&mut e, t0, 0, 100, 200, Some(400)); assert_eq!(drain(&mut e, t0), vec![(100, 200)]); // First stop reaches the embedder… - e.wire_update(t0 + ms(10), 0, 0, 0, Some(0)); + wire(&mut e, t0 + ms(10), 0, 0, 0, Some(0)); assert_eq!(drain(&mut e, t0 + ms(10)), vec![(0, 0)]); // …and the burst re-sends behind it are now silent. - e.wire_update(t0 + ms(20), 0, 0, 0, Some(0)); - e.wire_update(t0 + ms(30), 0, 0, 0, Some(0)); + wire(&mut e, t0 + ms(20), 0, 0, 0, Some(0)); + wire(&mut e, t0 + ms(30), 0, 0, 0, Some(0)); assert_eq!(drain(&mut e, t0 + ms(30)), Vec::new()); // But if the pad is buzzing (the stop that mattered was lost), a re-send still emits. - e.wire_update(t0 + ms(40), 0, 100, 200, Some(400)); + wire(&mut e, t0 + ms(40), 0, 100, 200, Some(400)); assert_eq!(drain(&mut e, t0 + ms(40)), vec![(100, 200)]); - e.wire_update(t0 + ms(50), 0, 0, 0, Some(0)); + wire(&mut e, t0 + ms(50), 0, 0, 0, Some(0)); assert_eq!(drain(&mut e, t0 + ms(50)), vec![(0, 0)]); } @@ -707,7 +814,7 @@ mod tests { fn an_overlong_lease_is_clamped_to_the_ceiling() { let mut e = RumbleEngine::new(); let t0 = Instant::now(); - e.wire_update(t0, 0, 100, 200, Some(u16::MAX)); + wire(&mut e, t0, 0, 100, 200, Some(u16::MAX)); assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 5000))); // Silenced at the ceiling, not at the 65 s the sender asked for. assert!(e.poll(t0 + ms(MAX_LEASE_MS as u64 - 1)).0.is_none()); @@ -726,11 +833,112 @@ mod tests { fn a_zero_ttl_envelope_silences_rather_than_taking_the_legacy_backstop() { let mut e = RumbleEngine::new(); let t0 = Instant::now(); - e.wire_update(t0, 0, 100, 200, Some(0)); + wire(&mut e, t0, 0, 100, 200, Some(0)); assert_eq!( e.poll(t0).0, Some(cmd(0, 0, 0, 0)), "a zero-length lease must expire immediately, not emit with a legacy backstop" ); } + + /// **The single most likely way to ship trigger rumble broken** (design §5): a rumble that + /// drives ONLY the impulse triggers is the normal shape of the content — racing titles run the + /// triggers continuously against silent handles. Every liveness test in the engine used to be + /// `(low, high) == (0, 0)`; left that way, a trigger-only update is read as a stop, dropped as + /// redundant on a silent pad, and the feature is dead with no error anywhere. + #[test] + fn a_trigger_only_rumble_is_a_live_level_not_a_stop() { + let mut e = RumbleEngine::new(); + let t0 = Instant::now(); + wire4(&mut e, t0, 0, 0, 0, 0x8000, 0, Some(400)); + assert_eq!( + e.poll(t0).0, + Some(cmd4(0, 0, 0, 0x8000, 0, 800)), + "a trigger-only level must emit with a live backstop" + ); + // It runs on the pad's ONE shared lease, exactly like the handles: no renewal, so the + // whole group silences at the deadline. + assert_eq!(e.poll(t0 + ms(200)), (None, Some(t0 + ms(400)))); + assert_eq!(e.poll(t0 + ms(400)).0, Some(cmd(0, 0, 0, 0))); + assert_eq!(e.poll(t0 + ms(500)), (None, None)); + } + + /// Backward compatibility for the pre-trigger C entry point + /// (`punktfunk_connection_next_rumble_cmd`, which writes `pad`/`low`/`high`/`backstop_ms` and + /// has no slot for the other two). Its embedder sees the same command, truncated to its first + /// two levels — and that truncation is CORRECT rather than merely tolerable: with no trigger + /// motors to drive, "handles silent" is what its actuator should do. The one visible + /// difference is that trigger traffic now produces commands where before the demux dropped it, + /// so such an embedder sees redundant handle stops while a trigger-only rumble runs. They are + /// idempotent; the redundant-stop suppression cannot apply, because the command is not silent. + #[test] + fn the_old_two_field_view_of_a_trigger_command_is_silent_handles() { + let mut e = RumbleEngine::new(); + let t0 = Instant::now(); + wire4(&mut e, t0, 0, 0x1111, 0, 0x8000, 0x4000, Some(400)); + let c = e.poll(t0).0.unwrap(); + assert_eq!((c.pad, c.low, c.high, c.backstop_ms), (0, 0x1111, 0, 800)); + assert_eq!((c.left_trigger, c.right_trigger), (0x8000, 0x4000)); + // Handles released, triggers still driven: the old view reads (0, 0) — a stop for the + // motors it owns — while the new view keeps the triggers alive. + wire4(&mut e, t0 + ms(50), 0, 0, 0, 0x8000, 0x4000, Some(400)); + let c = e.poll(t0 + ms(50)).0.unwrap(); + assert_eq!((c.low, c.high), (0, 0)); + assert_eq!((c.left_trigger, c.right_trigger), (0x8000, 0x4000)); + assert_ne!( + c.backstop_ms, 0, + "not a stop command — the pad is still live" + ); + } + + /// The trigger levels ride the pad's ONE seq/lease/keepalive apparatus, so a Deck-class + /// actuator's re-kicks carry them unchanged — and the dedupe nudge still only ever moves + /// `low`, never a trigger level (which would be a device write the policy did not order). + #[test] + fn keepalives_carry_the_trigger_levels_and_only_nudge_low() { + let mut e = RumbleEngine::new(); + e.set_quirks(0, DECK); + let t0 = Instant::now(); + wire4(&mut e, t0, 0, 100, 200, 300, 400, Some(400)); + assert_eq!(drain4(&mut e, t0), vec![(100, 200, 300, 400)]); + assert_eq!(drain4(&mut e, t0 + ms(40)), vec![(101, 200, 300, 400)]); + assert_eq!(drain4(&mut e, t0 + ms(80)), vec![(100, 200, 300, 400)]); + } + + /// The four-field re-derivation of the jitter proof (design §8): the reserved stop is now + /// all-four-zero, so the nudge must refuse only at `(1, 0, 0, 0)` — and must NOT refuse at + /// `(1, 0, lt, rt)`, where flipping the LSB is perfectly safe because the triggers keep the + /// command non-silent. A mechanical widening that kept testing `high` alone would get the + /// first case right and the second one wrong in the harmless direction; testing `(alt, high)` + /// against `(0, 0)` would get the first case wrong and send a Deck a stop nobody ordered. + #[test] + fn the_jitter_never_synthesizes_the_four_field_stop_sentinel() { + let mut e = RumbleEngine::new(); + e.set_quirks(0, DECK); + let t0 = Instant::now(); + // (1, 0, 0, 0): the ONE level whose LSB flip is the reserved stop — step up instead. + wire(&mut e, t0, 0, 1, 0, Some(400)); + assert_eq!(drain4(&mut e, t0), vec![(1, 0, 0, 0)]); + assert_eq!(drain4(&mut e, t0 + ms(40)), vec![(3, 0, 0, 0)]); + assert_eq!(drain4(&mut e, t0 + ms(80)), vec![(1, 0, 0, 0)]); + // (1, 0, lt, 0): a live trigger level, so the plain LSB flip to 0 is safe and taken. + let mut e = RumbleEngine::new(); + e.set_quirks(0, DECK); + wire4(&mut e, t0, 0, 1, 0, 0x8000, 0, Some(400)); + assert_eq!(drain4(&mut e, t0), vec![(1, 0, 0x8000, 0)]); + assert_eq!(drain4(&mut e, t0 + ms(40)), vec![(0, 0, 0x8000, 0)]); + assert_eq!(drain4(&mut e, t0 + ms(80)), vec![(1, 0, 0x8000, 0)]); + } + + /// A pad still buzzing on the triggers alone must be silenced by the close drain — the same + /// contract the handles have, and the reason `close_drain` tests all four levels. + #[test] + fn close_drain_silences_a_trigger_only_pad() { + let mut e = RumbleEngine::new(); + let t0 = Instant::now(); + wire4(&mut e, t0, 2, 0, 0, 0, 0x9000, Some(400)); + assert!(e.poll(t0).0.is_some()); + assert_eq!(e.close_drain(), Some(cmd(2, 0, 0, 0))); + assert_eq!(e.close_drain(), None); + } } diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 60b559dd..ef61b356 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -145,7 +145,18 @@ pub use stats::Stats; /// connection was simply lost. Purely a read of state the core already had: no new call is required /// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same /// bytes either way, so [`WIRE_VERSION`] is unchanged. -pub const ABI_VERSION: u32 = 17; +/// v18: added `punktfunk_connection_next_rumble_cmd2` — the policy engine's rumble command with the +/// two Xbox impulse-trigger motor levels off the 0xCA v3 tail +/// (`design/trigger-rumble-plane.md`), which the fixed out-params of +/// `punktfunk_connection_next_rumble_cmd` have no room for. A NEW symbol, not a widened one: an +/// exported parameter list is part of the contract, and growing one in place breaks every +/// out-of-tree embedder at once. The old entry point is unchanged in signature AND in the levels +/// it reports — it keeps writing the two handle motors, which is the correct instruction for the +/// actuators it owns, so an embedder that never adopts the new symbol behaves exactly as before. +/// Additive and client-local: the v3 tail has been on the wire (and length-tolerant in both +/// decoders) since it landed, and the host sends the same bytes either way, so [`WIRE_VERSION`] is +/// unchanged. +pub const ABI_VERSION: u32 = 18; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** diff --git a/docs/embedding-the-c-abi.md b/docs/embedding-the-c-abi.md index c7d76014..d3cf0c76 100644 --- a/docs/embedding-the-c-abi.md +++ b/docs/embedding-the-c-abi.md @@ -484,6 +484,29 @@ Pull these on your feedback thread (or poll with `timeout_ms = 0`). Same Amplitudes 0..0xFFFF; `(0,0)` = stop. `ttl_ms` is a host-supplied self-terminating lease — render the level for that long unless renewed; `PUNKTFUNK_RUMBLE_NO_TTL` means fall back to your own staleness timeout. (The v1 `_next_rumble` drops the TTL — prefer v2.) +- **Rumble, policy-engine form** — `punktfunk_connection_next_rumble_cmd(c, &pad, &low, &high, + &backstop_ms, timeout)` hands you **effective commands** instead of raw wire state: the core owns + lease expiry, legacy-host staleness and close-drain zeros, so you apply what you are told and keep + no staleness policy of your own. `backstop_ms` is a safety net for APIs that take a duration + (ignored by explicit-stop APIs; `0` on stops). Pick **one** rumble API per connection — they + consume the same plane. +- **Rumble with trigger motors** (ABI ≥ 18) — `punktfunk_connection_next_rumble_cmd2(c, &pad, &low, + &high, &left_trigger, &right_trigger, &backstop_ms, timeout)` is the same command with the two + Xbox impulse-trigger levels, on the same 0..0xFFFF scale; a stop is all four at zero. It is a + **new symbol, not a wider `_cmd`** — `_cmd` keeps its signature and its two-handle view forever, + so existing embedders need no change. Render the trigger pair only on a pad that has trigger + motors (Windows: `IGameInputDevice::SetRumbleState`'s `leftTrigger`/`rightTrigger`, or WGI's + `GamepadVibration`; SDL: `SDL_RumbleGamepadTriggers` gated on + `SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN`; Apple: `GCHapticsLocalityLeftTrigger` / + `…RightTrigger`) and **drop them otherwise — never fold them into a handle motor**: impulse-trigger + content is continuous (a racing title drives it off engine RPM and tyre slip while the handles stay + near silent), so folding drones a handle flat-out for the whole race at a level the game never + asked for. A pad without trigger motors is the common case, not an error; do not log per command. + Note that on a trigger-driving host a `_cmd` caller now sees commands carrying `low == high == 0` + while only the triggers run — correct (its motors *should* be silent) and idempotent. + Nothing sources non-zero trigger levels end to end yet: only the Windows HID Xbox pad has the + channel at all (XInput's `XINPUT_VIBRATION` and evdev's `FF_RUMBLE` each have exactly two + members), and it is reachable only through GameInput/WGI. - **DualSense HID output** — `punktfunk_connection_next_hidout(c, &out, timeout)`. `out.kind` selects lightbar RGB / player LEDs / adaptive-trigger effect / trackpad haptic. Replay on a real DualSense via the platform's controller API. Only a DualSense-backend session emits these. @@ -629,7 +652,10 @@ shared-mode render. Request 6/8 channels at connect for surround. and emit `GAMEPAD_BUTTON`/`GAMEPAD_AXIS` events. Because a real Xbox pad drives this, connect with `PUNKTFUNK_GAMEPAD_XBOXONE` for matching glyphs. Rumble comes **back** from the host — feed `punktfunk_connection_next_rumble2` into `IGameInputDevice::SetRumbleState` (map `low`→ -low-frequency, `high`→high-frequency motors). +low-frequency, `high`→high-frequency motors). `GameInputRumbleParams` has two more members, +`leftTrigger`/`rightTrigger`, and this is the one platform API that can drive them: use +`punktfunk_connection_next_rumble_cmd2` (ABI ≥ 18) instead and fill all four. The host can only +ever source non-zero trigger levels from its Windows HID Xbox pad, so expect zeros elsewhere. **Skeleton (C++):** diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index d7a44760..2f62ab3d 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -83,7 +83,18 @@ // connection was simply lost. Purely a read of state the core already had: no new call is required // of an embedder, a client that never calls it is unchanged, and the host sends exactly the same // bytes either way, so [`WIRE_VERSION`] is unchanged. -#define PUNKTFUNK_ABI_VERSION 17 +// v18: added `punktfunk_connection_next_rumble_cmd2` — the policy engine's rumble command with the +// two Xbox impulse-trigger motor levels off the 0xCA v3 tail +// (`design/trigger-rumble-plane.md`), which the fixed out-params of +// `punktfunk_connection_next_rumble_cmd` have no room for. A NEW symbol, not a widened one: an +// exported parameter list is part of the contract, and growing one in place breaks every +// out-of-tree embedder at once. The old entry point is unchanged in signature AND in the levels +// it reports — it keeps writing the two handle motors, which is the correct instruction for the +// actuators it owns, so an embedder that never adopts the new symbol behaves exactly as before. +// Additive and client-local: the v3 tail has been on the wire (and length-tolerant in both +// decoders) since it landed, and the host sends the same bytes either way, so [`WIRE_VERSION`] is +// unchanged. +#define PUNKTFUNK_ABI_VERSION 18 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -195,7 +206,10 @@ // uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so // games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain; -// impulse-trigger rumble is unreachable through a virtual pad). Useful for glyph-matching a +// impulse-trigger rumble is unreachable through THIS pad — evdev's `FF_RUMBLE` is two +// magnitudes and has no third, so a uinput backend can never source it. The Windows HID Xbox +// backend can, off its output report `0x03`; see +// [`punktfunk_connection_next_rumble_cmd2`]). Useful for glyph-matching a // physical X-Box One/Series controller on the client. #define PUNKTFUNK_GAMEPAD_XBOXONE 3 @@ -2774,8 +2788,20 @@ PunktfunkStatus punktfunk_connection_next_rumble2(PunktfunkConnection *c, // [`PunktfunkStatus::NoFrame`] on timeout; [`PunktfunkStatus::Closed`] once the session ended AND // every close-drain stop was delivered — silence all actuators on it. // -// An embedder uses EITHER this or `next_rumble`/`next_rumble2` for a connection's lifetime, -// never both (they consume the same wire plane). +// **Handle motors only.** A pad also carries two Xbox impulse-trigger levels, which this entry +// point has no out-params for and never will — +// [`punktfunk_connection_next_rumble_cmd2`] is the four-motor pull. Staying here is a supported +// choice, not a deprecation: for a controller with no trigger motors — every pad but an Xbox +// One/Series/Elite — the two views are identical, and where they differ, "the handles are silent" +// is exactly the right instruction for the motors this API owns. +// +// The one observable difference against a trigger-driving host: a rumble that moves only the +// triggers still produces commands here, carrying `low == high == 0`. They are idempotent stops +// for the handles; the engine's redundant-stop suppression cannot fold them away, because the +// command is not silent — some motor on that pad is running. +// +// An embedder uses EITHER this (or its `2` form) or `next_rumble`/`next_rumble2` for a +// connection's lifetime, never both (they consume the same wire plane). // // # Safety // `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one @@ -2788,6 +2814,52 @@ PunktfunkStatus punktfunk_connection_next_rumble_cmd(PunktfunkConnection *c, uint32_t timeout_ms); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`punktfunk_connection_next_rumble_cmd`] with the two Xbox impulse-trigger motors: the same +// command, all four of its levels. `*left_trigger` / `*right_trigger` are on the same +// `0..=0xFFFF` scale as `low`/`high`, and a stop is all four at zero. +// +// A NEW symbol rather than a wider signature on the old one, following the +// `next_rumble` → `next_rumble2` precedent in this file: an exported entry point's parameter list +// is part of the contract, and silently growing one breaks every out-of-tree embedder at once, +// with a stack-corruption signature rather than a link error. Old callers keep the old symbol and +// simply never see the trigger levels. +// +// **Render the trigger levels only on a pad that actually has trigger motors, and drop them +// otherwise** — do not fold them into the handles. Impulse-trigger content is continuous +// (a racing title drives engine RPM and tyre slip into the triggers while the handles stay near +// silent), so folding it produces a handle motor droning flat-out for the whole race at a level +// the game never asked for. Query the hardware: SDL's +// `SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN`, Apple's `GCDeviceHaptics.supportedLocalities` +// (`GCHapticsLocalityLeftTrigger`/`…RightTrigger`). A pad without them is the common case and not +// an error — do not log per command. +// +// **Nothing has driven these levels non-zero end to end yet, and that is structural, not an +// oversight.** Exactly one producer can ever source them — the Windows HID Xbox pad's output +// report `0x03` — because classic XInput's `XINPUT_VIBRATION` has two members and evdev's +// `FF_RUMBLE` has two, so no other host backend on any OS has the channel. That producer is +// reachable only through GameInput/WGI, and an xinputhid-promoted Xbox pad is not enumerated by +// GameInput at all (measured against a real Microsoft Elite, which is equally invisible there +// while XInput reads it live). So this delivery path is deliberately built ahead of its producer: +// the wire, the engine and this entry point are exercised only by synthetic levels. +// +// Same threading, timeout and close semantics as +// [`punktfunk_connection_next_rumble_cmd`]; the two share one wire plane and one policy engine, +// so an embedder calls exactly one of them. +// +// # Safety +// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one +// thread pulls rumble — it may run concurrently with the video/audio pullers. +PunktfunkStatus punktfunk_connection_next_rumble_cmd2(PunktfunkConnection *c, + uint16_t *pad, + uint16_t *low, + uint16_t *high, + uint16_t *left_trigger, + uint16_t *right_trigger, + uint32_t *backstop_ms, + uint32_t timeout_ms); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the // shared rumble policy engine instead of forking it (typically called at controller attach). -- 2.54.0