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.
99 lines
4.8 KiB
Markdown
99 lines
4.8 KiB
Markdown
# 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/`.
|