diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index 2e7a483c..da93f94b 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -323,7 +323,7 @@ static DECK_RDESC: [u8; 38] = [ // report forever. `0x3F` payload bytes so `FeatureReportByteLength` lands on 64, the buffer size // `channel_proof::query` asks with; the proof itself needs 17. #[rustfmt::skip] -static XBOX_RDESC: [u8; 150] = [ +static XBOX_RDESC: [u8; 223] = [ 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x05, // Usage (Game Pad) 0xA1, 0x01, // Collection (Application) @@ -386,6 +386,67 @@ static XBOX_RDESC: [u8; 150] = [ 0x75, 0x01, // Report Size (1) 0x95, 0x01, // Report Count (1) 0x81, 0x03, // Input (Cnst,Var,Abs) — pad to a byte boundary + // ---- Rumble OUTPUT report `0x03` (Physical Interface Device page) ---- + // + // Without this the pad can receive NOTHING. hidclass routes an output report only if the + // descriptor declares one, so with no `0x91` item `on_output_report` never fires, + // `publish_output` never writes the ring, and `parse_xbox_output` + // (`inject/windows/xbox_windows.rs`) is unreachable code — the whole host-side rumble plane is + // already built and was simply never fed. That is why the HID Xbox pad had no rumble at all, + // not merely no trigger rumble. + // + // ⚠️ PROVENANCE — HAND-WRITTEN, and it could not be otherwise. Every other output collection in + // this file is a capture, and §3 of `design/xbox-pad-windows-handoff.md` insists on captures. + // But the Elite capture taken for that work reports `OUTPUT items: 0` (Windows exposes no + // literal report-descriptor bytes; hidapi reconstructs from `HidD_GetPreparsedData`, and that + // reconstruction carries no output collection for this pad). So there was nothing to copy. + // This block is the documented Xbox One S / Elite Bluetooth rumble report — PID-page + // `Set Effect Report`, id `0x03`, 8 payload bytes — chosen because it is exactly the layout + // `parse_xbox_output` and `design/trigger-rumble-plane.md` §2.1 already specify: + // [0x03][enable][left_trigger][right_trigger][left][right][duration][delay][loop] + // with magnitudes 0..100 (hence `Logical Maximum (100)`, not 255). + // **Replace it with a Linux hidraw capture when one can be taken** — that is the only route to + // byte-exact truth here, and the enable-bit assignments for the two TRIGGER actuators remain + // unverified (see trigger-rumble-plane.md WP0). + // + // Declared AFTER the final Input item and re-stating every global it uses, so it cannot + // retroactively alter the 16-byte input layout `xbox_proto`'s tests pin. + 0x05, 0x0F, // Usage Page (Physical Interface Device) + 0x09, 0x21, // Usage (Set Effect Report) + 0x85, 0x03, // Report ID (3) + 0xA1, 0x02, // Collection (Logical) + 0x09, 0x97, // Usage (DC Enable Actuators) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x91, 0x02, // Output (Data,Var,Abs) — the enable mask, low nibble + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x00, // Logical Maximum (0) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x91, 0x03, // Output (Cnst,Var,Abs) — pad the enable byte + 0x09, 0x70, // Usage (Magnitude) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x64, // Logical Maximum (100) — percent, NOT 255 + 0x75, 0x08, // Report Size (8) + 0x95, 0x04, // Report Count (4) — LT, RT, left handle, right handle + 0x91, 0x02, // Output (Data,Var,Abs) + 0x09, 0x50, // Usage (Duration) + 0x66, 0x01, 0x10, // Unit (SI Linear: seconds) + 0x55, 0x0E, // Unit Exponent (-2) — centiseconds + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x91, 0x02, // Output (Data,Var,Abs) + 0x09, 0xA7, // Usage (Start Delay) — same unit and range as Duration + 0x91, 0x02, // Output (Data,Var,Abs) + 0x65, 0x00, // Unit (None) + 0x55, 0x00, // Unit Exponent (0) + 0x09, 0x7C, // Usage (Loop Count) + 0x91, 0x02, // Output (Data,Var,Abs) + 0xC0, // End Collection // The channel-proof feature report — see the ⚠️ above. Declared last so it cannot disturb the // INPUT layout `xbox_proto` packs against: every global item here (Report Size/Count, Logical // Min/Max) is re-stated after the final Input item, so nothing above is retroactively changed. @@ -413,7 +474,22 @@ static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01 static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01]; static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01]; static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes -static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x96, 0x00]; // 150 bytes +static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xDF, 0x00]; // 223 bytes + +// Each `wReportLength` above is a SECOND copy of a length that already exists as its descriptor's +// array size, and the two are edited in different places. Getting them out of step does not fail +// loudly — hidclass asks for `wReportLength` bytes and then parses whatever it got, so the pad +// either enumerates with a truncated descriptor or fails to enumerate at all, with nothing naming +// the cause. Assert the pairing at compile time instead; adding an item to a descriptor now cannot +// build until its length is updated too. +const fn declared_len(hid_desc: &[u8; 9]) -> usize { + (hid_desc[7] as usize) | ((hid_desc[8] as usize) << 8) +} +const _: () = assert!(declared_len(&HID_DESC) == DUALSENSE_RDESC.len()); +const _: () = assert!(declared_len(&DS4_HID_DESC) == DS4_RDESC.len()); +const _: () = assert!(declared_len(&EDGE_HID_DESC) == DS_EDGE_RDESC.len()); +const _: () = assert!(declared_len(&DECK_HID_DESC) == DECK_RDESC.len()); +const _: () = assert!(declared_len(&XBOX_HID_DESC) == XBOX_RDESC.len()); // HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11]. // `devtype` selects the identity: PS family (same Sony VID/version) or the N4-spike Deck. diff --git a/tools/hid-descriptor-dump/src/main.rs b/tools/hid-descriptor-dump/src/main.rs index cf14a2d0..2b293b98 100644 --- a/tools/hid-descriptor-dump/src/main.rs +++ b/tools/hid-descriptor-dump/src/main.rs @@ -54,22 +54,28 @@ fn extract_rust_array(src: &str, symbol: &str) -> Result, String> { let at = src .find(&format!("static {symbol}:")) .ok_or_else(|| format!("no `static {symbol}:` in that file"))?; - let open = src[at..] - .find('[') - .and_then(|i| src[at + i + 1..].find('[').map(|j| at + i + 1 + j + 1)) - .ok_or("could not find the array literal")?; - let close = src[open..] - .find(']') - .ok_or("array literal is never closed")? - + open; - // Strip trailing `// ...` comments LINE BY LINE before splitting on commas — the annotations in - // these arrays contain commas themselves (`// Input (Data,Var,Abs)`), so comma-splitting first - // scatters comment text into the byte stream. - let body: String = src[open..close] + + // 🛑 STRIP COMMENTS FIRST, then find the brackets — not the other way round. These arrays are + // heavily annotated and the annotations contain both `,` and `]` (a comment documenting a wire + // layout as `[0x03][enable][left]…` is real, and it appears inside `XBOX_RDESC`). Locating the + // closing bracket on the raw text stops at the first `]` in a COMMENT and silently truncates + // the array — which reads as a corrupt descriptor rather than a parse bug. + let tail: String = src[at..] .lines() .map(|l| l.split("//").next().unwrap_or("")) .collect::>() - .join(" "); + .join("\n"); + + // First `[` is the type's `[u8; N]`; the next one opens the literal. + let open = tail + .find('[') + .and_then(|i| tail[i + 1..].find('[').map(|j| i + 1 + j + 1)) + .ok_or("could not find the array literal")?; + let close = tail[open..] + .find(']') + .ok_or("array literal is never closed")? + + open; + let body = &tail[open..close]; let mut out = Vec::new(); for tok in body.split(',') { let tok = tok.trim(); diff --git a/tools/win-input-matrix/src/main.rs b/tools/win-input-matrix/src/main.rs index 3d8c999d..78ba28a7 100644 --- a/tools/win-input-matrix/src/main.rs +++ b/tools/win-input-matrix/src/main.rs @@ -41,7 +41,9 @@ mod imp { }; use windows::Win32::Foundation::ERROR_NO_MORE_ITEMS; use windows::Win32::System::Com::CoIncrementMTAUsage; - use windows::Win32::UI::Input::XboxController::{XINPUT_STATE, XInputGetState}; + use windows::Win32::UI::Input::XboxController::{ + XINPUT_STATE, XINPUT_VIBRATION, XInputGetState, XInputSetState, + }; // `Interface` brings `cast()` into scope, which is how a WinRT `Gamepad` is correlated to the // `RawGameController` that knows its name. use windows::core::{GUID, Interface}; @@ -158,6 +160,45 @@ mod imp { std::thread::sleep(Duration::from_millis(1500)); } + /// Drive rumble into an XInput slot and hold it, so the other end of the pipe can be watched. + /// + /// This is the WP0 probe from `design/trigger-rumble-plane.md`: does anything Windows-side ever + /// write an output report back to a synthesized `045E:0B13`? For the HID backend the chain + /// under test is `XInputSetState` → `xinputhid` → a HID output report on our collection → + /// `on_output_report` → the shm out-ring → `parse_xbox_output`, and the observable is the + /// devtest printing `rumble from game`. Run this with the devtest live and watch its stdout. + /// + /// ⚠️ `XINPUT_VIBRATION` has exactly TWO members, so this can only ever drive the two handle + /// motors — it can never source TRIGGER rumble. That is a property of the API, not of our + /// plumbing, and it is why the trigger plane needs its own transport. + fn rumble(slot: u32, seconds: u64) { + println!("== RUMBLE PROBE: XInputSetState(slot {slot}) for {seconds}s =="); + let v = XINPUT_VIBRATION { + wLeftMotorSpeed: 0xFFFF, + wRightMotorSpeed: 0x8000, + }; + // SAFETY: `v` is a valid, fully-initialised XINPUT_VIBRATION. + let rc = unsafe { XInputSetState(slot, &v) }; + println!( + " set low=0xFFFF high=0x8000 -> rc={rc}{}", + if rc == 0 { + " (accepted)" + } else { + " (REJECTED)" + } + ); + if rc != 0 { + println!(" (slot not connected — nothing downstream can be concluded)"); + return; + } + std::thread::sleep(Duration::from_secs(seconds)); + let off = XINPUT_VIBRATION::default(); + // SAFETY: as above. + let rc2 = unsafe { XInputSetState(slot, &off) }; + println!(" clear low=0 high=0 -> rc={rc2}"); + println!(" ⇒ now check the devtest stdout for `rumble from game`."); + } + fn xinput() { println!("== classic XInput (xinput1_4 walks GUID_DEVINTERFACE_XUSB) =="); for slot in 0..4u32 { @@ -281,6 +322,100 @@ mod imp { } } + /// Sample XInput over the whole watch window and report the RANGE each axis covered. + /// + /// A single `XInputGetState` call cannot tell "translated correctly" from "stuck at zero" — + /// a sweeping stick reads 0 every time it crosses centre. `dwPacketNumber` advancing proves + /// the state is changing at all; the min/max spread proves the AXES specifically are, which is + /// the half that can fail on its own while buttons work. + struct XiTrack { + first_packet: u32, + last_packet: u32, + lx: (i16, i16), + ly: (i16, i16), + rx: (i16, i16), + ry: (i16, i16), + buttons: u16, + lt: (u8, u8), + rt: (u8, u8), + } + + fn xinput_watch(rounds: usize) { + println!("\n== XINPUT WATCH ({rounds} samples) — do PACKETS advance and AXES move? =="); + for slot in 0..4u32 { + let mut t: Option = None; + for _ in 0..rounds { + let mut st = XINPUT_STATE::default(); + // SAFETY: `st` is a valid, fully-initialised XINPUT_STATE. + if unsafe { XInputGetState(slot, &mut st) } != 0 { + break; + } + let g = st.Gamepad; + match &mut t { + None => { + t = Some(XiTrack { + first_packet: st.dwPacketNumber, + last_packet: st.dwPacketNumber, + lx: (g.sThumbLX, g.sThumbLX), + ly: (g.sThumbLY, g.sThumbLY), + rx: (g.sThumbRX, g.sThumbRX), + ry: (g.sThumbRY, g.sThumbRY), + buttons: g.wButtons.0, + lt: (g.bLeftTrigger, g.bLeftTrigger), + rt: (g.bRightTrigger, g.bRightTrigger), + }); + } + Some(t) => { + t.last_packet = st.dwPacketNumber; + t.lx = (t.lx.0.min(g.sThumbLX), t.lx.1.max(g.sThumbLX)); + t.ly = (t.ly.0.min(g.sThumbLY), t.ly.1.max(g.sThumbLY)); + t.rx = (t.rx.0.min(g.sThumbRX), t.rx.1.max(g.sThumbRX)); + t.ry = (t.ry.0.min(g.sThumbRY), t.ry.1.max(g.sThumbRY)); + t.buttons |= g.wButtons.0; + t.lt = (t.lt.0.min(g.bLeftTrigger), t.lt.1.max(g.bLeftTrigger)); + t.rt = (t.rt.0.min(g.bRightTrigger), t.rt.1.max(g.bRightTrigger)); + } + } + std::thread::sleep(Duration::from_millis(120)); + } + match t { + None => println!(" slot {slot}: not connected"), + Some(t) => { + let moved = t.last_packet != t.first_packet; + let axes_moved = t.lx.0 != t.lx.1 + || t.ly.0 != t.ly.1 + || t.rx.0 != t.rx.1 + || t.ry.0 != t.ry.1 + || t.lt.0 != t.lt.1 + || t.rt.0 != t.rt.1; + println!( + " slot {slot}: packets {}..{} ({}), buttons seen 0x{:04X}", + t.first_packet, + t.last_packet, + if moved { "ADVANCING" } else { "FROZEN" }, + t.buttons + ); + println!( + " LX [{}..{}] LY [{}..{}] RX [{}..{}] RY [{}..{}] LT [{}..{}] RT [{}..{}] -> axes {}", + t.lx.0, + t.lx.1, + t.ly.0, + t.ly.1, + t.rx.0, + t.rx.1, + t.ry.0, + t.ry.1, + t.lt.0, + t.lt.1, + t.rt.0, + t.rt.1, + if axes_moved { "MOVING" } else { "STUCK" } + ); + } + } + } + } + /// Flag every device whose current reading differs from the baseline one. Once a device has /// moved it stays flagged — a pad that twitches once in twenty samples is still LIVE. fn mark_moved(base: &[Sample], now: &[Sample], moved: &mut [bool]) { @@ -350,6 +485,7 @@ mod imp { pub fn run() { let args: Vec = std::env::args().skip(1).collect(); let mut rounds = 0usize; + let mut rumble_slot: Option = None; let mut i = 0; while i < args.len() { match args[i].as_str() { @@ -357,10 +493,16 @@ mod imp { rounds = args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(20); i += 1; } + "--rumble" => { + rumble_slot = Some(args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(0)); + i += 1; + } "--help" | "-h" => { println!( - "win-input-matrix [--watch N]\n\n \ - --watch N sample WGI N times and report LIVE vs MUTE per device\n\n\ + "win-input-matrix [--watch N] [--rumble SLOT]\n\n \ + --watch N sample WGI N times and report LIVE vs MUTE per device\n \ + --rumble SLOT drive XInputSetState into that slot for 3 s (WP0 probe:\n \ + does anything write an output report back to our pad?)\n\n\ ALWAYS take a baseline with your virtual pad STOPPED and diff it: a real\n\ pad on the box owns XInput slot 0 and shows up in WGI." ); @@ -412,6 +554,11 @@ mod imp { if rounds > 0 { watch(rounds); + xinput_watch(rounds); + } + if let Some(slot) = rumble_slot { + println!(); + rumble(slot, 3); } } }