# QuNexus unresponsive after flashing a modified firmware image — technical report Prepared for KMI support (or anyone attempting recovery). Everything below is observed fact except where marked as hypothesis. ## Device | | | |---|---| | Product | QuNexus (RED), USB VID `0x1F38` PID `0x0018`, bcdDevice `0x0200` | | Bootloader version | 1.1.0 (reported by identity request **before** the flash) | | Application version before flash | 2.2.1 | | Host | macOS (Apple silicon), CoreMIDI | ## What was flashed A modified copy of `QuNexus_Firmware_v2.2.1-cs512.syx` (the image shipped in the qunexus-qt6 editor's Qt resources, `resources.qrc:83`). Sent with `SendSysEx` v0.10.0 in **raw send mode** (`-n -f `) after `QunexusEnterBootloader.syx`, using the default transfer settings: `-cs 512 -cd 100 -pd 500`. **Open question:** it is not known whether the transfer ran to completion. Per `SendSysEx --help`, for multi-message (chunked) files an identity-reply handshake is performed between every chunk, with `-cd` (default **100 ms**) as the timeout, "aborting the transfer otherwise". The console output was not retained. If the transfer aborted partway, the application region is **partially written**, which would be an additional and independent defect on top of the modification below. ## The modification Two edits relative to the stock v2.2.1 image (which spans `0x2400`–`0xF806`): 1. `0xDF28`: `LCALL 0xA57B` → `LCALL 0xE8F2` (2 bytes changed, at `0xDF29`) 2. `0xE8F2`: a new 23-byte routine: ```asm E8F2: 12 a5 7b LCALL 0xA57B ; original USB-MIDI cable routing E8F5: 90 0f 9b MOV DPTR,#0x0F9B ; the 4-byte USB-MIDI event buffer E8F8: e0 MOVX A,@DPTR E8F9: 54 f0 ANL A,#0F0h ; isolate the cable number E8FB: 70 0b JNZ 0xE908 ; not cable 0 -> done E8FD: e0 MOVX A,@DPTR E8FE: 44 20 ORL A,#020h ; retag cable 0 as cable 2 E900: f0 MOVX @DPTR,A E901: 7e 0f MOV R6,#00Fh E903: 7f 9b MOV R7,#09Bh E905: 12 a5 7b LCALL 0xA57B ; route again, into the USB-3/CV ring E908: 22 RET ``` Intent: have MIDI arriving on USB port 1 also reach the CV engine, since `CV_Out_Source` only offers Expander / USB 3. **The mistake:** `0xE8F2` lies in a 270-byte address range (`0xE8F2`–`0xE9FF`) that **no hex record in the stock image covers**. A new record was added for it. Whether the bootloader erases a flash page it otherwise never writes was never verified. ## Symptoms after flashing - Device enumerates normally and repeatedly: correct VID/PID, correct product string, correct MIDI port names ("QuNexus Control Surface" / "Expander" / "CV"). So USB init, the descriptor tables, and the USB interrupt path are intact. - **No response to any MIDI input.** A universal identity request (`F0 7E 7F 06 01 F7`) gets no reply at an 8-second timeout. - `QunexusEnterBootloader.syx` has no effect, sent to any of the three ports. - **No CV output**, from USB or from the local keys. ## Diagnostics performed | Check | Result | |---|---| | USB presence / PID (`ioreg`) | present, PID `0x0018` = application | | Identity request, Control Surface port | no reply (8 s) | | Bootloader-entry SysEx to all 3 ports | no state change | | Power-on bootloader window | **none** — kernel log shows exactly one enumeration per attach, always PID `0018`; the bootloader never appears on the bus | | USB vendor control request path | none — the EP0 handler at `0x7873` dispatches only Class (`0x20`) and Standard requests | | DIN MIDI in | separate UART path exists (`0xA90D` reads `SBUF0` → `0xE2AA` → 24-byte ring at `0x0F31`), but no Expander hardware available to test | ## Most probable mechanism: a second page erase destroyed 236 bytes of code `0xE8F2` lies in the 512-byte flash page `0xE800`–`0xE9FF`. The absence of a stock record for `0xE8F2`–`0xE9FF` meant only that the linker placed nothing there — **not** that the page was unused. In the stock image, `0xE800`–`0xE8F1` (the same page, just below the stub address) contains: - 236 bytes of real code - 27 branch targets, 19 of them functions with live callers - 40+ call sites spread across the entire firmware (`0xE888` from 9 sites, `0xE893` from 5, `0xE8D5` from 4, `0xE834` from 4) — the profile of compiler runtime helpers The added record was appended as a **new SysEx message immediately before the EOF record**, i.e. last in the transfer, long after the bootloader had already erased page `0xE800` and programmed those 236 bytes. To program into that page again the bootloader must erase all 512 bytes of it. That erase would have destroyed the 236 bytes of legitimate code, leaving only the 23 stub bytes. This accounts for every observed symptom simultaneously: calls to any of those 19 addresses now land in erased flash (`0xFF` = `MOV R7,A`) and run forward into unrelated code, so MIDI input never completes and the main loop derails, while interrupt-driven USB enumeration continues to work. This has not been confirmed by reading the device's flash back, which is not possible without C2 access. A transfer aborted by the 100 ms inter-chunk handshake (see "Open question" above) would be an additional, independent cause of missing data. **Implication for recovery:** a full reflash of `0x2400`–`0xF806` from the stock image restores everything; no permanent damage is expected. ## C2 flash read — confirmed actual state (2026-08-17) The hypothesis above ("a second page erase destroyed 236 bytes of code") is **DISPROVEN by reading the device's flash back over C2**. An RP2040 was wired to the EFM8 (GP2→C2CK/pin 13, GP3→C2D/pin 14) and a read-only C2 flash reader was built (`firmware-tools/c2probe/`, Pico SDK, implements only FPDAT Block Read 0x06 — no erase/write path). DEVICEID=`0x28` (EFM8UB2) confirmed. The full 64 KB was dumped to `firmware-tools/out/qunexus_device_dump.bin` and diffed against the stock image. Findings: | Address range | Stock | Device | Verdict | |---|---|---|---| | `0xE800`–`0xE8EB` (236 B of code) | real code | **identical** to stock | **INTACT — page was never erased** | | `0xE8F2`–`0xE908` (23-B stub site) | `0xFF` (gap) | `0xFF` | **stub never programmed** | | `0xDF29`–`0xDF2A` (LCALL target) | `A5 7B` | `E8 F2` | retarget **was** written | | `0xFBFF` (lock byte) | — | `0xFF` | unlocked | | `0xFC00`–`0xFFFF` (top 1 KB) | — | read fails | reserved/lock page (not app) | So the actual mechanism is the **transfer-abort** branch of the "Open question", not the page-erase branch: the `0xDF29` retarget sits in an existing record early in the transfer and was programmed; the appended `0xE8F2` record was *last* and was never sent before the 100 ms inter-chunk handshake aborted the transfer. Page `0xE800` was therefore never erased, the 236 bytes survived, and the stub was never written. The call at `0xDF28` now does `LCALL 0xE8F2`, which lands in `0xFF` flash (`MOV R7,A` then runs forward into `0xFF`...) and derails the USB-MIDI dispatcher `0xDF05` for every event on every USB port — matching "no response to any MIDI input" while interrupt-driven USB enumeration survives. Other differences from the stock image are **not** damage: - `0xF000`–`0xF806`: user configuration (MIDI routing / per-channel / CV settings). The stock `.syx` carries factory defaults; this unit was personalized. The per-channel blocks at `0xF200`/`0xF400`/`0xF600` carry channel-index bytes `01`/`02`/`03` and differ only in config values (`64 0F 14`→`64 01 0A`, etc.). Expected on a used device. - `0xEE00`–`0xEF5A`: ~348 bytes in a stock **gap** (no record covers it). The bootloader only erases pages it has records for, so data written here by an earlier image persists across reflashes. Harmless — stock code does not reference it. **This revises the recovery outlook materially.** The "no software recovery path" conclusion below was premised on `0xE8C5` (UART MIDI byte processor) and `0xE8E6` (`LJMP 0x0000`, the bootloader-entry jump) being erased. **They are not erased — both are intact** (inside the surviving 236 bytes). The only thing broken is the single `LCALL` at `0xDF28`. Consequently: - **Minimal C2 fix:** erase page `0xDF00`–`0xDFFF` (app region — **not** the bootloader) and reprogram it with the stock bytes, restoring `0xDF29`=`A5 7B`. That alone un-breaks the USB-MIDI receive path. - **Then normal recovery works:** with MIDI input restored, the SysEx bootloader-entry command reaches `0xB48D` → `0xE744` → `0xE8E6` (`LJMP 0x0000`) → bootloader, and the stock image can be reflashed over USB exactly as before the incident. No permanent damage; the bootloader region `0x0000`–`0x23FF` was never touched and must still not be erased. Either way only `0x2400`–`0xF806` should be programmed; no mass erase. ## C2 flash write — recovery executed (2026-08-17) The minimal C2 fix above was performed. The c2probe firmware was extended with guarded Page Erase (0x08) and Block Write (0x07) commands, hard-limited to the app region `0x2400`–`0xF9FF` (bootloader `0x0000`–`0x23FF` and lock/reserved `0xFA00`+ are refused; no Device Erase / mass-erase command exists at all). The host script `c2probe/reflash_page.py` ran the verified sequence: 1. `piinit` (halt core) + `wsetup` (AN127 Table 3.6 SFR setup: FLSCL=0x90, VDM0CN=0x80, CLKSEL=0x03, RSTSRC=0x02). 2. Read page `0xDE00`–`0xDFFF` (the 512-byte flash page containing `0xDF28`; note the page base is `0xDE00`, not `0xDF00` — pages are 512-byte aligned). 3. Page Erase page `0x6F` → verified all 512 bytes read back `0xFF`. 4. Block Write the stock bytes for `0xDE00` and `0xDF00` (256 + 256). 5. Read back and verify byte-identical to stock — **MATCH**. 6. C2 reset → EFM8 reboots into the restored app. Result: `0xDF28` = `12 A5 7B` (`LCALL 0xA57B`), the stock dispatcher call. The bad retarget to `0xE8F2` is gone. The bootloader region was never written or erased. The USB-MIDI receive path is intact again, so SysEx / bootloader entry should work; from here a full factory reflash of v2.2.1 over USB is possible but not required — only the one corrupted `LCALL` ever needed fixing. Two firmware bugs found and fixed during the write: (a) Page Erase step 8 must poll OutReady until **set** (then read the `0x0D` page-number ack), not until clear — AN127's "poll until clear" wording is misleading; the EFM8UB2 presents the ack byte with OutReady set. (b) The CDC command line buffer was 160 bytes, truncating the 522-char `bw` command; enlarged to 1024. ## USB-1→CV patch applied via C2 (2026-08-17) With the device recovered, the corrected patch (`patch_usb1_to_cv.py`, `STUB_ADDR=0x8126`) was applied surgically over C2 by `c2probe/patch_c2.py`: erase+reprogram page `0x8000` (23-byte stub into verified `0xFF` padding at `0x8126`) **first**, then page `0xDE00` (retarget `0xDF28`→`LCALL 0x8126`) **second** — stub-page-first ordering means a mid-failure never leaves a dangling retarget. Both pages read back byte-exact, and only the intended bytes changed. The device was reset and **functionally verified on an oscilloscope**: MIDI sent to USB port 1 (Control Surface) now drives the CV gate and 1 V/octave pitch outputs, which it never did before. User personalization at `0xF000`–`0xF806` was preserved (only two pages touched). The patch is fully reversible over C2 (`reflash_page.py` restores `0xDE00` to stock; the stub page can be restored the same way). ## The application has no alternative bootloader-entry path Established by recursive-descent disassembly of the whole image: - The only bootloader-entry trigger is the SysEx command. Handler at `0xB48D` checks category `0x11` (`SYX_SYSTEM`) and command `0x00` (`SYX_SYS_RESET`) at `0xB499`/`0xB49E`, then calls `0xE744`. - `0xE744` disables interrupts and falls through to `0xE8E6`, which is `LJMP 0x0000` — the only `LJMP 0x0000` in the entire 54 KB image. - `0xE744` has exactly one caller (`0xB4A6`); `0xE8E6` has exactly one referrer (`0xE751`, inside `0xE744`). - There is **no** software reset anywhere: every `RSTSRC` write is VDD-monitor init (`RSTSRC = 0x02` following `VDM0CN = 0x80`). No watchdog-forced reset path either. - No key/button code reaches `0xB48D`; its entire caller chain is MIDI message processing. **Two of the destroyed functions are exactly the ones needed for recovery**, both inside the erased range `0xE800`–`0xE8F1`: | Address | Function | Consequence | |---|---|---| | `0xE8C5` | UART/DIN MIDI byte processor, called from the main service loop at `0xE045` | Expander-port (`J2`) MIDI input is dead, so the enter-bootloader SysEx cannot be delivered over the UART either | | `0xE8E6` | `LJMP 0x0000` — the bootloader entry jump | bootloader entry is dead even if a command were received | The UART is configured for MIDI (`SCON0 = 0x50`, Timer 1 mode 2, reload `0xC0` → 31250 baud at 48 MHz), and the receive ISR at `0xA90D` reads `SBUF0` into a 24-byte ring at `0x0F31` via `0xE2AA`; the ring is drained at `0xE042`/`0xE47E` and each byte handed to the now-missing `0xE8C5`. Consequently **no software recovery path exists**: not USB MIDI, not DIN MIDI via the Expander port, not a key/button combination, not a USB vendor request, and not a power-on bootloader window (verified by kernel USB logs — exactly one enumeration per attach, always PID `0x0018`). Note also that the application does `ACALL 0x21D4` at `0x265E`, i.e. it calls a routine inside the bootloader region, so the bootloader exposes an API to the application. ## What is needed **The key question for KMI:** does the bootloader at `0x0000`–`0x23FF` check a button/key at power-on, or offer any entry path that does not require the application to be functional? That region is not present in any `.syx` file, so it could not be analysed here. Failing that, either: 1. **EFM8 factory bootloader** (AN945). The MCU is an **EFM8UB20F64G in QFP48**. Per the EFM8UB2 data sheet (Rev 1.3) Table 3.3, the 48-pin package enters bootload mode by holding **`P3.7` (QFP48 pin 23)** low at reset; the bootloader lives in the last three pages of code flash and runs after *any* reset when the Bootloader Signature Byte (the byte before the Lock Byte) is `0xA5`. The application image ends at `0xF806` and so never overlaps that region — but whether KMI erased the factory bootloader in production is unknown. For reference, QFP48 pin 13 = `RST`/`C2CK`, pin 14 = `C2D`. 2. **Direct reflash over the C2 debug interface.** In either case only `0x2400`–`0xF806` should be programmed, and no mass erase should be performed: that would destroy KMI's bootloader and/or the factory bootloader, neither of which is recoverable from available files. ## Files available In `firmware-tools/out/`: | File | Contents | |---|---| | `QuNexus_Firmware_v2.2.1.bin` | stock v2.2.1 application image, flat binary, base `0x2400`, 54 279 bytes | | `QuNexus_Firmware_v2.2.1.hex` | the same as standard ASCII Intel HEX | | `QuNexus_Firmware_v2.2.1-cs512-usb1cv-v2.syx` | corrected patch (stub relocated to `0x8126`, inside an existing stock record) — **not** the image that was flashed | The image that was flashed is reproducible from the stock `.syx` with `patch_usb1_to_cv.py` by setting `STUB_ADDR = 0xE8F2` and using the add-a-new-record path. Note that these images cover only `0x2400`–`0xF806`. The bootloader region `0x0000`–`0x23FF` is not present in any `.syx` and must not be erased.