Files
qunexus-firmware/RECOVERY.md
T
nils 514983c158 Add tooling + EFM8UB20 QFP48 pinout doc
Make the repo self-contained (no dependency on the parent firmware-tools/
checkout): copy in the patch/disasm/syx tools and the c2probe RP2040 C2
flash-reader/patcher firmware.

- patch_usb1_to_cv.py : byte-level USB-1->CV patch (imported by roundtrip.py)
- d8051.py             : standalone 8051 disassembler
- syx_extract.py       : SysEx extractor
- c2probe/             : RP2040 C2 flash reader + host scripts
  (c2probe.c, cdc/dump_flash/patch_c2/reflash_page/verify.py, CMake build)
- RECOVERY.md          : C2 flash recovery procedure
- EFM8UB20_PINOUT.md   : reverse-engineered QFP48 pinout + firmware pin usage
- roundtrip.py         : import local patch_usb1_to_cv (parent as fallback)
- .gitignore           : exclude c2probe/.venv, c2probe/build

Verified: stock + patched round-trips still re-assemble byte-identical.
2026-08-17 23:35:09 +02:00

15 KiB
Raw Blame History

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 <port> -f <file>) 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:
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.