Compare commits
3 Commits
4c44933b5d
...
5af8825747
| Author | SHA1 | Date | |
|---|---|---|---|
| 5af8825747 | |||
| 9ab9feb62f | |||
| c8c10d8977 |
BIN
rippr-2.0-debug.apk
Normal file
BIN
rippr-2.0-debug.apk
Normal file
Binary file not shown.
78
tunie/viewer/FORMAT.md
Normal file
78
tunie/viewer/FORMAT.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Triumph Keihin map-file format (reverse-engineered)
|
||||||
|
|
||||||
|
Recovered from the decompiled TuneECU: the loader `com/tuneecu/l.java`
|
||||||
|
(`zc` → `sc` → `Nc`) and the data classes `c/r/s/t.java`. This is what a
|
||||||
|
TunerPro XDF encodes, but pulled straight out of the app.
|
||||||
|
|
||||||
|
> **Confidence:** the extracted numbers (`mapdefs.json`) are exact. The *decode*
|
||||||
|
> of what each field means is read-confident but **not yet verified against a
|
||||||
|
> real map binary** — we don't have a dump. Treat table offsets as candidates
|
||||||
|
> until checked against a ROM read from the bike.
|
||||||
|
|
||||||
|
## Header
|
||||||
|
|
||||||
|
`zc(byte[])` validates and indexes a loaded map:
|
||||||
|
|
||||||
|
- **Magic:** the first 4 bytes, masked `& 0xFF00FF60`, must equal `0x18008060`.
|
||||||
|
- **Family byte:** `bArr[0]` selects the table directory —
|
||||||
|
`(bArr[0] & 0x7B) == 0x69` → `r.a`; `bArr[0] == 0x68` → `t.a`; else `s.a`.
|
||||||
|
(`0x67`/`0x68`/`0x69` are the map "generation" markers.)
|
||||||
|
- **Calibration signature:** 4 bytes at **offset 20** (big-endian). `sc()`
|
||||||
|
searches the directory for the record whose `field[0]` equals this value.
|
||||||
|
|
||||||
|
## Directory → metadata → geometry
|
||||||
|
|
||||||
|
Three levels, all in `mapdefs.json`:
|
||||||
|
|
||||||
|
1. **`r.a` / `s.a` / `t.a`** — 8 ints per record (926 / 1048 / 36 records).
|
||||||
|
- `field[0]` = the offset-20 signature (the lookup key).
|
||||||
|
- `field[1]` = index into `c.a` (the calibration-metadata record, "Qd").
|
||||||
|
- `field[2]` = `%100` → group index into `c.b` (geometry); `/100` → flags.
|
||||||
|
- `field[3..7]` = sizes / addresses / flags (not fully decoded).
|
||||||
|
|
||||||
|
2. **`c.a`** — 48 ints per record (153 records). Per-calibration metadata:
|
||||||
|
memory size and region, and the checksum location. Exact field map still
|
||||||
|
being pinned down.
|
||||||
|
|
||||||
|
3. **`c.b`** — 32 ints per record (76 groups) = **16 `(offset, length)` pairs**.
|
||||||
|
This is the table geometry: each pair is a byte offset into the map and the
|
||||||
|
table's byte length. Confirmed by the loader reading `c.b` in 32-int strides
|
||||||
|
into `Dd`, then using `Dd[i*2]` / `Dd[i*2+1]` as offset / size.
|
||||||
|
`extract_mapdefs.py` surfaces 413 candidate tables this way.
|
||||||
|
|
||||||
|
Example (group 0): `0x6000`/535, `0x6218`/1778, `0x8000`/8890, `0x10002`/42992.
|
||||||
|
|
||||||
|
Table **dimensions and axes** come from the runtime (`MainActivity.T8`/`U8` for
|
||||||
|
rows/cols) and the `title_axis` labels (Throttle %, MAP hPa, RPM, Load %, Temp,
|
||||||
|
Gear). Cells are **16-bit big-endian** with per-table scaling.
|
||||||
|
|
||||||
|
## Editing / write-back (this is the whole trick)
|
||||||
|
|
||||||
|
TuneECU keeps a **running 16-bit checksum** at a calibration-specific offset and
|
||||||
|
patches it incrementally on every edit (`l.java:1418-1431`):
|
||||||
|
|
||||||
|
```
|
||||||
|
oldWord = (map[off] << 8) | map[off+1]
|
||||||
|
map[off] = newWord >> 8
|
||||||
|
map[off+1] = newWord & 0xFF
|
||||||
|
checksum = (checksum + oldWord - newWord) & 0xFFFF # at the checksum offset
|
||||||
|
```
|
||||||
|
|
||||||
|
So editing a cell is: **overwrite the 2 bytes, then add `(oldWord - newWord)` to
|
||||||
|
the checksum word.** No full re-hash needed. (In the decompile the checksum sits
|
||||||
|
at byte `884744`/`0xD8048` for that ROM size; the offset is per-calibration and
|
||||||
|
lives in `c.a`.)
|
||||||
|
|
||||||
|
This is exactly what the viewer's editor does: edit cells → patch the checksum
|
||||||
|
word → export the modified copy. It is the core of the TuneECU edit-and-save
|
||||||
|
loop, reproduced locally and for free.
|
||||||
|
|
||||||
|
## What's still needed to fully replace TuneECU's editor
|
||||||
|
|
||||||
|
1. A real map/ROM binary to **validate** the geometry offsets above.
|
||||||
|
2. The per-calibration **checksum offset** decoded from `c.a` (currently a
|
||||||
|
configurable field in the editor).
|
||||||
|
3. Per-table **scaling factors and axis linkage** (partly in `l.java`'s
|
||||||
|
per-table-type read code; partly derivable by diffing known maps).
|
||||||
|
4. The flash **write path** to push an edited map to the bike — deliberately out
|
||||||
|
of scope for the read-only `tunie` tool; see `../research/`.
|
||||||
106
tunie/viewer/build_viewer.py
Normal file
106
tunie/viewer/build_viewer.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
"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())
|
||||||
93
tunie/viewer/extract_mapdefs.py
Normal file
93
tunie/viewer/extract_mapdefs.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Extract the Triumph Keihin map-format tables from the decompiled TuneECU.
|
||||||
|
|
||||||
|
These decompiled data classes are TuneECU's equivalent of a TunerPro XDF -- they
|
||||||
|
describe where every table lives inside a map binary. Recovered from
|
||||||
|
com/tuneecu/{c,r,s,t}.java and cross-read against the loader in l.java
|
||||||
|
(zc/sc/Nc). Record strides were derived from how the loader indexes each array:
|
||||||
|
|
||||||
|
r.a / s.a / t.a 8 ints per record calibration directory
|
||||||
|
field[0] = 4-byte signature matched against map bytes [20..23] (see sc())
|
||||||
|
field[1] = index into c.a (the "Qd" calibration-metadata record)
|
||||||
|
field[2] = %100 -> group index into c.b (table geometry); /100 -> flags
|
||||||
|
field[3..7] = sizes / addresses / flags (not fully decoded)
|
||||||
|
|
||||||
|
c.a 48 ints per record per-calibration metadata (memory size, region,
|
||||||
|
checksum location; exact field map still being confirmed)
|
||||||
|
|
||||||
|
c.b 32 ints per record = 16 (offset, length) pairs TABLE GEOMETRY.
|
||||||
|
Each pair is (byte offset into the map, byte length of the table).
|
||||||
|
Confirmed by the loader reading c.b in 32-int strides into Dd and then
|
||||||
|
using Dd[i*2] / Dd[i*2+1] as (offset, size).
|
||||||
|
|
||||||
|
NOTE: geometry decode is read-confident but UNVERIFIED against a real map file
|
||||||
|
(we don't have one yet). Treat offsets as candidates until checked against a ROM
|
||||||
|
dump. The extraction itself (the raw numbers) is exact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STRIDES = {"c.a": 48, "c.b": 32, "r.a": 8, "s.a": 8, "t.a": 8}
|
||||||
|
|
||||||
|
|
||||||
|
def _array(java: str, field: str) -> list[int]:
|
||||||
|
m = re.search(rf'\b{field} = \{{(.*?)\}};', java, re.S)
|
||||||
|
if not m:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for tok in m.group(1).split(','):
|
||||||
|
tok = tok.strip().rstrip('L')
|
||||||
|
if not tok:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out.append(int(tok, 0))
|
||||||
|
except ValueError:
|
||||||
|
out.append(0) # jadx resource-name corruption -> placeholder
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _records(flat: list[int], stride: int) -> list[list[int]]:
|
||||||
|
return [flat[i:i + stride] for i in range(0, len(flat) - stride + 1, stride)]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--src", required=True, type=Path,
|
||||||
|
help="decompiled com/tuneecu source dir (has c.java, r.java …)")
|
||||||
|
ap.add_argument("--out", required=True, type=Path)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
for key, stride in STRIDES.items():
|
||||||
|
cls, field = key.split(".")
|
||||||
|
flat = _array((args.src / f"{cls}.java").read_text(encoding="utf-8", errors="replace"), field)
|
||||||
|
recs = _records(flat, stride)
|
||||||
|
data[key] = {"stride": stride, "count": len(recs), "records": recs}
|
||||||
|
|
||||||
|
# Derive candidate table geometry from c.b: 16 (offset,length) pairs / group,
|
||||||
|
# dropping empty pairs.
|
||||||
|
geometry = []
|
||||||
|
for gi, rec in enumerate(data["c.b"]["records"]):
|
||||||
|
tables = []
|
||||||
|
for p in range(0, 32, 2):
|
||||||
|
off, ln = rec[p], rec[p + 1]
|
||||||
|
if off and ln:
|
||||||
|
tables.append({"offset": off, "offset_hex": f"0x{off:X}", "length": ln})
|
||||||
|
geometry.append({"group": gi, "tables": tables})
|
||||||
|
data["geometry"] = geometry
|
||||||
|
|
||||||
|
args.out.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
|
||||||
|
tot = sum(len(g["tables"]) for g in geometry)
|
||||||
|
print(f"wrote {args.out}: "
|
||||||
|
f"c.a {data['c.a']['count']} recs, c.b {data['c.b']['count']} groups "
|
||||||
|
f"({tot} candidate tables), r/s/t "
|
||||||
|
f"{data['r.a']['count']}/{data['s.a']['count']}/{data['t.a']['count']} dir entries")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
1
tunie/viewer/mapdefs.json
Normal file
1
tunie/viewer/mapdefs.json
Normal file
File diff suppressed because one or more lines are too long
BIN
tunie/viewer/sample_table.bin
Normal file
BIN
tunie/viewer/sample_table.bin
Normal file
Binary file not shown.
346
tunie/viewer/template.html
Normal file
346
tunie/viewer/template.html
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>tunie — Triumph tuning viewer</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d;
|
||||||
|
--fg:#e6edf3; --dim:#8b949e; --accent:#4cc4ff; --hot:#ff5a52; --ok:#51d88a;
|
||||||
|
--target:#ffb454;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||||
|
background:var(--bg);color:var(--fg)}
|
||||||
|
header{padding:14px 20px;border-bottom:1px solid var(--line);
|
||||||
|
display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;background:var(--panel)}
|
||||||
|
header h1{margin:0;font-size:17px;letter-spacing:.3px}
|
||||||
|
header .sub{color:var(--dim);font-size:12px}
|
||||||
|
nav{display:flex;gap:4px;padding:8px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||||||
|
nav button{background:none;border:0;color:var(--dim);padding:8px 14px;border-radius:7px;
|
||||||
|
cursor:pointer;font-size:13px}
|
||||||
|
nav button:hover{background:var(--panel2);color:var(--fg)}
|
||||||
|
nav button.active{background:var(--accent);color:#04121c;font-weight:600}
|
||||||
|
main{padding:18px 20px;max-width:1200px}
|
||||||
|
.tab{display:none} .tab.active{display:block}
|
||||||
|
.controls{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
|
||||||
|
input,select{background:var(--panel2);border:1px solid var(--line);color:var(--fg);
|
||||||
|
padding:7px 10px;border-radius:7px;font-size:13px}
|
||||||
|
input[type=text]{min-width:220px}
|
||||||
|
.chip{background:var(--panel2);border:1px solid var(--line);border-radius:20px;padding:6px 12px;
|
||||||
|
cursor:pointer;font-size:12px;color:var(--dim)}
|
||||||
|
.chip.on{background:var(--accent);color:#04121c;border-color:var(--accent);font-weight:600}
|
||||||
|
.count{color:var(--dim);font-size:12px;margin-left:auto}
|
||||||
|
table{border-collapse:collapse;width:100%;font-size:13px}
|
||||||
|
th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||||
|
th{color:var(--dim);font-weight:600;position:sticky;top:0;background:var(--bg);cursor:pointer;user-select:none}
|
||||||
|
tr:hover td{background:var(--panel)}
|
||||||
|
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||||
|
.id{color:var(--accent);font-weight:600}
|
||||||
|
.mine{box-shadow:inset 3px 0 0 var(--ok)}
|
||||||
|
.desc{color:var(--dim);white-space:pre-line;font-size:12px}
|
||||||
|
.grid-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
|
||||||
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
|
||||||
|
.card h3{margin:0 0 4px;font-size:14px}
|
||||||
|
.card .note{color:var(--dim);font-size:12px;margin-bottom:10px}
|
||||||
|
.item{padding:4px 0;border-bottom:1px solid var(--line);display:flex;gap:8px}
|
||||||
|
.item:last-child{border:0}
|
||||||
|
.item .n{color:var(--dim);width:26px;text-align:right;flex:none}
|
||||||
|
.item.target{color:var(--target);font-weight:600}
|
||||||
|
.badge{font-size:10px;background:var(--target);color:#1a1206;border-radius:4px;padding:1px 5px;margin-left:6px}
|
||||||
|
.drop{border:2px dashed var(--line);border-radius:12px;padding:40px;text-align:center;color:var(--dim);
|
||||||
|
cursor:pointer;margin-bottom:16px}
|
||||||
|
.drop.hover{border-color:var(--accent);color:var(--fg);background:var(--panel)}
|
||||||
|
.tv-controls{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-bottom:14px}
|
||||||
|
.tv-controls label{display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--dim)}
|
||||||
|
.heat{display:grid;gap:1px;margin-top:10px;overflow:auto}
|
||||||
|
.cell{padding:4px 2px;text-align:center;font-size:10px;border-radius:2px;color:#0a0a0a;font-family:ui-monospace,monospace}
|
||||||
|
.hexdump{font-family:ui-monospace,monospace;font-size:12px;color:var(--dim);white-space:pre;
|
||||||
|
background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;max-height:340px;margin-top:14px}
|
||||||
|
.hint{color:var(--dim);font-size:12px;margin-top:8px}
|
||||||
|
a{color:var(--accent)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>tunie</h1>
|
||||||
|
<span class="sub" id="meta"></span>
|
||||||
|
</header>
|
||||||
|
<nav>
|
||||||
|
<button data-tab="maps" class="active">Map catalogue</button>
|
||||||
|
<button data-tab="caps">Tune capabilities</button>
|
||||||
|
<button data-tab="table">Table viewer</button>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section id="tab-maps" class="tab active">
|
||||||
|
<div class="controls">
|
||||||
|
<input type="text" id="q" placeholder="search model, VIN, market…">
|
||||||
|
<select id="ecu"><option value="">All ECUs</option></select>
|
||||||
|
<select id="odo">
|
||||||
|
<option value="">Any odometer</option>
|
||||||
|
<option value="mechanical odometer">Mechanical odo</option>
|
||||||
|
<option value="lcd odometer">LCD odo</option>
|
||||||
|
</select>
|
||||||
|
<span class="chip" id="mineChip">★ My bike (Keihin · mechanical · Bonneville)</span>
|
||||||
|
<span class="count" id="mapCount"></span>
|
||||||
|
</div>
|
||||||
|
<table id="mapTable">
|
||||||
|
<thead><tr>
|
||||||
|
<th data-k="id">Map</th><th data-k="ecu">ECU</th><th data-k="model">Details</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-caps" class="tab">
|
||||||
|
<p class="hint">TuneECU's full editable surface, extracted from the APK. The
|
||||||
|
<span style="color:var(--target)">highlighted</span> devices are the SAI / O2 / exhaust
|
||||||
|
items this project plans to delete.</p>
|
||||||
|
<div class="grid-cards" id="caps"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-table" class="tab">
|
||||||
|
<div class="drop" id="drop">Drop a map file here (<span class="mono">.bin</span> or Intel <span class="mono">.hex</span>) — or click to choose.
|
||||||
|
<input type="file" id="file" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div id="tvBody" style="display:none">
|
||||||
|
<div class="tv-controls">
|
||||||
|
<label>Known table
|
||||||
|
<select id="known"><option value="">— pick / manual —</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Offset (hex)<input type="text" id="off" value="0"></label>
|
||||||
|
<label>Columns<input type="number" id="cols" value="16" min="1"></label>
|
||||||
|
<label>Rows<input type="number" id="rows" value="16" min="1"></label>
|
||||||
|
<label>Cell bytes
|
||||||
|
<select id="cell"><option value="1">1 (8-bit)</option><option value="2" selected>2 (16-bit)</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Endian
|
||||||
|
<select id="endian"><option value="be" selected>big</option><option value="le">little</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Scale ×<input type="text" id="scale" value="1"></label>
|
||||||
|
<label>Checksum off (hex)<input type="text" id="chkoff" value="" placeholder="e.g. d8048"></label>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<span class="chip" id="editChip">✎ Edit mode</span>
|
||||||
|
<button id="download" class="chip" style="border-color:var(--ok);color:var(--ok)">⭳ Download edited copy</button>
|
||||||
|
<span class="count" id="dirty"></span>
|
||||||
|
</div>
|
||||||
|
<div id="tvInfo" class="hint"></div>
|
||||||
|
<div id="heat" class="heat"></div>
|
||||||
|
<div id="hex" class="hexdump"></div>
|
||||||
|
</div>
|
||||||
|
<p class="hint"><b>Editing:</b> turn on ✎ Edit mode, click a cell, type a new value, Enter. The bytes
|
||||||
|
are written in place and — if a checksum offset is set — the running 16-bit checksum word is patched
|
||||||
|
by <span class="mono">(old − new)</span>, exactly as TuneECU does. Then Download the modified copy.
|
||||||
|
“Known table” lists the <span id="tgN">0</span> candidate offsets extracted from TuneECU; verify them
|
||||||
|
against a real ROM before trusting them.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script id="data" type="application/json">/*__DATA__*/</script>
|
||||||
|
<script>
|
||||||
|
const DATA = JSON.parse(document.getElementById('data').textContent);
|
||||||
|
document.getElementById('meta').textContent =
|
||||||
|
`${DATA.meta.mapCount} maps · ${DATA.meta.source}`;
|
||||||
|
|
||||||
|
// ---- tabs ----
|
||||||
|
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>{
|
||||||
|
document.querySelectorAll('nav button').forEach(x=>x.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
|
||||||
|
b.classList.add('active');
|
||||||
|
document.getElementById('tab-'+b.dataset.tab).classList.add('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- maps ----
|
||||||
|
const maps = DATA.maps;
|
||||||
|
const ecuSel = document.getElementById('ecu');
|
||||||
|
[...new Set(maps.map(m=>m.ecu))].sort().forEach(e=>{
|
||||||
|
const o=document.createElement('option'); o.value=e;
|
||||||
|
o.textContent='Type '+e+(e==='0'?' (Triumph Keihin)':e==='1'?' (Sagem)':e==='P'?' (Bosch)':'');
|
||||||
|
ecuSel.appendChild(o);
|
||||||
|
});
|
||||||
|
let mineOnly=false;
|
||||||
|
const q=document.getElementById('q'), odo=document.getElementById('odo'),
|
||||||
|
tbody=document.querySelector('#mapTable tbody'), mapCount=document.getElementById('mapCount');
|
||||||
|
document.getElementById('mineChip').onclick=e=>{mineOnly=!mineOnly;e.target.classList.toggle('on',mineOnly);render();};
|
||||||
|
[q,ecuSel,odo].forEach(el=>el.oninput=render);
|
||||||
|
|
||||||
|
function isMine(m){
|
||||||
|
const b=m.description.join(' ').toLowerCase();
|
||||||
|
return m.ecu==='0' && b.includes('mechanical odometer') && b.includes('bonneville');
|
||||||
|
}
|
||||||
|
function render(){
|
||||||
|
const needle=q.value.toLowerCase(), e=ecuSel.value, od=odo.value;
|
||||||
|
let rows=maps.filter(m=>{
|
||||||
|
const b=m.description.join(' ').toLowerCase();
|
||||||
|
if(needle && !b.includes(needle) && !m.id.includes(needle)) return false;
|
||||||
|
if(e && m.ecu!==e) return false;
|
||||||
|
if(od && !b.includes(od)) return false;
|
||||||
|
if(mineOnly && !isMine(m)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
tbody.innerHTML='';
|
||||||
|
rows.slice(0,600).forEach(m=>{
|
||||||
|
const tr=document.createElement('tr');
|
||||||
|
if(isMine(m)) tr.className='mine';
|
||||||
|
tr.innerHTML=`<td class="id mono">${m.id}</td><td class="mono">${m.ecu}</td>`+
|
||||||
|
`<td><b>${m.description[0]||''}</b><div class="desc">${m.description.slice(1).join('\n')}</div></td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
mapCount.textContent=`${rows.length} match${rows.length===1?'':'es'}`+
|
||||||
|
(rows.length>600?' (showing 600)':'');
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
|
||||||
|
// ---- capabilities ----
|
||||||
|
const targets=new Set(DATA.modTargets);
|
||||||
|
const capWrap=document.getElementById('caps');
|
||||||
|
Object.values(DATA.definitions).forEach(d=>{
|
||||||
|
if(!d.items.length) return;
|
||||||
|
const c=document.createElement('div'); c.className='card';
|
||||||
|
c.innerHTML=`<h3>${d.label}</h3><div class="note">${d.note}</div>`;
|
||||||
|
d.items.forEach(it=>{
|
||||||
|
const isT=targets.has(it.text);
|
||||||
|
const row=document.createElement('div'); row.className='item'+(isT?' target':'');
|
||||||
|
row.innerHTML=`<span class="n">${it.i}</span><span>${it.text}${isT?'<span class="badge">mod target</span>':''}</span>`;
|
||||||
|
c.appendChild(row);
|
||||||
|
});
|
||||||
|
capWrap.appendChild(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- table viewer ----
|
||||||
|
let BYTES=null;
|
||||||
|
const drop=document.getElementById('drop'), fileIn=document.getElementById('file');
|
||||||
|
drop.onclick=()=>fileIn.click();
|
||||||
|
drop.ondragover=e=>{e.preventDefault();drop.classList.add('hover');};
|
||||||
|
drop.ondragleave=()=>drop.classList.remove('hover');
|
||||||
|
drop.ondrop=e=>{e.preventDefault();drop.classList.remove('hover');load(e.dataTransfer.files[0]);};
|
||||||
|
fileIn.onchange=e=>load(e.target.files[0]);
|
||||||
|
|
||||||
|
function load(f){
|
||||||
|
if(!f) return;
|
||||||
|
FNAME=f.name;
|
||||||
|
const r=new FileReader();
|
||||||
|
r.onload=()=>{
|
||||||
|
let buf=new Uint8Array(r.result);
|
||||||
|
if(f.name.toLowerCase().endsWith('.hex')) buf=parseIntelHex(new TextDecoder().decode(buf));
|
||||||
|
BYTES=buf;
|
||||||
|
drop.textContent=`${f.name} — ${BYTES.length.toLocaleString()} bytes loaded`;
|
||||||
|
document.getElementById('tvBody').style.display='block';
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
r.readAsArrayBuffer(f);
|
||||||
|
}
|
||||||
|
function parseIntelHex(txt){
|
||||||
|
const out=[]; let base=0;
|
||||||
|
txt.split(/\r?\n/).forEach(l=>{
|
||||||
|
if(l[0]!==':') return;
|
||||||
|
const len=parseInt(l.substr(1,2),16), addr=parseInt(l.substr(3,4),16), type=parseInt(l.substr(7,2),16);
|
||||||
|
if(type===0x04){ base=parseInt(l.substr(9,4),16)<<16; }
|
||||||
|
else if(type===0x00){
|
||||||
|
for(let i=0;i<len;i++){ out[base+addr+i]=parseInt(l.substr(9+i*2,2),16); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const max=out.length; const b=new Uint8Array(max);
|
||||||
|
for(let i=0;i<max;i++) b[i]=out[i]||0;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
// ---- known-table picklist from extracted geometry ----
|
||||||
|
let EDIT=false, DIRTY=0, FNAME='map.bin';
|
||||||
|
const known=document.getElementById('known');
|
||||||
|
const allTables=[];
|
||||||
|
(DATA.geometry||[]).forEach(g=>g.tables.forEach(t=>allTables.push({...t,group:g.group})));
|
||||||
|
document.getElementById('tgN').textContent=allTables.length;
|
||||||
|
// de-dup by offset for a saner list
|
||||||
|
const seen=new Set();
|
||||||
|
allTables.filter(t=>!seen.has(t.offset)&&seen.add(t.offset))
|
||||||
|
.sort((a,b)=>a.offset-b.offset).forEach(t=>{
|
||||||
|
const o=document.createElement('option');
|
||||||
|
o.value=t.offset; o.textContent=`${t.offset_hex} · ${t.length} bytes (grp ${t.group})`;
|
||||||
|
known.appendChild(o);
|
||||||
|
});
|
||||||
|
known.onchange=()=>{
|
||||||
|
if(!known.value) return;
|
||||||
|
const t=allTables.find(x=>x.offset==known.value);
|
||||||
|
document.getElementById('off').value=(+known.value).toString(16);
|
||||||
|
// guess a square-ish 16-bit layout from the byte length
|
||||||
|
const cells=Math.floor(t.length/2), c=Math.min(16,cells)||1;
|
||||||
|
document.getElementById('cols').value=c;
|
||||||
|
document.getElementById('rows').value=Math.max(1,Math.floor(cells/c));
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('editChip').onclick=e=>{EDIT=!EDIT;e.target.classList.toggle('on',EDIT);draw();};
|
||||||
|
document.getElementById('download').onclick=()=>{
|
||||||
|
if(!BYTES) return;
|
||||||
|
const blob=new Blob([BYTES],{type:'application/octet-stream'});
|
||||||
|
const a=document.createElement('a');
|
||||||
|
a.href=URL.createObjectURL(blob); a.download=FNAME.replace(/(\.\w+)?$/,'.edited$1');
|
||||||
|
a.click(); URL.revokeObjectURL(a.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
['off','cols','rows','cell','endian','scale','chkoff'].forEach(id=>document.getElementById(id).oninput=draw);
|
||||||
|
|
||||||
|
function readCell(p,cw,be){ return cw===1 ? BYTES[p] : (be?(BYTES[p]<<8)|BYTES[p+1]:(BYTES[p+1]<<8)|BYTES[p]); }
|
||||||
|
function writeCell(p,cw,be,v){
|
||||||
|
v=Math.max(0,Math.round(v)) & (cw===1?0xff:0xffff);
|
||||||
|
const old=readCell(p,cw,be);
|
||||||
|
if(cw===1){ BYTES[p]=v; }
|
||||||
|
else if(be){ BYTES[p]=v>>8; BYTES[p+1]=v&0xff; } else { BYTES[p+1]=v>>8; BYTES[p]=v&0xff; }
|
||||||
|
// patch the running 16-bit checksum word, TuneECU-style: chk += (old - new)
|
||||||
|
const co=parseInt(document.getElementById('chkoff').value,16);
|
||||||
|
if(!isNaN(co) && cw===2 && co!==p){
|
||||||
|
let chk=(BYTES[co]<<8)|BYTES[co+1];
|
||||||
|
chk=(chk + old - v) & 0xffff;
|
||||||
|
BYTES[co]=chk>>8; BYTES[co+1]=chk&0xff;
|
||||||
|
}
|
||||||
|
DIRTY++; document.getElementById('dirty').textContent=DIRTY+' edit'+(DIRTY===1?'':'s');
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(){
|
||||||
|
if(!BYTES) return;
|
||||||
|
const off=parseInt(document.getElementById('off').value,16)||0;
|
||||||
|
const cols=+document.getElementById('cols').value, rows=+document.getElementById('rows').value;
|
||||||
|
const cw=+document.getElementById('cell').value, be=document.getElementById('endian').value==='be';
|
||||||
|
const scale=parseFloat(document.getElementById('scale').value)||1;
|
||||||
|
const vals=[]; let p=off;
|
||||||
|
for(let i=0;i<cols*rows;i++){ vals.push(readCell(p,cw,be)*scale); p+=cw; }
|
||||||
|
const mn=Math.min(...vals), mx=Math.max(...vals), span=mx-mn||1;
|
||||||
|
const heat=document.getElementById('heat');
|
||||||
|
heat.style.gridTemplateColumns=`repeat(${cols},1fr)`;
|
||||||
|
heat.innerHTML='';
|
||||||
|
vals.forEach((v,i)=>{
|
||||||
|
const t=(v-mn)/span, hue=(1-t)*220;
|
||||||
|
const cell=document.createElement('div'); cell.className='cell';
|
||||||
|
cell.style.background=`hsl(${hue},75%,${35+t*25}%)`;
|
||||||
|
cell.textContent = Number.isInteger(v)?v:v.toFixed(1);
|
||||||
|
if(EDIT){
|
||||||
|
cell.style.cursor='cell'; cell.style.outline='1px solid rgba(255,255,255,.15)';
|
||||||
|
cell.onclick=()=>{
|
||||||
|
const raw=prompt(`New value (scaled ×${scale}) for cell r${Math.floor(i/cols)} c${i%cols}:`, v);
|
||||||
|
if(raw===null) return;
|
||||||
|
const nv=parseFloat(raw); if(isNaN(nv)) return;
|
||||||
|
writeCell(off+i*cw, cw, be, nv/scale);
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
heat.appendChild(cell);
|
||||||
|
});
|
||||||
|
document.getElementById('tvInfo').textContent=
|
||||||
|
`range ${mn.toLocaleString()} … ${mx.toLocaleString()} over ${cols}×${rows} cells from 0x${off.toString(16)}`+
|
||||||
|
(EDIT?' · edit mode ON — click a cell':'');
|
||||||
|
let dump=''; const start=off & ~0xf;
|
||||||
|
for(let a=start;a<start+(rows*cols*cw)+16 && a<BYTES.length; a+=16){
|
||||||
|
let hex='',asc='';
|
||||||
|
for(let i=0;i<16;i++){ const b=BYTES[a+i]; if(b===undefined)break;
|
||||||
|
hex+=b.toString(16).padStart(2,'0')+' '; asc+=(b>=32&&b<127)?String.fromCharCode(b):'.'; }
|
||||||
|
dump+=a.toString(16).padStart(6,'0')+' '+hex.padEnd(48)+' '+asc+'\n';
|
||||||
|
}
|
||||||
|
document.getElementById('hex').textContent=dump;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
346
tunie/viewer/tunie-viewer.html
Normal file
346
tunie/viewer/tunie-viewer.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user