Files
samplez/tunie/viewer/FORMAT.md
uhryniuk 173f6fa0c4 Locate Keihin tables + render real fuel maps in the viewer
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.
2026-08-11 07:00:19 -05:00

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 equal 0x18001360. 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: byte0 selects the table directory — (byte0 & 0x7B) == 0x69 → r.a (0x69); byte0 == 0x68 → t.a; else s.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 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.

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

  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/.