Files
qunexus-firmware/roundtrip.py
T
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

439 lines
18 KiB
Python

#!/usr/bin/env python3
"""roundtrip.py -- convert the radare2 disassembly (qunexus_v2.2.1.asm) into a
single sdas8051 assembler source, assemble it, and verify the result is
byte-identical to the stock firmware image. Optionally apply the USB-1->CV
patch in source form and verify against the expected patched image.
Design:
* Relative-jump targets (sjmp/jz/jnz/jc/jnc/jb/jnb/jbc/djnz/cjne) are
computed from the original instruction bytes (addr + len + signed offset),
NOT parsed from r2's operand text -- this sidesteps r2's `aav.`/`str.`
symbol quirks. Each target becomes a label `L_XXXX` defined at its byte.
* Absolute jumps/calls (ljmp/lcall/ajmp/acall) take a raw `0xXXXX` operand.
ajmp/acall don't 11-bit-encode numerically in sdas8051, so they mis-encode
and get pinned to `.db` by the verifier (same length -> no layout drift).
* Bit operands `0xHH.B` are converted to the raw bit address.
* A few unambiguous direct-of-register forms (push/pop rN, mov @rI,rM) are
pre-converted; everything else is fed through and pinned to `.db` if
sdas8051 errors or emits different bytes.
* Iterative pinning: assemble, find the first layout drift (an instruction
whose assembled length != original) or any byte mismatch, pin those
instructions to `.db <orig bytes>`, reassemble, until clean. Converges
because `.db` always has the exact original length and bytes.
Usage:
python3 roundtrip.py # stock round-trip
python3 roundtrip.py --patch # apply USB-1->CV patch in source, verify
"""
import re
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
FWTOOLS = HERE.parent
sys.path.insert(0, str(FWTOOLS)) # fallback: parent firmware-tools/
sys.path.insert(0, str(HERE)) # local copy takes precedence (self-contained)
import patch_usb1_to_cv as P # noqa: E402
BASE = 0x2400
END = 0xF806 # inclusive last byte of the app image
SDAS = "sdas8051"
ASM = "/tmp/rt.asm"
LST = "/tmp/rt.lst"
# relative jumps: (length, offset_byte_index). djnz is special (2 or 3 bytes).
REL_FIXED = { # op -> (len, off_idx)
"sjmp": (2, 1), "jz": (2, 1), "jnz": (2, 1), "jc": (2, 1), "jnc": (2, 1),
"jb": (3, 2), "jnb": (3, 2), "jbc": (3, 2), "cjne": (3, 2),
}
ABS_OPS = {"ljmp", "lcall", "ajmp", "acall"}
LINE_RE = re.compile(
r'^\s*[\\|/]?\s*0x([0-9a-fA-F]{8})\s+([0-9a-fA-F]+)\s+(\S.*)$')
# Annotated addresses. Emitted as `;` comment lines before the instruction,
# so the disassembly reads as a narrative. These survive regeneration (they
# live in this script, not in the generated file). Patch-mode comments
# (PATCH_COMMENTS) override COMMENTS at the same address.
COMMENTS = {
0xa57b: "USB-MIDI router (fcn.0000a57b): route the 4-byte event at @r6:r7 to a per-cable destination ring (called from 0xdf28)",
0xa57d: "switch to register bank 1 (PSW.RS0 = 1)",
0xa580: "save event-buffer pointer r6:r7 -> 0x0d3b/0x0d3c",
0xa588: "if CV-source-locked flag (bit 0x22.2) set, early-out (r7 = 1)",
0xa590: "read event[0]; extract cable number (high nibble) -> r7",
0xa59c: "cable 0 (USB-1 / Control Surface) -> dest 0x70",
0xa5a4: "cable 1 -> dest 0xc3",
0xa5b0: "cable 2 (USB-3) -> dest 0x82 (CV ring)",
0xa5ba: "write destination descriptor (r3,r2,r1) -> 0x0d3d/e/f (fcn.0000551e)",
0xa5c0: "common routing pass: copy MIDI bytes from the event buffer to the selected destination ring (runs ONCE per router invocation)",
0xa660: "restore PSW; return (r7 = status)",
0xdf05: "USB-MIDI event dispatcher (fcn.0000df05)",
0xdf28: "dispatch call -> router 0xa57b",
}
# Patch-mode-only annotations (the stub + retargeted call site).
# Each comment describes the instruction AT that address.
PATCH_COMMENTS = {
0x8126: "usb1_cv_stub: mirror cable-0 (USB-1) events into the CV ring (dest 0x82); first run the original router unchanged",
0x8129: "USB-MIDI 4-byte event buffer at 0x0F9B",
0x812c: "load event[0] (cable nibble in top 4 bits)",
0x812d: "isolate cable number (top nibble of event[0])",
0x812f: "not cable 0 -> done (no mirror needed)",
0x8131: "reload event[0]",
0x8132: "retag cable 0 as cable 2 (set bit 0x20)",
0x8134: "store back",
0x8135: "DPTR high = 0x0F (buffer page)",
0x8137: "DPTR low = 0x9B (buffer addr)",
0x8139: "route again -> USB-3 / CV ring (dest 0x82)",
0x813c: "done",
0xdf28: "dispatch call -> usb1_cv_stub (was: router 0xa57b)",
}
def signed(b):
return b - 0x100 if b >= 0x80 else b
def parse_asm(path):
"""Return ordered list of (addr, bytes, op, args_str) for instruction lines."""
out = []
for ln in Path(path).read_text(errors="replace").splitlines():
m = LINE_RE.match(ln)
if not m:
continue
addr = int(m.group(1), 16)
bs = bytes.fromhex(m.group(2))
rest = m.group(3).split(";", 1)[0].rstrip()
if not rest:
continue
parts = rest.split(None, 1)
op = parts[0]
args = parts[1] if len(parts) > 1 else ""
out.append((addr, bs, op, args))
return out
def bit_to_raw(byte_hex, bit):
byte = int(byte_hex, 16)
if byte < 0x80:
return (byte - 0x20) * 8 + bit
return byte + bit
def convert_bits(s):
def repl(m):
return "0x%02X" % bit_to_raw(m.group(1), int(m.group(2)))
return re.sub(r"0x([0-9a-fA-F]+)\.([0-7])", repl, s)
def normalize_symbols(s):
s = re.sub(r"fcn\.0000([0-9a-fA-F]+)", r"0x\1", s)
s = re.sub(r"loc\.0000([0-9a-fA-F]+)", r"0x\1", s)
s = re.sub(r"aav\.0x0000([0-9a-fA-F]+)", r"0x\1", s)
s = re.sub(r"aav\.0x([0-9a-fA-F]+)", r"0x\1", s)
return s
def preconvert(op, args):
"""Unambiguous direct-of-register forms that would otherwise error."""
m = re.match(r"^(push|pop)\s+r([0-7])$", op + " " + args)
if m:
return m.group(1), "0x0" + m.group(2)
m = re.match(r"^mov\s+@r([01]),\s+r([0-7])$", op + " " + args)
if m:
return "mov", "@r%s, 0x0%s" % (m.group(1), m.group(2))
return op, args
def rel_target(addr, bs, op):
if op == "djnz":
if len(bs) == 2:
off = bs[1]
else:
off = bs[2]
return addr + len(bs) + signed(off)
ln, oi = REL_FIXED[op]
return addr + ln + signed(bs[oi])
def make_items(parsed, stock):
"""Build the ordered item list covering 0x2400..END.
Each item: dict(addr, len, orig (bytes), op, args, is_gap, force_db).
Relative-jump targets are collected and returned as a label set.
Instruction interiors that contain a label target are split into per-byte
.db items so the label can land there.
"""
# First pass: instruction items + gap items, plus label targets.
labels = set()
raw = [] # addr -> (bs, op, args, is_gap)
r2 = {a: (bs, op, args) for (a, bs, op, args) in parsed}
addr = BASE
n = END - BASE + 1
items = []
# collect relative targets first (need full parse)
for (a, bs, op, args) in parsed:
if op in REL_FIXED or op == "djnz":
t = rel_target(a, bs, op)
if BASE <= t <= END:
labels.add(t)
else:
labels.add(t) # keep; will force-db the jump if unplaceable
# walk addresses
i = 0
sorted_instrs = sorted(parsed, key=lambda x: x[0])
cur = 0
addr = BASE
while addr <= END:
if cur < len(sorted_instrs) and sorted_instrs[cur][0] == addr:
a, bs, op, args = sorted_instrs[cur]
# any label strictly inside (a, a+len)?
interior = [t for t in labels if a < t < a + len(bs)]
if interior:
# split into per-byte .db with labels on target bytes
for k in range(len(bs)):
items.append({"addr": a + k, "len": 1, "orig": bs[k:k + 1],
"op": None, "args": "", "is_gap": True,
"force_db": True, "split": True})
else:
items.append({"addr": a, "len": len(bs), "orig": bs,
"op": op, "args": args, "is_gap": False,
"force_db": False})
addr += len(bs)
cur += 1
else:
items.append({"addr": addr, "len": 1, "orig": stock[addr - BASE:addr - BASE + 1],
"op": None, "args": "", "is_gap": True, "force_db": False})
addr += 1
return items, labels
def emit_source(items, labels, pinned, patch=False):
"""Emit sdas8051 source. Returns (text, item_source_line) mapping item
index -> the source line number that produces its bytes (for error maps).
`items` already reflects the patch (stub + retarget) when patching -- see
patch_items() -- so emission is uniform: a label before any item whose
address is a jump/call target, then either a mnemonic or `.db`."""
lines = []
lines.append(".area CODE (ABS)")
lines.append(".org 0x%04x" % BASE)
item_line = {}
cmt = dict(COMMENTS)
if patch:
cmt.update(PATCH_COMMENTS)
for idx, it in enumerate(items):
a = it["addr"]
if a in cmt:
lines.append("; " + cmt[a])
if a in labels:
lines.append("L_%04X:" % a)
if it["is_gap"] or it["force_db"] or a in pinned:
if it["is_gap"] and not it.get("force_db"):
lines.append(".db 0x%02X" % it["orig"][0])
else:
lines.append(".db " + ", ".join("0x%02X" % b for b in it["orig"]))
item_line[idx] = len(lines)
else:
op, args = preconvert(it["op"], it["args"])
if op in REL_FIXED or op == "djnz":
t = rel_target(a, it["orig"], op)
arglist = [x.strip() for x in args.split(",")] if args else []
if arglist:
arglist[-1] = "L_%04X" % t
arglist = [convert_bits(x) for x in arglist]
args = ", ".join(arglist)
else:
args = convert_bits(args)
args = normalize_symbols(args)
lines.append("%s %s" % (op, args) if args else op)
item_line[idx] = len(lines)
return "\n".join(lines) + "\n", item_line
# The USB-1->CV stub as (addr, op, args, bytes) items, written into the 0xFF
# padding at 0x8126-0x813C. The jnz target (0x813C, the ret) and the stub
# entry (0x8126, called by the retargeted lcall) become labels.
STUB_ITEMS = [
(0x8126, "lcall", "0xa57b", b"\x12\xa5\x7b"),
(0x8129, "mov", "dptr, #0x0f9b", b"\x90\x0f\x9b"),
(0x812C, "movx", "a, @dptr", b"\xe0"),
(0x812D, "anl", "a, #0xf0", b"\x54\xf0"),
(0x812F, "jnz", "L_813C", b"\x70\x0b"),
(0x8131, "movx", "a, @dptr", b"\xe0"),
(0x8132, "orl", "a, #0x20", b"\x44\x20"),
(0x8134, "movx", "@dptr, a", b"\xf0"),
(0x8135, "mov", "r6, #0x0f", b"\x7e\x0f"),
(0x8137, "mov", "r7, #0x9b", b"\x7f\x9b"),
(0x8139, "lcall", "0xa57b", b"\x12\xa5\x7b"),
(0x813C, "ret", "", b"\x22"),
]
def patch_items(items, labels):
"""Return (items, labels) with the USB-1->CV patch applied: the stock
0xFF items at 0x8126-0x813C replaced by the stub, and the dispatch call
at 0xDF28 retargeted to L_8126."""
stub_lo, stub_hi = P.STUB_ADDR, P.STUB_ADDR + len(P.STUB) - 1
out = []
for it in items:
a = it["addr"]
if a == stub_lo:
for (saddr, sop, sargs, sbs) in STUB_ITEMS:
out.append({"addr": saddr, "len": len(sbs), "orig": sbs,
"op": sop, "args": sargs, "is_gap": False,
"force_db": False})
continue
if stub_lo < a <= stub_hi:
continue # covered by the stub block
if a == P.CALL_SITE:
out.append({"addr": a, "len": len(P.NEW_CALL), "orig": bytes(P.NEW_CALL),
"op": "lcall", "args": "L_%04X" % P.STUB_ADDR,
"is_gap": False, "force_db": False})
continue
out.append(it)
labels = set(labels)
labels.add(P.STUB_ADDR) # stub entry (called by retarget)
labels.add(stub_hi) # jnz target (the ret)
return out, labels
def parse_lst(path):
"""Return ordered list of (addr, bytes) for byte-producing lines.
sdas8051 listing: 6-hex addr, single space, hex byte pairs (single-space
separated), then a 2+ space column gap before `[cycles]` (instruction
lines) or the decimal line number (`.db` lines). A lazy byte field
followed by the 2+ space gap avoids grabbing digits from the line number.
"""
out = []
rx = re.compile(
r"^\s+([0-9a-fA-F]{6})\s+([0-9a-fA-F]{2}(?: [0-9a-fA-F]{2})*?)\s{2,}")
for ln in Path(path).read_text(errors="replace").splitlines():
m = rx.match(ln)
if not m:
continue
addr = int(m.group(1), 16)
bs = bytes.fromhex(m.group(2).replace(" ", ""))
out.append((addr, bs))
return out
def assemble():
Path(LST).unlink(missing_ok=True)
proc = subprocess.run([SDAS, "-l", ASM], capture_output=True, text=True)
return proc.returncode, proc.stderr
def run(items, labels, patch=False):
pinned = set()
# Patch items (stub + retarget) are known to assemble correctly (verified
# in isolation). They must be exempt from pinning: during early iterations
# length-mismatch items desync the listing, and these late/inserted items
# get falsely flagged as mismatches. Left unpinned, they assemble correctly
# once the real culprits are pinned.
no_pin = set()
if patch:
no_pin.add(P.CALL_SITE)
no_pin.update(a for (a, _op, _ar, _bs) in STUB_ITEMS)
for iteration in range(200):
text, item_line = emit_source(items, labels, pinned, patch=patch)
Path(ASM).write_text(text)
rc, stderr = assemble()
if not Path(LST).exists():
# no listing -> pin items on error lines
for ml in re.finditer(r":(\d+): Error", stderr):
ln = int(ml.group(1))
for idx, sl in item_line.items():
if sl == ln and items[idx]["addr"] not in no_pin:
pinned.add(items[idx]["addr"])
continue
lst = parse_lst(LST)
# map error lines to items too
err_items = set()
for ml in re.finditer(r":(\d+): Error", stderr):
ln = int(ml.group(1))
for idx, sl in item_line.items():
if sl == ln:
err_items.add(idx)
pins_this = set()
if err_items:
# errors desync the listing order; pin them and reassemble
for idx in err_items:
if items[idx]["addr"] not in no_pin:
pins_this.add(items[idx]["addr"])
else:
# no errors -> listing lines map 1:1 to items by emission order.
# A length mismatch (sdas8051 emitted a different opcode length)
# would desync the whole tail, so detect it per-item by comparing
# the assembled length AND bytes -- this pins the actual culprit,
# not the victim of the drift it caused.
li = 0
for idx, it in enumerate(items):
if li >= len(lst):
if it["addr"] not in no_pin:
pins_this.add(it["addr"]) # missing tail
li += 1
continue
laddr, lbytes = lst[li]
if len(lbytes) != it["len"] or lbytes != it["orig"]:
if it["addr"] not in no_pin:
pins_this.add(it["addr"])
li += 1
if not pins_this:
return verify(items, lst, patch), iteration, text
if iteration < 10 or iteration % 20 == 0:
print(" iter %d: pins_this=%d err=%d pinned_total=%d" %
(iteration, len(pins_this), len(err_items), len(pinned)))
pinned |= pins_this
return False, iteration, text
def verify(items, lst, patch):
# build flat image from lst
img = bytearray(END - BASE + 1)
for addr, bs in lst:
off = addr - BASE
img[off:off + len(bs)] = bs
if patch:
expected = bytearray((HERE / "QuNexus_Firmware_v2.2.1.bin").read_bytes())
expected[P.STUB_ADDR - BASE:P.STUB_ADDR - BASE + len(P.STUB)] = bytes(P.STUB)
expected[P.CALL_SITE - BASE:P.CALL_SITE - BASE + len(P.NEW_CALL)] = bytes(P.NEW_CALL)
expected = bytes(expected)
else:
expected = (HERE / "QuNexus_Firmware_v2.2.1.bin").read_bytes()
ok = bytes(img) == expected
if not ok:
# find first diff
for i in range(min(len(img), len(expected))):
if img[i] != expected[i]:
print(" first diff at 0x%04X: got %02X want %02X" %
(BASE + i, img[i], expected[i]))
break
return ok
def main():
patch = "--patch" in sys.argv
out = HERE / "firmware.asm"
stock = bytearray((HERE / "QuNexus_Firmware_v2.2.1.bin").read_bytes())
parsed = parse_asm(HERE / "qunexus_v2.2.1.asm")
items, labels = make_items(parsed, stock)
if patch:
items, labels = patch_items(items, labels)
print("items: %d, labels: %d, instructions: %d" %
(len(items), len(labels), len(parsed)))
ok, iters, text = run(items, labels, patch=patch)
mode = "PATCHED" if patch else "stock"
if ok:
out.write_text(text)
print("PASS: %s image re-assembled byte-identical in %d iterations." % (mode, iters + 1))
print("wrote %s (%d lines)" % (out.name, text.count("\n")))
return 0
print("FAIL: %s image did not match after %d iterations." % (mode, iters + 1))
return 1
if __name__ == "__main__":
sys.exit(main())