commit 8913cb4314bfbd5f4d258808264d1059c7a09c8c Author: Dejvino Date: Wed Sep 30 22:48:06 2026 +0200 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ea86f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# never ship in this repo +*.bin +*.zip +*.pdf +__pycache__/ +venv/ +scratch/ +# local dumps / captures of vendor IP +ic30*.bin +*_dump*.bin +# ghidra workdirs / projects +*_ghidra/ +pyproj/ +proj*/ +out/ +# os noise +.DS_Store diff --git a/FINDINGS.md b/FINDINGS.md new file mode 100644 index 0000000..1358a74 --- /dev/null +++ b/FINDINGS.md @@ -0,0 +1,121 @@ +# Lofi-12 XT — Findings Library + +Living knowledge base for this device. Process/tips live in `RE-PROCESS.md`. +Per-version firmware notes: `Lofi-12XT_v*/…/reversed.md`; version diff: `rev-diff.md`; +mod feasibility: `tweakability-report.md`; tool docs: `tools/README.md`. +Deep dives: `docs/hardware.md`, `docs/bootloader.md`, `docs/firmware-update.md`, +`docs/firmware/v1.5.205.md`. + +## 1. Hardware + +- Device: Sonicware Lofi-12 XT (shares platform with SmplTrek/Cydrums). +- Main SoC (core module): **TI TMS320C6748** — ARM9 + C674x DSP. Marking on unit: + `TMS320 C6748EZW T / 13CP99W / GI 375 / 527 ZWT`. +- DRAM (core module): **EtronTech EM68C16CWQG-25H** — 1 Gbit DDR2, base `0xC0000000`. +- SPI flash (core module, 2×): **Macronix MX25L12833F** (`MXIC MX 25L12833F M2I-10G`), + 16 MByte each, SOIC-8, silkscreen `IC300` / `IC301`. Shipping units hide the + marking under small stickers (`C047`/`C949`-style labels) — peel + photo to confirm. +- USB/aux MCU (main board): **ARTERY AT32F421K8T** (own firmware, out of scope). +- Storage: full-size SD slot (firmware update + samples/projects). +- PCBs seen: `405-VTOR-3852` (I/O), `405-VTOR-3849B` (main), `405-VTOR-3853` (jack), + core module with `CN201A/CN200A/CN203A/CN203B` board-to-board connectors. +- Silkscreen pin tables on main board expose `UART1_RX1/TX1`, `SPI1_SCK/MOSI`, + `SD_*`, `USB_DP/DN`, key/LED matrix — worth mapping before any invasive work. + +## 2. Memory map (DDR2) + +| Region | Content | +|---|---| +| `0xC0000000…` | DDR2 base (128 MB) | +| `0xC2xxxxxx` | SD `.bin` application image (DSP). v1.5 code `C2B80C00`, strings `C2D84DE0`, assets `C2DA7600` | +| `0xC7074630–C70B0598` | SPI AIS bootloader image (DSP), entry `C70A28A0` | +| `0xC706F280` | Runtime constant seen in updater (also == checksum seed, §5) | +| `0xC27C6282` | Scratch DDR used by updater (§5; value doubles as checksum seed) | + +## 3. SPI flash layout (`ic300.bin` / `ic301.bin`, 16 MiB each) + +Dumps: `ic300.bin` (`c2b86808…`), `ic301.bin` (`81f7b1f5…`). Always keep these +as recovery backups; re-verify with a second read (`sha256sum` + `cmp`). + +**IC300 (boot + app):** TI AIS image, magic `0x41504954` (`TIPA`) at file `0x0`. +PLL/DDR config (`0x5853590D` ×2), then 10× Section Load (`0x58535901`): + +| File off | DDR | Size | +|---|---|---| +| `0x000050` | `C7074630` | `0x1D0` | +| `0x00022C` | `C7074800` | `0x33B00` (main code) | +| `0x033D38` | `C70A8300` | `0x28CC` (rodata) | +| … | `C70AABD0`/`C70AABE0`/`C70AABF0` | `0xC`/`0xC`/`0xD44` | +| … | `C70AF000`/`C70AF4E8`/`C70AF5EC`/`C70AF660` | `0x200`/`0x104`/`0x70`/`0xF38` | + +`JumpClose (0x58535906)` at file `0x38660` → entry `C70A28A0` (DSP reset prelude +`ZERO b0; mvc b0,ier; mvc b0,csr`). After it: `FF` gap to `~0x100000`, data to +`~0x1EFFFF`, `FF` gap `0x1F–0x4Fxxxx`, data `0x500000–0xFC7C17`. No AIS CRC opcodes. + +**IC301 (data):** `AILS` header + drum-pattern names (`KICK/HIHAT/BALLAD/BLUES/…`), +24× `RIFF/WAVE` starting `0x8007E0`, nearly full to `0xFFFD7B`. Factory-sample flash. + +**Update path (from updater strings + code):** `SystemUpdater::{start, +updateBootSection, updateMainSection, updatePresetSection, updateMCUSection, +updateMCUBootSection}(CatMetaData::ROMSection)` + `SystemVerify::verifyPresetSection`. +Public ZIPs ship only the SYSTEM section (the SD `.bin`); BOOT/PRESET/MCU use +separate (unreleased) files. Updater lives in AIS flash, so a bad SYSTEM image +cannot kill recovery — hold PAD while powering on → `SYSTEM UPDATE` still works. + +## 4. SD `.bin` container (`cmtd/mmtd/sect → EOF`) + +| Off | Field | +|---|---| +| `0x00` | `cmtd` magic | +| `0x04` | u32 LE filesize (must match actual) | +| `0x08` | u32 checksum — **solved, §5** | +| `0x0C` | reserved 0 | +| `0x10–0x2F` | `12, 1, 3, fw_maj, fw_min, fw_patch, 48, remaining` | +| `0x30` | `mmtd` magic; `+0x04` remaining; `+0x08` flags (per-build, part of hash) | +| `0x40` | fw triple; `0x4C` entry point; `0x50` sect count | +| `0x54…` | `sect` ×N: `73 65 63 74` + u32 load_addr + u32 len + payload; must tile EOF | + +Known builds: v1.1.156 (1,595,605 B, entry `C26C4820`, 7 sects), v1.2.179 +(1,606,197 B, entry `C26CA4C0`, 6 sects), v1.5.205 (1,707,049 B, entry +`C2CD1AA0`, 7 sects). Code is C674x LE, entropy ~6.89, unencrypted. Parser/packer: +`tools/lofi_image.py` (`parse_image` asserts filesize + chain fit). + +## 5. Checksum (SOLVED) + +**Rule:** `cmtd+0x08` = CRC32-IEEE (init 0) over the entire file with bytes +`[8:12]` replaced by `0xC27C6282`. + +- `tools/lofi_image.py`: `CKSEED = 0xC27C6282`, `compute_checksum()`; `build_image()` + auto-computes by default (`--checksum keep` in `lofi_pack.py` preserves). +- Proof: GF(2) linear solve per image for the 32 unknown bits at `[8:12]` gives + `K = 0xC27C6282` on **all three** images independently (2⁻⁶⁴ fluke); forward + recompute reproduces `F58AF539 / DFF0D8C2 / B17C0857`; `unpack→pack` rebuilds + stock v1.5.205 byte-identical; a `Threshold→ThresholX` mod forges to a + self-consistent `462F735B`. +- Code anchors (bootloader DSP): CRC routine `C70A0A60` + (`NOT A6→state`, poly `EDB88320` via `MVK/MVKH`, byte loop, final `NOT` in delay + slot ≡ zlib chaining `F(buf,len,init)`); 4K-chunk caller `C708E4E4` + (`MVK 0x1000`, `CMPGT`, `CALLP C70A0A60`); check fn `C709E7C0` + (16-byte header CRC init 0 → chained 0x40000 chunks → `CMPEQ A13,A12` compare); + orchestrator `C708678C CALLP C709E7C0`, reject path builds + `ERROR : This file is invalid.` (`C70A8E82`). +- Forging: build image normally, then set `[8:12] = compute_checksum(image)`. + +## 6. OLED / updater UI strings (orientation) + +`Checking... 0%`, `Check the firmware file.`, `ERROR : This file is invalid.`, +`Erasing.../Writing.../Verifying... 0%`, `100%, done./OK./NG.`, +`Update completed./Update failed.`, `Please restart.`, `Are you sure?`, +`[CLR] : No / [OK] : Yes`, `>> BOOT/MCU/MCU Boot/PRESET/SYSTEM`, +`BOOT:/MCU:/MCU Boot:/PRESET:/SYSTEM:`, `$ systemupdate`, `[Lofi-12 XT] ~`, +`SYSTEM INFO/VERSION/BOOT/SYSTEM/MCU`, crash dumper (`Legacy NMI Exception`, +`A0–A31/B0–B31`, `NTSR/ITSR/…`). v1.5-only markers: `AppAudioExport*`, +`MIsolator/MRackComp/SlipRoll/HoldDelay`, `MIDINoteMap`, `REVERSE`, +`removeResourceFork` (all present in IC300 → board runs v1.5). + +## 7. Open questions + +- `mmtd+0x08` flags semantics (timestamp? covered by checksum as-is, no need to crack). +- Exact flash section table for BOOT/PRESET/MCU offsets (only SYSTEM reverse-engineered). +- AT32F421 firmware extraction/update protocol. +- UART/JTAG test-point mapping (silkscreen names exist, continuity not traced). diff --git a/RE-PROCESS.md b/RE-PROCESS.md new file mode 100644 index 0000000..74b1fab --- /dev/null +++ b/RE-PROCESS.md @@ -0,0 +1,116 @@ +# Lofi-12 XT — RE Process & Tips + +How the findings in `FINDINGS.md` were obtained, so nothing starts from scratch. +Golden rules: read-only first; two dumps + `cmp` before trusting anything; keep a +known-good stock SD for revert; never touch SPI flash or the AT32 unless recovery +is drilled and dumped. + +## 1. SPI dumping (checklist — full guide: `docs/flash-dumping.md`) + +Back up IC300 + IC301 before anything else; dumps stay local, never shipped. +Read-only, twice per chip, `sha256sum` + `cmp`, 16,777,216 B each; never +`-w`/`-E` until dumps verify. Watch for: stickers hiding markings (peel/photo), +black-CH341a 5 V signals (meter + 3.3 V mod), `5523`/UART vs `5512`/SPI jumper, +`errno=13` (udev/host execution — `distrobox-host-exec` from containers), +exact `flashrom -c` name. Sanity: `TIPA` at IC300+0, `AILS`/`RIFF` on IC301. + +## 2. Firmware triage (first hour, no disassembly) + +- `ls -l`, `sha256sum`, `xxd | head` (magic), `strings -n 6 | head`. +- Set-subtraction oracle: `strings -n 6` sets of SD image vs flash dump — + `onlySD == 0` against v1.5.205 proved the board's version (see `rev-diff.md` + for the v1.1→v1.2→v1.5 marker families). +- Opcode histogram for `xx 59 53 58` (AIS `0x585359xx` family): `01` = Section + Load, `06` = JumpClose — instant AIS map. Entrypoint disassembles under + C64x-LE to the reset prelude (`ZERO b0; mvc b0,ier; …`); ARM decode of the same + bytes is garbage → DSP-confirmed in seconds. +- `tools/lofi_image.py` parses/verifies the SD container (filesize + sect-chain + fit); `tools/ais_unpack.py` splits the AIS dump into DDR-addressed segments. + +## 3. Headless Ghidra + ghidra-c6000 lab (what actually worked) + +- Needs: Temurin JDK 21, Ghidra 12.1.3 + (`ghidra_12.1.3_PUBLIC_20260817.zip`, sha256 `93a5d11a…`), extension + `ghidra_12.1.3_PUBLIC_20260925_C6000.zip` (sha256 `ad44169d…`, + [geepot/ghidra-c6000](https://github.com/geepot/ghidra-c6000), targets Ghidra + 12.1.x, language `C6000:LE:32:default`). Versions must match — ext is tied to + the Ghidra build. Keep all of it in `/tmp/opencode/ghidra-lab` (outside repo). +- `unzip`/`python -m zipfile` extraction drops exec bits → `chmod +x` all + `*.sh`, `analyzeHeadless`, `ghidraRun*`, `*decompile*`, `launch*` or nothing runs. +- **`.java`/`.py` headless scripts do NOT run** in this setup + (`Failed to get OSGi bundle containing script` — affects even the extension's + own `C6000SetEntry.java`, any directory). Don't fight it. +- **Working path: PyGhidra** (`pip install pyghidra` in the project venv; + `GHIDRA_INSTALL_DIR` + `JAVA_HOME` set). Full API, no script providers: + `open_program(binary, language="C6000:LE:32:default", analyze=False)` → + `memory.createInitializedBlock(name, addr, InputStream, len, TaskMonitor.DUMMY, + False)` per AIS segment (exact overloads surface via the `TypeError` message — + read it, it lists signatures) → `flat.disassemble(entry)` + `createFunction` + → `flat.analyzeAll(program)` → `FlatDecompilerAPI(flat).decompile(fn)` + (returns the C string directly). Drivers: `/tmp/opencode/ghidra-lab/pyais*.py`. +- Auto-analysis creates almost no C6000 functions (VLIW flow) — force + `disassemble()` at targets, then `createFunction()`. `getReferencesTo()` is + empty for plain-`b` calls; find callers by scanning raw bytes for branch + targets instead. Dense-seed disassembly (every 8 B over a range) before dumping. +- Read `out/dis_*.txt` (predicated, packet-aware — far better than capstone) and + `out/dec_*.txt`. Project persists at `/tmp/opencode/ghidra-lab/pyproj` + (nested `pyproj/LofiPy/LofiPy.gpr`) for follow-up passes. + +## 4. C6000 static-analysis notes (C674x, LE) + +- Disassemble in 8-byte fetch packets; `CPKT`/`SPLOOP(W)`/`SPMASK`/`SPKERNEL` + markers matter. Capstone `CS_ARCH_TMS320C64X` is fine for triage but misdecodes + compact packets and hides predicates — confirm odd bytes in Ghidra. +- **Delay slots always execute**: `B`/5 slots, `CALLP`/6 slots. Args/returns are + set in delay slots *before* the callee runs (e.g. `MV A10,A4` after a `CALLP` + feeds the callee, not the code after return). Never read dataflow linearly. +- Calls: `B addr` (near), `CALLP addr,B3`, `BNOP reg` (virtual — target from a + vtable word, e.g. `LDW *+A3[2],B5`), `ADDKPC` sets the return. Returns: + `BNOP B3` (+ work hidden in its delay slots, e.g. final `NOT`). +- String refs are usually **not** absolute pointers: `MVK low + MVKH 0xC70A` + pairs, or `ADDKPC target,reg`. To find who uses a string, search for its + `MVK low16` (e.g. `ERROR` at `C70A8E82` ← `MVK -0x717E` + `MVKH -0x38F6`). +- ABI (TI C6000 EABI): args `A4,B4,A6,B6,…`, return `A4`, link `B3`, stack + `B15`/`A15` (`--` = push/prologue, `++` = pop/epilogue). `A10–A15/B10–B15` + callee-saved (survive calls — trace them across `CALLP`); `A0–A9/B0–B9` scratch. + `*++SP[n]` loads in epilogues are noise — filter non-SP bases when hunting + header parsers (`LDW *+Rn[1]/[2]` on the same non-SP reg ≈ struct+4/+8). +- CRC32-IEEE bitwise shape: `MVK 0x8320 + MVKH 0xEDB8` (poly), `LDBU *ptr++`, + `XOR`, 8× `AND 1 / SHRU 1 / [pred]XOR poly`, counter `SUB`, back-edge `B`; + `NOT` at entry (init) and in return delay slot (xorout) ⇒ zlib chaining + semantics `F(buf,len,init)`, so chunked ≡ whole. `DINT/RINT` around loops = + flash/SD critical section (update path smell). +- Decompiler limits (per ext docs): delay slots unmodelled, const-prop bounded + to 512 B, stack naming unreliable after `SUBAW`/push mixes — always verify + hot addresses against raw disassembly + file offsets. + +## 5. Checksum-cracking playbook (what cracked it) + +1. Rule out standard families first over spans + (`full/from_0x04/0x0C/0x30/0x54/payload`, DDR-sorted, addr+len+payload) × + inits × field-modes (checksum/filesize/flags zeroed/ff/asis) — + `tools/lofi_checksum*.py`. All failed here. +2. Kill whole classes mathematically instead of brute-forcing: the affine test + `(C1^C2)==(S1^S2)` over span pairs disproves *all* (seed, xor-mask) pairs for + CRC-32 at once. Check for a 256-word CRC table in-flash (linearity scan). +3. Isolate the routine from code (string builders → check fn → loop), confirm + semantics (poly/init/final/chaining), then enumerate the *remaining* unknowns + (here: 16-byte header + first init). +4. **GF(2) solve, don't guess:** CRC is affine — one image's 32-bit equality is + 32 linear constraints. Build columns via single-bit messages + (`CRC(single_bit) ^ CRC(zeros)`), Gaussian-eliminate, solve per image, and + demand the *same* constant from every image. Here all three gave + `0xC27C6282`, which also appears literally in the checker + (`MVK 0x6282/MVKH 0xC27C`, DDR scratch `*0xC27C6282`) — done. +5. Forge = compute over content with unknowns fixed, store result; verify by + re-check + `unpack→pack` byte-identity on stock images. + +## 6. Mod workflow (SD-only, OLED as oracle) + +1. Level-0: `lofi_patch_string.py stock.bin mod.bin Old New` (equal length!) — + checksum auto-computed (`lofi_image.compute_checksum`). +2. Copy to SD root as `Lofi-12 XT.bin`, hold PAD while powering on, read OLED + (`Checking…/Writing…/Verifying…/100%` vs `ERROR : This file is invalid.`). +3. Confirm the UI change on-device; keep stock SD for instant revert. +4. Escalate only after the loop is proven: xref-guided constant patches (Level-1), + then Ghidra-anchored branch/code-cave patches (Level-2+). diff --git a/README.md b/README.md new file mode 100644 index 0000000..097def1 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Lofi-12 XT custom firmware research + +> **Disclaimer:** everything here is for educational and entertainment purposes +> only. Modifying firmware can brick your device, void your warranty, or worse. +> Use at your own risk — the authors take no responsibility for damaged units, +> lost projects, or voided warranties. + +Reverse engineering + modding toolkit for the **Sonicware Lofi-12 XT** +(TI TMS320C6748: ARM9 + C674x DSP). Status: **SD-update checksum solved 3/3** — +custom `.bin` images can be forged and flashed with the stock updater, no +hardware mods needed. + +## Layout + +| Path | What | +|---|---| +| `FINDINGS.md` | Device knowledge library (hardware, flash map, formats, checksum, updater) | +| `RE-PROCESS.md` | How it was done: dumping, Ghidra lab, C6000 notes, cracking playbook | +| `rev-diff.md` | v1.1.156 → v1.2.179 → v1.5.205 firmware diff | +| `tweakability-report.md` | What mods are feasible, risk ladder | +| `tools/` | Stdlib-only Python: parse/pack/patch/verify SD images | +| `analysis/` | Function mapping, checksum solver + prover | +| `ghidra/` | Ghidra helpers (vtable CSV, section mapper, headless PyGhidra drivers) | +| `docs/` | `hardware.md`, `bootloader.md`, `firmware-update.md`, + `flash-dumping.md`, `firmware/v1.5.205.md`, own board photos | +| `docs/photos/` | Board + flash-chip photos (own work) | +| `docs/captures/` | Saleae Logic captures (`.sr`) | + +## Quickstart (Level-0 mod) + +You supply the stock `.bin` (official updates: +https://sonicware.jp/pages/downloads — see test vectors below) as `stock.bin`: + +```bash +python3 tools/lofi_unpack.py stock.bin unpack/ +python3 tools/lofi_pack.py unpack/ rebuilt.bin # must be byte-identical: cmp stock.bin rebuilt.bin +python3 tools/lofi_patch_string.py stock.bin mod.bin Threshold ThresholX +python3 tools/prove_checksum.py mod.bin # must print MATCH +``` + +Copy `mod.bin` to SD root as `Lofi-12 XT.bin`, hold PAD while powering on, +watch `SYSTEM UPDATE` on the OLED. Keep a stock SD for revert. + +## Test vectors (verify your stock files first) + +| Version | Size | SHA-256 | Entry | Sect | +|---|---|---|---|---| +| v1.1.156 | 1,595,605 | `617c3efe…1908ac5` (`617c3efea92399b0e3ed40890dd67a75013b0132059fa713076cae68a1908ac5`) | `C26C4820` | 7 | +| v1.2.179 | 1,606,197 | `30ea9eeb…616a212` (`30ea9eeb58cfeb2cddc68f1b15f65f2586d6956df27009eb9045ca78b616a212`) | `C26CA4C0` | 6 | +| v1.5.205 | 1,707,049 | `a8bffa65…198026` (`a8bffa65919c0a2d4ff2ce77d16301551af4d2964497b8804deed49ce4198026`) | `C2CD1AA0` | 7 | + +`python3 tools/prove_checksum.py` must print `MATCH` for every stock image +(checksums `F58AF539 / DFF0D8C2 / B17C0857`). + +## Safety + legal + +- Mods here touch only the SYSTEM image on SD. The updater lives in separate SPI + flash, so a bad image fails safe back to `SYSTEM UPDATE`. Still: no guarantees, + flash at your own risk, keep the stock revert SD. +- **No vendor binaries or flash dumps are shipped in this repo** (Sonicware IP). + Bring your own `.bin` from an official update ZIP; distribute mods as scripts, + never as full images. diff --git a/analysis/gen_funcmap.py b/analysis/gen_funcmap.py new file mode 100644 index 0000000..071a869 --- /dev/null +++ b/analysis/gen_funcmap.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""vtable-anchored function map + disassembly report for Lofi-12XT (capstone). + +Usage: + python3 analysis/gen_funcmap.py "Lofi-12XT_v1.5.205/.../Lofi-12 XT.bin" /tmp/opencode/fw +Writes: funcmap.json, funcmap.md, asm-linear.txt into outdir. +""" +import os +import struct +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools')) +from lofi_image import parse_image, find_sect_by_role, code_file_off + +from capstone import Cs, CS_ARCH_TMS320C64X, CS_MODE_LITTLE_ENDIAN + + +def collect_vfuncs(ro, code): + p = ro['payload'] + words = [struct.unpack('= 3: + runs.append((ro['addr'] + i * 4, words[i:j])) + i = j + else: + i += 1 + funcs = {} + for vaddr, ws in runs: + for k, w in enumerate(ws): + funcs.setdefault(w, []).append((vaddr, k)) + return runs, funcs + + +def main(): + image, outdir = sys.argv[1], sys.argv[2] + os.makedirs(outdir, exist_ok=True) + d = open(image, 'rb').read() + info = parse_image(d) + code = find_sect_by_role(info, 'code') + ro = find_sect_by_role(info, 'rodata') + entry = info['mmtd']['entry'] + runs, funcs = collect_vfuncs(ro, code) + + cs = Cs(CS_ARCH_TMS320C64X, CS_MODE_LITTLE_ENDIAN) + rows = [] + + + def head_at(faddr): + foff = code_file_off(info, faddr) + if foff is None: + return [""] + for base_off, base_addr in ((foff, faddr), + (foff - (faddr % 32), faddr - (faddr % 32))): + if base_off < 0: + continue + try: + insns = list(cs.disasm(d[base_off:base_off + 64], base_addr)) + except Exception as e: + return ["" % e] + for ins in insns: + if ins.address == faddr or base_addr != faddr: + return ["%08X %s %s" % (i.address, i.mnemonic, i.op_str) + for i in insns[:6]] or [""] + if insns and base_addr == faddr: + return ["%08X %s %s" % (i.address, i.mnemonic, i.op_str) + for i in insns[:6]] + return [""] + + for faddr in sorted(funcs): + rows.append({"addr": faddr, "addr_hex": "%08X" % faddr, + "refs": funcs[faddr], "nrefs": len(funcs[faddr]), + "head": head_at(faddr)}) + # entry must be present even if not vtable-referenced + if entry not in funcs: + foff = code_file_off(info, entry) + head = ["%08X %s %s" % (i.address, i.mnemonic, i.op_str) + for i in list(cs.disasm(d[foff:foff + 64], entry))[:6]] + rows.append({"addr": entry, "addr_hex": "%08X" % entry, + "refs": [], "nrefs": 0, "head": head}) + rows.sort(key=lambda r: r["addr"]) + + import json + json.dump({"image_size": len(d), "entry_hex": "%08X" % entry, + "code": {"addr_hex": "%08X" % code['addr'], "len": code['len']}, + "rodata": {"addr_hex": "%08X" % ro['addr'], "len": ro['len']}, + "nvtable": len(runs), "nfunc": len(rows), "funcs": rows}, + open(os.path.join(outdir, "funcmap.json"), "w"), indent=1) + + with open(os.path.join(outdir, "funcmap.md"), "w") as f: + f.write("# funcmap v1.5.205 — %d vtables, %d unique vfuncs (+entry %08X)\n\n" + % (len(runs), len(rows), entry)) + f.write("| func addr | xrefs | first insn |\n|---|---|---|\n") + for r in rows: + f.write("| %s | %d | `%s` |\n" + % (r["addr_hex"], r["nrefs"], r["head"][0].replace("|", "/"))) + + # linear sweep of whole code sect (fetch-packet granularity) + with open(os.path.join(outdir, "asm-linear.txt"), "w") as f: + cpay = code['payload'] + for i in range(0, len(cpay), 8): + chunk = cpay[i:i + 8] + if len(chunk) < 8: + break + for ins in cs.disasm(chunk, code['addr'] + i): + f.write("%08X: %s %s\n" % (ins.address, ins.mnemonic, ins.op_str)) + print("vtables=%d funcs=%d entry=%08X" % (len(runs), len(rows), entry)) + print("wrote %s/{funcmap.json,funcmap.md,asm-linear.txt}" % outdir) + + +if __name__ == '__main__': + main() diff --git a/analysis/solve_ckseed.py b/analysis/solve_ckseed.py new file mode 100644 index 0000000..c7a9772 --- /dev/null +++ b/analysis/solve_ckseed.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Solve the checksum seed: solve_ckseed.py image.bin + +CRC32 is affine, so one image's 32-bit equality is 32 linear constraints on +the 32 unknown bits at file[8:12]. Solves via GF(2) Gaussian elimination and +prints the constant K such that + stored == CRC32(file with [8:12] := K). +Run on several stock images: all must print the same K (here: C27C6282). +Stdlib only. +""" +import binascii +import struct +import sys + + +def crc(b: bytes, init: int = 0) -> int: + return binascii.crc32(b, init) & 0xFFFFFFFF + + +def solve_for_K(d: bytes, target: int): + n = len(d) + d0 = bytearray(d) + d0[8:12] = b"\0\0\0\0" + rhs = target ^ crc(bytes(d0)) + c0 = crc(bytes(n)) + cols = [] + for p in range(32): + m = bytearray(n) + m[8 + p // 8] = 1 << (p % 8) + cols.append(crc(bytes(m)) ^ c0) + rows = [] + for r in range(32): + mask = 0 + for p in range(32): + if (cols[p] >> r) & 1: + mask |= 1 << p + rows.append([mask, (rhs >> r) & 1]) + where = [-1] * 32 + row = 0 + for col in range(32): + sel = next((i for i in range(row, 32) + if (rows[i][0] >> col) & 1), -1) + if sel < 0: + continue + rows[row], rows[sel] = rows[sel], rows[row] + where[col] = row + for i in range(32): + if i != row and ((rows[i][0] >> col) & 1): + rows[i][0] ^= rows[row][0] + rows[i][1] ^= rows[row][1] + row += 1 + for i in range(32): + if rows[i][0] == 0 and rows[i][1] != 0: + return None # inconsistent: wrong structural hypothesis + if any(w < 0 for w in where): + return None # underdetermined + return sum((rows[where[p]][1] << p) for p in range(32)) + + +def main() -> int: + d = open(sys.argv[1], "rb").read() + target = struct.unpack("> BOOT / SYSTEM / PRESET / MCU / MCU Boot`, prompts `BOOT:`…): + `Erasing...`, `Writing...`, `Verifying...` with `%` progress. +4. `100%, done.` / `Update completed.` (`100%, NG.` / `Update failed.` on error) + → `Please restart.` + +## Checks performed on the `.bin` + +1. Magic: `cmtd` @`0x00`, `mmtd` @`0x30`, `sect` chain from `0x54`. +2. `cmtd+0x04` filesize equals actual file size. +3. `sect` chain tiles EOF exactly (`next = off + 12 + len`); load addresses must + land in DDR (`0xC2xxxxxx`); entry point must sit inside the code sect. +4. **Checksum** `cmtd+0x08` — recomputed and compared (`CMPEQ`, reject = + `ERROR : This file is invalid.`). Rule (proven 3/3, see below): + CRC32-IEEE, init 0, over the whole file with bytes `[8:12]` replaced by + `0xC27C6282`. `mmtd+0x08` flags are covered as-is (no special handling). + +## Forging a valid image + +```bash +python3 tools/lofi_patch_string.py stock.bin mod.bin OldString NewStringX +python3 tools/prove_checksum.py mod.bin # expect MATCH +``` + +`lofi_pack.py`/`lofi_patch_string.py` stamp the checksum automatically +(`lofi_image.compute_checksum`); `--checksum keep` preserves the old value. +`analysis/solve_ckseed.py` re-derives the seed constant from any stock image +(GF(2) solve, expect `C27C6282`). + +## Notes + +- Public ZIPs ship only the SYSTEM section; BOOT/PRESET/MCU use separate files. +- Only same-length string/asset/constant edits keep every address stable + (Level-0). Longer content needs slack-space pointer patching (`lofi_xref.py`). +- The AT32 USB MCU updates through `updateMCUSection` — protocol unreversed; + leave MCU sections alone. diff --git a/docs/firmware/notes-v1.1.156.md b/docs/firmware/notes-v1.1.156.md new file mode 100644 index 0000000..347cdef --- /dev/null +++ b/docs/firmware/notes-v1.1.156.md @@ -0,0 +1,95 @@ +> Historical snapshot (2026-09-26 working notes). Two claims are superseded: +> update entry is hold-PAD-while-powering-on (see ../firmware-update.md), +> and the cmtd checksum is solved (see ../../tools/README.md). + +# Lofi-12 XT.bin — Reverse Engineering Notes (v1.1.156) + +- File: `Lofi-12 XT.bin` (firmware v1.1.156) +- Size: 1,595,605 bytes (`0x1858D5`) +- SHA256: `617c3efea92399b0e3ed40890dd67a75013b0132059fa713076cae68a1908ac5` +- Folder also contains `.DS_Store`; no sample folders (unlike v1.5.205) +- Same SD-root `SYSTEM UPDATE` flow as v1.5.205 + +## Hardware context + +Same platform as v1.5.205 (see `../Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/reversed.md`): +TI TMS320C6748 (ARM9 + C674x DSP), DDR2 base `0xC0000000`. All `sect` +load addresses are `0xC2xxxxxx` (DDR2 snapshot loaded by the SPI-flash bootloader). + +## Container format (same Sonicware custom format as v1.5.205) + +``` +[cmtd 48B][mmtd 36B][sect x7 -> EOF, exact fit] +``` + +### cmtd (0x00–0x2F) + +| Off | Value | Meaning | +|-----|-------|---------| +| 0x00 | `cmtd` | magic | +| 0x04 | 1595605 | u32 LE filesize (matches) | +| 0x08 | `0xF58AF539` | u32 checksum (?) — differs per version, algorithm TBD | +| 0x10–0x2F | `(12, 1, 3, 1, 1, 156, 48, 1595557)` | container (?, 1, 3), fw (1, 1, 156), hdr len 48, remaining | + +### mmtd (0x30–0x53) + +| Off | Value | Meaning | +|-----|-------|---------| +| 0x30 | `mmtd` | magic | +| 0x34 | 1595557 | remaining size | +| 0x38 | `6e 25 13 20 ff ff ff ff` | timestamp/flags? (build-specific) | +| 0x40 | `(1, 1, 156)` | fw version | +| 0x4C | `0xC26C4820` | **entry point** (inside big code sect, verified below) | +| 0x50 | 7 | sect count | + +### sects + +| # | File off | Load addr | Len | End addr | Role | +|---|----------|-----------|-----|----------|------| +| 0 | 0x000054 | C27753B8 | 85912 (`0x14F98`) | C278A350 | asset/blob | +| 1 | 0x014FF8 | C2774C6C | 656 (`0x290`) | C2774EFC | float table | +| 2 | 0x015294 | C2775000 | 512 (`0x200`) | C2775200 | record table | +| 3 | 0x0154A0 | C2589800 | 1389120 (`0x153240`) | C26DCA40 | **main code (.text), C6000 DSP** | +| 4 | 0x1686EC | C2589508 | 8 | C2589510 | zeros (gap/patch slot, same as v1.5.205) | +| 5 | 0x168700 | C2755488 | 115561 (`0x1C369`) | C27718F1 | **.rodata/.data** (all readable strings) | +| 6 | 0x184A75 | C27737F8 | 3668 (`0xE54`) | C277464C | float ramp tail | + +Chain verified: `next_off = off + 12 + len`, ends exactly at EOF (`0x1858D5`). +Memory tiling: code `C25895xx–C26DCA40`, data `C2755488–C277464C` (same +code→data→assets split as v1.5.205, just at lower DDR addresses). + +## Code identification + +- Entry `0xC26C4820` → file off `0x1504CC`. C64x-LE disassembly: + `ZERO b0; mvc b0,ier; mvc b0,csr; mvk 0x37f4,b15; mvklh -0x3d89,b15; + and -8,…` — identical reset prelude shape as v1.2.179/v1.5.205, only the + stack immediates differ. Confirms C6000 DSP code. +- Big sect entropy 6.890, zeros 175,494 (12.6%) — same code+data mix as v1.5.205. +- String sect cross-refs: 5,854 words into code range + 2,932 self-pointers + (vs 6,362 + 3,141 in v1.5.205 — table growth with features). +- Total `strings>=4`: 10,902 (vs 11,110 in v1.2.179; v1.5.205 higher). + +## Data sect contents (file 0x168700–0x184A75) + +- Same C674x crash dumper (`Legacy NMI Exception`, `IERR=`, `A0=…A31=`, `B0=…B31=`, + `NTSR/ITSR/IRP/SSR/AMR`, `14AppSongBrowser`, `N3HAL…Driver`, libc++ `NSt3__`). +- Older `FileUtil` API shape: signatures use `(…S1_S1_PA256_c…Fv…)` / + `(…S1_jjPjS2_…)`; **no `removeResourceFork`** symbols at all (present in v1.5.205), + and no `Rb` (bool) params — file-DB / resource-fork handling postdates this build. +- v1.5-only feature strings absent: `Snip Loop` 0 hits, `PATTERN MIXDOWN` 0, + `AUDIO EXPORT` 0. (`Isolator` 1 hit, `Reverse` 1, `Compressor` 2 — isolated + pre-existing occurrences, not the v1.5 master-FX set.) + +## Differences vs newer builds + +| | v1.1.156 | v1.2.179 | v1.5.205 | +|---|---|---|---| +| size | 1,595,605 | 1,606,197 | 1,707,049 | +| nsect | 7 (with 8 B patch slot) | 6 (slot absent) | 7 (slot back) | +| code len | 1,389,120 | 1,399,200 | 1,490,112 | +| strings len | 115,561 | 117,825 | 125,081 | +| code base | C2589800 | C258D800 | C2B80C00 | +| entry | C26C4820 | C26CA4C0 | C2CD1AA0 | + +v1.1→v1.2 is a small delta (+10 kB code); v1.2→v1.5 is the big jump (+91 kB code, ++7 kB strings: audio export, 4 new master FX, 16 slices, MIDI Note Map, …). diff --git a/docs/firmware/notes-v1.2.179.md b/docs/firmware/notes-v1.2.179.md new file mode 100644 index 0000000..0878c1c --- /dev/null +++ b/docs/firmware/notes-v1.2.179.md @@ -0,0 +1,96 @@ +> Historical snapshot (2026-09-26 working notes). Two claims are superseded: +> update entry is hold-PAD-while-powering-on (see ../firmware-update.md), +> and the cmtd checksum is solved (see ../../tools/README.md). + +# Lofi-12 XT.bin — Reverse Engineering Notes (v1.2.179) + +- File: `Lofi-12 XT.bin` (firmware v1.2.179) +- Size: 1,606,197 bytes (`0x188235`) +- SHA256: `30ea9eeb58cfeb2cddc68f1b15f65f2586d6956df27009eb9045ca78b616a212` +- Same SD-root `SYSTEM UPDATE` flow as the other builds + +## Hardware context + +Same platform as v1.5.205 (see `../Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/reversed.md`): +TI TMS320C6748 (ARM9 + C674x DSP), DDR2 base `0xC0000000`. All `sect` +load addresses are `0xC2xxxxxx` (DDR2 snapshot loaded by the SPI-flash bootloader). + +## Container format (same Sonicware custom format) + +``` +[cmtd 48B][mmtd 36B][sect x6 -> EOF, exact fit] +``` + +Note: **6 sects** — the 8-byte zero patch slot (`C2589508` in v1.1.156, +`C2B809E8` in v1.5.205) is absent here. + +### cmtd (0x00–0x2F) + +| Off | Value | Meaning | +|-----|-------|---------| +| 0x00 | `cmtd` | magic | +| 0x04 | 1606197 | u32 LE filesize (matches) | +| 0x08 | `0xDFF0D8C2` | u32 checksum (?) — differs per version, algorithm TBD | +| 0x10–0x2F | `(12, 1, 3, 1, 2, 179, 48, 1606149)` | container (?, 1, 3), fw (1, 2, 179), hdr len 48, remaining | + +### mmtd (0x30–0x53) + +| Off | Value | Meaning | +|-----|-------|---------| +| 0x30 | `mmtd` | magic | +| 0x34 | 1606149 | remaining size | +| 0x38 | `4e b6 e4 04 ff ff ff ff` | timestamp/flags? (build-specific) | +| 0x40 | `(1, 2, 179)` | fw version | +| 0x4C | `0xC26CA4C0` | **entry point** (inside big code sect, verified below) | +| 0x50 | 6 | sect count | + +### sects + +| # | File off | Load addr | Len | End addr | Role | +|---|----------|-----------|-----|----------|------| +| 0 | 0x000054 | C277B320 | 83496 (`0x14628`) | C278F948 | asset/blob | +| 1 | 0x014688 | C277AB18 | 660 (`0x294`) | C277ADAC | float table | +| 2 | 0x014928 | C277B000 | 512 (`0x200`) | C277B200 | record table | +| 3 | 0x014B34 | C258D800 | 1399200 (`0x1559A0`) | C26E31A0 | **main code (.text), C6000 DSP** | +| 4 | 0x16A4E0 | C275A7A0 | 117825 (`0x1CC41`) | C27773E1 | **.rodata/.data** (all readable strings) | +| 5 | 0x18712D | C27793E8 | 4348 (`0x10FC`) | C277A4E4 | float ramp tail | + +Chain verified: `next_off = off + 12 + len`, ends exactly at EOF (`0x188235`). + +## Code identification + +- Entry `0xC26CA4C0` → file off `0x151800`. C64x-LE disassembly: + `ZERO b0; mvc b0,ier; mvc b0,csr; mvk -0x6c1c,b15; mvklh -0x3d89,b15; + and -8,…` — same reset prelude as v1.1.156/v1.5.205, only stack immediates + differ. Confirms C6000 DSP code. +- Big sect entropy 6.887, zeros 177,496 (12.7%) — same code+data mix. +- String sect cross-refs: 5,909 words into code range + 2,967 self-pointers + (vs 5,854 + 2,932 in v1.1.156; 6,362 + 3,141 in v1.5.205). +- Total `strings>=4`: 11,110; meaningful (≥10 chars) in data sect: 1,053. + +## Data sect contents (file 0x16A4E0–0x18712D) + +- Same C674x crash dumper, `14AppSongBrowser`, `N3HAL…Driver`, libc++ `NSt3__` + as v1.1.156. +- Same older `FileUtil` API shape as v1.1.156 (`(…S1_S1_PA256_c…Fv…)`, + `(…S1_jjPjS2_…)`); **no `removeResourceFork`** — that API (plus `Rb` params + and `Fvi` callbacks) appears only in v1.5.205. +- v1.5-only feature strings absent: `Snip Loop` 0, `PATTERN MIXDOWN` 0, + `AUDIO EXPORT` 0 (same isolated `Isolator`/`Reverse`/`Compressor` hits as v1.1.156). + +## Differences vs the other builds + +| | v1.1.156 | v1.2.179 | v1.5.205 | +|---|---|---|---| +| size | 1,595,605 | 1,606,197 | 1,707,049 | +| nsect | 7 (with 8 B patch slot) | **6 (slot absent)** | 7 (slot back) | +| code len | 1,389,120 | 1,399,200 | 1,490,112 | +| strings len | 115,561 | 117,825 | 125,081 | +| code base | C2589800 | C258D800 | C2B80C00 | +| entry | C26C4820 | C26CA4C0 | C2CD1AA0 | + +v1.1→v1.2 is a small delta (+10 kB code, +2 kB strings — matches the v1.2 +changelog: per-track swing, new filters LSF/HSF, EVEN slice mode, PAD hold/ROLL, +PTN MUTE, …). v1.2→v1.5 is the big jump (+91 kB code: audio export, 4 new master +FX, 16 slices, MIDI Note Map, …). The coming/going 8 B zero sect looks like +alignment/patch-slot churn in their image generator rather than a real payload. diff --git a/docs/firmware/notes-v1.5.205.md b/docs/firmware/notes-v1.5.205.md new file mode 100644 index 0000000..f860d82 --- /dev/null +++ b/docs/firmware/notes-v1.5.205.md @@ -0,0 +1,140 @@ +> Historical snapshot (2026-09-26 working notes). Two claims are superseded: +> update entry is hold-PAD-while-powering-on (see ../firmware-update.md), +> and the cmtd checksum is solved (see ../../tools/README.md). + +# Lofi-12 XT.bin — Reverse Engineering Notes + +- File: `Lofi-12 XT.bin` (firmware v1.5.205) +- Size: 1,707,049 bytes (`0x1A0C29`) +- SHA256: `a8bffa65919c0a2d4ff2ce77d16301551af4d2964497b8804deed49ce4198026` +- Source: `Lofi-12XT_v1.5.205` update ZIP (also contains `Dibiase/`, `Ski Beatz/` sample folders) +- Update method: copy `.bin` to SD root, boot with button held → `SYSTEM UPDATE` + +## Hardware context + +Based on community teardown (SmplTrek / Lofi-12 XT / Cydrums share platform): + +- TI TMS320C6748 (ARM9 + C674x floating-point DSP) — main SoC +- ARTERY AT32F421K8T (ARM Cortex-M, USB/aux) +- Etron EM68C16CWQG 1 Gbit DDR2, base `0xC0000000` +- Macronix MX25L12833F SPI flash (bootloader lives here, parses SD `.bin`) + +All `sect` load addresses are `0xC2xxxxxx`, i.e. DDR2. The `.bin` is a DDR snapshot +loaded by the SPI-flash bootloader, not a flash image itself. + +## Container format (Sonicware custom, not AIS/ELF) + +``` +[cmtd 48B][mmtd 36B][sect x7 -> EOF, exact fit] +``` + +### cmtd (file header, 0x00–0x2F, 48 bytes) + +| Off | Bytes | Meaning | +|-----|-------|---------| +| 0x00 | `63 6d 74 64` (`cmtd`) | magic | +| 0x04 | `29 0c 1a 00` | u32 LE filesize = 1707049 (matches) | +| 0x08 | `57 08 7c b1` | u32 checksum (?) `0xB17C0857`, algorithm TBD (not plain CRC32) | +| 0x0C | `00 00 00 00` | reserved | +| 0x10 | `0c 00 00 00` | 12 (?) | +| 0x14 | `01 00 00 00` | container ver major? (1) | +| 0x18 | `03 00 00 00` | container ver minor? (3) | +| 0x1C | `01 00 00 00` | firmware major (1) | +| 0x20 | `05 00 00 00` | firmware minor (5) | +| 0x24 | `cd 00 00 00` | firmware patch (205) → **v1.5.205** | +| 0x28 | `30 00 00 00` | 48 = cmtd header len | +| 0x2C | `f9 0b 1a 00` | remaining size = filesize − 48 = 1707001 | + +### mmtd (image header, 0x30–0x53, 36 bytes) + +| Off | Bytes | Meaning | +|-----|-------|---------| +| 0x30 | `6d 6d 74 64` (`mmtd`) | magic | +| 0x34 | `f9 0b 1a 00` | remaining size | +| 0x38 | `e8 bc f8 4f` | timestamp/flags? (`0x4FF8BCE8`) | +| 0x3C | `ff ff ff ff` | −1 | +| 0x40 | `01 00 00 00` | fw major (1) | +| 0x44 | `05 00 00 00` | fw minor (5) | +| 0x48 | `cd 00 00 00` | fw patch (205) | +| 0x4C | `a0 1a cd c2` | **entry point `0xC2CD1AA0`** (inside big code sect) | +| 0x50 | `07 00 00 00` | sect count = 7 | + +### sect (0x54 → EOF) + +Each: `73 65 63 74` (`sect`) | u32 LE load_addr | u32 LE len | payload. +Chain verified: `next_off = off + 12 + len`, ends exactly at EOF (`0x1A0C29`). + +| # | File off | Load addr | Len | End addr | Role | +|---|----------|-----------|-----|----------|------| +| 0 | 0x000054 | C2DA7600 | 86168 (`0x15098`) | C2DBC698 | asset/blob (`7e xx` patterned) | +| 1 | 0x0150F8 | C2DA6DD4 | 688 (`0x2B0`) | C2DA7084 | float table (~−97…−102) | +| 2 | 0x0153B4 | C2DA7400 | 512 (`0x200`) | C2DA7600 | record table, links #1→#0 | +| 3 | 0x0155C0 | C2B80C00 | 1490112 (`0x16BCC0`) | C2CEC8C0 | **main code (.text), C6000 DSP** | +| 4 | 0x18128C | C2B809E8 | 8 | C2B809F0 | zeros (gap/patch slot) | +| 5 | 0x1812A0 | C2D84DE0 | 125081 (`0x1E899`) | C2DA32D9 | **.rodata/.data** (all readable strings) | +| 6 | 0x019FB45 | C2DA5680 | 4312 (`0x10D8`) | C2DA6758 | float ramp (~−99.34 step) | + +Sorted by address they tile `C2B809E8…C2DBC698` with small gaps (descriptors/BSS). + +Unpacked with: + +```python +import struct +off = 0x54 +while d[off:off+4] == b'sect': + a, b = struct.unpack(' -r ic300_a.bin` (then `_b`). +4. `sha256sum ic300_*.bin; cmp ic300_a.bin ic300_b.bin` — must be identical; + expect exactly 16,777,216 B per MX25L12833F. Third read on any mismatch. + Then save one verified copy as `ic300.bin` (`ic301.bin` for the other chip). + Canonical names everywhere: `ic300.bin` / `ic301.bin`. +5. Never `-w`/`-E` (write/erase) during backup. Writing is only for recovery + with a verified dump in hand. + +## 6. Sanity-check the dumps + +- IC300 starts `54 49 50 41` (`TIPA` = AIS `0x41504954`), entropy ~6.9. +- IC301 starts `AILS`, pattern names in clear, `RIFF/WAVE`s from `0x8007E0`. +- `onlySD == 0` string-set test vs the running firmware version + (see `RE-PROCESS.md` §2) confirms which release is flashed. + +## Troubleshooting + +| Symptom | Cause → fix | +|---|---| +| `1a86:5523`, `ttyUSB0` | UART mode → move mode jumper, replug | +| `Couldn't open … errno=13` (even with sudo in container) | USB not passed through → run on host (§4) | +| `FF…`/`00…` reads, JEDEC ID unknown | Clip seating / wrong `-c` name / chip still powered by board → reseat, hold reset, re-probe `VCC` | +| Signals meter ~5 V | Unmodded black CH341a → §2 before retrying | diff --git a/docs/hardware.md b/docs/hardware.md new file mode 100644 index 0000000..348fef3 --- /dev/null +++ b/docs/hardware.md @@ -0,0 +1,52 @@ +# Hardware + +Static facts about the Lofi-12 XT hardware (own teardown + photos in +`photos/`). Behavioral/firmware side: `../FINDINGS.md`, `bootloader.md`. + +## Boards + +| PCB | Role | +|---|---| +| `405-VTOR-3852` | I/O board (jacks, MIDI, relays `HFD4/5`, USB-C area) | +| `405-VTOR-3849B` | Main board (`28-AUG-2023` rev on unit): SD slot, MCU, power, FFC to UI | +| `405-VTOR-3853` | Jack sub-board (`2/JUN/2022`) | +| core module (unmarked) | Compute: SoC + DDR + 2× SPI flash, board-to-board `CN200A/CN201A/CN203A/CN203B` | + +## Chips (all confirmed visually) + +- **TI TMS320C6748** (`TMS320 C6748EZW T / 13CP99W`) — main SoC, ARM9 + C674x DSP. +- **EtronTech EM68C16CWQG-25H** (`B07DY15MSYA0051`) — 1 Gbit DDR2. +- **2× Macronix MX25L12833F** (`MXIC MX 25L12833F M2I-10G`), 16 MiB SPI NOR each, + silkscreen `IC300` (boot+app) / `IC301` (data). Factory stickers cover the + marking (`C047`/`C949`-style labels) — peel + photograph before any clip work. +- **ARTERY AT32F421K8T** — USB/aux MCU, separate firmware (strings reference + `updateMCUSection`/`updateMCUBootSection`; protocol not reversed). + +## Memory map (DDR2, base `0xC0000000`, 128 MB) + +| Region | Use | +|---|---| +| `0xC2xxxxxx` | SD `.bin` application image (v1.5: code `C2B80C00`, strings `C2D84DE0`) | +| `0xC7074630–C70B0598` | SPI AIS bootloader image, entry `C70A28A0` | +| `0xC706F280` | Updater constant (file/scratch buffer area) | +| `0xC27C6282` | Updater DDR scratch; value reused as checksum seed | + +## Rails / probing + +- Flash `VCC` measures 3.3 V in-circuit — never apply 5 V (see `RE-PROCESS.md` §1). +- Jack board silkscreen: `A+7.5V / AGND / A-7.5V` analog rails, `DSU`/`AGNC`. +- Main-board silkscreen tables name `UART1_RX1/TX1`, `SPI1_SCK/MOSI`, `SD_*`, + `USB_DP/DN`, key matrix (`KS1–KS6`, `EN_1A–5B`), `TP1–TP5` — candidates for + UART/JTAG mapping (continuity not yet traced; see `uart-zoom.jpg`). + +## Photos + +![Core module: TMS320C6748 + DDR2 + IC300/IC301](photos/core-module-top.jpg) +*Core module — SoC, DDR2 and both SPI flashes (bottom edge).* + +![IC300 (left) + IC301 (right), stickers removed](photos/ic300-ic301-closeup.jpg) +*Both are Macronix MX25L12833F, 16 MiB. Pin-1 dots face down-left; note the +`IC300`/`IC301` silkscreen between the packages.* + +![UART area](photos/uart-zoom.jpg) +*Close-up of the UART test-point area (unannotated).* diff --git a/docs/photos/core-module-top.jpg b/docs/photos/core-module-top.jpg new file mode 100644 index 0000000..4937089 Binary files /dev/null and b/docs/photos/core-module-top.jpg differ diff --git a/docs/photos/ic300-ic301-closeup.jpg b/docs/photos/ic300-ic301-closeup.jpg new file mode 100644 index 0000000..dbb3c13 Binary files /dev/null and b/docs/photos/ic300-ic301-closeup.jpg differ diff --git a/docs/photos/uart-zoom.jpg b/docs/photos/uart-zoom.jpg new file mode 100644 index 0000000..da14500 Binary files /dev/null and b/docs/photos/uart-zoom.jpg differ diff --git a/ghidra/Lofi12XT_Map.py b/ghidra/Lofi12XT_Map.py new file mode 100644 index 0000000..14f7d04 --- /dev/null +++ b/ghidra/Lofi12XT_Map.py @@ -0,0 +1,91 @@ +# Lofi12XT_Map.py — Ghidra-side script (Jython2 + Ghidrathon compatible). +# Run headless as -postScript. Two modes: +# Mode A (map): Lofi12XT_Map.py +# Creates one memory block per sect*.bin at its DDR address (from headers.json), +# marks mmtd entry point, disassembles + makes a function there. +# Mode B (vtables): Lofi12XT_Map.py vtables +# Labels each vtable run, converts entries to pointers, bookmarks them. +# No f-strings (Jython 2.7 safe). No third-party deps. +import json +import os + +from ghidra.program.model.symbol import SourceType +from ghidra.program.model.data import PointerDataType +from java.io import File + + +def log(msg): + print("[Lofi12XT] " + msg) + + +def map_sects(unpack_dir): + info = json.load(open(os.path.join(unpack_dir, "headers.json"))) + mem = currentProgram.getMemory() + for s in info["sects"]: + name = "sect%d" % s["index"] + addr = toAddr(s["addr_hex"]) + fn = os.path.join(unpack_dir, + "sect%d_addr%s.bin" % (s["index"], s["addr_hex"])) + size = s["len"] + if mem.getBlock(addr) is not None: + log(name + " already mapped at " + s["addr_hex"] + ", skip") + continue + mem.createInitializedBlock(name, addr, File(fn), size, + "from lofi_unpack", "", False) + blk = mem.getBlock(addr) + is_code = (size > 1000000) # only the big sect is code + blk.setRead(True) + blk.setWrite(not is_code) + blk.setExecute(is_code) + log("mapped %s %s len=%d exec=%s" % (name, s["addr_hex"], size, is_code)) + + entry = toAddr(info["mmtd"]["entry_hex"]) + currentProgram.getSymbolTable().addExternalEntryPoint(entry) + createLabel(entry, "fw_entry", True, SourceType.IMPORTED) + disassemble(entry) + createFunction(entry, "fw_entry") + log("entry " + info["mmtd"]["entry_hex"] + " marked + function created") + + +def map_vtables(csv_path): + st = currentProgram.getSymbolTable() + bm = currentProgram.getBookmarkManager() + count = 0 + with open(csv_path) as f: + header = f.readline() + for line in f: + line = line.strip() + if not line: + continue + parts = line.split(",", 3) + load = toAddr(parts[1]) + entries = parts[3].split(";") + createLabel(load, "vtable_%s" % parts[1], True, + SourceType.ANALYSIS) + for k, e in enumerate(entries): + ea = load.add(k * 4) + try: + createData(ea, PointerDataType.dataType) + except Exception: + try: + createDword(ea) + except Exception: + pass # labels/bookmarks below still land + try: + createLabel(toAddr(e), "vfunc_%s_%d" % (parts[1], k), + False, SourceType.ANALYSIS) + except Exception: + pass + bm.setBookmark(load, "Note", "vtable", + "vtable %s n=%d" % (parts[1], len(entries))) + count += 1 + log("labeled %d vtables from %s" % (count, csv_path)) + + +args = getScriptArgs() +if len(args) == 1: + map_sects(args[0]) +elif len(args) == 2 and args[0] == "vtables": + map_vtables(args[1]) +else: + log("usage: Lofi12XT_Map.py OR Lofi12XT_Map.py vtables ") diff --git a/ghidra/gen_vtable_csv.py b/ghidra/gen_vtable_csv.py new file mode 100644 index 0000000..54a009d --- /dev/null +++ b/ghidra/gen_vtable_csv.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Offline helper (runs HERE, stdlib-only): cluster vtable candidates. + +A vtable = run of >=3 consecutive LE32 words in rodata, each pointing +into the code sect. Output CSV is consumed by the Ghidra-side script. + +Usage: + python3 ghidra/gen_vtable_csv.py "Lofi-12XT_v1.5.205/.../Lofi-12 XT.bin" /tmp/vtables-1.5.205.csv +""" +import os +import struct +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools')) +from lofi_image import parse_image, find_sect_by_role + + +def main(): + image, out = sys.argv[1], sys.argv[2] + d = open(image, 'rb').read() + info = parse_image(d) + code = find_sect_by_role(info, 'code') + ro = find_sect_by_role(info, 'rodata') + print("code sect%d %08X..%08X" % (code['index'], code['addr'], code['end'])) + print("rodata sect%d %08X..%08X" % (ro['index'], ro['addr'], ro['end'])) + + p = ro['payload'] + words = [struct.unpack('= 3: + runs.append((i * 4, j - i, words[i:j])) + i = j + else: + i += 1 + + with open(out, 'w') as f: + f.write("rodata_off,load_addr,nentries,entries\n") + for off, cnt, ws in runs: + f.write("%d,%08X,%d,%s\n" + % (off, ro['addr'] + off, cnt, + ";".join("%08X" % w for w in ws))) + to_code = sum(is_code_ptr) + print("rodata LE words -> code: %d, vtable runs(>=3): %d -> %s" + % (to_code, len(runs), out)) + + +if __name__ == '__main__': + main() diff --git a/ghidra/headless/DumpAIS.java b/ghidra/headless/DumpAIS.java new file mode 100644 index 0000000..1bf3297 --- /dev/null +++ b/ghidra/headless/DumpAIS.java @@ -0,0 +1,104 @@ +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.decompiler.DecompileResults; +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.address.AddressSpace; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.Instruction; +import ghidra.program.model.listing.InstructionIterator; +import ghidra.program.model.listing.Listing; + +import java.io.FileWriter; +import java.io.PrintWriter; + +/** + * DumpAIS.java — Ghidra headless postScript. Decompiles + disassembles + * checksum-hunt targets into /tmp/opencode/ghidra-lab/out/. + */ +public class DumpAIS extends GhidraScript { + + static final String OUT = "/tmp/opencode/ghidra-lab/out"; + static final String[] TARGETS = { + "C70A0A60", "C708E4E4", "C7086400", "C70A28A0", "C707841C" + }; + + @Override + public void run() throws Exception { + new java.io.File(OUT).mkdirs(); + AddressSpace space = currentProgram.getAddressFactory() + .getDefaultAddressSpace(); + Listing listing = currentProgram.getListing(); + DecompInterface iface = new DecompInterface(); + iface.openProgram(currentProgram); + + for (String t : TARGETS) { + Address addr = space.getAddress(t); + Function fn = getFunctionAt(addr); + if (fn == null) { + try { + disassemble(addr); + } + catch (Exception e) { + println("[DumpAIS] " + t + " disassemble: " + e); + } + try { + fn = createFunction(addr, "sub_" + t); + } + catch (Exception e) { + println("[DumpAIS] " + t + " createFunction: " + e); + } + } + else { + println("[DumpAIS] " + t + " fn=" + fn.getName()); + } + // disassembly window: 64B back, ~120 insns forward + try { + Address start = addr.addNoWrap(-64); + InstructionIterator it = listing.getInstructions(start, true); + PrintWriter pw = new PrintWriter(new FileWriter( + OUT + "/dis_" + t + ".txt")); + int n = 0; + while (it.hasNext() && n < 150) { + Instruction ins = it.next(); + pw.println(String.format("%08X %s %s", + ins.getAddress().getOffset(), + ins.getMnemonicString(), ins.toString())); + n++; + if (ins.getAddress().compareTo(addr) > 0 + && n > 100) { + break; + } + } + pw.close(); + } + catch (Exception e) { + println("[DumpAIS] " + t + " disasm dump: " + e); + } + if (fn != null) { + try { + DecompileResults res = iface.decompileFunction( + fn, 120, getMonitor()); + String c = (res != null + && res.getDecompiledFunction() != null) + ? res.getDecompiledFunction().getC() + : "DECOMPILE_NULL"; + PrintWriter pw = new PrintWriter(new FileWriter( + OUT + "/dec_" + t + ".txt")); + pw.print(c); + pw.close(); + println("[DumpAIS] " + t + " decompiled " + + (c == null ? 0 : c.length()) + " chars"); + } + catch (Exception e) { + println("[DumpAIS] " + t + " decompile: " + e); + PrintWriter pw = new PrintWriter(new FileWriter( + OUT + "/dec_" + t + ".txt")); + pw.print("DECOMPILE_ERROR: " + e); + pw.close(); + } + } + } + iface.dispose(); + println("[DumpAIS] done -> " + OUT); + } +} diff --git a/ghidra/headless/MapAIS.java b/ghidra/headless/MapAIS.java new file mode 100644 index 0000000..a0adf40 --- /dev/null +++ b/ghidra/headless/MapAIS.java @@ -0,0 +1,67 @@ +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.address.AddressSpace; +import ghidra.program.model.mem.Memory; +import ghidra.program.model.mem.MemoryBlock; +import ghidra.program.model.symbol.SourceType; + +import java.io.File; + +/** + * MapAIS.java — Ghidra headless preScript. Creates one memory block per + * AIS section of ic300_1.bin at its DDR load address, marks the AIS + * entry point. Paths are hardcoded (see tools/ais_unpack.py output). + */ +public class MapAIS extends GhidraScript { + + static final String UNPACK = "/tmp/ais-unpack"; + // {name, addrHex, len, file} + static final String[][] SEGS = { + {"ais0", "C7074630", "464", "ais_sect0_addrC7074630.bin"}, + {"ais1", "C7074800", "211712", "ais_sect1_addrC7074800.bin"}, + {"ais2", "C70A8300", "10444", "ais_sect2_addrC70A8300.bin"}, + {"ais3", "C70AABD0", "12", "ais_sect3_addrC70AABD0.bin"}, + {"ais4", "C70AABE0", "12", "ais_sect4_addrC70AABE0.bin"}, + {"ais5", "C70AABF0", "3396", "ais_sect5_addrC70AABF0.bin"}, + {"ais6", "C70AF000", "512", "ais_sect6_addrC70AF000.bin"}, + {"ais7", "C70AF4E8", "260", "ais_sect7_addrC70AF4E8.bin"}, + {"ais8", "C70AF5EC", "112", "ais_sect8_addrC70AF5EC.bin"}, + {"ais9", "C70AF660", "3896", "ais_sect9_addrC70AF660.bin"}, + }; + static final String ENTRY = "C70A28A0"; + + @Override + public void run() throws Exception { + AddressSpace space = currentProgram.getAddressFactory() + .getDefaultAddressSpace(); + Memory mem = currentProgram.getMemory(); + for (String[] s : SEGS) { + Address addr = space.getAddress(s[1]); + if (mem.getBlock(addr) != null) { + println("[MapAIS] " + s[0] + " already mapped, skip"); + continue; + } + File f = new File(UNPACK + "/" + s[3]); + long len = Long.parseLong(s[2]); + try { + mem.createInitializedBlock(s[0], addr, f, len, + "ais_unpack", "", false); + MemoryBlock blk = mem.getBlock(addr); + blk.setRead(true); + blk.setWrite(false); + blk.setExecute(true); + println("[MapAIS] mapped " + s[0] + " " + s[1] + + " len=" + len); + } + catch (Exception e) { + println("[MapAIS] FAILED " + s[0] + ": " + e); + } + } + Address entry = space.getAddress(ENTRY); + currentProgram.getSymbolTable().addExternalEntryPoint(entry); + createLabel(entry, "ais_entry", true, SourceType.IMPORTED); + disassemble(entry); + createFunction(entry, "ais_entry"); + println("[MapAIS] entry " + ENTRY + " marked"); + } +} diff --git a/ghidra/headless/README.md b/ghidra/headless/README.md new file mode 100644 index 0000000..acd2363 --- /dev/null +++ b/ghidra/headless/README.md @@ -0,0 +1,23 @@ +# Headless Ghidra drivers (C6000 AIS analysis) + +These drove the checksum extraction in `RE-PROCESS.md`. They need a local lab +that is **not** in this repo (too big, machine-specific): + +- Temurin JDK 21, Ghidra 12.1.3, [geepot/ghidra-c6000](https://github.com/geepot/ghidra-c6000) + release built for that exact Ghidra version (`C6000:LE:32:default`). +- `pip install pyghidra` (project venv), `GHIDRA_INSTALL_DIR` + `JAVA_HOME` set. + +Files: + +| File | Role | +|---|---| +| `MapAIS.java` / `DumpAIS.java` | Reference only — Ghidra **12 headless cannot run `.java` + outside an OSGi bundle** here (not even the extension's own scripts); kept for GUI use | +| `pyais.py` | The working driver: map AIS sections at DDR bases → analyze → decompile + targets (`C70A0A60`, `C708E4E4`, `C7086400`, entry, CRC-16) into `out/` | +| `pyais4.py` | Dense disassembly seeding + full-range listing dumps | +| `pyais5.py` | Batch create-function + decompile for a target list | + +Prepare segments with `../tools/ais_unpack.py ic300.bin /tmp/ais-unpack` +(IC300 dump stays local — never commit it), then adapt the hardcoded paths at +the top of `pyais.py` to your machine. diff --git a/ghidra/headless/pyais.py b/ghidra/headless/pyais.py new file mode 100644 index 0000000..916af18 --- /dev/null +++ b/ghidra/headless/pyais.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""PyGhidra driver: map AIS sections, analyze, decompile checksum targets. + +Runs with the project venv (has pyghidra): ./venv/bin/python /tmp/opencode/ghidra-lab/pyais.py +Env: GHIDRA_INSTALL_DIR + JAVA_HOME must be set (see launch command). +Writes /tmp/opencode/ghidra-lab/out/{dis,dec}_.txt +Stdlib + pyghidra + jpype only. +""" +import os +import struct +import sys + +UNPACK = "/tmp/ais-unpack" +OUT = "/tmp/opencode/ghidra-lab/out" +SECT_IMAGE = "/tmp/ais-unpack/ais_sect1_addrC7074800.bin" +PROJ_LOC = "/tmp/opencode/ghidra-lab/pyproj" +PROJ_NAME = "LofiPy" +LANG = "C6000:LE:32:default" +ENTRY = "C70A28A0" +TARGETS = ["C70A0A60", "C708E4E4", "C7086400", "C70A28A0", "C707841C"] + + +def parse_ais(path): + d = open(path, "rb").read() + assert d[:4] == b"TIPA", "bad AIS magic" + segs, i, n = [], 4, len(d) + entry = None + while i + 12 <= n: + op = d[i:i + 4] + if op == bytes([0x01, 0x59, 0x53, 0x58]): + addr, sz = struct.unpack(" " + OUT, flush=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ghidra/headless/pyais4.py b/ghidra/headless/pyais4.py new file mode 100644 index 0000000..b7fdd10 --- /dev/null +++ b/ghidra/headless/pyais4.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""PyGhidra pass 4: dense disassembly seeding + full-range listing dumps. + +Reuses /tmp/opencode/ghidra-lab/pyproj/LofiPy. +Writes /tmp/opencode/ghidra-lab/out/gfull_.txt +""" +import os + +OUT = "/tmp/opencode/ghidra-lab/out" +PROJ_LOC = "/tmp/opencode/ghidra-lab/pyproj/LofiPy" +PROJ_NAME = "LofiPy" +PROG = "/ais_sect1_addrC7074800.bin" +RANGES = { + "eloop": ("C708E400", 0x400), + "callers2": ("C709E700", 0x500), + "orch": ("C7086300", 0x900), +} + + +def main(): + import pyghidra + + pyghidra.start() + with pyghidra.open_project(PROJ_LOC, PROJ_NAME) as project: + with pyghidra.program_context(project, PROG) as program: + from ghidra.program.flatapi import FlatProgramAPI + + flat = FlatProgramAPI(program) + with pyghidra.transaction(program, "seed"): + for name, (t, size) in RANGES.items(): + base = int(t, 16) + n = 0 + a = base + while a < base + size: + try: + flat.disassemble(flat.toAddr("0x%08X" % a)) + n += 1 + except Exception: + pass + a += 8 + print("%s seeded %d" % (name, n), flush=True) + for name, (t, size) in RANGES.items(): + try: + base = int(t, 16) + it = program.getListing().getInstructions( + flat.toAddr("0x%08X" % base), True) + lines = [] + while it.hasNext(): + ins = it.next() + off = ins.getAddress().getOffset() + if off >= base + size: + break + lines.append("%08X %s %s" % ( + off, ins.getMnemonicString(), ins.toString())) + if len(lines) > 4000: + break + open(os.path.join(OUT, "gfull_" + name + ".txt"), + "w").write("\n".join(lines) + "\n") + print("%s: %d insns" % (name, len(lines)), flush=True) + except Exception as e: + print(name + " FAILED: " + str(e)[:200], flush=True) + print("GFULL_DONE", flush=True) + + +if __name__ == "__main__": + main() diff --git a/ghidra/headless/pyais5.py b/ghidra/headless/pyais5.py new file mode 100644 index 0000000..4cf4dc9 --- /dev/null +++ b/ghidra/headless/pyais5.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""PyGhidra pass 5: create + decompile check/orchestrator functions. + +Reuses /tmp/opencode/ghidra-lab/pyproj/LofiPy. +Writes /tmp/opencode/ghidra-lab/out/fn_.c.txt +""" +import os + +OUT = "/tmp/opencode/ghidra-lab/out" +PROJ_LOC = "/tmp/opencode/ghidra-lab/pyproj/LofiPy" +PROJ_NAME = "LofiPy" +PROG = "/ais_sect1_addrC7074800.bin" +FNS = ["C709E7C0", "C7086788", "C708E4A4", "C708E790", "C708E860", + "C709E888", "C708E468", "C709E990"] + + +def main(): + import pyghidra + + pyghidra.start() + with pyghidra.open_project(PROJ_LOC, PROJ_NAME) as project: + with pyghidra.program_context(project, PROG) as program: + from ghidra.program.flatapi import FlatProgramAPI + from ghidra.app.decompiler.flatapi import FlatDecompilerAPI + + flat = FlatProgramAPI(program) + with pyghidra.transaction(program, "mkfn2"): + for t in FNS: + a = flat.toAddr("0x" + t) + try: + flat.disassemble(a) + except Exception as e: + print(t + " dis: " + str(e)[:100], flush=True) + try: + if flat.getFunctionAt(a) is None: + flat.createFunction(a, "fn_" + t) + print(t + " fn created", flush=True) + except Exception as e: + print(t + " mkfn: " + str(e)[:150], flush=True) + dapi = FlatDecompilerAPI(flat) + try: + for t in FNS: + try: + fn = flat.getFunctionAt(flat.toAddr("0x" + t)) + if fn is None: + print(t + " no fn", flush=True) + continue + c = dapi.decompile(fn) + open(os.path.join(OUT, "fn_" + t + ".c.txt"), + "w").write(c if c else "DECOMPILE_NULL") + print(t + " %d chars" % (len(c) if c else 0), + flush=True) + except Exception as e: + print(t + " dec: " + str(e)[:200], flush=True) + finally: + dapi.dispose() + print("FN_DONE", flush=True) + + +if __name__ == "__main__": + main() diff --git a/rev-diff.md b/rev-diff.md new file mode 100644 index 0000000..80be74b --- /dev/null +++ b/rev-diff.md @@ -0,0 +1,130 @@ +# Lofi-12 XT firmware — cross-version diff (v1.1.156 → v1.2.179 → v1.5.205) + +Per-version structural notes: `docs/firmware/notes-v{1.1.156,1.2.179,1.5.205}.md` +(Sep-26 snapshots; update-entry + checksum notes there are superseded — see +`docs/firmware-update.md` and `tools/README.md`). +This file only documents what *changed* between builds. + +Method: `cmtd/mmtd/sect` headers parsed per build (all verified to tile EOF +exactly); string sets compared as `sort -u` of `strings` output with length ≥ 6. +(Raw set lists were scratch files, not shipped.) +Code sects are rebased between builds (different DDR load addresses), so no +byte-level code diff was attempted — deltas below are sizes + string/symbol +evidence. + +## TL;DR + +- v1.1.156 → v1.2.179: small delta (+10,592 B file, +10,080 B code). Only two + user-visible string additions: `TRACK MUTE`, `AppPad::handleKeyPadOnSliceMode`. +- v1.2.179 → v1.5.205: big delta (+100,852 B file, +90,912 B code). New subsystems + with fresh class families: **audio export**, **MIDI Note Map**, new master FX + (`MIsolator`, `MRackComp`, `SlipRoll`, `HoldDelay`), sample tag search, + precount/sequencer-transport rework, `REVERSE` sample mode, step-LED updates. +- Callback ABI shift in v1.5: many `std::function` symbols change `Fv` (void) → + `Fvi`/`Fvii`/`Fviii` signatures; `FileUtil` gains `removeResourceFork` + `Rb` + params; old `RenderBufferIhLi128ELi128E` display path symbols disappear. + +## Image headers + +| Field | v1.1.156 | v1.2.179 | v1.5.205 | +|---|---|---|---| +| filesize | 1,595,605 (`0x1858D5`) | 1,606,197 (`0x188235`) | 1,707,049 (`0x1A0C29`) | +| sha256 | `617c3efe…1908ac5` | `30ea9eeb…b616a212` | `a8bffa65…4198026` | +| cmtd cksum | `0xF58AF539` | `0xDFF0D8C2` | `0xB17C0857` | +| mmtd flags | `6e 25 13 20` | `4e b6 e4 04` | `e8 bc f8 4f` | +| version triple | (1, 1, 156) | (1, 2, 179) | (1, 5, 205) | +| entry point | `0xC26C4820` | `0xC26CA4C0` | `0xC2CD1AA0` | +| nsect | 7 | **6** (8 B zero slot absent) | 7 (slot back) | + +All three entries disassemble (C64x LE) to the same reset prelude +(`ZERO b0; mvc b0,ier; mvc b0,csr; mvk/mvklh b15/b14; and -8,…`) — only the +stack immediates differ. Container version field stays `(1, 3)` in all builds. +Checksum is solved: CRC32-IEEE init 0 over the image with bytes `[8:12]` +replaced by `0xC27C6282` (proven 3/3; `tools/` computes it). `mmtd` flags +semantics unknown, covered as-is, no handling needed. + +## Section map evolution + +| Role | v1.1.156 (addr / len) | v1.2.179 (addr / len) | v1.5.205 (addr / len) | +|---|---|---|---| +| asset/blob | C27753B8 / 85,912 | C277B320 / 83,496 | C2DA7600 / 86,168 | +| float table | C2774C6C / 656 | C277AB18 / 660 | C2DA6DD4 / 688 | +| record table | C2775000 / 512 | C277B000 / 512 | C2DA7400 / 512 | +| **code (.text)** | C2589800 / 1,389,120 | C258D800 / 1,399,200 | C2B80C00 / 1,490,112 | +| 8 B zero slot | C2589508 / 8 | — absent — | C2B809E8 / 8 | +| **strings (.rodata)** | C2755488 / 115,561 | C275A7A0 / 117,825 | C2D84DE0 / 125,081 | +| float ramp tail | C27737F8 / 3,668 | C27793E8 / 4,348 | C2DA5680 / 4,312 | + +Notes: + +- Whole image rebased upward each release (code base `C2589800 → C258D800 → C2B80C00`); + relative layout (code → data → assets) is unchanged. +- Code delta v1.1→v1.2 is +10,080 B; v1.2→v1.5 is +90,912 B. +- Strings-sect cross-refs grow monotonically: code-ptrs 5,854 → 5,909 → 6,362; + self-ptrs 2,932 → 2,967 → 3,141. +- Big-sect entropy constant (6.890 / 6.887 / ~6.89, ~12.6% zeros) — same + code+data mix, no new packing/encryption introduced. +- The 8 B zero sect comes and goes (present, absent, present) — looks like + alignment/patch-slot churn in Sonicware's image generator, not a payload. + +## v1.1.156 → v1.2.179: +369 / −318 unique strings (≥6) + +Essentially a feature-point release; only two user-visible additions: + +- `TRACK MUTE` (+ `PTN MUTE` behavior per changelog) +- `AppPad::handleKeyPadOnSliceMode` (+ lambda variant) — slice-mode pad handling + (matches v1.2 changelog: EVEN slice mode, PAD hold/ROLL, per-track swing, new + LSF/HSF filters — the rest is parameter/data churn, no new class families) + +## v1.2.179 → v1.5.205: +551 / −427 unique strings (≥6) + +### Added — new subsystems (each a whole class family, not one-offs) + +- Audio export: `18AppAudioExportMenu`, `21AppAudioExportMixdown`, + `25AppAudioExportMenuPattern`, `31AppAudioExportIndividualPattern`, + `22AppSongAudioExportMenu`, `25AppSongAudioExportMixdown`, + `28AppSongAudioExportIndividual` (+ `…::execute` lambdas), + `20DialogAudioExporting`, UI strings `AUDIO EXPORT`, `PATTERN/SONG MIXDOWN`, + `MIXDOWN FILE NAME:`, `INDIVIDUAL TRACKS`, `CANNOT EXPORT`, `TO EXPORT.` +- MIDI Note Map: `14AppMIDINoteMap`, `MIDI NOTE MAP`, `NOTE MAP` +- New master FX (match v1.5 changelog): `SoundEffect::MIsolator`, + `MRackComp`, `SlipRoll`, `HoldDelay` +- Sequencer transport rework: `…AppSequencerTransport…::startPrecount` (new + `EibiEUliE`/`EibiEUlvE` overloads; old `EbiEUliE`/`EbiEUlvE` removed) +- `REVERSE` (sample reverse mode), `18AppAudioFileSelect::playPreview`, + `18AppSampleTagSearch::action`, `22StepEditPageController::updateAppLED`, + `MixDown~` + +### Added — API/ABI evolution + +- `FileUtil::removeResourceFork` (4 refs, previously 0) + `Rb` (bool) params on + `convFsRecursive`/`searchFileRecursive` etc. — matches the v1.5 "1,024 files per + folder / FILE DATABASE" rework. Old `(…S1_jjPjS2_…)` / `(…_PA256_c…Fv…)` + overloads removed (6 `convFsRecursive` old-sig symbols gone). +- Broad `Fv` (void-callback) → `Fvi`/`Fvii`/`Fviii` signature migration across + `TrackAndModuleController::checkStepLock*`, `MainDelegate::loadProjectData…`, + graphics `RenderBuffer` functors, etc. + +### Removed (meaningful, 35 strings ≥12 chars; rest is relocated code-sect noise) + +- Old `FileUtil` overloads (see above), old `startPrecount` overloads, + `TrackAndModuleController::checkStepLock{ForUpdateParams,ForSendingMidiCc}` + old shapes, `MainDelegate::loadProjectDataWithProgressBar` old shape. +- Display pipeline: all `RenderBufferIhLi128ELi128ELi1327E` symbols gone — + matches the "enhanced GUI / visual feedback" rework. +- `CONVERTABLE FILES:` and `?REPITCH TO TEMPO` strings gone. + +### Continuity (present in all three) + +Crash dumper (`Legacy NMI Exception`, `A0–A31/B0–B31`, `NTSR/ITSR/…`), +`N3HAL*Driver` family, `14AppSongBrowser`, `Lofi-12XT` branding, +`TRACK MUTE` (added in v1.2, retained in v1.5). +`AppPad::handleKeyPadOnSliceMode` (added in v1.2) is *superseded* in v1.5 +(symbol absent — slice engine reworked for 16 slices / multi-slice edit). + +## Open questions (updated 2026-09-30) + +- ~~Checksum unknown~~ — solved (see above + `tools/lofi_image.compute_checksum`). +- C64x byte-level code diff: now feasible — Ghidra 12.1.3 + ghidra-c6000 lab is + set up (`ghidra/headless/`, PyGhidra path) and `analysis/gen_funcmap.py` maps + vtable-anchored functions; rebase-aware function-hash diffing still to be run. diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..d18fe70 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,53 @@ +# Lofi-12 XT custom-firmware tools + +Stdlib-only Python (`tools/`). Verified against v1.1.156 / v1.2.179 / v1.5.205. + +| Script | Purpose | +|---|---| +| `lofi_image.py` | Shared parser/packer library (import, not CLI) | +| `lofi_unpack.py` | `image.bin outdir/` — verify chain, dump `headers.json` + `sectN_addr*.bin` | +| `lofi_pack.py` | `indir/ out.bin [--checksum HEX]` — rebuild exact-fit image | +| `lofi_patch_string.py` | Same-length UI string swap (Level-0 mod, addresses stable) | +| `lofi_xref.py` | Count code/self pointers, list slack zero-gaps, locate string + refs | +| `lofi_checksum.py` | Quick check: CRC/adler/fletcher/sum/xor over obvious spans | +| `lofi_checksum_crack.py` | Long-running cracker: Phase A fast hashes + xor-mask detect, Phase B generic CRC param search (1,008 combos, multiprocessed), Phase C seeded CRC32-IEEE brute force 0..2²⁴ (hours) | + +## Safe first loop (do not flash until checksum is cracked) + +```bash +python3 tools/lofi_unpack.py "Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin" /tmp/opencode/fw/unpack-1.5.205 +python3 tools/lofi_pack.py /tmp/opencode/fw/unpack-1.5.205 /tmp/opencode/fw/rebuilt.bin +cmp "Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin" /tmp/opencode/fw/rebuilt.bin # must be identical +python3 tools/lofi_patch_string.py "Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin" /tmp/opencode/fw/patched.bin Threshold ThresholX +python3 tools/lofi_xref.py "Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin" --find "AUDIO EXPORT" +python3 tools/lofi_checksum.py +``` + +## Cracking the checksum (the blocker) + +```bash +# smoke tests (seconds): +python3 tools/lofi_checksum_crack.py --quick-only +python3 tools/lofi_checksum_crack.py --jobs 2 --limit 40 --out /tmp/opencode/ck-smoke.txt + +# full long run (Phase B ~minutes, Phase C ~hours, all cores): +python3 tools/lofi_checksum_crack.py --jobs $(nproc) --out /tmp/opencode/ck-results.txt + +# Phase D batch — CRC-16 family, ones-complement, chained per-sect, FNV-1/DJB2/Murmur (~1 min): +python3 tools/lofi_checksum_phaseD.py --jobs $(nproc) --out /tmp/opencode/ck-phaseD.txt +``` + +Results (exact or constant-xor-mask hits) append to the `--out` file; +progress checkpoints go to `--out.progress`. Any hit must match **all 3** +builds to be reported. Re-run with `--seed-max 4294967296` for the full +2³² seed space only if 2²⁴ finds nothing. + +## Blockers / rules + +- `cmtd+0x08` checksum: **solved** — `lofi_image.compute_checksum` + (CRC32-IEEE, init 0, over the image with bytes `[8:12]` replaced by + `0xC27C6282`; proven 3/3 against stock images). `lofi_pack` and + `lofi_patch_string` apply it automatically. +- Keep a known-good stock `.bin` on SD for revert; never touch SPI flash / USB MCU. +- Distribute mods as patches against user-supplied stock `.bin`, not full images. +- See `tweakability-report.md` (levels 0–4) and `rev-diff.md` for what is feasible. diff --git a/tools/lofi_checksum.py b/tools/lofi_checksum.py new file mode 100755 index 0000000..3db1263 --- /dev/null +++ b/tools/lofi_checksum.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Brute-force checksum hypotheses for cmtd+0x08 against all known images. + +Tests CRC32/zlib/adler/fletcher/sum/xor variants over multiple spans. +A hypothesis must match ALL builds to be reported as candidate. +Stdlib only. +""" +import binascii, os, struct, sys, zlib + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +IMAGES = [ + 'Lofi-12XT_v1.1.156/Lofi-12XT_v1.1.156/Lofi-12 XT.bin', + 'Lofi-12XT_v1.2.179/Lofi-12XT_v1.2.179/Lofi-12 XT.bin', + 'Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin', +] + +def fletcher16(data): + s1 = s2 = 0 + for b in data: + s1 = (s1 + b) % 255 + s2 = (s2 + s1) % 255 + return (s2 << 8) | s1 + +def fletcher32(data): + s1 = s2 = 0 + for i in range(0, len(data), 2): + w = data[i] | (data[i+1] << 8 if i+1 < len(data) else 0) + s1 = (s1 + w) % 0xFFFF + s2 = (s2 + s1) % 0xFFFF + return (s2 << 16) | s1 + +def sum32(data): + return sum(data) & 0xFFFFFFFF + +def xor32(data): + r = 0 + for i in range(0, len(data) - 3, 4): + (w,) = struct.unpack('I', struct.pack('> 1) ^ rpoly if c & 1 else c >> 1 + t.append(c & 0xFFFFFFFF) + else: + for i in range(256): + c = i << 24 + for _ in range(8): + c = ((c << 1) ^ poly) & 0xFFFFFFFF if c & 0x80000000 else (c << 1) & 0xFFFFFFFF + t.append(c) + _crc_tables[key] = t + return t + +def crc_generic(buf: bytes, poly, init, refin, xorout): + tab = crc_table(poly, refin) + crc = init + if refin: + for b in buf: + crc = tab[(crc ^ b) & 0xFF] ^ (crc >> 8) + else: + for b in buf: + crc = tab[((crc >> 24) ^ b) & 0xFF] ^ ((crc << 8) & 0xFFFFFFFF) + return (crc ^ xorout) & 0xFFFFFFFF + +def fnv1a32(buf: bytes): + h = 0x811C9DC5 + for b in buf: + h = ((h ^ b) * 0x01000193) & 0xFFFFFFFF + return h + +def wordsum_le(buf: bytes): + s = 0 + for i in range(0, len(buf) - 3, 4): + (w,) = struct.unpack('I', struct.pack('bufs, ck); pickled once per worker + +def _worker(param): + poly, init, xorout, refin, span, fmode = param + idx = _FIELDMODE_IDX[fmode] + try: + r = [] + for ver, sbufs, ck in _G_BLOBS: + v = crc_generic(sbufs[span][idx], poly, init, refin, xorout) + r.append((ver, ck, v)) + if r[0][2] == r[0][1] and r[1][2] == r[1][1] and r[2][2] == r[2][1]: + return ('EXACT', param, 0) + m0, m1, m2 = r[0][1] ^ r[0][2], r[1][1] ^ r[1][2], r[2][1] ^ r[2][2] + if m0 == m1 == m2: + return ('MASK', param, m0) + except Exception: + pass + return None + +def phase_b(blobs, jobs, out, limit=None, resume_from=0): + params = list(all_params()) + if limit: + params = params[:limit] + total = len(params) + print(f'=== Phase B: generic CRC search: {total} combos, jobs={jobs} ===', flush=True) + # pack span buffers once (pickle to workers a single time) + packed = [(ver, span_bufs(d), ck) for ver, d, ck in blobs] + done = resume_from + t0 = time.time() + found = [] + with mp.Pool(jobs, initializer=_init_worker, initargs=(packed,)) as pool: + CH = 8 + for i, res in enumerate(pool.imap_unordered(_worker, params, chunksize=CH), start=1): + if i <= done: + continue + if res is not None: + kind, param, mask = res + poly, init, xorout, refin, span, fmode = param + line = (f'{kind} poly={poly:08X} init={init:08X} xorout={xorout:08X} ' + f'refin={int(refin)} span={span} field={fmode} mask={mask:08X}') + print(f' *** {line}', flush=True) + found.append(line) + with open(out, 'a') as fh: + fh.write(line + '\n') + if i % 50 == 0 or i == total: + el = time.time() - t0 + rate = i / max(el, 1e-6) + print(f' [{i}/{total}] {rate:.1f} combos/s elapsed={el:.0f}s', flush=True) + with open(out + '.progress', 'w') as fh: + fh.write(json.dumps({'done': i, 'total': total, + 'elapsed': el, 'found': found}) + '\n') + el = time.time() - t0 + print(f'Phase B done: {total} combos in {el:.0f}s, {len(found)} candidates.', flush=True) + return found + +_G_SEED_BUFS = None +_G_SEED_CKS = None + +def _init_seed_worker(bufs, cks): + global _G_SEED_BUFS, _G_SEED_CKS + _G_SEED_BUFS = bufs + _G_SEED_CKS = cks + +def _seed_scan(task): + lo, hi = task + b1, b2, b3 = _G_SEED_BUFS + s1, s2, s3 = _G_SEED_CKS + hits = [] + for seed in range(lo, hi): + if (binascii.crc32(b1, seed) & 0xFFFFFFFF) == s1: + if ((binascii.crc32(b2, seed) & 0xFFFFFFFF) == s2 and + (binascii.crc32(b3, seed) & 0xFFFFFFFF) == s3): + hits.append(seed) + return (lo, hi, hits) + +def _seed_worker(args): + lo, hi, span, fmode = args + idx = _FIELDMODE_IDX[fmode] + b1, b2, b3 = _G_SEED[0][span][idx], _G_SEED[1][span][idx], _G_SEED[2][span][idx] + s1, s2, s3 = _G_SEED[3] + hits = [] + for seed in range(lo, hi): + c1 = binascii.crc32(b1, seed) & 0xFFFFFFFF + if c1 != s1: + # xor-mask path: derive mask from image 1, confirm on 2+3 + # (costs 2 more CRCs only on the rare near-hit; here c1!=s1 always + # so check mask constancy cheaply only every step? skip: exact-only + # in seed phase for speed; mask search lives in Phase B) + continue + c2 = binascii.crc32(b2, seed) & 0xFFFFFFFF + c3 = binascii.crc32(b3, seed) & 0xFFFFFFFF + if c2 == s2 and c3 == s3: + hits.append(f'EXACT seed={seed:08X} span={span} field={fmode}') + return hits + +def phase_c(blobs, jobs, out, seed_max=1 << 24, seed_span='full', seed_chunk=4096): + print(f'=== Phase C: seeded CRC32-IEEE brute force: seeds 0..{seed_max} ' + f'span={seed_span} jobs={jobs} ===', flush=True) + print(' (C-speed crc32; exact-match only; this is the hours-long phase)', flush=True) + # NOTE: 'zeroed' = cmtd+0x08 treated as 00s during hashing (standard scheme); + # 'asis' re-check is cheap relative to seed space, so do zeroed first. + t0 = time.time() + found = [] + for fmode in ('zeroed', 'asis'): + idx = _FIELDMODE_IDX[fmode] + bufs = [span_bufs(d)[seed_span][idx] for _, d, _ in blobs] + cks = [ck for _, _, ck in blobs] + found += _phase_c_loop(bufs, cks, seed_span, fmode, seed_max, seed_chunk, jobs, out, t0) + return found + +def _phase_c_loop(bufs, cks, span, fmode, seed_max, chunk, jobs, out, t0): + b1, b2, b3 = bufs + s1, s2, s3 = cks + found = [] + # shard seed space across workers: each task scans [lo,hi) + tasks = [(lo, min(lo + chunk, seed_max)) for lo in range(0, seed_max, chunk)] + total = len(tasks) + done = 0 + with mp.Pool(jobs, initializer=_init_seed_worker, initargs=(bufs, cks)) as pool: + for lo, hi, hits in pool.imap_unordered(_seed_scan, tasks, chunksize=4): + done += 1 + for seed in hits: + line = f'EXACT seed={seed:08X} span={span} field={fmode}' + print(f' *** {line}', flush=True) + found.append(line) + with open(out, 'a') as fh: + fh.write(line + '\n') + if done % max(1, total // 20) == 0 or done == total: + el = time.time() - t0 + print(f' Phase C [{done}/{total} chunks] seeds~{done*chunk}/{seed_max} ' + f'elapsed={el:.0f}s', flush=True) + el = time.time() - t0 + print(f'Phase C done: {seed_max} seeds in {el:.0f}s, {len(found)} hits.', flush=True) + return found + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--jobs', type=int, default=max(1, (os.cpu_count() or 4) - 1)) + ap.add_argument('--out', default='/tmp/opencode/ck-results.txt') + ap.add_argument('--quick-only', action='store_true') + ap.add_argument('--skip-phase-a', action='store_true') + ap.add_argument('--skip-phase-b', action='store_true') + ap.add_argument('--skip-phase-c', action='store_true') + ap.add_argument('--limit', type=int, default=None, help='test only first N combos (smoke test)') + ap.add_argument('--resume-from', type=int, default=0) + ap.add_argument('--seed-max', type=int, default=1 << 24, + help='seed brute-force range 0..SEED_MAX (default 2^24 ≈ hours)') + ap.add_argument('--seed-span', default='full', choices=SPANS) + ap.add_argument('--seed-chunk', type=int, default=4096) + a = ap.parse_args() + blobs = load_images() + for ver, d, ck in blobs: + print(f'{ver}: size={len(d)} stored_ck={ck:08X}', flush=True) + if not a.skip_phase_a: + phase_a(blobs) + if a.quick_only: + print('quick-only: stopping before Phase B/C.', flush=True) + return + open(a.out, 'a').write(f'# run {time.ctime()} jobs={a.jobs} limit={a.limit} seed_max={a.seed_max}\n') + if not a.skip_phase_b: + phase_b(blobs, a.jobs, a.out, limit=a.limit, resume_from=a.resume_from) + if not a.skip_phase_c and not a.limit: + phase_c(blobs, a.jobs, a.out, seed_max=a.seed_max, + seed_span=a.seed_span, seed_chunk=a.seed_chunk) + +if __name__ == '__main__': + main() diff --git a/tools/lofi_checksum_phaseD.py b/tools/lofi_checksum_phaseD.py new file mode 100755 index 0000000..803ab34 --- /dev/null +++ b/tools/lofi_checksum_phaseD.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Phase D checksum cracker: 16-bit/ones-complement/chained/DJB2/Murmur batch. + +Covers what Phases A-C did not: every candidate must match ALL 3 builds. +Stdlib only, read-only on stock .bin files. + + Quick smoke: python3 tools/lofi_checksum_phaseD.py --limit 30 + Full run: python3 tools/lofi_checksum_phaseD.py --jobs $(nproc) --out /tmp/opencode/ck-phaseD.txt + +Families: + CRC16: ARC/Modbus/CCITT-FALSE/XMODEM/Kermit/DNP/UMTS/BINHEX + halved-file pairs + Ones-complement: 16-bit word sums (LE/BE, folded, complemented) + Word sums: 16-bit LE/BE, BSD rotate, SysV folded + Chained per-sect: crc32-of-crcs, sum-of-crcs, xor-of-crcs + Odd hashes: FNV-1 (not 1a), DJB2, Murmur3-x86-32 +""" +import argparse, binascii, multiprocessing as mp +import os, struct, sys, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from lofi_checksum_crack import load_images, span_bufs, SPANS, _FIELDMODE_IDX + +CRC16_PARAMS = [ # (name, poly_trunc, init, refin, xorout, check_of_"123456789") + # NOTE: refin polys are given in bit-reversed (truncated) form, used as-is. + ('ARC', 0xA001, 0x0000, True, 0x0000, 0xBB3D), + ('MODBUS', 0xA001, 0xFFFF, True, 0x0000, 0x4B37), + ('USB', 0xA001, 0xFFFF, True, 0xFFFF, 0xB4C8), + ('CCITT-F', 0x1021, 0xFFFF, False, 0x0000, 0x29B1), + ('XMODEM', 0x1021, 0x0000, False, 0x0000, 0x31C3), + ('KERMIT', 0x8408, 0x0000, True, 0x0000, 0x2189), + ('X25', 0x8408, 0xFFFF, True, 0xFFFF, 0x906E), + ('DNP', 0xA6BC, 0x0000, True, 0xFFFF, 0xEA82), + ('GENIBUS', 0x1021, 0xFFFF, False, 0xFFFF, 0xD64E), + ('GSM', 0x1021, 0x0000, False, 0xFFFF, 0xCE3C), +] + +_tables16 = {} +def _tab16(poly_trunc, refin): + key = (poly_trunc, refin) + t = _tables16.get(key) + if t is not None: + return t + if refin: + # poly_trunc is already bit-reversed (e.g. 0xA001); use as-is. + p = poly_trunc + t = [] + for i in range(256): + c = i + for _ in range(8): + c = (c >> 1) ^ p if c & 1 else c >> 1 + t.append(c & 0xFFFF) + else: + p = poly_trunc + t = [] + for i in range(256): + c = i << 8 + for _ in range(8): + c = ((c << 1) ^ p) & 0xFFFF if c & 0x8000 else (c << 1) & 0xFFFF + t.append(c) + _tables16[key] = t + return t + +def crc16(buf, poly, init, refin, xorout): + tab = _tab16(poly, refin) + crc = init + if refin: + for b in buf: + crc = tab[(crc ^ b) & 0xFF] ^ (crc >> 8) + else: + for b in buf: + crc = tab[((crc >> 8) ^ b) & 0xFF] ^ ((crc << 8) & 0xFFFF) + return (crc ^ xorout) & 0xFFFF + +import array + +def _even(buf): + return buf if len(buf) % 2 == 0 else buf + b'\x00' + +def _words16(buf, endian): + a = array.array('H', _even(buf)) + if endian == 'big': + a.byteswap() + return a + +def ones_complement16(buf, endian): + mv = _words16(buf, endian) + s = sum(mv) & 0xFFFFFFFFFFFFFFFF + while s >> 16: + s = (s & 0xFFFF) + (s >> 16) + return (~s) & 0xFFFF + +def wordsum16(buf, endian): + return sum(_words16(buf, endian)) & 0xFFFF + +def bsd_sum(buf): + s = 0 + for b in buf: + s = ((s >> 1) | ((s & 1) << 15)) & 0xFFFF + s = (s + b) & 0xFFFF + return s + +def sysv_sum(buf): + s = sum(buf) + s = (s & 0xFFFF) + ((s >> 16) & 0xFFFF) + s = (s & 0xFFFF) + ((s >> 16) & 0xFFFF) + return s & 0xFFFF + +def fnv1_32(buf): + h = 0x811C9DC5 + for b in buf: + h = (h * 0x01000193) & 0xFFFFFFFF + h ^= b + return h + +def djb2(buf): + h = 5381 + for b in buf: + h = ((h * 33) + b) & 0xFFFFFFFF + return h + +def murmur3_x86_32(buf, seed=0): + h = seed + n = len(buf) & ~3 + for i in range(0, n, 4): + k = struct.unpack('> 17)) & 0xFFFFFFFF + k = (k * 0x1B873593) & 0xFFFFFFFF + h ^= k + h = ((h << 13) | (h >> 19)) & 0xFFFFFFFF + h = (h * 5 + 0xE6546B64) & 0xFFFFFFFF + tail = buf[n:] + k = 0 + for i, b in enumerate(tail): + k |= b << (8 * i) + if tail: + k = (k * 0xCC9E2D51) & 0xFFFFFFFF + k = ((k << 15) | (k >> 17)) & 0xFFFFFFFF + k = (k * 0x1B873593) & 0xFFFFFFFF + h ^= k + h ^= len(buf) + h ^= h >> 16 + h = (h * 0x85EBCA6B) & 0xFFFFFFFF + h ^= h >> 13 + h = (h * 0xC2B2AE35) & 0xFFFFFFFF + h ^= h >> 16 + return h + +def match16(stored, v): + """How a 16-bit value could sit in the 32-bit field.""" + lo, hi = stored & 0xFFFF, (stored >> 16) & 0xFFFF + if lo == v == hi: + return 'both16' + if lo == v: + return 'lo16' + if hi == v: + return 'hi16' + if stored == v: + return 'full32eq16' + if stored == ((v << 16) | v): + return 'duplicated16' + return None + +def build_jobs(): + jobs = [] + for name, poly, init, refin, xorout, _check in CRC16_PARAMS: + for span in SPANS: + for fmode in ('asis', 'zeroed', 'ff'): + jobs.append(('crc16', name, (poly, init, refin, xorout), span, fmode)) + for span in SPANS: + for fmode in ('asis', 'zeroed', 'ff'): + for nm in ('ones_le', 'ones_be', 'wsum16_le', 'wsum16_be', + 'bsd', 'sysv', 'fnv1', 'djb2', 'murmur0', 'murmurF'): + jobs.append(('fast32', nm, (), span, fmode)) + for span in SPANS: + for fmode in ('asis', 'zeroed'): + for nm in ('chain_crc_of_crcs', 'chain_sum_of_crcs', 'chain_xor_of_crcs', + 'halved_crc16_pair'): + jobs.append(('chain', nm, (), span, fmode)) + return jobs + +_G = None +def _init(blobs): + global _G + _G = blobs # [(ver, span->bufs[3], ck)] + +FAST32 = { + 'fnv1': fnv1_32, 'djb2': djb2, + 'murmur0': lambda b: murmur3_x86_32(b, 0), + 'murmurF': lambda b: murmur3_x86_32(b, 0xFFFFFFFF), +} + +def _run(job): + kind, name, params, span, fmode = job + idx = _FIELDMODE_IDX[fmode] + bufs = [(span_bufs_alias(d, span, idx), ck) for _, d, ck in _G] + if kind == 'crc16': + poly, init, refin, xorout = params + got = [crc16(b, poly, init, refin, xorout) for b, _ in bufs] + how = [match16(ck, v) for (_, ck), v in zip(bufs, got)] + if all(how): + return f'HIT crc16/{name} span={span} field={fmode} val={got[0]:04X} as={how[0]}' + masks = [(ck ^ v) & 0xFFFF for (_, ck), v in zip(bufs, got)] + if masks[0] == masks[1] == masks[2]: + return (f'MASK16 crc16/{name} span={span} field={fmode} ' + f'mask={masks[0]:04X}') + return None + if kind == 'fast32': + if name == 'ones_le': + got = [ones_complement16(b, 'little') for b, _ in bufs] + how = [match16(ck, v) for (_, ck), v in zip(bufs, got)] + if all(how): + return f'HIT ones-complement-LE span={span} field={fmode} as={how[0]}' + return None + if name == 'ones_be': + got = [ones_complement16(b, 'big') for b, _ in bufs] + how = [match16(ck, v) for (_, ck), v in zip(bufs, got)] + if all(how): + return f'HIT ones-complement-BE span={span} field={fmode} as={how[0]}' + return None + if name == 'wsum16_le': + got = [wordsum16(b, 'little') for b, _ in bufs] + elif name == 'wsum16_be': + got = [wordsum16(b, 'big') for b, _ in bufs] + elif name == 'bsd': + got = [bsd_sum(b) for b, _ in bufs] + elif name == 'sysv': + got = [sysv_sum(b) for b, _ in bufs] + else: + fn = FAST32[name] + got = [fn(b) for b, _ in bufs] + if all(ck == v for (_, ck), v in zip(bufs, got)): + return f'HIT {name} span={span} field={fmode} val={got[0]:08X}' + masks = [(ck ^ v) & 0xFFFFFFFF for (_, ck), v in zip(bufs, got)] + if masks[0] == masks[1] == masks[2]: + return f'MASK32 {name} span={span} field={fmode} mask={masks[0]:08X}' + return None + how = [match16(ck, v) for (_, ck), v in zip(bufs, got)] + if all(how): + return f'HIT {name} span={span} field={fmode} val={got[0]:04X} as={how[0]}' + return None + # chained per-sect schemes + nsects = [struct.unpack(' EOF, exact fit] +Each sect: b'sect' | u32 LE load_addr | u32 LE len | payload[len] + +All addresses are DDR2 (0xC2xxxxxx, TMS320C6748). Code is C674x DSP LE. +Stdlib only. +""" +import struct +import json +import binascii + +CMTD_LEN = 48 +MMTD_LEN = 36 + +# Checksum seed: cmtd+0x08 coverage replaces file bytes [8:12] with this +# constant before CRC32-IEEE (init 0). Proven 3/3 against stock images +# (solved independently per image via GF(2), all give this same value; +# it is also built literally in the bootloader: MVK 0x6282/MVKH 0xC27C). +CKSEED = 0xC27C6282 + + +def compute_checksum(d: bytes) -> int: + """Valid cmtd+0x08 for a complete image: CRC32 of the image with + bytes [8:12] replaced by CKSEED.""" + b = bytearray(d) + b[8:12] = struct.pack(' dict: + assert d[0:4] == b'cmtd', "bad cmtd magic" + assert d[0x30:0x34] == b'mmtd', "bad mmtd magic" + fsize, cksum = struct.unpack(' bytes: + """Rebuild image from meta (cmtd/mmtd dicts) + payload list [(addr, bytes)]. + Recalculates filesize/remaining fields. Preserves entry/flags/fw unless + caller edited meta. Checksum: explicit value, 'keep' preserves the + original from meta, None (default) computes the valid checksum.""" + nsect = len(payloads) + total = CMTD_LEN + MMTD_LEN + sum(12 + len(p) for _, p in payloads) + # checksum covers the whole file, so build with a zero placeholder, + # then finalize: explicit int wins, 'keep' preserves meta, None computes. + ck = 0 + out = bytearray() + out += b'cmtd' + out += struct.pack(' dict: + return { + 'cmtd': info['cmtd'], + 'mmtd': {**info['mmtd'], + 'entry_hex': f"{info['mmtd']['entry']:08X}", + 'flags_hex': f"{info['mmtd']['flags']:08X}"}, + 'sects': [{'index': s['index'], 'off_hex': f"{s['off']:06X}", + 'addr_hex': f"{s['addr']:08X}", 'len': s['len'], + 'end_hex': f"{s['end']:08X}"} for s in info['sects']], + } + + +def find_sect_by_role(info: dict, role: str) -> dict: + """role='code' -> largest sect; role='rodata' -> sect holding b'Threshold' + (fallback: second largest).""" + sects = info['sects'] + if role == 'code': + return max(sects, key=lambda s: s['len']) + if role == 'rodata': + for s in sects: + if b'Threshold' in s['payload']: + return s + rest = sorted(sects, key=lambda s: s['len'], reverse=True) + return rest[1] if len(rest) > 1 else rest[0] + raise ValueError(role) + + +def code_file_off(info: dict, addr: int) -> int | None: + for s in info['sects']: + if s['addr'] <= addr < s['end']: + return s['off'] + 12 + (addr - s['addr']) + return None diff --git a/tools/lofi_pack.py b/tools/lofi_pack.py new file mode 100755 index 0000000..293851f --- /dev/null +++ b/tools/lofi_pack.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Repack directory (headers.json + sect*.bin) -> .bin. Verifies exact-fit chain.""" +import argparse, json, glob, os, struct, sys +sys.path.insert(0, os.path.dirname(__file__)) +from lofi_image import build_image + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('indir') + ap.add_argument('output') + ap.add_argument('--checksum', default=None, + help="'auto' (default: compute valid checksum), hex u32, or 'keep' (headers.json value)") + a = ap.parse_args() + meta = json.load(open(os.path.join(a.indir, 'headers.json'))) + # cmtd raw hex -> rebuild path needs raw_0x0C_0x30; headers.json stores it + files = sorted(glob.glob(os.path.join(a.indir, 'sect*_addr*.bin'))) + assert files, 'no sect files found' + payloads = [] + for f in files: + base = os.path.basename(f) + addr = int(base.split('addr')[1].split('.')[0], 16) + payloads.append((addr, open(f, 'rb').read())) + if a.checksum is None or a.checksum == 'auto': + ck = None + elif a.checksum == 'keep': + ck = 'keep' + else: + ck = int(a.checksum, 16) + out = build_image(meta, payloads, checksum=ck) + open(a.output, 'wb').write(out) + print(f"wrote {a.output} ({len(out)} B) checksum={struct.unpack(' directory with headers.json + sect payloads.""" +import argparse, json, os, struct, sys +sys.path.insert(0, os.path.dirname(__file__)) +from lofi_image import parse_image, headers_to_json + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('image') + ap.add_argument('outdir') + a = ap.parse_args() + d = open(a.image, 'rb').read() + info = parse_image(d) + os.makedirs(a.outdir, exist_ok=True) + open(os.path.join(a.outdir, 'headers.json'), 'w').write( + json.dumps(headers_to_json(info), indent=2)) + for s in info['sects']: + fn = f"sect{s['index']}_addr{s['addr']:08X}.bin" + open(os.path.join(a.outdir, fn), 'wb').write(s['payload']) + m = info['mmtd'] + print(f"fw {tuple(m['fw'])} entry={m['entry']:08X} nsect={m['nsect']} " + f"cksum={info['cmtd']['checksum']:08X} size={len(d)}") + for s in info['sects']: + print(f" sect{s['index']} off={s['off']:06X} addr={s['addr']:08X} " + f"len={s['len']} end={s['end']:08X}") + print(f"wrote {a.outdir}/") + +if __name__ == '__main__': + main() diff --git a/tools/lofi_xref.py b/tools/lofi_xref.py new file mode 100755 index 0000000..a727cff --- /dev/null +++ b/tools/lofi_xref.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""xref + slack scanner: LE32 pointers into code/rodata, NUL strings, zero gaps. + +Usage: lofi_xref.py "Lofi-12 XT.bin" [--find TEXT] [--strings-min 6] +""" +import argparse, os, struct, sys +sys.path.insert(0, os.path.dirname(__file__)) +from lofi_image import parse_image, find_sect_by_role + +def le_words(payload): + for i in range(0, len(payload) - 3, 4): + yield i, struct.unpack('= minlen: + out.append((i, j - i)) + i = j + else: + i += 1 + return out + +def c_strings(payload, minlen=6): + out, i, n = [], 0, len(payload) + while i < n: + if 32 <= payload[i] < 127: + j = i + while j < n and 32 <= payload[j] < 127: + j += 1 + if j - i >= minlen and j < n and payload[j] == 0: + out.append((i, payload[i:j].decode())) + i = max(j, i + 1) + else: + i += 1 + return out + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('image') + ap.add_argument('--find', default=None) + ap.add_argument('--strings-min', type=int, default=6) + ap.add_argument('--slack-min', type=int, default=64) + a = ap.parse_args() + d = open(a.image, 'rb').read() + info = parse_image(d) + code = find_sect_by_role(info, 'code') + ro = find_sect_by_role(info, 'rodata') + print(f"code: sect{code['index']} {code['addr']:08X}..{code['end']:08X} len={code['len']}") + print(f"rodata: sect{ro['index']} {ro['addr']:08X}..{ro['end']:08X} len={ro['len']}") + # pointers in rodata -> code, and rodata -> self + to_code = to_self = 0 + code_hits = [] + for off, w in le_words(ro['payload']): + if code['addr'] <= w < code['end']: + to_code += 1 + code_hits.append((off, w)) + elif ro['addr'] <= w < ro['end']: + to_self += 1 + print(f"rodata xrefs: {to_code} -> code, {to_self} -> self") + gaps = zero_runs(ro['payload'], a.slack_min) + gaps.sort(key=lambda t: -t[1]) + print(f"top zero gaps in rodata (min {a.slack_min}):") + for off, ln in gaps[:10]: + print(f" +{off:#x} (file {ro['off']+12+off:#x}) len={ln}") + if a.find: + needle = a.find.encode() + hits = [] + p = ro['payload'] + s = 0 + while True: + i = p.find(needle, s) + if i < 0: + break + hits.append(i) + s = i + 1 + print(f"'{a.find}': {len(hits)} hit(s) in rodata") + for h in hits: + faddr = ro['addr'] + h + refs = [off for off, w in code_hits if False] # placeholder + # find pointers TO this string: scan rodata words == faddr + # (vtable-adjacent tables) — cheap exact scan + ptrs = [] + for off, w in le_words(ro['payload']): + if w == faddr: + ptrs.append(ro['off'] + 12 + off) + print(f" +{h:#x} file={ro['off']+12+h:#x} load={faddr:08X} " + f"referenced_by_{len(ptrs)}={['%#x' % x for x in ptrs[:8]]}") + else: + strs = c_strings(ro['payload'], a.strings_min) + print(f"NUL strings len>={a.strings_min} in rodata: {len(strs)}") + +if __name__ == '__main__': + main() diff --git a/tools/prove_checksum.py b/tools/prove_checksum.py new file mode 100644 index 0000000..ca77f55 --- /dev/null +++ b/tools/prove_checksum.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Verify cmtd+0x08 checksums: prove_checksum.py a.bin [b.bin ...] + +Exit 0 iff every image's stored checksum equals compute_checksum(image). +See lofi_image.compute_checksum for the algorithm. +""" +import os +import struct +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from lofi_image import compute_checksum, parse_image + + +def main() -> int: + ok = True + for path in sys.argv[1:]: + d = open(path, "rb").read() + info = parse_image(d) # validates container chain + stored = info["cmtd"]["checksum"] + calc = compute_checksum(d) + match = stored == calc + ok &= match + fw = ".".join(map(str, info["mmtd"]["fw"])) + print("%s fw=%s size=%d stored=%08X calc=%08X %s" + % (path, fw, len(d), stored, calc, + "MATCH" if match else "MISMATCH")) + return 0 if ok and len(sys.argv) > 1 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tweakability-report.md b/tweakability-report.md new file mode 100644 index 0000000..b5ea8cf --- /dev/null +++ b/tweakability-report.md @@ -0,0 +1,112 @@ +# Lofi-12 XT — Custom Firmware Tweakability Report + +Based on: `Lofi-12 XT.bin` v1.1.156 (1,595,605 B), v1.2.179 (1,606,197 B), +v1.5.205 (1,707,049 B); structural notes in `*/reversed.md`; cross-version +diff in `rev-diff.md`. All three images parse cleanly with the same tooling. + +Date: 2026-09-26, updated 2026-09-30. Hardware dumped since (IC300/IC301 via +CH341a); checksum solved; Ghidra lab built. Still static-only — no modded image +has been boot-tested on-device yet. + +## TL;DR + +| Question | Answer | +|---|---| +| Feasible to build a modded firmware? | **Yes, plausibly — but only up to a ceiling.** Container is fully parsed, code is identified (TI C674x DSP, unencrypted/unpacked), data sect is rich with symbols. Three versions give us a feature-diff oracle. | +| #1 blocker | **Solved 2026-09-30:** `cmtd+0x08` = CRC32-IEEE init 0 over the image with bytes `[8:12]` replaced by `0xC27C6282` (proven 3/3; `tools/` stamps it automatically). | +| Risk of bricking? | **Low if careful.** The `.bin` is a DDR snapshot loaded by the separate SPI-flash bootloader (MX25L12833F), and the updater lives in that bootloader - a bad image aborts `SYSTEM UPDATE` without killing recovery. Confirmed recovery: hold PAD while powering on with a stock SD. | +| How far can we go? | **Text/UI swaps, asset swaps, constant tuning, small code patches: realistic. New DSP effects, new file formats, USB/stack changes: out of reach without a major RE campaign.** | +| Recommended first mod | Same-length UI string swap on v1.5.205 (`Threshold` -> `ThresholX`), forged image already built and self-verified (`462F735B`) - awaiting the first on-device boot-test. | + +## 1. What we actually have (and why it matters) + +1. **Complete container format.** `[cmtd 48B][mmtd 36B][sect ×6–7 → EOF, exact fit]` verified on all three builds (chain math `next_off = off + 12 + len` tiles EOF exactly). Repacking is therefore a byte-shuffling problem, not a guessing problem. Section roles are stable across releases: asset blob (~85 kB) → float tables → 512 B record table → big code sect (1.39→1.49 MB) → 8 B zero slot (comes and goes) → strings/rodata (115→125 kB) → float ramp tail. +2. **Code CPU identified: TI C674x DSP (C6000 family), little-endian.** All three entry points disassemble under C64x-LE to the same reset prelude (`ZERO b0; mvc b0,ier; mvc b0,csr; …` — interrupt disable + stack align); only stack immediates differ. ARM/Thumb disassembly of the same bytes yields ~nothing. The `.bin` load addresses are all `0xC2xxxxxx` = DDR2 (SoC is TMS320C6748: ARM9 + C674x, 1 Gbit DDR2 at `0xC0000000`). So: DSP does the application; ARM9 is presumably bootloader-only in SPI flash. +3. **No packing, no encryption, no obfuscation.** Big-sect entropy is constant 6.887–6.890 with ~12.6% zeros across all builds; code/data mix with a low-entropy zero-padded tail. Nothing suggests compression or crypto was introduced between v1.1 and v1.5. +4. **Symbol-rich rodata.** The strings sect contains full C++ RTTI/mangled names (libc++ `NSt3__`, i.e. TI clang-based CGT toolchain): `FileUtil::…`, `App*Menu`, `N3HAL*Driver`, `N8SoundOSC…`, plus UI strings, RIFF/WAVE/MIDI tags, project chunk tags (`PJST/PTDT/SONG/…`), and a full C674x crash dumper (`Legacy NMI Exception`, `A0–A31/B0–B31`, `NTSR/ITSR/…`). Code↔data cross-refs grow monotonically (5,854→5,909→6,362 code-ptrs; 2,932→2,967→3,141 self-ptrs) — i.e. vtables/function tables live in the strings sect and can anchor disassembly. +5. **Three-point version oracle.** v1.1→v1.2 is a +10 kB small delta (only user-visible string adds: `TRACK MUTE`, `AppPad::handleKeyPadOnSliceMode`); v1.2→v1.5 is a +91 kB big delta (audio export family, MIDI Note Map, 4 new master FX `MIsolator/MRackComp/SlipRoll/HoldDelay`, `REVERSE`, precount rework with `EibiEUliE` overloads, `FileUtil::removeResourceFork` + `Rb` params, `Fv→Fvi/Fvii/Fviii` callback migration, display-pipeline symbol turnover). This tells us exactly which subsystems are self-contained enough to backport/forward-port. + +## 2. Risk assessment + +| Risk | Level | Notes | +|---|---|---| +| Permanent brick (bootloader kill) | **Low** | Bootloader lives in SPI flash (MX25L12833F, dumped + mapped — see `docs/bootloader.md`), update `.bin` is a DDR image parsed by it. A corrupt `.bin` aborts `SYSTEM UPDATE`; recovery is the PAD-held boot with stock SD. | +| Soft-brick / failed boot loop | **Medium** | Wrong checksum, bad entry point, or misaligned sect will likely hang or drop back to updater. Mitigation: keep a known-good SD card with stock v1.5.205, document the hold-PAD-while-powering-on update flow, never ship without a revert image. | +| Silent data corruption (projects/SD) | **Medium** | FileUtil rework in v1.5 touches recursive FS ops + resource forks. Patches near file I/O deserve extra caution and SD-card backups. | +| Checksum | **Solved, no longer a risk** | `cmtd+0x08` reproduced on all 3 builds; `mmtd+0x38` flags need no handling (covered as-is). | +| Legal / warranty | **Medium** | Distributing full modded images contains Sonicware IP. Prefer distributing patches (xdelta/bps + script) against user-supplied stock `.bin`. Warranty implications unknown. | +| No debugger mapped | **Medium** | UART1_TX/RX + SPI + SD test points are named on the silkscreen (see `docs/hardware.md`), continuity untraced. Crash dumper strings suggest NMI output exists — worth hunting on-board before any code patch. | + +**Bottom line on risk:** string/asset-only mods with a solved checksum are low-risk. Any code-segment edit without a debugger or recovery plan is medium-risk. Touching anything outside the `.bin` (SPI flash, AT32F421 USB MCU) is high-risk and out of scope. + +## 3. Tweakability ladder (easiest → hardest) + +### Level 0 — Trivial (days, one person, no DSP expertise) +- **Same-length UI string swaps.** The strings sect is NUL-separated with padding (e.g. `Threshold\x00\x00\x00\x00ENCODER TEST`). Any replacement of equal byte-length (pad with spaces/NULs) keeps every address stable — no pointer fixups, no checksum-of-lengths to worry about (only the one global checksum). Ideal for renaming menu items, translating UI, joke builds, ownership marks. +- **Asset/blob swaps.** Sect #0 (~85 kB, `7e xx` patterned bitmap/font/waveform data) can be recolored or redrawn in place as long as length is preserved. Fonts/icons are the likely content. +- **Version-string spoofing.** `cmtd`/`mmtd` version triples are plain LE u32s — trivial to bump for fork identification (checksum auto-recomputed, flags preserved as-is). + +### Level 1 — Easy (slack-space + constants, basic C6000 asm) +- **Longer/shorter strings via slack.** Top zero-runs in the v1.5 strings sect include 388, 256, 223, and many 196-byte gaps — enough to relocate a handful of lengthened strings and patch their pointers. Requires finding xrefs (the 6,362 code-ptrs give a starting map) but no code-cave engineering. +- **Numeric constant tuning.** Float tables (sects #1/#6) and the float ramp tail are plain LE float32 — filter/window coeffs, tempo/level defaults, threshold values. Tunable by hex-edit + listen. Same for the 512 B record table (preset/pattern-shaped fixed records). +- **Behavior flags / timeouts / limits.** Precount on/off defaults, LED timings, "1,024 files per folder" caps, `ARE YOU SURE?` confirm gates are typically single immediates or branches — findable via string xref → code. + +### Level 2 — Medium (real code patches, needs Ghidra + C6000 skill) +- **Feature NOPs / unlocks.** Skipping a confirm dialog, forcing precount off, enabling hidden menus: overwrite a branch with NOP or invert a compare. Needs rebase-aware disassembly (load code sect at its DDR base, e.g. `C2B80C00` for v1.5) and vtable-anchored function boundaries. +- **Branch-to-cave mini-features.** The 8 B zero slot is *not* usable code space (too small, and it vanishes in v1.2 — it's alignment churn), but the low-entropy zero-padded tail of the code sect (`+0x140000…`, entropy ~3.0) offers code-cave room for small injected routines (extra MIDI mapping, custom pad behavior) reached by patching a call site. +- **Backports between versions.** Because v1.1→v1.2 is tiny, diffing those two isolates e.g. `TRACK MUTE` / slice-mode handling almost cleanly — the most realistic "port feature X to version Y" target. v1.2→v1.5 features are larger families and harder to lift. + +### Level 3 — Hard (weeks–months, DSP + toolchain mastery) +- **New/changed DSP behavior** (custom filter, altered timestretch/slice engine, new LFO shape). Requires understanding TI CGT calling conventions, `addkpc`-relative addressing, fixed-vs-float pipelines — plus listening tests per iteration since there is no emulator. +- **Display-pipeline changes.** v1.5 turned over all `RenderBufferIhLi128ELi128ELi1327E` symbols; the GUI stack is evidently custom and version-fragile. +- **FileUtil / project-format changes.** `Rb` params, `removeResourceFork`, `Fv→Fvi` callback migration mean v1.5's FS layer differs structurally from v1.1/v1.2 — grafting across that boundary is genuinely hard. + +### Level 4 — Out of reach (even with current assets) +- **Custom bootloader / SPI-flash layout changes.** Dumps exist and the AIS + image is mapped (`docs/bootloader.md`), but reflashing a bad bootloader can + only be recovered via external programmer — out of scope for SD-only modding. +- **USB stack (AT32F421 MCU).** Separate chip, separate firmware, not in scope of these images. +- **New codecs / new file formats / USB audio / major new FX algorithms from scratch.** No source, no SDK, no DSP build chain verified; would amount to writing C674x DSP code blind. + +## 4. What we can tweak vs. what is beyond reach (concrete list) + +**Realistic mods:** +- Rename/translate/reword any menu, dialog, error string (`AUDIO EXPORT`, `ARE YOU SURE?`, `MIXDOWN FILE NAME:`, …). +- Redraw/replace bitmap assets (fonts, icons, waveform glyphs) in sect #0. +- Retune float constants (filter coeffs, ramp, default tempo/thresholds) and fixed-record presets. +- NOP confirm dialogs, change defaults (precount, mute behavior), remap pad/MIDI-note handling, adjust LED update logic (`StepEditPageController::updateAppLED` exists as a named target). +- Backport small v1.2 behaviors (track/pattern mute, slice-mode pad handling) across versions; cherry-pick single v1.5 strings/behaviors that are data-driven. + +**Beyond reach (without new inputs):** +- New master FX on par with `MIsolator/MRackComp/SlipRoll/HoldDelay` (these were ~90 kB of new code — a team-sized effort to replicate blind). +- Audio export itself as a backport to v1.1/v1.2 (whole class family: `AppAudioExport*`, `DialogAudioExporting`, `MixDown~`). +- Reliable larger-than-slack additions (no dynamic allocator mapped; memory map beyond the tiled `C2B809E8…C2DBC698` range has gaps marked only as descriptors/BSS). +- USB behavior, SD-driver changes (`N3HAL*Driver` family is named but unmapped), sample-rate/format support changes. + +## 5. Toolchain & skills required + +- **Repacker:** done — `tools/lofi_image.py` parses/packs the exact-fit chain and + stamps the valid checksum (`compute_checksum`, proven 3/3). +- **Disassembly:** working lab — Ghidra 12.1.3 + ghidra-c6000 + PyGhidra drivers + (`ghidra/headless/`), proven on the bootloader CRC routine; `analysis/gen_funcmap.py` + for vtable-anchored maps; capstone `CS_ARCH_TMS320C64X` for quick preludes; + rebase-aware diffing via function hashes (raw byte-diff is defeated by rebasing + `C2589800→C258D800→C2B80C00`). +- **Patching:** C674x assembly literacy, pointer/xref hunting from the strings sect, xdelta/bps distribution to avoid shipping Sonicware binaries. +- **Hardware (before any code mod boots):** revert flow is hold-PAD-while-powering-on + with stock SD; PCB photographed (`docs/photos/`); UART1_TX/RX test points named + on silkscreen (continuity untraced); SPI dumps banked locally; SD-card backups. + +## 6. Suggested plan (lowest-risk order) + +1. ~~Crack `cmtd+0x08`~~ — **done** (CRC32 + `0xC27C6282` seed; `tools/` stamps it). +2. **Prove the loop with a Level-0 mod.** Same-length string swap on v1.5.205 → boot → revert to stock. If this fails, stop: no higher level is viable. +3. **Map xrefs for one Level-1 target** (e.g. a confirm dialog or precount default) using string→code pointers; patch, test, revert. +4. **Only then** attempt Ghidra full-disassembly + v1.1↔v1.2 micro-diff for a first Level-2 backport. +5. **Do not touch** SPI flash, USB MCU, or FS-write paths until UART/crash logs are captured and a revert is drill-tested. + +## 7. Verdict + +- **Feasibility: moderate-to-good for small mods, poor for big features.** The format is open, packing is solved, the code is identified and unprotected, and three versions triangulate features well. The project no longer lives or dies on the checksum — it lives or dies on the first on-device boot test. +- **Risk: contained if disciplined** (DDR-image-only, stock revert on hand, string-first progression), and uncontained if the bootloader or USB MCU is touched. +- **Ceiling with current assets:** customized UI/assets, tuned constants, disabled annoyances, small behavior patches, possibly one backported mini-feature. New DSP engines, new I/O, and custom bootloaders remain out of reach.