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