Files
samplez/tunie/research/reference-maps
uhryniuk eaa804fc35 Add validated SAI/O2 device-flag checkbox editor
Reversed the device-flag mechanism (l.java lc() + ee bit logic) and validated the
flag locations against a real reference map: 20188Map2009AIRBOXBONNY (explicitly
"NO SAI, NO O2 SENSORS"). Diffing its flat ROM vs stock 20188 isolated exactly
three byte-boolean flags that flip 1->0:

  0x53801  SAI            (= base + fe[33] + 0x00)
  0x53818  O2 sensor      (+ 0x17)
  0x53819  O2 sensor (2)  (+ 0x18)

1 = enabled, 0 = disabled. Neither stock map could reveal these (both have SAI+O2
on); the delete map was the key. See research/reference-maps/DEVICES.md.

Viewer: the Triumph-tables tab now has a Device flags panel — checkboxes reflect
the loaded map's real state (stock: all on; delete map: all off), toggling flips
the byte, and "Export edited .hex" re-encodes the distribution format. The
decode->edit->encode round-trip is byte-exact (verified). The caXX header bytes
are map-ID metadata, not a cal checksum; the ECU-flash checksum is applied at
write time.
2026-08-11 08:04:13 -05:00
..

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