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.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user