feat: classify USB-direct controllers by interface descriptor, not a VID:PID allowlist - #150
Merged
emir-hasanbegovic merged 1 commit intoJul 22, 2026
Conversation
…VID:PID allowlist Direct mode recognized a controller only if its exact VID:PID was in the native table, so an XInput pad under an unlisted PID -- e.g. an 8BitDo Ultimate 2.4g dongle in X-input mode -- fell through to the generic-HID guess and failed to claim, even though the OS xpad driver handles it fine. The Kotlin side also claimed the first HID/vendor interface with an interrupt-IN, which on a composite Xbox 360 controller can be the audio interface. Classify by the USB interface descriptor instead: - New pure usbparsers::classifyDevice(vid, pid, class, subclass, protocol): a known-table entry still wins (preserving every listed device's parser/init/quirks and the Sony/Switch vendor report formats); otherwise 0xFF/0x5D/0x01 -> XInput, 0xFF/0x47/0xD0 -> Xbox One GIP (power-on), class 0x03 -> HID. Host-tested. - Plumb the interface triple through attachUsbDevice (Kotlin -> JNI -> usbhost::attachDevice). - Rank interface selection so the gamepad interface is chosen over the Xbox 360 audio/expansion (0x5D/0x02, 0x5D/0x03) and security (0xFD) interfaces, which are never claimed. Any standards-compliant wired XInput / GIP / HID controller now works in Direct mode without a table entry; the 8BitDo dongle is a consequence, not a special case, and its rumble + analog triggers come with the XInput path. Auto-claim is unchanged -- descriptor-classified devices are reached only through an explicit Direct pick, never auto-claimed. Sourced from the Linux xpad driver and [MS-GIPUSB]. Scoped out (needs the hardware to verify, not guessed): the Microsoft Xbox 360 wireless-receiver (0x5D/0x81) presence/unwrap state machine, and deeper HID multi-report muxing. Existing table entries for those keep working unchanged.
emir-hasanbegovic
force-pushed
the
feat/usb-direct-descriptor-classification
branch
from
July 22, 2026 16:13
2df107a to
4817394
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
USB Direct mode recognized a controller only if its exact VID:PID was in the native device table. An XInput controller under an unlisted PID — e.g. an 8BitDo Ultimate 2.4g dongle in X-input mode — fell through to the generic-HID guess and failed to claim (silent garbage or a probe timeout), even though the OS
xpaddriver handles it fine (which is why it works in Moonlight). The table already held ~200 XInput/GIP rows that all map to the same two parsers — encoding one bit the interface descriptor states directly.Separately, the Kotlin side claimed the first HID/vendor interface with an interrupt-IN, which on a composite Xbox 360 controller can be the audio interface (
0x5D/0x03) rather than the gamepad.Fix — classify by the USB interface descriptor, not a VID:PID allowlist
usbparsers::classifyDevice(vid, pid, class, subclass, protocol)(pure, host-tested): a known-table entry still wins (so every listed device keeps its exact parser/init/quirks and the Sony/Switch vendor report formats); otherwise the descriptor decides —0xFF/0x5D/0x01 → XInput,0xFF/0x47/0xD0 → Xbox One GIP(power-on),class 0x03 → HID.attachUsbDevice(Kotlin → JNI →usbhost::attachDevice).0x5D/0x02,0x5D/0x03) and security (0xFD) interfaces, which are never claimed.Any standards-compliant wired XInput / GIP / HID controller now works in Direct mode without a table entry; the 8BitDo dongle is a consequence, not a special case, and its rumble + analog triggers come with the XInput path (which the DInput path can't express). Everything is sourced from the Linux
xpaddriver and[MS-GIPUSB]— no guessed bytes.Auto-claim is unchanged:
isVerifiedFastLanestill keys on thekKnowntable only, so descriptor-classified devices are reached only through an explicit Direct pick (dashboard / wizard "Try Direct"), never auto-claimed.Tests
classifyDevicecases (usb_parsers_test.cpp): table-wins-over-descriptor, wired XInput / GIP / HID by triple, the 8BitDo dongle triple → XInput without a table entry, unknown-vendor fallback.UsbGamepadManagerTest.kt): a composite Xbox 360 device enumerated audio-first must claim the gamepad interface — verified to fail against the old first-match selection.testDebugUnitTest,nativeTest(174/174),assembleDebug.Scope (deliberate, not hacked)
Out of scope because doing them correctly needs the physical hardware to verify (shipping a guessed implementation would violate the "no guessing" bar): the Microsoft Xbox 360 wireless-receiver (
0x5D/0x81) presence/unwrap state machine, and deeper HID multi-report muxing. Existing table entries for those keep working unchanged.