Skip to content

ControllerV2: implement working flash persistence (flushConfig)#1

Open
lucasp0927 wants to merge 2 commits into
OpenWinControls:mainfrom
lucasp0927:flash-persistence
Open

ControllerV2: implement working flash persistence (flushConfig)#1
lucasp0927 wants to merge 2 commits into
OpenWinControls:mainfrom
lucasp0927:flash-persistence

Conversation

@lucasp0927

Copy link
Copy Markdown

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 CMD enum at all:
0x29 (device responds 0x01) and 0x2a (responds 0x00). The official
app's per-Apply choreography is:

BRACKET : magic-0x21 → 0x2b → 0x29 → N × 0x2b (2 s apart) → 0x2a → 0x22
WRITE   : magic-0x21 → 19 × 0x43 pages → 0x27 (checksum verify) → 0x22
FLUSH   : magic-0x21 → 0x25 → 0x22
BRACKET : again — the flash burn happens in this window

The 0x25 flush alone is acknowledged but persists nothing — the response
(byte 8 = 0x00, tail 02 00 00 02) is byte-identical whether or not the
brackets 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 after
suspend/resume; the exact same write wrapped in brackets → mapping survives
suspend/resume and a full power cycle.

Inside a bracket, 0x2b (Init2) carries the payload d0 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 0x21 before write/flush transactions carries a fixed
56-byte payload (page 0, checksum 0x2284; the byte values are 0x80–0xbf
in a fixed scramble). Reads work with a bare 0x21 as today. (I haven't
isolated 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.)

a2 a3 a0 a1 a6 a7 a4 a5 ba bb b8 b9 be bf bc bd
b2 b3 b0 b1 b6 b7 b4 b5 8a 8b 88 89 8e 8f 8c 8d
82 83 80 81 86 87 84 85 9a 9b 98 99 9e 9f 9c 9d
92 93 90 91 96 97 94 95

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 = 0x2a added to the CMD enum
  • initWriteCommunication() sends the 56-byte unlock payload
  • sendKeepAlive() — the 0x2b + d0 07 00 00 packet
  • flashBracket() — the bracket choreography (keep-alives 2 s apart, matching
    the official app's cadence)
  • writePages() — the existing page-write loop, factored out of
    writeConfig(), plus the header self-checksum fixup
  • flushConfig() — bracket → write pages → 0x25 flush → bracket. It
    re-sends the pages itself so it is self-contained: callable after
    writeConfig() or standalone, it always commits what configBuf holds.
    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 on
Linux — interface 0 has an interrupt-OUT endpoint, so output reports get
routed down the interrupt pipe, which the config handler silently ignores.

Verification

  • Full replay + the C++ implementation produce responses byte-identical to
    the official app's capture, including the device's 0x27 checksum of the
    written 1024-byte config.
  • Hardware test on Win Mini 2025 (fw 1.22): set → suspend/resume → binding
    held; set → full reboot → binding survived. No mode-switch toggle.
    The same tests failed with the bare 0x25 flush.
  • Not yet tested on the Win 5 controller (same VID/PID and protocol, and the
    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.md to match: the header
self-checksum, the unlock payload, the flash session bracket (0x29/0x2a
and the keep-alive packet), the Linux transport note — and removes the
help-wanted warning on flush.

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).
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