Init
This commit is contained in:
+17
@@ -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
@@ -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
@@ -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+).
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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, …).
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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`.
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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 |
|
||||||
@@ -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 — SoC, DDR2 and both SPI flashes (bottom edge).*
|
||||||
|
|
||||||
|

|
||||||
|
*Both are Macronix MX25L12833F, 16 MiB. Pin-1 dots face down-left; note the
|
||||||
|
`IC300`/`IC301` silkscreen between the packages.*
|
||||||
|
|
||||||
|

|
||||||
|
*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 |
@@ -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>")
|
||||||
@@ -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()
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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.
|
||||||
Executable
+112
@@ -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()
|
||||||
Executable
+338
@@ -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()
|
||||||
Executable
+314
@@ -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()
|
||||||
Executable
+139
@@ -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
|
||||||
Executable
+34
@@ -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()
|
||||||
Executable
+55
@@ -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()
|
||||||
Executable
+29
@@ -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()
|
||||||
Executable
+98
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user