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.
15 KiB
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):
0xDF28:LCALL 0xA57B→LCALL 0xE8F2(2 bytes changed, at0xDF29)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.syxhas 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 (
0xE888from 9 sites,0xE893from 5,0xE8D5from 4,0xE834from 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.syxcarries factory defaults; this unit was personalized. The per-channel blocks at0xF200/0xF400/0xF600carry channel-index bytes01/02/03and 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, restoring0xDF29=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 region0x0000–0x23FFwas 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:
piinit(halt core) +wsetup(AN127 Table 3.6 SFR setup: FLSCL=0x90, VDM0CN=0x80, CLKSEL=0x03, RSTSRC=0x02).- Read page
0xDE00–0xDFFF(the 512-byte flash page containing0xDF28; note the page base is0xDE00, not0xDF00— pages are 512-byte aligned). - Page Erase page
0x6F→ verified all 512 bytes read back0xFF. - Block Write the stock bytes for
0xDE00and0xDF00(256 + 256). - Read back and verify byte-identical to stock — MATCH.
- 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
0xB48Dchecks category0x11(SYX_SYSTEM) and command0x00(SYX_SYS_RESET) at0xB499/0xB49E, then calls0xE744. 0xE744disables interrupts and falls through to0xE8E6, which isLJMP 0x0000— the onlyLJMP 0x0000in the entire 54 KB image.0xE744has exactly one caller (0xB4A6);0xE8E6has exactly one referrer (0xE751, inside0xE744).- There is no software reset anywhere: every
RSTSRCwrite is VDD-monitor init (RSTSRC = 0x02followingVDM0CN = 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:
- 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) is0xA5. The application image ends at0xF806and 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. - 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.