Add recompilable sdas8051 source; round-trips to stock v2.2.1 binary

roundtrip.py converts the radare2 disassembly (qunexus_v2.2.1.asm) into a
single sdas8051 assembler source (firmware.asm) and verifies it re-assembles
byte-identical to the 54,279-byte stock image (base 0x2400).

  31,051 of 31,346 items (99.06%) re-assemble from mnemonics; 295 are
  pinned to .db where r2's display syntax doesn't round-trip through
  sdas8051 (256 ajmp/acall that sdas8051 mis-encodes, 39 data-in-code).
  Converges in 3 iterations.

Also adds patch_usb1_to_cv.asm (the USB-1->CV patch as a standalone
sdas8051 source) and verify_patch_asm.py (proves the assembled patch
matches the bytes written to the device over C2).

Reproduce:  python3 roundtrip.py
This commit is contained in:
2026-08-17 23:00:36 +02:00
parent 27c21396f8
commit ae2de3a787
5 changed files with 33911 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.lst
*.sym
*.asm.bak
+33361
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
; ---------------------------------------------------------------------------
; USB-1 -> CV routing patch for QuNexus firmware v2.2.1
;
; Goal: MIDI arriving on USB port 1 (Control Surface, cable 0) should also
; reach the CV engine. The stock firmware's CV_Out_Source only offers
; Expander / USB-3, so cable-0 events never enter the CV ring.
;
; Mechanism: retarget the single USB-MIDI dispatch call at 0xDF28 from the
; stock router (0xA57B) to a small stub (0x8126). The stub first runs the
; original router unchanged, then inspects the 4-byte USB-MIDI event buffer
; at 0x0F9B: if the event came in on cable 0 (top nibble == 0), it retags it
; as cable 2 (sets bit 0x20) and calls the router again so the event also
; lands in the USB-3 / CV ring. Events on any other cable pass through
; untouched (one router call, as stock).
;
; Abort-safety (matches patch_c2.py): the stub page (0x8000) is programmed
; BEFORE the retarget page (0xDE00), so a mid-failure never leaves a call to
; unprogrammed flash.
;
; This file assembles with sdas8051 (SDCC): sdas8051 -l patch_usb1_to_cv.asm
; The resulting bytes must match exactly what patch_c2.py writes to the
; device over C2 (see verify_patch_asm.py).
; ---------------------------------------------------------------------------
.area CODE (ABS)
; --- Stub routine (programmed into 0xFF padding at 0x8126, page 0x8000) ---
.org 0x8126
usb1_cv_stub:
lcall 0xa57b ; 12 a5 7b original cable-number router
mov dptr, #0x0f9b ; 90 0f 9b USB-MIDI 4-byte event buffer
movx a, @dptr ; e0 load event[0] (cable nibble in top 4 bits)
anl a, #0xf0 ; 54 f0 isolate cable number
jnz stub_done ; 70 0b not cable 0 -> nothing to mirror
movx a, @dptr ; e0 reload event[0]
orl a, #0x20 ; 44 20 retag cable 0 as cable 2
movx @dptr, a ; f0 store back
mov r6, #0x0f ; 7e 0f DPTR high = 0x0F (buffer page)
mov r7, #0x9b ; 7f 9b DPTR low = 0x9B (buffer addr)
lcall 0xa57b ; 12 a5 7b route again -> USB-3 / CV ring
stub_done:
ret ; 22
; --- Retarget: the single dispatch call site (page 0xDE00) ---------------
; Stock at 0xDF28 is `lcall 0xa57b` (12 a5 7b). Patched to call the stub.
.org 0xdf28
lcall 0x8126 ; 12 81 26 -> usb1_cv_stub
+395
View File
@@ -0,0 +1,395 @@
#!/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))
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.*)$')
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 = {}
for idx, it in enumerate(items):
a = it["addr"]
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())
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""verify_patch_asm.py -- prove the sdas8051-assembled patch matches the
bytes actually written to the device by patch_c2.py.
Pipeline:
1. Parse patch_usb1_to_cv.lst (sdas8051 listing) -> {addr: bytes}.
2. Extract the assembled stub (0x8126, 23 B) and retarget (0xDF28, 3 B).
3. Compare against patch_usb1_to_cv.STUB / NEW_CALL (the authoritative
bytes patch_c2.py verified on the device over C2).
4. Splice the assembled bytes into the stock binary and diff the result
against the expected patched image (stock + STUB + NEW_CALL).
Run from this directory:
sdas8051 -l -s patch_usb1_to_cv.asm
python3 verify_patch_asm.py
"""
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
FWTOOLS = HERE.parent
sys.path.insert(0, str(FWTOOLS))
import patch_usb1_to_cv as p # noqa: E402
BASE = 0x2400 # app image load address (flat binary base)
def parse_lst_bytes(lst_path):
"""Return {addr: bytes} for every instruction/data line in the listing."""
out = {}
# " 008126 12 A5 7B [24] 30 lcall ..."
line_re = re.compile(r'^\s+([0-9a-fA-F]{6})\s+([0-9a-fA-F]{2}(?:\s+[0-9a-fA-F]{2})*)\s+\[\d+\]')
for ln in Path(lst_path).read_text(errors="replace").splitlines():
m = line_re.match(ln)
if not m:
continue
addr = int(m.group(1), 16)
bs = bytes(int(b, 16) for b in m.group(2).split())
out[addr] = bs
return out
def splice(stock, addr, data):
"""Overlay `data` at file offset (addr - BASE) in a copy of stock."""
off = addr - BASE
img = bytearray(stock)
img[off:off + len(data)] = data
return bytes(img)
def main():
lst = HERE / "patch_usb1_to_cv.lst"
if not lst.exists():
sys.exit("missing patch_usb1_to_cv.lst -- run: sdas8051 -l -s patch_usb1_to_cv.asm")
mem = parse_lst_bytes(lst)
# Assembled patch bytes (contiguous).
asm_stub = mem[p.STUB_ADDR]
asm_call = mem[p.CALL_SITE]
# Sanity: stub must be exactly 23 contiguous bytes 0x8126..0x813C.
expected_stub_len = len(p.STUB)
assert len(asm_stub) == 3, asm_stub # first instruction is lcall (3 B); listing gives per-line bytes
# Reassemble the full stub by walking consecutive listing entries.
stub_bytes = b""
a = p.STUB_ADDR
while len(stub_bytes) < expected_stub_len:
chunk = mem[a]
stub_bytes += chunk
a += len(chunk)
assert len(stub_bytes) == expected_stub_len, (len(stub_bytes), expected_stub_len)
print("assembled stub :", stub_bytes.hex(' '))
print("expected STUB :", p.STUB.hex(' '))
print("assembled call :", asm_call.hex(' '))
print("expected CALL :", p.NEW_CALL.hex(' '))
ok_stub = stub_bytes == bytes(p.STUB)
ok_call = asm_call == bytes(p.NEW_CALL)
print(f"\nstub match : {ok_stub}")
print(f"call match : {ok_call}")
# Full-image diff: stock + assembled patch vs stock + authoritative patch.
stock = (HERE / "QuNexus_Firmware_v2.2.1.bin").read_bytes()
img_asm = splice(stock, p.STUB_ADDR, stub_bytes)
img_asm = splice(img_asm, p.CALL_SITE, asm_call)
img_exp = splice(stock, p.STUB_ADDR, bytes(p.STUB))
img_exp = splice(img_exp, p.CALL_SITE, bytes(p.NEW_CALL))
print(f"\nstock size : {len(stock)} bytes")
print(f"patched (asm) : {len(img_asm)} bytes")
print(f"patched (expect): {len(img_exp)} bytes")
print(f"full-image match: {img_asm == img_exp}")
if ok_stub and ok_call and img_asm == img_exp:
print("\nPASS: sdas8051-assembled patch is byte-identical to the patch "
"written to the device.")
return 0
print("\nFAIL")
return 1
if __name__ == "__main__":
sys.exit(main())