From ae35e8b4d763f59a95e954e2aefd31a319d5875a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 19:02:13 +0200 Subject: [PATCH] test(tools): capture the real Xbox descriptor, because ours was invented and disagrees with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `XBOX_RDESC` is the only report descriptor in `pf-gamepad` that was hand-written rather than captured off hardware, and its own provenance warning has now come true three times. The fix for that class of bug is not another careful reading — it is a tool that goes and asks the device. `tools/hid-descriptor-dump` does that: it dumps a real HID device's report descriptor, decodes it into an annotated item listing plus a bit-offset LAYOUT TABLE, and can decode a blob we already ship through the same decoder (`--rust-source --symbol `) so the two are diffable line for line. `--read N` pulls live wire bytes, which is the only ground truth a reconstructed descriptor cannot give you. Deliberately NOT a workspace member — it pulls `hidapi`, a C library wanting libudev on Linux, which has no business in `cargo build --workspace` or on a CI leg with no pad attached. It is a bring-your-own-hardware tool and it is excluded in the root manifest, so CI never sees it. The captured Elite disagrees with our blob in four ways, and the dangerous one is field ORDER: the real pad reports sticks, ONE combined 16-bit Z trigger, then BUTTONS, then the hat, in an UNNUMBERED 15-byte report; ours declares Report ID 1, two Simulation-page trigger axes, then the hat, then 15 buttons. Since we claim a genuine Microsoft VID/PID and SDL/Steam/Windows all apply stock mappings keyed on it, that ordering difference is exactly how every control silently lands on the wrong action. The driver comment now records the diff and the two blockers that stop the capture from simply being pasted in. VERIFIED * `cargo fmt --check` clean, `cargo clippy --all-targets -- -D warnings` clean (macOS). * The tool builds and runs on macOS and on .173 (Windows 11 26200, cargo 1.96, MSVC, no WDK). * TOOL VALIDATED AGAINST A KNOWN-GOOD CONTROL: pointed at the live DualSense on .173, it reproduces the real `DUALSENSE_RDESC` layout exactly (input 0x01, 64 B, X,Y,Z,Rz,Rx,Ry at bytes 1..6, hat 8.0, 15 buttons 8.4, output 0x02, the full feature ladder), and `--read` returned live len=64 reports with sticks centred at 80 80 80 80 and the counter incrementing. * `cargo metadata` on the root workspace still resolves and does NOT list this crate. * The Elite capture is reproducible: `--vid 045E --pid 0B22`. NOT VERIFIED * That the capture equals the pad's NATIVE report map. Windows exposes no API for a device's literal descriptor bytes, so hidapi reconstructs from `HidD_GetPreparsedData` — faithful in structure, item order and bit offsets, not byte-exact (measured: the DualSense's real 273-byte descriptor reconstructs to 467). `xinputhid` also filters that pad, and the captured shape is the legacy DirectInput view. A byte-exact answer needs Linux hidraw. * Why the Elite returned ZERO input reports across two runs (72 s and 90 s) while the DualSense streamed fine on the same code path — untouched pad, or exclusive claim by the XInput translator. Unresolved. * Nothing here was built on Windows as a driver: `XBOX_RDESC` itself is UNCHANGED, so no behaviour changes. The only edit to the driver is its provenance comment. --- Cargo.toml | 3 + .../windows/drivers/pf-gamepad/src/lib.rs | 30 +- tools/hid-descriptor-dump/Cargo.lock | 78 +++ tools/hid-descriptor-dump/Cargo.toml | 27 + .../dualsense-054C-0CE6-usb-windows.txt | 425 +++++++++++++ ...ox-elite-series2-045E-0B22-ble-windows.txt | 204 ++++++ tools/hid-descriptor-dump/src/decode.rs | 581 ++++++++++++++++++ tools/hid-descriptor-dump/src/main.rs | 426 +++++++++++++ 8 files changed, 1769 insertions(+), 5 deletions(-) create mode 100644 tools/hid-descriptor-dump/Cargo.lock create mode 100644 tools/hid-descriptor-dump/Cargo.toml create mode 100644 tools/hid-descriptor-dump/captures/dualsense-054C-0CE6-usb-windows.txt create mode 100644 tools/hid-descriptor-dump/captures/xbox-elite-series2-045E-0B22-ble-windows.txt create mode 100644 tools/hid-descriptor-dump/src/decode.rs create mode 100644 tools/hid-descriptor-dump/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 8a6b99d4..e9f14efd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,9 @@ members = [ exclude = [ "packaging/linux/steam-deck-gadget/usbip-poc", "clients/android/native/vendor/ndk", + # Bring-your-own-hardware measurement tool: pulls `hidapi`, a C library wanting libudev on + # Linux, which has no place in `cargo build --workspace` or on a CI leg with no pad attached. + "tools/hid-descriptor-dump", ] # ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index 0db9911d..2e7a483c 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -286,11 +286,31 @@ static DECK_RDESC: [u8; 38] = [ // report laid out differently from the real device, every control silently lands on the wrong // action — the same class of bug this whole change exists to kill. // -// **Before shipping: capture the report descriptor from a real Xbox Wireless Controller over -// Bluetooth and diff it against this.** Recipe: pair the pad, then read -// `HKLM\SYSTEM\CurrentControlSet\Enum\BTHENUM\...\Device Parameters` or use a HID monitor; -// `hidapi`'s `hidapi-hidtest` and Linux `/sys/class/hidraw/hidrawN/device/report_descriptor` both -// dump it directly. Replace this blob and re-run the `xbox_proto` layout tests. +// ⭐ **2026-08-09 — THE CAPTURE NOW EXISTS AND THIS BLOB DISAGREES WITH IT.** A real Xbox Elite +// Series 2 (`045E:0B22`, Bluetooth LE) was captured on `.173` with `tools/hid-descriptor-dump`; the +// dump, its provenance and the DualSense control that validates the tool are in +// `tools/hid-descriptor-dump/captures/`. Re-take it any time with `--vid 045E --pid 0B22`, and +// decode THIS array through the same decoder — no hardware needed — with: +// +// hid-descriptor-dump --rust-source packaging/windows/drivers/pf-gamepad/src/lib.rs \ +// --symbol XBOX_RDESC +// +// Four differences, and the ORDER one is the dangerous one: +// * the real pad's game-controller report is **UNNUMBERED** (15 bytes of fields, no report id); +// this one declares Report ID 1; +// * it carries **ONE combined 16-bit `Z`** trigger axis at byte 8, not two Simulation-page axes; +// * it declares **16 buttons at byte 10, BEFORE the hat** — this one puts 15 buttons AFTER it; +// * neither has an OUTPUT collection, so the rumble gap is real on both. +// +// 🛑 **Do NOT simply paste the capture over this array.** Two blockers, recorded in +// `design/xbox-pad-windows-handoff.md` §3.3: (1) it is unverified whether Windows' view equals the +// pad's NATIVE report map — `xinputhid` filters that pad and the captured shape is the legacy +// DirectInput view, so cross-check on Linux hidraw first; (2) **the real descriptor has no Feature +// report, and we cannot ship without one** — `0x85` is the sealed channel's proof transport, and +// report ids are all-or-nothing, so declaring it forces a numbered input report the real pad does +// not have. Matching the hardware byte for byte and keeping the sealed channel as it stands are +// mutually exclusive; that needs a decision, not a paste. Whatever lands, re-run `xbox_proto`'s +// layout tests — they pin these offsets on the host side. // // ⚠️ The trailing vendor-defined Feature report `0x85` is NOT cosmetic and must not be trimmed as // "unused": it is the CHANNEL PROOF transport (`ProofTransport::HidFeatureReport`). The captured diff --git a/tools/hid-descriptor-dump/Cargo.lock b/tools/hid-descriptor-dump/Cargo.lock new file mode 100644 index 00000000..acc08855 --- /dev/null +++ b/tools/hid-descriptor-dump/Cargo.lock @@ -0,0 +1,78 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "hid-descriptor-dump" +version = "0.26.0" +dependencies = [ + "hidapi", +] + +[[package]] +name = "hidapi" +version = "2.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c78dadfc12f865bc3fcac3897e64533b930737ceb9ef245c8277de98d0b010e9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/tools/hid-descriptor-dump/Cargo.toml b/tools/hid-descriptor-dump/Cargo.toml new file mode 100644 index 00000000..304f49fe --- /dev/null +++ b/tools/hid-descriptor-dump/Cargo.toml @@ -0,0 +1,27 @@ +# Capture a real HID device's report descriptor and decode it into something diffable against the +# blobs `packaging/windows/drivers/pf-gamepad/src/lib.rs` serves. Every descriptor we ship must be +# CAPTURED, not constructed (see that file's provenance warning, and the three bugs a constructed +# one already cost us) — this is the tool that captures them. +# +# Deliberately NOT a workspace member (see the root `Cargo.toml` `exclude` list): it pulls `hidapi`, +# a C library needing libudev on Linux, which we do not want in `cargo build --workspace` or on any +# CI leg. It is a bring-your-own-hardware measurement tool — build it standalone on the box that has +# the pad: +# +# cargo run --manifest-path tools/hid-descriptor-dump/Cargo.toml -- --list +# +# Stands alone. Without this, cargo walks up, finds the repo's `[workspace]` and refuses to build a +# package that root does not list as a member. +[workspace] + +[package] +name = "hid-descriptor-dump" +description = "Capture and decode a real HID device's report descriptor, for diffing against the ones we synthesize" +version = "0.26.0" +edition = "2024" +rust-version = "1.96.0" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +hidapi = "2.6" diff --git a/tools/hid-descriptor-dump/captures/dualsense-054C-0CE6-usb-windows.txt b/tools/hid-descriptor-dump/captures/dualsense-054C-0CE6-usb-windows.txt new file mode 100644 index 00000000..7bb40ad2 --- /dev/null +++ b/tools/hid-descriptor-dump/captures/dualsense-054C-0CE6-usb-windows.txt @@ -0,0 +1,425 @@ +DualSense Wireless Controller — report descriptor, captured 2026-08-09 on .173 over USB. + +WHY THIS FILE EXISTS: it is the CONTROL that makes the Elite capture next to it trustworthy. +`DUALSENSE_RDESC` in packaging/windows/drivers/pf-gamepad/src/lib.rs is verbatim from real hardware +(via inputtino), so pointing the tool at a real DualSense on the same box, in the same session, +tests the tool against a known-good answer. + +RESULT — PASS, on both halves of the tool: + * descriptor: the reconstruction reproduces the real DualSense layout exactly — input report 0x01, + 64 bytes, axes X,Y,Z,Rz,Rx,Ry packed 8-bit at bytes 1..6, hat at 8.0, 15 buttons at 8.4, vendor + bulk to byte 63, output report 0x02, and the feature-report ladder 0x05/0x08/0x09/0x0A/0x0B/ + 0x0C/0x20/0x21/0x22/0x80..0x85/0xA0/0xE0/0xF0..0xF5. It came back 467 bytes against the real + 273 — same layout, more verbose encoding. That single number is the evidence for the "diff the + layout, not the bytes" rule stated in the Elite capture's header. + * live reads: `--read 4` returned len=64 reports whose first byte is 0x01 (the report id), sticks + centred at 80 80 80 80 with the triggers at 00 00, byte 7 a monotonic counter, and the IMU and + trailing CRC bytes moving every frame. Exactly the documented report. + + +================================================================================================ +COLLECTION 1/1 — 054C:0CE6 usage_page 0x0001 (Generic Desktop) usage 0x0005 + manufacturer : Sony Interactive Entertainment + product : DualSense Wireless Controller + serial : + release : 0x0100 + interface : 3 + path : \\?\HID#VID_054C&PID_0CE6&MI_03#9&2429cc0c&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030} +================================================================================================ + +-- RAW (467 bytes) -- + 0000 05 01 09 05 A1 01 85 01 09 30 09 31 09 32 09 35 + 0010 09 33 09 34 15 00 26 FF 00 75 08 95 06 81 02 06 + 0020 00 FF 09 20 15 00 26 FF 00 75 08 95 01 81 02 05 + 0030 01 09 39 15 00 25 07 35 00 46 3B 01 65 14 75 04 + 0040 95 01 81 42 05 09 19 01 29 0F 15 00 25 01 75 01 + 0050 95 0F 45 00 65 00 81 02 06 00 FF 09 21 15 00 25 + 0060 01 75 01 95 0D 81 02 09 22 15 00 26 FF 00 35 00 + 0070 46 3B 01 75 08 95 34 81 02 85 02 09 23 15 00 26 + 0080 FF 00 75 08 95 2F 91 02 85 05 09 33 15 00 26 FF + 0090 00 75 08 95 28 B1 02 85 08 09 34 15 00 26 FF 00 + 00A0 75 08 95 2F B1 02 85 09 09 24 15 00 26 FF 00 75 + 00B0 08 95 13 B1 02 85 0A 09 25 15 00 26 FF 00 75 08 + 00C0 95 1A B1 02 85 0B 09 41 15 00 26 FF 00 75 08 95 + 00D0 29 B1 02 85 0C 09 42 15 00 26 FF 00 75 08 95 29 + 00E0 B1 02 85 20 09 26 15 00 26 FF 00 75 08 95 3F B1 + 00F0 02 85 21 09 27 15 00 26 FF 00 75 08 95 04 B1 02 + 0100 85 22 09 40 15 00 26 FF 00 75 08 95 3F B1 02 85 + 0110 80 09 28 15 00 26 FF 00 75 08 95 3F B1 02 85 81 + 0120 09 29 15 00 26 FF 00 75 08 95 3F B1 02 85 82 09 + 0130 2A 15 00 26 FF 00 75 08 95 09 B1 02 85 83 09 2B + 0140 15 00 26 FF 00 75 08 95 3F B1 02 85 84 09 2C 15 + 0150 00 26 FF 00 75 08 95 3F B1 02 85 85 09 2D 15 00 + 0160 26 FF 00 75 08 95 02 B1 02 85 A0 09 2E 15 00 26 + 0170 FF 00 75 08 95 01 B1 02 85 E0 09 2F 15 00 26 FF + 0180 00 75 08 95 3F B1 02 85 F0 09 30 15 00 26 FF 00 + 0190 75 08 95 3F B1 02 85 F1 09 31 15 00 26 FF 00 75 + 01A0 08 95 3F B1 02 85 F2 09 32 15 00 26 FF 00 75 08 + 01B0 95 0F B1 02 85 F4 09 35 15 00 26 FF 00 75 08 95 + 01C0 3F B1 02 85 F5 09 36 15 00 26 FF 00 75 08 95 03 + 01D0 B1 02 C0 + +-- ITEMS -- +0x05, 0x01, // Usage Page (Generic Desktop) +0x09, 0x05, // Usage (Game Pad) +0xA1, 0x01, // Collection (Application) +0x85, 0x01, // Report ID (1) +0x09, 0x30, // Usage (X) +0x09, 0x31, // Usage (Y) +0x09, 0x32, // Usage (Z) +0x09, 0x35, // Usage (Rz) +0x09, 0x33, // Usage (Rx) +0x09, 0x34, // Usage (Ry) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x06, // Report Count (6) +0x81, 0x02, // Input (Data,Var,Abs) +0x06, 0x00, 0xFF, // Usage Page (Vendor Defined) +0x09, 0x20, // Usage (0x20) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x01, // Report Count (1) +0x81, 0x02, // Input (Data,Var,Abs) +0x05, 0x01, // Usage Page (Generic Desktop) +0x09, 0x39, // Usage (Hat switch) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x07, // Logical Maximum (7) +0x35, 0x00, // Physical Minimum (0) +0x46, 0x3B, 0x01, // Physical Maximum (315) +0x65, 0x14, // Unit (Eng Rot: Degrees) +0x75, 0x04, // Report Size (4) +0x95, 0x01, // Report Count (1) +0x81, 0x42, // Input (Data,Var,Abs,Null State) +0x05, 0x09, // Usage Page (Button) +0x19, 0x01, // Usage Minimum (1) +0x29, 0x0F, // Usage Maximum (15) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x01, // Logical Maximum (1) +0x75, 0x01, // Report Size (1) +0x95, 0x0F, // Report Count (15) +0x45, 0x00, // Physical Maximum (0) +0x65, 0x00, // Unit (None) +0x81, 0x02, // Input (Data,Var,Abs) +0x06, 0x00, 0xFF, // Usage Page (Vendor Defined) +0x09, 0x21, // Usage (0x21) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x01, // Logical Maximum (1) +0x75, 0x01, // Report Size (1) +0x95, 0x0D, // Report Count (13) +0x81, 0x02, // Input (Data,Var,Abs) +0x09, 0x22, // Usage (0x22) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x35, 0x00, // Physical Minimum (0) +0x46, 0x3B, 0x01, // Physical Maximum (315) +0x75, 0x08, // Report Size (8) +0x95, 0x34, // Report Count (52) +0x81, 0x02, // Input (Data,Var,Abs) +0x85, 0x02, // Report ID (2) +0x09, 0x23, // Usage (0x23) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x2F, // Report Count (47) +0x91, 0x02, // Output (Data,Var,Abs) +0x85, 0x05, // Report ID (5) +0x09, 0x33, // Usage (0x33) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x28, // Report Count (40) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x08, // Report ID (8) +0x09, 0x34, // Usage (0x34) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x2F, // Report Count (47) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x09, // Report ID (9) +0x09, 0x24, // Usage (0x24) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x13, // Report Count (19) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x0A, // Report ID (10) +0x09, 0x25, // Usage (0x25) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x1A, // Report Count (26) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x0B, // Report ID (11) +0x09, 0x41, // Usage (0x41) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x29, // Report Count (41) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x0C, // Report ID (12) +0x09, 0x42, // Usage (0x42) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x29, // Report Count (41) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x20, // Report ID (32) +0x09, 0x26, // Usage (0x26) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x21, // Report ID (33) +0x09, 0x27, // Usage (0x27) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x04, // Report Count (4) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x22, // Report ID (34) +0x09, 0x40, // Usage (0x40) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x80, // Report ID (128) +0x09, 0x28, // Usage (0x28) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x81, // Report ID (129) +0x09, 0x29, // Usage (0x29) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x82, // Report ID (130) +0x09, 0x2A, // Usage (0x2A) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x09, // Report Count (9) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x83, // Report ID (131) +0x09, 0x2B, // Usage (0x2B) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x84, // Report ID (132) +0x09, 0x2C, // Usage (0x2C) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0x85, // Report ID (133) +0x09, 0x2D, // Usage (0x2D) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x02, // Report Count (2) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xA0, // Report ID (160) +0x09, 0x2E, // Usage (0x2E) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x01, // Report Count (1) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xE0, // Report ID (224) +0x09, 0x2F, // Usage (0x2F) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xF0, // Report ID (240) +0x09, 0x30, // Usage (0x30) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xF1, // Report ID (241) +0x09, 0x31, // Usage (0x31) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xF2, // Report ID (242) +0x09, 0x32, // Usage (0x32) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x0F, // Report Count (15) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xF4, // Report ID (244) +0x09, 0x35, // Usage (0x35) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x3F, // Report Count (63) +0xB1, 0x02, // Feature (Data,Var,Abs) +0x85, 0xF5, // Report ID (245) +0x09, 0x36, // Usage (0x36) +0x15, 0x00, // Logical Minimum (0) +0x26, 0xFF, 0x00, // Logical Maximum (255) +0x75, 0x08, // Report Size (8) +0x95, 0x03, // Report Count (3) +0xB1, 0x02, // Feature (Data,Var,Abs) +0xC0, // End Collection + +-- LAYOUT -- + + Input report 0x01 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×6 X, Y, Z, Rz, Rx, Ry 0..255 Data,Var,Abs + 7.0 8×1 0x20 0..255 Data,Var,Abs + 8.0 4×1 Hat switch 0..7 Data,Var,Abs,Null State + 8.4 1×15 Button 1..15 0..1 Data,Var,Abs + 10.3 1×13 0x21 0..1 Data,Var,Abs + 12.0 8×52 0x22 0..255 Data,Var,Abs + + Output report 0x02 — 376 bits, 48 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×47 0x23 0..255 Data,Var,Abs + + Feature report 0x05 — 320 bits, 41 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×40 0x33 0..255 Data,Var,Abs + + Feature report 0x08 — 376 bits, 48 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×47 0x34 0..255 Data,Var,Abs + + Feature report 0x09 — 152 bits, 20 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×19 0x24 0..255 Data,Var,Abs + + Feature report 0x0A — 208 bits, 27 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×26 0x25 0..255 Data,Var,Abs + + Feature report 0x0B — 328 bits, 42 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×41 0x41 0..255 Data,Var,Abs + + Feature report 0x0C — 328 bits, 42 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×41 0x42 0..255 Data,Var,Abs + + Feature report 0x20 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x26 0..255 Data,Var,Abs + + Feature report 0x21 — 32 bits, 5 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×4 0x27 0..255 Data,Var,Abs + + Feature report 0x22 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x40 0..255 Data,Var,Abs + + Feature report 0x80 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x28 0..255 Data,Var,Abs + + Feature report 0x81 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x29 0..255 Data,Var,Abs + + Feature report 0x82 — 72 bits, 10 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×9 0x2A 0..255 Data,Var,Abs + + Feature report 0x83 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x2B 0..255 Data,Var,Abs + + Feature report 0x84 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x2C 0..255 Data,Var,Abs + + Feature report 0x85 — 16 bits, 3 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×2 0x2D 0..255 Data,Var,Abs + + Feature report 0xA0 — 8 bits, 2 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×1 0x2E 0..255 Data,Var,Abs + + Feature report 0xE0 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x2F 0..255 Data,Var,Abs + + Feature report 0xF0 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x30 0..255 Data,Var,Abs + + Feature report 0xF1 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x31 0..255 Data,Var,Abs + + Feature report 0xF2 — 120 bits, 16 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×15 0x32 0..255 Data,Var,Abs + + Feature report 0xF4 — 504 bits, 64 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×63 0x35 0..255 Data,Var,Abs + + Feature report 0xF5 — 24 bits, 4 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 8×3 0x36 0..255 Data,Var,Abs + +-- SUMMARY -- + INPUT items: 6 + OUTPUT items: 1 + FEATURE items: 22 + structure: OK + +-- RUST -- +#[rustfmt::skip] +static DUALSENSE_CAPTURED: [u8; 467] = [ + 0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35, + 0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06, + 0x00, 0xFF, 0x09, 0x20, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01, 0x81, 0x02, 0x05, + 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, + 0x95, 0x01, 0x81, 0x42, 0x05, 0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, + 0x95, 0x0F, 0x45, 0x00, 0x65, 0x00, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x21, 0x15, 0x00, 0x25, + 0x01, 0x75, 0x01, 0x95, 0x0D, 0x81, 0x02, 0x09, 0x22, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x35, 0x00, + 0x46, 0x3B, 0x01, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x15, 0x00, 0x26, + 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2F, 0x91, 0x02, 0x85, 0x05, 0x09, 0x33, 0x15, 0x00, 0x26, 0xFF, + 0x00, 0x75, 0x08, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, + 0x75, 0x08, 0x95, 0x2F, 0xB1, 0x02, 0x85, 0x09, 0x09, 0x24, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, + 0x08, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, + 0x95, 0x1A, 0xB1, 0x02, 0x85, 0x0B, 0x09, 0x41, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, + 0x29, 0xB1, 0x02, 0x85, 0x0C, 0x09, 0x42, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x29, + 0xB1, 0x02, 0x85, 0x20, 0x09, 0x26, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, + 0x02, 0x85, 0x21, 0x09, 0x27, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x04, 0xB1, 0x02, + 0x85, 0x22, 0x09, 0x40, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, + 0x80, 0x09, 0x28, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x81, + 0x09, 0x29, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, + 0x2A, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0xB1, 0x02, 0x85, 0x83, 0x09, 0x2B, + 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x15, + 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x2D, 0x15, 0x00, + 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x15, 0x00, 0x26, + 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xE0, 0x09, 0x2F, 0x15, 0x00, 0x26, 0xFF, + 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x15, 0x00, 0x26, 0xFF, 0x00, + 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF1, 0x09, 0x31, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, + 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, + 0x95, 0x0F, 0xB1, 0x02, 0x85, 0xF4, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, + 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, + 0xB1, 0x02, 0xC0, +]; diff --git a/tools/hid-descriptor-dump/captures/xbox-elite-series2-045E-0B22-ble-windows.txt b/tools/hid-descriptor-dump/captures/xbox-elite-series2-045E-0B22-ble-windows.txt new file mode 100644 index 00000000..e03a4327 --- /dev/null +++ b/tools/hid-descriptor-dump/captures/xbox-elite-series2-045E-0B22-ble-windows.txt @@ -0,0 +1,204 @@ +Xbox Elite Wireless Controller Series 2 — report descriptor, as captured 2026-08-09. + +HOW THIS WAS TAKEN + box .173, Windows 11 26200, German locale + pad Xbox Elite Wireless Controller Series 2, VID 045E PID 0B22, HID rev 0x0521, + BD_ADDR 686CE647F191, paired and connected over BLUETOOTH LOW ENERGY (HID-over-GATT). + Windows enumerates it as BTHLEDEVICE\{00001812-...}, NOT classic BTHENUM. + command hid-descriptor-dump --vid 045E --pid 0B22 --name XBOX_ELITE2_RDESC + tool tools/hid-descriptor-dump (this directory) + +⚠️ WHAT THIS IS AND IS NOT — READ BEFORE COPYING BYTES OUT OF IT. +Windows exposes no API returning a device's literal report-descriptor bytes: the HID class driver +keeps only the parsed form, so hidapi RECONSTRUCTS a descriptor from HidD_GetPreparsedData. The +reconstruction is faithful in STRUCTURE, ITEM ORDER and every field's BIT OFFSET; the byte encoding +is not the wire encoding. Measured proof, from the same run against the DualSense on the same box: +its real descriptor is 273 bytes and the reconstruction came back 467, because the reconstructor +re-states global items (Logical Min/Max, Report Size) before every report instead of letting them +persist. Same layout, different bytes. +⇒ DIFF THE LAYOUT TABLE, NOT THE RAW BYTES. A byte-exact capture needs Linux + /sys/class/hidraw/hidrawN/device/report_descriptor. + +⚠️ UNVERIFIED: whether this equals the pad's NATIVE report map. `xinputhid` is attached as an +UpperFilter on this pad's BLE transport node (DevicePropertyFlags=0x1 "BusDevice"), and the shape +below — one combined 16-bit `Z` trigger axis, 16 buttons, no report id, no OUTPUT collection — is +the classic legacy/DirectInput view rather than the two-separate-triggers layout documented for +Xbox pads over classic Bluetooth. The absence of ANY output collection is the tell: a real Xbox BT +pad does accept rumble output reports, and this view offers nowhere to send them. Cross-check on +Linux hidraw before treating this as the native map. + + +================================================================================================ +COLLECTION 1/2 — 045E:0B22 usage_page 0x0001 (Generic Desktop) usage 0x0005 + manufacturer : Microsoft + product : Xbox Wireless Controller + serial : 686ce647f191 + release : 0x0521 + interface : -1 + path : \\?\HID#{00001812-0000-1000-8000-00805f9b34fb}&Dev&VID_045e&PID_0b22&REV_0521&686ce647f191&Col01&IG_00#c&7384879&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030} +================================================================================================ + +-- RAW (117 bytes) -- + 0000 05 01 09 05 A1 01 09 00 A1 00 09 30 09 31 15 00 + 0010 25 FF 35 00 45 FF 75 10 95 02 81 02 C0 09 00 A1 + 0020 00 09 33 09 34 15 00 25 FF 75 10 95 02 81 02 C0 + 0030 09 00 A1 00 09 32 15 00 25 FF 75 10 95 01 81 02 + 0040 C0 05 09 19 01 29 10 15 00 25 01 75 01 95 10 45 + 0050 00 81 02 05 01 09 39 15 01 25 08 35 00 46 3B 10 + 0060 65 0E 75 04 95 01 81 42 75 04 95 01 81 03 75 08 + 0070 95 02 81 03 C0 + +-- ITEMS -- +0x05, 0x01, // Usage Page (Generic Desktop) +0x09, 0x05, // Usage (Game Pad) +0xA1, 0x01, // Collection (Application) +0x09, 0x00, // Usage (0x00) +0xA1, 0x00, // Collection (Physical) +0x09, 0x30, // Usage (X) +0x09, 0x31, // Usage (Y) +0x15, 0x00, // Logical Minimum (0) +0x25, 0xFF, // Logical Maximum (-1 — unsigned reading: 255) +0x35, 0x00, // Physical Minimum (0) +0x45, 0xFF, // Physical Maximum (-1) +0x75, 0x10, // Report Size (16) +0x95, 0x02, // Report Count (2) +0x81, 0x02, // Input (Data,Var,Abs) +0xC0, // End Collection +0x09, 0x00, // Usage (0x00) +0xA1, 0x00, // Collection (Physical) +0x09, 0x33, // Usage (Rx) +0x09, 0x34, // Usage (Ry) +0x15, 0x00, // Logical Minimum (0) +0x25, 0xFF, // Logical Maximum (-1 — unsigned reading: 255) +0x75, 0x10, // Report Size (16) +0x95, 0x02, // Report Count (2) +0x81, 0x02, // Input (Data,Var,Abs) +0xC0, // End Collection +0x09, 0x00, // Usage (0x00) +0xA1, 0x00, // Collection (Physical) +0x09, 0x32, // Usage (Z) +0x15, 0x00, // Logical Minimum (0) +0x25, 0xFF, // Logical Maximum (-1 — unsigned reading: 255) +0x75, 0x10, // Report Size (16) +0x95, 0x01, // Report Count (1) +0x81, 0x02, // Input (Data,Var,Abs) +0xC0, // End Collection +0x05, 0x09, // Usage Page (Button) +0x19, 0x01, // Usage Minimum (1) +0x29, 0x10, // Usage Maximum (16) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x01, // Logical Maximum (1) +0x75, 0x01, // Report Size (1) +0x95, 0x10, // Report Count (16) +0x45, 0x00, // Physical Maximum (0) +0x81, 0x02, // Input (Data,Var,Abs) +0x05, 0x01, // Usage Page (Generic Desktop) +0x09, 0x39, // Usage (Hat switch) +0x15, 0x01, // Logical Minimum (1) +0x25, 0x08, // Logical Maximum (8) +0x35, 0x00, // Physical Minimum (0) +0x46, 0x3B, 0x10, // Physical Maximum (4155) +0x65, 0x0E, // Unit (0xE) +0x75, 0x04, // Report Size (4) +0x95, 0x01, // Report Count (1) +0x81, 0x42, // Input (Data,Var,Abs,Null State) +0x75, 0x04, // Report Size (4) +0x95, 0x01, // Report Count (1) +0x81, 0x03, // Input (Cnst,Var,Abs) +0x75, 0x08, // Report Size (8) +0x95, 0x02, // Report Count (2) +0x81, 0x03, // Input (Cnst,Var,Abs) +0xC0, // End Collection + +-- LAYOUT -- + + Input report 0x00 — 120 bits, 15 bytes on the wire (unnumbered) + byte.bit size×cnt usage logical range flags + 0.0 16×2 X, Y 0..-1 Data,Var,Abs + 4.0 16×2 Rx, Ry 0..-1 Data,Var,Abs + 8.0 16×1 Z 0..-1 Data,Var,Abs + 10.0 1×16 Button 1..16 0..1 Data,Var,Abs + 12.0 4×1 Hat switch 1..8 Data,Var,Abs,Null State + 12.4 4×1 — (padding) 1..8 Cnst,Var,Abs + 13.0 8×2 — (padding) 1..8 Cnst,Var,Abs + +-- SUMMARY -- + INPUT items: 7 + OUTPUT items: 0 <-- NONE + FEATURE items: 0 <-- NONE + structure: OK + +-- RUST -- +#[rustfmt::skip] +static XBOX_ELITE2_RDESC_COL01: [u8; 117] = [ + 0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x09, 0x00, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x00, + 0x25, 0xFF, 0x35, 0x00, 0x45, 0xFF, 0x75, 0x10, 0x95, 0x02, 0x81, 0x02, 0xC0, 0x09, 0x00, 0xA1, + 0x00, 0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x25, 0xFF, 0x75, 0x10, 0x95, 0x02, 0x81, 0x02, 0xC0, + 0x09, 0x00, 0xA1, 0x00, 0x09, 0x32, 0x15, 0x00, 0x25, 0xFF, 0x75, 0x10, 0x95, 0x01, 0x81, 0x02, + 0xC0, 0x05, 0x09, 0x19, 0x01, 0x29, 0x10, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x10, 0x45, + 0x00, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x01, 0x25, 0x08, 0x35, 0x00, 0x46, 0x3B, 0x10, + 0x65, 0x0E, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x75, 0x04, 0x95, 0x01, 0x81, 0x03, 0x75, 0x08, + 0x95, 0x02, 0x81, 0x03, 0xC0, +]; + +================================================================================================ +COLLECTION 2/2 — 045E:0B22 usage_page 0x0001 (Generic Desktop) usage 0x0006 + manufacturer : Microsoft + product : Xbox Wireless Controller + serial : 686ce647f191 + release : 0x0521 + interface : -1 + path : \\?\HID#{00001812-0000-1000-8000-00805f9b34fb}&Dev&VID_045e&PID_0b22&REV_0521&686ce647f191&Col02&IG_00#c&7384879&0&0001#{4d1e55b2-f16f-11cf-88cb-001111000030}\KBD +================================================================================================ + +-- RAW (45 bytes) -- + 0000 05 01 09 06 A1 01 85 05 05 07 19 E0 29 E7 15 00 + 0010 25 01 75 01 95 08 81 02 75 08 95 01 81 03 19 00 + 0020 29 65 15 00 25 65 75 08 95 06 81 00 C0 + +-- ITEMS -- +0x05, 0x01, // Usage Page (Generic Desktop) +0x09, 0x06, // Usage (Keyboard) +0xA1, 0x01, // Collection (Application) +0x85, 0x05, // Report ID (5) +0x05, 0x07, // Usage Page (Keyboard/Keypad) +0x19, 0xE0, // Usage Minimum (224) +0x29, 0xE7, // Usage Maximum (231) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x01, // Logical Maximum (1) +0x75, 0x01, // Report Size (1) +0x95, 0x08, // Report Count (8) +0x81, 0x02, // Input (Data,Var,Abs) +0x75, 0x08, // Report Size (8) +0x95, 0x01, // Report Count (1) +0x81, 0x03, // Input (Cnst,Var,Abs) +0x19, 0x00, // Usage Minimum (0) +0x29, 0x65, // Usage Maximum (101) +0x15, 0x00, // Logical Minimum (0) +0x25, 0x65, // Logical Maximum (101) +0x75, 0x08, // Report Size (8) +0x95, 0x06, // Report Count (6) +0x81, 0x00, // Input (Data,Arr,Abs) +0xC0, // End Collection + +-- LAYOUT -- + + Input report 0x05 — 64 bits, 9 bytes on the wire (id included) + byte.bit size×cnt usage logical range flags + 1.0 1×8 Keyboard/Keypad 224..231 0..1 Data,Var,Abs + 2.0 8×1 — (padding) 0..1 Cnst,Var,Abs + 3.0 8×6 Keyboard/Keypad 0..101 0..101 Data,Arr,Abs + +-- SUMMARY -- + INPUT items: 3 + OUTPUT items: 0 <-- NONE + FEATURE items: 0 <-- NONE + structure: OK + +-- RUST -- +#[rustfmt::skip] +static XBOX_ELITE2_RDESC_COL02: [u8; 45] = [ + 0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x85, 0x05, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, + 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x75, 0x08, 0x95, 0x01, 0x81, 0x03, 0x19, 0x00, + 0x29, 0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0, +]; diff --git a/tools/hid-descriptor-dump/src/decode.rs b/tools/hid-descriptor-dump/src/decode.rs new file mode 100644 index 00000000..f2824240 --- /dev/null +++ b/tools/hid-descriptor-dump/src/decode.rs @@ -0,0 +1,581 @@ +//! A HID 1.11 report-descriptor decoder, written for ONE job: making a captured descriptor +//! diffable, by eye, against the hand-annotated blobs in +//! `packaging/windows/drivers/pf-gamepad/src/lib.rs`. +//! +//! Two outputs matter, and they answer different questions: +//! +//! * the **item listing** — one line per HID item, formatted exactly like the annotated `static +//! XBOX_RDESC` arrays, so a capture can be pasted straight in and read side by side; +//! * the **layout map** — the running bit offset of every field, per report id and per report +//! kind. This is the one that catches the bugs that actually bite: `xbox_proto`'s layout tests +//! pin byte offsets, and a descriptor that declares the same usages in a different ORDER lands +//! every control on the wrong byte while looking correct item for item. + +use std::fmt::Write as _; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum MainKind { + Input, + Output, + Feature, +} + +impl MainKind { + pub fn as_str(self) -> &'static str { + match self { + MainKind::Input => "Input", + MainKind::Output => "Output", + MainKind::Feature => "Feature", + } + } +} + +/// One `Input`/`Output`/`Feature` main item, resolved against the global/local state in force. +pub struct Field { + pub kind: MainKind, + pub report_id: u8, + /// Bit offset within the report, report id byte NOT included (it is offset 0 of the wire + /// bytes, so add 8 when comparing against a wire layout that carries the id). + pub bit_offset: u32, + pub bit_size: u32, + pub count: u32, + pub usage_page: u16, + pub usages: Vec, + pub usage_range: Option<(u32, u32)>, + pub logical_min: i64, + pub logical_max: i64, + pub flags: u32, +} + +impl Field { + fn is_constant(&self) -> bool { + self.flags & 1 != 0 + } + + /// How the field would be written in an `Input (...)` annotation. + fn flags_str(&self) -> String { + let mut parts: Vec<&str> = Vec::new(); + parts.push(if self.flags & 0x01 != 0 { + "Cnst" + } else { + "Data" + }); + parts.push(if self.flags & 0x02 != 0 { "Var" } else { "Arr" }); + parts.push(if self.flags & 0x04 != 0 { "Rel" } else { "Abs" }); + if self.flags & 0x08 != 0 { + parts.push("Wrap"); + } + if self.flags & 0x10 != 0 { + parts.push("NonLin"); + } + if self.flags & 0x20 != 0 { + parts.push("NoPref"); + } + if self.flags & 0x40 != 0 { + parts.push("Null State"); + } + if self.flags & 0x80 != 0 { + parts.push("Volatile"); + } + if self.flags & 0x100 != 0 { + parts.push("Buff"); + } + parts.join(",") + } +} + +pub struct Decoded { + /// The annotated item listing. + pub listing: String, + pub fields: Vec, + /// Anything structurally wrong — trailing bytes, unbalanced collections, a truncated item. + pub problems: Vec, +} + +#[derive(Clone, Default)] +struct GlobalState { + usage_page: u16, + logical_min: i64, + logical_max: i64, + physical_min: i64, + physical_max: i64, + unit: u32, + unit_exp: u32, + report_size: u32, + report_id: u8, + report_count: u32, +} + +/// Running bit cursor, keyed by (report id, kind) — each report kind numbers its bits from zero. +#[derive(Default)] +struct Cursors { + input: Vec<(u8, u32)>, + output: Vec<(u8, u32)>, + feature: Vec<(u8, u32)>, +} + +impl Cursors { + fn take(&mut self, kind: MainKind, id: u8, bits: u32) -> u32 { + let v = match kind { + MainKind::Input => &mut self.input, + MainKind::Output => &mut self.output, + MainKind::Feature => &mut self.feature, + }; + match v.iter_mut().find(|(rid, _)| *rid == id) { + Some((_, at)) => { + let start = *at; + *at += bits; + start + } + None => { + v.push((id, bits)); + 0 + } + } + } +} + +/// Sign-extend `value`, which came off the wire in `size` bytes. +fn sign_extend(value: u32, size: usize) -> i64 { + match size { + 1 => value as u8 as i8 as i64, + 2 => value as u16 as i16 as i64, + 4 => value as i32 as i64, + _ => value as i64, + } +} + +pub fn usage_page_name(page: u16) -> &'static str { + match page { + 0x01 => "Generic Desktop", + 0x02 => "Simulation Controls", + 0x03 => "VR Controls", + 0x04 => "Sport Controls", + 0x05 => "Game Controls", + 0x06 => "Generic Device Controls", + 0x07 => "Keyboard/Keypad", + 0x08 => "LED", + 0x09 => "Button", + 0x0A => "Ordinal", + 0x0C => "Consumer", + 0x0D => "Digitizer", + 0x0F => "Physical Input Device (PID)", + 0xFF00..=0xFFFF => "Vendor Defined", + _ => "", + } +} + +fn usage_name(page: u16, usage: u32) -> &'static str { + match (page, usage) { + (0x01, 0x01) => "Pointer", + (0x01, 0x02) => "Mouse", + (0x01, 0x04) => "Joystick", + (0x01, 0x05) => "Game Pad", + (0x01, 0x06) => "Keyboard", + (0x01, 0x30) => "X", + (0x01, 0x31) => "Y", + (0x01, 0x32) => "Z", + (0x01, 0x33) => "Rx", + (0x01, 0x34) => "Ry", + (0x01, 0x35) => "Rz", + (0x01, 0x36) => "Slider", + (0x01, 0x37) => "Dial", + (0x01, 0x38) => "Wheel", + (0x01, 0x39) => "Hat switch", + (0x01, 0x3A) => "Counted Buffer", + (0x01, 0x80) => "System Control", + (0x01, 0x85) => "System Main Menu", + (0x02, 0xC4) => "Accelerator", + (0x02, 0xC5) => "Brake", + (0x02, 0xBB) => "Throttle", + (0x02, 0xBA) => "Rudder", + (0x06, 0x20) => "Battery Strength", + (0x0C, 0x01) => "Consumer Control", + (0x0C, 0x223) => "AC Home", + (0x0C, 0x224) => "AC Back", + _ => "", + } +} + +fn collection_name(v: u32) -> &'static str { + match v { + 0x00 => "Physical", + 0x01 => "Application", + 0x02 => "Logical", + 0x03 => "Report", + 0x04 => "Named Array", + 0x05 => "Usage Switch", + 0x06 => "Usage Modifier", + _ => "Vendor", + } +} + +pub fn decode(desc: &[u8]) -> Decoded { + let mut listing = String::new(); + let mut problems = Vec::new(); + let mut fields = Vec::new(); + + let mut g = GlobalState::default(); + let mut stack: Vec = Vec::new(); + let mut usages: Vec = Vec::new(); + let mut usage_min: Option = None; + let mut usage_max: Option = None; + let mut cursors = Cursors::default(); + let mut depth: usize = 0; + + let mut i = 0usize; + while i < desc.len() { + let prefix = desc[i]; + let start = i; + + // Long items (prefix 0xFE) exist in the spec and in no gamepad we have ever seen; carry + // them through so an unexpected one is reported rather than silently desynchronising the + // rest of the parse. + if prefix == 0xFE { + if i + 2 >= desc.len() { + problems.push(format!("truncated long item at byte {start}")); + break; + } + let data_size = desc[i + 1] as usize; + let tag = desc[i + 2]; + let end = i + 3 + data_size; + if end > desc.len() { + problems.push(format!("long item at byte {start} runs past the end")); + break; + } + let _ = writeln!( + listing, + "{:pad$}0xFE, /* long item, tag 0x{tag:02X}, {data_size} bytes */", + "", + pad = depth * 2 + ); + i = end; + continue; + } + + let size_code = (prefix & 0x03) as usize; + let data_size = if size_code == 3 { 4 } else { size_code }; + let ty = (prefix >> 2) & 0x03; + let tag = prefix >> 4; + if i + 1 + data_size > desc.len() { + problems.push(format!( + "truncated item at byte {start}: prefix 0x{prefix:02X} wants {data_size} data bytes, \ + {} remain", + desc.len() - i - 1 + )); + break; + } + let mut raw: u32 = 0; + for b in 0..data_size { + raw |= (desc[i + 1 + b] as u32) << (8 * b); + } + let signed = sign_extend(raw, data_size); + i += 1 + data_size; + + let bytes_hex = desc[start..i] + .iter() + .map(|b| format!("0x{b:02X},")) + .collect::>() + .join(" "); + + // Indentation mirrors the annotated arrays in the driver: collections indent their body. + let mut emit = |depth: usize, text: String| { + let _ = writeln!( + listing, + "{:<38} // {:pad$}{text}", + bytes_hex, + "", + pad = depth * 2 + ); + }; + + match ty { + // ---- Main ---- + 0 => match tag { + 0x08 | 0x09 | 0x0B => { + let kind = match tag { + 0x08 => MainKind::Input, + 0x09 => MainKind::Output, + _ => MainKind::Feature, + }; + let bits = g.report_size * g.report_count; + let bit_offset = cursors.take(kind, g.report_id, bits); + let f = Field { + kind, + report_id: g.report_id, + bit_offset, + bit_size: g.report_size, + count: g.report_count, + usage_page: g.usage_page, + usages: usages.clone(), + usage_range: match (usage_min, usage_max) { + (Some(a), Some(b)) => Some((a, b)), + _ => None, + }, + logical_min: g.logical_min, + logical_max: g.logical_max, + flags: raw, + }; + emit(depth, format!("{} ({})", kind.as_str(), f.flags_str())); + fields.push(f); + usages.clear(); + usage_min = None; + usage_max = None; + } + 0x0A => { + emit(depth, format!("Collection ({})", collection_name(raw))); + depth += 1; + usages.clear(); + usage_min = None; + usage_max = None; + } + 0x0C => { + depth = depth.saturating_sub(1); + emit(depth, "End Collection".to_string()); + usages.clear(); + usage_min = None; + usage_max = None; + } + _ => { + problems.push(format!("unknown Main tag 0x{tag:X} at byte {start}")); + emit(depth, format!("")); + } + }, + // ---- Global ---- + 1 => match tag { + 0x0 => { + g.usage_page = raw as u16; + let n = usage_page_name(g.usage_page); + emit( + depth, + if n.is_empty() { + format!("Usage Page (0x{:04X})", g.usage_page) + } else { + format!("Usage Page ({n})") + }, + ); + } + 0x1 => { + g.logical_min = signed; + emit(depth, format!("Logical Minimum ({signed})")); + } + 0x2 => { + g.logical_max = signed; + emit( + depth, + // A maximum is only signed when the minimum was; showing both readings + // keeps a `0x25 0xFF` (255 or -1) from being silently misread. + if g.logical_min < 0 || signed >= 0 { + format!("Logical Maximum ({signed})") + } else { + format!("Logical Maximum ({signed} — unsigned reading: {raw})") + }, + ); + } + 0x3 => { + g.physical_min = signed; + emit(depth, format!("Physical Minimum ({signed})")); + } + 0x4 => { + g.physical_max = signed; + emit(depth, format!("Physical Maximum ({signed})")); + } + 0x5 => { + g.unit_exp = raw; + emit(depth, format!("Unit Exponent (0x{raw:X})")); + } + 0x6 => { + g.unit = raw; + emit( + depth, + match raw { + 0x14 => "Unit (Eng Rot: Degrees)".to_string(), + 0x00 => "Unit (None)".to_string(), + _ => format!("Unit (0x{raw:X})"), + }, + ); + } + 0x7 => { + g.report_size = raw; + emit(depth, format!("Report Size ({raw})")); + } + 0x8 => { + g.report_id = raw as u8; + emit(depth, format!("Report ID ({raw})")); + } + 0x9 => { + g.report_count = raw; + emit(depth, format!("Report Count ({raw})")); + } + 0xA => { + stack.push(g.clone()); + emit(depth, "Push".to_string()); + } + 0xB => { + match stack.pop() { + Some(prev) => g = prev, + None => problems.push(format!("Pop with an empty stack at byte {start}")), + } + emit(depth, "Pop".to_string()); + } + _ => { + problems.push(format!("unknown Global tag 0x{tag:X} at byte {start}")); + emit(depth, format!("")); + } + }, + // ---- Local ---- + 2 => match tag { + 0x0 => { + // A 4-byte Usage carries its page in the high half. + let (page, u) = if data_size == 4 { + ((raw >> 16) as u16, raw & 0xFFFF) + } else { + (g.usage_page, raw) + }; + usages.push(u); + let n = usage_name(page, u); + emit( + depth, + if n.is_empty() { + format!("Usage (0x{u:02X})") + } else { + format!("Usage ({n})") + }, + ); + } + 0x1 => { + usage_min = Some(raw); + emit(depth, format!("Usage Minimum ({raw})")); + } + 0x2 => { + usage_max = Some(raw); + emit(depth, format!("Usage Maximum ({raw})")); + } + 0x3 => emit(depth, format!("Designator Index ({raw})")), + 0x4 => emit(depth, format!("Designator Minimum ({raw})")), + 0x5 => emit(depth, format!("Designator Maximum ({raw})")), + 0x7 => emit(depth, format!("String Index ({raw})")), + 0x8 => emit(depth, format!("String Minimum ({raw})")), + 0x9 => emit(depth, format!("String Maximum ({raw})")), + 0xA => emit(depth, format!("Delimiter ({raw})")), + _ => { + problems.push(format!("unknown Local tag 0x{tag:X} at byte {start}")); + emit(depth, format!("")); + } + }, + _ => { + problems.push(format!("reserved item type at byte {start}")); + emit(depth, "".to_string()); + } + } + } + + if depth != 0 { + problems.push(format!("{depth} collection(s) never closed")); + } + if !stack.is_empty() { + problems.push(format!("{} Push(es) never popped", stack.len())); + } + + Decoded { + listing, + fields, + problems, + } +} + +/// The bit-offset table. This is what a layout diff should be read off — item order, not item +/// presence, is what silently lands a control on the wrong byte. +pub fn layout_map(fields: &[Field]) -> String { + let mut out = String::new(); + for kind in [MainKind::Input, MainKind::Output, MainKind::Feature] { + let mut ids: Vec = fields + .iter() + .filter(|f| f.kind == kind) + .map(|f| f.report_id) + .collect(); + ids.sort_unstable(); + ids.dedup(); + for id in ids { + let of_report: Vec<&Field> = fields + .iter() + .filter(|f| f.kind == kind && f.report_id == id) + .collect(); + let bits: u32 = of_report.iter().map(|f| f.bit_size * f.count).sum(); + // The id byte is on the wire whenever the descriptor numbers its reports at all. + let wire = if id == 0 { + bits.div_ceil(8) as usize + } else { + bits.div_ceil(8) as usize + 1 + }; + let _ = writeln!( + out, + "\n {} report 0x{id:02X} — {bits} bits, {wire} bytes on the wire{}", + kind.as_str(), + if id == 0 { + " (unnumbered)" + } else { + " (id included)" + } + ); + let _ = writeln!( + out, + " {:<12} {:<9} {:<26} {:<20} flags", + "byte.bit", "size×cnt", "usage", "logical range" + ); + for f in of_report { + let id_shift = if id == 0 { 0 } else { 8 }; + let abs = f.bit_offset + id_shift; + let usage = if let Some((a, b)) = f.usage_range { + format!("{} {a}..{b}", usage_page_name(f.usage_page)) + } else if f.usages.is_empty() { + if f.is_constant() { + "— (padding)".to_string() + } else { + "— (none declared)".to_string() + } + } else { + f.usages + .iter() + .map(|u| { + let n = usage_name(f.usage_page, *u); + if n.is_empty() { + format!("0x{u:02X}") + } else { + n.to_string() + } + }) + .collect::>() + .join(", ") + }; + let _ = writeln!( + out, + " {:<12} {:<9} {:<26} {:<20} {}", + format!("{}.{}", abs / 8, abs % 8), + format!("{}×{}", f.bit_size, f.count), + usage, + format!("{}..{}", f.logical_min, f.logical_max), + f.flags_str() + ); + } + } + } + out +} + +/// Emit the blob as a `static` ready to paste into the driver. +pub fn rust_array(name: &str, desc: &[u8]) -> String { + let mut out = format!( + "#[rustfmt::skip]\nstatic {name}: [u8; {}] = [\n", + desc.len() + ); + for chunk in desc.chunks(16) { + out.push_str(" "); + for b in chunk { + let _ = write!(out, "0x{b:02X}, "); + } + out.push('\n'); + } + out.push_str("];\n"); + out +} diff --git a/tools/hid-descriptor-dump/src/main.rs b/tools/hid-descriptor-dump/src/main.rs new file mode 100644 index 00000000..cf14a2d0 --- /dev/null +++ b/tools/hid-descriptor-dump/src/main.rs @@ -0,0 +1,426 @@ +//! Capture a real HID device's report descriptor, decode it, and print it in the shape the +//! `pf-gamepad` driver keeps its blobs in. +//! +//! WHY THIS EXISTS. `packaging/windows/drivers/pf-gamepad/src/lib.rs` serves a report descriptor +//! per emulated pad. Three of the four are verbatim captures off real hardware; `XBOX_RDESC` was +//! hand-constructed, and its own provenance warning came true three separate times (a missing +//! channel-proof Feature report meant the pad never delivered a single input report; there is no +//! OUTPUT collection at all, so rumble cannot arrive; `xinputhid` appears to validate the +//! descriptor and rejects ours). We claim a genuine Microsoft VID/PID, and SDL, Steam and Windows +//! all apply stock mappings keyed on it — so a layout that differs from the real pad lands every +//! control on the wrong action. Captures, not constructions. +//! +//! USAGE +//! ```text +//! hid-descriptor-dump --list # every HID device, with vid/pid and usage +//! hid-descriptor-dump --vid 045E --pid 0B22 # dump every collection of that device +//! hid-descriptor-dump --vid 054C --pid 0CE6 --name DUALSENSE_RDESC +//! hid-descriptor-dump --path '\\?\HID#...' # one exact collection +//! ``` +//! +//! WHAT THE DESCRIPTOR COMES FROM, PER PLATFORM. On Linux hidapi reads +//! `/sys/class/hidraw/hidrawN/device/report_descriptor` — the literal bytes the device sent. On +//! Windows there is no API that returns those bytes: the HID class driver keeps only the parsed +//! form, so hidapi RECONSTRUCTS a descriptor from `HidD_GetPreparsedData`. The reconstruction is +//! faithful in structure, item order and every field's bit offset — which is what a layout diff +//! needs — but the byte encoding may differ from the wire (an item the device sent as one byte can +//! come back as two, and hidapi emits collections it inferred). ⇒ **Diff the LAYOUT MAP and the +//! item listing, not the raw bytes, when the capture came off Windows.** A byte-exact capture +//! needs Linux hidraw. +//! +//! This tool is deliberately not a workspace member; see its `Cargo.toml`. + +mod decode; + +use std::process::ExitCode; + +struct Args { + list: bool, + vid: Option, + pid: Option, + path: Option, + name: Option, + read: Option, + rust_source: Option, + symbol: Option, +} + +/// Pull a `static NAME: [u8; N] = [ 0x.., ... ];` out of a Rust source file. +/// +/// This is what makes the diff exact rather than eyeballed: our own shipped blobs get decoded by +/// the same decoder, into the same listing and the same layout table, as a capture off real +/// hardware. No hardware needed for this mode. +fn extract_rust_array(src: &str, symbol: &str) -> Result, String> { + let at = src + .find(&format!("static {symbol}:")) + .ok_or_else(|| format!("no `static {symbol}:` in that file"))?; + let open = src[at..] + .find('[') + .and_then(|i| src[at + i + 1..].find('[').map(|j| at + i + 1 + j + 1)) + .ok_or("could not find the array literal")?; + let close = src[open..] + .find(']') + .ok_or("array literal is never closed")? + + open; + // Strip trailing `// ...` comments LINE BY LINE before splitting on commas — the annotations in + // these arrays contain commas themselves (`// Input (Data,Var,Abs)`), so comma-splitting first + // scatters comment text into the byte stream. + let body: String = src[open..close] + .lines() + .map(|l| l.split("//").next().unwrap_or("")) + .collect::>() + .join(" "); + let mut out = Vec::new(); + for tok in body.split(',') { + let tok = tok.trim(); + if tok.is_empty() { + continue; + } + let hex = tok.trim_start_matches("0x").trim_start_matches("0X"); + out.push(u8::from_str_radix(hex, 16).map_err(|_| format!("`{tok}` is not a hex byte"))?); + } + Ok(out) +} + +fn parse_u16(s: &str) -> Option { + let s = s.trim_start_matches("0x").trim_start_matches("0X"); + u16::from_str_radix(s, 16).ok() +} + +fn parse_args() -> Result { + let mut a = Args { + list: false, + vid: None, + pid: None, + path: None, + name: None, + read: None, + rust_source: None, + symbol: None, + }; + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "--list" | "-l" => a.list = true, + "--vid" => { + let v = it.next().ok_or("--vid wants a hex value")?; + a.vid = Some(parse_u16(&v).ok_or_else(|| format!("--vid: {v} is not hex"))?); + } + "--pid" => { + let v = it.next().ok_or("--pid wants a hex value")?; + a.pid = Some(parse_u16(&v).ok_or_else(|| format!("--pid: {v} is not hex"))?); + } + "--path" => a.path = Some(it.next().ok_or("--path wants a device path")?), + "--name" => a.name = Some(it.next().ok_or("--name wants an identifier")?), + "--read" => { + let v = it.next().ok_or("--read wants a count")?; + a.read = Some( + v.parse() + .map_err(|_| format!("--read: {v} is not a count"))?, + ); + } + "--rust-source" => a.rust_source = Some(it.next().ok_or("--rust-source wants a path")?), + "--symbol" => a.symbol = Some(it.next().ok_or("--symbol wants an identifier")?), + "--help" | "-h" => { + println!("{}", HELP); + std::process::exit(0); + } + other => return Err(format!("unknown argument {other}")), + } + } + if !a.list && a.vid.is_none() && a.path.is_none() && a.rust_source.is_none() { + a.list = true; + } + if a.rust_source.is_some() != a.symbol.is_some() { + return Err("--rust-source and --symbol go together".into()); + } + Ok(a) +} + +const HELP: &str = "\ +hid-descriptor-dump — capture a real HID device's report descriptor + + --list list every HID device this box can open + --vid select by vendor id (e.g. 045E) + --pid select by product id (e.g. 0B22) + --path select one exact collection by its device path + --name also emit a `static IDENT: [u8; N]` ready to paste into the driver + --read after dumping, read n live input reports and show which bytes move + --rust-source decode a blob we already ship instead of a device (no hardware needed) + --symbol which `static IDENT: [u8; N]` in that file to decode + +With --vid/--pid every matching collection is dumped: a real Xbox pad presents two (a game +controller and a keyboard), and they are separate devices to hidapi. + +--read is the ground truth a reconstructed descriptor cannot give you: it is the literal wire +bytes. Use it to settle report length, whether reports are numbered, and which byte a control +actually lives in — wiggle one control at a time and watch the changed-byte mask."; + +/// Print a descriptor every way that is useful for a diff: raw, item listing, layout table, +/// a presence summary, and optionally a paste-ready Rust `static`. +fn report(desc: &[u8], emit_as: Option<&str>) { + println!("\n-- RAW ({} bytes) --", desc.len()); + for (off, chunk) in desc.chunks(16).enumerate() { + let hex = chunk + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" "); + println!(" {:04X} {hex}", off * 16); + } + + let decoded = decode::decode(desc); + println!("\n-- ITEMS --"); + print!("{}", decoded.listing); + + println!("\n-- LAYOUT --"); + print!("{}", decode::layout_map(&decoded.fields)); + + println!("\n-- SUMMARY --"); + for (k, label) in [ + (decode::MainKind::Input, "INPUT"), + (decode::MainKind::Output, "OUTPUT"), + (decode::MainKind::Feature, "FEATURE"), + ] { + let count = decoded.fields.iter().filter(|f| f.kind == k).count(); + println!( + " {label:<8} items: {count}{}", + if count == 0 { " <-- NONE" } else { "" } + ); + } + if decoded.problems.is_empty() { + println!(" structure: OK"); + } else { + println!(" structure: {} PROBLEM(S)", decoded.problems.len()); + for p in &decoded.problems { + println!(" - {p}"); + } + } + + if let Some(name) = emit_as { + println!("\n-- RUST --"); + print!("{}", decode::rust_array(name, desc)); + } +} + +/// Read live input reports and show which bytes ever move. The descriptor says where a control +/// SHOULD be; this says where it IS. +fn watch(dev: &hidapi::HidDevice, count: usize) { + println!("\n-- LIVE REPORTS ({count} requested, 3 s each) --"); + println!(" (move ONE control at a time and read the changed-byte mask)"); + let mut buf = [0u8; 256]; + let mut first: Option> = None; + let mut ever_changed = vec![false; 256]; + let mut got = 0usize; + for _ in 0..count { + match dev.read_timeout(&mut buf, 3000) { + Ok(0) => { + println!(" (timeout — no report; the pad may be idle)"); + continue; + } + Ok(n) => { + got += 1; + let sample = &buf[..n]; + let hex = sample + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" "); + match &first { + None => { + println!(" len={n} {hex} <-- baseline"); + first = Some(sample.to_vec()); + } + Some(base) => { + let mut marks = String::new(); + for i in 0..n { + let differs = base.get(i) != Some(&sample[i]); + if differs { + ever_changed[i] = true; + } + marks.push_str(if differs { "^^ " } else { ".. " }); + } + println!(" len={n} {hex}"); + println!(" {marks}"); + } + } + } + Err(e) => { + println!(" read error: {e}"); + break; + } + } + } + if let Some(base) = &first { + let moved: Vec = (0..base.len()) + .filter(|i| ever_changed[*i]) + .map(|i| i.to_string()) + .collect(); + println!( + " {got} report(s); report length {}; bytes that ever moved: {}", + base.len(), + if moved.is_empty() { + "none".to_string() + } else { + moved.join(", ") + } + ); + println!( + " first byte of every report was 0x{:02X} — {}", + base[0], + if base[0] == 0x01 { + "consistent with a numbered report id 1" + } else { + "note this when deciding whether reports are numbered" + } + ); + } +} + +fn main() -> ExitCode { + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + eprintln!("error: {e}\n\n{HELP}"); + return ExitCode::FAILURE; + } + }; + + // Decoding one of our own blobs needs no hardware, so it runs before hidapi is even opened — + // this mode works on any box, including CI and a Mac. + if let (Some(file), Some(symbol)) = (&args.rust_source, &args.symbol) { + let src = match std::fs::read_to_string(file) { + Ok(s) => s, + Err(e) => { + eprintln!("error: {file}: {e}"); + return ExitCode::FAILURE; + } + }; + let desc = match extract_rust_array(&src, symbol) { + Ok(d) => d, + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::FAILURE; + } + }; + println!("{}", "=".repeat(96)); + println!("SHIPPED BLOB — {symbol} from {file}"); + println!("{}", "=".repeat(96)); + report(&desc, args.name.as_deref()); + return ExitCode::SUCCESS; + } + + let api = match hidapi::HidApi::new() { + Ok(a) => a, + Err(e) => { + eprintln!("error: hidapi init failed: {e}"); + return ExitCode::FAILURE; + } + }; + + let devices: Vec<_> = api.device_list().collect(); + if args.list { + println!( + "{:<6} {:<6} {:<5} {:<5} {:<34} path", + "vid", "pid", "page", "usage", "product" + ); + for d in &devices { + println!( + "{:04X} {:04X} {:04X} {:04X} {:<34} {}", + d.vendor_id(), + d.product_id(), + d.usage_page(), + d.usage(), + d.product_string().unwrap_or("—"), + d.path().to_string_lossy() + ); + } + println!("\n{} device(s).", devices.len()); + if args.vid.is_none() && args.path.is_none() { + return ExitCode::SUCCESS; + } + } + + let selected: Vec<_> = devices + .iter() + .filter(|d| { + if let Some(p) = &args.path { + return d.path().to_string_lossy() == p.as_str(); + } + args.vid.is_none_or(|v| d.vendor_id() == v) + && args.pid.is_none_or(|p| d.product_id() == p) + }) + .collect(); + + if selected.is_empty() { + eprintln!( + "error: nothing matched. If this is a Bluetooth pad, POWER IT ON — a disconnected BLE \ + device leaves its devnodes behind but has no HID interface to open." + ); + return ExitCode::FAILURE; + } + + let mut failures = 0usize; + for (n, d) in selected.iter().enumerate() { + println!("\n{}", "=".repeat(96)); + println!( + "COLLECTION {}/{} — {:04X}:{:04X} usage_page 0x{:04X} ({}) usage 0x{:04X}", + n + 1, + selected.len(), + d.vendor_id(), + d.product_id(), + d.usage_page(), + decode::usage_page_name(d.usage_page()), + d.usage() + ); + println!( + " manufacturer : {}", + d.manufacturer_string().unwrap_or("—") + ); + println!(" product : {}", d.product_string().unwrap_or("—")); + println!(" serial : {}", d.serial_number().unwrap_or("—")); + println!(" release : 0x{:04X}", d.release_number()); + println!(" interface : {}", d.interface_number()); + println!(" path : {}", d.path().to_string_lossy()); + println!("{}", "=".repeat(96)); + + let dev = match api.open_path(d.path()) { + Ok(dev) => dev, + Err(e) => { + eprintln!(" !! could not open: {e}"); + failures += 1; + continue; + } + }; + // 4 KiB is the HID class driver's own ceiling for a report descriptor. + let mut buf = vec![0u8; 4096]; + let len = match dev.get_report_descriptor(&mut buf) { + Ok(n) => n, + Err(e) => { + eprintln!(" !! could not read the report descriptor: {e}"); + failures += 1; + continue; + } + }; + buf.truncate(len); + + let emit_as = args.name.as_ref().map(|name| { + if selected.len() > 1 { + format!("{name}_COL{:02}", n + 1) + } else { + name.clone() + } + }); + report(&buf, emit_as.as_deref()); + + if let Some(count) = args.read { + watch(&dev, count); + } + } + + if failures > 0 { + eprintln!("\n{failures} collection(s) could not be read."); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +}