Files
nils 514983c158 Add tooling + EFM8UB20 QFP48 pinout doc
Make the repo self-contained (no dependency on the parent firmware-tools/
checkout): copy in the patch/disasm/syx tools and the c2probe RP2040 C2
flash-reader/patcher firmware.

- patch_usb1_to_cv.py : byte-level USB-1->CV patch (imported by roundtrip.py)
- d8051.py             : standalone 8051 disassembler
- syx_extract.py       : SysEx extractor
- c2probe/             : RP2040 C2 flash reader + host scripts
  (c2probe.c, cdc/dump_flash/patch_c2/reflash_page/verify.py, CMake build)
- RECOVERY.md          : C2 flash recovery procedure
- EFM8UB20_PINOUT.md   : reverse-engineered QFP48 pinout + firmware pin usage
- roundtrip.py         : import local patch_usb1_to_cv (parent as fallback)
- .gitignore           : exclude c2probe/.venv, c2probe/build

Verified: stock + patched round-trips still re-assemble byte-identical.
2026-08-17 23:35:09 +02:00

126 lines
5.2 KiB
Python

#!/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())