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:
99
tunie/viewer/download_maps.py
Normal file
99
tunie/viewer/download_maps.py
Normal 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())
|
||||
Reference in New Issue
Block a user