# 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+).