Files
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

293 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```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.