From 173f6fa0c4c63dccbe7291ee240f70a537024aca Mon Sep 17 00:00:00 2001 From: uhryniuk Date: Tue, 11 Aug 2026 07:00:19 -0500 Subject: [PATCH] Locate Keihin tables + render real fuel maps in the viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 6 + tunie/research/reference-maps/README.md | 99 ++++++++++++ tunie/research/reference-maps/TABLES.md | 61 ++++++++ tunie/research/reference-maps/decode_map.py | 81 ++++++++++ .../reference-maps/reconstruct_rom.py | 82 ++++++++++ tunie/research/reference-maps/table_map.py | 131 ++++++++++++++++ tunie/viewer/FORMAT.md | 30 +++- tunie/viewer/build_viewer.py | 46 ++++++ tunie/viewer/download_maps.py | 99 ++++++++++++ tunie/viewer/serve.py | 44 ++++++ tunie/viewer/template.html | 144 ++++++++++++++++- tunie/viewer/tunie-viewer.html | 146 +++++++++++++++++- 12 files changed, 959 insertions(+), 10 deletions(-) create mode 100644 tunie/research/reference-maps/README.md create mode 100644 tunie/research/reference-maps/TABLES.md create mode 100644 tunie/research/reference-maps/decode_map.py create mode 100644 tunie/research/reference-maps/reconstruct_rom.py create mode 100644 tunie/research/reference-maps/table_map.py create mode 100644 tunie/viewer/download_maps.py create mode 100644 tunie/viewer/serve.py diff --git a/.gitignore b/.gitignore index 837332e..2ddbc4c 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,9 @@ cython_debug/ # macOS .DS_Store + +# proprietary TuneECU/Triumph map binaries — do not redistribute +*.hex +*.dec.bin +maps_cache/ +tunie/**/table_map.json diff --git a/tunie/research/reference-maps/README.md b/tunie/research/reference-maps/README.md new file mode 100644 index 0000000..90e7a18 --- /dev/null +++ b/tunie/research/reference-maps/README.md @@ -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/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 +``` diff --git a/tunie/research/reference-maps/TABLES.md b/tunie/research/reference-maps/TABLES.md new file mode 100644 index 0000000..9c3fd1e --- /dev/null +++ b/tunie/research/reference-maps/TABLES.md @@ -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[] +``` + +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. diff --git a/tunie/research/reference-maps/decode_map.py b/tunie/research/reference-maps/decode_map.py new file mode 100644 index 0000000..c5e03c8 --- /dev/null +++ b/tunie/research/reference-maps/decode_map.py @@ -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 ", 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)) diff --git a/tunie/research/reference-maps/reconstruct_rom.py b/tunie/research/reference-maps/reconstruct_rom.py new file mode 100644 index 0000000..cf9fbb6 --- /dev/null +++ b/tunie/research/reference-maps/reconstruct_rom.py @@ -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 " 0) + print(f"\naftermarket-minus-production: {pos}/{len(deltas)} cells richer " + f"(mean {sum(deltas)/len(deltas):+.0f}) -> enrichment, as expected") diff --git a/tunie/research/reference-maps/table_map.py b/tunie/research/reference-maps/table_map.py new file mode 100644 index 0000000..9eae011 --- /dev/null +++ b/tunie/research/reference-maps/table_map.py @@ -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[] + +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") diff --git a/tunie/viewer/FORMAT.md b/tunie/viewer/FORMAT.md index 8502d8e..56f28cb 100644 --- a/tunie/viewer/FORMAT.md +++ b/tunie/viewer/FORMAT.md @@ -13,11 +13,16 @@ TunerPro XDF encodes, but pulled straight out of the app. `zc(byte[])` validates and indexes a loaded map: -- **Magic:** the first 4 bytes, masked `& 0xFF00FF60`, must equal `0x18008060`. -- **Family byte:** `bArr[0]` selects the table directory — - `(bArr[0] & 0x7B) == 0x69` → `r.a`; `bArr[0] == 0x68` → `t.a`; else `s.a`. - (`0x67`/`0x68`/`0x69` are the map "generation" markers.) -- **Calibration signature:** 4 bytes at **offset 20** (big-endian). `sc()` +- **Magic:** the first 4 bytes read as a **little-endian** u32, masked + `& 0xFF00FFE0`, must equal `0x18001360`. Equivalently, by byte: + `byte0 & 0xE0 == 0x60`, `byte1 == 0x13`, `byte3 == 0x18`. (`j5()` is + little-endian; big-endian does not satisfy the family bytes, so this is + settled.) +- **Family byte:** `byte0` selects the table directory — + `(byte0 & 0x7B) == 0x69` → `r.a` (0x69); `byte0 == 0x68` → `t.a`; else `s.a` + (0x67). These are the map "generation" markers, all consistent with the magic. +- **Calibration signature:** 4 bytes at **offset 20**, read **big-endian** (note: + different endianness than the magic — this is how `sc()` reads it). `sc()` searches the directory for the record whose `field[0]` equals this value. ## Directory → metadata → geometry @@ -46,6 +51,21 @@ Table **dimensions and axes** come from the runtime (`MainActivity.T8`/`U8` for rows/cols) and the `title_axis` labels (Throttle %, MAP hPa, RPM, Load %, Temp, Gear). Cells are **16-bit big-endian** with per-table scaling. +## The body is encrypted (verified against real maps) + +Four real stock maps pulled from TuneECU's server +(`research/reference-maps/`) confirmed the **header** decode exactly — but their +bodies are **encrypted**: uniform ~7.91 bit/byte entropy, and two maps that +should differ only in fueling (20187 vs 20188) share just 0.1% of bytes. A +low-entropy footer (last ~5 KB) holds the key/signature material. + +The loader reflects this: `zc()` copies 16 bytes at offset 8 into `Kd` (key/IV) +for `byte0=0x67` maps; `p8()` reads key bytes from the file **tail** and derives +a key via `m.Sb()`, then `m.uc()` unpacks using the `c.b` geometry. Likely +AES-128 (same machinery as the ECU seed/key). **Until this is reversed, the table +offsets below describe the *decrypted* map and can't be validated against a real +file.** The four reference maps are the ciphertext test vectors for that work. + ## Editing / write-back (this is the whole trick) TuneECU keeps a **running 16-bit checksum** at a calibration-specific offset and diff --git a/tunie/viewer/build_viewer.py b/tunie/viewer/build_viewer.py index 58755df..6ca4859 100644 --- a/tunie/viewer/build_viewer.py +++ b/tunie/viewer/build_viewer.py @@ -62,17 +62,62 @@ def _extract_arrays(arrays_xml: Path) -> dict: return out +# fe-field -> named table map (from research/reference-maps/table_map.py, validated). +_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"), +] + + +def _extract_romdefs(args) -> dict: + """Extract the c.a and s.a directory arrays needed to decode a real map in-browser. + + Requires --tuneecu-src pointing at the decompiled com/tuneecu sources. If not + given or not found, returns {} and the viewer's Triumph-tables tab is disabled. + """ + src = getattr(args, "tuneecu_src", None) + if not src: + return {} + src = Path(src) + if not (src / "c.java").exists(): + return {} + + def arr(cls: str, field: str) -> list[int]: + s = (src / f"{cls}.java").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()] + + return { + "ca": arr("c", "a"), + "sa": arr("s", "a"), + "tables": [{"name": n, "fe": f, "kind": k} for n, f, k in _TABLE_DEFS], + "rows": 32, "cols": 20, "rpmFe": 8, "throttleFe": 27, + } + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--arrays", required=True, type=Path) ap.add_argument("--maps", required=True, type=Path) ap.add_argument("--template", required=True, type=Path) ap.add_argument("--out", required=True, type=Path) + ap.add_argument("--tuneecu-src", default=None, + help="decompiled com/tuneecu dir (enables the Triumph-tables tab)") args = ap.parse_args() defs = _extract_arrays(args.arrays) maps = json.loads(args.maps.read_text(encoding="utf-8")) + # Directory arrays + table map for decoding/rendering real Triumph maps. + romdefs = _extract_romdefs(args) + # Optional: table geometry from extract_mapdefs.py, if it has been run. mapdefs_path = args.out.parent / "mapdefs.json" geometry = [] @@ -83,6 +128,7 @@ def main() -> int: "definitions": defs, "maps": maps, "geometry": geometry, + "romdefs": romdefs, "modTargets": sorted(MOD_TARGETS), "meta": { "mapCount": len(maps), diff --git a/tunie/viewer/download_maps.py b/tunie/viewer/download_maps.py new file mode 100644 index 0000000..607459a --- /dev/null +++ b/tunie/viewer/download_maps.py @@ -0,0 +1,99 @@ +"""Download TuneECU Triumph map files into a local cache for the viewer. + +The viewer can render any of these from a dropdown (when served over HTTP, since +browsers block fetch() on file://). Maps come from the same endpoint the TuneECU +app uses: https://www.tuneecu.fr/Maps/ (found in the decompile). + + python3 download_maps.py # mechanical-odo Bonneville set + python3 download_maps.py --filter Bonneville # all Bonneville twin maps + python3 download_maps.py 20187 20188 20262 # specific map numbers + python3 download_maps.py --all-twins # every Triumph twin (~big) + +Writes maps_cache/Map.hex and maps_cache/index.json (id + description). +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.request +from pathlib import Path + +BASE = "https://www.tuneecu.fr/Maps/" +LIST_URL = BASE + "mapList.dat" +CACHE = Path(__file__).parent / "maps_cache" +MAPS_JSON = Path(__file__).parent / "maps.json" + +# Default: the 2010 mechanical-odometer Bonneville candidates. +DEFAULT_IDS = ["20187", "20188", "20191", "20192"] + + +def _get(url: str) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": "tunie"}) + with urllib.request.urlopen(req, timeout=30) as r: + return r.read() + + +def _map_list() -> list[str]: + """Return the '/Map.hex' entries from the server's map list.""" + text = _get(LIST_URL).decode("latin1") + return [ln.strip() for ln in text.splitlines() if ln.strip().endswith("Map.hex")] + + +def _descriptions() -> dict[str, list[str]]: + if not MAPS_JSON.exists(): + return {} + return {m["id"]: m["description"] for m in json.loads(MAPS_JSON.read_text())} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("ids", nargs="*", help="specific map numbers to fetch") + ap.add_argument("--filter", help="download every map whose path contains this substring") + ap.add_argument("--all-twins", action="store_true", help="all Triumph twin maps") + args = ap.parse_args() + + entries = _map_list() # e.g. "Triumph/Bonneville/20187Map.hex" + if args.ids: + wanted = [e for e in entries if any(f"/{i}Map.hex" in e for i in args.ids)] + elif args.all_twins: + wanted = [e for e in entries if e.startswith("Triumph/") + and any(k in e for k in ("Bonneville", "Thruxton", "Scrambler", + "America", "Speedmaster"))] + elif args.filter: + wanted = [e for e in entries if args.filter.lower() in e.lower()] + else: + wanted = [e for e in entries if any(f"/{i}Map.hex" in e for i in DEFAULT_IDS)] + + if not wanted: + print("Nothing matched.", file=sys.stderr) + return 1 + + CACHE.mkdir(exist_ok=True) + descs = _descriptions() + index = [] + for path in wanted: + fname = path.rsplit("/", 1)[-1] # 20187Map.hex + num = fname.replace("Map.hex", "") + dest = CACHE / fname + if not dest.exists(): + try: + data = _get(BASE + path) + except Exception as exc: + print(f" skip {fname}: {exc}", file=sys.stderr) + continue + dest.write_bytes(data) + print(f" got {fname} ({len(data)} bytes)") + index.append({"file": fname, "id": num, + "desc": descs.get(num, [num])}) + + index.sort(key=lambda x: x["id"]) + (CACHE / "index.json").write_text(json.dumps(index, indent=2)) + print(f"\n{len(index)} maps in {CACHE}/ (index.json written)") + print("Serve them with: python3 serve.py then use the catalogue dropdown.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tunie/viewer/serve.py b/tunie/viewer/serve.py new file mode 100644 index 0000000..4a71327 --- /dev/null +++ b/tunie/viewer/serve.py @@ -0,0 +1,44 @@ +"""Serve the tunie viewer locally so the catalogue dropdown can fetch cached maps. + + python3 serve.py # serves this folder at http://localhost:8000 + python3 serve.py 9000 # custom port + +Browsers block fetch() on file:// URLs, so the Triumph-tables catalogue dropdown +only works when the viewer is served over HTTP. Drag-and-drop works either way. +Run download_maps.py first to populate maps_cache/. +""" + +from __future__ import annotations + +import http.server +import socketserver +import sys +import webbrowser +from pathlib import Path + +HERE = Path(__file__).parent + + +def main() -> int: + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000 + if not (HERE / "maps_cache" / "index.json").exists(): + print("note: maps_cache/index.json not found — run download_maps.py first " + "for the catalogue dropdown (drag-and-drop still works).") + + handler = lambda *a, **k: http.server.SimpleHTTPRequestHandler(*a, directory=str(HERE), **k) + with socketserver.TCPServer(("127.0.0.1", port), handler) as httpd: + url = f"http://localhost:{port}/tunie-viewer.html" + print(f"serving {HERE} at {url}\nCtrl-C to stop.") + try: + webbrowser.open(url) + except Exception: + pass + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nstopped.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tunie/viewer/template.html b/tunie/viewer/template.html index d4d0ec3..33e3bfb 100644 --- a/tunie/viewer/template.html +++ b/tunie/viewer/template.html @@ -70,7 +70,8 @@
@@ -101,6 +102,33 @@
+
+

Drop a real TuneECU Triumph .hex map (e.g. + 20187Map.hex). It's decoded, unpacked to the flat ROM, and its + fuel/ignition tables are rendered with real RPM/throttle axes — no XDF needed. Drop a + second map to see the difference (e.g. production vs aftermarket enrichment).

+ +
+
Map A — drop .hex +
+
Map B (optional, for diff) +
+
+ +
+
Drop a map file here (.bin or Intel .hex) — or click to choose. @@ -211,7 +239,119 @@ Object.values(DATA.definitions).forEach(d=>{ capWrap.appendChild(c); }); -// ---- table viewer ---- +// ---- Triumph real-map tables ---- +const RD = DATA.romdefs || null; +if(!RD){ document.querySelector('nav button[data-tab=triumph]').style.display='none'; } +else { + const I2={0:0x80808080,1:0x80808080,2:0x84808081,3:0x87848284}; + function dcDecode(raw){ + const b=new Uint8Array(raw); const i5=b[3]-24; + const i2 = (i5 in I2)?I2[i5] : ((i5+3)<<24)|((i5+1)<<8)|i5|((i5+2)<<16); + const key=((i2 | (b[0]|(b[1]<<8)|(b[2]<<16)|(b[3]<<24)))>>>0); + let prev=0; + for(let i=4;i>>(((i-4)%4)*8))&0xff; b[i]=(prev^c^ks)&0xff; prev=c; } + return b; + } + const le=(b,o,n)=>{let v=0;for(let i=0;i>>0;}; + const be16=(b,o)=>((b[o]<<8)|b[o+1]); + function flatRom(dec){ + const i21=le(dec,28,2); + if(le(dec,i21+31,2)!==0x6F66) throw new Error('bad unpack marker'); + const cnt=dec[i21+33]; const ents=[]; + for(let k=0;ksz=Math.max(sz,o+l)); + const rom=new Uint8Array(sz).fill(0xff); let p=i21+34+cnt*8; + ents.forEach(([o,l])=>{ rom.set(dec.subarray(p,p+l), o); p+=l; }); + return rom; + } + function resolve(dec){ + let sig=(dec[20]<<24|dec[21]<<16|dec[22]<<8|dec[23])>>>0; + if(dec[0]===0x67) sig=((sig&0xFFFF0000)|(((sig&0xFFFF)+dec[25])&0xFFFF))>>>0; + let qd=null; + for(let i=0;i>>0)===sig){ qd=RD.sa[i*8+1]; break; } } + if(qd===null) throw new Error('signature not in directory'); + const fe=RD.ca.slice(qd*48, qd*48+48); + return {fe, base:(fe[0]&0x2F0)<<12, qd}; + } + function readTable(rom, off){ + const g=[]; for(let r=0;r({...t, off:base+(fe[t.fe]&0x7FFFF)})); + return {rom, base, rpm, thr, tables, desc:new TextDecoder().decode(dec.subarray(30,30+le(dec,28,2)))}; + } + + let TA=null, TB=null, tdiff=false; + function wireDrop(dropId, fileId, setter){ + const drop=document.getElementById(dropId), inp=document.getElementById(fileId); + drop.onclick=()=>inp.click(); + drop.ondragover=e=>{e.preventDefault();drop.classList.add('hover');}; + drop.ondragleave=()=>drop.classList.remove('hover'); + const go=f=>{ if(!f)return; const r=new FileReader(); + r.onload=()=>{ try{ setter(loadMap(r.result), f.name, drop); }catch(e){ drop.textContent=f.name+' — '+e.message; } }; + r.readAsArrayBuffer(f); }; + drop.ondrop=e=>{e.preventDefault();drop.classList.remove('hover');go(e.dataTransfer.files[0]);}; + inp.onchange=e=>go(e.target.files[0]); + } + wireDrop('tdropA','tfileA',(m,name,drop)=>{ TA=m; drop.textContent=name+' — '+m.desc.split('\n')[0]; initTriumph(); }); + wireDrop('tdropB','tfileB',(m,name,drop)=>{ TB=m; drop.textContent=name+' — '+m.desc.split('\n')[0]; renderT(); }); + + // Catalogue dropdowns (only when served over http, so fetch() works). + if(location.protocol.startsWith('http')){ + fetch('maps_cache/index.json').then(r=>r.ok?r.json():null).then(list=>{ + if(!list||!list.length) return; + document.getElementById('tcatRow').style.display='flex'; + const fill=sel=>list.forEach(m=>{ const o=document.createElement('option'); + o.value=m.file; o.textContent=`${m.id} — ${(m.desc[0]||'')}${m.desc[1]?' · '+m.desc[1]:''}`; + sel.appendChild(o); }); + const A=document.getElementById('tcatA'), B=document.getElementById('tcatB'); fill(A); fill(B); + const pick=(sel,isB)=>{ if(!sel.value) return; + fetch('maps_cache/'+sel.value).then(r=>r.arrayBuffer()).then(buf=>{ + const m=loadMap(buf); + if(isB){ TB=m; renderT(); } else { TA=m; initTriumph(); } }); }; + A.onchange=()=>pick(A,false); B.onchange=()=>pick(B,true); + }).catch(()=>{}); + } + + function initTriumph(){ + document.getElementById('tBody').style.display='block'; + const sel=document.getElementById('tsel'); sel.innerHTML=''; + TA.tables.forEach((t,i)=>{ const o=document.createElement('option'); o.value=i; + o.textContent=`${t.name} (0x${t.off.toString(16)})`; sel.appendChild(o); }); + sel.onchange=renderT; renderT(); + } + document.getElementById('tdiffChip').onclick=e=>{ tdiff=!tdiff; e.target.classList.toggle('on',tdiff); renderT(); }; + + function renderT(){ + if(!TA) return; + const idx=+document.getElementById('tsel').value; + const t=TA.tables[idx]; const A=readTable(TA.rom,t.off); + const B=(tdiff&&TB)?readTable(TB.rom, TB.tables[idx].off):null; + let vals=[]; for(let r=0;rh+=`${(v/10).toFixed(0)}`); h+=''; + for(let r=0;r${TA.rpm[r]}`; + for(let c=0;c0?'+'+v:v):v; + h+=`${txt}`; + } + h+=''; + } + g.innerHTML=h; + document.getElementById('tinfo').textContent = (B?`Δ (B−A) `:'')+ + `${t.name} · ${RD.rows}×${RD.cols} · range ${mn}…${mx}`+(B&&!TB?'':''); + } +} + +// ---- raw table viewer ---- let BYTES=null; const drop=document.getElementById('drop'), fileIn=document.getElementById('file'); drop.onclick=()=>fileIn.click(); diff --git a/tunie/viewer/tunie-viewer.html b/tunie/viewer/tunie-viewer.html index ea5b599..1de99f6 100644 --- a/tunie/viewer/tunie-viewer.html +++ b/tunie/viewer/tunie-viewer.html @@ -70,7 +70,8 @@
@@ -101,6 +102,33 @@
+
+

Drop a real TuneECU Triumph .hex map (e.g. + 20187Map.hex). It's decoded, unpacked to the flat ROM, and its + fuel/ignition tables are rendered with real RPM/throttle axes — no XDF needed. Drop a + second map to see the difference (e.g. production vs aftermarket enrichment).

+ +
+
Map A — drop .hex +
+
Map B (optional, for diff) +
+
+ +
+
Drop a map file here (.bin or Intel .hex) — or click to choose. @@ -140,7 +168,7 @@
- +