Reversed the device-flag mechanism (l.java lc() + ee bit logic) and validated the flag locations against a real reference map: 20188Map2009AIRBOXBONNY (explicitly "NO SAI, NO O2 SENSORS"). Diffing its flat ROM vs stock 20188 isolated exactly three byte-boolean flags that flip 1->0: 0x53801 SAI (= base + fe[33] + 0x00) 0x53818 O2 sensor (+ 0x17) 0x53819 O2 sensor (2) (+ 0x18) 1 = enabled, 0 = disabled. Neither stock map could reveal these (both have SAI+O2 on); the delete map was the key. See research/reference-maps/DEVICES.md. Viewer: the Triumph-tables tab now has a Device flags panel — checkboxes reflect the loaded map's real state (stock: all on; delete map: all off), toggling flips the byte, and "Export edited .hex" re-encodes the distribution format. The decode->edit->encode round-trip is byte-exact (verified). The caXX header bytes are map-ID metadata, not a cal checksum; the ECU-flash checksum is applied at write time.
161 lines
5.9 KiB
Python
161 lines
5.9 KiB
Python
"""Build a self-contained HTML viewer for the Triumph tuning project.
|
||
|
||
Extracts TuneECU's editable-surface definitions (tunable parameters, table
|
||
axes, toggleable devices, live sensors, actuator tests) from the decompiled
|
||
APK resources, combines them with the already-extracted map catalogue
|
||
(maps.json), and embeds everything into a single double-click-to-open HTML file
|
||
with no server and no external dependencies.
|
||
|
||
Usage:
|
||
python3 viewer/build_viewer.py \
|
||
--arrays ../work/apktool_out/res/values/arrays.xml \
|
||
--maps maps.json \
|
||
--template viewer/template.html \
|
||
--out viewer/tunie-viewer.html
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import re
|
||
from pathlib import Path
|
||
|
||
# Resource arrays worth surfacing, with a friendly label and short note.
|
||
DEF_ARRAYS = {
|
||
"Parameters": ("Tunable parameters", "Single-value settings TuneECU can change."),
|
||
"title_axis": ("Table axes", "The axes every fuel/ignition map is built on."),
|
||
"Devices": ("Toggleable devices", "Emissions/hardware features that can be turned off."),
|
||
"eSensor": ("Live sensors", "Channels the ECU streams while running."),
|
||
"test_devices": ("Actuator tests", "Outputs TuneECU can command for bench testing."),
|
||
"reset_devices": ("Reset routines", "Adaptations/adjustments that can be reset."),
|
||
"ftrim_name": ("Fuel-trim tables", "Per-cylinder fuel trim table names."),
|
||
"ecu": ("ECU families", "Supported ECUs; the code after ':' is the type char."),
|
||
}
|
||
|
||
# Devices/parameters directly relevant to this bike's planned mods.
|
||
MOD_TARGETS = {"SAI", "O2 Sensor", "O2 Sensor (2)", "Exhaust Valve", "Air Flap"}
|
||
|
||
|
||
def _extract_arrays(arrays_xml: Path) -> dict:
|
||
s = arrays_xml.read_text(encoding="utf-8")
|
||
|
||
def arr(name: str) -> list[str] | None:
|
||
m = re.search(
|
||
rf'<string-array name="{name}"[^>]*>(.*?)</string-array>', s, re.S
|
||
)
|
||
if not m:
|
||
return None
|
||
return [
|
||
re.sub(r"\s+", " ", i.strip().strip('"'))
|
||
for i in re.findall(r"<item>(.*?)</item>", m.group(1), re.S)
|
||
]
|
||
|
||
out = {}
|
||
for name, (label, note) in DEF_ARRAYS.items():
|
||
items = arr(name) or []
|
||
out[name] = {
|
||
"label": label,
|
||
"note": note,
|
||
"items": [{"i": i, "text": t} for i, t in enumerate(items) if t],
|
||
}
|
||
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,
|
||
# Device-enable flags: byte at base + fe[33] + rel (1=on, 0=off).
|
||
# Validated against a NO-SAI-NO-O2 reference map (see research DEVICES.md).
|
||
"deviceFe": 33,
|
||
"devices": [
|
||
{"name": "SAI (Secondary Air Injection)", "rel": 0x00},
|
||
{"name": "O2 sensor", "rel": 0x17},
|
||
{"name": "O2 sensor (2)", "rel": 0x18},
|
||
],
|
||
}
|
||
|
||
|
||
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 = []
|
||
if mapdefs_path.exists():
|
||
geometry = json.loads(mapdefs_path.read_text(encoding="utf-8")).get("geometry", [])
|
||
|
||
payload = {
|
||
"definitions": defs,
|
||
"maps": maps,
|
||
"geometry": geometry,
|
||
"romdefs": romdefs,
|
||
"modTargets": sorted(MOD_TARGETS),
|
||
"meta": {
|
||
"mapCount": len(maps),
|
||
"tableGroups": len(geometry),
|
||
"source": "TuneECU APK resources (arrays.xml) + maps.json + mapdefs.json",
|
||
},
|
||
}
|
||
|
||
template = args.template.read_text(encoding="utf-8")
|
||
blob = json.dumps(payload, separators=(",", ":"))
|
||
html = template.replace("/*__DATA__*/", blob)
|
||
|
||
args.out.write_text(html, encoding="utf-8")
|
||
kb = len(html.encode("utf-8")) / 1024
|
||
print(f"wrote {args.out} ({kb:.0f} KB, {len(maps)} maps, "
|
||
f"{sum(len(d['items']) for d in defs.values())} definition rows)")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|