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:
2026-08-17 23:35:09 +02:00
parent 3340cafb46
commit 514983c158
16 changed files with 2323 additions and 1 deletions
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Extract the raw firmware image from a KMI QuNexus firmware .syx file.
Container format (mirrors SysExEncDecode in qt-qunexus/source/midiio/sysexencdecode.cpp):
F0 00 01 5F 7A 19 [pad 00...] 01 <7-in-8 encoded stream> F7
* 00 01 5F 7A = manufacturer id bytes, 19 = product, 00 = format
* 01 = SX_PACKET_START (sysexencdecode.cpp:33)
* the encoded stream packs 7 data bytes as their low 7 bits followed by one
byte holding their high bits (bit j = high bit of data byte j) --
midi_sx_encode_char(), sysexencdecode.cpp:1830, SX_ENCODE_LEN = 7
The decoded stream is a packet preamble (00 02 <cat> <type> <crc16>) followed
by framed records: 03 <len+1> <binary Intel HEX record>
where the record is 3A LL AAAA TT <data...> CC (checksum = two's complement
of the sum of LL..data), i.e. Intel HEX in binary rather than ASCII form.
"""
import sys
import argparse
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):
"""Undo midi_sx_encode_char(): 7 low-7-bit bytes, then a high-bits byte."""
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 decode_message(msg):
"""Strip the sysex header up to SX_PACKET_START and 7-in-8 decode the body."""
body = msg[1:-1] # drop F0 / F7
i = 6 # manufacturer id (4) + product + format
while i < len(body) and body[i] == 0x00:
i += 1 # padding before the packet start
if i >= len(body) or body[i] != 0x01: # SX_PACKET_START
return b""
return decode_7in8(body[i + 1:])
def parse_hex_records(stream):
"""Scan the decoded stream for checksum-valid binary Intel HEX records."""
records, i, skipped = [], 0, 0
while i < len(stream):
if stream[i] != 0x3A:
i += 1
skipped += 1
continue
if i + 5 > len(stream):
break
ln = stream[i + 1]
end = i + 5 + ln # 3A LL AA AA TT data...
if end >= len(stream):
break
rec = stream[i + 1:end] # LL AAAA TT data (checksummed span)
if (sum(rec) + stream[end]) & 0xFF != 0:
i += 1 # not a real record, keep scanning
skipped += 1
continue
records.append((stream[i + 4], # type
(stream[i + 2] << 8) | stream[i + 3], # address
bytes(rec[4:]))) # data
i = end + 1
return records, skipped
def build_image(records):
"""Apply Intel HEX records (types 00/01/02/04) to a sparse address space."""
mem, base, eof = {}, 0, False
for rtype, addr, data in records:
if rtype == 0x00:
for k, b in enumerate(data):
mem[base + addr + k] = b
elif rtype == 0x01:
eof = True
elif rtype == 0x02 and len(data) == 2:
base = ((data[0] << 8) | data[1]) << 4
elif rtype == 0x04 and len(data) == 2:
base = ((data[0] << 8) | data[1]) << 16
return mem, eof
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("syx")
ap.add_argument("-o", "--out", help="write flat binary image here")
ap.add_argument("--hex", help="also write a standard ASCII .hex file here")
ap.add_argument("--fill", default="0xFF", help="gap fill byte (default 0xFF)")
args = ap.parse_args()
data = open(args.syx, "rb").read()
msgs = sysex_messages(data)
stream = b"".join(decode_message(m) for m in msgs)
records, skipped = parse_hex_records(stream)
mem, eof = build_image(records)
print(f"file : {args.syx}")
print(f"sysex messages : {len(msgs)}")
print(f"decoded stream : {len(stream)} bytes ({skipped} non-record bytes skipped)")
print(f"hex records : {len(records)} (EOF record seen: {eof})")
if not mem:
print("no data records found", file=sys.stderr)
return 1
lo, hi = min(mem), max(mem)
types = sorted({t for t, _, _ in records})
print(f"record types : {[hex(t) for t in types]}")
print(f"address range : 0x{lo:08X} - 0x{hi:08X} ({hi - lo + 1} bytes span)")
print(f"bytes covered : {len(mem)} (gaps: {hi - lo + 1 - len(mem)})")
fill = int(args.fill, 0)
img = bytes(mem.get(a, fill) for a in range(lo, hi + 1))
if args.out:
open(args.out, "wb").write(img)
print(f"wrote : {args.out} ({len(img)} bytes, base 0x{lo:08X})")
if args.hex:
lines, base = [], None
for a in range(lo, hi + 1, 16):
chunk = bytes(mem.get(a + k, fill) for k in range(min(16, hi + 1 - a)))
upper = a >> 16
if upper != base:
base = upper
rec = bytes([2, 0, 0, 4, upper >> 8, upper & 0xFF])
lines.append(":" + (rec + bytes([(-sum(rec)) & 0xFF])).hex().upper())
rec = bytes([len(chunk), (a >> 8) & 0xFF, a & 0xFF, 0]) + chunk
lines.append(":" + (rec + bytes([(-sum(rec)) & 0xFF])).hex().upper())
lines.append(":00000001FF")
open(args.hex, "w").write("\n".join(lines) + "\n")
print(f"wrote : {args.hex}")
return 0
if __name__ == "__main__":
sys.exit(main())