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.
233 lines
8.9 KiB
Python
233 lines
8.9 KiB
Python
#!/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())
|