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.
This commit is contained in:
2026-08-17 23:35:09 +02:00
parent 3340cafb46
commit 514983c158
16 changed files with 2323 additions and 1 deletions
+3
View File
@@ -2,3 +2,6 @@ __pycache__/
*.lst *.lst
*.sym *.sym
*.asm.bak *.asm.bak
# c2probe (RP2040) build artifacts
c2probe/.venv/
c2probe/build/
+156
View File
@@ -0,0 +1,156 @@
# EFM8UB20F64G (QFP48) — reverse-engineered pinout & firmware pin usage
QuNexus main MCU = **EFM8UB20F64G-B-QFP48** (48-pin TQFP). App firmware v2.2.1.
> There is **no QFN48** variant of the EFM8UB20F64G — the datasheet lists only
> QFP48, QFP32, and QFN32. The 48-pin part is therefore QFP48, and the QFP48
> ADC channel map is the one that matches the firmware.
All findings below are derived from the disassembly (`qunexus_v2.2.1.asm`) and
verified against the SiLabs header (SFR addresses) and the EFM8UB2 reference
manual (ADC channel→pin map). The pin *configuration* and *which pins are
actively toggled* are directly proven from the code; the identity of the
external device on the P2/P3/P4 bus is inferred from the bus structure.
## Port configuration
The entire crossbar/port setup is one block at `0xde6f`, written once
(firmware-wide — XBR / MDOUT / MDIN / SKIP are never written elsewhere):
| Register | Value | Meaning |
|----------|-------|---------|
| XBR0 | 0x01 | **UART0 only** — no SPI, no SMBus, no comparators routed |
| XBR1 | 0x43 | crossbar enabled (XBARE = 1) |
| XBR2 | 0x00 | default, never written — UART1/SMB1 off |
| P0SKIP | 0xCF | skip P0.0-3,6,7 → leave P0.4/P0.5 for UART0 |
| P1SKIP | 0x03 | skip P1.0/P1.1 |
| P2SKIP | 0x00 | default, never written |
| P3SKIP | 0x00 | default (P3SKIP = 0xDF, never written) |
| P0MDIN | 0x30 | P0.4/P0.5 digital; P0.0-3,6,7 **analog** |
| P1MDIN | 0x3F | P1.0-5 digital; P1.6/P1.7 **analog** |
| P2/P3/P4MDIN | 0xFF | all digital |
| P0MDOUT | 0x10 | P0.4 push-pull (TX); rest open-drain |
| P1MDOUT | 0x3F | P1.0-5 push-pull; P1.6/7 open-drain |
| P2MDOUT | 0xFF | all push-pull |
| P3MDOUT | 0xF0 | P3.4-7 push-pull; **P3.0-3 open-drain** |
| P4MDOUT | 0xFF | all push-pull |
> The P4 port latch is SFR **0xC7** — not 0xC0 (0xC0 is SMB0CN0, the SMBus
> control register). Easy to misidentify.
## Peripherals actually used
- **UART0** → P0.4 (TX) / P0.5 (RX) = the 5-pin DIN MIDI port. (USB-MIDI is a
separate class engine over D+/D−, not this UART.)
- **ADC0**: `AMX0P = 0x11` → **P0.3** (ADC0P.17 on QFP48), `AMX0N = 0x1f`
(GND, single-ended), `ADC0CN0 = 0x02` (enabled). AMX0P is written **exactly
once**, so the firmware reads a single fixed ADC channel. ADC0L/H (0xBD/0xBE)
are read at `0x8a37`, `0xa838`, `0xcadd`. → An **external analog mux**
(steered by the digital scan bus) feeds many sensors into P0.3.
- **No SPI0, no SMB0, no hardware EMIF** — none of their config registers are
ever written. The parallel bus below is **bit-banged**.
## Runtime GPIO — a bit-banged parallel bus to an external engine
The EFM8 is not driving the LEDs/touch directly; it talks to an external chip
(CPLD / expander / LED+touch controller) over a hand-strobed parallel bus, and
advances a select matrix on a timer tick:
- **P2.0–P2.7** (push-pull): written from a Timer ISR — `mov P2,a` at `0xc537`
+ `setb TR2`, ends `reti`. The `rlc a` / `djnz` / `cpl a` loop builds a
walking one-hot pattern → **scan / select bus**.
- **P3.0–P3.3** (open-drain): toggled with `orl/anl P3,#0x0f` (`0xc942`,
`0xc997`, `0xca89`, `0xcad1`, `0xcad8`, `0xd774`) → **control strobes**.
- **P4.0–P4.7** (push-pull, SFR 0xC7): `mov P4,a` (`0xc950`, `0xc999`, `0xd778`),
always paired with a P3 strobe → **8-bit data bus**.
- **P1.5** (push-pull): toggled (`clr P1.5` @ `0x860e`, `mov` @ `0xe38f`) →
GPIO output, function unknown. P1.0/P1.1 are skipped = reserved GPIO.
## Full QFP48 pin table (firmware function)
| Pin | Port | Firmware function | Used? |
|-----|------|--------------------|-------|
| 1 | P0.5 | UART0 RX — MIDI In | ✓ |
| 2 | P0.4 | UART0 TX — MIDI Out | ✓ |
| 3 | P0.3 | **ADC input** (single channel, ext. mux output) | ✓ |
| 4 | P0.2 | analog, no ADC/CMP fn | ○ unused |
| 5 | P0.1 | analog, no ADC/CMP fn | ○ unused |
| 6 | P0.0 | analog, no ADC/CMP fn | ○ unused |
| 7 | GND | ground | — |
| 8 | D+ | USB (USB-MIDI class) | ✓ |
| 9 | D− | USB | ✓ |
| 10 | VDD | supply / reg output | — |
| 11 | VREGIN | 5V reg input | — |
| 12 | VBUS | USB VBUS sense | ✓ |
| 13 | RST/C2CK | reset / C2 flash clock | ✓ |
| 14 | C2D | C2 flash data | ✓ |
| 15 | P4.7 | data bus D7 | ✓ |
| 16 | P4.6 | data bus D6 | ✓ |
| 17 | P4.5 | data bus D5 | ✓ |
| 18 | P4.4 | data bus D4 | ✓ |
| 19 | P4.3 | data bus D3 | ✓ |
| 20 | P4.2 | data bus D2 | ✓ |
| 21 | P4.1 | data bus D1 | ✓ |
| 22 | P4.0 | data bus D0 | ✓ |
| 23 | P3.7 | push-pull out, never written | ○ static |
| 24 | P3.6 | push-pull out, referenced once | ○ ~unused |
| 25 | P3.5 | push-pull out, never written | ○ static |
| 26 | P3.4 | push-pull out, never written | ○ static |
| 27 | P3.3 | open-drain **strobe** | ✓ |
| 28 | P3.2 | open-drain **strobe** | ✓ |
| 29 | P3.1 | open-drain **strobe** | ✓ |
| 30 | P3.0 | open-drain **strobe** | ✓ |
| 31 | P2.7 | **scan/select** (timer ISR) | ✓ |
| 32 | P2.6 | scan/select | ✓ |
| 33 | P2.5 | scan/select | ✓ |
| 34 | P2.4 | scan/select | ✓ |
| 35 | P2.3 | scan/select | ✓ |
| 36 | P2.2 | scan/select | ✓ |
| 37 | P2.1 | scan/select | ✓ |
| 38 | P2.0 | scan/select | ✓ |
| 39 | P1.7 | analog (EMIF /WR not used) | ○ unused |
| 40 | P1.6 | analog (EMIF /RD not used) | ○ unused |
| 41 | P1.5 | GPIO output (toggled) | ✓ |
| 42 | P1.4 | CNVSTR/GPIO (ADC is SW-triggered) | ○ ~unused |
| 43 | P1.3 | push-pull GPIO | ○ ~unused |
| 44 | P1.2 | push-pull GPIO | ○ ~unused |
| 45 | P1.1 | skipped GPIO | ○ ~unused |
| 46 | P1.0 | skipped GPIO | ○ ~unused |
| 47 | P0.7 | XTAL2 — internal oscillator | ○ unused |
| 48 | P0.6 | XTAL1 — internal oscillator | ○ unused |
Legend: ✓ actively driven/read by firmware · ○ configured but no active drive
found (likely unused/static) · — power/USB/debug (hardware).
## Architecture summary
The EFM8UB20 is essentially a **USB-MIDI class engine + DIN-MIDI UART + bus
master**, not the thing doing the LED/touch work:
- **USB** (pins 8/9/12) = USB-MIDI class traffic.
- **UART0** (pins 1/2) = 5-pin DIN MIDI in/out.
- **ADC** (pin 3, P0.3) = one analog channel reading an external analog mux.
- **Bit-banged parallel bus** to an external LED/touch engine: **P4 = 8-bit
data** (pins 15–22), **P3.0–3 = strobes** (pins 27–30), **P2 = scan/select**
(pins 31–38, advanced by a timer ISR).
- **C2** (pins 13/14) = how we flash/read it.
Used pins: **1, 2, 3** (MIDI + ADC), **8, 9, 11, 12** (USB), **13, 14** (C2
debug), **15–22** (P4 data), **27–30** (P3 strobes), **31–38** (P2 scan),
**41** (P1.5 GPIO). Everything else is configured but shows no active drive.
## Caveat
The "external LED/touch engine" on the P2/P3/P4 bus is an inference from the
bus structure and the walking-one scan pattern — the firmware's driving of the
bus is directly visible, but what sits on the other end can only be confirmed
from board photos or a schematic.
## Sources
- EFM8UB2 Reference Manual, Table 12.1 (AMX0P ADC channel→pin map):
https://www.silabs.com/documents/public/reference-manuals/efm8ub2-rm.pdf
- EFM8UB20F64G-B-QFP48 datasheet, Table 6.1 (QFP48 pin definitions):
https://resources.ampheo.com/static/datasheets/silicon-labs/efm8ub20f64g-b-qfp48.pdf
- si_efm8ub2_defs.h (SFR address verification):
https://www.keil.com/dd/docs/c51/silabs/efm8ub2/inc/si_efm8ub2_defs.h
+292
View File
@@ -0,0 +1,292 @@
# 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.
+18
View File
@@ -0,0 +1,18 @@
cmake_minimum_required(VERSION 3.13)
include(pico_sdk_import.cmake)
project(c2probe C CXX)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
pico_sdk_init()
add_executable(c2probe c2probe.c lock_stubs.c)
target_link_libraries(c2probe pico_stdlib hardware_gpio)
pico_enable_stdio_usb(c2probe 1)
pico_enable_stdio_uart(c2probe 0)
# Generate c2probe.uf2 (and .bin/.hex/.dis/.map) next to the executable.
pico_add_extra_outputs(c2probe)
+547
View File
@@ -0,0 +1,547 @@
// c2probe -- RP2040 bit-bang programmer for the Silicon Labs C2 interface.
//
// Reads code flash on EFM8UB2 (and C8051F) parts. READ-ONLY by design: it
// implements only the C2 frame primitives, register read/write, PI init, and
// the FPDAT Block Read (0x06) command. There is no erase/write/lock path and
// no Device Erase arming, so it cannot damage flash.
//
// Wiring: GP2 = C2CK (target RST/C2CK), GP3 = C2D. 3.3V, direct.
// Protocol reference: Silicon Labs AN127 Rev 1.4.
//
// Host command interface over USB CDC (line-based, LF-terminated):
// hello
// speed <us> C2CK half-period in us (low=high=<us>), default 1
// fpdat <hex> set FPDAT register address (default 0xAD EFM8UB2)
// pins report pin assignment
// reset C2 device reset (C2CK low >=20us)
// status Address Read -> status byte (FLBusy/EError/InBusy/OutReady)
// rdreg <hexaddr> Address Write + Data Read -> register value
// wrreg <hexaddr> <hex> Address Write + Data Write
// id read DEVICEID(0x00) and REVID(0x01)
// piinit full PI init (reset + FPCTL 0x02,0x04,0x01 + 20ms)
// rawaw <hex> raw Address Write
// rawar raw Address Read (-> status)
// rawdw <hex> raw Data Write
// rawdr raw Data Read
// read <hexaddr> <len> FPDAT Block Read, len 1..256 (0=256) -> hex bytes
// dump <hexstart> <hexend> loop read over range, one line per block
//
// Replies: "ok ...", "data <addr> <n> <hex>", "err <code>", "stat <hex>", etc.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "pico/stdlib.h"
#include "hardware/gpio.h"
#include "hardware/sync.h"
#include "pico/bootrom.h"
// C2CK / C2D pin numbers are runtime-configurable via the `pins` command so the
// host can try both wiring orientations without reflashing. Default: GP2=C2CK,
// GP3=C2D.
static int C2CK = 2;
static int C2D = 3;
// C2CK low/high time for a bit strobe, in microseconds. Must satisfy
// 20ns <= tCL < 5000ns (else a reset is triggered). 1us is safe.
static uint32_t half_us = 1;
// FPDAT register address (device-specific). EFM8UB2 = 0xAD. Settable at runtime.
static uint8_t fpdat_addr = 0xAD;
// ----------------------------------------------------------------- low-level
static inline void c2ck_set(int v) { gpio_put(C2CK, v); }
static inline void c2d_drive(int v) {
gpio_set_dir(C2D, GPIO_OUT);
gpio_put(C2D, v ? 1 : 0);
}
static inline void c2d_release(void) {
gpio_set_dir(C2D, GPIO_IN); // high-Z, no pull
}
static inline int c2d_get(void) { return gpio_get(C2D) ? 1 : 0; }
// One C2CK strobe: high->low (tCL)->high (tCH). IRQs disabled across the low
// window so the low time stays under the 5us reset threshold even if a USB
// IRQ fires. Used when the master is driving C2D (write bits / start / stop).
static void strobe_write(void) {
uint32_t save = save_and_disable_interrupts();
gpio_put(C2CK, 0);
busy_wait_us(half_us);
gpio_put(C2CK, 1);
restore_interrupts(save);
busy_wait_us(half_us);
}
// One C2CK strobe that reads a slave-driven bit. C2D is released (input) and
// sampled after the rising edge + tDV. IRQs disabled across low+sample.
static int strobe_read(void) {
c2d_release();
uint32_t save = save_and_disable_interrupts();
gpio_put(C2CK, 0);
busy_wait_us(half_us);
gpio_put(C2CK, 1);
busy_wait_us(half_us); // tDV ~20ns; half_us gives margin
int v = gpio_get(C2D) ? 1 : 0;
restore_interrupts(save);
return v;
}
static void c2_start(void) { c2d_drive(1); strobe_write(); }
static void c2_stop(void) { c2d_drive(1); strobe_write(); c2d_release(); }
static void c2_write_bit(int b) {
c2d_drive(b ? 1 : 0);
strobe_write();
}
// ----------------------------------------------------------------- frames
// Address Write (INS=11b): START, INS(1,1), ADDRESS 8 bits LSB-first, STOP.
static void c2_addr_write(uint8_t addr) {
c2_start();
c2_write_bit(1); c2_write_bit(1); // INS = 11b
for (int i = 0; i < 8; i++) c2_write_bit((addr >> i) & 1);
c2_stop();
}
// Address Read (INS=10b): START, INS(0,1), release, read 8 bits = status, STOP.
static uint8_t c2_addr_read(void) {
c2_start();
c2_write_bit(0); c2_write_bit(1); // INS = 10b
uint8_t v = 0;
for (int i = 0; i < 8; i++) v |= (strobe_read() << i);
c2_stop();
return v;
}
// Data Write (INS=01b): START, INS(1,0), LENGTH(00=1B), DATA 8, WAIT(0s then 1), STOP.
// Returns 0 on success, -1 on WAIT timeout.
static int c2_data_write(uint8_t val) {
c2_start();
c2_write_bit(1); c2_write_bit(0); // INS = 01b
c2_write_bit(0); c2_write_bit(0); // LENGTH = 00 (1 byte)
for (int i = 0; i < 8; i++) c2_write_bit((val >> i) & 1);
// WAIT: slave releases 0s then a 1; clock and read until 1 seen.
int waited = 0;
while (strobe_read() == 0) {
if (++waited > 8192) { c2_stop(); return -1; }
}
c2_stop();
return 0;
}
// Data Read (INS=00b): START, INS(0,0), LENGTH(00), WAIT(0s then 1), DATA 8, STOP.
// Returns 0 on success, -1 on WAIT timeout.
static int c2_data_read(uint8_t *out) {
c2_start();
c2_write_bit(0); c2_write_bit(0); // INS = 00b
c2_write_bit(0); c2_write_bit(0); // LENGTH = 00 (1 byte)
int waited = 0;
while (strobe_read() == 0) { // WAIT
if (++waited > 8192) { c2_stop(); return -1; }
}
uint8_t v = 0;
for (int i = 0; i < 8; i++) v |= (strobe_read() << i); // DATA
c2_stop();
*out = v;
return 0;
}
// ----------------------------------------------------------------- helpers
static uint8_t c2_status(void) { return c2_addr_read(); }
static uint8_t c2_reg_read(uint8_t addr) { c2_addr_write(addr); uint8_t v = 0; c2_data_read(&v); return v; }
static int c2_reg_write(uint8_t addr, uint8_t val) { c2_addr_write(addr); return c2_data_write(val); }
static int c2_poll_inbusy(void) {
for (long i = 0; i < 200000L; i++) {
uint8_t s = c2_status();
if (!((s >> 1) & 1)) return 0; // InBusy cleared
}
return -1;
}
static int c2_poll_outready(void) {
for (long i = 0; i < 200000L; i++) {
uint8_t s = c2_status();
if (s & 1) return 0; // OutReady set
}
return -1;
}
static void c2_reset(void) {
// Device reset: C2CK low >= 20us, high, wait >= 2us. Also leaves C2CK high.
c2d_release();
c2ck_set(0);
busy_wait_us(50); // tRD >= 20us
c2ck_set(1);
busy_wait_us(5); // tSD >= 2us
}
static void c2_pi_init(void) {
c2_reset();
c2_reg_write(0x02, 0x02); // FPCTL <- 0x02 (enable)
c2_reg_write(0x02, 0x04); // FPCTL <- 0x04 (halt core)
c2_reg_write(0x02, 0x01); // FPCTL <- 0x01
busy_wait_us(20000); // >= 20ms
}
// Block Read: read `len` bytes (1..256, 0 => 256) from flash `addr`.
// Returns number of bytes read, or negative error code.
static int c2_block_read(uint16_t addr, uint8_t len, uint8_t *buf) {
int n = len ? (int)len : 256;
uint8_t st;
c2_addr_write(fpdat_addr);
if (c2_data_write(0x06)) return -10; // Block Read cmd
if (c2_poll_inbusy()) return -1;
if (c2_poll_outready()) return -2;
if (c2_data_read(&st)) return -3;
if (st != 0x0D) return -4; // status not OK
if (c2_data_write((addr >> 8) & 0xFF)) return -11; // addr high
if (c2_poll_inbusy()) return -5;
if (c2_data_write(addr & 0xFF)) return -12; // addr low
if (c2_poll_inbusy()) return -6;
if (c2_data_write(len)) return -13; // length code (0=256)
if (c2_poll_inbusy()) return -7;
// Block-ack status: after addr+len the PI emits a second 0x0D before the
// data stream (confirmed vs ec2drv: read_port(..., cmd[3]+1) "// +1 for
// 0x0d"). Consume and discard it, else it lands as buf[0] and the whole
// block reads shifted by one (off-by-one vs stock).
if (c2_poll_outready()) return -14;
if (c2_data_read(&st)) return -15;
if (st != 0x0D) return -16;
for (int k = 0; k < n; k++) {
if (c2_poll_outready()) return -8 - k;
uint8_t b;
if (c2_data_read(&b)) return -200 - k;
buf[k] = b;
}
return n;
}
// ----------------------------------------------------------------- write/erase
//
// SAFETY: the bootloader lives at 0x0000-0x23FF and the flash lock / reserved
// area at 0xFA00-0xFFFF. These are UNRECOVERABLE if erased (no image available).
// Every erase/write below is hard-limited to the application region
// 0x2400-0xF9FF. There is no Device Erase / mass-erase command anywhere in
// this firmware. The guards are both compile-time (constants) and runtime.
#define APP_LO 0x2400 // first application address (inclusive)
#define APP_HI 0xFA00 // first protected address (exclusive)
#define PAGE_BYTES 512
#define APP_PAGE_LO (APP_LO / PAGE_BYTES) // 0x12
#define APP_PAGE_HI ((APP_HI / PAGE_BYTES) - 1) // 0x7C (0xF800 page is last app page)
static int app_addr_ok(uint32_t a, uint32_t len) {
return a >= APP_LO && (a + len) <= APP_HI && len > 0;
}
static int app_page_ok(uint8_t page) {
return page >= APP_PAGE_LO && page <= APP_PAGE_HI;
}
// Poll until OutReady CLEARS (used by Page Erase step 8).
static int c2_poll_outready_clear(void) {
for (long i = 0; i < 400000L; i++) { // erase can take longer
uint8_t s = c2_status();
if (!(s & 1)) return 0; // OutReady cleared
}
return -1;
}
// Direct Write (FPDAT 0x0A): write one SFR. Used for the pre-flash VDD-monitor
// / flash-timing / clock setup. No address guard (SFRs are 0x80-0xFF by
// definition; this only touches the four documented EFM8UB2 setup registers).
static int c2_direct_write(uint8_t sfr, uint8_t val) {
c2_addr_write(fpdat_addr);
if (c2_data_write(0x0A)) return -10; // Direct Write cmd
if (c2_poll_inbusy()) return -1;
if (c2_poll_outready()) return -2;
uint8_t st;
if (c2_data_read(&st)) return -3;
if (st != 0x0D) return -4;
if (c2_data_write(sfr)) return -11; // SFR address
if (c2_poll_inbusy()) return -5;
if (c2_data_write(0x01)) return -12; // length = 1 byte
if (c2_poll_inbusy()) return -6;
if (c2_data_write(val)) return -13; // SFR value
if (c2_poll_inbusy()) return -7;
return 0;
}
// EFM8UB2 pre-flash setup (AN127 Table 3.6): flash timing, enable VDD monitor,
// clock, supply-monitor reset source. Must run once after piinit, before any
// erase/write. Returns 0 on success.
static int c2_pgm_setup(void) {
if (c2_direct_write(0xB6, 0x90)) return -1; // FLSCL = 0x90 (flash timing)
if (c2_direct_write(0xFF, 0x80)) return -2; // VDM0CN = 0x80 (VDD mon enable)
if (c2_direct_write(0xA9, 0x03)) return -3; // CLKSEL = 0x03
if (c2_direct_write(0xEF, 0x02)) return -4; // RSTSRC = 0x02 (VDD mon reset src)
return 0;
}
// Page Erase (FPDAT 0x08). `page` = address / 512. Guarded to app region only.
static int c2_page_erase(uint8_t page) {
if (!app_page_ok(page)) return -100; // REFUSED: outside app region
uint8_t st;
c2_addr_write(fpdat_addr);
if (c2_data_write(0x08)) return -10; // Page Erase cmd
if (c2_poll_inbusy()) return -1;
if (c2_poll_outready()) return -2;
if (c2_data_read(&st)) return -3;
if (st != 0x0D) return -4;
if (c2_data_write(page)) return -11; // target page number
if (c2_poll_inbusy()) return -5;
// Page-number ack: the PI sets OutReady with a 0x0D status after consuming
// the page number. AN127 step 8 says "poll until clear" but on the EFM8UB2
// the byte is genuinely pending (OutReady stuck SET), so we poll until SET
// and read it -- mirroring the command-ack (steps 4-5) and completion-ack
// (steps 12-13). The 0x0D check below rejects any error status safely.
if (c2_poll_outready()) return -6; // OutReady -> 1 (ack ready)
if (c2_data_read(&st)) return -7;
if (st != 0x0D) return -8;
if (c2_data_write(0x00)) return -12; // initiate erase
if (c2_poll_inbusy()) return -9;
if (c2_poll_outready()) return -13; // OutReady -> 1 (done)
if (c2_data_read(&st)) return -14;
if (st != 0x0D) return -15;
return 0;
}
// Block Write (FPDAT 0x07): program `len` bytes (1..256, 0 => 256) at `addr`.
// addr..addr+len must lie inside the app region. Flash must be erased (0xFF)
// at the target first. Returns number of bytes written, or negative error.
static int c2_block_write(uint16_t addr, uint8_t len, const uint8_t *buf) {
int n = len ? (int)len : 256;
if (!app_addr_ok(addr, n)) return -100; // REFUSED: outside app region
uint8_t st;
c2_addr_write(fpdat_addr);
if (c2_data_write(0x07)) return -10; // Block Write cmd
if (c2_poll_inbusy()) return -1;
if (c2_poll_outready()) return -2;
if (c2_data_read(&st)) return -3;
if (st != 0x0D) return -4;
if (c2_data_write((addr >> 8) & 0xFF)) return -11; // addr high
if (c2_poll_inbusy()) return -5;
if (c2_data_write(addr & 0xFF)) return -12; // addr low
if (c2_poll_inbusy()) return -6;
if (c2_data_write(len)) return -13; // length code (0=256)
if (c2_poll_inbusy()) return -7;
for (int k = 0; k < n; k++) {
if (c2_data_write(buf[k])) return -200 - k; // data byte
if (c2_poll_inbusy()) return -8 - k;
}
if (c2_poll_outready()) return -14; // write complete
if (c2_data_read(&st)) return -15;
if (st != 0x0D) return -16;
return n;
}
// ----------------------------------------------------------------- command I/O
static int get_line(char *buf, int maxlen) {
int n = 0;
while (n < maxlen - 1) {
int c = getchar_timeout_us(1000);
if (c == PICO_ERROR_TIMEOUT) continue;
if (c < 0) continue;
if (c == '\r') continue;
if (c == '\n') break;
buf[n++] = (char)c;
}
buf[n] = 0;
return n;
}
static void print_hex(const uint8_t *p, int n) {
for (int k = 0; k < n; k++) printf("%02x", p[k]);
printf("\n");
}
static void do_read(uint16_t addr, uint8_t len) {
static uint8_t buf[256];
int rc = c2_block_read(addr, len, buf);
if (rc < 0) {
printf("err %d\n", rc);
return;
}
printf("data %04x %d ", addr, rc);
print_hex(buf, rc);
}
// (Re)configure the C2CK / C2D pins. Idles C2CK high and releases C2D.
static void c2_setup_pins(int ck, int d) {
C2CK = ck; C2D = d;
gpio_init(ck); gpio_set_dir(ck, GPIO_OUT); gpio_put(ck, 1);
gpio_init(d); gpio_set_dir(d, GPIO_OUT); gpio_put(d, 1);
c2d_release();
}
int main(void) {
stdio_init_all();
c2_setup_pins(2, 3); // default: GP2=C2CK, GP3=C2D
// Must hold the longest command: "bw ffff 0 " (10) + 512 hex chars = 522.
char line[1024];
while (true) {
int n = get_line(line, sizeof(line));
if (n == 0) { printf("ok\n"); continue; }
char cmd[32];
if (sscanf(line, "%31s", cmd) != 1) { printf("err parse\n"); continue; }
if (!strcmp(cmd, "hello")) {
printf("ok c2probe v2\n");
} else if (!strcmp(cmd, "bootsel")) {
printf("ok bootsel\n");
fflush(stdout);
busy_wait_us(5000);
reset_usb_boot(0, 0); // reboot into USB bootloader (BOOTSEL)
} else if (!strcmp(cmd, "speed")) {
unsigned us = 1;
sscanf(line, "%*s %u", &us);
half_us = (us > 1000) ? 1000 : us;
printf("ok speed %luus\n", (unsigned long)half_us);
} else if (!strcmp(cmd, "fpdat")) {
unsigned a = fpdat_addr;
sscanf(line, "%*s %x", &a);
fpdat_addr = a & 0xFF;
printf("ok fpdat %02x\n", fpdat_addr);
} else if (!strcmp(cmd, "pins")) {
int ck = C2CK, d = C2D;
if (sscanf(line, "%*s %i %i", &ck, &d) == 2) {
c2_setup_pins(ck, d);
}
printf("ok ck=gp%d d=gp%d\n", C2CK, C2D);
} else if (!strcmp(cmd, "reset")) {
c2_reset();
printf("ok reset\n");
} else if (!strcmp(cmd, "status")) {
printf("stat %02x\n", c2_status());
} else if (!strcmp(cmd, "rdreg")) {
unsigned a = 0;
sscanf(line, "%*s %x", &a);
printf("reg %02x\n", c2_reg_read(a & 0xFF));
} else if (!strcmp(cmd, "wrreg")) {
unsigned a = 0, v = 0;
sscanf(line, "%*s %x %x", &a, &v);
int rc = c2_reg_write(a & 0xFF, v & 0xFF);
printf("%s\n", rc ? "err wait" : "ok");
} else if (!strcmp(cmd, "id")) {
uint8_t d = c2_reg_read(0x00), r = c2_reg_read(0x01);
printf("id devid=%02x revid=%02x\n", d, r);
} else if (!strcmp(cmd, "id2")) {
// atomic: reset then read DEVICEID/REVID with no host round-trip
c2_reset();
uint8_t d = c2_reg_read(0x00), r = c2_reg_read(0x01);
printf("id2 devid=%02x revid=%02x\n", d, r);
} else if (!strcmp(cmd, "dbg")) {
// 3-state read of C2D: floating line follows the pull; a driven
// line ignores it. Reveals whether a slave is driving C2D.
gpio_set_dir(C2D, GPIO_IN);
gpio_disable_pulls(C2D); busy_wait_us(20); int none = c2d_get();
gpio_pull_up(C2D); busy_wait_us(20); int up = c2d_get();
gpio_disable_pulls(C2D);
gpio_pull_down(C2D); busy_wait_us(20); int dn = c2d_get();
gpio_disable_pulls(C2D);
printf("dbg c2d none=%d up=%d dn=%d c2ck=%d (float if up=1,dn=0)\n",
none, up, dn, gpio_get(C2CK));
} else if (!strcmp(cmd, "piinit")) {
c2_pi_init();
printf("ok piinit\n");
} else if (!strcmp(cmd, "rawaw")) {
unsigned a = 0; sscanf(line, "%*s %x", &a);
c2_addr_write(a & 0xFF);
printf("ok\n");
} else if (!strcmp(cmd, "rawar")) {
printf("ar %02x\n", c2_addr_read());
} else if (!strcmp(cmd, "rawdw")) {
unsigned v = 0; sscanf(line, "%*s %x", &v);
printf("%s\n", c2_data_write(v & 0xFF) ? "err wait" : "ok");
} else if (!strcmp(cmd, "rawdr")) {
uint8_t v = 0;
int rc = c2_data_read(&v);
printf("dr %02x %s\n", v, rc ? "err" : "ok");
} else if (!strcmp(cmd, "read")) {
unsigned a = 0; int l = 0;
sscanf(line, "%*s %x %i", &a, &l);
if (l < 0) l = 0;
if (l > 256) l = 256;
do_read(a & 0xFFFF, (uint8_t)l);
} else if (!strcmp(cmd, "dump")) {
unsigned s = 0, e = 0;
sscanf(line, "%*s %x %x", &s, &e);
if (e > 0x10000) e = 0x10000;
int blocks = 0;
for (unsigned a = s; a < e; ) {
int chunk = e - a;
if (chunk > 256) chunk = 256;
do_read(a & 0xFFFF, (uint8_t)chunk);
a += chunk;
blocks++;
}
printf("done %d\n", blocks);
} else if (!strcmp(cmd, "sfrw")) {
// Direct Write one SFR (0x80-0xFF). For the pre-flash setup only.
unsigned a = 0, v = 0;
sscanf(line, "%*s %x %x", &a, &v);
int rc = c2_direct_write(a & 0xFF, v & 0xFF);
printf("%s\n", rc ? "err" : "ok");
if (rc) printf("sfrw rc %d\n", rc);
} else if (!strcmp(cmd, "wsetup")) {
// EFM8UB2 pre-flash SFR setup (flash timing + VDD monitor + clock).
int rc = c2_pgm_setup();
printf("%s\n", rc ? "err" : "ok");
if (rc) printf("wsetup rc %d\n", rc);
} else if (!strcmp(cmd, "pe")) {
// Page Erase. Page number = addr/512. Guarded to app region.
unsigned pg = 0;
sscanf(line, "%*s %x", &pg);
int rc = c2_page_erase(pg & 0xFF);
if (rc == -100) printf("refused page %02x outside app region\n", pg & 0xFF);
else printf("%s\n", rc ? "err" : "ok");
if (rc && rc != -100) printf("pe rc %d\n", rc);
} else if (!strcmp(cmd, "bw")) {
// Block Write: bw <addr> <len> <hex...>. Guarded to app region.
unsigned a = 0; int l = 0;
char hex[600];
hex[0] = 0;
// "bw ADDR LEN HEXSTR"
int got = sscanf(line, "%*s %x %i %599s", &a, &l, hex);
if (got < 3) { printf("err bw usage\n"); }
else {
if (l < 0) l = 0;
if (l > 256) l = 256;
int n = l ? l : 256;
static uint8_t wbuf[256];
int hl = (int)strlen(hex);
if (hl < n * 2) { printf("err bw short hex (%d want %d)\n", hl, n * 2); }
else {
int ok = 1;
for (int k = 0; k < n; k++) {
unsigned b;
if (sscanf(hex + k * 2, "%2x", &b) != 1) { ok = 0; break; }
wbuf[k] = (uint8_t)b;
}
if (!ok) printf("err bw hex parse\n");
else {
int rc = c2_block_write(a & 0xFFFF, (uint8_t)l, wbuf);
if (rc == -100) printf("refused addr %04x outside app region\n", a & 0xFFFF);
else if (rc < 0) { printf("err\n"); printf("bw rc %d\n", rc); }
else printf("ok bw %04x %d\n", a & 0xFFFF, rc);
}
}
}
} else {
printf("err unknown\n");
}
fflush(stdout);
}
return 0;
}
Executable
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
# cdc.py -- minimal host helper to talk to the c2probe CDC line protocol.
# Uses termios (no pyserial). Usage:
# ./cdc.py hello
# ./cdc.py id2
# ./cdc.py "read 2400 16"
# Sends one command, prints all reply lines until a known terminator/prompt.
import sys, os, termios, tty, time, select
DEV = os.environ.get("C2PROBE_DEV", "/dev/cu.usbmodem2101")
def open_port():
fd = os.open(DEV, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
attrs = termios.tcgetattr(fd)
attrs[2] = termios.CS8 | termios.CLOCAL | termios.CREAD # cflag
attrs[3] = 0 # lflag: raw
attrs[4] = termios.B115200 # ispeed
attrs[5] = termios.B115200 # ospeed
# no flow control, no special chars
attrs[0] = 0; attrs[1] = 0
termios.tcsetattr(fd, termios.TCSANOW, attrs)
return fd
def drain(fd, t=0.15):
r, _, _ = select.select([fd], [], [], t)
if r:
try: return os.read(fd, 4096).decode("utf-8", "replace")
except BlockingIOError: return ""
return ""
def cmd(fd, line, timeout=3.0):
os.write(fd, (line + "\n").encode())
out = ""
end = time.time() + timeout
while time.time() < end:
r, _, _ = select.select([fd], [], [], 0.1)
if r:
try: out += os.read(fd, 4096).decode("utf-8", "replace")
except BlockingIOError: pass
# stop once we have at least one complete line and the port goes quiet
if "\n" in out:
# give a short quiet window for any trailing lines
r2, _, _ = select.select([fd], [], [], 0.08)
if not r2:
break
return out
if __name__ == "__main__":
line = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else "hello"
fd = open_port()
drain(fd, 0.3) # drop any startup/banner
print(cmd(fd, line), end="")
os.close(fd)
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
# dump_flash.py -- read the entire EFM8 flash over the c2probe CDC link,
# write a .bin, and compare against the stock firmware image.
#
# SAFETY: this script only ever issues Block Read (0x06). It never sends an
# erase/write/lock command, and it never issues a C2 reset between blocks (a
# reset would un-halt the core and break reads). The c2probe firmware itself
# is read-only by design. Flash cannot be harmed.
#
# Workflow: piinit (halt core) -> loop Block Read over 0x0000..0xFFFF in
# 256-byte blocks -> write out/qunexus_device_dump.bin -> read lock byte at
# 0xFBFF -> diff against stock (out/QuNexus_Firmware_v2.2.1.bin, base 0x2400)
# -> focus report on the RECOVERY.md suspect page 0xE800..0xE9FF.
import os, sys, time, select, termios
DEV = os.environ.get("C2PROBE_DEV", "/dev/cu.usbmodem2101")
HERE = os.path.dirname(os.path.abspath(__file__))
OUTDIR = os.path.join(HERE, "..", "out")
STOCK = os.path.join(OUTDIR, "QuNexus_Firmware_v2.2.1.bin")
STOCK_BASE = 0x2400
DUMP_BIN = os.path.join(OUTDIR, "qunexus_device_dump.bin")
FLASH_SIZE = 0x10000 # 64 KB
BLOCK = 256 # bytes per Block Read (length code 0 = 256)
SUSPECT_LO, SUSPECT_HI = 0xE800, 0xEA00 # RECOVERY.md page (inclusive..exclusive)
# ---- CDC I/O (termios, no pyserial) ----------------------------------------
def open_port():
fd = os.open(DEV, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
a = termios.tcgetattr(fd)
a[2] = termios.CS8 | termios.CLOCAL | termios.CREAD
a[3] = 0; a[0] = 0; a[1] = 0
a[4] = termios.B115200; a[5] = termios.B115200
termios.tcsetattr(fd, termios.TCSANOW, a)
return fd
def drain(fd, t=0.2):
r, _, _ = select.select([fd], [], [], t)
if r:
try: return os.read(fd, 4096)
except BlockingIOError: return b""
return b""
def cmd(fd, line, timeout=10.0):
os.write(fd, (line + "\n").encode())
out = b""
end = time.time() + timeout
while time.time() < end:
r, _, _ = select.select([fd], [], [], 0.1)
if r:
try: out += os.read(fd, 4096)
except BlockingIOError: pass
if b"\n" in out:
r2, _, _ = select.select([fd], [], [], 0.06)
if not r2:
break
return out.decode("utf-8", "replace").strip()
def piinit(fd):
r = cmd(fd, "piinit", 12)
if r != "ok piinit":
raise RuntimeError(f"piinit failed: {r!r}")
return True
def block_read(fd, addr, n, retries=3):
"""Issue one Block Read of n bytes (1..256) at addr. Returns bytes or None."""
lc = n if n < 256 else 0
for attempt in range(retries):
r = cmd(fd, f"read {addr:04x} {lc}", 12)
# expected: "data <addr> <n> <hex>"
if r.startswith("data "):
parts = r.split()
# parts: ['data', 'addr', 'count', hexstr]
try:
hexstr = parts[3]
data = bytes.fromhex(hexstr)
if len(data) == n:
return data
# short read: pad/record; retry
except (IndexError, ValueError):
pass
if r.startswith("err"):
# desync likely: re-init and retry
piinit(fd)
continue
# unexpected: retry
return None
def main():
os.makedirs(OUTDIR, exist_ok=True)
fd = open_port()
drain(fd, 0.4)
print(f"[piinit] halting EFM8 core...")
piinit(fd)
print(f"[piinit] ok")
buf = bytearray(FLASH_SIZE)
bad = [] # list of (addr, 'gap')
t0 = time.time()
addr = 0
while addr < FLASH_SIZE:
n = min(BLOCK, FLASH_SIZE - addr)
data = block_read(fd, addr, n)
if data is None:
print(f" [FAIL] 0x{addr:04x}..0x{addr+n-1:04x} (gap)")
bad.append(addr)
addr += n
continue
buf[addr:addr+n] = data
if (addr % 0x1000) == 0:
dt = time.time() - t0
print(f" 0x{addr:04x} ({dt:.1f}s)")
addr += n
dt = time.time() - t0
print(f"[dump] done in {dt:.1f}s, {len(bad)} gap(s)")
# lock byte
lock = block_read(fd, 0xFBFF, 1)
lockval = lock[0] if lock else None
print(f"[lock] byte @0xFBFF = {('0x%02x' % lockval) if lockval is not None else 'read FAILED'}")
with open(DUMP_BIN, "wb") as f:
f.write(buf)
print(f"[write] {DUMP_BIN} ({len(buf)} bytes)")
# ---- compare to stock --------------------------------------------------
print("\n[compare] vs stock", os.path.basename(STOCK))
if not os.path.exists(STOCK):
print(" stock image not found; skipping diff")
else:
stock = open(STOCK, "rb").read()
stock_end = STOCK_BASE + len(stock) # exclusive
# compare over the region both cover
lo = STOCK_BASE
hi = min(stock_end, FLASH_SIZE)
diffs = []
for a in range(lo, hi):
if buf[a] != stock[a - STOCK_BASE]:
diffs.append(a)
print(f" stock region 0x{lo:04x}..0x{hi-1:04x} ({hi-lo} bytes)")
print(f" differing bytes: {len(diffs)}")
if diffs:
# group consecutive
groups = []
start = prev = diffs[0]
for a in diffs[1:]:
if a == prev + 1:
prev = a
else:
groups.append((start, prev)); start = prev = a
groups.append((start, prev))
print(f" {len(groups)} contiguous run(s):")
for s, e in groups[:40]:
length = e - s + 1
dev = buf[s:e+1]
stk = stock[s-STOCK_BASE:e+1-STOCK_BASE]
print(f" 0x{s:04x}..0x{e:04x} ({length} B) dev={dev.hex()} stock={stk.hex()}")
if len(groups) > 40:
print(f" ... ({len(groups)-40} more)")
else:
print(" >>> device app region is BYTE-IDENTICAL to stock <<<")
# ---- focused report: suspect page --------------------------------------
print(f"\n[RECOVERY] suspect page 0x{SUSPECT_LO:04x}..0x{SUSPECT_HI-1:04x}")
page = bytes(buf[SUSPECT_LO:SUSPECT_HI])
ff = sum(1 for b in page if b == 0xFF)
nonff = [i for i, b in enumerate(page) if b != 0xFF]
print(f" {len(page)} bytes; 0xFF count = {ff}; non-0xFF count = {len(nonff)}")
if nonff:
print(f" non-0xFF offsets (first 40): {[('0x%x'%(SUSPECT_LO+i)) for i in nonff[:40]]}")
else:
print(" >>> entire page reads 0xFF (ERASED) -- confirms RECOVERY.md hypothesis <<<")
# hex dump first 64 bytes of the page
print(" first 64 bytes:")
for off in range(0, min(64, len(page)), 16):
chunk = page[off:off+16]
print(f" {SUSPECT_LO+off:04x}: " + " ".join(f"{b:02x}" for b in chunk))
os.close(fd)
return 0 if not bad else 1
if __name__ == "__main__":
sys.exit(main())
+32
View File
@@ -0,0 +1,32 @@
// lock_stubs.c -- no-op retargetable-lock stubs for newlib.
//
// The Pico SDK expects a *non-retargetable* newlib (lock calls compile to
// nothing). The toolchain we build with ships a *retargetable* newlib, whose
// stdio/malloc/exit code references __lock___* objects and __retarget_lock_*
// functions that the SDK does not provide. c2probe is strictly single
// threaded, so all of these are no-ops. The named objects are the complete set
// enumerated from newlib's libc/libg (thumb) with `nm -u`.
#include <sys/lock.h>
/* Backing mutex objects newlib declares extern via __LOCK_INIT(). */
struct __lock __lock___sfp_recursive_mutex;
struct __lock __lock___sinit_recursive_mutex;
struct __lock __lock___malloc_recursive_mutex;
struct __lock __lock___env_recursive_mutex;
struct __lock __lock___atexit_recursive_mutex;
struct __lock __lock___at_quick_exit_mutex;
struct __lock __lock___tz_mutex;
struct __lock __lock___arc4random_mutex;
struct __lock __lock___dd_hash_mutex;
void __retarget_lock_init(_LOCK_T l) { (void)l; }
void __retarget_lock_init_recursive(_LOCK_T l) { (void)l; }
void __retarget_lock_close(_LOCK_T l) { (void)l; }
void __retarget_lock_close_recursive(_LOCK_T l) { (void)l; }
void __retarget_lock_acquire(_LOCK_T l) { (void)l; }
void __retarget_lock_acquire_recursive(_LOCK_T l) { (void)l; }
int __retarget_lock_try_acquire(_LOCK_T l) { (void)l; return 1; }
int __retarget_lock_try_acquire_recursive(_LOCK_T l) { (void)l; return 1; }
void __retarget_lock_release(_LOCK_T l) { (void)l; }
void __retarget_lock_release_recursive(_LOCK_T l) { (void)l; }
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
# patch_c2.py -- apply the USB-1->CV patch to the QuNexus via the C2 interface,
# surgically: erase + reprogram only the TWO flash pages the patch touches.
#
# The patch (see ../patch_usb1_to_cv.py, STUB_ADDR=0x8126):
# * page 0x8000-0x81FF: a 23-byte stub written into 0xFF linker padding at
# 0x8126 (verified 0xFF in stock). Routes cable-0 events to the CV ring
# after the normal router call.
# * page 0xDE00-0xDFFF: retarget the LCALL at 0xDF28 from 0xA57B to 0x8126.
#
# SAFETY / ORDERING: the stub page is written and verified FIRST, the retarget
# page SECOND. So at no intermediate point does the retarget reference a stub
# that isn't there. If the stub-page step fails, we STOP and the device is left
# stock (working, unpatched). If the retarget-page step fails, the stub is in
# place but unreferenced -> also stock behaviour. The device can never be bricked
# mid-process. The bootloader (0x0000-0x23FF) and lock/reserved (0xFA00+) are
# never touched; only app-region pages 0x40 and 0x6F are erased/written.
import os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "..")) # for patch_usb1_to_cv
import cdc
from patch_usb1_to_cv import STUB, STUB_ADDR, CALL_SITE, NEW_CALL, OLD_CALL, ROUTER
STOCK_BIN = os.path.join(HERE, "..", "out", "QuNexus_Firmware_v2.2.1.bin")
STOCK_BASE = 0x2400
PAGE = 512
STUB_PAGE = 0x8000 # page 0x40
CALL_PAGE = 0xDE00 # page 0x6F
def stock_page(addr):
s = open(STOCK_BIN, "rb").read()
o = addr - STOCK_BASE
return bytearray(s[o:o + PAGE])
def patch_page(page_addr, edits):
"""edits: list of (addr, bytes). Returns patched 512-byte page."""
p = stock_page(page_addr)
for addr, data in edits:
off = addr - page_addr
assert 0 <= off and off + len(data) <= PAGE, f"edit 0x{addr:04x} outside page 0x{page_addr:04x}"
p[off:off + len(data)] = data
return p
def block_read(fd, addr, n=256):
lc = n if n < 256 else 0
r = cdc.cmd(fd, f"read {addr:04x} {lc}", 12).strip()
if r.startswith("data "):
try:
b = bytes.fromhex(r.split()[3])
if len(b) == n:
return b
except (IndexError, ValueError):
pass
return None
def read_page(fd, page_addr):
lo = block_read(fd, page_addr, 256)
hi = block_read(fd, page_addr + 256, 256)
if lo is None or hi is None:
return None
return lo + hi
def erase_page(fd, page_addr):
return cdc.cmd(fd, f"pe {page_addr // PAGE:x}", 25).strip()
def write_block(fd, addr, data):
lc = len(data) if len(data) < 256 else 0
return cdc.cmd(fd, f"bw {addr:04x} {lc} " + data.hex(), 25).strip()
def write_page(fd, page_addr, data):
r1 = write_block(fd, page_addr, data[:256])
r2 = write_block(fd, page_addr + 256, data[256:])
return r1, r2
def fail(msg):
print(f"\n[FAIL] {msg}")
print(" Device left in a WORKING state (retarget not written, or stub")
print(" written but unreferenced). Re-run to retry.")
return 1
def do_page(fd, label, page_addr, patched, expect_stock_first=True):
"""Full verified erase+write of one patched page. Returns True on success."""
pgnum = page_addr // PAGE
print(f"\n[{label}] page 0x{page_addr:04x}-0x{page_addr+PAGE-1:04x} (page 0x{pgnum:02x})")
# 1. read current page; confirm it's currently stock (clean baseline)
cur = read_page(fd, page_addr)
if cur is None:
print(" err: could not read current page"); return False
stock = stock_page(page_addr)
if cur != stock:
nd = sum(1 for i in range(PAGE) if cur[i] != stock[i])
print(f" WARNING: current page is NOT stock ({nd} bytes differ).")
if expect_stock_first:
print(" Refusing to patch a non-stock page (unexpected state).")
return False
print(" current page == stock OK")
# 2. erase
r = erase_page(fd, page_addr)
print(f" pe: {r}")
if r != "ok":
print(" err: page erase failed"); return False
# 3. verify erased
er = read_page(fd, page_addr)
if er is None or sum(1 for b in er if b == 0xFF) != PAGE:
print(" err: page did not erase to all 0xFF"); return False
print(" erased: all 0xFF OK")
# 4. write patched bytes
r1, r2 = write_page(fd, page_addr, patched)
print(f" bw lo: {r1}")
print(f" bw hi: {r2}")
if not (r1.startswith("ok bw") and r2.startswith("ok bw")):
print(" err: block write failed"); return False
# 5. verify == patched
got = read_page(fd, page_addr)
if got is None:
print(" err: could not read back page"); return False
if got != bytes(patched):
diffs = [i for i in range(PAGE) if got[i] != patched[i]]
print(f" err: write-back MISMATCH ({len(diffs)} bytes; first {diffs[:8]})")
return False
# confirm only the intended bytes changed vs stock
intended = [i for i in range(PAGE) if patched[i] != stock[i]]
actual = [i for i in range(PAGE) if got[i] != stock[i]]
if intended != actual:
print(f" err: unintended bytes changed. intended {len(intended)}, actual {len(actual)}")
return False
print(f" verified == patched; {len(intended)} byte(s) changed vs stock OK")
return True
def main():
# build the two patched pages from stock + patch constants
stub_page = patch_page(STUB_PAGE, [(STUB_ADDR, STUB)])
call_page = patch_page(CALL_PAGE, [(CALL_SITE, NEW_CALL)])
# sanity: confirm the stub lands on 0xFF and the call site is the old LCALL
s = stock_page(STUB_PAGE)
assert all(s[STUB_ADDR - STUB_PAGE + k] == 0xFF for k in range(len(STUB))), "stub site not 0xFF in stock"
c = stock_page(CALL_PAGE)
assert bytes(c[CALL_SITE - CALL_PAGE:CALL_SITE - CALL_PAGE + 3]) == OLD_CALL, "call site not old LCALL in stock"
print(f"[patch] stub @0x{STUB_ADDR:04x} ({len(STUB)} B): {STUB.hex(' ')}")
print(f"[patch] call @0x{CALL_SITE:04x}: {OLD_CALL.hex(' ')} -> {NEW_CALL.hex(' ')} (LCALL 0x{ROUTER:04X} -> LCALL 0x{STUB_ADDR:04X})")
print(f"[patch] pages to modify: 0x{STUB_PAGE:04x} (stub, written FIRST) and 0x{CALL_PAGE:04x} (retarget, written SECOND)")
fd = cdc.open_port()
cdc.drain(fd, 0.4)
def c(s, t=15): return cdc.cmd(fd, s, t).strip()
print("\n[init] piinit + wsetup")
r = c("piinit", 12); print(" piinit:", r)
if r != "ok piinit": return fail("piinit failed")
r = c("wsetup", 12); print(" wsetup:", r)
if r != "ok": return fail("wsetup failed")
# --- stub page FIRST ---
if not do_page(fd, "STUB", STUB_PAGE, stub_page):
return fail("stub page step failed -- retarget NOT written; device is stock/working")
# --- retarget page SECOND ---
if not do_page(fd, "CALL", CALL_PAGE, call_page):
return fail("retarget page step failed -- stub is written but unreferenced; device is stock/working")
# --- reset so the patched app boots ---
print("\n[reset] booting patched app")
c("reset", 5)
print(" ok reset")
print("\n[DONE] patch applied & verified. 0xDF28 now LCALLs 0x8126, which runs the")
print(" normal router then re-routes cable-0 (USB-1) events to the USB-3/CV ring.")
print(" Plug the QuNexus into the Mac and test: send MIDI to USB port 1 and")
print(" confirm CV output responds (in addition to the normal Control Surface path).")
return 0
if __name__ == "__main__":
sys.exit(main())
+84
View File
@@ -0,0 +1,84 @@
# This is a copy of <PICO_SDK_PATH>/external/pico_sdk_import.cmake
# This can be dropped into an external project to help locate this SDK
# It should be include()ed prior to project()
if (DEFINED ENV{PICO_SDK_PATH} AND (NOT PICO_SDK_PATH))
set(PICO_SDK_PATH $ENV{PICO_SDK_PATH})
message("Using PICO_SDK_PATH from environment ('${PICO_SDK_PATH}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT} AND (NOT PICO_SDK_FETCH_FROM_GIT))
set(PICO_SDK_FETCH_FROM_GIT $ENV{PICO_SDK_FETCH_FROM_GIT})
message("Using PICO_SDK_FETCH_FROM_GIT from environment ('${PICO_SDK_FETCH_FROM_GIT}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_PATH} AND (NOT PICO_SDK_FETCH_FROM_GIT_PATH))
set(PICO_SDK_FETCH_FROM_GIT_PATH $ENV{PICO_SDK_FETCH_FROM_GIT_PATH})
message("Using PICO_SDK_FETCH_FROM_GIT_PATH from environment ('${PICO_SDK_FETCH_FROM_GIT_PATH}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_TAG} AND (NOT PICO_SDK_FETCH_FROM_GIT_TAG))
set(PICO_SDK_FETCH_FROM_GIT_TAG $ENV{PICO_SDK_FETCH_FROM_GIT_TAG})
message("Using PICO_SDK_FETCH_FROM_GIT_TAG from environment ('${PICO_SDK_FETCH_FROM_GIT_TAG}')")
endif ()
if (PICO_SDK_FETCH_FROM_GIT AND NOT PICO_SDK_FETCH_FROM_GIT_TAG)
set(PICO_SDK_FETCH_FROM_GIT_TAG "master")
message("Using master as default value for PICO_SDK_FETCH_FROM_GIT_TAG")
endif()
set(PICO_SDK_PATH "${PICO_SDK_PATH}" CACHE PATH "Path to the Raspberry Pi Pico SDK")
set(PICO_SDK_FETCH_FROM_GIT "${PICO_SDK_FETCH_FROM_GIT}" CACHE BOOL "Set to ON to fetch copy of SDK from git if not otherwise locatable")
set(PICO_SDK_FETCH_FROM_GIT_PATH "${PICO_SDK_FETCH_FROM_GIT_PATH}" CACHE FILEPATH "location to download SDK")
set(PICO_SDK_FETCH_FROM_GIT_TAG "${PICO_SDK_FETCH_FROM_GIT_TAG}" CACHE FILEPATH "release tag for SDK")
if (NOT PICO_SDK_PATH)
if (PICO_SDK_FETCH_FROM_GIT)
include(FetchContent)
set(FETCHCONTENT_BASE_DIR_SAVE ${FETCHCONTENT_BASE_DIR})
if (PICO_SDK_FETCH_FROM_GIT_PATH)
get_filename_component(FETCHCONTENT_BASE_DIR "${PICO_SDK_FETCH_FROM_GIT_PATH}" REALPATH BASE_DIR "${CMAKE_SOURCE_DIR}")
endif ()
# GIT_SUBMODULES_RECURSE was added in 3.17
if (${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.17.0")
FetchContent_Declare(
pico_sdk
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
GIT_SUBMODULES_RECURSE FALSE
)
else ()
FetchContent_Declare(
pico_sdk
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
)
endif ()
if (NOT pico_sdk)
message("Downloading Raspberry Pi Pico SDK")
FetchContent_Populate(pico_sdk)
set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR})
endif ()
set(FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR_SAVE})
else ()
message(FATAL_ERROR
"SDK location was not specified. Please set PICO_SDK_PATH or set PICO_SDK_FETCH_FROM_GIT to on to fetch from git."
)
endif ()
endif ()
get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
if (NOT EXISTS ${PICO_SDK_PATH})
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' not found")
endif ()
set(PICO_SDK_INIT_CMAKE_FILE ${PICO_SDK_PATH}/pico_sdk_init.cmake)
if (NOT EXISTS ${PICO_SDK_INIT_CMAKE_FILE})
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' does not appear to contain the Raspberry Pi Pico SDK")
endif ()
set(PICO_SDK_PATH ${PICO_SDK_PATH} CACHE PATH "Path to the Raspberry Pi Pico SDK" FORCE)
include(${PICO_SDK_INIT_CMAKE_FILE})
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
# reflash_page.py -- surgical recovery: erase + reprogram ONE flash page to
# undo the bad patch, restoring stock 2.2.1 code at the LCALL site 0xDF28.
#
# What it does: page 0xDE00-0xDFFF (512 B) contains 0xDF28 (the LCALL the patch
# retargeted to empty 0xE8F2). We erase that one page and reprogram it with the
# stock bytes, restoring 0xDF29 = A5 7B (LCALL 0xA57B). This un-breaks the
# USB-MIDI dispatcher, so SysEx / bootloader entry work again.
#
# SAFETY: only page 0x6F (0xDE00) is erased, and only 0xDE00/0xDF00 written.
# The firmware hard-guards all erase/write to the app region 0x2400-0xF9FF; the
# bootloader (0x0000-0x23FF) and lock/reserved (0xFA00+) are unreachable.
# Every step is verified; the script aborts on any mismatch.
import os, sys, time, select, termios
DEV = os.environ.get("C2PROBE_DEV", "/dev/cu.usbmodem2101")
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import cdc
STOCK_BIN = os.path.join(HERE, "..", "out", "QuNexus_Firmware_v2.2.1.bin")
STOCK_BASE = 0x2400
PAGE_ADDR = 0xDE00
PAGE_LEN = 512
PAGE_NUM = PAGE_ADDR // 512 # 0x6F
LCALL_ADDR = 0xDF28
def main():
stock = open(STOCK_BIN, "rb").read()
page = stock[PAGE_ADDR - STOCK_BASE : PAGE_ADDR - STOCK_BASE + PAGE_LEN]
assert len(page) == PAGE_LEN, "stock page incomplete"
expect_call = stock[LCALL_ADDR - STOCK_BASE : LCALL_ADDR - STOCK_BASE + 3]
print(f"[stock] page 0x{PAGE_ADDR:04x}-0x{PAGE_ADDR+PAGE_LEN-1:04x} ready")
print(f"[stock] 0x{LCALL_ADDR:04x} = {expect_call.hex(' ')} (target: restore this)")
fd = cdc.open_port()
cdc.drain(fd, 0.4)
def c(s, t=15):
return cdc.cmd(fd, s, t).strip()
# 1. halt core + flash-programming SFR setup
print("\n[1] piinit + wsetup")
r = c("piinit", 12); print(" piinit:", r)
if r != "ok piinit": return fail("piinit failed")
r = c("wsetup", 12); print(" wsetup:", r)
if r != "ok": return fail("wsetup failed")
# 2. read current page, confirm the patch is present (0xDF29 = e8 f2)
print("\n[2] read current page (confirm patch present)")
cur_lo = block_read(fd, 0xDE00, 256)
cur_hi = block_read(fd, 0xDF00, 256)
if cur_lo is None or cur_hi is None: return fail("could not read current page")
cur = cur_lo + cur_hi
print(f" 0x{LCALL_ADDR:04x} now = {cur[LCALL_ADDR-PAGE_ADDR:LCALL_ADDR-PAGE_ADDR+3].hex(' ')}")
if cur[LCALL_ADDR-PAGE_ADDR+1:LCALL_ADDR-PAGE_ADDR+3] != b"\xe8\xf2":
print(" WARNING: 0xDF29 is not e8 f2 -- patch may already be undone")
# 3. erase the page
print(f"\n[3] page erase page 0x{PAGE_NUM:02x} (0x{PAGE_ADDR:04x})")
r = c(f"pe {PAGE_NUM:x}", 20)
print(" pe:", r)
if r != "ok": return fail(f"page erase failed: {r}")
# 4. verify the page is now all 0xFF
print("\n[4] verify page erased (all 0xFF)")
er_lo = block_read(fd, 0xDE00, 256)
er_hi = block_read(fd, 0xDF00, 256)
if er_lo is None or er_hi is None: return fail("could not read erased page")
erased = er_lo + er_hi
nff = sum(1 for b in erased if b == 0xFF)
print(f" 0xFF count: {nff}/512")
if nff != 512:
print(" erased page:", erased.hex())
return fail("page erase did NOT yield all 0xFF -- aborting before write")
# 5. program the stock bytes (two 256-byte block writes)
print("\n[5] block write stock bytes")
r = c("bw de00 0 " + page[:256].hex(), 20); print(" bw de00:", r)
if not r.startswith("ok bw"): return fail(f"bw de00 failed: {r}")
r = c("bw df00 0 " + page[256:].hex(), 20); print(" bw df00:", r)
if not r.startswith("ok bw"): return fail(f"bw df00 failed: {r}")
# 6. verify the page now matches stock
print("\n[6] verify page == stock")
v_lo = block_read(fd, 0xDE00, 256)
v_hi = block_read(fd, 0xDF00, 256)
if v_lo is None or v_hi is None: return fail("could not read back page")
got = v_lo + v_hi
if got == page:
print(f" MATCH -- 0x{LCALL_ADDR:04x} = {got[LCALL_ADDR-PAGE_ADDR:LCALL_ADDR-PAGE_ADDR+3].hex(' ')}")
else:
diffs = [i for i in range(512) if got[i] != page[i]]
print(f" MISMATCH: {len(diffs)} bytes differ; first: {diffs[:8]}")
return fail("write-back did not match stock")
# 7. reset the device so it boots the restored app
print("\n[7] reset device (boot restored app)")
c("reset", 5)
print(" ok reset")
print("\n[DONE] page 0xDE00 restored to stock. The LCALL at 0xDF28 now targets")
print(" 0xA57B again, so the USB-MIDI dispatcher is intact. The device")
print(" should enumerate and respond to MIDI / SysEx. Test it, then if")
print(" you want a full factory-fresh image, reflash v2.2.1 over USB.")
return 0
def block_read(fd, addr, n):
lc = n if n < 256 else 0
r = cdc.cmd(fd, f"read {addr:04x} {lc}", 12).strip()
if r.startswith("data "):
try:
b = bytes.fromhex(r.split()[3])
if len(b) == n: return b
except (IndexError, ValueError):
pass
return None
def fail(msg):
print(f"\n[FAIL] {msg}")
print(" The device is NO worse than before (bootloader untouched).")
print(" Re-run this script to retry.")
return 1
if __name__ == "__main__":
sys.exit(main())
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
# verify.py -- re-read specific flash regions N times to check whether the
# bytes are stable (real flash) or vary (C2 read errors). Also re-reads the
# known-intact 0xE800 page as a control.
import os, sys, time, select, termios
DEV = os.environ.get("C2PROBE_DEV", "/dev/cu.usbmodem2101")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdc
REGIONS = [
("0xE800 (control, intact)", 0xE800, 256),
("0xEDFF (big diff run)", 0xEDFF, 256),
("0xEF00 (within diff run)", 0xEF00, 128),
("0xF1D0 (scattered diffs)", 0xF1D0, 64),
("0xF200 (diff)", 0xF200, 16),
("0xF800 (6B diff @app end)", 0xF800, 16),
("0xFC00 (gap region)", 0xFC00, 64),
]
N = 4
def main():
fd = cdc.open_port()
cdc.drain(fd, 0.4)
r = cdc.cmd(fd, "piinit", 12)
print("piinit:", r)
if r.strip() != "ok piinit":
print("piinit failed"); return 1
for name, addr, n in REGIONS:
reads = []
ok = True
for _ in range(N):
lc = n if n < 256 else 0
r = cdc.cmd(fd, f"read {addr:04x} {lc}", 12).strip()
if r.startswith("data "):
parts = r.split()
try:
reads.append(bytes.fromhex(parts[3]))
except (IndexError, ValueError):
reads.append(None); ok = False
else:
reads.append(None); ok = False
# stability check
good = [b for b in reads if b is not None and len(b) == n]
stable = len(set(good)) == 1 and len(good) == N
print(f"\n{name} @0x{addr:04x} len={n}")
if not good:
print(" ALL READS FAILED")
continue
for i, b in enumerate(reads):
tag = "ok " if (b is not None and len(b)==n) else "FAIL"
print(f" [{i}] {tag} {b.hex() if b else ''}")
print(f" stable={stable} distinct={len(set(good))}")
return 0
if __name__ == "__main__":
sys.exit(main())
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Minimal but complete MCS-51 (8051) disassembler."""
SFR = {
0x80: 'P0', 0x81: 'SP', 0x82: 'DPL', 0x83: 'DPH', 0x84: 'DPL1', 0x85: 'DPH1',
0x86: 'DPS', 0x87: 'PCON', 0x88: 'TCON', 0x89: 'TMOD', 0x8A: 'TL0', 0x8B: 'TL1',
0x8C: 'TH0', 0x8D: 'TH1', 0x8E: 'CKCON', 0x90: 'P1', 0x91: 'TMR3CN',
0x98: 'SCON0', 0x99: 'SBUF0', 0xA0: 'P2', 0xA8: 'IE', 0xB0: 'P3', 0xB8: 'IP',
0xC0: 'SMB0CN', 0xC8: 'TMR2CN', 0xD0: 'PSW', 0xD8: 'WDTCN', 0xE0: 'ACC',
0xE8: 'EIE1', 0xF0: 'B', 0xF8: 'EIP1',
}
BITSFR = {0xD0: 'PSW', 0xD8: 'WDTCN', 0xE0: 'ACC', 0xF0: 'B', 0x80: 'P0', 0x88: 'TCON',
0x90: 'P1', 0x98: 'SCON0', 0xA0: 'P2', 0xA8: 'IE', 0xB0: 'P3', 0xB8: 'IP',
0xC0: 'SMB0CN', 0xC8: 'TMR2CN'}
def d(a):
return SFR.get(a, f'0x{a:02X}')
def bit(a):
base = a & 0xF8 if a >= 0x80 else 0x20 + (a >> 3)
if a >= 0x80:
return f'{BITSFR.get(base, f"0x{base:02X}")}.{a & 7}'
return f'0x{base:02X}.{a & 7}'
# name, length, operand formatter
def _(n, l, f=None):
return (n, l, f)
def build():
t = {}
simple = {
0x00: 'NOP', 0x03: 'RR A', 0x04: 'INC A', 0x06: 'INC @R0', 0x07: 'INC @R1',
0x13: 'RRC A', 0x14: 'DEC A', 0x16: 'DEC @R0', 0x17: 'DEC @R1',
0x22: 'RET', 0x23: 'RL A', 0x26: 'ADD A,@R0', 0x27: 'ADD A,@R1',
0x32: 'RETI', 0x33: 'RLC A', 0x36: 'ADDC A,@R0', 0x37: 'ADDC A,@R1',
0x46: 'ORL A,@R0', 0x47: 'ORL A,@R1', 0x56: 'ANL A,@R0', 0x57: 'ANL A,@R1',
0x66: 'XRL A,@R0', 0x67: 'XRL A,@R1', 0x73: 'JMP @A+DPTR',
0x83: 'MOVC A,@A+PC', 0x84: 'DIV AB', 0x93: 'MOVC A,@A+DPTR',
0x96: 'SUBB A,@R0', 0x97: 'SUBB A,@R1', 0xA3: 'INC DPTR', 0xA4: 'MUL AB',
0xA5: 'DB 0A5h', 0xB3: 'CPL C', 0xC3: 'CLR C', 0xC4: 'SWAP A',
0xC6: 'XCH A,@R0', 0xC7: 'XCH A,@R1', 0xD3: 'SETB C', 0xD4: 'DA A',
0xD6: 'XCHD A,@R0', 0xD7: 'XCHD A,@R1', 0xE0: 'MOVX A,@DPTR',
0xE2: 'MOVX A,@R0', 0xE3: 'MOVX A,@R1', 0xE4: 'CLR A',
0xE6: 'MOV A,@R0', 0xE7: 'MOV A,@R1', 0xF0: 'MOVX @DPTR,A',
0xF2: 'MOVX @R0,A', 0xF3: 'MOVX @R1,A', 0xF4: 'CPL A',
0xF6: 'MOV @R0,A', 0xF7: 'MOV @R1,A',
}
for k, v in simple.items():
t[k] = (v, 1, None)
# register-form groups: base opcode -> template with {r}
for base, tmpl in ((0x08, 'INC R{r}'), (0x18, 'DEC R{r}'), (0x28, 'ADD A,R{r}'),
(0x38, 'ADDC A,R{r}'), (0x48, 'ORL A,R{r}'), (0x58, 'ANL A,R{r}'),
(0x68, 'XRL A,R{r}'), (0x98, 'SUBB A,R{r}'), (0xC8, 'XCH A,R{r}'),
(0xE8, 'MOV A,R{r}'), (0xF8, 'MOV R{r},A')):
for r in range(8):
t[base + r] = (tmpl.format(r=r), 1, None)
for r in range(8):
t[0x78 + r] = (f'MOV R{r},#{{i}}', 2, 'imm')
t[0x88 + r] = (f'MOV {{d}},R{r}', 2, 'dir')
t[0xA8 + r] = (f'MOV R{r},{{d}}', 2, 'dir')
t[0xB8 + r] = (f'CJNE R{r},#{{i}},{{t}}', 3, 'immrel')
t[0xD8 + r] = (f'DJNZ R{r},{{t}}', 2, 'rel')
two_dir = {0x05: 'INC {d}', 0x15: 'DEC {d}', 0x25: 'ADD A,{d}', 0x35: 'ADDC A,{d}',
0x42: 'ORL {d},A', 0x45: 'ORL A,{d}', 0x52: 'ANL {d},A', 0x55: 'ANL A,{d}',
0x62: 'XRL {d},A', 0x65: 'XRL A,{d}', 0x86: 'MOV {d},@R0',
0x87: 'MOV {d},@R1', 0x95: 'SUBB A,{d}', 0xA6: 'MOV @R0,{d}',
0xA7: 'MOV @R1,{d}', 0xC0: 'PUSH {d}', 0xC5: 'XCH A,{d}',
0xD0: 'POP {d}', 0xE5: 'MOV A,{d}', 0xF5: 'MOV {d},A'}
for k, v in two_dir.items():
t[k] = (v, 2, 'dir')
two_imm = {0x24: 'ADD A,#{i}', 0x34: 'ADDC A,#{i}', 0x44: 'ORL A,#{i}',
0x54: 'ANL A,#{i}', 0x64: 'XRL A,#{i}', 0x74: 'MOV A,#{i}',
0x76: 'MOV @R0,#{i}', 0x77: 'MOV @R1,#{i}', 0x94: 'SUBB A,#{i}'}
for k, v in two_imm.items():
t[k] = (v, 2, 'imm')
two_bit = {0x72: 'ORL C,{b}', 0x82: 'ANL C,{b}', 0x92: 'MOV {b},C',
0xA0: 'ORL C,/{b}', 0xA2: 'MOV C,{b}', 0xB0: 'ANL C,/{b}',
0xB2: 'CPL {b}', 0xC2: 'CLR {b}', 0xD2: 'SETB {b}'}
for k, v in two_bit.items():
t[k] = (v, 2, 'bitop')
two_rel = {0x40: 'JC {t}', 0x50: 'JNC {t}', 0x60: 'JZ {t}', 0x70: 'JNZ {t}',
0x80: 'SJMP {t}'}
for k, v in two_rel.items():
t[k] = (v, 2, 'rel')
t[0x10] = ('JBC {b},{t}', 3, 'bitrel')
t[0x20] = ('JB {b},{t}', 3, 'bitrel')
t[0x30] = ('JNB {b},{t}', 3, 'bitrel')
t[0x02] = ('LJMP {a}', 3, 'addr16')
t[0x12] = ('LCALL {a}', 3, 'addr16')
t[0x90] = ('MOV DPTR,#{a}', 3, 'addr16')
t[0x43] = ('ORL {d},#{i}', 3, 'dirimm')
t[0x53] = ('ANL {d},#{i}', 3, 'dirimm')
t[0x63] = ('XRL {d},#{i}', 3, 'dirimm')
t[0x75] = ('MOV {d},#{i}', 3, 'dirimm')
t[0x85] = ('MOV {d2},{d1}', 3, 'dirdir')
t[0xB4] = ('CJNE A,#{i},{t}', 3, 'immrel')
t[0xB5] = ('CJNE A,{d},{t}', 3, 'dirrel')
t[0xB6] = ('CJNE @R0,#{i},{t}', 3, 'immrel')
t[0xB7] = ('CJNE @R1,#{i},{t}', 3, 'immrel')
t[0xD5] = ('DJNZ {d},{t}', 3, 'dirrel')
for hi in range(8):
t[(hi << 5) | 0x01] = ('AJMP {a}', 2, 'addr11')
t[(hi << 5) | 0x11] = ('ACALL {a}', 2, 'addr11')
return t
TAB = build()
def disasm_one(mem, pc):
"""Return (text, length, target_or_None, is_call, ends_block)."""
op = mem[pc]
ent = TAB.get(op)
if ent is None:
return (f'DB 0{op:02X}h', 1, None, False, False)
name, ln, kind = ent
b = mem[pc:pc + ln]
if len(b) < ln:
return (f'DB 0{op:02X}h', 1, None, False, False)
nxt = pc + ln
tgt = None
if kind == 'imm':
s = name.format(i=f'0{b[1]:02X}h')
elif kind == 'dir':
s = name.format(d=d(b[1]))
elif kind == 'bitop':
s = name.format(b=bit(b[1]))
elif kind == 'rel':
tgt = (nxt + ((b[1] ^ 0x80) - 0x80)) & 0xFFFF
s = name.format(t=f'0x{tgt:04X}')
elif kind == 'bitrel':
tgt = (nxt + ((b[2] ^ 0x80) - 0x80)) & 0xFFFF
s = name.format(b=bit(b[1]), t=f'0x{tgt:04X}')
elif kind == 'immrel':
tgt = (nxt + ((b[2] ^ 0x80) - 0x80)) & 0xFFFF
s = name.format(i=f'0{b[1]:02X}h', t=f'0x{tgt:04X}')
elif kind == 'dirrel':
tgt = (nxt + ((b[2] ^ 0x80) - 0x80)) & 0xFFFF
s = name.format(d=d(b[1]), t=f'0x{tgt:04X}')
elif kind == 'dirimm':
s = name.format(d=d(b[1]), i=f'0{b[2]:02X}h')
elif kind == 'dirdir':
s = name.format(d1=d(b[1]), d2=d(b[2]))
elif kind == 'addr16':
a = (b[1] << 8) | b[2]
s = name.format(a=f'0x{a:04X}')
if op != 0x90:
tgt = a
elif kind == 'addr11':
a = (nxt & 0xF800) | ((op & 0xE0) << 3) | b[1]
tgt = a
s = name.format(a=f'0x{a:04X}')
else:
s = name
is_call = op in (0x12,) or (op & 0x1F) == 0x11
ends = op in (0x02, 0x22, 0x32, 0x80, 0x73) or (op & 0x1F) == 0x01
return (s, ln, tgt, is_call, ends)
class Image:
def __init__(self, path, base):
self.data = open(path, 'rb').read()
self.base = base
def __getitem__(self, k):
if isinstance(k, slice):
return self.data[k.start - self.base:k.stop - self.base]
return self.data[k - self.base]
def __contains__(self, a):
return self.base <= a < self.base + len(self.data)
def listing(img, start, count=40, labels=None):
out, pc = [], start
for _ in range(count):
if pc not in img:
break
s, ln, tgt, _c, ends = disasm_one(img, pc)
raw = bytes(img[pc:pc + ln]).hex(' ')
lab = f'{labels.get(pc,""):>12} ' if labels else ''
out.append(f'{lab}{pc:04X}: {raw:<9} {s}')
pc += ln
return '\n'.join(out)
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Patch QuNexus firmware so USB port 1 MIDI also drives the CV outputs.
Background (all addresses are in the 8051 code space of the application image,
which the bootloader programs from 0x2400 upward):
0xD3C0 USB endpoint-2 OUT service loop: reads a 4-byte USB-MIDI event from
FIFO2 into XDATA 0x0F9B, then calls the dispatcher at 0xDF05.
0xDF05 computes the CIN length, then calls the router at 0xA57B (its only
caller, via `LCALL 0xA57B` at 0xDF28).
0xA57B reads byte 0 of the event, takes the cable number (SWAP A / ANL A,#0Fh
at 0xA596) and selects the destination ring buffer:
cable 0 -> 0x0370 USB port 1 "Control Surface"
cable 1 -> 0x01C3 USB port 2 "Expander"
cable 2 -> 0x0382 USB port 3, the port feeding the CV engine
Because the CV_Out_*_MIDI_Input_Device preset enum only offers Expander / USB 3
(see CV_Out_Source in qt-qunexus/source/midiio/sysexencdecode.cpp:652), MIDI
arriving on USB 1 can never reach the CV outputs.
This patch redirects the single call site at 0xDF28 to a stub placed in unused
flash at 0xE8F2. The stub runs the original routing first (so USB 1 keeps every
existing behaviour), and then, only for cable 0, rewrites the cable nibble to 2
and routes the same event a second time -- into the USB-3/CV ring. Net effect:
USB 1 events are delivered to both their normal destination and the CV engine.
"""
import argparse
import sys
# ---------------------------------------------------------------- syx container
def sysex_messages(data):
msgs, i = [], 0
while True:
s = data.find(b"\xf0", i)
if s < 0:
break
e = data.find(b"\xf7", s)
if e < 0:
break
msgs.append(data[s:e + 1])
i = e + 1
return msgs
def decode_7in8(buf):
out = bytearray()
for i in range(0, len(buf) - 7, 8):
hi = buf[i + 7]
for j in range(7):
out.append(buf[i + j] | (0x80 if (hi >> j) & 1 else 0))
return bytes(out)
def encode_7in8(buf):
"""Inverse of midi_sx_encode_char(); caller must pad buf to a multiple of 7."""
assert len(buf) % 7 == 0
out = bytearray()
for i in range(0, len(buf), 7):
grp = buf[i:i + 7]
hi = 0
for j, c in enumerate(grp):
out.append(c & 0x7F)
if c & 0x80:
hi |= 1 << j
out.append(hi)
return bytes(out)
def split_message(msg):
"""-> (prefix bytes through SX_PACKET_START, decoded payload)."""
body = msg[1:-1]
i = 6
while i < len(body) and body[i] == 0x00:
i += 1
assert body[i] == 0x01, "SX_PACKET_START not found"
return msg[:1 + i + 1], decode_7in8(body[i + 1:])
def rebuild_message(prefix, payload):
pad = (-len(payload)) % 7
return prefix + encode_7in8(payload + b"\x00" * pad) + b"\xf7"
def crc16(data, crc=0xFFFF):
"""SysExEncDecode::crc_byte (sysexencdecode.cpp:1878), seed 0xFFFF."""
for ch in data:
temp = ((crc >> 8) ^ ch) & 0xFFFF
crc = (crc << 8) & 0xFFFF
quick = (temp ^ (temp >> 4)) & 0xFFFF
crc = (crc ^ quick) & 0xFFFF
quick = (quick << 5) & 0xFFFF
crc = (crc ^ quick) & 0xFFFF
quick = (quick << 7) & 0xFFFF
crc = (crc ^ quick) & 0xFFFF
return crc
def make_packet(addr, data, rtype=0x00):
"""Build one framed record: 03 LEN 3A LL AAAA TT <data> CC CRChi CRClo."""
rec = bytes([len(data), (addr >> 8) & 0xFF, addr & 0xFF, rtype]) + data
rec = b"\x3a" + rec + bytes([(-sum(rec)) & 0xFF])
body = bytes([0x03, len(rec) + 1]) + rec
c = crc16(body)
return body + bytes([c >> 8, c & 0xFF])
def iter_packets(payload):
"""Yield (start, end, addr, rtype, datalen) for each packet in a payload."""
i = 7
while i + 1 < len(payload):
while i < len(payload) and payload[i] == 0x00:
i += 1
if i + 1 >= len(payload) or payload[i] != 0x03:
break
ln = payload[i + 1]
end = i + 1 + ln + 2
addr = (payload[i + 4] << 8) | payload[i + 5]
yield (i, end, addr, payload[i + 6], ln - 7)
i = end
def reseal(payload, start, end):
"""Recompute the hex checksum and CRC16 of the packet at [start:end)."""
ln = payload[start + 1]
span = payload[start + 3:start + ln] # LL AAAA TT data
payload[start + ln] = (-sum(span)) & 0xFF # CC (Intel-HEX checksum)
c = crc16(bytes(payload[start:start + 1 + ln]))
payload[start + 1 + ln] = c >> 8
payload[start + 2 + ln] = c & 0xFF
# ---------------------------------------------------------------- the patch
# Where the stub goes. This MUST be flash that a stock hex record already
# covers, otherwise the bootloader may never erase/program it and the LCALL
# lands in unprogrammed flash. 0x8126 is 25 bytes of 0xFF linker padding
# between two data tables and is inside a stock record; 0xE8F2 (a 270-byte
# hole covered by NO record) was tried first and bricked MIDI input, because
# the added record was not programmed. Do not use uncovered gaps.
STUB_ADDR = 0x8126
STUB_MAX = 25 # size of the 0xFF run at STUB_ADDR
CALL_SITE = 0xDF28 # LCALL 0xA57B inside the USB-MIDI dispatcher 0xDF05
ROUTER = 0xA57B
EVENT_BUF = 0x0F9B # XDATA holding the 4-byte USB-MIDI event
STUB = bytes([
0x12, ROUTER >> 8, ROUTER & 0xFF, # LCALL 0xA57B normal routing
0x90, EVENT_BUF >> 8, EVENT_BUF & 0xFF, # MOV DPTR,#0x0F9B
0xE0, # MOVX A,@DPTR A = byte0
0x54, 0xF0, # ANL A,#0F0h cable nibble
0x70, 0x0B, # JNZ done not cable 0
0xE0, # MOVX A,@DPTR
0x44, 0x20, # ORL A,#020h cable 0 -> 2
0xF0, # MOVX @DPTR,A
0x7E, EVENT_BUF >> 8, # MOV R6,#00Fh
0x7F, EVENT_BUF & 0xFF, # MOV R7,#09Bh
0x12, ROUTER >> 8, ROUTER & 0xFF, # LCALL 0xA57B -> CV ring
0x22, # done: RET
])
NEW_CALL = bytes([0x12, STUB_ADDR >> 8, STUB_ADDR & 0xFF])
OLD_CALL = bytes([0x12, ROUTER >> 8, ROUTER & 0xFF])
def main():
ap = argparse.ArgumentParser()
ap.add_argument("infile")
ap.add_argument("outfile")
args = ap.parse_args()
data = open(args.infile, "rb").read()
msgs = [split_message(m) for m in sysex_messages(data)]
msgs = [(p, bytearray(pl)) for p, pl in msgs]
# --- 1. retarget the LCALL at 0xDF28 -------------------------------------
# An instruction can straddle two hex records, so write byte-wise and
# reseal every record touched.
index = [] # (addr, dlen, payload, start, end)
for _prefix, payload in msgs:
for start, end, addr, rtype, dlen in iter_packets(payload):
if rtype == 0x00:
index.append((addr, dlen, payload, start, end))
def locate(a):
for addr, dlen, payload, start, end in index:
if addr <= a < addr + dlen:
return payload, start, end, start + 7 + (a - addr)
return None
touched = {}
def write(addr, new, expect=None):
"""Write bytes at `addr` into whatever record(s) already cover them."""
for k, b in enumerate(new):
loc = locate(addr + k)
if loc is None:
raise SystemExit(
f"ERROR: 0x{addr + k:04X} is not covered by any hex record. "
f"The stub must live in flash a stock record already writes.")
payload, start, end, off = loc
if expect is not None and payload[off] != expect[k]:
raise SystemExit(
f"ERROR: expected 0x{expect[k]:02X} at 0x{addr + k:04X}, "
f"found 0x{payload[off]:02X}")
payload[off] = b
touched[(id(payload), start)] = (payload, start, end)
if len(STUB) > STUB_MAX:
raise SystemExit(f"ERROR: stub is {len(STUB)} bytes, only {STUB_MAX} free")
# 1. the stub, into existing 0xFF padding (verify it really is free first)
write(STUB_ADDR, STUB, expect=b"\xff" * len(STUB))
# 2. retarget the call site (may straddle two records)
write(CALL_SITE, NEW_CALL, expect=OLD_CALL)
for payload, start, end in touched.values():
reseal(payload, start, end)
print(f" {len(touched)} record(s) modified; message count and sizes unchanged")
out = b"".join(rebuild_message(p, bytes(pl)) for p, pl in msgs)
open(args.outfile, "wb").write(out)
print(f"in : {args.infile} ({len(data)} bytes, {len(msgs)} messages)")
print(f"out : {args.outfile} ({len(out)} bytes, {len(msgs)} messages)")
print(f" 0x{CALL_SITE:04X}: LCALL 0x{ROUTER:04X} -> LCALL 0x{STUB_ADDR:04X}")
print(f" 0x{STUB_ADDR:04X}: {len(STUB)}-byte stub added ({STUB.hex(' ')})")
return 0
if __name__ == "__main__":
sys.exit(main())
+2 -1
View File
@@ -32,7 +32,8 @@ from pathlib import Path
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
FWTOOLS = HERE.parent FWTOOLS = HERE.parent
sys.path.insert(0, str(FWTOOLS)) sys.path.insert(0, str(FWTOOLS)) # fallback: parent firmware-tools/
sys.path.insert(0, str(HERE)) # local copy takes precedence (self-contained)
import patch_usb1_to_cv as P # noqa: E402 import patch_usb1_to_cv as P # noqa: E402
BASE = 0x2400 BASE = 0x2400
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Extract the raw firmware image from a KMI QuNexus firmware .syx file.
Container format (mirrors SysExEncDecode in qt-qunexus/source/midiio/sysexencdecode.cpp):
F0 00 01 5F 7A 19 [pad 00...] 01 <7-in-8 encoded stream> F7
* 00 01 5F 7A = manufacturer id bytes, 19 = product, 00 = format
* 01 = SX_PACKET_START (sysexencdecode.cpp:33)
* the encoded stream packs 7 data bytes as their low 7 bits followed by one
byte holding their high bits (bit j = high bit of data byte j) --
midi_sx_encode_char(), sysexencdecode.cpp:1830, SX_ENCODE_LEN = 7
The decoded stream is a packet preamble (00 02 <cat> <type> <crc16>) followed
by framed records: 03 <len+1> <binary Intel HEX record>
where the record is 3A LL AAAA TT <data...> CC (checksum = two's complement
of the sum of LL..data), i.e. Intel HEX in binary rather than ASCII form.
"""
import sys
import argparse
def sysex_messages(data):
msgs, i = [], 0
while True:
s = data.find(b"\xf0", i)
if s < 0:
break
e = data.find(b"\xf7", s)
if e < 0:
break
msgs.append(data[s:e + 1])
i = e + 1
return msgs
def decode_7in8(buf):
"""Undo midi_sx_encode_char(): 7 low-7-bit bytes, then a high-bits byte."""
out = bytearray()
for i in range(0, len(buf) - 7, 8):
hi = buf[i + 7]
for j in range(7):
out.append(buf[i + j] | (0x80 if (hi >> j) & 1 else 0))
return bytes(out)
def decode_message(msg):
"""Strip the sysex header up to SX_PACKET_START and 7-in-8 decode the body."""
body = msg[1:-1] # drop F0 / F7
i = 6 # manufacturer id (4) + product + format
while i < len(body) and body[i] == 0x00:
i += 1 # padding before the packet start
if i >= len(body) or body[i] != 0x01: # SX_PACKET_START
return b""
return decode_7in8(body[i + 1:])
def parse_hex_records(stream):
"""Scan the decoded stream for checksum-valid binary Intel HEX records."""
records, i, skipped = [], 0, 0
while i < len(stream):
if stream[i] != 0x3A:
i += 1
skipped += 1
continue
if i + 5 > len(stream):
break
ln = stream[i + 1]
end = i + 5 + ln # 3A LL AA AA TT data...
if end >= len(stream):
break
rec = stream[i + 1:end] # LL AAAA TT data (checksummed span)
if (sum(rec) + stream[end]) & 0xFF != 0:
i += 1 # not a real record, keep scanning
skipped += 1
continue
records.append((stream[i + 4], # type
(stream[i + 2] << 8) | stream[i + 3], # address
bytes(rec[4:]))) # data
i = end + 1
return records, skipped
def build_image(records):
"""Apply Intel HEX records (types 00/01/02/04) to a sparse address space."""
mem, base, eof = {}, 0, False
for rtype, addr, data in records:
if rtype == 0x00:
for k, b in enumerate(data):
mem[base + addr + k] = b
elif rtype == 0x01:
eof = True
elif rtype == 0x02 and len(data) == 2:
base = ((data[0] << 8) | data[1]) << 4
elif rtype == 0x04 and len(data) == 2:
base = ((data[0] << 8) | data[1]) << 16
return mem, eof
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("syx")
ap.add_argument("-o", "--out", help="write flat binary image here")
ap.add_argument("--hex", help="also write a standard ASCII .hex file here")
ap.add_argument("--fill", default="0xFF", help="gap fill byte (default 0xFF)")
args = ap.parse_args()
data = open(args.syx, "rb").read()
msgs = sysex_messages(data)
stream = b"".join(decode_message(m) for m in msgs)
records, skipped = parse_hex_records(stream)
mem, eof = build_image(records)
print(f"file : {args.syx}")
print(f"sysex messages : {len(msgs)}")
print(f"decoded stream : {len(stream)} bytes ({skipped} non-record bytes skipped)")
print(f"hex records : {len(records)} (EOF record seen: {eof})")
if not mem:
print("no data records found", file=sys.stderr)
return 1
lo, hi = min(mem), max(mem)
types = sorted({t for t, _, _ in records})
print(f"record types : {[hex(t) for t in types]}")
print(f"address range : 0x{lo:08X} - 0x{hi:08X} ({hi - lo + 1} bytes span)")
print(f"bytes covered : {len(mem)} (gaps: {hi - lo + 1 - len(mem)})")
fill = int(args.fill, 0)
img = bytes(mem.get(a, fill) for a in range(lo, hi + 1))
if args.out:
open(args.out, "wb").write(img)
print(f"wrote : {args.out} ({len(img)} bytes, base 0x{lo:08X})")
if args.hex:
lines, base = [], None
for a in range(lo, hi + 1, 16):
chunk = bytes(mem.get(a + k, fill) for k in range(min(16, hi + 1 - a)))
upper = a >> 16
if upper != base:
base = upper
rec = bytes([2, 0, 0, 4, upper >> 8, upper & 0xFF])
lines.append(":" + (rec + bytes([(-sum(rec)) & 0xFF])).hex().upper())
rec = bytes([len(chunk), (a >> 8) & 0xFF, a & 0xFF, 0]) + chunk
lines.append(":" + (rec + bytes([(-sum(rec)) & 0xFF])).hex().upper())
lines.append(":00000001FF")
open(args.hex, "w").write("\n".join(lines) + "\n")
print(f"wrote : {args.hex}")
return 0
if __name__ == "__main__":
sys.exit(main())