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

78
tunie/viewer/FORMAT.md Normal file
View File

@@ -0,0 +1,78 @@
# Triumph Keihin map-file format (reverse-engineered)
Recovered from the decompiled TuneECU: the loader `com/tuneecu/l.java`
(`zc` → `sc` → `Nc`) and the data classes `c/r/s/t.java`. This is what a
TunerPro XDF encodes, but pulled straight out of the app.
> **Confidence:** the extracted numbers (`mapdefs.json`) are exact. The *decode*
> of what each field means is read-confident but **not yet verified against a
> real map binary** — we don't have a dump. Treat table offsets as candidates
> until checked against a ROM read from the bike.
## Header
`zc(byte[])` validates and indexes a loaded map:
- **Magic:** the first 4 bytes, masked `& 0xFF00FF60`, must equal `0x18008060`.
- **Family byte:** `bArr[0]` selects the table directory —
`(bArr[0] & 0x7B) == 0x69` → `r.a`; `bArr[0] == 0x68` → `t.a`; else `s.a`.
(`0x67`/`0x68`/`0x69` are the map "generation" markers.)
- **Calibration signature:** 4 bytes at **offset 20** (big-endian). `sc()`
searches the directory for the record whose `field[0]` equals this value.
## Directory → metadata → geometry
Three levels, all in `mapdefs.json`:
1. **`r.a` / `s.a` / `t.a`** — 8 ints per record (926 / 1048 / 36 records).
- `field[0]` = the offset-20 signature (the lookup key).
- `field[1]` = index into `c.a` (the calibration-metadata record, "Qd").
- `field[2]` = `%100` → group index into `c.b` (geometry); `/100` → flags.
- `field[3..7]` = sizes / addresses / flags (not fully decoded).
2. **`c.a`** — 48 ints per record (153 records). Per-calibration metadata:
memory size and region, and the checksum location. Exact field map still
being pinned down.
3. **`c.b`** — 32 ints per record (76 groups) = **16 `(offset, length)` pairs**.
This is the table geometry: each pair is a byte offset into the map and the
table's byte length. Confirmed by the loader reading `c.b` in 32-int strides
into `Dd`, then using `Dd[i*2]` / `Dd[i*2+1]` as offset / size.
`extract_mapdefs.py` surfaces 413 candidate tables this way.
Example (group 0): `0x6000`/535, `0x6218`/1778, `0x8000`/8890, `0x10002`/42992.
Table **dimensions and axes** come from the runtime (`MainActivity.T8`/`U8` for
rows/cols) and the `title_axis` labels (Throttle %, MAP hPa, RPM, Load %, Temp,
Gear). Cells are **16-bit big-endian** with per-table scaling.
## Editing / write-back (this is the whole trick)
TuneECU keeps a **running 16-bit checksum** at a calibration-specific offset and
patches it incrementally on every edit (`l.java:1418-1431`):
```
oldWord = (map[off] << 8) | map[off+1]
map[off] = newWord >> 8
map[off+1] = newWord & 0xFF
checksum = (checksum + oldWord - newWord) & 0xFFFF # at the checksum offset
```
So editing a cell is: **overwrite the 2 bytes, then add `(oldWord - newWord)` to
the checksum word.** No full re-hash needed. (In the decompile the checksum sits
at byte `884744`/`0xD8048` for that ROM size; the offset is per-calibration and
lives in `c.a`.)
This is exactly what the viewer's editor does: edit cells → patch the checksum
word → export the modified copy. It is the core of the TuneECU edit-and-save
loop, reproduced locally and for free.
## What's still needed to fully replace TuneECU's editor
1. A real map/ROM binary to **validate** the geometry offsets above.
2. The per-calibration **checksum offset** decoded from `c.a` (currently a
configurable field in the editor).
3. Per-table **scaling factors and axis linkage** (partly in `l.java`'s
per-table-type read code; partly derivable by diffing known maps).
4. The flash **write path** to push an edited map to the bike — deliberately out
of scope for the read-only `tunie` tool; see `../research/`.

View File

@@ -73,13 +73,21 @@ def main() -> int:
defs = _extract_arrays(args.arrays) defs = _extract_arrays(args.arrays)
maps = json.loads(args.maps.read_text(encoding="utf-8")) maps = json.loads(args.maps.read_text(encoding="utf-8"))
# Optional: table geometry from extract_mapdefs.py, if it has been run.
mapdefs_path = args.out.parent / "mapdefs.json"
geometry = []
if mapdefs_path.exists():
geometry = json.loads(mapdefs_path.read_text(encoding="utf-8")).get("geometry", [])
payload = { payload = {
"definitions": defs, "definitions": defs,
"maps": maps, "maps": maps,
"geometry": geometry,
"modTargets": sorted(MOD_TARGETS), "modTargets": sorted(MOD_TARGETS),
"meta": { "meta": {
"mapCount": len(maps), "mapCount": len(maps),
"source": "TuneECU APK resources (arrays.xml) + maps.json", "tableGroups": len(geometry),
"source": "TuneECU APK resources (arrays.xml) + maps.json + mapdefs.json",
}, },
} }

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())

File diff suppressed because one or more lines are too long

View File

@@ -107,6 +107,9 @@
</div> </div>
<div id="tvBody" style="display:none"> <div id="tvBody" style="display:none">
<div class="tv-controls"> <div class="tv-controls">
<label>Known table
<select id="known"><option value="">— pick / manual —</option></select>
</label>
<label>Offset (hex)<input type="text" id="off" value="0"></label> <label>Offset (hex)<input type="text" id="off" value="0"></label>
<label>Columns<input type="number" id="cols" value="16" min="1"></label> <label>Columns<input type="number" id="cols" value="16" min="1"></label>
<label>Rows<input type="number" id="rows" value="16" min="1"></label> <label>Rows<input type="number" id="rows" value="16" min="1"></label>
@@ -117,14 +120,22 @@
<select id="endian"><option value="be" selected>big</option><option value="le">little</option></select> <select id="endian"><option value="be" selected>big</option><option value="le">little</option></select>
</label> </label>
<label>Scale ×<input type="text" id="scale" value="1"></label> <label>Scale ×<input type="text" id="scale" value="1"></label>
<label>Checksum off (hex)<input type="text" id="chkoff" value="" placeholder="e.g. d8048"></label>
</div>
<div class="controls">
<span class="chip" id="editChip">✎ Edit mode</span>
<button id="download" class="chip" style="border-color:var(--ok);color:var(--ok)">⭳ Download edited copy</button>
<span class="count" id="dirty"></span>
</div> </div>
<div id="tvInfo" class="hint"></div> <div id="tvInfo" class="hint"></div>
<div id="heat" class="heat"></div> <div id="heat" class="heat"></div>
<div id="hex" class="hexdump"></div> <div id="hex" class="hexdump"></div>
</div> </div>
<p class="hint">No dump yet? This becomes useful after <span class="mono">tunie</span> reads a ROM. <p class="hint"><b>Editing:</b> turn on ✎ Edit mode, click a cell, type a new value, Enter. The bytes
Meanwhile you can drop any TuneECU <span class="mono">.hex</span> map to eyeball its bytes, are written in place and — if a checksum offset is set — the running 16-bit checksum word is patched
and hand-locate tables by diffing two maps.</p> by <span class="mono">(old − new)</span>, exactly as TuneECU does. Then Download the modified copy.
“Known table” lists the <span id="tgN">0</span> candidate offsets extracted from TuneECU; verify them
against a real ROM before trusting them.</p>
</section> </section>
</main> </main>
@@ -211,6 +222,7 @@ fileIn.onchange=e=>load(e.target.files[0]);
function load(f){ function load(f){
if(!f) return; if(!f) return;
FNAME=f.name;
const r=new FileReader(); const r=new FileReader();
r.onload=()=>{ r.onload=()=>{
let buf=new Uint8Array(r.result); let buf=new Uint8Array(r.result);
@@ -236,7 +248,58 @@ function parseIntelHex(txt){
for(let i=0;i<max;i++) b[i]=out[i]||0; for(let i=0;i<max;i++) b[i]=out[i]||0;
return b; return b;
} }
['off','cols','rows','cell','endian','scale'].forEach(id=>document.getElementById(id).oninput=draw); // ---- known-table picklist from extracted geometry ----
let EDIT=false, DIRTY=0, FNAME='map.bin';
const known=document.getElementById('known');
const allTables=[];
(DATA.geometry||[]).forEach(g=>g.tables.forEach(t=>allTables.push({...t,group:g.group})));
document.getElementById('tgN').textContent=allTables.length;
// de-dup by offset for a saner list
const seen=new Set();
allTables.filter(t=>!seen.has(t.offset)&&seen.add(t.offset))
.sort((a,b)=>a.offset-b.offset).forEach(t=>{
const o=document.createElement('option');
o.value=t.offset; o.textContent=`${t.offset_hex} · ${t.length} bytes (grp ${t.group})`;
known.appendChild(o);
});
known.onchange=()=>{
if(!known.value) return;
const t=allTables.find(x=>x.offset==known.value);
document.getElementById('off').value=(+known.value).toString(16);
// guess a square-ish 16-bit layout from the byte length
const cells=Math.floor(t.length/2), c=Math.min(16,cells)||1;
document.getElementById('cols').value=c;
document.getElementById('rows').value=Math.max(1,Math.floor(cells/c));
draw();
};
document.getElementById('editChip').onclick=e=>{EDIT=!EDIT;e.target.classList.toggle('on',EDIT);draw();};
document.getElementById('download').onclick=()=>{
if(!BYTES) return;
const blob=new Blob([BYTES],{type:'application/octet-stream'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob); a.download=FNAME.replace(/(\.\w+)?$/,'.edited$1');
a.click(); URL.revokeObjectURL(a.href);
};
['off','cols','rows','cell','endian','scale','chkoff'].forEach(id=>document.getElementById(id).oninput=draw);
function readCell(p,cw,be){ return cw===1 ? BYTES[p] : (be?(BYTES[p]<<8)|BYTES[p+1]:(BYTES[p+1]<<8)|BYTES[p]); }
function writeCell(p,cw,be,v){
v=Math.max(0,Math.round(v)) & (cw===1?0xff:0xffff);
const old=readCell(p,cw,be);
if(cw===1){ BYTES[p]=v; }
else if(be){ BYTES[p]=v>>8; BYTES[p+1]=v&0xff; } else { BYTES[p+1]=v>>8; BYTES[p]=v&0xff; }
// patch the running 16-bit checksum word, TuneECU-style: chk += (old - new)
const co=parseInt(document.getElementById('chkoff').value,16);
if(!isNaN(co) && cw===2 && co!==p){
let chk=(BYTES[co]<<8)|BYTES[co+1];
chk=(chk + old - v) & 0xffff;
BYTES[co]=chk>>8; BYTES[co+1]=chk&0xff;
}
DIRTY++; document.getElementById('dirty').textContent=DIRTY+' edit'+(DIRTY===1?'':'s');
}
function draw(){ function draw(){
if(!BYTES) return; if(!BYTES) return;
const off=parseInt(document.getElementById('off').value,16)||0; const off=parseInt(document.getElementById('off').value,16)||0;
@@ -244,29 +307,33 @@ function draw(){
const cw=+document.getElementById('cell').value, be=document.getElementById('endian').value==='be'; const cw=+document.getElementById('cell').value, be=document.getElementById('endian').value==='be';
const scale=parseFloat(document.getElementById('scale').value)||1; const scale=parseFloat(document.getElementById('scale').value)||1;
const vals=[]; let p=off; const vals=[]; let p=off;
for(let i=0;i<cols*rows;i++){ for(let i=0;i<cols*rows;i++){ vals.push(readCell(p,cw,be)*scale); p+=cw; }
let v;
if(cw===1){ v=BYTES[p]; p+=1; }
else { v = be ? (BYTES[p]<<8)|BYTES[p+1] : (BYTES[p+1]<<8)|BYTES[p]; p+=2; }
vals.push(v*scale);
}
const mn=Math.min(...vals), mx=Math.max(...vals), span=mx-mn||1; const mn=Math.min(...vals), mx=Math.max(...vals), span=mx-mn||1;
const heat=document.getElementById('heat'); const heat=document.getElementById('heat');
heat.style.gridTemplateColumns=`repeat(${cols},1fr)`; heat.style.gridTemplateColumns=`repeat(${cols},1fr)`;
heat.innerHTML=''; heat.innerHTML='';
vals.forEach(v=>{ vals.forEach((v,i)=>{
const t=(v-mn)/span; // blue(low) -> red(high) const t=(v-mn)/span, hue=(1-t)*220;
const hue=(1-t)*220;
const cell=document.createElement('div'); cell.className='cell'; const cell=document.createElement('div'); cell.className='cell';
cell.style.background=`hsl(${hue},75%,${35+t*25}%)`; cell.style.background=`hsl(${hue},75%,${35+t*25}%)`;
cell.textContent = Number.isInteger(v)?v:v.toFixed(1); cell.textContent = Number.isInteger(v)?v:v.toFixed(1);
if(EDIT){
cell.style.cursor='cell'; cell.style.outline='1px solid rgba(255,255,255,.15)';
cell.onclick=()=>{
const raw=prompt(`New value (scaled ×${scale}) for cell r${Math.floor(i/cols)} c${i%cols}:`, v);
if(raw===null) return;
const nv=parseFloat(raw); if(isNaN(nv)) return;
writeCell(off+i*cw, cw, be, nv/scale);
draw();
};
}
heat.appendChild(cell); heat.appendChild(cell);
}); });
document.getElementById('tvInfo').textContent= document.getElementById('tvInfo').textContent=
`range ${mn.toLocaleString()} … ${mx.toLocaleString()} over ${cols}×${rows} cells from 0x${off.toString(16)}`; `range ${mn.toLocaleString()} … ${mx.toLocaleString()} over ${cols}×${rows} cells from 0x${off.toString(16)}`+
// hex dump around the offset (EDIT?' · edit mode ON — click a cell':'');
let dump=''; const start=off & ~0xf; let dump=''; const start=off & ~0xf;
for(let a=start;a<start+ (rows*cols*cw)+16 && a<BYTES.length; a+=16){ for(let a=start;a<start+(rows*cols*cw)+16 && a<BYTES.length; a+=16){
let hex='',asc=''; let hex='',asc='';
for(let i=0;i<16;i++){ const b=BYTES[a+i]; if(b===undefined)break; for(let i=0;i<16;i++){ const b=BYTES[a+i]; if(b===undefined)break;
hex+=b.toString(16).padStart(2,'0')+' '; asc+=(b>=32&&b<127)?String.fromCharCode(b):'.'; } hex+=b.toString(16).padStart(2,'0')+' '; asc+=(b>=32&&b<127)?String.fromCharCode(b):'.'; }

File diff suppressed because one or more lines are too long