Files
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

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