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.
439 lines
18 KiB
Python
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()) |