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,99 @@
# 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
```

View File

@@ -0,0 +1,61 @@
# Keihin SH7054 (Triumph 865 twin, mechanical odo) — table map
Reversed from `Nc()`/`Lb()` in `l.java` and validated against the stock reference
maps. All offsets are in the reconstructed **flat ROM** (0x60000). Compute per map:
```
fe = c.a[Qd*48] # Qd from the s.a directory lookup on the map signature
base = (fe[0] & 0x2F0) << 12 # = 0x50000 for the 865 twin family
offset(table) = base + fe[<field>]
```
Main tables are **32 rows (RPM) × 20 cols (throttle), 16-bit big-endian**.
## Axes
| Axis | fe field | Entries | Values (20187) | Meaning |
|---|---|---|---|---|
| RPM (rows) | `fe[8]` | 32 | 0, 500, 900, 1000 … 10000 | engine speed, direct RPM |
| Throttle (cols) | `fe[27]` | 20 | 0, 10, 20 … 780, 1000 | throttle %, ÷10 (0–100.0%) |
## Tables (validated by production 20187 vs aftermarket 20188 diff)
| Table | fe | Offset (20187) | Value range | Notes |
|---|---|---|---|---|
| Main fuel — cyl 1 | 11 | 0x56990 | 951–10480 | **strong**: 75% diff, smooth VE, aftermarket richer |
| Main fuel — cyl 2 | 12 | 0x56E90 | 951–10680 | **strong**: 67% diff |
| Low-throttle fuel — cyl 1 | 15 | 0x55590 | 0–10760 | 70% diff; starts at 0 (closed throttle) |
| Low-throttle fuel — cyl 2 | 16 | 0x55A90 | 0–10810 | 68% diff; cyl-1 pair |
| Fuel — base/idle | 9 | 0x55500 | 0–10760 | 61% diff; lower values |
| Ignition advance — gear 1 | 19 | 0x587D0 | 13–600 | 4 evenly-spaced (0x500) 20×32 tables |
| Ignition advance — gears 2–5 | 20 | 0x58CD0 | 13–600 | main operating map |
| Ignition advance — gear 6 | 21 | 0x591D0 | 13–600 | overdrive |
| Ignition advance — neutral | 22 | 0x596D0 | 60–600 | idle/free-rev |
## AFR / target lambda
`fe[2]` @ 0x52260 is an interleaved (value, breakpoint) curve where **128 = λ1.00
(stoich, 14.7 AFR)** — e.g. `… 128, 500, 128, 400, 128, 300 …`. The `128` cells are
the closed-loop lambda targets; disabling closed loop (O2 delete) lets you set
these richer. Exact dimensions still being confirmed.
## Scaling (confidence varies)
- **Fuel** values are VE/fuel-mass in the ECU's internal units (≈ 0–10800). Real
units (injector µs / VE %) need the ECU's fuel constant; relative changes are
exact, absolute scaling TBD.
- **Ignition** 13–600 is advance in an internal unit (candidate: value ÷ 10 = °BTDC,
giving ~1.3–60°; the low-RPM row 90→14→20 fits a retard-then-advance curve). TBD.
- **AFR** 128 = λ1.00 is solid (standard Keihin convention).
## SAI / O2 / lambda flags
Now findable via the localised flat-ROM diff (20187 vs 20188), which isolates small
single-byte changes (e.g. 0x50C13, 0x534AD, 0x59759) as prime toggle candidates.
Confirming which is SAI vs O2 needs either bench testing or the finer `Nc` device
logic — a follow-up.
## Status
Fuel and ignition table **locations** are validated end-to-end. Remaining: exact
scaling constants, AFR dimensions, and confirming the individual device flags.

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))

View File

@@ -0,0 +1,82 @@
"""Reconstruct the flat ECU ROM image from a decoded TuneECU map, and locate
calibration tables.
Chain (all reversed from the decompile, verified against real stock maps):
encrypted .hex --dc()--> decoded map --unpack directory--> flat 0x60000 ROM
The decoded map carries its own unpack directory (from MainActivity.p8):
desc_len = le16(dec[28]); base i21 = desc_len
marker le16(dec[i21+31]) == 0x6F66
count dec[i21+33]
entries count * (le32 dest_offset, le32 length) at i21+34
data packed chunks follow, copied verbatim to flat_rom[dest:dest+len]
Reconstructing into the flat ROM is what makes sibling maps comparable:
20187 (production) vs 20188 (aftermarket) diff drops from 96% (packed, misaligned)
to 1.4% (flat ROM) — the difference is real fuel enrichment, not noise.
Table pointers live in the calibration-metadata record fe = c.a[Qd*48], where Qd
comes from the directory lookup (s.a record for the map's signature). Table
address = fe[39] (base, 0x50000) + fe[k]. VERIFIED: fe[11]=0x6990 -> 0x56990 is a
main fuel table; 20188-minus-20187 there is uniformly positive (richer), exactly
as an aftermarket-exhaust tune should be.
"""
from __future__ import annotations
import struct
from pathlib import Path
from decode_map import decode
FLAT_MARKER = 0x6F66
def flat_rom(encrypted_or_decoded: bytes) -> bytes:
"""Return the reconstructed flat ROM. Accepts an encrypted .hex or a decoded map."""
d = encrypted_or_decoded
# Bytes 0..3 are plaintext in BOTH forms, so detect via the description block
# at offset 30 (readable ASCII once decoded).
if not all(c in (10, 13) or 32 <= c < 127 for c in d[30:45]):
d = decode(d)
le = lambda o, n: int.from_bytes(d[o:o + n], "little")
i21 = le(28, 2)
if le(i21 + 31, 2) != FLAT_MARKER:
raise ValueError(f"bad unpack marker 0x{le(i21+31,2):04X} (expected 0x6F66)")
count = d[i21 + 33]
entries = [(le(i21 + 34 + k * 8, 4), le(i21 + 38 + k * 8, 4)) for k in range(count)]
p = i21 + 34 + count * 8
rom = bytearray(b"\xff" * max(o + l for o, l in entries))
for off, ln in entries:
rom[off:off + ln] = d[p:p + ln]
p += ln
return bytes(rom)
def read_table_u16(rom: bytes, offset: int, cols: int, rows: int, be: bool = True) -> list[list[int]]:
fmt = ">H" if be else "<H"
return [
[struct.unpack_from(fmt, rom, offset + (r * cols + c) * 2)[0] for c in range(cols)]
for r in range(rows)
]
if __name__ == "__main__":
here = Path(__file__).parent
roms = {m: flat_rom((here / f"{m}Map.hex").read_bytes()) for m in ("20187", "20188")}
a, b = roms["20187"], roms["20188"]
n = min(len(a), len(b))
diff = sum(1 for i in range(n) if a[i] != b[i])
print(f"flat ROM 0x{len(a):X}; 20187 vs 20188 differ {100*diff/n:.2f}% ({diff} bytes)")
# Main fuel table at 0x56990 (fe[39]=0x50000 + fe[11]=0x6990), 20 cols.
off, cols, rows = 0x56990, 20, 16
ta = read_table_u16(a, off, cols, rows)
tb = read_table_u16(b, off, cols, rows)
print(f"\nfuel table @0x{off:X} (20187 production), first {rows}x{cols}:")
for row in ta:
print(" " + " ".join(f"{v:5d}" for v in row))
deltas = [tb[r][c] - ta[r][c] for r in range(rows) for c in range(cols)]
pos = sum(1 for x in deltas if x > 0)
print(f"\naftermarket-minus-production: {pos}/{len(deltas)} cells richer "
f"(mean {sum(deltas)/len(deltas):+.0f}) -> enrichment, as expected")

View File

@@ -0,0 +1,131 @@
"""Named calibration-table map for the Triumph Keihin SH7054 (865 twin, mechanical
odo family), derived from Nc()/Lb() in l.java and validated against the stock
reference maps.
Pipeline (all reversed, see reconstruct_rom.py and decode_map.py):
.hex --decode--> packed map --unpack--> flat 0x60000 ROM
signature @ decoded[20] --> s.a directory record --> Qd = field[1]
fe = c.a[Qd*48] (per-calibration metadata)
base = (fe[0] & 0x2F0) << 12 (= 0x50000 here)
table offset (flat ROM) = base + fe[<field>]
Tables are 32 rows (RPM) x 20 cols (throttle), 16-bit big-endian. Axes:
RPM axis = fe[8] (32 breakpoints, e.g. 0..10000)
Throttle axis = fe[27] (20 breakpoints, 0..1000 = 0..100.0%)
fe-field -> table assignment (validated by production(20187) vs aftermarket(20188)
diff and by value range/shape):
"""
from __future__ import annotations
import json
import re
import struct
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from reconstruct_rom import flat_rom
SRC = "/Users/dylan/dojo/tuner/work/jadx_out/sources/com/tuneecu"
ROWS, COLS = 32, 20 # RPM x throttle
# name, fe field, kind. Confidence: fuel/ignition are strong; base-fuel & AFR noted.
TABLE_DEFS = [
("Main fuel — cylinder 1", 11, "fuel"),
("Main fuel — cylinder 2", 12, "fuel"),
("Low-throttle fuel — cyl 1", 15, "fuel"),
("Low-throttle fuel — cyl 2", 16, "fuel"),
("Fuel — base/idle", 9, "fuel"),
("Ignition advance — gear 1", 19, "ignition"),
("Ignition advance — gears 2–5", 20, "ignition"),
("Ignition advance — gear 6", 21, "ignition"),
("Ignition advance — neutral", 22, "ignition"),
]
RPM_AXIS_FE, THR_AXIS_FE = 8, 27
AFR_FE = 2 # interleaved (value,breakpoint); 128 == lambda 1.00
def _load_int_array(java_path: str, field: str) -> list[int]:
s = Path(java_path).read_text()
m = re.search(rf"\b{field} = \{{(.*?)\}};", s, re.S)
return [int(t.strip().rstrip("L")) for t in m.group(1).split(",") if t.strip()]
def _u(x: int) -> int:
return x & 0xFFFFFFFF
def resolve(map_path: str) -> dict:
"""Reconstruct the flat ROM and resolve every named table's absolute offset."""
ca = _load_int_array(f"{SRC}/c.java", "a")
sa = _load_int_array(f"{SRC}/s.java", "a")
rom = flat_rom(Path(map_path).read_bytes())
# Resolve Qd by scanning s.a for the record whose field[0] matches the map's
# signature (read from the DECODED header); field[1] of that record is Qd.
dec_sig = _map_signature(map_path)
qd = None
for i in range(len(sa) // 8):
if _u(sa[i * 8]) == _u(dec_sig):
qd = sa[i * 8 + 1]
break
if qd is None:
raise ValueError("signature not found in s.a directory")
fe = ca[qd * 48: qd * 48 + 48]
base = (fe[0] & 0x2F0) << 12
def table(off):
return [
[struct.unpack_from(">H", rom, off + (r * COLS + c) * 2)[0] for c in range(COLS)]
for r in range(ROWS)
]
out = {
"map": Path(map_path).stem,
"qd": qd,
"base": base,
"rpm_axis": _axis(rom, base + fe[RPM_AXIS_FE], ROWS),
"throttle_axis": _axis(rom, base + fe[THR_AXIS_FE], COLS),
"tables": [],
}
for name, field, kind in TABLE_DEFS:
off = base + (fe[field] & 0x7FFFF)
out["tables"].append({
"name": name, "kind": kind, "fe": field,
"offset": off, "offset_hex": f"0x{off:X}",
"rows": ROWS, "cols": COLS, "data": table(off),
})
return out
def _axis(rom: bytes, off: int, n: int) -> list[int]:
return [struct.unpack_from(">H", rom, off + i * 2)[0] for i in range(n)]
def _map_signature(map_path: str) -> int:
from decode_map import decode
d = decode(Path(map_path).read_bytes())
sig = int.from_bytes(d[20:24], "big")
if d[0] == 0x67:
sig = (sig & 0xFFFF0000) | (((sig & 0xFFFF) + d[25]) & 0xFFFF)
return sig
if __name__ == "__main__":
r = resolve("20187Map.hex")
print(f"map {r['map']} Qd={r['qd']} base=0x{r['base']:X}")
print(f"RPM axis: {r['rpm_axis']}")
print(f"throttle axis: {r['throttle_axis']}")
for t in r["tables"]:
flat = [v for row in t["data"] for v in row]
print(f" {t['name']:32} {t['offset_hex']:>8} "
f"range {min(flat)}..{max(flat)}")
Path("table_map.json").write_text(json.dumps(
{"defs": [{"name": n, "fe": f, "kind": k} for n, f, k in TABLE_DEFS],
"rows": ROWS, "cols": COLS,
"axes": {"rpm_fe": RPM_AXIS_FE, "throttle_fe": THR_AXIS_FE},
"reference": r}, separators=(",", ":")))
print("wrote table_map.json")