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:
93
tunie/viewer/extract_mapdefs.py
Normal file
93
tunie/viewer/extract_mapdefs.py
Normal 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())
|
||||
Reference in New Issue
Block a user