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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -177,3 +177,9 @@ cython_debug/
|
|||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# proprietary TuneECU/Triumph map binaries — do not redistribute
|
||||||
|
*.hex
|
||||||
|
*.dec.bin
|
||||||
|
maps_cache/
|
||||||
|
tunie/**/table_map.json
|
||||||
|
|||||||
99
tunie/research/reference-maps/README.md
Normal file
99
tunie/research/reference-maps/README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Reference stock maps (real, from TuneECU's server)
|
||||||
|
|
||||||
|
Four genuine factory Bonneville calibrations, downloaded from
|
||||||
|
`https://www.tuneecu.fr/Maps/Triumph/Bonneville/<n>Map.hex` (the endpoint the
|
||||||
|
TuneECU app itself uses; found in the decompile at `MainActivity.java:7407`).
|
||||||
|
|
||||||
|
| File | Map | Fits |
|
||||||
|
|---|---|---|
|
||||||
|
| `20187Map.hex` | 20187 | Bonneville, **production** silencers, mechanical odo |
|
||||||
|
| `20188Map.hex` | 20188 | Bonneville, **aftermarket** silencers, mechanical odo |
|
||||||
|
| `20191Map.hex` | 20191 | production, up to VIN 739050, E25 |
|
||||||
|
| `20192Map.hex` | 20192 | aftermarket, up to VIN 739050, E25 |
|
||||||
|
|
||||||
|
These are the candidate stock maps for the 2010 T100 (mechanical odometer). Once
|
||||||
|
we dump the bike, its ROM should correspond to one of these.
|
||||||
|
|
||||||
|
## What they validated
|
||||||
|
|
||||||
|
- **Header format is correct.** All four satisfy the reversed magic:
|
||||||
|
little-endian u32 of bytes[0:4] `& 0xFF00FFE0 == 0x18001360`, i.e.
|
||||||
|
`byte0=0x67, byte1=0x13, byte3=0x18`. This confirms the format reversing in
|
||||||
|
`../../viewer/FORMAT.md` and the little-endian correction.
|
||||||
|
- **`c.b` is the table directory.** `m.uc()` reassembles a map by copying tables
|
||||||
|
at the `c.b` (offset,length) pairs — independent confirmation of the geometry.
|
||||||
|
|
||||||
|
## What they revealed (the new blocker)
|
||||||
|
|
||||||
|
**The map body is encrypted.** Evidence:
|
||||||
|
|
||||||
|
- Uniform entropy ~7.91 bits/byte across the whole body (8.0 = random).
|
||||||
|
- Sibling maps that should differ only in fueling (20187 vs 20188) share just
|
||||||
|
**0.1%** of bytes, with no equal run ≥16 bytes.
|
||||||
|
- A low-entropy footer (last ~5 KB, `H≈3.0`) that holds structured key/signature
|
||||||
|
material — matching `p8()`, which reads its key from the file **tail**
|
||||||
|
(`length-42`, `length-35`, …), derives it via `m.Sb()`, and unpacks via
|
||||||
|
`m.uc()`.
|
||||||
|
|
||||||
|
So the table offsets in `mapdefs.json` describe the **decrypted** map, and can't
|
||||||
|
be validated against these files until the map decryption is reversed. The
|
||||||
|
signature lookup (`sc()` at header offset 20) does **not** match our clean `s.a`
|
||||||
|
directory for these files — consistent with the real directory key living in the
|
||||||
|
encrypted/footer region, not the header.
|
||||||
|
|
||||||
|
## DECRYPTION SOLVED (`decode_map.py`)
|
||||||
|
|
||||||
|
The distribution format is decrypted by `l.dc()` — a self-synchronising CBC-style
|
||||||
|
XOR stream cipher seeded by the (plaintext) 4-byte header. NOT AES; the earlier
|
||||||
|
`m.uc`/`m.Sb` path is for raw ROM dumps, not these downloads. `p8()` calls
|
||||||
|
`l.dc()` on any map that isn't a raw-ROM size.
|
||||||
|
|
||||||
|
Reversed, ported, and **verified**:
|
||||||
|
- `decode_map.py` decodes all four maps; encode(decode(x)) == x (round-trip).
|
||||||
|
- Decoded 20187 contains the plaintext strings `Bonneville` / `Production
|
||||||
|
silencers` / `Mechanical odometer` at 0x1E/0x29/0x3E — matching the catalogue.
|
||||||
|
- Entropy drops 7.99 → ~6.1 bit/byte.
|
||||||
|
- **Directory lookup now validates on the decoded map:** signature at offset 20
|
||||||
|
= `0x0187CA84`, which matches `s.a` record #541 (`field[0]=25676420`). So the
|
||||||
|
whole chain works: decode → signature@20 → `s.a` directory → `c.a`/`c.b`.
|
||||||
|
|
||||||
|
Decoded files: `*.dec.bin`.
|
||||||
|
|
||||||
|
## SOLVED: flat ROM reconstruction + table location (`reconstruct_rom.py`)
|
||||||
|
|
||||||
|
The decoded map is a **packed** container, which is why a naive byte-diff of two
|
||||||
|
decoded maps showed 96% difference — the packed chunks are misaligned. Each
|
||||||
|
decoded map carries its own **unpack directory** (from `MainActivity.p8`):
|
||||||
|
`base = le16(dec[28])`; a `0x6F66` marker at `base+31`; a count at `base+33`;
|
||||||
|
then `count` (le32 dest_offset, le32 length) entries, followed by the packed
|
||||||
|
data copied verbatim to `flat_rom[dest:dest+len]`. This yields a **384 KB
|
||||||
|
(0x60000) flat ROM** — the real ECU address space.
|
||||||
|
|
||||||
|
**In the flat ROM, 20187 (production) vs 20188 (aftermarket) differ by only
|
||||||
|
1.4%** (down from 96%), localised to 26 regions. That is the actual production→
|
||||||
|
aftermarket calibration change, not noise.
|
||||||
|
|
||||||
|
Table pointers are in `fe = c.a[Qd*48]` (Qd from the directory lookup). Table
|
||||||
|
address = `fe[39]` (base 0x50000) + `fe[k]`. **VERIFIED:** `fe[11]=0x6990` →
|
||||||
|
`0x56990` is a **main fuel table** — a smooth 20-wide VE surface, and the
|
||||||
|
aftermarket map is richer in 284/320 cells (mean +323), exactly as an
|
||||||
|
aftermarket-exhaust tune should be.
|
||||||
|
|
||||||
|
The two big high-entropy diff regions (`0x55599`, `0x56990`) are the two fuel
|
||||||
|
tables; the small isolated diffs (e.g. single bytes at `0x50C13`, `0x534AD`,
|
||||||
|
`0x59759`) are prime **SAI / O2 / lambda flag** candidates — now findable because
|
||||||
|
the flat-ROM diff is localised.
|
||||||
|
|
||||||
|
Still to finish: map the remaining `fe[k]` pointers to named tables (small-
|
||||||
|
throttle fuel, AFR, ignition-by-gear, idle, limiters) and confirm each table's
|
||||||
|
dimensions/axes/scaling from the `fe` metadata (or cross-check with an XDF). The
|
||||||
|
hard part — decrypt, reconstruct, locate — is done and validated.
|
||||||
|
|
||||||
|
## Provenance (sha256)
|
||||||
|
|
||||||
|
```
|
||||||
|
cd02cd2a…306c99 20187Map.hex
|
||||||
|
bed451a9…e1ebe4 20188Map.hex
|
||||||
|
7436b0f2…79ca3a6 20191Map.hex
|
||||||
|
f9b9c60d…ffa10f 20192Map.hex
|
||||||
|
```
|
||||||
61
tunie/research/reference-maps/TABLES.md
Normal file
61
tunie/research/reference-maps/TABLES.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Keihin SH7054 (Triumph 865 twin, mechanical odo) — table map
|
||||||
|
|
||||||
|
Reversed from `Nc()`/`Lb()` in `l.java` and validated against the stock reference
|
||||||
|
maps. All offsets are in the reconstructed **flat ROM** (0x60000). Compute per map:
|
||||||
|
|
||||||
|
```
|
||||||
|
fe = c.a[Qd*48] # Qd from the s.a directory lookup on the map signature
|
||||||
|
base = (fe[0] & 0x2F0) << 12 # = 0x50000 for the 865 twin family
|
||||||
|
offset(table) = base + fe[<field>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Main tables are **32 rows (RPM) × 20 cols (throttle), 16-bit big-endian**.
|
||||||
|
|
||||||
|
## Axes
|
||||||
|
|
||||||
|
| Axis | fe field | Entries | Values (20187) | Meaning |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| RPM (rows) | `fe[8]` | 32 | 0, 500, 900, 1000 … 10000 | engine speed, direct RPM |
|
||||||
|
| Throttle (cols) | `fe[27]` | 20 | 0, 10, 20 … 780, 1000 | throttle %, ÷10 (0–100.0%) |
|
||||||
|
|
||||||
|
## Tables (validated by production 20187 vs aftermarket 20188 diff)
|
||||||
|
|
||||||
|
| Table | fe | Offset (20187) | Value range | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Main fuel — cyl 1 | 11 | 0x56990 | 951–10480 | **strong**: 75% diff, smooth VE, aftermarket richer |
|
||||||
|
| Main fuel — cyl 2 | 12 | 0x56E90 | 951–10680 | **strong**: 67% diff |
|
||||||
|
| Low-throttle fuel — cyl 1 | 15 | 0x55590 | 0–10760 | 70% diff; starts at 0 (closed throttle) |
|
||||||
|
| Low-throttle fuel — cyl 2 | 16 | 0x55A90 | 0–10810 | 68% diff; cyl-1 pair |
|
||||||
|
| Fuel — base/idle | 9 | 0x55500 | 0–10760 | 61% diff; lower values |
|
||||||
|
| Ignition advance — gear 1 | 19 | 0x587D0 | 13–600 | 4 evenly-spaced (0x500) 20×32 tables |
|
||||||
|
| Ignition advance — gears 2–5 | 20 | 0x58CD0 | 13–600 | main operating map |
|
||||||
|
| Ignition advance — gear 6 | 21 | 0x591D0 | 13–600 | overdrive |
|
||||||
|
| Ignition advance — neutral | 22 | 0x596D0 | 60–600 | idle/free-rev |
|
||||||
|
|
||||||
|
## AFR / target lambda
|
||||||
|
|
||||||
|
`fe[2]` @ 0x52260 is an interleaved (value, breakpoint) curve where **128 = λ1.00
|
||||||
|
(stoich, 14.7 AFR)** — e.g. `… 128, 500, 128, 400, 128, 300 …`. The `128` cells are
|
||||||
|
the closed-loop lambda targets; disabling closed loop (O2 delete) lets you set
|
||||||
|
these richer. Exact dimensions still being confirmed.
|
||||||
|
|
||||||
|
## Scaling (confidence varies)
|
||||||
|
|
||||||
|
- **Fuel** values are VE/fuel-mass in the ECU's internal units (≈ 0–10800). Real
|
||||||
|
units (injector µs / VE %) need the ECU's fuel constant; relative changes are
|
||||||
|
exact, absolute scaling TBD.
|
||||||
|
- **Ignition** 13–600 is advance in an internal unit (candidate: value ÷ 10 = °BTDC,
|
||||||
|
giving ~1.3–60°; the low-RPM row 90→14→20 fits a retard-then-advance curve). TBD.
|
||||||
|
- **AFR** 128 = λ1.00 is solid (standard Keihin convention).
|
||||||
|
|
||||||
|
## SAI / O2 / lambda flags
|
||||||
|
|
||||||
|
Now findable via the localised flat-ROM diff (20187 vs 20188), which isolates small
|
||||||
|
single-byte changes (e.g. 0x50C13, 0x534AD, 0x59759) as prime toggle candidates.
|
||||||
|
Confirming which is SAI vs O2 needs either bench testing or the finer `Nc` device
|
||||||
|
logic — a follow-up.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Fuel and ignition table **locations** are validated end-to-end. Remaining: exact
|
||||||
|
scaling constants, AFR dimensions, and confirming the individual device flags.
|
||||||
81
tunie/research/reference-maps/decode_map.py
Normal file
81
tunie/research/reference-maps/decode_map.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Decrypt a TuneECU Triumph map file (the download / distribution format).
|
||||||
|
|
||||||
|
Reverse-engineered from com/tuneecu/l.java `dc()`, the routine p8() calls on any
|
||||||
|
map that isn't a raw ROM-dump size. It is a self-synchronising CBC-style XOR
|
||||||
|
stream cipher, seeded by the (unencrypted) 4-byte header:
|
||||||
|
|
||||||
|
i5 = header[3] - 24
|
||||||
|
i2 = {0,1: 0x80808080, 2: 0x84808081, 3: 0x87848284, 87: ...}[i5]
|
||||||
|
key32 = i2 | le_u32(header[0:4]) # header seeds the keystream
|
||||||
|
prev = 0
|
||||||
|
for i in range(4, len(buf)): # bytes 0..3 stay plaintext
|
||||||
|
c = buf[i]
|
||||||
|
ks = (key32 >> (((i-4) % 4) * 8)) & 0xFF
|
||||||
|
buf[i] = prev ^ c ^ ks # decode
|
||||||
|
prev = c # feedback = ciphertext
|
||||||
|
|
||||||
|
VERIFIED: decoding 20187Map.hex yields the plaintext strings "Bonneville",
|
||||||
|
"Production silencers", "Mechanical odometer" at offsets 0x1E/0x29/0x3E, matching
|
||||||
|
the map catalogue exactly. Entropy drops 7.99 -> ~6.1 bit/byte.
|
||||||
|
|
||||||
|
Encode is the same with feedback = the freshly written (cipher) byte; pass
|
||||||
|
encode=True.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 decode_map.py 20187Map.hex 20187.dec.bin
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# i2 constant selected by (header[3] - 24), from l.java dc().
|
||||||
|
_I2 = {0: 0x80808080, 1: 0x80808080, 2: 0x84808081, 3: 0x87848284}
|
||||||
|
|
||||||
|
|
||||||
|
def _i2_for(i5: int) -> int:
|
||||||
|
if i5 in _I2:
|
||||||
|
return _I2[i5]
|
||||||
|
if i5 == 87: # the ((i5+3)<<24)|((i5+1)<<8)|i5|((i5+2)<<16) branch
|
||||||
|
return ((i5 + 3) << 24) | ((i5 + 1) << 8) | i5 | ((i5 + 2) << 16)
|
||||||
|
raise ValueError(f"unhandled header[3]-24 = {i5}; add its i2 constant from l.java")
|
||||||
|
|
||||||
|
|
||||||
|
def transcode(buf: bytes, *, encode: bool = False) -> bytes:
|
||||||
|
b = bytearray(buf)
|
||||||
|
i5 = b[3] - 24
|
||||||
|
i2 = _i2_for(i5)
|
||||||
|
key32 = (i2 | (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24))) & 0xFFFFFFFF
|
||||||
|
prev = 0
|
||||||
|
for i in range(4, len(b)):
|
||||||
|
c = b[i]
|
||||||
|
ks = (key32 >> (((i - 4) % 4) * 8)) & 0xFF
|
||||||
|
out = prev ^ c ^ ks
|
||||||
|
b[i] = out
|
||||||
|
prev = out if encode else c
|
||||||
|
return bytes(b)
|
||||||
|
|
||||||
|
|
||||||
|
def decode(buf: bytes) -> bytes:
|
||||||
|
return transcode(buf, encode=False)
|
||||||
|
|
||||||
|
|
||||||
|
def encode(buf: bytes) -> bytes:
|
||||||
|
return transcode(buf, encode=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("usage: decode_map.py <in.hex> <out.bin>", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
raw = open(sys.argv[1], "rb").read()
|
||||||
|
dec = decode(raw)
|
||||||
|
open(sys.argv[2], "wb").write(dec)
|
||||||
|
# round-trip sanity: re-encoding must reproduce the original file
|
||||||
|
assert encode(dec) == raw, "round-trip failed"
|
||||||
|
strings = [
|
||||||
|
dec[o:o + n].decode("latin1")
|
||||||
|
for o, n in ((0x1E, 10), (0x29, 20), (0x3E, 19))
|
||||||
|
]
|
||||||
|
print(f"decoded {len(dec)} bytes, round-trip OK")
|
||||||
|
print("header strings:", " / ".join(s.strip() for s in strings))
|
||||||
82
tunie/research/reference-maps/reconstruct_rom.py
Normal file
82
tunie/research/reference-maps/reconstruct_rom.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Reconstruct the flat ECU ROM image from a decoded TuneECU map, and locate
|
||||||
|
calibration tables.
|
||||||
|
|
||||||
|
Chain (all reversed from the decompile, verified against real stock maps):
|
||||||
|
encrypted .hex --dc()--> decoded map --unpack directory--> flat 0x60000 ROM
|
||||||
|
|
||||||
|
The decoded map carries its own unpack directory (from MainActivity.p8):
|
||||||
|
desc_len = le16(dec[28]); base i21 = desc_len
|
||||||
|
marker le16(dec[i21+31]) == 0x6F66
|
||||||
|
count dec[i21+33]
|
||||||
|
entries count * (le32 dest_offset, le32 length) at i21+34
|
||||||
|
data packed chunks follow, copied verbatim to flat_rom[dest:dest+len]
|
||||||
|
|
||||||
|
Reconstructing into the flat ROM is what makes sibling maps comparable:
|
||||||
|
20187 (production) vs 20188 (aftermarket) diff drops from 96% (packed, misaligned)
|
||||||
|
to 1.4% (flat ROM) — the difference is real fuel enrichment, not noise.
|
||||||
|
|
||||||
|
Table pointers live in the calibration-metadata record fe = c.a[Qd*48], where Qd
|
||||||
|
comes from the directory lookup (s.a record for the map's signature). Table
|
||||||
|
address = fe[39] (base, 0x50000) + fe[k]. VERIFIED: fe[11]=0x6990 -> 0x56990 is a
|
||||||
|
main fuel table; 20188-minus-20187 there is uniformly positive (richer), exactly
|
||||||
|
as an aftermarket-exhaust tune should be.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from decode_map import decode
|
||||||
|
|
||||||
|
FLAT_MARKER = 0x6F66
|
||||||
|
|
||||||
|
|
||||||
|
def flat_rom(encrypted_or_decoded: bytes) -> bytes:
|
||||||
|
"""Return the reconstructed flat ROM. Accepts an encrypted .hex or a decoded map."""
|
||||||
|
d = encrypted_or_decoded
|
||||||
|
# Bytes 0..3 are plaintext in BOTH forms, so detect via the description block
|
||||||
|
# at offset 30 (readable ASCII once decoded).
|
||||||
|
if not all(c in (10, 13) or 32 <= c < 127 for c in d[30:45]):
|
||||||
|
d = decode(d)
|
||||||
|
le = lambda o, n: int.from_bytes(d[o:o + n], "little")
|
||||||
|
i21 = le(28, 2)
|
||||||
|
if le(i21 + 31, 2) != FLAT_MARKER:
|
||||||
|
raise ValueError(f"bad unpack marker 0x{le(i21+31,2):04X} (expected 0x6F66)")
|
||||||
|
count = d[i21 + 33]
|
||||||
|
entries = [(le(i21 + 34 + k * 8, 4), le(i21 + 38 + k * 8, 4)) for k in range(count)]
|
||||||
|
p = i21 + 34 + count * 8
|
||||||
|
rom = bytearray(b"\xff" * max(o + l for o, l in entries))
|
||||||
|
for off, ln in entries:
|
||||||
|
rom[off:off + ln] = d[p:p + ln]
|
||||||
|
p += ln
|
||||||
|
return bytes(rom)
|
||||||
|
|
||||||
|
|
||||||
|
def read_table_u16(rom: bytes, offset: int, cols: int, rows: int, be: bool = True) -> list[list[int]]:
|
||||||
|
fmt = ">H" if be else "<H"
|
||||||
|
return [
|
||||||
|
[struct.unpack_from(fmt, rom, offset + (r * cols + c) * 2)[0] for c in range(cols)]
|
||||||
|
for r in range(rows)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
here = Path(__file__).parent
|
||||||
|
roms = {m: flat_rom((here / f"{m}Map.hex").read_bytes()) for m in ("20187", "20188")}
|
||||||
|
a, b = roms["20187"], roms["20188"]
|
||||||
|
n = min(len(a), len(b))
|
||||||
|
diff = sum(1 for i in range(n) if a[i] != b[i])
|
||||||
|
print(f"flat ROM 0x{len(a):X}; 20187 vs 20188 differ {100*diff/n:.2f}% ({diff} bytes)")
|
||||||
|
|
||||||
|
# Main fuel table at 0x56990 (fe[39]=0x50000 + fe[11]=0x6990), 20 cols.
|
||||||
|
off, cols, rows = 0x56990, 20, 16
|
||||||
|
ta = read_table_u16(a, off, cols, rows)
|
||||||
|
tb = read_table_u16(b, off, cols, rows)
|
||||||
|
print(f"\nfuel table @0x{off:X} (20187 production), first {rows}x{cols}:")
|
||||||
|
for row in ta:
|
||||||
|
print(" " + " ".join(f"{v:5d}" for v in row))
|
||||||
|
deltas = [tb[r][c] - ta[r][c] for r in range(rows) for c in range(cols)]
|
||||||
|
pos = sum(1 for x in deltas if x > 0)
|
||||||
|
print(f"\naftermarket-minus-production: {pos}/{len(deltas)} cells richer "
|
||||||
|
f"(mean {sum(deltas)/len(deltas):+.0f}) -> enrichment, as expected")
|
||||||
131
tunie/research/reference-maps/table_map.py
Normal file
131
tunie/research/reference-maps/table_map.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""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")
|
||||||
@@ -13,11 +13,16 @@ TunerPro XDF encodes, but pulled straight out of the app.
|
|||||||
|
|
||||||
`zc(byte[])` validates and indexes a loaded map:
|
`zc(byte[])` validates and indexes a loaded map:
|
||||||
|
|
||||||
- **Magic:** the first 4 bytes, masked `& 0xFF00FF60`, must equal `0x18008060`.
|
- **Magic:** the first 4 bytes read as a **little-endian** u32, masked
|
||||||
- **Family byte:** `bArr[0]` selects the table directory —
|
`& 0xFF00FFE0`, must equal `0x18001360`. Equivalently, by byte:
|
||||||
`(bArr[0] & 0x7B) == 0x69` → `r.a`; `bArr[0] == 0x68` → `t.a`; else `s.a`.
|
`byte0 & 0xE0 == 0x60`, `byte1 == 0x13`, `byte3 == 0x18`. (`j5()` is
|
||||||
(`0x67`/`0x68`/`0x69` are the map "generation" markers.)
|
little-endian; big-endian does not satisfy the family bytes, so this is
|
||||||
- **Calibration signature:** 4 bytes at **offset 20** (big-endian). `sc()`
|
settled.)
|
||||||
|
- **Family byte:** `byte0` selects the table directory —
|
||||||
|
`(byte0 & 0x7B) == 0x69` → `r.a` (0x69); `byte0 == 0x68` → `t.a`; else `s.a`
|
||||||
|
(0x67). These are the map "generation" markers, all consistent with the magic.
|
||||||
|
- **Calibration signature:** 4 bytes at **offset 20**, read **big-endian** (note:
|
||||||
|
different endianness than the magic — this is how `sc()` reads it). `sc()`
|
||||||
searches the directory for the record whose `field[0]` equals this value.
|
searches the directory for the record whose `field[0]` equals this value.
|
||||||
|
|
||||||
## Directory → metadata → geometry
|
## Directory → metadata → geometry
|
||||||
@@ -46,6 +51,21 @@ 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,
|
rows/cols) and the `title_axis` labels (Throttle %, MAP hPa, RPM, Load %, Temp,
|
||||||
Gear). Cells are **16-bit big-endian** with per-table scaling.
|
Gear). Cells are **16-bit big-endian** with per-table scaling.
|
||||||
|
|
||||||
|
## The body is encrypted (verified against real maps)
|
||||||
|
|
||||||
|
Four real stock maps pulled from TuneECU's server
|
||||||
|
(`research/reference-maps/`) confirmed the **header** decode exactly — but their
|
||||||
|
bodies are **encrypted**: uniform ~7.91 bit/byte entropy, and two maps that
|
||||||
|
should differ only in fueling (20187 vs 20188) share just 0.1% of bytes. A
|
||||||
|
low-entropy footer (last ~5 KB) holds the key/signature material.
|
||||||
|
|
||||||
|
The loader reflects this: `zc()` copies 16 bytes at offset 8 into `Kd` (key/IV)
|
||||||
|
for `byte0=0x67` maps; `p8()` reads key bytes from the file **tail** and derives
|
||||||
|
a key via `m.Sb()`, then `m.uc()` unpacks using the `c.b` geometry. Likely
|
||||||
|
AES-128 (same machinery as the ECU seed/key). **Until this is reversed, the table
|
||||||
|
offsets below describe the *decrypted* map and can't be validated against a real
|
||||||
|
file.** The four reference maps are the ciphertext test vectors for that work.
|
||||||
|
|
||||||
## Editing / write-back (this is the whole trick)
|
## Editing / write-back (this is the whole trick)
|
||||||
|
|
||||||
TuneECU keeps a **running 16-bit checksum** at a calibration-specific offset and
|
TuneECU keeps a **running 16-bit checksum** at a calibration-specific offset and
|
||||||
|
|||||||
@@ -62,17 +62,62 @@ def _extract_arrays(arrays_xml: Path) -> dict:
|
|||||||
return out
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--arrays", required=True, type=Path)
|
ap.add_argument("--arrays", required=True, type=Path)
|
||||||
ap.add_argument("--maps", required=True, type=Path)
|
ap.add_argument("--maps", required=True, type=Path)
|
||||||
ap.add_argument("--template", required=True, type=Path)
|
ap.add_argument("--template", required=True, type=Path)
|
||||||
ap.add_argument("--out", 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()
|
args = ap.parse_args()
|
||||||
|
|
||||||
defs = _extract_arrays(args.arrays)
|
defs = _extract_arrays(args.arrays)
|
||||||
maps = json.loads(args.maps.read_text(encoding="utf-8"))
|
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.
|
# Optional: table geometry from extract_mapdefs.py, if it has been run.
|
||||||
mapdefs_path = args.out.parent / "mapdefs.json"
|
mapdefs_path = args.out.parent / "mapdefs.json"
|
||||||
geometry = []
|
geometry = []
|
||||||
@@ -83,6 +128,7 @@ def main() -> int:
|
|||||||
"definitions": defs,
|
"definitions": defs,
|
||||||
"maps": maps,
|
"maps": maps,
|
||||||
"geometry": geometry,
|
"geometry": geometry,
|
||||||
|
"romdefs": romdefs,
|
||||||
"modTargets": sorted(MOD_TARGETS),
|
"modTargets": sorted(MOD_TARGETS),
|
||||||
"meta": {
|
"meta": {
|
||||||
"mapCount": len(maps),
|
"mapCount": len(maps),
|
||||||
|
|||||||
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())
|
||||||
44
tunie/viewer/serve.py
Normal file
44
tunie/viewer/serve.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Serve the tunie viewer locally so the catalogue dropdown can fetch cached maps.
|
||||||
|
|
||||||
|
python3 serve.py # serves this folder at http://localhost:8000
|
||||||
|
python3 serve.py 9000 # custom port
|
||||||
|
|
||||||
|
Browsers block fetch() on file:// URLs, so the Triumph-tables catalogue dropdown
|
||||||
|
only works when the viewer is served over HTTP. Drag-and-drop works either way.
|
||||||
|
Run download_maps.py first to populate maps_cache/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http.server
|
||||||
|
import socketserver
|
||||||
|
import sys
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
|
||||||
|
if not (HERE / "maps_cache" / "index.json").exists():
|
||||||
|
print("note: maps_cache/index.json not found — run download_maps.py first "
|
||||||
|
"for the catalogue dropdown (drag-and-drop still works).")
|
||||||
|
|
||||||
|
handler = lambda *a, **k: http.server.SimpleHTTPRequestHandler(*a, directory=str(HERE), **k)
|
||||||
|
with socketserver.TCPServer(("127.0.0.1", port), handler) as httpd:
|
||||||
|
url = f"http://localhost:{port}/tunie-viewer.html"
|
||||||
|
print(f"serving {HERE} at {url}\nCtrl-C to stop.")
|
||||||
|
try:
|
||||||
|
webbrowser.open(url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nstopped.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -70,7 +70,8 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<button data-tab="maps" class="active">Map catalogue</button>
|
<button data-tab="maps" class="active">Map catalogue</button>
|
||||||
<button data-tab="caps">Tune capabilities</button>
|
<button data-tab="caps">Tune capabilities</button>
|
||||||
<button data-tab="table">Table viewer</button>
|
<button data-tab="triumph">Triumph tables</button>
|
||||||
|
<button data-tab="table">Raw table viewer</button>
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
@@ -101,6 +102,33 @@
|
|||||||
<div class="grid-cards" id="caps"></div>
|
<div class="grid-cards" id="caps"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="tab-triumph" class="tab">
|
||||||
|
<p class="hint">Drop a real TuneECU Triumph <span class="mono">.hex</span> map (e.g.
|
||||||
|
<span class="mono">20187Map.hex</span>). It's decoded, unpacked to the flat ROM, and its
|
||||||
|
fuel/ignition tables are rendered with real RPM/throttle axes — no XDF needed. Drop a
|
||||||
|
second map to see the difference (e.g. production vs aftermarket enrichment).</p>
|
||||||
|
<div class="controls" id="tcatRow" style="display:none">
|
||||||
|
<label style="color:var(--dim);font-size:12px">Catalogue A
|
||||||
|
<select id="tcatA"><option value="">— pick a downloaded map —</option></select></label>
|
||||||
|
<label style="color:var(--dim);font-size:12px">Catalogue B (diff)
|
||||||
|
<select id="tcatB"><option value="">— none —</option></select></label>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
||||||
|
<div class="drop" id="tdropA" style="flex:1;min-width:260px;padding:22px">Map A — drop <span class="mono">.hex</span>
|
||||||
|
<input type="file" id="tfileA" style="display:none"></div>
|
||||||
|
<div class="drop" id="tdropB" style="flex:1;min-width:260px;padding:22px">Map B (optional, for diff)
|
||||||
|
<input type="file" id="tfileB" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tBody" style="display:none">
|
||||||
|
<div class="controls">
|
||||||
|
<select id="tsel"></select>
|
||||||
|
<span class="chip" id="tdiffChip">Δ Show A→B difference</span>
|
||||||
|
<span class="count" id="tinfo"></span>
|
||||||
|
</div>
|
||||||
|
<div style="overflow:auto"><table id="tgrid" class="mono" style="font-size:11px"></table></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="tab-table" class="tab">
|
<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.
|
<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">
|
<input type="file" id="file" style="display:none">
|
||||||
@@ -211,7 +239,119 @@ Object.values(DATA.definitions).forEach(d=>{
|
|||||||
capWrap.appendChild(c);
|
capWrap.appendChild(c);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- table viewer ----
|
// ---- Triumph real-map tables ----
|
||||||
|
const RD = DATA.romdefs || null;
|
||||||
|
if(!RD){ document.querySelector('nav button[data-tab=triumph]').style.display='none'; }
|
||||||
|
else {
|
||||||
|
const I2={0:0x80808080,1:0x80808080,2:0x84808081,3:0x87848284};
|
||||||
|
function dcDecode(raw){
|
||||||
|
const b=new Uint8Array(raw); const i5=b[3]-24;
|
||||||
|
const i2 = (i5 in I2)?I2[i5] : ((i5+3)<<24)|((i5+1)<<8)|i5|((i5+2)<<16);
|
||||||
|
const key=((i2 | (b[0]|(b[1]<<8)|(b[2]<<16)|(b[3]<<24)))>>>0);
|
||||||
|
let prev=0;
|
||||||
|
for(let i=4;i<b.length;i++){ const c=b[i]; const ks=(key>>>(((i-4)%4)*8))&0xff; b[i]=(prev^c^ks)&0xff; prev=c; }
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
const le=(b,o,n)=>{let v=0;for(let i=0;i<n;i++)v|=b[o+i]<<(8*i);return v>>>0;};
|
||||||
|
const be16=(b,o)=>((b[o]<<8)|b[o+1]);
|
||||||
|
function flatRom(dec){
|
||||||
|
const i21=le(dec,28,2);
|
||||||
|
if(le(dec,i21+31,2)!==0x6F66) throw new Error('bad unpack marker');
|
||||||
|
const cnt=dec[i21+33]; const ents=[];
|
||||||
|
for(let k=0;k<cnt;k++) ents.push([le(dec,i21+34+k*8,4), le(dec,i21+38+k*8,4)]);
|
||||||
|
let sz=0; ents.forEach(([o,l])=>sz=Math.max(sz,o+l));
|
||||||
|
const rom=new Uint8Array(sz).fill(0xff); let p=i21+34+cnt*8;
|
||||||
|
ents.forEach(([o,l])=>{ rom.set(dec.subarray(p,p+l), o); p+=l; });
|
||||||
|
return rom;
|
||||||
|
}
|
||||||
|
function resolve(dec){
|
||||||
|
let sig=(dec[20]<<24|dec[21]<<16|dec[22]<<8|dec[23])>>>0;
|
||||||
|
if(dec[0]===0x67) sig=((sig&0xFFFF0000)|(((sig&0xFFFF)+dec[25])&0xFFFF))>>>0;
|
||||||
|
let qd=null;
|
||||||
|
for(let i=0;i<RD.sa.length/8;i++){ if((RD.sa[i*8]>>>0)===sig){ qd=RD.sa[i*8+1]; break; } }
|
||||||
|
if(qd===null) throw new Error('signature not in directory');
|
||||||
|
const fe=RD.ca.slice(qd*48, qd*48+48);
|
||||||
|
return {fe, base:(fe[0]&0x2F0)<<12, qd};
|
||||||
|
}
|
||||||
|
function readTable(rom, off){
|
||||||
|
const g=[]; for(let r=0;r<RD.rows;r++){ const row=[]; for(let c=0;c<RD.cols;c++) row.push(be16(rom, off+(r*RD.cols+c)*2)); g.push(row); } return g;
|
||||||
|
}
|
||||||
|
function loadMap(raw){
|
||||||
|
const dec=dcDecode(raw); const rom=flatRom(dec); const {fe,base}=resolve(dec);
|
||||||
|
const rpm=[],thr=[];
|
||||||
|
for(let i=0;i<RD.rows;i++) rpm.push(be16(rom, base+(fe[RD.rpmFe]&0x7FFFF)+i*2));
|
||||||
|
for(let i=0;i<RD.cols;i++) thr.push(be16(rom, base+(fe[RD.throttleFe]&0x7FFFF)+i*2));
|
||||||
|
const tables=RD.tables.map(t=>({...t, off:base+(fe[t.fe]&0x7FFFF)}));
|
||||||
|
return {rom, base, rpm, thr, tables, desc:new TextDecoder().decode(dec.subarray(30,30+le(dec,28,2)))};
|
||||||
|
}
|
||||||
|
|
||||||
|
let TA=null, TB=null, tdiff=false;
|
||||||
|
function wireDrop(dropId, fileId, setter){
|
||||||
|
const drop=document.getElementById(dropId), inp=document.getElementById(fileId);
|
||||||
|
drop.onclick=()=>inp.click();
|
||||||
|
drop.ondragover=e=>{e.preventDefault();drop.classList.add('hover');};
|
||||||
|
drop.ondragleave=()=>drop.classList.remove('hover');
|
||||||
|
const go=f=>{ if(!f)return; const r=new FileReader();
|
||||||
|
r.onload=()=>{ try{ setter(loadMap(r.result), f.name, drop); }catch(e){ drop.textContent=f.name+' — '+e.message; } };
|
||||||
|
r.readAsArrayBuffer(f); };
|
||||||
|
drop.ondrop=e=>{e.preventDefault();drop.classList.remove('hover');go(e.dataTransfer.files[0]);};
|
||||||
|
inp.onchange=e=>go(e.target.files[0]);
|
||||||
|
}
|
||||||
|
wireDrop('tdropA','tfileA',(m,name,drop)=>{ TA=m; drop.textContent=name+' — '+m.desc.split('\n')[0]; initTriumph(); });
|
||||||
|
wireDrop('tdropB','tfileB',(m,name,drop)=>{ TB=m; drop.textContent=name+' — '+m.desc.split('\n')[0]; renderT(); });
|
||||||
|
|
||||||
|
// Catalogue dropdowns (only when served over http, so fetch() works).
|
||||||
|
if(location.protocol.startsWith('http')){
|
||||||
|
fetch('maps_cache/index.json').then(r=>r.ok?r.json():null).then(list=>{
|
||||||
|
if(!list||!list.length) return;
|
||||||
|
document.getElementById('tcatRow').style.display='flex';
|
||||||
|
const fill=sel=>list.forEach(m=>{ const o=document.createElement('option');
|
||||||
|
o.value=m.file; o.textContent=`${m.id} — ${(m.desc[0]||'')}${m.desc[1]?' · '+m.desc[1]:''}`;
|
||||||
|
sel.appendChild(o); });
|
||||||
|
const A=document.getElementById('tcatA'), B=document.getElementById('tcatB'); fill(A); fill(B);
|
||||||
|
const pick=(sel,isB)=>{ if(!sel.value) return;
|
||||||
|
fetch('maps_cache/'+sel.value).then(r=>r.arrayBuffer()).then(buf=>{
|
||||||
|
const m=loadMap(buf);
|
||||||
|
if(isB){ TB=m; renderT(); } else { TA=m; initTriumph(); } }); };
|
||||||
|
A.onchange=()=>pick(A,false); B.onchange=()=>pick(B,true);
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTriumph(){
|
||||||
|
document.getElementById('tBody').style.display='block';
|
||||||
|
const sel=document.getElementById('tsel'); sel.innerHTML='';
|
||||||
|
TA.tables.forEach((t,i)=>{ const o=document.createElement('option'); o.value=i;
|
||||||
|
o.textContent=`${t.name} (0x${t.off.toString(16)})`; sel.appendChild(o); });
|
||||||
|
sel.onchange=renderT; renderT();
|
||||||
|
}
|
||||||
|
document.getElementById('tdiffChip').onclick=e=>{ tdiff=!tdiff; e.target.classList.toggle('on',tdiff); renderT(); };
|
||||||
|
|
||||||
|
function renderT(){
|
||||||
|
if(!TA) return;
|
||||||
|
const idx=+document.getElementById('tsel').value;
|
||||||
|
const t=TA.tables[idx]; const A=readTable(TA.rom,t.off);
|
||||||
|
const B=(tdiff&&TB)?readTable(TB.rom, TB.tables[idx].off):null;
|
||||||
|
let vals=[]; for(let r=0;r<RD.rows;r++)for(let c=0;c<RD.cols;c++) vals.push(B?B[r][c]-A[r][c]:A[r][c]);
|
||||||
|
const mn=Math.min(...vals), mx=Math.max(...vals), span=(mx-mn)||1;
|
||||||
|
const g=document.getElementById('tgrid');
|
||||||
|
let h='<tr><th style="position:sticky;left:0">RPM\TP%</th>';
|
||||||
|
TA.thr.forEach(v=>h+=`<th>${(v/10).toFixed(0)}</th>`); h+='</tr>';
|
||||||
|
for(let r=0;r<RD.rows;r++){
|
||||||
|
h+=`<tr><th style="position:sticky;left:0">${TA.rpm[r]}</th>`;
|
||||||
|
for(let c=0;c<RD.cols;c++){
|
||||||
|
const v=B?B[r][c]-A[r][c]:A[r][c]; const tt=(v-mn)/span; const hue=(1-tt)*220;
|
||||||
|
const txt=B?(v>0?'+'+v:v):v;
|
||||||
|
h+=`<td style="padding:2px 4px;background:hsl(${hue},70%,${30+tt*28}%);color:#0a0a0a">${txt}</td>`;
|
||||||
|
}
|
||||||
|
h+='</tr>';
|
||||||
|
}
|
||||||
|
g.innerHTML=h;
|
||||||
|
document.getElementById('tinfo').textContent = (B?`Δ (B−A) `:'')+
|
||||||
|
`${t.name} · ${RD.rows}×${RD.cols} · range ${mn}…${mx}`+(B&&!TB?'':'');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- raw table viewer ----
|
||||||
let BYTES=null;
|
let BYTES=null;
|
||||||
const drop=document.getElementById('drop'), fileIn=document.getElementById('file');
|
const drop=document.getElementById('drop'), fileIn=document.getElementById('file');
|
||||||
drop.onclick=()=>fileIn.click();
|
drop.onclick=()=>fileIn.click();
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user