Add map-format reversing + in-browser table editor

Reverse-engineered the Triumph Keihin map format from the TuneECU loader
(l.java zc/sc/Nc) and data classes c/r/s/t.java, and turned the viewer's table
tab into a working editor.

extract_mapdefs.py pulls the table directory out of the decompiled app into
mapdefs.json: r.a/s.a/t.a (calibration directory, 8-int records keyed by the
map's offset-20 signature) -> c.a (48-int calibration metadata) -> c.b (32-int
groups = 16 offset/length pairs each), yielding 413 candidate table offsets.
FORMAT.md documents the header magic (0x18008060 masked), the directory chain,
and the write-back checksum.

The viewer now edits: pick a known table offset (or set it manually), toggle
edit mode, click a cell to change its value, and the bytes are rewritten with
the running 16-bit checksum patched by (old - new) exactly as TuneECU does,
then Download the modified copy. Verified: editing 2016->9999 with the checksum
word at 0 yields 57553 = (0 + 2016 - 9999) & 0xffff.

Geometry offsets are read-confident but not yet validated against a real map
binary; FORMAT.md flags this. Editing/checksum stay local to a downloaded copy;
the flash write path remains out of the read-only tunie tool.
This commit is contained in:
2026-08-10 15:36:23 -05:00
parent c8c10d8977
commit 9ab9feb62f
6 changed files with 348 additions and 34 deletions

View File

@@ -0,0 +1,93 @@
"""Extract the Triumph Keihin map-format tables from the decompiled TuneECU.
These decompiled data classes are TuneECU's equivalent of a TunerPro XDF -- they
describe where every table lives inside a map binary. Recovered from
com/tuneecu/{c,r,s,t}.java and cross-read against the loader in l.java
(zc/sc/Nc). Record strides were derived from how the loader indexes each array:
r.a / s.a / t.a 8 ints per record calibration directory
field[0] = 4-byte signature matched against map bytes [20..23] (see sc())
field[1] = index into c.a (the "Qd" calibration-metadata record)
field[2] = %100 -> group index into c.b (table geometry); /100 -> flags
field[3..7] = sizes / addresses / flags (not fully decoded)
c.a 48 ints per record per-calibration metadata (memory size, region,
checksum location; exact field map still being confirmed)
c.b 32 ints per record = 16 (offset, length) pairs TABLE GEOMETRY.
Each pair is (byte offset into the map, byte length of the table).
Confirmed by the loader reading c.b in 32-int strides into Dd and then
using Dd[i*2] / Dd[i*2+1] as (offset, size).
NOTE: geometry decode is read-confident but UNVERIFIED against a real map file
(we don't have one yet). Treat offsets as candidates until checked against a ROM
dump. The extraction itself (the raw numbers) is exact.
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
STRIDES = {"c.a": 48, "c.b": 32, "r.a": 8, "s.a": 8, "t.a": 8}
def _array(java: str, field: str) -> list[int]:
m = re.search(rf'\b{field} = \{{(.*?)\}};', java, re.S)
if not m:
return []
out = []
for tok in m.group(1).split(','):
tok = tok.strip().rstrip('L')
if not tok:
continue
try:
out.append(int(tok, 0))
except ValueError:
out.append(0) # jadx resource-name corruption -> placeholder
return out
def _records(flat: list[int], stride: int) -> list[list[int]]:
return [flat[i:i + stride] for i in range(0, len(flat) - stride + 1, stride)]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--src", required=True, type=Path,
help="decompiled com/tuneecu source dir (has c.java, r.java …)")
ap.add_argument("--out", required=True, type=Path)
args = ap.parse_args()
data = {}
for key, stride in STRIDES.items():
cls, field = key.split(".")
flat = _array((args.src / f"{cls}.java").read_text(encoding="utf-8", errors="replace"), field)
recs = _records(flat, stride)
data[key] = {"stride": stride, "count": len(recs), "records": recs}
# Derive candidate table geometry from c.b: 16 (offset,length) pairs / group,
# dropping empty pairs.
geometry = []
for gi, rec in enumerate(data["c.b"]["records"]):
tables = []
for p in range(0, 32, 2):
off, ln = rec[p], rec[p + 1]
if off and ln:
tables.append({"offset": off, "offset_hex": f"0x{off:X}", "length": ln})
geometry.append({"group": gi, "tables": tables})
data["geometry"] = geometry
args.out.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
tot = sum(len(g["tables"]) for g in geometry)
print(f"wrote {args.out}: "
f"c.a {data['c.a']['count']} recs, c.b {data['c.b']['count']} groups "
f"({tot} candidate tables), r/s/t "
f"{data['r.a']['count']}/{data['s.a']['count']}/{data['t.a']['count']} dir entries")
return 0
if __name__ == "__main__":
raise SystemExit(main())