Files
samplez/tunie/docs/CONTEXT.md
uhryniuk 4debdfa518 Add tunie/docs (full context + open-source roadmap); refresh README
docs/CONTEXT.md captures the entire reverse-engineering effort in one place:
KWP2000 protocol, AES-128 seed/key, the map format (dc decrypt -> flat-ROM
unpack -> directory -> fe table pointers), the fuel/ignition table map, the
validated SAI/O2 device flags, checksums, hardware, and a per-finding confidence
table.

docs/ROADMAP.md covers open-sourcing (legal/IP posture on proprietary maps and
the AES keys, repo hygiene, packaging/CI, community) and the technical path to
tuning the bike (first contact -> ROM dump -> calibration model -> write path ->
editor), with immediate next steps and a risk register.

Top-level README refreshed to describe the whole project (tool + viewer +
research) and point at docs/.
2026-08-11 08:21:16 -05:00

202 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# tunie — full technical context
Everything we've reverse-engineered and built for an open-source Triumph Keihin
ECU toolchain. This is the pick-it-up-cold reference; if you read one file, read
this one. Companion docs: [ROADMAP.md](ROADMAP.md) (open-source + next steps),
and the deeper per-topic writeups linked throughout.
---
## 1. The target
- **Bike:** 2010 Triumph Bonneville T100, 865cc air-cooled parallel twin,
**mechanical/analog odometer** variant.
- **ECU:** Keihin, on a Renesas SH7054 (32-bit SuperH RISC). ~384 KB internal
flash (corroborated: the app has a 393216-byte code path; npkern lists SH7054).
- **Goal:** an open, offline toolchain to read, view, edit, and (eventually)
flash the calibration — replacing the closed TuneECU app.
- **Owner's end state for the bike:** 2-1 exhaust, airbox removal, O2 delete, SAI
delete. This matches the community map `20188Map2009AIRBOXBONNY` almost exactly.
## 2. What exists today
| Piece | Path | State |
|---|---|---|
| Read-only KWP2000 diagnostic tool | `src/tunie/` | built, unit-tested; **not yet run on the bike** |
| Map catalogue (1811 maps) | `maps.json` | extracted from the APK |
| Seed/key (AES-128) reference | `research/keihin_seedkey.py` | reversed + FIPS-verified |
| Map decrypt / unpack / table map | `research/reference-maps/` | reversed + validated on real maps |
| Self-contained viewer/editor | `viewer/tunie-viewer.html` | renders real fuel/ignition tables + device toggles |
Nothing has electrically touched the ECU yet. Everything is software + RE against
downloaded map files.
## 3. KWP2000 diagnostic protocol (read path)
Recovered from the TuneECU APK (`m.java`, `MainActivity.java`), validated
byte-for-byte in `tests/verify_protocol.py`:
- **ECU address `0xD5`**, **tester `0xF5`** on K-Line (NOT the common `0xF1`).
CAN-era Triumphs use `0xF1`; K-line uses `0xF5`.
- **Framing:** format byte `0x80 | length`; long form (`0x80` + separate length
byte) when payload > 120 B; **additive mod-256 checksum**.
- **Init:** fast init (`ATTP5`) or 5-baud (`ATTP4` + `ATIIAD5`), address `0xD5`.
- **ECU type char:** `0` = Keihin, `1` = Sagem, `P` = Bosch (from `ecu` array).
- Reference frames: `81 d5 f5 81 cc` (StartComms), `82 d5 f5 1a 80 e6`
(ReadEcuIdentification), `84 d5 f5 18 00 ff 00 65` (ReadDtcByStatus).
The tool (`src/tunie/`) is **read-only by construction**: `safety.assert_read_only`
runs on every outbound request and refuses all write/flash services (0x27, 0x31,
0x34/0x36, 0x35, 0x37, 0x14, 0x11, 0x2E) and programming sessions. A bug can't
brick the ECU because the write path isn't implemented. Details: top-level
`README.md`, `STATUS.md`.
## 4. Security Access seed/key — AES-128
`m.java` `Vb()`. **Standard AES-128**, not the Honda XOR scheme the early brief
guessed. Verified: all 256 T-table entries match AES Te0; the port passes the
FIPS-197 known-answer test. `Vb()` AES-encrypts a 128-bit seed with one of three
embedded keys (selected by an ECU-code index) and returns the first ciphertext
word as the 4-byte key. Reference + keys: `research/keihin_seedkey.py`,
`research/FINDINGS.md`. This is **write-path** material, kept out of the read-only
tool.
Still open (framing, not crypto): which of the 3 keys for this ECU, and the exact
seed-block padding. One captured seed/key pair resolves both.
## 5. Map file format (this was the big reverse)
The full chain, all reversed from `l.java`/`MainActivity.java` and validated
against real stock maps. See `viewer/FORMAT.md` and
`research/reference-maps/README.md`.
### 5a. Download & decryption
- Maps download from `https://www.tuneecu.fr/Maps/<path>` (e.g.
`Triumph/Bonneville/20187Map.hex`), ~300 KB, **encrypted** (entropy ~7.99).
- Decryptor is `l.dc()`: a **self-synchronising CBC-style XOR stream cipher**
seeded by the plaintext 4-byte header. Bytes 0–3 stay plaintext (the magic);
from byte 4: `plain = prev_cipher ^ cipher ^ ((key>>((i%4)*8))&0xFF)`, where
`key = i2 | le32(header)` and `i2` is a constant from `header[3]-24`.
Reversed + round-trip-verified in `research/reference-maps/decode_map.py`.
- Header magic: `le32(bytes[0:4]) & 0xFF00FFE0 == 0x18001360`, i.e.
`byte0 ∈ {0x67,0x68,0x69}` (family), `byte1==0x13`, `byte3==0x18`.
### 5b. Unpack to the flat ROM
The decoded map is **packed** — which is why a naive diff of two decoded maps
showed 96% difference (misaligned chunks). Each decoded map carries an **unpack
directory** (base `le16(dec[28])`, a `0x6F66` marker, a count, then
`(dest_offset, length)` entries) that scatters chunks into a **384 KB (0x60000)
flat ROM** = the real ECU address space. In the flat ROM, production (20187) vs
aftermarket (20188) differ only **1.4%** — the real fuel enrichment.
Reversed in `research/reference-maps/reconstruct_rom.py`.
### 5c. Directory lookup → calibration metadata
Decoded signature at offset 20 → `s.a` directory record → `Qd = field[1]` →
`fe = c.a[Qd*48]` (the 48-int per-calibration metadata that holds all the table
pointers). Validated: sig `0x0187CA84` → `s.a` #541 → Qd 32.
## 6. Calibration tables
`research/reference-maps/TABLES.md` + `table_map.py`. Base `= (fe[0]&0x2F0)<<12 =
0x50000`; **table offset = base + fe[k]**. Tables are **32 RPM rows × 20 throttle
cols, 16-bit big-endian**.
| Table | fe | Offset (20187) | Confidence |
|---|---|---|---|
| Main fuel cyl 1 / 2 | 11 / 12 | 0x56990 / 0x56E90 | high (75/67% aftermarket diff, smooth VE) |
| Low-throttle fuel cyl 1 / 2 | 15 / 16 | 0x55590 / 0x55A90 | high |
| Fuel base/idle | 9 | 0x55500 | high |
| Ignition advance gear 1 / 2–5 / 6 / neutral | 19–22 | 0x587D0 … 0x596D0 | high |
| AFR / target lambda | 2 | 0x52260 | medium (128 = λ1.00 confirmed) |
Axes: RPM = `fe[8]` (32 breakpoints, real RPM); throttle = `fe[27]` (20, 0–100%×10).
**Validated end-to-end:** the main fuel table renders as a clean VE surface, and
the aftermarket map is richer in 284/320 cells — physically correct enrichment.
**Scaling** (relative changes are exact; absolute units still approximate): fuel
values are the ECU's internal VE/fuel-mass units (~0–10800); ignition 13–600 is an
internal advance unit; AFR 128 = λ1.00. Exact fuel/ignition constants: TBD (an XDF
cross-check or more RE).
## 7. Device-enable flags (SAI / O2)
`research/reference-maps/DEVICES.md`. Byte-boolean array at `base + fe[33]`
(=`0x53801`), 1=on/0=off. Validated against the real NO-SAI-NO-O2 map
`20188Map2009AIRBOXBONNY`, which cleared exactly three bytes:
| Offset | Device | Confidence |
|---|---|---|
| `0x53801` | **SAI** | ~90% (code `lc()` → Devices[0] via fe[33], **and** the delete map) |
| `0x53818` / `0x53819` | **O2 sensor 1 / 2** | ~80% (two O2 sensors ↔ two adjacent bytes; not individually isolated) |
**Critical caveat:** correct flags ≠ a good tune. Disabling O2 forces open-loop —
the bike runs on the base fuel map with no live trim; on a stock map that can run
lean/rough. A proper delete pairs flags-off with fuel enrichment (which is why the
reference map changed the fuel tables too). **Flash a complete matching delete map,
not hand-toggled flags on a stock one.** The owner's target = the AIRBOXBONNY map
(SAI/O2 off + airbox fueling done); only the 2-1-vs-2-2 midrange fuel needs
fine-tuning.
## 8. Checksums
Two distinct ones:
- **Table write-back (edit) checksum:** a running 16-bit word, `chk = (chk + old -
new) & 0xFFFF`, used when a cell changes. Location is per-calibration.
- **Distribution `.hex` integrity:** the `dc` cipher + unpack directory. The `caXX`
header/tail bytes are **map-ID metadata, not a cal checksum**, so re-encoding an
edit is complete for the file format.
- **ECU flash checksum:** applied by the flashing tool at write time — out of scope
for the read/edit tool, part of the write path.
## 9. Hardware / cable
Owner has a **VAG KKL 409.1** cable (right class — K-Line) and a Bluetooth
ELM327. The VAG cable is wired for a VW OBD-II socket; the 2010 Bonneville uses
Triumph's proprietary diagnostic connector, so it needs an adapter/re-pin
(K-Line + switched 12V + ground). **Confirm the connector pinout before plugging
in — that's the one genuine electrical risk.** Identify the chip first
(`tunie ports`: FTDI = `/dev/cu.usbserial-*`; CH340 = `/dev/cu.wchusbserial*`).
## 10. The viewer
`viewer/tunie-viewer.html` — one self-contained file, no server, no external refs.
Tabs: Map catalogue (1811, filterable, "my bike" filter) · Tune capabilities
(devices/params/sensors from the APK) · **Triumph tables** (drop a real `.hex` →
decode → unpack → render real fuel/ignition tables with axes + A→B diff + device
checkboxes) · Raw table viewer. Build with `viewer/build_viewer.py`
(`--tuneecu-src` enables the Triumph tab by embedding the `c.a`/`s.a` directory).
`download_maps.py` + `serve.py` add a catalogue dropdown of locally cached maps.
## 11. Validation status & confidence (honest)
| Finding | Confidence | How validated |
|---|---|---|
| KWP2000 addressing/framing | high | byte-identical to TuneECU |
| Seed/key = AES-128 | high | FIPS-197 + T-table match |
| Map decrypt (`dc`) | high | round-trip + readable strings on real maps |
| Flat-ROM unpack | high | sibling diff 96%→1.4%, directory lookup resolves |
| Fuel/ignition table offsets | high | render as real tables; enrichment physically correct |
| Table scaling (absolute units) | low–med | relative exact; absolute TBD |
| SAI flag (0x53801) | high (~90%) | code + real delete map |
| O2 flags (0x53818/9) | probable (~80%) | group-validated, not isolated |
| Anything on the actual bike | **none yet** | no hardware connected |
## 12. What is NOT done
- No comms with the real ECU (blocked on the cable adapter/pinout).
- No ROM dump from the bike.
- No write/flash path (seed/key which-key + padding, flash kernel, ECU checksum).
- Editor exports the distribution `.hex` but re-packing an edited **flat ROM** back
through the unpack directory + `.hex` for arbitrary edits is only partly wired
(device flags work because they live in one chunk).
- Absolute fuel/ignition scaling constants.
- Individual SAI-vs-O2 isolation and the other device flags (exhaust valve, etc.).
## 13. Key source references (in the decompile)
`l.java`: `dc()` decrypt, `zc()`/`Nc()`/`Lb()` map load + tables, `sc()` directory,
`lc()` device list. `m.java`: `Vb()` seed/key, KWP framing (`Ld`/`Ub`), `uc()`
ROM packer. `MainActivity.java`: `p8()` load pipeline, unpack directory build,
`a3`/`W2` init sequences, Maps/ URLs. `c/r/s/t.java`: directory + geometry arrays.
`x.java` `a()`: per-ECU device layout (`x.t/x.s/x.q`) — not fully traced.