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 @@
"""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/<path> (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/<n>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 '<path>/<n>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())