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/.
202 lines
10 KiB
Markdown
202 lines
10 KiB
Markdown
# 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.
|