Skip to content

sokol: Windows reads the pads XInput does not place - #3076

Merged
nesbox merged 12 commits into
mainfrom
sokol-windows-gamepads
Oct 12, 2026
Merged

nesbox merged 12 commits into
mainfrom
sokol-windows-gamepads

Conversation

@nesbox

@nesbox nesbox commented Oct 12, 2026

Copy link
Copy Markdown
Owner

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.Input on the owner's machine, with the S-mode 8BitDo connected: one raw controller and nothing in Gamepad.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.c drives them from reports, on every platform, and a sgamepad-hid-test target 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 SetupDiGetClassDevs and 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 overlapped ReadFile is 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. hid and setupapi come with every Windows SDK; no redistributable, no gameinput.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 Switch 0x3F-only reader and the wired pad that may want the full 0x30 report 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

nesbox and others added 12 commits October 12, 2026 13:11
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>
@nesbox nesbox self-assigned this Oct 12, 2026
@nesbox
nesbox merged commit ef112ec into main Oct 12, 2026
14 checks passed
@nesbox
nesbox deleted the sokol-windows-gamepads branch October 12, 2026 12:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant