This commit is contained in:
Dejvino
2026-09-30 22:48:06 +02:00
commit 8913cb4314
37 changed files with 3190 additions and 0 deletions
+17
View File
@@ -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
+121
View File
@@ -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).
+116
View File
@@ -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+).
+62
View File
@@ -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.
+118
View File
@@ -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('<I', p[i:i + 4])[0]
for i in range(0, len(p) - 3, 4)]
is_ptr = [code['addr'] <= w < code['end'] for w in words]
runs, i, n = [], 0, len(words)
while i < n:
if is_ptr[i]:
j = i
while j < n and is_ptr[j]:
j += 1
if j - i >= 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 ["<outside code>"]
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 ["<capstone err %s>" % 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 ["<undecodable>"]
if insns and base_addr == faddr:
return ["%08X %s %s" % (i.address, i.mnemonic, i.op_str)
for i in insns[:6]]
return ["<undecodable>"]
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()
+69
View File
@@ -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("<I", d[8:12])[0]
K = solve_for_K(d, target)
print("%08X" % K if K is not None else "NO_SOLUTION")
return 0 if K is not None else 1
if __name__ == "__main__":
sys.exit(main())
+51
View File
@@ -0,0 +1,51 @@
# Bootloader
How the device boots, from power-on to app. Static analysis of the IC300 SPI
dump (kept local, never shipped) + TI C6748 public boot docs.
## Chain
1. **SoC ROM (ARM9)** reads boot-mode pins → SPI flash master mode.
2. ROM parses the **AIS image** at flash `0x0` (magic `0x41504954`, `TIPA`).
3. AIS prelude runs **PLL/DDR/EMIF init** (opcodes `0x5853590D` ×2 at file
`0x8`/`0x1C`), so DDR is usable before any load.
4. ROM processes **10× Section Load** (`0x58535901`), copying code/data to
`0xC7074630–0xC70B0598` (see table below), then **JumpClose** (`0x58535906`,
file `0x38660`) to entry **`C70A28A0`**.
5. Entry is DSP code (C674x reset prelude `ZERO b0; mvc b0,ier; mvc b0,csr`,
stack align) — from here the DSP owns the system; ARM ROM's job is done.
No AIS CRC opcodes are present.
## AIS section table (file off → DDR, size)
| File | DDR | Size | Role |
|---|---|---|---|
| `0x000050` | `C7074630` | `0x1D0` | header/code |
| `0x00022C` | `C7074800` | `0x33B00` | main code (updater lives here) |
| `0x033D38` | `C70A8300` | `0x28CC` | rodata (strings, vtables) |
| … | `C70AABD0`/`C70AABE0`/`C70AABF0` | `C`/`C`/`D44` | small records |
| … | `C70AF000`/`C70AF4E8`/`C70AF5EC`/`C70AF660` | `200`/`104`/`70`/`F38` | tables/tail |
Re-split any dump with `tools/ais_unpack.py`.
## What the bootloader contains
- Full C++ application core (libc++, TI CGT): `Foundation::SystemUpdater`,
`SystemVerify`, `BootLoader(Delegate)`, HAL drivers (`N3HAL` SPI/I2C/UART/SD),
OLED/display stack, crash dumper (`Legacy NMI Exception`, register dump).
- Updater methods per flash section: `updateBootSection`, `updateMainSection`
(the SD `.bin` = SYSTEM), `updatePresetSection`, `updateMCUSection`,
`updateMCUBootSection`, all taking `CatMetaData::ROMSection`.
- Checksum engine: bitwise CRC-32/IEEE at **`C70A0A60`**
(`NOT` init/final in/around the routine ⇒ zlib chaining `F(buf,len,init)`),
4 KiB-chunk driver at **`C708E4E4`**, file check at **`C709E7C0`**
(16-byte header CRC seed 0 → chained `0x40000` chunks → `CMPEQ` vs stored),
orchestrated from **`C708678C`**. Ghidra decompilations that proved this are
described in `RE-PROCESS.md` §3–4 (project kept local).
## Rest of IC300 flash
Past the AIS image: `FF` to `~0x100000`, data to `~0x1EFFFF`, `FF` gap
`0x1F–0x4Fxxxx`, data `0x500000–0xFC7C17` (app/data sections loaded by the
updater; exact section table not yet mapped — open question in `FINDINGS.md`).
IC301 is separate factory content (`AILS`, pattern names, 24 WAVs).
+53
View File
@@ -0,0 +1,53 @@
# Firmware update (SD path)
How a stock or modded `.bin` gets onto the device and exactly what is verified.
No hardware access needed — SD card + OLED only.
## Trigger
File named `Lofi-12 XT.bin` at SD root (from the official update ZIP:
https://sonicware.jp/pages/downloads), then hold **PAD while powering on**.
Updater (`SYSTEM UPDATE`) lives in SPI AIS flash, **not** in the `.bin`, so a
bad SYSTEM image can always be reverted with a stock SD.
## Stages (OLED text)
1. `Checking... 0%` — parse + validate the file (checks below). Failures:
`The firmware file not exist.`, `This card is invalid format.`,
`ERROR : This file is invalid.` / `Check the firmware file.`
2. `Are you sure?` — `[CLR] : No / [OK] : Yes`.
3. Per section (`>> 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.
+95
View File
@@ -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, …).
+96
View File
@@ -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.
+140
View File
@@ -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('<II', d[off+4:off+12])
open(f'sect_addr{a:08X}_off{off:06X}.bin','wb').write(d[off+12:off+12+b])
off += 12 + b
```
Split files in `/tmp/opencode/lofi/sect*_addr*.bin` (7 files).
## Code identification
- Entry `0xC2CD1AA0` → file off `0x16646C`. Disassembled as **TMS320C64x LE**
(capstone `CS_ARCH_TMS320C64X`) gives a textbook C6000 reset prelude:
`ZERO b0; mvc b0,ier; mvc b0,csr; mvk/mvklh b15/b14; and -8,…`
(disable interrupts, align stack). ARM/Thumb disassembly of the big sect
yields ~nothing (2× `E92D` push in 1.5 MB; `bx lr` hits are coincidental
Thumb-dense false positives).
- Data sect has 6,362 words pointing into the big code range (`C2BDxxxx`,
e.g. C++ vtables) + 3,051 self-pointers. Big sect itself has ~0 pointers
into data (PC-relative `addkpc` style) + 102 self-words — consistent with
C6000 `.text` vs `.rodata` split.
- Big sect entropy ~6.9–7.0 with ~12% zeros (code+data mix, not packed/encrypted);
tail (`+0x140000…`) drops to ~3.0 with repeating 64 B zero-padded structs
(data/BSS area).
## Data sect contents (file 0x1812A0–0x19FB45)
- C++ RTTI/mangled names (libc++, `NSt3__`, so TI clang-based CGT):
`FileUtil::removeResourceFork/copyFolderRecursive/convFsRecursive/…`,
`14AppSongBrowser/TestControl/TestEncoder`, `13AppFileRename/FileSelect/
SongRename/SystemInfo/SystemMenu`, `12AppSceneMenu/TempoMenu/TrackMenu`,
`11AppSDReader/SongEdit`, `10AppTapeRec/TestADC/TestLED`, `N3HAL9I2CDriverE/
SPIDriver/IODriver/SDDriver`, `N8SoundOSC4StepE`, `N5Asset5PLockE`, …
- C674x crash dumper: `Legacy NMI Exception`, `IERR=`, `Fetch packet`,
`Execute packet`, `Opcode/Privilege/Loop buffer`, `A0=…A31=`, `B0=…B31=`,
`NTSR/ITSR/IRP/SSR/AMR/RILC/ILC, Exception at, EFR/NRP`, cache/security faults.
- UI strings: `Lofi-12XT`, `PATTERN MIXDOWN/MENU`, `PROJECT SAVE AS/SELECT`,
`SONG MIXDOWN`, `SCENE/TRACK MANAGER`, `CARD/MIDI SETTING`, `ARE YOU SURE?`,
`PROCESSING/COPYING…`, `Threshold`, `Tempo: 120.0`, `*.wav`, …
- Container/chunk tags: `RIFF/WAVE/fmt /smpl/labl/adtl/data/cue /MThd/MTrk`,
project tags `PJST/SMPS/PTD2/PTDT/PJCF/SONG/TRAU/TMAM/…`, `TRACK0/TRK0`.
## Small sects
- #0 (86 kB): byte pattern `00 7e 7e 00 … 7e xx 00 0f` — bitmap/font/waveform asset.
- #1/#6: LE float32 tables (filter/window coeffs?).
- #2 (512 B): fixed records, e.g. `f6 54 3c 00 5a a3 xx 00 … 6a 61 00`
(`ja\0`/`jma\0` fragments) — preset/pattern table.
## Open questions / next steps
1. Checksum at `cmtd+0x08` (`0xB17C0857`) — not CRC32 of obvious spans; brute-force
variants (BE, seeded, Fletcher, TI AIS style) before attempting repack.
2. Full C64x disassembly of sect #3 (TI CGT / Ghidra C6000, or capstone script);
recover vtables using pointers in sect #5.
3. Diff vs older `.bin` (e.g. v1.2.x) to isolate v1.5 feature code.
4. Confirm ARM9 vs DSP split (ARM9 may be bootloader-only in SPI flash;
everything in `.bin` looks like DSP).
+51
View File
@@ -0,0 +1,51 @@
# Firmware v1.5.205 — analyzed image
Deep dive on the newest analyzed release (the version running on the dumped
unit: every `strings≥6` in this image also occurs in IC300). Older releases and
deltas: `rev-diff.md`. Container/code basics: `FINDINGS.md` §4.
## Vitals
- File `Lofi-12 XT.bin`: **1,707,049 B**, sha256
`a8bffa65919c0a2d4ff2ce77d16301551af4d2964497b8804deed49ce4198026`,
checksum `B17C0857`, triple (1, 5, 205), container (1, 3).
- Entry **`C2CD1AA0`** (DSP reset prelude), **7 sects**, chain tiles EOF exactly.
## Composition
| # | Load addr | Len | Role |
|---|---|---|---|
| 0 | `C2DA7600` | 86,168 | asset/blob (`7e xx` bitmap/font/waveform pattern) |
| 1 | `C2DA6DD4` | 688 | float table (filter/window coeffs) |
| 2 | `C2DA7400` | 512 | fixed-record table (presets/patterns) |
| 3 | `C2B80C00` | 1,490,112 | **code** (C674x LE, entropy ~6.89, ~12.6% zeros) |
| 4 | `C2B809E8` | 8 | zero slot (alignment churn, not usable space) |
| 5 | `C2D84DE0` | 125,081 | **rodata** (all strings, vtables; 6,362 code-ptrs, 3,141 self-ptrs) |
| 6 | `C2DA5680` | 4,312 | float ramp tail |
## What it does (from strings/symbols)
- Groovebox app: patterns/songs/tracks/scenes, 16-slice engine, pad handling
(`AppPad`), step sequencer + transport with precount, per-track swing/mute.
- **Audio export** (`AppAudioExport*`, `DialogAudioExporting`, `MixDown~`):
pattern/song mixdown + individual tracks, `MIXDOWN FILE NAME:`.
- **MIDI Note Map** (`AppMIDINoteMap`), MIDI I/O + CC step-lock handling.
- Master FX: `MIsolator`, `MRackComp`, `SlipRoll`, `HoldDelay`; sample `REVERSE`;
tag search, file normalize/convert, `FILE DATABASE` (1,024 files/folder).
- Storage: SD via `N3HAL` drivers; project chunk tags
(`PJST/SMPS/PTD2/PTDT/PJCF/SONG/TRAU/…`); audio `RIFF/WAVE/smpl/cue`,
`MThd/MTrk`; `FileUtil` recursive copy/search + `removeResourceFork`.
- UI: OLED via custom `RenderBuffer` pipeline + `StepEditPageController`
(`updateAppLED`); `SYSTEM INFO` (VERSION/BOOT/SYSTEM/MCU); `SYSTEM UPDATE`;
full C674x crash dumper (`Legacy NMI Exception`, `A0–A31/B0–B31`).
- Toolchain traces: libc++ RTTI (`NSt3__`…) ⇒ TI clang-based CGT.
## Learned / verified
- Unpacked + repacked with `tools/` → byte-identical (`cmp` clean).
- `Threshold→ThresholX` Level-0 mod forges to self-consistent `462F735B`.
- Top string-sect zero gaps (388/256/223/≈196 B) bound same-build string growth
without pointer surgery.
- v1.2→v1.5 added ~91 KiB code (export + note-map + 4 FX + transport rework);
callback ABI migrated `Fv→Fvi/Fvii/Fviii`; display symbols turned over
(`RenderBufferIhLi128ELi128ELi1327E` gone) — see `rev-diff.md`.
+78
View File
@@ -0,0 +1,78 @@
# SPI flash dumping (CH341a)
Required once: backs up the bootloader/app (`IC300`) and factory data (`IC301`)
before any modding, and produced the dumps every finding here rests on. Dumps
stay local — never commit or publish them (vendor IP; `.gitignore` blocks
`*.bin`). Involved enough to deserve its own page; checklist version in
`RE-PROCESS.md` §1.
## 0. What you need
- Black CH341a Mini Programmer (v1612-class flow below; others similar),
SOIC-8 clip, multimeter, a Linux host with `flashrom`.
- Target: core module, `IC300` (boot+app) + `IC301` (data), both `MX25L12833F`.
## 1. Identify the chips
Factory stickers cover the markings — peel carefully, photograph first.
Confirm SOIC-8 pin 1 (dot/dimple, lower-left on both packages below) and probe
`VCC`/`GND` powered on (`VCC` = 3.3 V on this board). The exact part name
matters for `flashrom -c`.
![IC300 + IC301, stickers removed — both MX25L12833F](photos/ic300-ic301-closeup.jpg)
## 2. Fix the programmer voltage (do not skip)
The black board's 3.3/5 V jumper only switches ZIF `VCC`. The CH341A chip itself
stays on 5 V USB, so `CS/MOSI/CLK` idle at ~5 V even in the 3.3 V position —
out of spec for 2.7–3.6 V flash (degrades/kills). Verify: plug in empty, meter
`GND → CS/MOSI/CLK`. If ~5 V, either do the 3.3 V mod (feed CH341 `VCC` pin 28
from the `AMS1117` 3.3 V output) or use a level-shifter adapter board. Re-meter:
all signals ~3.3 V before touching the device.
## 3. Select SPI mode
Two personalities: `1a86:5523` + `ttyUSB0` = UART mode (useless here),
`1a86:5512` = SPI mode (`flashrom` needs this). Move the P/SPI jumper, replug,
confirm with `dmesg` (`New USB device found, idVendor=1a86, idProduct=5512`).
## 4. Permissions and containers
- `Couldn't open device … errno=13` = permissions. Test with `sudo`; make it
permanent with a udev rule for `1a86:5512` (`MODE="0666"`) +
`udevadm control --reload-rules && udevadm trigger`.
- `sudo` inside distrobox/toolbox is **not** host root for USB. Run on the host:
`distrobox-host-exec sudo flashrom …` (or `flatpak-spawn --host …`); podman /
docker need `--privileged -v /dev/bus/usb:/dev/bus/usb`.
## 5. Dump (read-only)
1. Device fully unpowered. Hold the SoC in reset if clipping in-circuit (avoids
bus contention with the C6748 driving SPI); `WP`/`HOLD` pulled high.
2. Seat the SOIC-8 clip on pin 1 → pin 1. A slipped clip shorts rails — reseat,
don't force.
3. Per chip, read **twice** to scratch files, then keep the verified copy as
the canonical dump:
`flashrom -p ch341a_spi -c <exact-chip-name> -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 |
+52
View File
@@ -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).*
Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

+91
View File
@@ -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 <unpack_dir>
# 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 <vtables.csv>
# 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 <unpack_dir> OR Lofi12XT_Map.py vtables <csv>")
+58
View File
@@ -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('<I', p[i:i + 4])[0]
for i in range(0, len(p) - 3, 4)]
is_code_ptr = [code['addr'] <= w < code['end'] for w in words]
runs = []
i = 0
n = len(words)
while i < n:
if is_code_ptr[i]:
j = i
while j < n and is_code_ptr[j]:
j += 1
if j - i >= 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()
+104
View File
@@ -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);
}
}
+67
View File
@@ -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");
}
}
+23
View File
@@ -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.
+155
View File
@@ -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}_<ADDR>.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("<II", d[i + 4:i + 12])
segs.append((addr, d[i + 12:i + 12 + sz]))
i += 12 + sz
elif op == bytes([0x06, 0x59, 0x53, 0x58]):
entry = struct.unpack("<I", d[i + 4:i + 8])[0]
break
else:
i += 4
return segs, entry
def main():
os.makedirs(OUT, exist_ok=True)
import jpype
import pyghidra
segs, entry = parse_ais("/var/home/dejvino/Downloads/Lofi-12XT/ic300.bin")
print("segs=%d entry=%08X" % (len(segs), entry), flush=True)
pyghidra.start()
with pyghidra.open_program(
SECT_IMAGE,
project_location=PROJ_LOC,
project_name=PROJ_NAME,
analyze=False,
language=LANG,
) as flat:
program = flat.getCurrentProgram()
print("program=" + program.getName()
+ " lang=" + program.getLanguageID().toString(), flush=True)
JByte = jpype.JArray(jpype.JByte)
try:
from java.io import ByteArrayInputStream
except ImportError:
import jpype.imports # noqa
from java.io import ByteArrayInputStream
from ghidra.util.task import TaskMonitor
from ghidra.program.model.symbol import SourceType
# 1. map blocks at DDR addresses
with pyghidra.transaction(program, "map AIS"):
mem = program.getMemory()
for idx, (addr_int, blob) in enumerate(segs):
addr = flat.toAddr("0x%08X" % addr_int)
if mem.getBlock(addr) is not None:
print("ais%d already mapped" % idx, flush=True)
continue
stream = ByteArrayInputStream(JByte(bytes(blob)))
blk = mem.createInitializedBlock(
"ais%d" % idx, addr, stream, len(blob),
TaskMonitor.DUMMY, False)
blk.setRead(True)
blk.setWrite(False)
blk.setExecute(True)
print("mapped ais%d %08X len=%d"
% (idx, addr_int, len(blob)), flush=True)
# 2. entry + analyze
with pyghidra.transaction(program, "entry"):
eaddr = flat.toAddr("0x" + ENTRY)
try:
program.getSymbolTable().addExternalEntryPoint(eaddr)
except Exception as e:
print("entry point: " + str(e), flush=True)
flat.disassemble(eaddr)
if flat.getFunctionAt(eaddr) is None:
flat.createFunction(eaddr, "ais_entry")
print("analyzing...", flush=True)
flat.analyzeAll(program)
print("analysis done", flush=True)
# 3. decompile + disassembly dump per target
from ghidra.app.decompiler.flatapi import FlatDecompilerAPI
dapi = FlatDecompilerAPI(flat)
try:
for t in TARGETS:
addr = flat.toAddr("0x" + t)
try:
fn = flat.getFunctionAt(addr)
if fn is None:
flat.disassemble(addr)
try:
fn = flat.createFunction(addr, "sub_" + t)
except Exception as e:
print(t + " createFunction: " + str(e),
flush=True)
# disassembly window
try:
start = flat.toAddr("0x%08X"
% (int(t, 16) - 64))
it = program.getListing().getInstructions(start,
True)
lines, n = [], 0
while it.hasNext() and n < 150:
ins = it.next()
lines.append("%08X %s %s" % (
ins.getAddress().getOffset(),
ins.getMnemonicString(), ins.toString()))
n += 1
open(os.path.join(OUT, "dis_" + t + ".txt"),
"w").write("\n".join(lines) + "\n")
except Exception as e:
print(t + " disasm: " + str(e), flush=True)
# decompile
if fn is not None:
try:
c = dapi.decompile(fn)
if not c:
c = "DECOMPILE_NULL"
open(os.path.join(OUT, "dec_" + t + ".txt"),
"w").write(c if c else "DECOMPILE_NULL")
print(t + " decompiled %d chars"
% (len(c) if c else 0), flush=True)
except Exception as e:
print(t + " decompile: " + str(e), flush=True)
open(os.path.join(OUT, "dec_" + t + ".txt"),
"w").write("DECOMPILE_ERROR: " + str(e))
except Exception as e:
print(t + " FAILED: " + str(e), flush=True)
finally:
dapi.dispose()
print("ALL_DONE -> " + OUT, flush=True)
if __name__ == "__main__":
sys.exit(main())
+66
View File
@@ -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_<name>.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()
+61
View File
@@ -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_<ADDR>.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()
+130
View File
@@ -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.
+53
View File
@@ -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.
+112
View File
@@ -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', data[i:i+4])
r ^= w
return r & 0xFFFFFFFF
def load():
out = []
for rel in IMAGES:
p = os.path.join(BASE, rel)
d = open(p, 'rb').read()
ck = struct.unpack('<I', d[8:12])[0]
out.append((rel.split('/')[0], d, ck))
return out
def spans(d):
nsect = struct.unpack('<I', d[0x50:0x54])[0]
off = 0x54
payloads = b''
for _ in range(nsect):
ln = struct.unpack('<I', d[off+8:off+12])[0]
payloads += d[off+12:off+12+ln]
off += 12 + ln
z8 = bytearray(d); z8[8:12] = b'\0\0\0\0'
return {
'full': d,
'from_0x04': d[0x04:],
'from_0x0C': d[0x0C:],
'from_0x30': d[0x30:],
'from_0x54': d[0x54:],
'payload_concat': payloads,
'full_ck_zeroed': bytes(z8),
'from0x0C_ck_zeroed': bytes(z8)[0x0C:],
}
def main():
blobs = load()
for name, d, ck in blobs:
print(f"{name}: size={len(d)} stored_ck={ck:08X}")
algs = {
'crc32_le': lambda b: binascii.crc32(b) & 0xFFFFFFFF,
'crc32_be_byteswap': lambda b: struct.unpack('>I', struct.pack('<I', binascii.crc32(b) & 0xFFFFFFFF))[0],
'crc32_complement': lambda b: (~binascii.crc32(b)) & 0xFFFFFFFF,
'adler32': lambda b: zlib.adler32(b) & 0xFFFFFFFF,
'fletcher16': fletcher16,
'fletcher32': fletcher32,
'sum32': sum32,
'xor32': xor32,
}
cands = []
total = 0
for aname, fn in algs.items():
for sname in spans(blobs[0][1]):
total += 1
ok = True
for _, d, ck in blobs:
try:
v = fn(spans(d)[sname]) & 0xFFFFFFFF
except Exception:
ok = False
break
if v != ck:
ok = False
break
status = 'MATCH-ALL' if ok else 'no'
if ok:
cands.append((aname, sname))
# show near-misses? only print matches to stay concise
if ok:
print(f" {aname} x {sname}: {status}")
print(f"tested {total} hypotheses, {len(cands)} full-match candidates")
if not cands:
print("No match: checksum is not plain CRC32/adler/fletcher/sum/xor over obvious spans.")
print("Next: try seeded CRC, TI-AIS style, per-sect accumulate, or mmtd.flags correlation.")
if __name__ == '__main__':
main()
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""Long-running checksum cracker for Lofi-12 XT cmtd+0x08.
Strategy: 3 known (image, checksum) pairs let us test each hypothesis fast
with early exit, plus detect CONSTANT-XOR-masked CRCs (stored = crc ^ mask).
Phases:
A (seconds): zlib-speed hashes (crc32/adler/word-sums/fnv) x spans x field-modes.
B (long): generic table-driven CRC32 parameter search
(poly x init x xorout x refin x span x field-mode),
multiprocessed, checkpointed, resumable.
Usage (long run):
python3 tools/lofi_checksum_crack.py --jobs $(nproc) --out /tmp/opencode/ck-results.txt
Quick smoke test:
python3 tools/lofi_checksum_crack.py --quick-only
python3 tools/lofi_checksum_crack.py --jobs 2 --limit 40 --out /tmp/opencode/ck-smoke.txt
Stdlib only. Safe: read-only on stock .bin files.
"""
import argparse, binascii, itertools, json, multiprocessing as mp
import os, struct, sys, time, zlib
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
IMAGES = [
('1.1.156', 'Lofi-12XT_v1.1.156/Lofi-12XT_v1.1.156/Lofi-12 XT.bin'),
('1.2.179', 'Lofi-12XT_v1.2.179/Lofi-12XT_v1.2.179/Lofi-12 XT.bin'),
('1.5.205', 'Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin'),
]
POLYS = [ # normal (non-reflected) form
0x04C11DB7, # IEEE / PKZIP
0x1EDC6F41, # CRC-32C (Castagnoli)
0x741B8CD7, # CRC-32K (Koopman)
0x1A2B3E55, # spare / nonstandard probes
0x814141AB, # CRC-32Q
0x000000AF, # tiny-poly probe (catches nibble-CRC schemes fast-fail)
]
INITS = [0x00000000, 0xFFFFFFFF]
XOROUTS = [0x00000000, 0xFFFFFFFF]
REFINS = [True, False]
FIELDMODES = ['asis', 'zeroed', 'ff'] # how cmtd+0x08 bytes are treated during hashing
SPANS = ['full', 'from_0x0C', 'from_0x30', 'from_0x54', 'payload_concat',
'headers_only', 'sect_headers_mixed']
def load_images():
blobs = []
for ver, rel in IMAGES:
p = os.path.join(BASE, rel)
d = open(p, 'rb').read()
assert d[:4] == b'cmtd' and d[0x30:0x34] == b'mmtd', p
ck = struct.unpack('<I', d[8:12])[0]
blobs.append((ver, d, ck))
return blobs
def span_bufs(d: bytes):
nsect = struct.unpack('<I', d[0x50:0x54])[0]
off = 0x54
pay = bytearray()
hdrs = bytearray()
for _ in range(nsect):
assert d[off:off+4] == b'sect'
ln = struct.unpack('<I', d[off+8:off+12])[0]
hdrs += d[off:off+12]
pay += d[off+12:off+12+ln]
off += 12 + ln
z = bytearray(d); z[8:12] = b'\0\0\0\0'
f = bytearray(d); f[8:12] = b'\xff\xff\xff\xff'
return {
'full': [d, bytes(z), bytes(f)],
'from_0x0C': [d[0x0C:], bytes(z)[0x0C:], bytes(f)[0x0C:]],
'from_0x30': [d[0x30:], d[0x30:], d[0x30:]],
'from_0x54': [d[0x54:], d[0x54:], d[0x54:]],
'payload_concat': [bytes(pay), bytes(pay), bytes(pay)],
'headers_only': [d[:0x54], bytes(z)[:0x54], bytes(f)[:0x54]],
'sect_headers_mixed': [bytes(hdrs), bytes(hdrs), bytes(hdrs)],
}
_FIELDMODE_IDX = {'asis': 0, 'zeroed': 1, 'ff': 2}
# ---------- generic CRC32 (table-driven, pure python) ----------
_crc_tables = {}
def crc_table(poly, refin):
key = (poly, refin)
t = _crc_tables.get(key)
if t is not None:
return t
t = []
if refin:
rpoly = int(f'{poly:032b}'[::-1], 2)
for i in range(256):
c = i
for _ in range(8):
c = (c >> 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', buf[i:i+4])
s = (s + w) & 0xFFFFFFFF
return s
# ---------- phase A: fast hashes ----------
def phase_a(blobs):
print('=== Phase A: fast hashes (crc32/adler/fnv/wordsum) ===', flush=True)
cands = []
for span in SPANS:
bufs = [(ver, span_bufs(d)[span], ck) for ver, d, ck in blobs]
tests = {
'crc32': lambda b: binascii.crc32(b) & 0xFFFFFFFF,
'crc32_bswap': lambda b: struct.unpack('>I', struct.pack('<I', binascii.crc32(b) & 0xFFFFFFFF))[0],
'adler32': lambda b: zlib.adler32(b) & 0xFFFFFFFF,
'fnv1a32': fnv1a32,
'wordsum_le': wordsum_le,
'sum_bytes': lambda b: sum(b) & 0xFFFFFFFF,
}
for aname, fn in tests.items():
for fmode in _FIELDMODE_IDX:
idx = _FIELDMODE_IDX[fmode]
try:
vals = [(ver, ck, fn(b[idx])) for ver, b, ck in bufs]
except Exception as e:
print(f' {aname} x {span} x {fmode}: error {e}')
continue
if all(v == ck for _, ck, v in vals):
print(f' *** EXACT MATCH: {aname} x {span} x {fmode}')
cands.append((aname, span, fmode, 0))
masks = [ck ^ v for _, ck, v in vals]
if masks[0] == masks[1] == masks[2]:
print(f' --- xor-mask candidate: {aname} x {span} x {fmode} '
f'mask={masks[0]:08X} (masked CRC, needs 1 confirmation)')
cands.append((aname, span, fmode, masks[0]))
if not cands:
print('Phase A: no exact or xor-mask candidates.', flush=True)
return cands
# ---------- phase B: generic CRC search ----------
def all_params():
for poly, init, xorout, refin, span, fmode in itertools.product(
POLYS, INITS, XOROUTS, REFINS, SPANS, FIELDMODES):
yield (poly, init, xorout, refin, span, fmode)
_G_BLOBS = None
def _init_worker(blobs_packed):
global _G_BLOBS
_G_BLOBS = blobs_packed # list of (ver, span->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()
+314
View File
@@ -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('<I', buf[i:i+4])[0]
k = (k * 0xCC9E2D51) & 0xFFFFFFFF
k = ((k << 15) | (k >> 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('<I', d[0x50:0x54])[0] for _, d, _ in _G]
crcs_per_image = []
for (_, d, _), n in zip(_G, nsects):
off, lst = 0x54, []
for _ in range(n):
ln = struct.unpack('<I', d[off+8:off+12])[0]
lst.append(binascii.crc32(d[off+12:off+12+ln]) & 0xFFFFFFFF)
off += 12 + ln
crcs_per_image.append(lst)
if name == 'chain_crc_of_crcs':
got = [binascii.crc32(struct.pack(f'<{len(c)}I', *c)) & 0xFFFFFFFF
for c in crcs_per_image]
elif name == 'chain_sum_of_crcs':
got = [sum(c) & 0xFFFFFFFF for c in crcs_per_image]
elif name == 'chain_xor_of_crcs':
got = []
for c in crcs_per_image:
x = 0
for v in c:
x ^= v
got.append(x)
elif name == 'halved_crc16_pair':
got = []
for b, _ in bufs:
h = len(b) // 2
a = crc16(b[:h], 0xA001, 0, True, 0)
c = crc16(b[h:], 0xA001, 0, True, 0)
got.append(((a << 16) | c) & 0xFFFFFFFF)
else:
return None
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
def span_bufs_alias(d, span, idx):
return span_bufs(d)[span][idx]
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-phaseD.txt')
ap.add_argument('--limit', type=int, default=None)
a = ap.parse_args()
blobs = load_images()
packed = [(ver, d, ck) for ver, d, ck in blobs]
for ver, d, ck in blobs:
print(f'{ver}: size={len(d)} stored_ck={ck:08X}', flush=True)
jobs = build_jobs()
if a.limit:
jobs = jobs[:a.limit]
print(f'Phase D: {len(jobs)} combos, jobs={a.jobs}', flush=True)
t0 = time.time()
found, done = [], 0
total = len(jobs)
with mp.Pool(a.jobs, initializer=_init, initargs=(packed,)) as pool:
for res in pool.imap_unordered(_run, jobs, chunksize=4):
done += 1
if res:
print(f' *** {res}', flush=True)
found.append(res)
with open(a.out, 'a') as fh:
fh.write(res + '\n')
if done % 50 == 0 or done == total:
print(f' [{done}/{total}] elapsed={time.time()-t0:.0f}s',
flush=True)
print(f'Phase D done: {total} combos in {time.time()-t0:.0f}s, '
f'{len(found)} candidates.', flush=True)
if __name__ == '__main__':
main()
+139
View File
@@ -0,0 +1,139 @@
"""Shared parser/packer for Lofi-12 XT Sonicware .bin images.
Format: [cmtd 48B][mmtd 36B][sect xN -> 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('<I', CKSEED)
return binascii.crc32(bytes(b)) & 0xFFFFFFFF
def parse_image(d: bytes) -> dict:
assert d[0:4] == b'cmtd', "bad cmtd magic"
assert d[0x30:0x34] == b'mmtd', "bad mmtd magic"
fsize, cksum = struct.unpack('<II', d[4:12])
assert fsize == len(d), f"cmtd filesize {fsize} != actual {len(d)}"
cmtd = {
'filesize': fsize,
'checksum': cksum,
'raw_0x0C_0x30': d[0x0C:0x30].hex(),
'u32_0x10_0x30': list(struct.unpack('<8I', d[0x10:0x30])),
}
mmtd_rem, mmtd_flags, mmtd_ff = struct.unpack('<III', d[0x34:0x40])
fw = struct.unpack('<III', d[0x40:0x4C])
entry, nsect = struct.unpack('<II', d[0x4C:0x54])
mmtd = {
'remaining': mmtd_rem,
'flags': mmtd_flags,
'ff': mmtd_ff,
'fw': list(fw),
'entry': entry,
'nsect': nsect,
}
assert mmtd_rem == len(d) - CMTD_LEN, "mmtd remaining mismatch"
sects = []
off = 0x54
for i in range(nsect):
assert d[off:off+4] == b'sect', f"bad sect magic at {off:#x}"
addr, ln = struct.unpack('<II', d[off+4:off+12])
payload = d[off+12:off+12+ln]
assert len(payload) == ln, f"sect {i} truncated"
sects.append({'index': i, 'off': off, 'addr': addr, 'len': ln,
'end': addr + ln, 'payload': payload})
off += 12 + ln
assert off == len(d), f"sect chain ends at {off:#x}, EOF {len(d):#x}"
return {'cmtd': cmtd, 'mmtd': mmtd, 'sects': sects}
def build_image(meta: dict, payloads: list, checksum: int | None = None) -> 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('<II', total, ck)
out += bytes.fromhex(meta['cmtd']['raw_0x0C_0x30'])
# fix embedded filesize/remaining inside cmtd raw (last two u32s):
# raw layout: 8 x u32 at 0x10..0x30 = [12,1,3,maj,min,patch,48,remaining]
# patch remaining just in case caller changed payload sizes
u = list(struct.unpack('<8I', out[0x10:0x30]))
u[6] = CMTD_LEN
u[7] = total - CMTD_LEN
out[0x10:0x30] = struct.pack('<8I', *u)
out += b'mmtd'
out += struct.pack('<I', total - CMTD_LEN)
out += struct.pack('<II', meta['mmtd']['flags'], meta['mmtd']['ff'])
out += struct.pack('<III', *meta['mmtd']['fw'])
out += struct.pack('<II', meta['mmtd']['entry'], nsect)
for addr, p in payloads:
out += b'sect'
out += struct.pack('<II', addr, len(p))
out += p
assert len(out) == total
if checksum == 'keep':
final_ck = meta['cmtd']['checksum']
elif checksum is None:
final_ck = compute_checksum(bytes(out))
else:
final_ck = checksum
out[8:12] = struct.pack('<I', final_ck)
return bytes(out)
def headers_to_json(info: dict) -> 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
+34
View File
@@ -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('<I', out[8:12])[0]:08X}")
if __name__ == '__main__':
main()
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Same-length UI string swap. Keeps every address stable (Level-0 mod).
Example: lofi_patch_string.py "Lofi-12 XT.bin" out.bin Threshold ThresholX
"""
import argparse, struct, sys, os
sys.path.insert(0, os.path.dirname(__file__))
from lofi_image import parse_image, build_image, find_sect_by_role
def main():
ap = argparse.ArgumentParser()
ap.add_argument('image')
ap.add_argument('output')
ap.add_argument('old')
ap.add_argument('new')
ap.add_argument('--sect', type=int, default=None)
ap.add_argument('--first-only', action='store_true')
a = ap.parse_args()
old_b, new_b = a.old.encode(), a.new.encode()
assert len(old_b) == len(new_b), \
f"lengths differ ({len(old_b)} vs {len(new_b)}); use equal length to keep addresses stable"
d = open(a.image, 'rb').read()
info = parse_image(d)
tgt = None
if a.sect is not None:
tgt = info['sects'][a.sect]
else:
tgt = find_sect_by_role(info, 'rodata')
hits = []
p = tgt['payload']
start = 0
while True:
i = p.find(old_b, start)
if i < 0:
break
hits.append(i)
start = i + 1
if not hits:
sys.exit(f"'{a.old}' not found in sect{tgt['index']} (addr {tgt['addr']:08X})")
print(f"found {len(hits)} hit(s) in sect{tgt['index']} at offsets: "
+ ', '.join(f"+{h:#x} (file {tgt['off']+12+h:#x})" for h in hits))
if a.first_only:
hits = hits[:1]
np = bytearray(p)
for h in hits:
np[h:h+len(old_b)] = new_b
payloads = [(s['addr'], bytes(np) if s['index'] == tgt['index'] else s['payload'])
for s in info['sects']]
out = build_image(info, payloads) # recomputes valid checksum
open(a.output, 'wb').write(out)
print(f"wrote {a.output} with fresh checksum "
f"{struct.unpack('<I', out[8:12])[0]:08X} — verify on device.")
if __name__ == '__main__':
main()
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Unpack Lofi-12 XT .bin -> 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()
+98
View File
@@ -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('<I', payload[i:i+4])[0]
def zero_runs(payload, minlen=32):
out, i, n = [], 0, len(payload)
while i < n:
if payload[i] == 0:
j = i
while j < n and payload[j] == 0:
j += 1
if j - i >= 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()
+32
View File
@@ -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())
+112
View File
@@ -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.