diff --git a/tunie/viewer/build_viewer.py b/tunie/viewer/build_viewer.py
new file mode 100644
index 0000000..fcd30d4
--- /dev/null
+++ b/tunie/viewer/build_viewer.py
@@ -0,0 +1,98 @@
+"""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']*>(.*?)', s, re.S
+ )
+ if not m:
+ return None
+ return [
+ re.sub(r"\s+", " ", i.strip().strip('"'))
+ for i in re.findall(r"(.*?)", 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
+
+
+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)
+ args = ap.parse_args()
+
+ defs = _extract_arrays(args.arrays)
+ maps = json.loads(args.maps.read_text(encoding="utf-8"))
+
+ payload = {
+ "definitions": defs,
+ "maps": maps,
+ "modTargets": sorted(MOD_TARGETS),
+ "meta": {
+ "mapCount": len(maps),
+ "source": "TuneECU APK resources (arrays.xml) + maps.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())
diff --git a/tunie/viewer/sample_table.bin b/tunie/viewer/sample_table.bin
new file mode 100644
index 0000000..8b4747a
Binary files /dev/null and b/tunie/viewer/sample_table.bin differ
diff --git a/tunie/viewer/template.html b/tunie/viewer/template.html
new file mode 100644
index 0000000..8443c8f
--- /dev/null
+++ b/tunie/viewer/template.html
@@ -0,0 +1,279 @@
+
+
+
+
+
+tunie — Triumph tuning viewer
+
+
+
+
+
tunie
+
+
+
+
+
+
+
+
+
+
+ ★ My bike (Keihin · mechanical · Bonneville)
+
+
+
+
+
Map
ECU
Details
+
+
+
+
+
+
+
TuneECU's full editable surface, extracted from the APK. The
+ highlighted devices are the SAI / O2 / exhaust
+ items this project plans to delete.
+
+
+
+
+
Drop a map file here (.bin or Intel .hex) — or click to choose.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No dump yet? This becomes useful after tunie reads a ROM.
+ Meanwhile you can drop any TuneECU .hex map to eyeball its bytes,
+ and hand-locate tables by diffing two maps.
+
+
+
+ ★ My bike (Keihin · mechanical · Bonneville)
+
+
+
+
+
Map
ECU
Details
+
+
+
+
+
+
+
TuneECU's full editable surface, extracted from the APK. The
+ highlighted devices are the SAI / O2 / exhaust
+ items this project plans to delete.
+
+
+
+
+
Drop a map file here (.bin or Intel .hex) — or click to choose.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No dump yet? This becomes useful after tunie reads a ROM.
+ Meanwhile you can drop any TuneECU .hex map to eyeball its bytes,
+ and hand-locate tables by diffing two maps.