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.
This commit is contained in:
2026-08-11 07:00:19 -05:00
parent 5af8825747
commit 173f6fa0c4
12 changed files with 959 additions and 10 deletions

View File

@@ -0,0 +1,81 @@
"""Decrypt a TuneECU Triumph map file (the download / distribution format).
Reverse-engineered from com/tuneecu/l.java `dc()`, the routine p8() calls on any
map that isn't a raw ROM-dump size. It is a self-synchronising CBC-style XOR
stream cipher, seeded by the (unencrypted) 4-byte header:
i5 = header[3] - 24
i2 = {0,1: 0x80808080, 2: 0x84808081, 3: 0x87848284, 87: ...}[i5]
key32 = i2 | le_u32(header[0:4]) # header seeds the keystream
prev = 0
for i in range(4, len(buf)): # bytes 0..3 stay plaintext
c = buf[i]
ks = (key32 >> (((i-4) % 4) * 8)) & 0xFF
buf[i] = prev ^ c ^ ks # decode
prev = c # feedback = ciphertext
VERIFIED: decoding 20187Map.hex yields the plaintext strings "Bonneville",
"Production silencers", "Mechanical odometer" at offsets 0x1E/0x29/0x3E, matching
the map catalogue exactly. Entropy drops 7.99 -> ~6.1 bit/byte.
Encode is the same with feedback = the freshly written (cipher) byte; pass
encode=True.
Usage:
python3 decode_map.py 20187Map.hex 20187.dec.bin
"""
from __future__ import annotations
import sys
# i2 constant selected by (header[3] - 24), from l.java dc().
_I2 = {0: 0x80808080, 1: 0x80808080, 2: 0x84808081, 3: 0x87848284}
def _i2_for(i5: int) -> int:
if i5 in _I2:
return _I2[i5]
if i5 == 87: # the ((i5+3)<<24)|((i5+1)<<8)|i5|((i5+2)<<16) branch
return ((i5 + 3) << 24) | ((i5 + 1) << 8) | i5 | ((i5 + 2) << 16)
raise ValueError(f"unhandled header[3]-24 = {i5}; add its i2 constant from l.java")
def transcode(buf: bytes, *, encode: bool = False) -> bytes:
b = bytearray(buf)
i5 = b[3] - 24
i2 = _i2_for(i5)
key32 = (i2 | (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24))) & 0xFFFFFFFF
prev = 0
for i in range(4, len(b)):
c = b[i]
ks = (key32 >> (((i - 4) % 4) * 8)) & 0xFF
out = prev ^ c ^ ks
b[i] = out
prev = out if encode else c
return bytes(b)
def decode(buf: bytes) -> bytes:
return transcode(buf, encode=False)
def encode(buf: bytes) -> bytes:
return transcode(buf, encode=True)
if __name__ == "__main__":
if len(sys.argv) != 3:
print("usage: decode_map.py <in.hex> <out.bin>", file=sys.stderr)
raise SystemExit(2)
raw = open(sys.argv[1], "rb").read()
dec = decode(raw)
open(sys.argv[2], "wb").write(dec)
# round-trip sanity: re-encoding must reproduce the original file
assert encode(dec) == raw, "round-trip failed"
strings = [
dec[o:o + n].decode("latin1")
for o, n in ((0x1E, 10), (0x29, 20), (0x3E, 19))
]
print(f"decoded {len(dec)} bytes, round-trip OK")
print("header strings:", " / ".join(s.strip() for s in strings))