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.
100 lines
4.8 KiB
Markdown
100 lines
4.8 KiB
Markdown
# Reference stock maps (real, from TuneECU's server)
|
|
|
|
Four genuine factory Bonneville calibrations, downloaded from
|
|
`https://www.tuneecu.fr/Maps/Triumph/Bonneville/<n>Map.hex` (the endpoint the
|
|
TuneECU app itself uses; found in the decompile at `MainActivity.java:7407`).
|
|
|
|
| File | Map | Fits |
|
|
|---|---|---|
|
|
| `20187Map.hex` | 20187 | Bonneville, **production** silencers, mechanical odo |
|
|
| `20188Map.hex` | 20188 | Bonneville, **aftermarket** silencers, mechanical odo |
|
|
| `20191Map.hex` | 20191 | production, up to VIN 739050, E25 |
|
|
| `20192Map.hex` | 20192 | aftermarket, up to VIN 739050, E25 |
|
|
|
|
These are the candidate stock maps for the 2010 T100 (mechanical odometer). Once
|
|
we dump the bike, its ROM should correspond to one of these.
|
|
|
|
## What they validated
|
|
|
|
- **Header format is correct.** All four satisfy the reversed magic:
|
|
little-endian u32 of bytes[0:4] `& 0xFF00FFE0 == 0x18001360`, i.e.
|
|
`byte0=0x67, byte1=0x13, byte3=0x18`. This confirms the format reversing in
|
|
`../../viewer/FORMAT.md` and the little-endian correction.
|
|
- **`c.b` is the table directory.** `m.uc()` reassembles a map by copying tables
|
|
at the `c.b` (offset,length) pairs — independent confirmation of the geometry.
|
|
|
|
## What they revealed (the new blocker)
|
|
|
|
**The map body is encrypted.** Evidence:
|
|
|
|
- Uniform entropy ~7.91 bits/byte across the whole body (8.0 = random).
|
|
- Sibling maps that should differ only in fueling (20187 vs 20188) share just
|
|
**0.1%** of bytes, with no equal run ≥16 bytes.
|
|
- A low-entropy footer (last ~5 KB, `H≈3.0`) that holds structured key/signature
|
|
material — matching `p8()`, which reads its key from the file **tail**
|
|
(`length-42`, `length-35`, …), derives it via `m.Sb()`, and unpacks via
|
|
`m.uc()`.
|
|
|
|
So the table offsets in `mapdefs.json` describe the **decrypted** map, and can't
|
|
be validated against these files until the map decryption is reversed. The
|
|
signature lookup (`sc()` at header offset 20) does **not** match our clean `s.a`
|
|
directory for these files — consistent with the real directory key living in the
|
|
encrypted/footer region, not the header.
|
|
|
|
## DECRYPTION SOLVED (`decode_map.py`)
|
|
|
|
The distribution format is decrypted by `l.dc()` — a self-synchronising CBC-style
|
|
XOR stream cipher seeded by the (plaintext) 4-byte header. NOT AES; the earlier
|
|
`m.uc`/`m.Sb` path is for raw ROM dumps, not these downloads. `p8()` calls
|
|
`l.dc()` on any map that isn't a raw-ROM size.
|
|
|
|
Reversed, ported, and **verified**:
|
|
- `decode_map.py` decodes all four maps; encode(decode(x)) == x (round-trip).
|
|
- Decoded 20187 contains the plaintext strings `Bonneville` / `Production
|
|
silencers` / `Mechanical odometer` at 0x1E/0x29/0x3E — matching the catalogue.
|
|
- Entropy drops 7.99 → ~6.1 bit/byte.
|
|
- **Directory lookup now validates on the decoded map:** signature at offset 20
|
|
= `0x0187CA84`, which matches `s.a` record #541 (`field[0]=25676420`). So the
|
|
whole chain works: decode → signature@20 → `s.a` directory → `c.a`/`c.b`.
|
|
|
|
Decoded files: `*.dec.bin`.
|
|
|
|
## SOLVED: flat ROM reconstruction + table location (`reconstruct_rom.py`)
|
|
|
|
The decoded map is a **packed** container, which is why a naive byte-diff of two
|
|
decoded maps showed 96% difference — the packed chunks are misaligned. Each
|
|
decoded map carries its own **unpack directory** (from `MainActivity.p8`):
|
|
`base = le16(dec[28])`; a `0x6F66` marker at `base+31`; a count at `base+33`;
|
|
then `count` (le32 dest_offset, le32 length) entries, followed by the packed
|
|
data copied verbatim to `flat_rom[dest:dest+len]`. This yields a **384 KB
|
|
(0x60000) flat ROM** — the real ECU address space.
|
|
|
|
**In the flat ROM, 20187 (production) vs 20188 (aftermarket) differ by only
|
|
1.4%** (down from 96%), localised to 26 regions. That is the actual production→
|
|
aftermarket calibration change, not noise.
|
|
|
|
Table pointers are in `fe = c.a[Qd*48]` (Qd from the directory lookup). Table
|
|
address = `fe[39]` (base 0x50000) + `fe[k]`. **VERIFIED:** `fe[11]=0x6990` →
|
|
`0x56990` is a **main fuel table** — a smooth 20-wide VE surface, and the
|
|
aftermarket map is richer in 284/320 cells (mean +323), exactly as an
|
|
aftermarket-exhaust tune should be.
|
|
|
|
The two big high-entropy diff regions (`0x55599`, `0x56990`) are the two fuel
|
|
tables; the small isolated diffs (e.g. single bytes at `0x50C13`, `0x534AD`,
|
|
`0x59759`) are prime **SAI / O2 / lambda flag** candidates — now findable because
|
|
the flat-ROM diff is localised.
|
|
|
|
Still to finish: map the remaining `fe[k]` pointers to named tables (small-
|
|
throttle fuel, AFR, ignition-by-gear, idle, limiters) and confirm each table's
|
|
dimensions/axes/scaling from the `fe` metadata (or cross-check with an XDF). The
|
|
hard part — decrypt, reconstruct, locate — is done and validated.
|
|
|
|
## Provenance (sha256)
|
|
|
|
```
|
|
cd02cd2a…306c99 20187Map.hex
|
|
bed451a9…e1ebe4 20188Map.hex
|
|
7436b0f2…79ca3a6 20191Map.hex
|
|
f9b9c60d…ffa10f 20192Map.hex
|
|
```
|