ControllerV2: implement working flash persistence (flushConfig)#1
Open
lucasp0927 wants to merge 2 commits into
Open
ControllerV2: implement working flash persistence (flushConfig)#1lucasp0927 wants to merge 2 commits into
lucasp0927 wants to merge 2 commits into
Conversation
On the 2f24:0137 controller (GPD Win Mini 2025, fw 1.22) writeConfig()
only lands in RAM: the mapping applies immediately but reverts to flash
on the next controller reset (reboot/suspend) unless the user physically
toggles the mode switch.
Captured the official WinControls app's Apply over USB (USBPcap) and
replayed it on Linux to isolate the persistence mechanism. Three pieces
were missing:
1. The write/flush transactions must be opened by a 0x21 init carrying a
fixed 56-byte unlock payload (page 0, checksum 0x2284), not a bare
0x21. The read path is unaffected.
2. The write+flush must be wrapped in flash-session brackets using two
previously unknown commands: 0x29 (responds 0x01) and 0x2a (responds
0x00), with 0x2b keep-alives carrying u32 2000 (the poll interval the
official app uses). Bracket shape:
magic-0x21, 0x2b, 0x29, N x 0x2b (2s apart), 0x2a, 0x22
The 0x25 flush alone is acknowledged (byte8=0x00) but persists
nothing without the brackets - which is why the old flushConfig was
"NOT WORKING".
3. The config header maintains a self-checksum: u16@4 = sum of the 1012
data bytes, u16@8 = its complement (0xffff - cks). The official app
updates it on every write; writing it stale is another difference
from the official traffic.
flushConfig() is self-contained: bracket, re-send all pages (with the
header checksum fixed up), 0x25 flush, closing bracket (the burn happens
in that window). Callers can invoke it after writeConfig() or
standalone; the base class provides a default no-op so V1 callers are
unaffected.
Transport note: this works over HID feature reports (hidapi Linux
default). Interface 0 has an interrupt-OUT endpoint, so
hid_send_output_report() would be routed down the interrupt pipe, which
the config handler ignores - feature reports ride the control pipe just
like the official app's SET_REPORT(Output).
Verified on hardware: L4/R4 remapping survives suspend/resume and the
device checksum response matches the official capture byte-for-byte
(0x5f05 for the test config).
- config header self-checksum (u16@4 = sum of data bytes, u16@8 = complement) - the 56-byte unlock payload the official app sends in the 0x21 init before write/flush sessions - the flash session bracket (0x29 / 0x2a / 2s keep-alive with the interval as payload) that write + flush must run between to actually persist - removes the help-wanted warning on flush - Linux transport note: feature reports (control pipe), not output reports (interrupt pipe) All values verified against a USB capture of the official app and on hardware (Win Mini 2025, fw 1.22).
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.
Thanks for building such a great library! I took a stab at the binding persistence issue and managed to get a working implementation tested on my GPD Win Mini 2025.
Since firmware isn't my primary domain, I relied heavily on Claude to help interpret the traffic captured and the technical write-up below. I'd really appreciate an extra set of eyes to scrutinize the logic and ensure everything looks safe before we merge.
This makes
flushConfig()actually persist the config to controller flash.With it, mappings survive reboot and suspend/resume with no physical mode-switch
toggle.
Verified on hardware: GPD Win Mini 2025 (Ryzen AI 9 HX 370), controller
2f24:0137, firmware 1.22, on Linux (Bazzite).Disclaimer up front: I'm not familiar with firmware development — this is
the result of capturing what the official app does and replaying it until
persistence reproduced, with Claude doing much of the diagnosis. The capture
is of the official WinControls app for the GPD Win 5, and everything here has only been tested on my own Win Mini 2025 HX370.
How this was found
I captured the official app over USB on Windows and decoded the complete traffic,
then replayed it from Linux over hidraw until persistence reproduced. Three things turned out to be missing, and all
three are needed:
1. The flush must be wrapped in flash-session brackets (the actual bug)
The capture contains two commands that aren't in the V2
CMDenum at all:0x29(device responds0x01) and0x2a(responds0x00). The officialapp's per-Apply choreography is:
The
0x25flush alone is acknowledged but persists nothing — the response(byte 8 =
0x00, tail02 00 00 02) is byte-identical whether or not thebrackets are present, which is why the current implementation looks correct
on the wire but doesn't work. I confirmed this the hard way: bare
magic-init +
0x25→ device reverted to its flash config aftersuspend/resume; the exact same write wrapped in brackets → mapping survives
suspend/resume and a full power cycle.
Inside a bracket,
0x2b(Init2) carries the payloadd0 07 00 00(= 2000):the capture shows these sent every 2000 ms — a session keep-alive whose
payload is the interval itself.
2. Write/flush sessions need a non-empty 0x21 init
The official app's
0x21before write/flush transactions carries a fixed56-byte payload (page 0, checksum
0x2284; the byte values are0x80–0xbfin a fixed scramble). Reads work with a bare
0x21as today. (I haven'tisolated whether the payload is strictly required inside brackets — it's kept
for byte-fidelity to the official traffic, which is the configuration that's
hardware-verified.)
3. The config header keeps a self-checksum the lib never updates
The two configs the official app wrote in my capture differ in exactly four
bytes: the two remapped keys, and two bytes in the 12-byte header. That
revealed:
u16@4= sum of the 1012 data bytes,u16@8=0xffff − u16@4.The lib currently writes the header back stale after modifying config data;
writePages()now recomputes it on every write.What the patch does
FlashLock = 0x29/FlashUnlock = 0x2aadded to theCMDenuminitWriteCommunication()sends the 56-byte unlock payloadsendKeepAlive()— the0x2b+d0 07 00 00packetflashBracket()— the bracket choreography (keep-alives 2 s apart, matchingthe official app's cadence)
writePages()— the existing page-write loop, factored out ofwriteConfig(), plus the header self-checksum fixupflushConfig()— bracket → write pages →0x25flush → bracket. Itre-sends the pages itself so it is self-contained: callable after
writeConfig()or standalone, it always commits whatconfigBufholds.Declared as a base-class virtual with a default no-op so V1 (which persists
on write) is unaffected and callers don't need to care.
Note on duration: the call takes ~12 s, nearly all of it the deliberate 2 s
keep-alive cadence around the burn window. The capture has no decoded
"flash done" signal (keep-alive responses are constant), so the bracket is
held open for a conservative few periods rather than risking closing it
mid-burn. It can likely be tuned down experimentally.
Transport note for other frontends: this works over HID feature reports
(the hidapi default on Linux).
hid_send_output_report()must be avoided onLinux — interface 0 has an interrupt-OUT endpoint, so output reports get
routed down the interrupt pipe, which the config handler silently ignores.
Verification
the official app's capture, including the device's
0x27checksum of thewritten 1024-byte config.
set→ suspend/resume → bindingheld;
set→ full reboot → binding survived. No mode-switch toggle.The same tests failed with the bare
0x25flush.captured app is the Win 5's own — I'd expect it to work, but I only own the
Win Mini). V1 devices are untouched.
The second commit updates
docs/protocolV2.mdto match: the headerself-checksum, the unlock payload, the flash session bracket (
0x29/0x2aand the keep-alive packet), the Linux transport note — and removes the
help-wanted warning on flush.