Files
samplez/tunie/research/reference-maps/table_map.py
uhryniuk 173f6fa0c4 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.
2026-08-11 07:00:19 -05:00

132 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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