From 731555d26474d866763ed18b7b30a3037a0a16a5 Mon Sep 17 00:00:00 2001 From: Lucas Peng Date: Fri, 10 Jul 2026 00:29:38 -0700 Subject: [PATCH 1/2] ControllerV2: implement working flash persistence (flushConfig) 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). --- src/controller/Controller.h | 9 +++ src/controller/ControllerV2.cpp | 138 ++++++++++++++++++++++++++++++-- src/controller/ControllerV2.h | 13 ++- 3 files changed, 145 insertions(+), 15 deletions(-) diff --git a/src/controller/Controller.h b/src/controller/Controller.h index cac2f20..d693f5d 100644 --- a/src/controller/Controller.h +++ b/src/controller/Controller.h @@ -82,6 +82,15 @@ namespace OWC { */ [[nodiscard]] virtual bool writeConfig() const = 0; + /*! + * @brief persist the current in-RAM config to device flash + * @details commits changes so they survive a controller reset / reboot + * without physically toggling the mode switch. Default no-op for + * controllers that persist on write. + * @return true on success + */ + [[nodiscard]] virtual bool flushConfig() const { return true; } + /*! * @brief reset the whole controller configuration memory * @details writes a known working configuration buffer diff --git a/src/controller/ControllerV2.cpp b/src/controller/ControllerV2.cpp index 7a29e80..4026779 100644 --- a/src/controller/ControllerV2.cpp +++ b/src/controller/ControllerV2.cpp @@ -17,6 +17,8 @@ */ #include #include +#include +#include #include "ControllerV2.h" #include "../Utils.h" @@ -52,11 +54,93 @@ namespace OWC { } bool ControllerV2::initWriteCommunication() const { - prepareSendBuffer(CMD::Init1); + // GPD's official app opens every write/flush transaction with 0x21 carrying + // a fixed 56-byte "unlock" payload (not a bare init). The firmware only + // persists writes / honours the 0x25 flush when this token is present; + // a bare 0x21 leaves changes in RAM only (which is why flushing did nothing). + static constexpr uint8_t unlock[56] = { + 0xa2,0xa3,0xa0,0xa1,0xa6,0xa7,0xa4,0xa5, 0xba,0xbb,0xb8,0xb9,0xbe,0xbf,0xbc,0xbd, + 0xb2,0xb3,0xb0,0xb1,0xb6,0xb7,0xb4,0xb5, 0x8a,0x8b,0x88,0x89,0x8e,0x8f,0x8c,0x8d, + 0x82,0x83,0x80,0x81,0x86,0x87,0x84,0x85, 0x9a,0x9b,0x98,0x99,0x9e,0x9f,0x9c,0x9d, + 0x92,0x93,0x90,0x91,0x96,0x97,0x94,0x95 + }; + uint16_t *sendBufU16 = reinterpret_cast(sendBuf); + + prepareSendBuffer(CMD::Init1, sizeof(unlock)); + sendBufU16[2] = 0; // page index + sendBufU16[3] = getBytesSum(unlock, sizeof(unlock)); // checksum (== 0x2284) + std::memcpy(sendBuf + 8, unlock, sizeof(unlock)); + + return sendReadRequest() && respBuf[8] == 0xaa; + } + + bool ControllerV2::sendKeepAlive() const { + // 0x2b carrying u32 2000 — the keep-alive the official app sends every + // 2000ms while a config session is open (payload = the interval itself) + static constexpr uint8_t interval[4] = {0xd0, 0x07, 0x00, 0x00}; + uint16_t *sendBufU16 = reinterpret_cast(sendBuf); + + prepareSendBuffer(CMD::Init2, sizeof(interval)); + sendBufU16[2] = 0; // page index + sendBufU16[3] = getBytesSum(interval, sizeof(interval)); // checksum + std::memcpy(sendBuf + 8, interval, sizeof(interval)); return sendReadRequest() && respBuf[8] == 0xaa; } + bool ControllerV2::flashBracket(const int keepAlives) const { + // The official app brackets every write/flush with a + // magic-0x21, 0x2b, 0x29, N x 0x2b (2s apart), 0x2a, 0x22 + // session (captured from WinControls on Windows). Without it the 0x25 + // flush is acknowledged but nothing is persisted; with it the config + // survives a controller reset. 0x29 appears to close/commit the flash + // session, 0x2a to (re)arm config writes. + using namespace std::chrono_literals; + + if (!initWriteCommunication()) { + if (logFn) + writeLog(L"failed to init communication"); + + return false; + } + + if (!sendKeepAlive()) + return false; + + prepareSendBuffer(CMD::FlashLock); + if (!sendReadRequest() || isCmdRejected()) { + if (logFn) + writeLog(L"flash lock (0x29) rejected"); + + return false; + } + + for (int i=0; i(sendBuf); + uint16_t *configBufU16 = reinterpret_cast(configBuf); + const uint16_t dataSum = getBytesSum(configData, configBufLen - configHeaderSz); + + // keep the header self-checksum consistent with the (possibly modified) + // config data: u16@4 = sum of data bytes, u16@8 = its complement. + // The official app maintains these on every write. + configBufU16[2] = dataSum; + configBufU16[4] = 0xffff - dataSum; if (!initWriteCommunication()) { if (logFn) @@ -211,9 +303,6 @@ namespace OWC { return false; } - if (logFn) - writeLog(bufferToString(configBuf, configBufLen)); - for (int i=0; i write all pages -> flush (0x25) -> bracket (burn window) + // + // Self-contained: re-sends the pages itself, so it can be called after + // writeConfig() (or standalone) and always commits what configBuf holds. + if (!flashBracket(2)) + return false; + + if (!writePages()) + return false; + if (!initWriteCommunication()) { if (logFn) writeLog(L"failed to init communication"); @@ -242,21 +356,29 @@ namespace OWC { return false; } + // 01 25 04 00 00 00 04 00 00 04 — same arg shape as the checksum query prepareSendBuffer(CMD::Flush, 4); - // required args sendBuf[6] = 4; // checksum sendBuf[9] = 4; // unk if (!sendReadRequest() || isCmdRejected()) { if (logFn) - writeLog(L"failed to flush config buffer"); + writeLog(L"failed to flush config to flash"); return false; } prepareSendBuffer(CMD::EndCmd); - return sendReadRequest() && !isCmdRejected(); + if (!sendReadRequest() || isCmdRejected()) { + if (logFn) + writeLog(L"failed end cmd after flush"); + + return false; + } + + // post-flush bracket: the flash burn happens in this window + return flashBracket(3); } bool ControllerV2::resetConfig() const { diff --git a/src/controller/ControllerV2.h b/src/controller/ControllerV2.h index 9ef81ee..6f1af5a 100644 --- a/src/controller/ControllerV2.h +++ b/src/controller/ControllerV2.h @@ -36,6 +36,8 @@ namespace OWC { Write = 0x43, Checksum = 0x27, Flush = 0x25, + FlashLock = 0x29, // close/commit a flash session (official app sends after flush; device replies 0x01) + FlashUnlock = 0x2a, // (re)open a config session (official app sends right before a write; device replies 0x00) EndCmd = 0x22 }; @@ -50,6 +52,9 @@ namespace OWC { [[nodiscard]] bool initReadCommunication() const; [[nodiscard]] bool initWriteCommunication() const; + [[nodiscard]] bool sendKeepAlive() const; + [[nodiscard]] bool flashBracket(int keepAlives) const; + [[nodiscard]] bool writePages() const; void prepareSendBuffer(CMD cmd, int bytesCount = 0) const; [[nodiscard]] bool isCmdRejected() const; [[nodiscard]] bool isValidRespPacket() const; @@ -121,16 +126,10 @@ namespace OWC { */ [[nodiscard]] int getBackButtonHoldTime(int num, int slot) const; - /*! - * @brief flush config buffer to controller to make it permanent - * @details NOT WORKING, help needed - * @return true on success - */ - [[nodiscard]] bool flushConfig() const; - [[nodiscard]] bool readVersion() override; [[nodiscard]] bool readConfig() override; [[nodiscard]] bool writeConfig() const override; + [[nodiscard]] bool flushConfig() const override; [[nodiscard]] bool resetConfig() const override; [[nodiscard]] bool setButton(Button btn, const std::string &key) const override; [[nodiscard]] std::string getButton(Button btn) const override; From a034a4dd68013ce9990495fa3eeda561b847e871 Mon Sep 17 00:00:00 2001 From: Lucas Peng Date: Fri, 10 Jul 2026 12:27:53 -0700 Subject: [PATCH 2/2] docs: document the flash persistence protocol - 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). --- docs/protocolV2.md | 305 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 303 insertions(+), 2 deletions(-) diff --git a/docs/protocolV2.md b/docs/protocolV2.md index b67e27b..fb14ff4 100644 --- a/docs/protocolV2.md +++ b/docs/protocolV2.md @@ -89,6 +89,37 @@ Configuration total size is 1024 bytes. Structure: 12 bytes header + 1012 bytes data. +Header: + + + + + + + + + + + + + + + + + + + + + + + +
0-34-56-78-910-11
constdata checksumdata checksum ^ 0xffffconst
75 56 34 12xx xx00 00xx xxff ff
+ +**data checksum** is the sum of the 1012 data bytes. + +The official app keeps both header checksum fields consistent with the data on +every write; update them after modifying the data. + ## Back buttons ### mini 25 @@ -178,6 +209,14 @@ Unless stated otherwise, checksum is the sum of all bytes after it. **bytes count** is the number of bytes set, after the header. +> NOTE: +> +> All packets go over the control pipe. The official app sends them as +> SET_REPORT(Output); on Linux use HID **feature** reports +> (`hid_send_feature_report`) — interface 0 also has an interrupt OUT +> endpoint, so output reports are routed down the interrupt pipe there and +> the command handler silently ignores them. + ## Init communication Send @@ -3636,6 +3675,56 @@ Response Successful initialization returns **0xaa** in byte **8**. +The payload-less init above is enough for writes that only reach controller +RAM (lost on controller reset). For a write that is going to be flushed to +flash, the official app always sends this init with a fixed 56 bytes unlock +payload instead: + +Send + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
01234-5678-63
IDcmdbytes countpagechecksumunlock payload
0121380000 008422see below
+ +Unlock payload (fixed): + +``` +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 +``` + +The response is the same as for the payload-less init. + ## Write config to memory You must send all 1024 bytes, writes don't have a response. @@ -6624,6 +6713,9 @@ Send ## Prepare for flush +The official app sends this init with the same fixed 56 bytes unlock payload +described in [Prepare for write](#prepare-for-write). + Send @@ -6694,9 +6786,26 @@ Successful initialization returns **0xaa** in byte **8**. ## Flush config to controller -> [!WARNING]: +Flush persists the current configuration to controller flash, so it survives a +controller reset (reboot / suspend) without toggling the controller mode +switch. + +> [!IMPORTANT] +> +> The flush command is acknowledged even when it has no effect. To actually +> persist, the write and the flush must run between two +> [flash session brackets](#flash-session-bracket): +> +> ``` +> bracket : the full sequence below +> write : prepare for write (with unlock payload) → write config → checksum validation → end +> flush : prepare for flush → flush → end +> bracket : again — the flash burn happens in this window +> ``` > -> Not working for some reason, help is needed! +> Verified on Win Mini 2025 (firmware 1.22): with the brackets the config +> survives suspend/resume and a full power cycle; without them the device +> reverts to its previous flash config on the next reset. Send @@ -6763,3 +6872,195 @@ Send
00
+ +## Flash session bracket + +The official app wraps every write + flush between two of these sequences; +the flash burn happens in the window after the flush (see +[Flush config to controller](#flush-config-to-controller)). + +``` +init (0x21, with unlock payload) → keep-alive (0x2b) → 0x29 → +N × keep-alive (2 s apart) → 0x2a → end (0x22) +``` + +`0x29` responds **0x01** in byte **8**, `0x2a` responds **0x00**. The exact +semantics are unknown; from the ordering, `0x29` appears to close/commit a +flash session and `0x2a` to (re)arm config writes. The official app keeps a +bracket open with keep-alives for as long as it runs; N = 2–3 keep-alives per +bracket is verified to be sufficient. + +### Keep-alive + +Inside a flash session the official app sends this every 2000 ms; the payload +is the interval itself (u32, 0x07d0 = 2000). + +Send + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
01234-5678-1112-63
IDcmdbytes countpagechecksuminterval ms
012b040000 00d700d0 07 00 0000
+ +The response is the same as for [Init communication](#init-communication) +(**0xaa** in byte **8**). + +### 0x29 + +Send + + + + + + + + + + + + + + + + + + + + +
0123-63
IDcmdbytes count
01290000
+ +Response + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
0123456789-63
IDcmdbytes countunkchecksumstatus
01290100xx0401000100
+ +### 0x2a + +Send + + + + + + + + + + + + + + + + + + + + +
0123-63
IDcmdbytes count
012a0000
+ +Response + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
0123456789-63
IDcmdbytes countunkchecksumstatus
012a0100xx0400000000