Add tooling + EFM8UB20 QFP48 pinout doc
Make the repo self-contained (no dependency on the parent firmware-tools/ checkout): copy in the patch/disasm/syx tools and the c2probe RP2040 C2 flash-reader/patcher firmware. - patch_usb1_to_cv.py : byte-level USB-1->CV patch (imported by roundtrip.py) - d8051.py : standalone 8051 disassembler - syx_extract.py : SysEx extractor - c2probe/ : RP2040 C2 flash reader + host scripts (c2probe.c, cdc/dump_flash/patch_c2/reflash_page/verify.py, CMake build) - RECOVERY.md : C2 flash recovery procedure - EFM8UB20_PINOUT.md : reverse-engineered QFP48 pinout + firmware pin usage - roundtrip.py : import local patch_usb1_to_cv (parent as fallback) - .gitignore : exclude c2probe/.venv, c2probe/build Verified: stock + patched round-trips still re-assemble byte-identical.
This commit is contained in:
@@ -2,3 +2,6 @@ __pycache__/
|
|||||||
*.lst
|
*.lst
|
||||||
*.sym
|
*.sym
|
||||||
*.asm.bak
|
*.asm.bak
|
||||||
|
# c2probe (RP2040) build artifacts
|
||||||
|
c2probe/.venv/
|
||||||
|
c2probe/build/
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# EFM8UB20F64G (QFP48) — reverse-engineered pinout & firmware pin usage
|
||||||
|
|
||||||
|
QuNexus main MCU = **EFM8UB20F64G-B-QFP48** (48-pin TQFP). App firmware v2.2.1.
|
||||||
|
|
||||||
|
> There is **no QFN48** variant of the EFM8UB20F64G — the datasheet lists only
|
||||||
|
> QFP48, QFP32, and QFN32. The 48-pin part is therefore QFP48, and the QFP48
|
||||||
|
> ADC channel map is the one that matches the firmware.
|
||||||
|
|
||||||
|
All findings below are derived from the disassembly (`qunexus_v2.2.1.asm`) and
|
||||||
|
verified against the SiLabs header (SFR addresses) and the EFM8UB2 reference
|
||||||
|
manual (ADC channel→pin map). The pin *configuration* and *which pins are
|
||||||
|
actively toggled* are directly proven from the code; the identity of the
|
||||||
|
external device on the P2/P3/P4 bus is inferred from the bus structure.
|
||||||
|
|
||||||
|
## Port configuration
|
||||||
|
|
||||||
|
The entire crossbar/port setup is one block at `0xde6f`, written once
|
||||||
|
(firmware-wide — XBR / MDOUT / MDIN / SKIP are never written elsewhere):
|
||||||
|
|
||||||
|
| Register | Value | Meaning |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| XBR0 | 0x01 | **UART0 only** — no SPI, no SMBus, no comparators routed |
|
||||||
|
| XBR1 | 0x43 | crossbar enabled (XBARE = 1) |
|
||||||
|
| XBR2 | 0x00 | default, never written — UART1/SMB1 off |
|
||||||
|
| P0SKIP | 0xCF | skip P0.0-3,6,7 → leave P0.4/P0.5 for UART0 |
|
||||||
|
| P1SKIP | 0x03 | skip P1.0/P1.1 |
|
||||||
|
| P2SKIP | 0x00 | default, never written |
|
||||||
|
| P3SKIP | 0x00 | default (P3SKIP = 0xDF, never written) |
|
||||||
|
| P0MDIN | 0x30 | P0.4/P0.5 digital; P0.0-3,6,7 **analog** |
|
||||||
|
| P1MDIN | 0x3F | P1.0-5 digital; P1.6/P1.7 **analog** |
|
||||||
|
| P2/P3/P4MDIN | 0xFF | all digital |
|
||||||
|
| P0MDOUT | 0x10 | P0.4 push-pull (TX); rest open-drain |
|
||||||
|
| P1MDOUT | 0x3F | P1.0-5 push-pull; P1.6/7 open-drain |
|
||||||
|
| P2MDOUT | 0xFF | all push-pull |
|
||||||
|
| P3MDOUT | 0xF0 | P3.4-7 push-pull; **P3.0-3 open-drain** |
|
||||||
|
| P4MDOUT | 0xFF | all push-pull |
|
||||||
|
|
||||||
|
> The P4 port latch is SFR **0xC7** — not 0xC0 (0xC0 is SMB0CN0, the SMBus
|
||||||
|
> control register). Easy to misidentify.
|
||||||
|
|
||||||
|
## Peripherals actually used
|
||||||
|
|
||||||
|
- **UART0** → P0.4 (TX) / P0.5 (RX) = the 5-pin DIN MIDI port. (USB-MIDI is a
|
||||||
|
separate class engine over D+/D−, not this UART.)
|
||||||
|
- **ADC0**: `AMX0P = 0x11` → **P0.3** (ADC0P.17 on QFP48), `AMX0N = 0x1f`
|
||||||
|
(GND, single-ended), `ADC0CN0 = 0x02` (enabled). AMX0P is written **exactly
|
||||||
|
once**, so the firmware reads a single fixed ADC channel. ADC0L/H (0xBD/0xBE)
|
||||||
|
are read at `0x8a37`, `0xa838`, `0xcadd`. → An **external analog mux**
|
||||||
|
(steered by the digital scan bus) feeds many sensors into P0.3.
|
||||||
|
- **No SPI0, no SMB0, no hardware EMIF** — none of their config registers are
|
||||||
|
ever written. The parallel bus below is **bit-banged**.
|
||||||
|
|
||||||
|
## Runtime GPIO — a bit-banged parallel bus to an external engine
|
||||||
|
|
||||||
|
The EFM8 is not driving the LEDs/touch directly; it talks to an external chip
|
||||||
|
(CPLD / expander / LED+touch controller) over a hand-strobed parallel bus, and
|
||||||
|
advances a select matrix on a timer tick:
|
||||||
|
|
||||||
|
- **P2.0–P2.7** (push-pull): written from a Timer ISR — `mov P2,a` at `0xc537`
|
||||||
|
+ `setb TR2`, ends `reti`. The `rlc a` / `djnz` / `cpl a` loop builds a
|
||||||
|
walking one-hot pattern → **scan / select bus**.
|
||||||
|
- **P3.0–P3.3** (open-drain): toggled with `orl/anl P3,#0x0f` (`0xc942`,
|
||||||
|
`0xc997`, `0xca89`, `0xcad1`, `0xcad8`, `0xd774`) → **control strobes**.
|
||||||
|
- **P4.0–P4.7** (push-pull, SFR 0xC7): `mov P4,a` (`0xc950`, `0xc999`, `0xd778`),
|
||||||
|
always paired with a P3 strobe → **8-bit data bus**.
|
||||||
|
- **P1.5** (push-pull): toggled (`clr P1.5` @ `0x860e`, `mov` @ `0xe38f`) →
|
||||||
|
GPIO output, function unknown. P1.0/P1.1 are skipped = reserved GPIO.
|
||||||
|
|
||||||
|
## Full QFP48 pin table (firmware function)
|
||||||
|
|
||||||
|
| Pin | Port | Firmware function | Used? |
|
||||||
|
|-----|------|--------------------|-------|
|
||||||
|
| 1 | P0.5 | UART0 RX — MIDI In | ✓ |
|
||||||
|
| 2 | P0.4 | UART0 TX — MIDI Out | ✓ |
|
||||||
|
| 3 | P0.3 | **ADC input** (single channel, ext. mux output) | ✓ |
|
||||||
|
| 4 | P0.2 | analog, no ADC/CMP fn | ○ unused |
|
||||||
|
| 5 | P0.1 | analog, no ADC/CMP fn | ○ unused |
|
||||||
|
| 6 | P0.0 | analog, no ADC/CMP fn | ○ unused |
|
||||||
|
| 7 | GND | ground | — |
|
||||||
|
| 8 | D+ | USB (USB-MIDI class) | ✓ |
|
||||||
|
| 9 | D− | USB | ✓ |
|
||||||
|
| 10 | VDD | supply / reg output | — |
|
||||||
|
| 11 | VREGIN | 5V reg input | — |
|
||||||
|
| 12 | VBUS | USB VBUS sense | ✓ |
|
||||||
|
| 13 | RST/C2CK | reset / C2 flash clock | ✓ |
|
||||||
|
| 14 | C2D | C2 flash data | ✓ |
|
||||||
|
| 15 | P4.7 | data bus D7 | ✓ |
|
||||||
|
| 16 | P4.6 | data bus D6 | ✓ |
|
||||||
|
| 17 | P4.5 | data bus D5 | ✓ |
|
||||||
|
| 18 | P4.4 | data bus D4 | ✓ |
|
||||||
|
| 19 | P4.3 | data bus D3 | ✓ |
|
||||||
|
| 20 | P4.2 | data bus D2 | ✓ |
|
||||||
|
| 21 | P4.1 | data bus D1 | ✓ |
|
||||||
|
| 22 | P4.0 | data bus D0 | ✓ |
|
||||||
|
| 23 | P3.7 | push-pull out, never written | ○ static |
|
||||||
|
| 24 | P3.6 | push-pull out, referenced once | ○ ~unused |
|
||||||
|
| 25 | P3.5 | push-pull out, never written | ○ static |
|
||||||
|
| 26 | P3.4 | push-pull out, never written | ○ static |
|
||||||
|
| 27 | P3.3 | open-drain **strobe** | ✓ |
|
||||||
|
| 28 | P3.2 | open-drain **strobe** | ✓ |
|
||||||
|
| 29 | P3.1 | open-drain **strobe** | ✓ |
|
||||||
|
| 30 | P3.0 | open-drain **strobe** | ✓ |
|
||||||
|
| 31 | P2.7 | **scan/select** (timer ISR) | ✓ |
|
||||||
|
| 32 | P2.6 | scan/select | ✓ |
|
||||||
|
| 33 | P2.5 | scan/select | ✓ |
|
||||||
|
| 34 | P2.4 | scan/select | ✓ |
|
||||||
|
| 35 | P2.3 | scan/select | ✓ |
|
||||||
|
| 36 | P2.2 | scan/select | ✓ |
|
||||||
|
| 37 | P2.1 | scan/select | ✓ |
|
||||||
|
| 38 | P2.0 | scan/select | ✓ |
|
||||||
|
| 39 | P1.7 | analog (EMIF /WR not used) | ○ unused |
|
||||||
|
| 40 | P1.6 | analog (EMIF /RD not used) | ○ unused |
|
||||||
|
| 41 | P1.5 | GPIO output (toggled) | ✓ |
|
||||||
|
| 42 | P1.4 | CNVSTR/GPIO (ADC is SW-triggered) | ○ ~unused |
|
||||||
|
| 43 | P1.3 | push-pull GPIO | ○ ~unused |
|
||||||
|
| 44 | P1.2 | push-pull GPIO | ○ ~unused |
|
||||||
|
| 45 | P1.1 | skipped GPIO | ○ ~unused |
|
||||||
|
| 46 | P1.0 | skipped GPIO | ○ ~unused |
|
||||||
|
| 47 | P0.7 | XTAL2 — internal oscillator | ○ unused |
|
||||||
|
| 48 | P0.6 | XTAL1 — internal oscillator | ○ unused |
|
||||||
|
|
||||||
|
Legend: ✓ actively driven/read by firmware · ○ configured but no active drive
|
||||||
|
found (likely unused/static) · — power/USB/debug (hardware).
|
||||||
|
|
||||||
|
## Architecture summary
|
||||||
|
|
||||||
|
The EFM8UB20 is essentially a **USB-MIDI class engine + DIN-MIDI UART + bus
|
||||||
|
master**, not the thing doing the LED/touch work:
|
||||||
|
|
||||||
|
- **USB** (pins 8/9/12) = USB-MIDI class traffic.
|
||||||
|
- **UART0** (pins 1/2) = 5-pin DIN MIDI in/out.
|
||||||
|
- **ADC** (pin 3, P0.3) = one analog channel reading an external analog mux.
|
||||||
|
- **Bit-banged parallel bus** to an external LED/touch engine: **P4 = 8-bit
|
||||||
|
data** (pins 15–22), **P3.0–3 = strobes** (pins 27–30), **P2 = scan/select**
|
||||||
|
(pins 31–38, advanced by a timer ISR).
|
||||||
|
- **C2** (pins 13/14) = how we flash/read it.
|
||||||
|
|
||||||
|
Used pins: **1, 2, 3** (MIDI + ADC), **8, 9, 11, 12** (USB), **13, 14** (C2
|
||||||
|
debug), **15–22** (P4 data), **27–30** (P3 strobes), **31–38** (P2 scan),
|
||||||
|
**41** (P1.5 GPIO). Everything else is configured but shows no active drive.
|
||||||
|
|
||||||
|
## Caveat
|
||||||
|
|
||||||
|
The "external LED/touch engine" on the P2/P3/P4 bus is an inference from the
|
||||||
|
bus structure and the walking-one scan pattern — the firmware's driving of the
|
||||||
|
bus is directly visible, but what sits on the other end can only be confirmed
|
||||||
|
from board photos or a schematic.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- EFM8UB2 Reference Manual, Table 12.1 (AMX0P ADC channel→pin map):
|
||||||
|
https://www.silabs.com/documents/public/reference-manuals/efm8ub2-rm.pdf
|
||||||
|
- EFM8UB20F64G-B-QFP48 datasheet, Table 6.1 (QFP48 pin definitions):
|
||||||
|
https://resources.ampheo.com/static/datasheets/silicon-labs/efm8ub20f64g-b-qfp48.pdf
|
||||||
|
- si_efm8ub2_defs.h (SFR address verification):
|
||||||
|
https://www.keil.com/dd/docs/c51/silabs/efm8ub2/inc/si_efm8ub2_defs.h
|
||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
# QuNexus unresponsive after flashing a modified firmware image — technical report
|
||||||
|
|
||||||
|
Prepared for KMI support (or anyone attempting recovery). Everything below is
|
||||||
|
observed fact except where marked as hypothesis.
|
||||||
|
|
||||||
|
## Device
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Product | QuNexus (RED), USB VID `0x1F38` PID `0x0018`, bcdDevice `0x0200` |
|
||||||
|
| Bootloader version | 1.1.0 (reported by identity request **before** the flash) |
|
||||||
|
| Application version before flash | 2.2.1 |
|
||||||
|
| Host | macOS (Apple silicon), CoreMIDI |
|
||||||
|
|
||||||
|
## What was flashed
|
||||||
|
|
||||||
|
A modified copy of `QuNexus_Firmware_v2.2.1-cs512.syx` (the image shipped in the
|
||||||
|
qunexus-qt6 editor's Qt resources, `resources.qrc:83`).
|
||||||
|
|
||||||
|
Sent with `SendSysEx` v0.10.0 in **raw send mode** (`-n <port> -f <file>`) after
|
||||||
|
`QunexusEnterBootloader.syx`, using the default transfer settings:
|
||||||
|
`-cs 512 -cd 100 -pd 500`.
|
||||||
|
|
||||||
|
**Open question:** it is not known whether the transfer ran to completion. Per
|
||||||
|
`SendSysEx --help`, for multi-message (chunked) files an identity-reply handshake
|
||||||
|
is performed between every chunk, with `-cd` (default **100 ms**) as the timeout,
|
||||||
|
"aborting the transfer otherwise". The console output was not retained. If the
|
||||||
|
transfer aborted partway, the application region is **partially written**, which
|
||||||
|
would be an additional and independent defect on top of the modification below.
|
||||||
|
|
||||||
|
## The modification
|
||||||
|
|
||||||
|
Two edits relative to the stock v2.2.1 image (which spans `0x2400`–`0xF806`):
|
||||||
|
|
||||||
|
1. `0xDF28`: `LCALL 0xA57B` → `LCALL 0xE8F2` (2 bytes changed, at `0xDF29`)
|
||||||
|
2. `0xE8F2`: a new 23-byte routine:
|
||||||
|
|
||||||
|
```asm
|
||||||
|
E8F2: 12 a5 7b LCALL 0xA57B ; original USB-MIDI cable routing
|
||||||
|
E8F5: 90 0f 9b MOV DPTR,#0x0F9B ; the 4-byte USB-MIDI event buffer
|
||||||
|
E8F8: e0 MOVX A,@DPTR
|
||||||
|
E8F9: 54 f0 ANL A,#0F0h ; isolate the cable number
|
||||||
|
E8FB: 70 0b JNZ 0xE908 ; not cable 0 -> done
|
||||||
|
E8FD: e0 MOVX A,@DPTR
|
||||||
|
E8FE: 44 20 ORL A,#020h ; retag cable 0 as cable 2
|
||||||
|
E900: f0 MOVX @DPTR,A
|
||||||
|
E901: 7e 0f MOV R6,#00Fh
|
||||||
|
E903: 7f 9b MOV R7,#09Bh
|
||||||
|
E905: 12 a5 7b LCALL 0xA57B ; route again, into the USB-3/CV ring
|
||||||
|
E908: 22 RET
|
||||||
|
```
|
||||||
|
|
||||||
|
Intent: have MIDI arriving on USB port 1 also reach the CV engine, since
|
||||||
|
`CV_Out_Source` only offers Expander / USB 3.
|
||||||
|
|
||||||
|
**The mistake:** `0xE8F2` lies in a 270-byte address range (`0xE8F2`–`0xE9FF`)
|
||||||
|
that **no hex record in the stock image covers**. A new record was added for it.
|
||||||
|
Whether the bootloader erases a flash page it otherwise never writes was never
|
||||||
|
verified.
|
||||||
|
|
||||||
|
## Symptoms after flashing
|
||||||
|
|
||||||
|
- Device enumerates normally and repeatedly: correct VID/PID, correct product
|
||||||
|
string, correct MIDI port names ("QuNexus Control Surface" / "Expander" / "CV").
|
||||||
|
So USB init, the descriptor tables, and the USB interrupt path are intact.
|
||||||
|
- **No response to any MIDI input.** A universal identity request
|
||||||
|
(`F0 7E 7F 06 01 F7`) gets no reply at an 8-second timeout.
|
||||||
|
- `QunexusEnterBootloader.syx` has no effect, sent to any of the three ports.
|
||||||
|
- **No CV output**, from USB or from the local keys.
|
||||||
|
|
||||||
|
## Diagnostics performed
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| USB presence / PID (`ioreg`) | present, PID `0x0018` = application |
|
||||||
|
| Identity request, Control Surface port | no reply (8 s) |
|
||||||
|
| Bootloader-entry SysEx to all 3 ports | no state change |
|
||||||
|
| Power-on bootloader window | **none** — kernel log shows exactly one enumeration per attach, always PID `0018`; the bootloader never appears on the bus |
|
||||||
|
| USB vendor control request path | none — the EP0 handler at `0x7873` dispatches only Class (`0x20`) and Standard requests |
|
||||||
|
| DIN MIDI in | separate UART path exists (`0xA90D` reads `SBUF0` → `0xE2AA` → 24-byte ring at `0x0F31`), but no Expander hardware available to test |
|
||||||
|
|
||||||
|
## Most probable mechanism: a second page erase destroyed 236 bytes of code
|
||||||
|
|
||||||
|
`0xE8F2` lies in the 512-byte flash page `0xE800`–`0xE9FF`. The absence of a
|
||||||
|
stock record for `0xE8F2`–`0xE9FF` meant only that the linker placed nothing
|
||||||
|
there — **not** that the page was unused. In the stock image, `0xE800`–`0xE8F1`
|
||||||
|
(the same page, just below the stub address) contains:
|
||||||
|
|
||||||
|
- 236 bytes of real code
|
||||||
|
- 27 branch targets, 19 of them functions with live callers
|
||||||
|
- 40+ call sites spread across the entire firmware (`0xE888` from 9 sites,
|
||||||
|
`0xE893` from 5, `0xE8D5` from 4, `0xE834` from 4) — the profile of compiler
|
||||||
|
runtime helpers
|
||||||
|
|
||||||
|
The added record was appended as a **new SysEx message immediately before the
|
||||||
|
EOF record**, i.e. last in the transfer, long after the bootloader had already
|
||||||
|
erased page `0xE800` and programmed those 236 bytes. To program into that page
|
||||||
|
again the bootloader must erase all 512 bytes of it. That erase would have
|
||||||
|
destroyed the 236 bytes of legitimate code, leaving only the 23 stub bytes.
|
||||||
|
|
||||||
|
This accounts for every observed symptom simultaneously: calls to any of those
|
||||||
|
19 addresses now land in erased flash (`0xFF` = `MOV R7,A`) and run forward into
|
||||||
|
unrelated code, so MIDI input never completes and the main loop derails, while
|
||||||
|
interrupt-driven USB enumeration continues to work.
|
||||||
|
|
||||||
|
This has not been confirmed by reading the device's flash back, which is not
|
||||||
|
possible without C2 access. A transfer aborted by the 100 ms inter-chunk
|
||||||
|
handshake (see "Open question" above) would be an additional, independent cause
|
||||||
|
of missing data.
|
||||||
|
|
||||||
|
**Implication for recovery:** a full reflash of `0x2400`–`0xF806` from the stock
|
||||||
|
image restores everything; no permanent damage is expected.
|
||||||
|
|
||||||
|
## C2 flash read — confirmed actual state (2026-08-17)
|
||||||
|
|
||||||
|
The hypothesis above ("a second page erase destroyed 236 bytes of code") is
|
||||||
|
**DISPROVEN by reading the device's flash back over C2**. An RP2040 was wired
|
||||||
|
to the EFM8 (GP2→C2CK/pin 13, GP3→C2D/pin 14) and a read-only C2 flash reader
|
||||||
|
was built (`firmware-tools/c2probe/`, Pico SDK, implements only FPDAT Block
|
||||||
|
Read 0x06 — no erase/write path). DEVICEID=`0x28` (EFM8UB2) confirmed. The full
|
||||||
|
64 KB was dumped to `firmware-tools/out/qunexus_device_dump.bin` and diffed
|
||||||
|
against the stock image. Findings:
|
||||||
|
|
||||||
|
| Address range | Stock | Device | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `0xE800`–`0xE8EB` (236 B of code) | real code | **identical** to stock | **INTACT — page was never erased** |
|
||||||
|
| `0xE8F2`–`0xE908` (23-B stub site) | `0xFF` (gap) | `0xFF` | **stub never programmed** |
|
||||||
|
| `0xDF29`–`0xDF2A` (LCALL target) | `A5 7B` | `E8 F2` | retarget **was** written |
|
||||||
|
| `0xFBFF` (lock byte) | — | `0xFF` | unlocked |
|
||||||
|
| `0xFC00`–`0xFFFF` (top 1 KB) | — | read fails | reserved/lock page (not app) |
|
||||||
|
|
||||||
|
So the actual mechanism is the **transfer-abort** branch of the "Open question",
|
||||||
|
not the page-erase branch: the `0xDF29` retarget sits in an existing record
|
||||||
|
early in the transfer and was programmed; the appended `0xE8F2` record was
|
||||||
|
*last* and was never sent before the 100 ms inter-chunk handshake aborted the
|
||||||
|
transfer. Page `0xE800` was therefore never erased, the 236 bytes survived, and
|
||||||
|
the stub was never written. The call at `0xDF28` now does `LCALL 0xE8F2`, which
|
||||||
|
lands in `0xFF` flash (`MOV R7,A` then runs forward into `0xFF`...) and derails
|
||||||
|
the USB-MIDI dispatcher `0xDF05` for every event on every USB port — matching
|
||||||
|
"no response to any MIDI input" while interrupt-driven USB enumeration survives.
|
||||||
|
|
||||||
|
Other differences from the stock image are **not** damage:
|
||||||
|
|
||||||
|
- `0xF000`–`0xF806`: user configuration (MIDI routing / per-channel / CV
|
||||||
|
settings). The stock `.syx` carries factory defaults; this unit was
|
||||||
|
personalized. The per-channel blocks at `0xF200`/`0xF400`/`0xF600` carry
|
||||||
|
channel-index bytes `01`/`02`/`03` and differ only in config values
|
||||||
|
(`64 0F 14`→`64 01 0A`, etc.). Expected on a used device.
|
||||||
|
- `0xEE00`–`0xEF5A`: ~348 bytes in a stock **gap** (no record covers it). The
|
||||||
|
bootloader only erases pages it has records for, so data written here by an
|
||||||
|
earlier image persists across reflashes. Harmless — stock code does not
|
||||||
|
reference it.
|
||||||
|
|
||||||
|
**This revises the recovery outlook materially.** The "no software recovery
|
||||||
|
path" conclusion below was premised on `0xE8C5` (UART MIDI byte processor) and
|
||||||
|
`0xE8E6` (`LJMP 0x0000`, the bootloader-entry jump) being erased. **They are
|
||||||
|
not erased — both are intact** (inside the surviving 236 bytes). The only thing
|
||||||
|
broken is the single `LCALL` at `0xDF28`. Consequently:
|
||||||
|
|
||||||
|
- **Minimal C2 fix:** erase page `0xDF00`–`0xDFFF` (app region — **not** the
|
||||||
|
bootloader) and reprogram it with the stock bytes, restoring `0xDF29`=`A5 7B`.
|
||||||
|
That alone un-breaks the USB-MIDI receive path.
|
||||||
|
- **Then normal recovery works:** with MIDI input restored, the SysEx
|
||||||
|
bootloader-entry command reaches `0xB48D` → `0xE744` → `0xE8E6`
|
||||||
|
(`LJMP 0x0000`) → bootloader, and the stock image can be reflashed over USB
|
||||||
|
exactly as before the incident. No permanent damage; the bootloader region
|
||||||
|
`0x0000`–`0x23FF` was never touched and must still not be erased.
|
||||||
|
|
||||||
|
Either way only `0x2400`–`0xF806` should be programmed; no mass erase.
|
||||||
|
|
||||||
|
## C2 flash write — recovery executed (2026-08-17)
|
||||||
|
|
||||||
|
The minimal C2 fix above was performed. The c2probe firmware was extended with
|
||||||
|
guarded Page Erase (0x08) and Block Write (0x07) commands, hard-limited to the
|
||||||
|
app region `0x2400`–`0xF9FF` (bootloader `0x0000`–`0x23FF` and lock/reserved
|
||||||
|
`0xFA00`+ are refused; no Device Erase / mass-erase command exists at all). The
|
||||||
|
host script `c2probe/reflash_page.py` ran the verified sequence:
|
||||||
|
|
||||||
|
1. `piinit` (halt core) + `wsetup` (AN127 Table 3.6 SFR setup: FLSCL=0x90,
|
||||||
|
VDM0CN=0x80, CLKSEL=0x03, RSTSRC=0x02).
|
||||||
|
2. Read page `0xDE00`–`0xDFFF` (the 512-byte flash page containing `0xDF28`;
|
||||||
|
note the page base is `0xDE00`, not `0xDF00` — pages are 512-byte aligned).
|
||||||
|
3. Page Erase page `0x6F` → verified all 512 bytes read back `0xFF`.
|
||||||
|
4. Block Write the stock bytes for `0xDE00` and `0xDF00` (256 + 256).
|
||||||
|
5. Read back and verify byte-identical to stock — **MATCH**.
|
||||||
|
6. C2 reset → EFM8 reboots into the restored app.
|
||||||
|
|
||||||
|
Result: `0xDF28` = `12 A5 7B` (`LCALL 0xA57B`), the stock dispatcher call. The
|
||||||
|
bad retarget to `0xE8F2` is gone. The bootloader region was never written or
|
||||||
|
erased. The USB-MIDI receive path is intact again, so SysEx / bootloader entry
|
||||||
|
should work; from here a full factory reflash of v2.2.1 over USB is possible but
|
||||||
|
not required — only the one corrupted `LCALL` ever needed fixing.
|
||||||
|
|
||||||
|
Two firmware bugs found and fixed during the write: (a) Page Erase step 8 must
|
||||||
|
poll OutReady until **set** (then read the `0x0D` page-number ack), not until
|
||||||
|
clear — AN127's "poll until clear" wording is misleading; the EFM8UB2 presents
|
||||||
|
the ack byte with OutReady set. (b) The CDC command line buffer was 160 bytes,
|
||||||
|
truncating the 522-char `bw` command; enlarged to 1024.
|
||||||
|
|
||||||
|
## USB-1→CV patch applied via C2 (2026-08-17)
|
||||||
|
|
||||||
|
With the device recovered, the corrected patch (`patch_usb1_to_cv.py`,
|
||||||
|
`STUB_ADDR=0x8126`) was applied surgically over C2 by `c2probe/patch_c2.py`:
|
||||||
|
erase+reprogram page `0x8000` (23-byte stub into verified `0xFF` padding at
|
||||||
|
`0x8126`) **first**, then page `0xDE00` (retarget `0xDF28`→`LCALL 0x8126`)
|
||||||
|
**second** — stub-page-first ordering means a mid-failure never leaves a
|
||||||
|
dangling retarget. Both pages read back byte-exact, and only the intended
|
||||||
|
bytes changed. The device was reset and **functionally verified on an
|
||||||
|
oscilloscope**: MIDI sent to USB port 1 (Control Surface) now drives the CV
|
||||||
|
gate and 1 V/octave pitch outputs, which it never did before. User
|
||||||
|
personalization at `0xF000`–`0xF806` was preserved (only two pages touched).
|
||||||
|
The patch is fully reversible over C2 (`reflash_page.py` restores `0xDE00` to
|
||||||
|
stock; the stub page can be restored the same way).
|
||||||
|
|
||||||
|
## The application has no alternative bootloader-entry path
|
||||||
|
|
||||||
|
Established by recursive-descent disassembly of the whole image:
|
||||||
|
|
||||||
|
- The only bootloader-entry trigger is the SysEx command. Handler at `0xB48D`
|
||||||
|
checks category `0x11` (`SYX_SYSTEM`) and command `0x00` (`SYX_SYS_RESET`) at
|
||||||
|
`0xB499`/`0xB49E`, then calls `0xE744`.
|
||||||
|
- `0xE744` disables interrupts and falls through to `0xE8E6`, which is
|
||||||
|
`LJMP 0x0000` — the only `LJMP 0x0000` in the entire 54 KB image.
|
||||||
|
- `0xE744` has exactly one caller (`0xB4A6`); `0xE8E6` has exactly one referrer
|
||||||
|
(`0xE751`, inside `0xE744`).
|
||||||
|
- There is **no** software reset anywhere: every `RSTSRC` write is VDD-monitor
|
||||||
|
init (`RSTSRC = 0x02` following `VDM0CN = 0x80`). No watchdog-forced reset
|
||||||
|
path either.
|
||||||
|
- No key/button code reaches `0xB48D`; its entire caller chain is MIDI message
|
||||||
|
processing.
|
||||||
|
|
||||||
|
**Two of the destroyed functions are exactly the ones needed for recovery**, both
|
||||||
|
inside the erased range `0xE800`–`0xE8F1`:
|
||||||
|
|
||||||
|
| Address | Function | Consequence |
|
||||||
|
|---|---|---|
|
||||||
|
| `0xE8C5` | UART/DIN MIDI byte processor, called from the main service loop at `0xE045` | Expander-port (`J2`) MIDI input is dead, so the enter-bootloader SysEx cannot be delivered over the UART either |
|
||||||
|
| `0xE8E6` | `LJMP 0x0000` — the bootloader entry jump | bootloader entry is dead even if a command were received |
|
||||||
|
|
||||||
|
The UART is configured for MIDI (`SCON0 = 0x50`, Timer 1 mode 2, reload `0xC0`
|
||||||
|
→ 31250 baud at 48 MHz), and the receive ISR at `0xA90D` reads `SBUF0` into a
|
||||||
|
24-byte ring at `0x0F31` via `0xE2AA`; the ring is drained at `0xE042`/`0xE47E`
|
||||||
|
and each byte handed to the now-missing `0xE8C5`.
|
||||||
|
|
||||||
|
Consequently **no software recovery path exists**: not USB MIDI, not DIN MIDI via
|
||||||
|
the Expander port, not a key/button combination, not a USB vendor request, and
|
||||||
|
not a power-on bootloader window (verified by kernel USB logs — exactly one
|
||||||
|
enumeration per attach, always PID `0x0018`).
|
||||||
|
|
||||||
|
Note also that the application does `ACALL 0x21D4` at `0x265E`, i.e. it calls a
|
||||||
|
routine inside the bootloader region, so the bootloader exposes an API to the
|
||||||
|
application.
|
||||||
|
|
||||||
|
## What is needed
|
||||||
|
|
||||||
|
**The key question for KMI:** does the bootloader at `0x0000`–`0x23FF` check a
|
||||||
|
button/key at power-on, or offer any entry path that does not require the
|
||||||
|
application to be functional? That region is not present in any `.syx` file, so
|
||||||
|
it could not be analysed here.
|
||||||
|
|
||||||
|
Failing that, either:
|
||||||
|
|
||||||
|
1. **EFM8 factory bootloader** (AN945). The MCU is an **EFM8UB20F64G in QFP48**.
|
||||||
|
Per the EFM8UB2 data sheet (Rev 1.3) Table 3.3, the 48-pin package enters
|
||||||
|
bootload mode by holding **`P3.7` (QFP48 pin 23)** low at reset; the
|
||||||
|
bootloader lives in the last three pages of code flash and runs after *any*
|
||||||
|
reset when the Bootloader Signature Byte (the byte before the Lock Byte) is
|
||||||
|
`0xA5`. The application image ends at `0xF806` and so never overlaps that
|
||||||
|
region — but whether KMI erased the factory bootloader in production is
|
||||||
|
unknown. For reference, QFP48 pin 13 = `RST`/`C2CK`, pin 14 = `C2D`.
|
||||||
|
2. **Direct reflash over the C2 debug interface.**
|
||||||
|
|
||||||
|
In either case only `0x2400`–`0xF806` should be programmed, and no mass erase
|
||||||
|
should be performed: that would destroy KMI's bootloader and/or the factory
|
||||||
|
bootloader, neither of which is recoverable from available files.
|
||||||
|
|
||||||
|
## Files available
|
||||||
|
|
||||||
|
In `firmware-tools/out/`:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `QuNexus_Firmware_v2.2.1.bin` | stock v2.2.1 application image, flat binary, base `0x2400`, 54 279 bytes |
|
||||||
|
| `QuNexus_Firmware_v2.2.1.hex` | the same as standard ASCII Intel HEX |
|
||||||
|
| `QuNexus_Firmware_v2.2.1-cs512-usb1cv-v2.syx` | corrected patch (stub relocated to `0x8126`, inside an existing stock record) — **not** the image that was flashed |
|
||||||
|
|
||||||
|
The image that was flashed is reproducible from the stock `.syx` with
|
||||||
|
`patch_usb1_to_cv.py` by setting `STUB_ADDR = 0xE8F2` and using the
|
||||||
|
add-a-new-record path.
|
||||||
|
|
||||||
|
Note that these images cover only `0x2400`–`0xF806`. The bootloader region
|
||||||
|
`0x0000`–`0x23FF` is not present in any `.syx` and must not be erased.
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
// c2probe -- RP2040 bit-bang programmer for the Silicon Labs C2 interface.
|
||||||
|
//
|
||||||
|
// Reads code flash on EFM8UB2 (and C8051F) parts. READ-ONLY by design: it
|
||||||
|
// implements only the C2 frame primitives, register read/write, PI init, and
|
||||||
|
// the FPDAT Block Read (0x06) command. There is no erase/write/lock path and
|
||||||
|
// no Device Erase arming, so it cannot damage flash.
|
||||||
|
//
|
||||||
|
// Wiring: GP2 = C2CK (target RST/C2CK), GP3 = C2D. 3.3V, direct.
|
||||||
|
// Protocol reference: Silicon Labs AN127 Rev 1.4.
|
||||||
|
//
|
||||||
|
// Host command interface over USB CDC (line-based, LF-terminated):
|
||||||
|
// hello
|
||||||
|
// speed <us> C2CK half-period in us (low=high=<us>), default 1
|
||||||
|
// fpdat <hex> set FPDAT register address (default 0xAD EFM8UB2)
|
||||||
|
// pins report pin assignment
|
||||||
|
// reset C2 device reset (C2CK low >=20us)
|
||||||
|
// status Address Read -> status byte (FLBusy/EError/InBusy/OutReady)
|
||||||
|
// rdreg <hexaddr> Address Write + Data Read -> register value
|
||||||
|
// wrreg <hexaddr> <hex> Address Write + Data Write
|
||||||
|
// id read DEVICEID(0x00) and REVID(0x01)
|
||||||
|
// piinit full PI init (reset + FPCTL 0x02,0x04,0x01 + 20ms)
|
||||||
|
// rawaw <hex> raw Address Write
|
||||||
|
// rawar raw Address Read (-> status)
|
||||||
|
// rawdw <hex> raw Data Write
|
||||||
|
// rawdr raw Data Read
|
||||||
|
// read <hexaddr> <len> FPDAT Block Read, len 1..256 (0=256) -> hex bytes
|
||||||
|
// dump <hexstart> <hexend> loop read over range, one line per block
|
||||||
|
//
|
||||||
|
// Replies: "ok ...", "data <addr> <n> <hex>", "err <code>", "stat <hex>", etc.
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include "pico/stdlib.h"
|
||||||
|
#include "hardware/gpio.h"
|
||||||
|
#include "hardware/sync.h"
|
||||||
|
#include "pico/bootrom.h"
|
||||||
|
|
||||||
|
// C2CK / C2D pin numbers are runtime-configurable via the `pins` command so the
|
||||||
|
// host can try both wiring orientations without reflashing. Default: GP2=C2CK,
|
||||||
|
// GP3=C2D.
|
||||||
|
static int C2CK = 2;
|
||||||
|
static int C2D = 3;
|
||||||
|
|
||||||
|
// C2CK low/high time for a bit strobe, in microseconds. Must satisfy
|
||||||
|
// 20ns <= tCL < 5000ns (else a reset is triggered). 1us is safe.
|
||||||
|
static uint32_t half_us = 1;
|
||||||
|
|
||||||
|
// FPDAT register address (device-specific). EFM8UB2 = 0xAD. Settable at runtime.
|
||||||
|
static uint8_t fpdat_addr = 0xAD;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- low-level
|
||||||
|
|
||||||
|
static inline void c2ck_set(int v) { gpio_put(C2CK, v); }
|
||||||
|
static inline void c2d_drive(int v) {
|
||||||
|
gpio_set_dir(C2D, GPIO_OUT);
|
||||||
|
gpio_put(C2D, v ? 1 : 0);
|
||||||
|
}
|
||||||
|
static inline void c2d_release(void) {
|
||||||
|
gpio_set_dir(C2D, GPIO_IN); // high-Z, no pull
|
||||||
|
}
|
||||||
|
static inline int c2d_get(void) { return gpio_get(C2D) ? 1 : 0; }
|
||||||
|
|
||||||
|
// One C2CK strobe: high->low (tCL)->high (tCH). IRQs disabled across the low
|
||||||
|
// window so the low time stays under the 5us reset threshold even if a USB
|
||||||
|
// IRQ fires. Used when the master is driving C2D (write bits / start / stop).
|
||||||
|
static void strobe_write(void) {
|
||||||
|
uint32_t save = save_and_disable_interrupts();
|
||||||
|
gpio_put(C2CK, 0);
|
||||||
|
busy_wait_us(half_us);
|
||||||
|
gpio_put(C2CK, 1);
|
||||||
|
restore_interrupts(save);
|
||||||
|
busy_wait_us(half_us);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One C2CK strobe that reads a slave-driven bit. C2D is released (input) and
|
||||||
|
// sampled after the rising edge + tDV. IRQs disabled across low+sample.
|
||||||
|
static int strobe_read(void) {
|
||||||
|
c2d_release();
|
||||||
|
uint32_t save = save_and_disable_interrupts();
|
||||||
|
gpio_put(C2CK, 0);
|
||||||
|
busy_wait_us(half_us);
|
||||||
|
gpio_put(C2CK, 1);
|
||||||
|
busy_wait_us(half_us); // tDV ~20ns; half_us gives margin
|
||||||
|
int v = gpio_get(C2D) ? 1 : 0;
|
||||||
|
restore_interrupts(save);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void c2_start(void) { c2d_drive(1); strobe_write(); }
|
||||||
|
static void c2_stop(void) { c2d_drive(1); strobe_write(); c2d_release(); }
|
||||||
|
|
||||||
|
static void c2_write_bit(int b) {
|
||||||
|
c2d_drive(b ? 1 : 0);
|
||||||
|
strobe_write();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- frames
|
||||||
|
|
||||||
|
// Address Write (INS=11b): START, INS(1,1), ADDRESS 8 bits LSB-first, STOP.
|
||||||
|
static void c2_addr_write(uint8_t addr) {
|
||||||
|
c2_start();
|
||||||
|
c2_write_bit(1); c2_write_bit(1); // INS = 11b
|
||||||
|
for (int i = 0; i < 8; i++) c2_write_bit((addr >> i) & 1);
|
||||||
|
c2_stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address Read (INS=10b): START, INS(0,1), release, read 8 bits = status, STOP.
|
||||||
|
static uint8_t c2_addr_read(void) {
|
||||||
|
c2_start();
|
||||||
|
c2_write_bit(0); c2_write_bit(1); // INS = 10b
|
||||||
|
uint8_t v = 0;
|
||||||
|
for (int i = 0; i < 8; i++) v |= (strobe_read() << i);
|
||||||
|
c2_stop();
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Write (INS=01b): START, INS(1,0), LENGTH(00=1B), DATA 8, WAIT(0s then 1), STOP.
|
||||||
|
// Returns 0 on success, -1 on WAIT timeout.
|
||||||
|
static int c2_data_write(uint8_t val) {
|
||||||
|
c2_start();
|
||||||
|
c2_write_bit(1); c2_write_bit(0); // INS = 01b
|
||||||
|
c2_write_bit(0); c2_write_bit(0); // LENGTH = 00 (1 byte)
|
||||||
|
for (int i = 0; i < 8; i++) c2_write_bit((val >> i) & 1);
|
||||||
|
// WAIT: slave releases 0s then a 1; clock and read until 1 seen.
|
||||||
|
int waited = 0;
|
||||||
|
while (strobe_read() == 0) {
|
||||||
|
if (++waited > 8192) { c2_stop(); return -1; }
|
||||||
|
}
|
||||||
|
c2_stop();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Read (INS=00b): START, INS(0,0), LENGTH(00), WAIT(0s then 1), DATA 8, STOP.
|
||||||
|
// Returns 0 on success, -1 on WAIT timeout.
|
||||||
|
static int c2_data_read(uint8_t *out) {
|
||||||
|
c2_start();
|
||||||
|
c2_write_bit(0); c2_write_bit(0); // INS = 00b
|
||||||
|
c2_write_bit(0); c2_write_bit(0); // LENGTH = 00 (1 byte)
|
||||||
|
int waited = 0;
|
||||||
|
while (strobe_read() == 0) { // WAIT
|
||||||
|
if (++waited > 8192) { c2_stop(); return -1; }
|
||||||
|
}
|
||||||
|
uint8_t v = 0;
|
||||||
|
for (int i = 0; i < 8; i++) v |= (strobe_read() << i); // DATA
|
||||||
|
c2_stop();
|
||||||
|
*out = v;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
static uint8_t c2_status(void) { return c2_addr_read(); }
|
||||||
|
|
||||||
|
static uint8_t c2_reg_read(uint8_t addr) { c2_addr_write(addr); uint8_t v = 0; c2_data_read(&v); return v; }
|
||||||
|
static int c2_reg_write(uint8_t addr, uint8_t val) { c2_addr_write(addr); return c2_data_write(val); }
|
||||||
|
|
||||||
|
static int c2_poll_inbusy(void) {
|
||||||
|
for (long i = 0; i < 200000L; i++) {
|
||||||
|
uint8_t s = c2_status();
|
||||||
|
if (!((s >> 1) & 1)) return 0; // InBusy cleared
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
static int c2_poll_outready(void) {
|
||||||
|
for (long i = 0; i < 200000L; i++) {
|
||||||
|
uint8_t s = c2_status();
|
||||||
|
if (s & 1) return 0; // OutReady set
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void c2_reset(void) {
|
||||||
|
// Device reset: C2CK low >= 20us, high, wait >= 2us. Also leaves C2CK high.
|
||||||
|
c2d_release();
|
||||||
|
c2ck_set(0);
|
||||||
|
busy_wait_us(50); // tRD >= 20us
|
||||||
|
c2ck_set(1);
|
||||||
|
busy_wait_us(5); // tSD >= 2us
|
||||||
|
}
|
||||||
|
|
||||||
|
static void c2_pi_init(void) {
|
||||||
|
c2_reset();
|
||||||
|
c2_reg_write(0x02, 0x02); // FPCTL <- 0x02 (enable)
|
||||||
|
c2_reg_write(0x02, 0x04); // FPCTL <- 0x04 (halt core)
|
||||||
|
c2_reg_write(0x02, 0x01); // FPCTL <- 0x01
|
||||||
|
busy_wait_us(20000); // >= 20ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block Read: read `len` bytes (1..256, 0 => 256) from flash `addr`.
|
||||||
|
// Returns number of bytes read, or negative error code.
|
||||||
|
static int c2_block_read(uint16_t addr, uint8_t len, uint8_t *buf) {
|
||||||
|
int n = len ? (int)len : 256;
|
||||||
|
uint8_t st;
|
||||||
|
|
||||||
|
c2_addr_write(fpdat_addr);
|
||||||
|
if (c2_data_write(0x06)) return -10; // Block Read cmd
|
||||||
|
if (c2_poll_inbusy()) return -1;
|
||||||
|
if (c2_poll_outready()) return -2;
|
||||||
|
if (c2_data_read(&st)) return -3;
|
||||||
|
if (st != 0x0D) return -4; // status not OK
|
||||||
|
|
||||||
|
if (c2_data_write((addr >> 8) & 0xFF)) return -11; // addr high
|
||||||
|
if (c2_poll_inbusy()) return -5;
|
||||||
|
if (c2_data_write(addr & 0xFF)) return -12; // addr low
|
||||||
|
if (c2_poll_inbusy()) return -6;
|
||||||
|
|
||||||
|
if (c2_data_write(len)) return -13; // length code (0=256)
|
||||||
|
if (c2_poll_inbusy()) return -7;
|
||||||
|
|
||||||
|
// Block-ack status: after addr+len the PI emits a second 0x0D before the
|
||||||
|
// data stream (confirmed vs ec2drv: read_port(..., cmd[3]+1) "// +1 for
|
||||||
|
// 0x0d"). Consume and discard it, else it lands as buf[0] and the whole
|
||||||
|
// block reads shifted by one (off-by-one vs stock).
|
||||||
|
if (c2_poll_outready()) return -14;
|
||||||
|
if (c2_data_read(&st)) return -15;
|
||||||
|
if (st != 0x0D) return -16;
|
||||||
|
|
||||||
|
for (int k = 0; k < n; k++) {
|
||||||
|
if (c2_poll_outready()) return -8 - k;
|
||||||
|
uint8_t b;
|
||||||
|
if (c2_data_read(&b)) return -200 - k;
|
||||||
|
buf[k] = b;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- write/erase
|
||||||
|
//
|
||||||
|
// SAFETY: the bootloader lives at 0x0000-0x23FF and the flash lock / reserved
|
||||||
|
// area at 0xFA00-0xFFFF. These are UNRECOVERABLE if erased (no image available).
|
||||||
|
// Every erase/write below is hard-limited to the application region
|
||||||
|
// 0x2400-0xF9FF. There is no Device Erase / mass-erase command anywhere in
|
||||||
|
// this firmware. The guards are both compile-time (constants) and runtime.
|
||||||
|
#define APP_LO 0x2400 // first application address (inclusive)
|
||||||
|
#define APP_HI 0xFA00 // first protected address (exclusive)
|
||||||
|
#define PAGE_BYTES 512
|
||||||
|
#define APP_PAGE_LO (APP_LO / PAGE_BYTES) // 0x12
|
||||||
|
#define APP_PAGE_HI ((APP_HI / PAGE_BYTES) - 1) // 0x7C (0xF800 page is last app page)
|
||||||
|
|
||||||
|
static int app_addr_ok(uint32_t a, uint32_t len) {
|
||||||
|
return a >= APP_LO && (a + len) <= APP_HI && len > 0;
|
||||||
|
}
|
||||||
|
static int app_page_ok(uint8_t page) {
|
||||||
|
return page >= APP_PAGE_LO && page <= APP_PAGE_HI;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll until OutReady CLEARS (used by Page Erase step 8).
|
||||||
|
static int c2_poll_outready_clear(void) {
|
||||||
|
for (long i = 0; i < 400000L; i++) { // erase can take longer
|
||||||
|
uint8_t s = c2_status();
|
||||||
|
if (!(s & 1)) return 0; // OutReady cleared
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct Write (FPDAT 0x0A): write one SFR. Used for the pre-flash VDD-monitor
|
||||||
|
// / flash-timing / clock setup. No address guard (SFRs are 0x80-0xFF by
|
||||||
|
// definition; this only touches the four documented EFM8UB2 setup registers).
|
||||||
|
static int c2_direct_write(uint8_t sfr, uint8_t val) {
|
||||||
|
c2_addr_write(fpdat_addr);
|
||||||
|
if (c2_data_write(0x0A)) return -10; // Direct Write cmd
|
||||||
|
if (c2_poll_inbusy()) return -1;
|
||||||
|
if (c2_poll_outready()) return -2;
|
||||||
|
uint8_t st;
|
||||||
|
if (c2_data_read(&st)) return -3;
|
||||||
|
if (st != 0x0D) return -4;
|
||||||
|
if (c2_data_write(sfr)) return -11; // SFR address
|
||||||
|
if (c2_poll_inbusy()) return -5;
|
||||||
|
if (c2_data_write(0x01)) return -12; // length = 1 byte
|
||||||
|
if (c2_poll_inbusy()) return -6;
|
||||||
|
if (c2_data_write(val)) return -13; // SFR value
|
||||||
|
if (c2_poll_inbusy()) return -7;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EFM8UB2 pre-flash setup (AN127 Table 3.6): flash timing, enable VDD monitor,
|
||||||
|
// clock, supply-monitor reset source. Must run once after piinit, before any
|
||||||
|
// erase/write. Returns 0 on success.
|
||||||
|
static int c2_pgm_setup(void) {
|
||||||
|
if (c2_direct_write(0xB6, 0x90)) return -1; // FLSCL = 0x90 (flash timing)
|
||||||
|
if (c2_direct_write(0xFF, 0x80)) return -2; // VDM0CN = 0x80 (VDD mon enable)
|
||||||
|
if (c2_direct_write(0xA9, 0x03)) return -3; // CLKSEL = 0x03
|
||||||
|
if (c2_direct_write(0xEF, 0x02)) return -4; // RSTSRC = 0x02 (VDD mon reset src)
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page Erase (FPDAT 0x08). `page` = address / 512. Guarded to app region only.
|
||||||
|
static int c2_page_erase(uint8_t page) {
|
||||||
|
if (!app_page_ok(page)) return -100; // REFUSED: outside app region
|
||||||
|
uint8_t st;
|
||||||
|
c2_addr_write(fpdat_addr);
|
||||||
|
if (c2_data_write(0x08)) return -10; // Page Erase cmd
|
||||||
|
if (c2_poll_inbusy()) return -1;
|
||||||
|
if (c2_poll_outready()) return -2;
|
||||||
|
if (c2_data_read(&st)) return -3;
|
||||||
|
if (st != 0x0D) return -4;
|
||||||
|
if (c2_data_write(page)) return -11; // target page number
|
||||||
|
if (c2_poll_inbusy()) return -5;
|
||||||
|
// Page-number ack: the PI sets OutReady with a 0x0D status after consuming
|
||||||
|
// the page number. AN127 step 8 says "poll until clear" but on the EFM8UB2
|
||||||
|
// the byte is genuinely pending (OutReady stuck SET), so we poll until SET
|
||||||
|
// and read it -- mirroring the command-ack (steps 4-5) and completion-ack
|
||||||
|
// (steps 12-13). The 0x0D check below rejects any error status safely.
|
||||||
|
if (c2_poll_outready()) return -6; // OutReady -> 1 (ack ready)
|
||||||
|
if (c2_data_read(&st)) return -7;
|
||||||
|
if (st != 0x0D) return -8;
|
||||||
|
if (c2_data_write(0x00)) return -12; // initiate erase
|
||||||
|
if (c2_poll_inbusy()) return -9;
|
||||||
|
if (c2_poll_outready()) return -13; // OutReady -> 1 (done)
|
||||||
|
if (c2_data_read(&st)) return -14;
|
||||||
|
if (st != 0x0D) return -15;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block Write (FPDAT 0x07): program `len` bytes (1..256, 0 => 256) at `addr`.
|
||||||
|
// addr..addr+len must lie inside the app region. Flash must be erased (0xFF)
|
||||||
|
// at the target first. Returns number of bytes written, or negative error.
|
||||||
|
static int c2_block_write(uint16_t addr, uint8_t len, const uint8_t *buf) {
|
||||||
|
int n = len ? (int)len : 256;
|
||||||
|
if (!app_addr_ok(addr, n)) return -100; // REFUSED: outside app region
|
||||||
|
uint8_t st;
|
||||||
|
c2_addr_write(fpdat_addr);
|
||||||
|
if (c2_data_write(0x07)) return -10; // Block Write cmd
|
||||||
|
if (c2_poll_inbusy()) return -1;
|
||||||
|
if (c2_poll_outready()) return -2;
|
||||||
|
if (c2_data_read(&st)) return -3;
|
||||||
|
if (st != 0x0D) return -4;
|
||||||
|
if (c2_data_write((addr >> 8) & 0xFF)) return -11; // addr high
|
||||||
|
if (c2_poll_inbusy()) return -5;
|
||||||
|
if (c2_data_write(addr & 0xFF)) return -12; // addr low
|
||||||
|
if (c2_poll_inbusy()) return -6;
|
||||||
|
if (c2_data_write(len)) return -13; // length code (0=256)
|
||||||
|
if (c2_poll_inbusy()) return -7;
|
||||||
|
for (int k = 0; k < n; k++) {
|
||||||
|
if (c2_data_write(buf[k])) return -200 - k; // data byte
|
||||||
|
if (c2_poll_inbusy()) return -8 - k;
|
||||||
|
}
|
||||||
|
if (c2_poll_outready()) return -14; // write complete
|
||||||
|
if (c2_data_read(&st)) return -15;
|
||||||
|
if (st != 0x0D) return -16;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- command I/O
|
||||||
|
|
||||||
|
static int get_line(char *buf, int maxlen) {
|
||||||
|
int n = 0;
|
||||||
|
while (n < maxlen - 1) {
|
||||||
|
int c = getchar_timeout_us(1000);
|
||||||
|
if (c == PICO_ERROR_TIMEOUT) continue;
|
||||||
|
if (c < 0) continue;
|
||||||
|
if (c == '\r') continue;
|
||||||
|
if (c == '\n') break;
|
||||||
|
buf[n++] = (char)c;
|
||||||
|
}
|
||||||
|
buf[n] = 0;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void print_hex(const uint8_t *p, int n) {
|
||||||
|
for (int k = 0; k < n; k++) printf("%02x", p[k]);
|
||||||
|
printf("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void do_read(uint16_t addr, uint8_t len) {
|
||||||
|
static uint8_t buf[256];
|
||||||
|
int rc = c2_block_read(addr, len, buf);
|
||||||
|
if (rc < 0) {
|
||||||
|
printf("err %d\n", rc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printf("data %04x %d ", addr, rc);
|
||||||
|
print_hex(buf, rc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Re)configure the C2CK / C2D pins. Idles C2CK high and releases C2D.
|
||||||
|
static void c2_setup_pins(int ck, int d) {
|
||||||
|
C2CK = ck; C2D = d;
|
||||||
|
gpio_init(ck); gpio_set_dir(ck, GPIO_OUT); gpio_put(ck, 1);
|
||||||
|
gpio_init(d); gpio_set_dir(d, GPIO_OUT); gpio_put(d, 1);
|
||||||
|
c2d_release();
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
stdio_init_all();
|
||||||
|
|
||||||
|
c2_setup_pins(2, 3); // default: GP2=C2CK, GP3=C2D
|
||||||
|
|
||||||
|
// Must hold the longest command: "bw ffff 0 " (10) + 512 hex chars = 522.
|
||||||
|
char line[1024];
|
||||||
|
while (true) {
|
||||||
|
int n = get_line(line, sizeof(line));
|
||||||
|
if (n == 0) { printf("ok\n"); continue; }
|
||||||
|
|
||||||
|
char cmd[32];
|
||||||
|
if (sscanf(line, "%31s", cmd) != 1) { printf("err parse\n"); continue; }
|
||||||
|
|
||||||
|
if (!strcmp(cmd, "hello")) {
|
||||||
|
printf("ok c2probe v2\n");
|
||||||
|
} else if (!strcmp(cmd, "bootsel")) {
|
||||||
|
printf("ok bootsel\n");
|
||||||
|
fflush(stdout);
|
||||||
|
busy_wait_us(5000);
|
||||||
|
reset_usb_boot(0, 0); // reboot into USB bootloader (BOOTSEL)
|
||||||
|
} else if (!strcmp(cmd, "speed")) {
|
||||||
|
unsigned us = 1;
|
||||||
|
sscanf(line, "%*s %u", &us);
|
||||||
|
half_us = (us > 1000) ? 1000 : us;
|
||||||
|
printf("ok speed %luus\n", (unsigned long)half_us);
|
||||||
|
} else if (!strcmp(cmd, "fpdat")) {
|
||||||
|
unsigned a = fpdat_addr;
|
||||||
|
sscanf(line, "%*s %x", &a);
|
||||||
|
fpdat_addr = a & 0xFF;
|
||||||
|
printf("ok fpdat %02x\n", fpdat_addr);
|
||||||
|
} else if (!strcmp(cmd, "pins")) {
|
||||||
|
int ck = C2CK, d = C2D;
|
||||||
|
if (sscanf(line, "%*s %i %i", &ck, &d) == 2) {
|
||||||
|
c2_setup_pins(ck, d);
|
||||||
|
}
|
||||||
|
printf("ok ck=gp%d d=gp%d\n", C2CK, C2D);
|
||||||
|
} else if (!strcmp(cmd, "reset")) {
|
||||||
|
c2_reset();
|
||||||
|
printf("ok reset\n");
|
||||||
|
} else if (!strcmp(cmd, "status")) {
|
||||||
|
printf("stat %02x\n", c2_status());
|
||||||
|
} else if (!strcmp(cmd, "rdreg")) {
|
||||||
|
unsigned a = 0;
|
||||||
|
sscanf(line, "%*s %x", &a);
|
||||||
|
printf("reg %02x\n", c2_reg_read(a & 0xFF));
|
||||||
|
} else if (!strcmp(cmd, "wrreg")) {
|
||||||
|
unsigned a = 0, v = 0;
|
||||||
|
sscanf(line, "%*s %x %x", &a, &v);
|
||||||
|
int rc = c2_reg_write(a & 0xFF, v & 0xFF);
|
||||||
|
printf("%s\n", rc ? "err wait" : "ok");
|
||||||
|
} else if (!strcmp(cmd, "id")) {
|
||||||
|
uint8_t d = c2_reg_read(0x00), r = c2_reg_read(0x01);
|
||||||
|
printf("id devid=%02x revid=%02x\n", d, r);
|
||||||
|
} else if (!strcmp(cmd, "id2")) {
|
||||||
|
// atomic: reset then read DEVICEID/REVID with no host round-trip
|
||||||
|
c2_reset();
|
||||||
|
uint8_t d = c2_reg_read(0x00), r = c2_reg_read(0x01);
|
||||||
|
printf("id2 devid=%02x revid=%02x\n", d, r);
|
||||||
|
} else if (!strcmp(cmd, "dbg")) {
|
||||||
|
// 3-state read of C2D: floating line follows the pull; a driven
|
||||||
|
// line ignores it. Reveals whether a slave is driving C2D.
|
||||||
|
gpio_set_dir(C2D, GPIO_IN);
|
||||||
|
gpio_disable_pulls(C2D); busy_wait_us(20); int none = c2d_get();
|
||||||
|
gpio_pull_up(C2D); busy_wait_us(20); int up = c2d_get();
|
||||||
|
gpio_disable_pulls(C2D);
|
||||||
|
gpio_pull_down(C2D); busy_wait_us(20); int dn = c2d_get();
|
||||||
|
gpio_disable_pulls(C2D);
|
||||||
|
printf("dbg c2d none=%d up=%d dn=%d c2ck=%d (float if up=1,dn=0)\n",
|
||||||
|
none, up, dn, gpio_get(C2CK));
|
||||||
|
} else if (!strcmp(cmd, "piinit")) {
|
||||||
|
c2_pi_init();
|
||||||
|
printf("ok piinit\n");
|
||||||
|
} else if (!strcmp(cmd, "rawaw")) {
|
||||||
|
unsigned a = 0; sscanf(line, "%*s %x", &a);
|
||||||
|
c2_addr_write(a & 0xFF);
|
||||||
|
printf("ok\n");
|
||||||
|
} else if (!strcmp(cmd, "rawar")) {
|
||||||
|
printf("ar %02x\n", c2_addr_read());
|
||||||
|
} else if (!strcmp(cmd, "rawdw")) {
|
||||||
|
unsigned v = 0; sscanf(line, "%*s %x", &v);
|
||||||
|
printf("%s\n", c2_data_write(v & 0xFF) ? "err wait" : "ok");
|
||||||
|
} else if (!strcmp(cmd, "rawdr")) {
|
||||||
|
uint8_t v = 0;
|
||||||
|
int rc = c2_data_read(&v);
|
||||||
|
printf("dr %02x %s\n", v, rc ? "err" : "ok");
|
||||||
|
} else if (!strcmp(cmd, "read")) {
|
||||||
|
unsigned a = 0; int l = 0;
|
||||||
|
sscanf(line, "%*s %x %i", &a, &l);
|
||||||
|
if (l < 0) l = 0;
|
||||||
|
if (l > 256) l = 256;
|
||||||
|
do_read(a & 0xFFFF, (uint8_t)l);
|
||||||
|
} else if (!strcmp(cmd, "dump")) {
|
||||||
|
unsigned s = 0, e = 0;
|
||||||
|
sscanf(line, "%*s %x %x", &s, &e);
|
||||||
|
if (e > 0x10000) e = 0x10000;
|
||||||
|
int blocks = 0;
|
||||||
|
for (unsigned a = s; a < e; ) {
|
||||||
|
int chunk = e - a;
|
||||||
|
if (chunk > 256) chunk = 256;
|
||||||
|
do_read(a & 0xFFFF, (uint8_t)chunk);
|
||||||
|
a += chunk;
|
||||||
|
blocks++;
|
||||||
|
}
|
||||||
|
printf("done %d\n", blocks);
|
||||||
|
} else if (!strcmp(cmd, "sfrw")) {
|
||||||
|
// Direct Write one SFR (0x80-0xFF). For the pre-flash setup only.
|
||||||
|
unsigned a = 0, v = 0;
|
||||||
|
sscanf(line, "%*s %x %x", &a, &v);
|
||||||
|
int rc = c2_direct_write(a & 0xFF, v & 0xFF);
|
||||||
|
printf("%s\n", rc ? "err" : "ok");
|
||||||
|
if (rc) printf("sfrw rc %d\n", rc);
|
||||||
|
} else if (!strcmp(cmd, "wsetup")) {
|
||||||
|
// EFM8UB2 pre-flash SFR setup (flash timing + VDD monitor + clock).
|
||||||
|
int rc = c2_pgm_setup();
|
||||||
|
printf("%s\n", rc ? "err" : "ok");
|
||||||
|
if (rc) printf("wsetup rc %d\n", rc);
|
||||||
|
} else if (!strcmp(cmd, "pe")) {
|
||||||
|
// Page Erase. Page number = addr/512. Guarded to app region.
|
||||||
|
unsigned pg = 0;
|
||||||
|
sscanf(line, "%*s %x", &pg);
|
||||||
|
int rc = c2_page_erase(pg & 0xFF);
|
||||||
|
if (rc == -100) printf("refused page %02x outside app region\n", pg & 0xFF);
|
||||||
|
else printf("%s\n", rc ? "err" : "ok");
|
||||||
|
if (rc && rc != -100) printf("pe rc %d\n", rc);
|
||||||
|
} else if (!strcmp(cmd, "bw")) {
|
||||||
|
// Block Write: bw <addr> <len> <hex...>. Guarded to app region.
|
||||||
|
unsigned a = 0; int l = 0;
|
||||||
|
char hex[600];
|
||||||
|
hex[0] = 0;
|
||||||
|
// "bw ADDR LEN HEXSTR"
|
||||||
|
int got = sscanf(line, "%*s %x %i %599s", &a, &l, hex);
|
||||||
|
if (got < 3) { printf("err bw usage\n"); }
|
||||||
|
else {
|
||||||
|
if (l < 0) l = 0;
|
||||||
|
if (l > 256) l = 256;
|
||||||
|
int n = l ? l : 256;
|
||||||
|
static uint8_t wbuf[256];
|
||||||
|
int hl = (int)strlen(hex);
|
||||||
|
if (hl < n * 2) { printf("err bw short hex (%d want %d)\n", hl, n * 2); }
|
||||||
|
else {
|
||||||
|
int ok = 1;
|
||||||
|
for (int k = 0; k < n; k++) {
|
||||||
|
unsigned b;
|
||||||
|
if (sscanf(hex + k * 2, "%2x", &b) != 1) { ok = 0; break; }
|
||||||
|
wbuf[k] = (uint8_t)b;
|
||||||
|
}
|
||||||
|
if (!ok) printf("err bw hex parse\n");
|
||||||
|
else {
|
||||||
|
int rc = c2_block_write(a & 0xFFFF, (uint8_t)l, wbuf);
|
||||||
|
if (rc == -100) printf("refused addr %04x outside app region\n", a & 0xFFFF);
|
||||||
|
else if (rc < 0) { printf("err\n"); printf("bw rc %d\n", rc); }
|
||||||
|
else printf("ok bw %04x %d\n", a & 0xFFFF, rc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
printf("err unknown\n");
|
||||||
|
}
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Executable
+53
@@ -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)
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# dump_flash.py -- read the entire EFM8 flash over the c2probe CDC link,
|
||||||
|
# write a .bin, and compare against the stock firmware image.
|
||||||
|
#
|
||||||
|
# SAFETY: this script only ever issues Block Read (0x06). It never sends an
|
||||||
|
# erase/write/lock command, and it never issues a C2 reset between blocks (a
|
||||||
|
# reset would un-halt the core and break reads). The c2probe firmware itself
|
||||||
|
# is read-only by design. Flash cannot be harmed.
|
||||||
|
#
|
||||||
|
# Workflow: piinit (halt core) -> loop Block Read over 0x0000..0xFFFF in
|
||||||
|
# 256-byte blocks -> write out/qunexus_device_dump.bin -> read lock byte at
|
||||||
|
# 0xFBFF -> diff against stock (out/QuNexus_Firmware_v2.2.1.bin, base 0x2400)
|
||||||
|
# -> focus report on the RECOVERY.md suspect page 0xE800..0xE9FF.
|
||||||
|
import os, sys, time, select, termios
|
||||||
|
|
||||||
|
DEV = os.environ.get("C2PROBE_DEV", "/dev/cu.usbmodem2101")
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
OUTDIR = os.path.join(HERE, "..", "out")
|
||||||
|
STOCK = os.path.join(OUTDIR, "QuNexus_Firmware_v2.2.1.bin")
|
||||||
|
STOCK_BASE = 0x2400
|
||||||
|
DUMP_BIN = os.path.join(OUTDIR, "qunexus_device_dump.bin")
|
||||||
|
FLASH_SIZE = 0x10000 # 64 KB
|
||||||
|
BLOCK = 256 # bytes per Block Read (length code 0 = 256)
|
||||||
|
SUSPECT_LO, SUSPECT_HI = 0xE800, 0xEA00 # RECOVERY.md page (inclusive..exclusive)
|
||||||
|
|
||||||
|
# ---- CDC I/O (termios, no pyserial) ----------------------------------------
|
||||||
|
def open_port():
|
||||||
|
fd = os.open(DEV, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||||
|
a = termios.tcgetattr(fd)
|
||||||
|
a[2] = termios.CS8 | termios.CLOCAL | termios.CREAD
|
||||||
|
a[3] = 0; a[0] = 0; a[1] = 0
|
||||||
|
a[4] = termios.B115200; a[5] = termios.B115200
|
||||||
|
termios.tcsetattr(fd, termios.TCSANOW, a)
|
||||||
|
return fd
|
||||||
|
|
||||||
|
def drain(fd, t=0.2):
|
||||||
|
r, _, _ = select.select([fd], [], [], t)
|
||||||
|
if r:
|
||||||
|
try: return os.read(fd, 4096)
|
||||||
|
except BlockingIOError: return b""
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def cmd(fd, line, timeout=10.0):
|
||||||
|
os.write(fd, (line + "\n").encode())
|
||||||
|
out = b""
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
r, _, _ = select.select([fd], [], [], 0.1)
|
||||||
|
if r:
|
||||||
|
try: out += os.read(fd, 4096)
|
||||||
|
except BlockingIOError: pass
|
||||||
|
if b"\n" in out:
|
||||||
|
r2, _, _ = select.select([fd], [], [], 0.06)
|
||||||
|
if not r2:
|
||||||
|
break
|
||||||
|
return out.decode("utf-8", "replace").strip()
|
||||||
|
|
||||||
|
def piinit(fd):
|
||||||
|
r = cmd(fd, "piinit", 12)
|
||||||
|
if r != "ok piinit":
|
||||||
|
raise RuntimeError(f"piinit failed: {r!r}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def block_read(fd, addr, n, retries=3):
|
||||||
|
"""Issue one Block Read of n bytes (1..256) at addr. Returns bytes or None."""
|
||||||
|
lc = n if n < 256 else 0
|
||||||
|
for attempt in range(retries):
|
||||||
|
r = cmd(fd, f"read {addr:04x} {lc}", 12)
|
||||||
|
# expected: "data <addr> <n> <hex>"
|
||||||
|
if r.startswith("data "):
|
||||||
|
parts = r.split()
|
||||||
|
# parts: ['data', 'addr', 'count', hexstr]
|
||||||
|
try:
|
||||||
|
hexstr = parts[3]
|
||||||
|
data = bytes.fromhex(hexstr)
|
||||||
|
if len(data) == n:
|
||||||
|
return data
|
||||||
|
# short read: pad/record; retry
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
pass
|
||||||
|
if r.startswith("err"):
|
||||||
|
# desync likely: re-init and retry
|
||||||
|
piinit(fd)
|
||||||
|
continue
|
||||||
|
# unexpected: retry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUTDIR, exist_ok=True)
|
||||||
|
fd = open_port()
|
||||||
|
drain(fd, 0.4)
|
||||||
|
|
||||||
|
print(f"[piinit] halting EFM8 core...")
|
||||||
|
piinit(fd)
|
||||||
|
print(f"[piinit] ok")
|
||||||
|
|
||||||
|
buf = bytearray(FLASH_SIZE)
|
||||||
|
bad = [] # list of (addr, 'gap')
|
||||||
|
t0 = time.time()
|
||||||
|
addr = 0
|
||||||
|
while addr < FLASH_SIZE:
|
||||||
|
n = min(BLOCK, FLASH_SIZE - addr)
|
||||||
|
data = block_read(fd, addr, n)
|
||||||
|
if data is None:
|
||||||
|
print(f" [FAIL] 0x{addr:04x}..0x{addr+n-1:04x} (gap)")
|
||||||
|
bad.append(addr)
|
||||||
|
addr += n
|
||||||
|
continue
|
||||||
|
buf[addr:addr+n] = data
|
||||||
|
if (addr % 0x1000) == 0:
|
||||||
|
dt = time.time() - t0
|
||||||
|
print(f" 0x{addr:04x} ({dt:.1f}s)")
|
||||||
|
addr += n
|
||||||
|
dt = time.time() - t0
|
||||||
|
print(f"[dump] done in {dt:.1f}s, {len(bad)} gap(s)")
|
||||||
|
|
||||||
|
# lock byte
|
||||||
|
lock = block_read(fd, 0xFBFF, 1)
|
||||||
|
lockval = lock[0] if lock else None
|
||||||
|
print(f"[lock] byte @0xFBFF = {('0x%02x' % lockval) if lockval is not None else 'read FAILED'}")
|
||||||
|
|
||||||
|
with open(DUMP_BIN, "wb") as f:
|
||||||
|
f.write(buf)
|
||||||
|
print(f"[write] {DUMP_BIN} ({len(buf)} bytes)")
|
||||||
|
|
||||||
|
# ---- compare to stock --------------------------------------------------
|
||||||
|
print("\n[compare] vs stock", os.path.basename(STOCK))
|
||||||
|
if not os.path.exists(STOCK):
|
||||||
|
print(" stock image not found; skipping diff")
|
||||||
|
else:
|
||||||
|
stock = open(STOCK, "rb").read()
|
||||||
|
stock_end = STOCK_BASE + len(stock) # exclusive
|
||||||
|
# compare over the region both cover
|
||||||
|
lo = STOCK_BASE
|
||||||
|
hi = min(stock_end, FLASH_SIZE)
|
||||||
|
diffs = []
|
||||||
|
for a in range(lo, hi):
|
||||||
|
if buf[a] != stock[a - STOCK_BASE]:
|
||||||
|
diffs.append(a)
|
||||||
|
print(f" stock region 0x{lo:04x}..0x{hi-1:04x} ({hi-lo} bytes)")
|
||||||
|
print(f" differing bytes: {len(diffs)}")
|
||||||
|
if diffs:
|
||||||
|
# group consecutive
|
||||||
|
groups = []
|
||||||
|
start = prev = diffs[0]
|
||||||
|
for a in diffs[1:]:
|
||||||
|
if a == prev + 1:
|
||||||
|
prev = a
|
||||||
|
else:
|
||||||
|
groups.append((start, prev)); start = prev = a
|
||||||
|
groups.append((start, prev))
|
||||||
|
print(f" {len(groups)} contiguous run(s):")
|
||||||
|
for s, e in groups[:40]:
|
||||||
|
length = e - s + 1
|
||||||
|
dev = buf[s:e+1]
|
||||||
|
stk = stock[s-STOCK_BASE:e+1-STOCK_BASE]
|
||||||
|
print(f" 0x{s:04x}..0x{e:04x} ({length} B) dev={dev.hex()} stock={stk.hex()}")
|
||||||
|
if len(groups) > 40:
|
||||||
|
print(f" ... ({len(groups)-40} more)")
|
||||||
|
else:
|
||||||
|
print(" >>> device app region is BYTE-IDENTICAL to stock <<<")
|
||||||
|
|
||||||
|
# ---- focused report: suspect page --------------------------------------
|
||||||
|
print(f"\n[RECOVERY] suspect page 0x{SUSPECT_LO:04x}..0x{SUSPECT_HI-1:04x}")
|
||||||
|
page = bytes(buf[SUSPECT_LO:SUSPECT_HI])
|
||||||
|
ff = sum(1 for b in page if b == 0xFF)
|
||||||
|
nonff = [i for i, b in enumerate(page) if b != 0xFF]
|
||||||
|
print(f" {len(page)} bytes; 0xFF count = {ff}; non-0xFF count = {len(nonff)}")
|
||||||
|
if nonff:
|
||||||
|
print(f" non-0xFF offsets (first 40): {[('0x%x'%(SUSPECT_LO+i)) for i in nonff[:40]]}")
|
||||||
|
else:
|
||||||
|
print(" >>> entire page reads 0xFF (ERASED) -- confirms RECOVERY.md hypothesis <<<")
|
||||||
|
# hex dump first 64 bytes of the page
|
||||||
|
print(" first 64 bytes:")
|
||||||
|
for off in range(0, min(64, len(page)), 16):
|
||||||
|
chunk = page[off:off+16]
|
||||||
|
print(f" {SUSPECT_LO+off:04x}: " + " ".join(f"{b:02x}" for b in chunk))
|
||||||
|
|
||||||
|
os.close(fd)
|
||||||
|
return 0 if not bad else 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// lock_stubs.c -- no-op retargetable-lock stubs for newlib.
|
||||||
|
//
|
||||||
|
// The Pico SDK expects a *non-retargetable* newlib (lock calls compile to
|
||||||
|
// nothing). The toolchain we build with ships a *retargetable* newlib, whose
|
||||||
|
// stdio/malloc/exit code references __lock___* objects and __retarget_lock_*
|
||||||
|
// functions that the SDK does not provide. c2probe is strictly single
|
||||||
|
// threaded, so all of these are no-ops. The named objects are the complete set
|
||||||
|
// enumerated from newlib's libc/libg (thumb) with `nm -u`.
|
||||||
|
|
||||||
|
#include <sys/lock.h>
|
||||||
|
|
||||||
|
/* Backing mutex objects newlib declares extern via __LOCK_INIT(). */
|
||||||
|
struct __lock __lock___sfp_recursive_mutex;
|
||||||
|
struct __lock __lock___sinit_recursive_mutex;
|
||||||
|
struct __lock __lock___malloc_recursive_mutex;
|
||||||
|
struct __lock __lock___env_recursive_mutex;
|
||||||
|
struct __lock __lock___atexit_recursive_mutex;
|
||||||
|
struct __lock __lock___at_quick_exit_mutex;
|
||||||
|
struct __lock __lock___tz_mutex;
|
||||||
|
struct __lock __lock___arc4random_mutex;
|
||||||
|
struct __lock __lock___dd_hash_mutex;
|
||||||
|
|
||||||
|
void __retarget_lock_init(_LOCK_T l) { (void)l; }
|
||||||
|
void __retarget_lock_init_recursive(_LOCK_T l) { (void)l; }
|
||||||
|
void __retarget_lock_close(_LOCK_T l) { (void)l; }
|
||||||
|
void __retarget_lock_close_recursive(_LOCK_T l) { (void)l; }
|
||||||
|
void __retarget_lock_acquire(_LOCK_T l) { (void)l; }
|
||||||
|
void __retarget_lock_acquire_recursive(_LOCK_T l) { (void)l; }
|
||||||
|
int __retarget_lock_try_acquire(_LOCK_T l) { (void)l; return 1; }
|
||||||
|
int __retarget_lock_try_acquire_recursive(_LOCK_T l) { (void)l; return 1; }
|
||||||
|
void __retarget_lock_release(_LOCK_T l) { (void)l; }
|
||||||
|
void __retarget_lock_release_recursive(_LOCK_T l) { (void)l; }
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# This is a copy of <PICO_SDK_PATH>/external/pico_sdk_import.cmake
|
||||||
|
|
||||||
|
# This can be dropped into an external project to help locate this SDK
|
||||||
|
# It should be include()ed prior to project()
|
||||||
|
|
||||||
|
if (DEFINED ENV{PICO_SDK_PATH} AND (NOT PICO_SDK_PATH))
|
||||||
|
set(PICO_SDK_PATH $ENV{PICO_SDK_PATH})
|
||||||
|
message("Using PICO_SDK_PATH from environment ('${PICO_SDK_PATH}')")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT} AND (NOT PICO_SDK_FETCH_FROM_GIT))
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT $ENV{PICO_SDK_FETCH_FROM_GIT})
|
||||||
|
message("Using PICO_SDK_FETCH_FROM_GIT from environment ('${PICO_SDK_FETCH_FROM_GIT}')")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_PATH} AND (NOT PICO_SDK_FETCH_FROM_GIT_PATH))
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT_PATH $ENV{PICO_SDK_FETCH_FROM_GIT_PATH})
|
||||||
|
message("Using PICO_SDK_FETCH_FROM_GIT_PATH from environment ('${PICO_SDK_FETCH_FROM_GIT_PATH}')")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_TAG} AND (NOT PICO_SDK_FETCH_FROM_GIT_TAG))
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT_TAG $ENV{PICO_SDK_FETCH_FROM_GIT_TAG})
|
||||||
|
message("Using PICO_SDK_FETCH_FROM_GIT_TAG from environment ('${PICO_SDK_FETCH_FROM_GIT_TAG}')")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (PICO_SDK_FETCH_FROM_GIT AND NOT PICO_SDK_FETCH_FROM_GIT_TAG)
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT_TAG "master")
|
||||||
|
message("Using master as default value for PICO_SDK_FETCH_FROM_GIT_TAG")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(PICO_SDK_PATH "${PICO_SDK_PATH}" CACHE PATH "Path to the Raspberry Pi Pico SDK")
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT "${PICO_SDK_FETCH_FROM_GIT}" CACHE BOOL "Set to ON to fetch copy of SDK from git if not otherwise locatable")
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT_PATH "${PICO_SDK_FETCH_FROM_GIT_PATH}" CACHE FILEPATH "location to download SDK")
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT_TAG "${PICO_SDK_FETCH_FROM_GIT_TAG}" CACHE FILEPATH "release tag for SDK")
|
||||||
|
|
||||||
|
if (NOT PICO_SDK_PATH)
|
||||||
|
if (PICO_SDK_FETCH_FROM_GIT)
|
||||||
|
include(FetchContent)
|
||||||
|
set(FETCHCONTENT_BASE_DIR_SAVE ${FETCHCONTENT_BASE_DIR})
|
||||||
|
if (PICO_SDK_FETCH_FROM_GIT_PATH)
|
||||||
|
get_filename_component(FETCHCONTENT_BASE_DIR "${PICO_SDK_FETCH_FROM_GIT_PATH}" REALPATH BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||||
|
endif ()
|
||||||
|
# GIT_SUBMODULES_RECURSE was added in 3.17
|
||||||
|
if (${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.17.0")
|
||||||
|
FetchContent_Declare(
|
||||||
|
pico_sdk
|
||||||
|
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
|
||||||
|
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
|
||||||
|
GIT_SUBMODULES_RECURSE FALSE
|
||||||
|
)
|
||||||
|
else ()
|
||||||
|
FetchContent_Declare(
|
||||||
|
pico_sdk
|
||||||
|
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
|
||||||
|
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
|
||||||
|
)
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (NOT pico_sdk)
|
||||||
|
message("Downloading Raspberry Pi Pico SDK")
|
||||||
|
FetchContent_Populate(pico_sdk)
|
||||||
|
set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR})
|
||||||
|
endif ()
|
||||||
|
set(FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR_SAVE})
|
||||||
|
else ()
|
||||||
|
message(FATAL_ERROR
|
||||||
|
"SDK location was not specified. Please set PICO_SDK_PATH or set PICO_SDK_FETCH_FROM_GIT to on to fetch from git."
|
||||||
|
)
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
|
||||||
|
if (NOT EXISTS ${PICO_SDK_PATH})
|
||||||
|
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' not found")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
set(PICO_SDK_INIT_CMAKE_FILE ${PICO_SDK_PATH}/pico_sdk_init.cmake)
|
||||||
|
if (NOT EXISTS ${PICO_SDK_INIT_CMAKE_FILE})
|
||||||
|
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' does not appear to contain the Raspberry Pi Pico SDK")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
set(PICO_SDK_PATH ${PICO_SDK_PATH} CACHE PATH "Path to the Raspberry Pi Pico SDK" FORCE)
|
||||||
|
|
||||||
|
include(${PICO_SDK_INIT_CMAKE_FILE})
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Patch QuNexus firmware so USB port 1 MIDI also drives the CV outputs.
|
||||||
|
|
||||||
|
Background (all addresses are in the 8051 code space of the application image,
|
||||||
|
which the bootloader programs from 0x2400 upward):
|
||||||
|
|
||||||
|
0xD3C0 USB endpoint-2 OUT service loop: reads a 4-byte USB-MIDI event from
|
||||||
|
FIFO2 into XDATA 0x0F9B, then calls the dispatcher at 0xDF05.
|
||||||
|
0xDF05 computes the CIN length, then calls the router at 0xA57B (its only
|
||||||
|
caller, via `LCALL 0xA57B` at 0xDF28).
|
||||||
|
0xA57B reads byte 0 of the event, takes the cable number (SWAP A / ANL A,#0Fh
|
||||||
|
at 0xA596) and selects the destination ring buffer:
|
||||||
|
cable 0 -> 0x0370 USB port 1 "Control Surface"
|
||||||
|
cable 1 -> 0x01C3 USB port 2 "Expander"
|
||||||
|
cable 2 -> 0x0382 USB port 3, the port feeding the CV engine
|
||||||
|
|
||||||
|
Because the CV_Out_*_MIDI_Input_Device preset enum only offers Expander / USB 3
|
||||||
|
(see CV_Out_Source in qt-qunexus/source/midiio/sysexencdecode.cpp:652), MIDI
|
||||||
|
arriving on USB 1 can never reach the CV outputs.
|
||||||
|
|
||||||
|
This patch redirects the single call site at 0xDF28 to a stub placed in unused
|
||||||
|
flash at 0xE8F2. The stub runs the original routing first (so USB 1 keeps every
|
||||||
|
existing behaviour), and then, only for cable 0, rewrites the cable nibble to 2
|
||||||
|
and routes the same event a second time -- into the USB-3/CV ring. Net effect:
|
||||||
|
USB 1 events are delivered to both their normal destination and the CV engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- syx container
|
||||||
|
|
||||||
|
def sysex_messages(data):
|
||||||
|
msgs, i = [], 0
|
||||||
|
while True:
|
||||||
|
s = data.find(b"\xf0", i)
|
||||||
|
if s < 0:
|
||||||
|
break
|
||||||
|
e = data.find(b"\xf7", s)
|
||||||
|
if e < 0:
|
||||||
|
break
|
||||||
|
msgs.append(data[s:e + 1])
|
||||||
|
i = e + 1
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
def decode_7in8(buf):
|
||||||
|
out = bytearray()
|
||||||
|
for i in range(0, len(buf) - 7, 8):
|
||||||
|
hi = buf[i + 7]
|
||||||
|
for j in range(7):
|
||||||
|
out.append(buf[i + j] | (0x80 if (hi >> j) & 1 else 0))
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_7in8(buf):
|
||||||
|
"""Inverse of midi_sx_encode_char(); caller must pad buf to a multiple of 7."""
|
||||||
|
assert len(buf) % 7 == 0
|
||||||
|
out = bytearray()
|
||||||
|
for i in range(0, len(buf), 7):
|
||||||
|
grp = buf[i:i + 7]
|
||||||
|
hi = 0
|
||||||
|
for j, c in enumerate(grp):
|
||||||
|
out.append(c & 0x7F)
|
||||||
|
if c & 0x80:
|
||||||
|
hi |= 1 << j
|
||||||
|
out.append(hi)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def split_message(msg):
|
||||||
|
"""-> (prefix bytes through SX_PACKET_START, decoded payload)."""
|
||||||
|
body = msg[1:-1]
|
||||||
|
i = 6
|
||||||
|
while i < len(body) and body[i] == 0x00:
|
||||||
|
i += 1
|
||||||
|
assert body[i] == 0x01, "SX_PACKET_START not found"
|
||||||
|
return msg[:1 + i + 1], decode_7in8(body[i + 1:])
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_message(prefix, payload):
|
||||||
|
pad = (-len(payload)) % 7
|
||||||
|
return prefix + encode_7in8(payload + b"\x00" * pad) + b"\xf7"
|
||||||
|
|
||||||
|
|
||||||
|
def crc16(data, crc=0xFFFF):
|
||||||
|
"""SysExEncDecode::crc_byte (sysexencdecode.cpp:1878), seed 0xFFFF."""
|
||||||
|
for ch in data:
|
||||||
|
temp = ((crc >> 8) ^ ch) & 0xFFFF
|
||||||
|
crc = (crc << 8) & 0xFFFF
|
||||||
|
quick = (temp ^ (temp >> 4)) & 0xFFFF
|
||||||
|
crc = (crc ^ quick) & 0xFFFF
|
||||||
|
quick = (quick << 5) & 0xFFFF
|
||||||
|
crc = (crc ^ quick) & 0xFFFF
|
||||||
|
quick = (quick << 7) & 0xFFFF
|
||||||
|
crc = (crc ^ quick) & 0xFFFF
|
||||||
|
return crc
|
||||||
|
|
||||||
|
|
||||||
|
def make_packet(addr, data, rtype=0x00):
|
||||||
|
"""Build one framed record: 03 LEN 3A LL AAAA TT <data> CC CRChi CRClo."""
|
||||||
|
rec = bytes([len(data), (addr >> 8) & 0xFF, addr & 0xFF, rtype]) + data
|
||||||
|
rec = b"\x3a" + rec + bytes([(-sum(rec)) & 0xFF])
|
||||||
|
body = bytes([0x03, len(rec) + 1]) + rec
|
||||||
|
c = crc16(body)
|
||||||
|
return body + bytes([c >> 8, c & 0xFF])
|
||||||
|
|
||||||
|
|
||||||
|
def iter_packets(payload):
|
||||||
|
"""Yield (start, end, addr, rtype, datalen) for each packet in a payload."""
|
||||||
|
i = 7
|
||||||
|
while i + 1 < len(payload):
|
||||||
|
while i < len(payload) and payload[i] == 0x00:
|
||||||
|
i += 1
|
||||||
|
if i + 1 >= len(payload) or payload[i] != 0x03:
|
||||||
|
break
|
||||||
|
ln = payload[i + 1]
|
||||||
|
end = i + 1 + ln + 2
|
||||||
|
addr = (payload[i + 4] << 8) | payload[i + 5]
|
||||||
|
yield (i, end, addr, payload[i + 6], ln - 7)
|
||||||
|
i = end
|
||||||
|
|
||||||
|
|
||||||
|
def reseal(payload, start, end):
|
||||||
|
"""Recompute the hex checksum and CRC16 of the packet at [start:end)."""
|
||||||
|
ln = payload[start + 1]
|
||||||
|
span = payload[start + 3:start + ln] # LL AAAA TT data
|
||||||
|
payload[start + ln] = (-sum(span)) & 0xFF # CC (Intel-HEX checksum)
|
||||||
|
c = crc16(bytes(payload[start:start + 1 + ln]))
|
||||||
|
payload[start + 1 + ln] = c >> 8
|
||||||
|
payload[start + 2 + ln] = c & 0xFF
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the patch
|
||||||
|
|
||||||
|
# Where the stub goes. This MUST be flash that a stock hex record already
|
||||||
|
# covers, otherwise the bootloader may never erase/program it and the LCALL
|
||||||
|
# lands in unprogrammed flash. 0x8126 is 25 bytes of 0xFF linker padding
|
||||||
|
# between two data tables and is inside a stock record; 0xE8F2 (a 270-byte
|
||||||
|
# hole covered by NO record) was tried first and bricked MIDI input, because
|
||||||
|
# the added record was not programmed. Do not use uncovered gaps.
|
||||||
|
STUB_ADDR = 0x8126
|
||||||
|
STUB_MAX = 25 # size of the 0xFF run at STUB_ADDR
|
||||||
|
CALL_SITE = 0xDF28 # LCALL 0xA57B inside the USB-MIDI dispatcher 0xDF05
|
||||||
|
ROUTER = 0xA57B
|
||||||
|
EVENT_BUF = 0x0F9B # XDATA holding the 4-byte USB-MIDI event
|
||||||
|
|
||||||
|
STUB = bytes([
|
||||||
|
0x12, ROUTER >> 8, ROUTER & 0xFF, # LCALL 0xA57B normal routing
|
||||||
|
0x90, EVENT_BUF >> 8, EVENT_BUF & 0xFF, # MOV DPTR,#0x0F9B
|
||||||
|
0xE0, # MOVX A,@DPTR A = byte0
|
||||||
|
0x54, 0xF0, # ANL A,#0F0h cable nibble
|
||||||
|
0x70, 0x0B, # JNZ done not cable 0
|
||||||
|
0xE0, # MOVX A,@DPTR
|
||||||
|
0x44, 0x20, # ORL A,#020h cable 0 -> 2
|
||||||
|
0xF0, # MOVX @DPTR,A
|
||||||
|
0x7E, EVENT_BUF >> 8, # MOV R6,#00Fh
|
||||||
|
0x7F, EVENT_BUF & 0xFF, # MOV R7,#09Bh
|
||||||
|
0x12, ROUTER >> 8, ROUTER & 0xFF, # LCALL 0xA57B -> CV ring
|
||||||
|
0x22, # done: RET
|
||||||
|
])
|
||||||
|
NEW_CALL = bytes([0x12, STUB_ADDR >> 8, STUB_ADDR & 0xFF])
|
||||||
|
OLD_CALL = bytes([0x12, ROUTER >> 8, ROUTER & 0xFF])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("infile")
|
||||||
|
ap.add_argument("outfile")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
data = open(args.infile, "rb").read()
|
||||||
|
msgs = [split_message(m) for m in sysex_messages(data)]
|
||||||
|
msgs = [(p, bytearray(pl)) for p, pl in msgs]
|
||||||
|
|
||||||
|
# --- 1. retarget the LCALL at 0xDF28 -------------------------------------
|
||||||
|
# An instruction can straddle two hex records, so write byte-wise and
|
||||||
|
# reseal every record touched.
|
||||||
|
index = [] # (addr, dlen, payload, start, end)
|
||||||
|
for _prefix, payload in msgs:
|
||||||
|
for start, end, addr, rtype, dlen in iter_packets(payload):
|
||||||
|
if rtype == 0x00:
|
||||||
|
index.append((addr, dlen, payload, start, end))
|
||||||
|
|
||||||
|
def locate(a):
|
||||||
|
for addr, dlen, payload, start, end in index:
|
||||||
|
if addr <= a < addr + dlen:
|
||||||
|
return payload, start, end, start + 7 + (a - addr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
touched = {}
|
||||||
|
|
||||||
|
def write(addr, new, expect=None):
|
||||||
|
"""Write bytes at `addr` into whatever record(s) already cover them."""
|
||||||
|
for k, b in enumerate(new):
|
||||||
|
loc = locate(addr + k)
|
||||||
|
if loc is None:
|
||||||
|
raise SystemExit(
|
||||||
|
f"ERROR: 0x{addr + k:04X} is not covered by any hex record. "
|
||||||
|
f"The stub must live in flash a stock record already writes.")
|
||||||
|
payload, start, end, off = loc
|
||||||
|
if expect is not None and payload[off] != expect[k]:
|
||||||
|
raise SystemExit(
|
||||||
|
f"ERROR: expected 0x{expect[k]:02X} at 0x{addr + k:04X}, "
|
||||||
|
f"found 0x{payload[off]:02X}")
|
||||||
|
payload[off] = b
|
||||||
|
touched[(id(payload), start)] = (payload, start, end)
|
||||||
|
|
||||||
|
if len(STUB) > STUB_MAX:
|
||||||
|
raise SystemExit(f"ERROR: stub is {len(STUB)} bytes, only {STUB_MAX} free")
|
||||||
|
|
||||||
|
# 1. the stub, into existing 0xFF padding (verify it really is free first)
|
||||||
|
write(STUB_ADDR, STUB, expect=b"\xff" * len(STUB))
|
||||||
|
# 2. retarget the call site (may straddle two records)
|
||||||
|
write(CALL_SITE, NEW_CALL, expect=OLD_CALL)
|
||||||
|
|
||||||
|
for payload, start, end in touched.values():
|
||||||
|
reseal(payload, start, end)
|
||||||
|
print(f" {len(touched)} record(s) modified; message count and sizes unchanged")
|
||||||
|
|
||||||
|
out = b"".join(rebuild_message(p, bytes(pl)) for p, pl in msgs)
|
||||||
|
open(args.outfile, "wb").write(out)
|
||||||
|
|
||||||
|
print(f"in : {args.infile} ({len(data)} bytes, {len(msgs)} messages)")
|
||||||
|
print(f"out : {args.outfile} ({len(out)} bytes, {len(msgs)} messages)")
|
||||||
|
print(f" 0x{CALL_SITE:04X}: LCALL 0x{ROUTER:04X} -> LCALL 0x{STUB_ADDR:04X}")
|
||||||
|
print(f" 0x{STUB_ADDR:04X}: {len(STUB)}-byte stub added ({STUB.hex(' ')})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+2
-1
@@ -32,7 +32,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
FWTOOLS = HERE.parent
|
FWTOOLS = HERE.parent
|
||||||
sys.path.insert(0, str(FWTOOLS))
|
sys.path.insert(0, str(FWTOOLS)) # fallback: parent firmware-tools/
|
||||||
|
sys.path.insert(0, str(HERE)) # local copy takes precedence (self-contained)
|
||||||
import patch_usb1_to_cv as P # noqa: E402
|
import patch_usb1_to_cv as P # noqa: E402
|
||||||
|
|
||||||
BASE = 0x2400
|
BASE = 0x2400
|
||||||
|
|||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract the raw firmware image from a KMI QuNexus firmware .syx file.
|
||||||
|
|
||||||
|
Container format (mirrors SysExEncDecode in qt-qunexus/source/midiio/sysexencdecode.cpp):
|
||||||
|
|
||||||
|
F0 00 01 5F 7A 19 [pad 00...] 01 <7-in-8 encoded stream> F7
|
||||||
|
|
||||||
|
* 00 01 5F 7A = manufacturer id bytes, 19 = product, 00 = format
|
||||||
|
* 01 = SX_PACKET_START (sysexencdecode.cpp:33)
|
||||||
|
* the encoded stream packs 7 data bytes as their low 7 bits followed by one
|
||||||
|
byte holding their high bits (bit j = high bit of data byte j) --
|
||||||
|
midi_sx_encode_char(), sysexencdecode.cpp:1830, SX_ENCODE_LEN = 7
|
||||||
|
|
||||||
|
The decoded stream is a packet preamble (00 02 <cat> <type> <crc16>) followed
|
||||||
|
by framed records: 03 <len+1> <binary Intel HEX record>
|
||||||
|
where the record is 3A LL AAAA TT <data...> CC (checksum = two's complement
|
||||||
|
of the sum of LL..data), i.e. Intel HEX in binary rather than ASCII form.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def sysex_messages(data):
|
||||||
|
msgs, i = [], 0
|
||||||
|
while True:
|
||||||
|
s = data.find(b"\xf0", i)
|
||||||
|
if s < 0:
|
||||||
|
break
|
||||||
|
e = data.find(b"\xf7", s)
|
||||||
|
if e < 0:
|
||||||
|
break
|
||||||
|
msgs.append(data[s:e + 1])
|
||||||
|
i = e + 1
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
def decode_7in8(buf):
|
||||||
|
"""Undo midi_sx_encode_char(): 7 low-7-bit bytes, then a high-bits byte."""
|
||||||
|
out = bytearray()
|
||||||
|
for i in range(0, len(buf) - 7, 8):
|
||||||
|
hi = buf[i + 7]
|
||||||
|
for j in range(7):
|
||||||
|
out.append(buf[i + j] | (0x80 if (hi >> j) & 1 else 0))
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_message(msg):
|
||||||
|
"""Strip the sysex header up to SX_PACKET_START and 7-in-8 decode the body."""
|
||||||
|
body = msg[1:-1] # drop F0 / F7
|
||||||
|
i = 6 # manufacturer id (4) + product + format
|
||||||
|
while i < len(body) and body[i] == 0x00:
|
||||||
|
i += 1 # padding before the packet start
|
||||||
|
if i >= len(body) or body[i] != 0x01: # SX_PACKET_START
|
||||||
|
return b""
|
||||||
|
return decode_7in8(body[i + 1:])
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hex_records(stream):
|
||||||
|
"""Scan the decoded stream for checksum-valid binary Intel HEX records."""
|
||||||
|
records, i, skipped = [], 0, 0
|
||||||
|
while i < len(stream):
|
||||||
|
if stream[i] != 0x3A:
|
||||||
|
i += 1
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if i + 5 > len(stream):
|
||||||
|
break
|
||||||
|
ln = stream[i + 1]
|
||||||
|
end = i + 5 + ln # 3A LL AA AA TT data...
|
||||||
|
if end >= len(stream):
|
||||||
|
break
|
||||||
|
rec = stream[i + 1:end] # LL AAAA TT data (checksummed span)
|
||||||
|
if (sum(rec) + stream[end]) & 0xFF != 0:
|
||||||
|
i += 1 # not a real record, keep scanning
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
records.append((stream[i + 4], # type
|
||||||
|
(stream[i + 2] << 8) | stream[i + 3], # address
|
||||||
|
bytes(rec[4:]))) # data
|
||||||
|
i = end + 1
|
||||||
|
return records, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def build_image(records):
|
||||||
|
"""Apply Intel HEX records (types 00/01/02/04) to a sparse address space."""
|
||||||
|
mem, base, eof = {}, 0, False
|
||||||
|
for rtype, addr, data in records:
|
||||||
|
if rtype == 0x00:
|
||||||
|
for k, b in enumerate(data):
|
||||||
|
mem[base + addr + k] = b
|
||||||
|
elif rtype == 0x01:
|
||||||
|
eof = True
|
||||||
|
elif rtype == 0x02 and len(data) == 2:
|
||||||
|
base = ((data[0] << 8) | data[1]) << 4
|
||||||
|
elif rtype == 0x04 and len(data) == 2:
|
||||||
|
base = ((data[0] << 8) | data[1]) << 16
|
||||||
|
return mem, eof
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("syx")
|
||||||
|
ap.add_argument("-o", "--out", help="write flat binary image here")
|
||||||
|
ap.add_argument("--hex", help="also write a standard ASCII .hex file here")
|
||||||
|
ap.add_argument("--fill", default="0xFF", help="gap fill byte (default 0xFF)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
data = open(args.syx, "rb").read()
|
||||||
|
msgs = sysex_messages(data)
|
||||||
|
stream = b"".join(decode_message(m) for m in msgs)
|
||||||
|
records, skipped = parse_hex_records(stream)
|
||||||
|
mem, eof = build_image(records)
|
||||||
|
|
||||||
|
print(f"file : {args.syx}")
|
||||||
|
print(f"sysex messages : {len(msgs)}")
|
||||||
|
print(f"decoded stream : {len(stream)} bytes ({skipped} non-record bytes skipped)")
|
||||||
|
print(f"hex records : {len(records)} (EOF record seen: {eof})")
|
||||||
|
if not mem:
|
||||||
|
print("no data records found", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
lo, hi = min(mem), max(mem)
|
||||||
|
types = sorted({t for t, _, _ in records})
|
||||||
|
print(f"record types : {[hex(t) for t in types]}")
|
||||||
|
print(f"address range : 0x{lo:08X} - 0x{hi:08X} ({hi - lo + 1} bytes span)")
|
||||||
|
print(f"bytes covered : {len(mem)} (gaps: {hi - lo + 1 - len(mem)})")
|
||||||
|
|
||||||
|
fill = int(args.fill, 0)
|
||||||
|
img = bytes(mem.get(a, fill) for a in range(lo, hi + 1))
|
||||||
|
if args.out:
|
||||||
|
open(args.out, "wb").write(img)
|
||||||
|
print(f"wrote : {args.out} ({len(img)} bytes, base 0x{lo:08X})")
|
||||||
|
if args.hex:
|
||||||
|
lines, base = [], None
|
||||||
|
for a in range(lo, hi + 1, 16):
|
||||||
|
chunk = bytes(mem.get(a + k, fill) for k in range(min(16, hi + 1 - a)))
|
||||||
|
upper = a >> 16
|
||||||
|
if upper != base:
|
||||||
|
base = upper
|
||||||
|
rec = bytes([2, 0, 0, 4, upper >> 8, upper & 0xFF])
|
||||||
|
lines.append(":" + (rec + bytes([(-sum(rec)) & 0xFF])).hex().upper())
|
||||||
|
rec = bytes([len(chunk), (a >> 8) & 0xFF, a & 0xFF, 0]) + chunk
|
||||||
|
lines.append(":" + (rec + bytes([(-sum(rec)) & 0xFF])).hex().upper())
|
||||||
|
lines.append(":00000001FF")
|
||||||
|
open(args.hex, "w").write("\n".join(lines) + "\n")
|
||||||
|
print(f"wrote : {args.hex}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user