diff --git a/Cargo.toml b/Cargo.toml index 8a6b99d4..16f1369e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,11 @@ members = [ exclude = [ "packaging/linux/steam-deck-gadget/usbip-poc", "clients/android/native/vendor/ndk", + # 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/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/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-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 3ea79836..48d3813b 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -791,6 +791,37 @@ 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; + /// `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/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/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/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/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index e43ab836..715dc9f2 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], @@ -263,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 { @@ -339,13 +363,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 +394,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 +410,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 +425,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 +496,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 +760,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 +810,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 +822,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 +840,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 +885,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 +917,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 +927,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 +947,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 +981,7 @@ mod tests { }]; let out = RefCell::new(0u32); m.pump( - |_, _, _| {}, + |_, _, _, _, _| {}, |_| { *out.borrow_mut() += 1; }, @@ -976,7 +1051,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 +1059,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 +1073,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 +1100,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 72871d63..340c1f5a 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::>() }) @@ -1018,7 +1031,15 @@ mod drain_tests { WinDsIdentity::dualsense_edge().hwid, super::super::dualshock4_windows::DS4_HWID, super::super::steam_deck_windows::DECK_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!( @@ -1032,6 +1053,81 @@ mod drain_tests { } } + /// 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 + /// `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. 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!( + 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: Vec = super::super::xbox_windows::XBOX_IDENTITIES + .iter() + .map(|i| i.hwid.to_ascii_lowercase()) + .collect(); + + 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; + }; + 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(); + // `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" + ); + } + } + 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 /// 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 @@ -1071,7 +1167,7 @@ mod drain_tests { .collect(); assert_eq!( entries.len(), - 4, + 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" ); @@ -1098,7 +1194,16 @@ mod drain_tests { super::super::steam_deck_windows::DECK_HWID, pf_driver_proto::gamepad::DEVTYPE_STEAMDECK, ), - ] { + ] + .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/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_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 b6b8ed91..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!( @@ -356,7 +363,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 +381,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 +398,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 new file mode 100644 index 00000000..8098d45c --- /dev/null +++ b/crates/pf-inject/src/inject/windows/xbox_windows.rs @@ -0,0 +1,463 @@ +//! 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 +//! 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 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). +//! +//! ⚠️ **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 +//! 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; + +/// 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, +} + +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. +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` 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(); + // 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) = 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); + 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!("{}_{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: 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: id.description, + })?; // 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 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( + id.hwid, + "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, 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, 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. +/// +/// 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. +/// +/// ⚠️ **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 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, +/// unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`], exactly as for the PS pads. +/// +/// 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; + 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, self.identity)?; + tracing::info!( + index = idx, + identity = self.identity.usb_vid_pid, + description = self.identity.description, + "virtual Xbox pad created (Windows UMDF HID)" + ); + 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::*; + + // 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() { + let full = [0x03, 0x0F, 0, 0, 100, 100, 0, 0, 1]; + 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, 0, 0))); + } + + /// 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, 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 nothing is a stop. + #[test] + fn the_enable_mask_gates_each_motor() { + 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, 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 + /// 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 c1f63766..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 @@ -474,6 +483,25 @@ pub mod uhid_abi; #[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/uhid_manager.rs"] pub mod uhid_manager; +/// 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 +/// 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; +/// 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; /// 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 { @@ -484,7 +512,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/abi.rs b/crates/punktfunk-core/src/abi.rs index 82a6b008..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 @@ -1219,6 +1222,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 +1352,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); @@ -2717,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 @@ -2769,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 eceb6474..02c38bf7 100644 --- a/crates/punktfunk-core/src/client/pump/datagram_task.rs +++ b/crates/punktfunk-core/src/client/pump/datagram_task.rs @@ -91,8 +91,24 @@ 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). + // + // 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/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-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/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 2fff4e6c..5b47a4ce 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:?}"); @@ -364,6 +366,16 @@ 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"); + // 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 @@ -386,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.", @@ -395,7 +424,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) { @@ -406,17 +437,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)); @@ -433,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." @@ -440,8 +489,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 @@ -463,6 +514,30 @@ 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 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/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..6f205080 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 { @@ -1819,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 diff --git a/crates/punktfunk-host/src/native/gamepad.rs b/crates/punktfunk-host/src/native/gamepad.rs index ba8b16ad..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,75 @@ 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 +/// `PUNKTFUNK_XBOX_BACKEND=xusb` to go back to the companion. +/// +/// **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. +/// +/// 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 { + 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 /// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive. pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { @@ -239,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 @@ -335,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 377d5037..179881e3 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -123,6 +123,21 @@ 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. + /// + /// 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")] @@ -165,6 +180,12 @@ impl Pads { #[cfg(target_os = "windows")] dualsense_win: None, #[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, @@ -291,6 +312,38 @@ impl Pads { .steamdeck_win .get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new) .handle(ev), + // 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 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) @@ -408,12 +461,18 @@ 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 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( &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 { @@ -451,6 +510,19 @@ impl Pads { } #[cfg(target_os = "windows")] { + // 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 { m.pump(&mut rumble, &mut hidout); } @@ -731,26 +803,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() }; @@ -767,11 +871,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, @@ -792,14 +899,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]; @@ -1014,43 +1127,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| { @@ -1070,27 +1190,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()); @@ -1259,11 +1373,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" ); @@ -1305,4 +1420,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/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 079fccd8..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 @@ -236,6 +250,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`. @@ -1196,6 +1216,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 @@ -2759,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 @@ -2773,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). diff --git a/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx index a861c3b1..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,6 +50,19 @@ pf_gamepad.dll=1 %DeviceDescDS4%=pfGamepad, pf_dualshock4 %DeviceDescEdge%=pfGamepad, pf_dualsenseedge %DeviceDescDeck%=pfGamepad, pf_steamdeck +; ⚠️ 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 @@ -82,6 +98,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" @@ -100,3 +187,10 @@ 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" +; ⚠️ 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 15dae40e..86a06166 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -1,7 +1,10 @@ // 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), 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. // @@ -72,6 +75,55 @@ const DS_EDGE_PID: u16 = 0x0DF2; const DECK_VID: u16 = 0x28DE; const DECK_PID: u16 = 0x1205; +// ---- 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 +// 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 — `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. +/// +/// ⭐ 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; +/// 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). // NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273. #[rustfmt::skip] @@ -241,6 +293,229 @@ static DECK_RDESC: [u8; 38] = [ 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0, ]; +// ---- 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`, +// [`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. +// +// ⭐ **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 +// 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; 223] = [ + 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 + // 🛑 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, 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) + 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 + // ---- 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. + 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 +]; + +/// 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 +523,68 @@ 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 +// 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. +// `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) = 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), + 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]; 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. All three +/// Xbox identities share one descriptor, hence one report length. +fn input_report_len(devtype: u8) -> usize { + match devtype { + 4 | 5 | 6 => 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 +618,27 @@ 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, + // 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 } } @@ -455,8 +791,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. @@ -472,9 +808,15 @@ 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_steamdeck", 3u8), + ("pf_xboxwireless", 4u8), + ("pf_xboxones", 5), + ("pf_xboxelite", 6), + ("pf_steamdeck", 3), ("pf_dualsenseedge", 2), ("pf_dualshock4", 1), ("pf_dualsense", 0), @@ -724,7 +1066,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,13 +1104,17 @@ extern "C" fn evt_io_device_control( 1 => &DS4_HID_DESC, 2 => &EDGE_HID_DESC, 3 => &DECK_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 | 5 | 6 => &XBOX_RDESC[..], _ => &DUALSENSE_RDESC[..], }), IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => { @@ -776,7 +1122,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 +1376,7 @@ fn on_get_string(request: &Request) -> NTSTATUS { 0 | 0x000e => match devtype { 1 => "Sony Computer Entertainment".into(), 3 => "Valve Software".into(), + 4 | 5 | 6 => "Microsoft".into(), _ => "Sony Interactive Entertainment".into(), }, // Per-pad serials (see `pad_index`): SDL reads this via HidD_GetSerialNumberString and @@ -1035,12 +1388,30 @@ 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. 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 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(), }, }; @@ -1052,7 +1423,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, 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 @@ -1142,7 +1514,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); } } 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 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..2b293b98 --- /dev/null +++ b/tools/hid-descriptor-dump/src/main.rs @@ -0,0 +1,432 @@ +//! 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"))?; + + // 🛑 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("\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(); + 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 +} 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..7d94386e --- /dev/null +++ b/tools/win-input-matrix/Cargo.toml @@ -0,0 +1,35 @@ +# 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", + "Win32_System_LibraryLoader", + "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/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 new file mode 100644 index 00000000..545ff65c --- /dev/null +++ b/tools/win-input-matrix/src/main.rs @@ -0,0 +1,608 @@ +//! 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 gameinput; + +#[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, 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}; + + /// `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)); + } + + /// 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 { + 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)"); + } + } + + /// 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]) { + 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 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() { + "--watch" => { + 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; + } + "--help" | "-h" => { + println!( + "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." + ); + 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); + xinput_watch(rounds); + } + if let Some(slot) = rumble_slot { + 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); + } + } + } + } + } +} + +#[cfg(windows)] +fn main() { + imp::run(); +}