Reversed the inner map format from l.java (Nc/Lb) and validated it against the stock reference maps: - reconstruct_rom.py: decoded map -> unpack directory -> 384KB flat ROM. In the flat ROM, production (20187) vs aftermarket (20188) differ only 1.4% (vs 96% packed), i.e. real fuel enrichment. - table_map.py + TABLES.md: fe = c.a[Qd*48]; base = (fe[0]&0x2F0)<<12 = 0x50000; table = base + fe[k]. Located 9 tables (main/low-throttle fuel per cylinder, base/idle fuel, ignition by gear x4), 32 RPM rows x 20 throttle cols, with real axes (RPM fe[8], throttle fe[27]) and the AFR curve (fe[2], 128=lambda 1.00). Validated: main-fuel delta is uniformly richer in the aftermarket map. Viewer now has a "Triumph tables" tab: drop a real .hex (or pick two) and it decodes, unpacks, and renders the fuel/ignition tables as heatmaps with real axes, plus an A->B difference view. build_viewer.py embeds the c.a/s.a directory so any map resolves in-browser. Catalogue dropdown: download_maps.py fetches map .hex files into maps_cache/; serve.py serves the viewer over http so the dropdown can fetch them (drag-and-drop still works on file://). Proprietary map binaries (*.hex, *.dec.bin, maps_cache/) are gitignored — code and docs only.
4.8 KiB
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 read as a little-endian u32, masked
& 0xFF00FFE0, must equal0x18001360. Equivalently, by byte:byte0 & 0xE0 == 0x60,byte1 == 0x13,byte3 == 0x18. (j5()is little-endian; big-endian does not satisfy the family bytes, so this is settled.) - Family byte:
byte0selects the table directory —(byte0 & 0x7B) == 0x69→r.a(0x69);byte0 == 0x68→t.a; elses.a(0x67). These are the map "generation" markers, all consistent with the magic. - Calibration signature: 4 bytes at offset 20, read big-endian (note:
different endianness than the magic — this is how
sc()reads it).sc()searches the directory for the record whosefield[0]equals this value.
Directory → metadata → geometry
Three levels, all in mapdefs.json:
-
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 intoc.a(the calibration-metadata record, "Qd").field[2]=%100→ group index intoc.b(geometry);/100→ flags.field[3..7]= sizes / addresses / flags (not fully decoded).
-
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. -
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 readingc.bin 32-int strides intoDd, then usingDd[i*2]/Dd[i*2+1]as offset / size.extract_mapdefs.pysurfaces 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.
The body is encrypted (verified against real maps)
Four real stock maps pulled from TuneECU's server
(research/reference-maps/) confirmed the header decode exactly — but their
bodies are encrypted: uniform ~7.91 bit/byte entropy, and two maps that
should differ only in fueling (20187 vs 20188) share just 0.1% of bytes. A
low-entropy footer (last ~5 KB) holds the key/signature material.
The loader reflects this: zc() copies 16 bytes at offset 8 into Kd (key/IV)
for byte0=0x67 maps; p8() reads key bytes from the file tail and derives
a key via m.Sb(), then m.uc() unpacks using the c.b geometry. Likely
AES-128 (same machinery as the ECU seed/key). Until this is reversed, the table
offsets below describe the decrypted map and can't be validated against a real
file. The four reference maps are the ciphertext test vectors for that work.
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
- A real map/ROM binary to validate the geometry offsets above.
- The per-calibration checksum offset decoded from
c.a(currently a configurable field in the editor). - Per-table scaling factors and axis linkage (partly in
l.java's per-table-type read code; partly derivable by diffing known maps). - The flash write path to push an edited map to the bike — deliberately out
of scope for the read-only
tunietool; see../research/.