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 +}