Repository navigation
sokol: Windows reads the pads XInput does not place - #3076
Merged
Merged
Conversation
The pads XInput does not report, read from the reports they send: the DualShock 4 and DualSense, the Switch Pro and Joy-Con, and anything presenting as one — which is how the 8BitDo in its S mode arrives. The decoding is ported from SDL's own drivers rather than derived, so the report ids, the button bytes, the hat nibble and the Bluetooth CRC are proven code re-hosted instead of a parser invented here. A reader is `report + length -> bits + right stick`, with no handle and no platform in it, which is what makes the one part of a platform backend that can be checked without the pad in hand checkable: the reports beside `tests/sgamepad.c` drive the readers on every platform. That is how the Switch stick offsets and the Bluetooth packet offsets were found to be wrong rather than by a pad on a bench. The readers are not reached by any backend yet — the Windows device layer they will hang off is the next change on this branch. Co-Authored-By: Claude Code <noreply@anthropic.com>
XInput reaches the Xbox family and whatever presents as one, and it stays the floor: a pad it places is read there and nowhere else. The PlayStation and Switch families are not XInput devices at all — an 8BitDo in its S mode is one of them — and were invisible on Windows, where SDL's HIDAPI drivers used to read them. This is that half back. The devices are found with SetupDiGetClassDevs and opened by the vendor and product ids the readers know, which is what leaves a wheel, a flight stick or a pad in a vendor mode with a layout of its own unclaimed rather than read as buttons nobody pressed. A pad is read with one overlapped ReadFile left outstanding and looked at once a frame, so nothing blocks on a pad that has gone quiet. A pad that reports at all takes its slot: these families send while they are held and go quiet on the table, where an XInput pad answers a poll whether anyone has picked it up. That is the layer's policy — a device that shows it is used takes the first free slot — read for a device whose silence means it is not there. Over Bluetooth a PlayStation pad stops streaming unless the host asks it to go on, so the layer sends SDL's tickle when a pad has been quiet — with the CRC the DualSense checks, which is why the CRC is the readers'. hid and setupapi come with every Windows SDK; there is no redistributable and no gameinput.lib. Co-Authored-By: Claude Code <noreply@anthropic.com>
The reports are built to SDL's documented layouts rather than recorded from a pad — which is what the table can do before the bench, and not the same claim. Said plainly in the file's own header, so nobody reads a passing run as a pad having been plugged in. Co-Authored-By: Claude Code <noreply@anthropic.com>
The CRC is what keeps a Bluetooth pad streaming and what makes its reports readable at all, and it is the one part of the readers that is a computation rather than an offset. SDL's own crc32 is written out in the test beside it — the source it was ported from, kept apart from the port — and the two are compared over every length and every one-byte header, plus the standard sum of "123456789" so a rewrite has something to be wrong against. Co-Authored-By: Claude Code <noreply@anthropic.com>
Four things the review of the finished diff found, and the report table could not see, because it was built from the same misreading: The DualSense sends its state report in two layouts, and SDL splits them on the size — 10 and 78 bytes are the simple layout, where the buttons sit where a DualShock 4 keeps them, and 64 is the DualSense's own. Reading all of them as the extended one put the trigger bytes where the hat and the face buttons are, so a pad in its default mode read as held up with nothing else ever working. The left stick steered nothing. The machine has one set of direction bits and every other backend reads the stick into it; these readers read only the hat, so a DualShock could not steer with the stick where an Xbox pad can. Both now, which is what the design's own scenarios say. The Switch's `x` and `y` looked swapped and are not: bit `0x01` is the bottom button and `0x02` the right one, which is what the machine's `a` and `b` mean. SDL's default for a Switch pad is the other way round — its *labels*, which is what `RemapButton` swaps away from — so the readers deliberately differ from SDL there and follow the design's positions. Said so in the comment, so it does not get "fixed" back. And the keepalive is no longer a real effects report: the CRC made it one, which is a pad being changed rather than one being asked to keep talking. The reader now reports whether the pad is in its extended mode, since that is the state the tickle is for and the packet would move a simple pad into it — SDL's own comment says as much. Co-Authored-By: Claude Code <noreply@anthropic.com>
XInput numbers its pads from zero and the HID pads were landing in the same entries, so an Xbox pad and a DualSense together meant one of them invisible and the other one's input under both. The HID pads take the slots past the four XInput can reach, and the table's own limit follows. The keepalive was sending to every quiet PlayStation pad and writing on a handle it could not wait on: an overlapped handle can report a write as complete without doing it. It now goes only to a pad in its extended report mode — the state SDL tickles — through a second, non-overlapped handle opened for the writes, and only counts as sent when it was. The device scan's buffer was a byte array cast to a structure, which is the structure's alignment only by luck; it comes from the heap now, and the path is copied out of it with a wide-length that is checked. Co-Authored-By: Claude Code <noreply@anthropic.com>
The reports the review's findings needed to be caught by: the simple and extended DualSense layouts under one id, the left stick's four directions, and a keepalive pinned as "the magic byte and nothing else" — a CRC in there is a pad being changed, not asked to keep talking. The resting stick value moves to 0x7F, which is what the wire's 0x80 actually is: centered on the boundary of the readers' threshold, so the same byte read as a direction or not depending on which side of a float comparison it landed. A pad at rest is below it. Co-Authored-By: Claude Code <noreply@anthropic.com>
The second review found the sign of every vertical axis inverted, and it was right. The readers negated the y they read, on the premise that the pads send it the machine's way — and the premise was written down in the header, which is how it survived the first review and a table built on it. What settled it is what SDL's own convention is, in its words: its SDL_gamecontroller.h says an axis runs from -32768 (up/left) to 32767 (down/right). Its backends put every platform into that, which is why its XInput one negates sThumbLY — Microsoft's pads send y upwards. SDL's pads send y upwards too, and the formula it reads them with lands on exactly -32768 for the smallest byte, so what the readers produced was already in the machine's sign and the extra negation turned up into down. Push the stick up and the left-stick bits said down, and the mouse went the wrong way down the screen. The Switch's full report says the same thing from the other side: SDL inverts y there and not in the simple report its own driver calls the default for a wired pad — so the two disagree, and the inversion in the simple path is what this reader is. Also from that review: the Bluetooth DualShock 4's keepalive became unreachable when it was gated on the enhanced state, since the PS4 reader never reported one. It does now — the 0x11..0x19 band exists only in that mode, which is what SDL asks the pad for on the way in. And a 0x31 report is guarded by the length its packet needs rather than a shorter one that would have read past it. Co-Authored-By: Claude Code <noreply@anthropic.com>
The expectations carried the same inverted premise the readers did, so they passed either way and could not have caught it. What they say now is what the wire says: the smallest byte is the stick held up, and up comes out negative — which is checkable against the pad rather than against the code. Two assertions on the enhanced flag come with it, both ways round: the 0x11 band means a DualShock 4 the keepalive is for, and the common state report means one it is not. Co-Authored-By: Claude Code <noreply@anthropic.com>
The close gives back the outputs, the file and the event, and each of them is checked before it is: a pad that failed to open partway leaves some of them null, and CloseHandle is not a thing to hand a null. Co-Authored-By: Claude Code <noreply@anthropic.com>
The first time MSVC saw this file it stopped at
`SP_DEVICE_INTERFACE_DATA interface = { .cbSize = sizeof interface };`
— "unexpected token 'struct'", pointed at the variable name, because a
C initializer list with designators is not something its C parser takes
and it read the whole declaration as something else. Everything after it
in the function was a cascade of that one line.
Designated initializers are what the rest of this header uses and clang is
happy with, so nothing local said a word: the hand-written stubs for the
platform's headers type-check clean either way, which is exactly the gap
this job exists to close. The fields are set one at a time instead, which
every compiler here takes.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The next thing MSVC stopped on was `SP_DEVICE_INTERFACE_DATA interface;` — "unexpected token 'struct'", pointed at the variable name. That is the tell: `interface` is a macro the Windows SDK defines as `struct`, so the declaration reached the C parser as `SP_DEVICE_INTERFACE_DATA struct;` and everything after it in the function fell apart the same way the initializer list did. It is one of those names that is a keyword in one language and a macro in a header, and neither clang nor the stubs say a word about it. `entry` is what it is anyway: one device interface out of the list. Co-Authored-By: Claude Code <noreply@anthropic.com>
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.
The sokol layer's Windows backend reads what XInput reaches — the Xbox family, the Steam Deck's virtual pad, an 8BitDo in its X mode. Three of the five pad families are not XInput devices at all: DualShock 4 and DualSense, Switch Pro and Joy-Con, and an 8BitDo in its S mode. Today's SDL build reads them through its HIDAPI drivers, so switching the desktop to the sokol layer without this would take those pads away from Windows players. This is that half back.
The route was decided by a spike, not a preference. Two PowerShell lines against
Windows.Gaming.Inputon the owner's machine, with the S-mode 8BitDo connected: one raw controller and nothing inGamepad.Gamepads. Windows has no standard layout for that family, and GameInput would have nothing to place it with. For PlayStation there is no pad on the bench at all, so taking GameInput there would have stacked two unverified things — Microsoft's table and our integration — with no way to see either fail. So: our own readers, both families, decoding ported from the vendored SDL rather than derived. The layouts, the report ids, the hat nibble and the Bluetooth CRC are proven code re-hosted; only what the machine reads is taken.A reader is
report + length → bits + right stick— no handle, no platform, no timing.src/system/sokol/sgamepad_hid.h, included by the Windows branch. That is what makes the one part of a platform backend that otherwise needs the pad in hand testable:tests/sgamepad_hid.cdrives them from reports, on every platform, and asgamepad-hid-testtarget runs it in CI. It was worth its keep — it caught the Switch stick offsets being two bytes early and the Bluetooth packet offsets being out by the length byte before any hardware was involved.On Windows, devices are found with
SetupDiGetClassDevsand opened by the vendor and product ids the readers know, which leaves a wheel or a pad in a vendor mode with its own layout unclaimed rather than read as buttons nobody pressed. One overlappedReadFileis left outstanding and looked at once a frame, so nothing blocks on a pad that has gone quiet; the device list is walked at startup, when a pad stops answering, and on a one-second clock, because reading a device's ids means opening it. A pad that reports at all takes its slot: these families send while they are held and go quiet on the table, where an XInput pad answers a poll whether anyone has picked it up. Over Bluetooth a PlayStation pad stops streaming unless the host asks it to go on, so the layer sends SDL's tickle when a pad in its extended report mode has been quiet — without the CRC, since a packet whose sum adds up is a real effects report, which is a pad being changed rather than one being asked to keep talking.hidandsetupapicome with every Windows SDK; no redistributable, nogameinput.lib.Verified, and not. The readers run under
ctest -R sgamepad(15 scenarios, both connections, both DualSense layouts, the CRC checked against SDL's own over every length and prefix). The macOS sokol target builds and links with the new header. The Windows device layer has been type-checked against stub headers only — it has never been compiled by a Windows compiler and never run; the CI step this PR adds is its first real compile, and the bench its first run. The Switch0x3F-only reader and the wired pad that may want the full0x30report are open until a pad answers.Two reviews, and the second one mattered more. The first found four real bugs: the DualSense sends its state report in two layouts and only one was read, so a pad in its default mode parsed its trigger bytes as the hat and face buttons; the Windows device table had two backends writing the same entries, so an Xbox pad and a DualSense together meant one invisible and the other's input; the keepalive was a real effects report; and the left stick steered nothing. The second found the worst of them, which the first had looked at and passed: every vertical axis was inverted, on both sticks and all three families. The readers negated the y they read, and the reason was written down in the header as a premise rather than checked — and the test table had been built on the same premise, so it agreed with the code. What settles it is SDL's own documented convention (
SDL_gamecontroller.h: an axis runs from -32768, up/left, to 32767, down/right) and what its backends do to reach it. A pad's sticks now point the way the pad is pushed, checked by hand at the byte level rather than by re-reading the reader.Desktop still ships the SDL layer, so nothing user-visible changes until the migration change turns this one on.
🤖 Generated with Claude Code