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.
82 lines
2.7 KiB
Python
82 lines
2.7 KiB
Python
"""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))
|