Commit tune maps and research so tunes are reachable from the phone

Removes the *.hex/maps_cache gitignore rule (explicit user call, reversing
the earlier no-redistribution stance) so the official TuneECU catalogue
maps, derived SAI/O2-delete composites, and the checksum/composition
tooling are actually available to pull up on a phone browser when using
the real TuneECU app. Also folds in tonight's KWP2000 fixes (TesterPresent
keep-alive, connect-failure cleanup, slow-init StartCommunication fix) and
the accumulated research docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FP2GaxS9HkUdL5sLBnjKje
This commit is contained in:
2026-08-27 01:34:05 -05:00
parent 587f75eab4
commit 4aa5da53d2
98 changed files with 4148 additions and 49 deletions

View File

@@ -0,0 +1,727 @@
# Community tuning knowledge — SAI/O2/exhaust, 865 twin
External research (Aug 2026), collected while deciding how to fuel-map the
2010 T100 for an Arrow 2-in-1 system. This is **secondhand community/vendor
information**, not independently verified against our own reversing — cross-
check against `reference-maps/DEVICES.md` and `TABLES.md` before trusting it
over what we've actually decoded.
## The standard mod order
Community consensus (multiple sources) for the air-cooled 865 twin:
1. O2 sensor removal
2. SAI (secondary air injection) elimination
3. Airbox baffle/snorkel removal
4. Free-flowing exhaust
5. Re-tune to match
"Once you've made these mods, you want to tune your engine to take full
advantage of them" — each step changes what the engine ingests/exhausts, so
tuning last is deliberate, not incidental.
[triumphbonneville.org: EFI Tuning – Triumph Twin Power](https://triumphbonneville.org/efi-tuning-triumph-twin-power/)
## IMPORTANT correction to our own assumption: what the TuneECU checkbox actually does
Multiple independent forum threads describe the SAI/O2 checkboxes in TuneECU's
own Map Edit → Parameters → Devices screen (the same UI our `viewer/` mimics,
and the same bytes we found at `0x53801`/`0x53818`/`0x53819`) as **only
suppressing the fault-code/CEL logic for a missing sensor — not a functional
open-loop switch**:
> "This does not disable either system though, only stops the CEL appearing."
> "Simply unchecking the box in TuneECU will only prevent warning lights from
> appearing — it won't actually disable the hardware itself. The sensor will
> still function unless physically removed or blocked off."
[triumphrat.net: Desactivate the O2 sensor on TuneECU](https://www.triumphrat.net/threads/desactivate-the-o2-sensor-on-tuneecu.200858/),
[thespeedtriple.com: How to turn off O2 sensor on TuneECU](https://www.thespeedtriple.com/threads/how-to-turn-off-o2-sensor-on-tuneecu-to-eliminate-check-engine-light.39850/)
**This needs reconciling with `DEVICES.md`**, which currently states flipping
these bytes "forces the ECU open-loop." The two claims aren't necessarily
contradictory — `DEVICES.md` also notes the real NO-SAI/NO-O2 reference map
changed *two more bytes* beyond the three device flags (`0x5369C`, `0x536AB`,
described there as "idle/open-loop trim that comes with removing the O2
feedback"). It's plausible the **device-enable bytes alone only gate the DTC
logic**, and the *actual* closed-loop-vs-open-loop fueling behavior lives in
those separate trim bytes (or elsewhere, e.g. the lambda-target curve at
`fe[2]`/`0x52260` noted in `TABLES.md`). Until we isolate that separately,
**do not assume our hand-toggled `derived/*-noSAI-noO2.hex` files change
fueling behavior** — they very likely only suppress fault codes, matching what
these threads describe. Treat them as "silences the CEL for missing
hardware," not "retunes for open loop." Real open-loop fueling probably
requires touching the lambda-target curve too — worth a dedicated diff pass
against a real delete map that isolates just the trim bytes from the device
flags.
Practical implication carried over from these threads: **if you physically
remove the O2 sensors, they must actually be unplugged/removed** — leaving
them wired with the checkbox off does not change what the ECU reads from
them, per the community description above.
## TuneECU map catalogue IDs seen in the wild (verify before trusting)
Forum threads reference these Bonneville map IDs for O2/SAI removal +
aftermarket exhaust:
| ID | Claimed to be | Checked against our `maps.json` |
|---|---|---|
| `20516` | "aftermarket exhaust... SAI/O2 removed" | **Wrong bike for us** — actually `ecu='E'`, America/Speedmaster, **LCD odometer**. Not compatible with the 2010 mechanical-odo T100. |
| `20500` | "OEM Arrow 2-2, up to E25" | `ecu='8'`, **LCD odometer**. Wrong generation for us. |
| `20505` | "OEM Arrow 2-2, up to E10" | `ecu='8'`, **LCD odometer**. Wrong generation for us. |
| `20507` | "aftermarket silencers, up to E25" | `ecu='8'`, **LCD odometer**. Wrong generation for us. |
None of these are usable for the mechanical-odo 2010 T100 — they're all a
later ECU generation (post-2016 water-cooled-era Bonnevilles use LCD
odometers and a different `ecu` type code in the catalogue). **Lesson: always
cross-check a forum-cited map ID against `maps.json`'s `ecu`/odometer fields
before using it** — the ID numbering isn't obviously tied to hardware
generation, and it's easy to grab the wrong one.
[triumphrat.net: Tune ECU Mapping for O2 and SAI removal - Bonneville](https://www.triumphrat.net/threads/tune-ecu-mapping-for-o2-and-sai-removal-bonneville.755058/)
No official TuneECU catalogue entry for the **mechanical-odo** Bonneville
(any exhaust, any of the ~163 entries we have) ships with SAI/O2 off — see
prior finding, still holds after this search.
## Commercial alternatives to a hand-toggled map
Several vendors sell purpose-built, dyno-derived 865-twin tunes rather than a
flag flip on a stock map — the safer route per our own `DEVICES.md`
recommendation:
- **Triumph Twin Power (TTP)**, run by Mike Cripps — "865 EFI Tune Map 3" is
built for Stage 1 mods (aftermarket exhaust incl. Arrow, SAI removed, O2
removed), delivered via TuneECU/TuneLoader software or mail-in ECU
reprogram. TTP reportedly offers **17 different Bonneville tune variants**
for different mod combinations. Currently listed out of stock on their
site.
[triumphtwinpower.com: 865 EFI tune map 3](https://www.triumphtwinpower.com/bonnevillet100-performance-tune-3.php?review=all),
[triumphbonneville.org write-up](https://triumphbonneville.org/efi-tuning-triumph-twin-power/)
- **DNK TuneWorks** — $275, mail-in ECU service. Explicitly offers
"Enable/Disable Secondary Air Injection" and O2 sensors as a configurable
option, plus top-speed-limiter removal, AFR/timing optimization at full
throttle, deceleration-pop reduction, and speedometer correction (relevant
since our bike's mechanical odo/speedo would need correction if final
gearing or wheel/tire size changes).
[dnktuneworks.com: Bonneville T100/SE (865)](https://dnktuneworks.com/product/triumph-bonneville-900/)
- **British Customs** tuning guide recommends, for 2008–2017 air-cooled
Bonneville/T100: the catalogue "Arrow 2-2" maps for slip-ons, "Arrow 2-1"
for full systems/performance packages, or a custom tune from DNK/TTP —
explicitly warns against self-tuning without dyno validation ("an incorrect
tune can create running issues or even cause damage to your bike").
[britishcustoms.com: Triumph Motorcycle Tuning Guide](https://britishcustoms.com/blogs/bc-blog/triumph-motorcycle-tuning-guide)
## Full ROM-level tuning path (beyond TuneECU's catalogue)
For editing tables directly rather than picking from TuneECU's preset list:
- **OldSkullTuning** and **Tuniverse** sell XDF definition files (~€70) for
TunerPro, covering the Keihin **SH7054** (our ECU family) and the related
**SH72531**. The XDF exposes fuel tables, spark advance by gear, idle
speed, RPM limiter, and **"lambda deactivation"** as editable parameters —
the last one likely corresponds to the AFR/lambda curve we already located
at flat-ROM `0x52260` (`fe[2]` in `TABLES.md`), which is a stronger lead on
where real open-loop behavior is actually controlled versus the device
on/off bytes.
[oldskulltuning.com: Triumph Keihin SH7054 TunerPro Maps](https://oldskulltuning.com/triumph-keihin-sh7054-tunerpro-maps/)
- This path requires a **ROM dump** (Phase 2 in `docs/ROADMAP.md`, not yet
implemented — `tunie` is read-only by construction) — XDF+TunerPro edits
the raw ROM, not the encrypted download-format `.hex` our `decode_map.py`/
`toggle_devices.py` work with. Two different file formats, same underlying
address space.
## Practical procedural notes from the community (unverified, for later)
- One installation note (TTP-specific, may not generalize): during whatever
post-flash adaptation/reset procedure applies, "pull out the cold start
knob only long enough to get your engine started" — leaving it out too long
during the reset apparently corrupts the adaptation and forces a restart of
the procedure from a cold engine. Relevant once we're actually flashing;
irrelevant to read-only work now.
- Community sentiment is split on hand-toggling checkboxes on a stock map:
several threads describe doing exactly that (download OEM aftermarket-
silencer map, untick SAI/O2 in Map Edit, reflash, clear codes) as a known,
common shortcut — but per the correction above, that shortcut may only be
silencing warning lights, not actually retuning for open loop. Worth
weighing against a matched TTP/DNK map before trusting it as a real tune.
## Existing tunes for airbox removal + 2-1 exhaust (Aug 2026)
**TTP did/does sell exactly this combination** — but it's paid, and every
variant is currently out of stock (consistent with the TTP-closure finding
above):
| Tune | Mods | Price | Stock |
|---|---|---|---|
| `865 EFI Tune Map 1 2-1` | 2-1 exhaust + Breathe HiFlo intake **cover** (airbox shell kept) + DNA filter + O2 removal + Stage 1 ignition | £130 (~$177 USD) | Out of stock |
| **`865 EFI Tune Map 2 2-1`** | 2-1 exhaust + intake cover + **airbox baffle removed** (not full removal) + DNA filter + O2 removal — **best match to this build**, and the exact combo the Aug 2026 dyno data above (69.02 BHP/54.38 ft-lb) is for | £130 (~$177 USD) | Out of stock |
| `865 EFI Tune Map 11 2-1` | **Full airbox removal** + pod filters + 2-1 exhaust + O2/SAI removal (per customer reviews: "removed the O2's and fitted a 2/1 tec pipe", "Air box removed... 2 into 1 with O2 sensors and air injection system removed") | not listed | Out of stock |
| `865 EFI Tune Map 13` | Long free-flow silencers (Arrow 2-2 named explicitly, not 2-1) + pod filters + performance cams + O2 removal | £130 (~$177 USD) | Out of stock |
[triumphtwinpower.com: 865 EFI tune map 1 2-1](https://www.triumphtwinpower.com/bonnevillet100-performance-tune-1-2-1.php?review=all),
[triumphtwinpower.com: 865 EFI tune map 2 2-1](https://www.triumphtwinpower.com/bonnevillet100-performance-tune-2-2-1.php),
[triumphtwinpower.com: 865 EFI tune map 11 2-1](https://www.triumphtwinpower.com/bonnevillet100-performance-tune-11-2-1.php?review=all&ro=2),
[triumphtwinpower.com: 865 EFI tune map 13](https://www.triumphtwinpower.com/bonnevillet100-performance-tune-13.php)
**Site-wide stock-status trap, found Aug 2026:** TTP's master listing page
(`triumph-bonneville-t100-865-efi-tunes.php`) shows every single tune as
"In stock" — this is wrong, a catalogue-display artifact, not real
inventory. Verified directly: `Tune 2 2-1`'s own product page clearly
shows "We don't currently have that one in stock." **Always check the
individual product page, never trust the listing page's stock column.**
**Free, from TuneECU's own catalogue: none.** Dumped and grepped all 163
Bonneville entries in `maps.json` for any of "air[box]", "race",
"perform[ance]", "competition", "track", "open" (as in open/velocity-stack
airbox) — zero matches, on top of the earlier zero matches for
delete/SAI/O2/lambda language. TuneECU never shipped an official Bonneville
calibration that assumes the airbox is removed, at any exhaust
configuration, free or otherwise. The only two "airbox removed" reference
points that exist anywhere are: the paid, currently-unavailable TTP tunes
above, and the single community `2009AIRBOXBONNY.hex` file (aftermarket
silencers, not Arrow, not in this repo).
**Checked catalogue-wide, not just Bonneville:** grepped all 1811 entries in
`maps.json` (every Triumph model TuneECU covers) for "airbox"/"air box"/
"velocity stack"/"open filter"/"race filter"/"competition filter" — **zero
matches, for any model.** TuneECU has never shipped an official
airbox-removed calibration for anything. There's no "independent airbox
tune" sitting in the free catalogue to pair with the independent Arrow 2-1
tune (`20262`) — the airbox side of this doesn't exist as a catalogue entry
at all, official or otherwise, so there's nothing free to merge in the first
place. And even if there were, the overlap finding two sections up means a
byte-level merge of two independent deltas isn't sound anyway — this closes
off the "merge two catalogue tunes" idea from both directions (no second
input map to merge, and the merge mechanism itself doesn't work at the byte
level).
**Bottom line: no free airbox-removal-matched tune exists for this bike,
from TuneECU or anywhere else found so far.** If airbox removal stays in
the plan, the realistic options are (a) pay for TTP stock to return, (b) a
DNK Tuneworks mail-in tune, or (c) run without airbox removal and keep just
Arrow 2-1 + SAI-flag-off, which *is* fully covered by the free catalogue
(`derived/20262-noSAI-only.hex`) plus the resistor swap above.
## Can we build "Arrow 2-in-1 + airbox removal + SAI delete" ourselves?
Split by what's actually being asked, because the answer is different for
each piece:
**"Suppress the SAI fault code so the resistor-swapped solenoid doesn't
throw a DTC" — yes, solved, already built.** This is exactly what the
`0x53801` device-enable byte is for, per the checkbox research above (it's
specifically a fault-suppression toggle, not an engine-behavior one) — so
using it for this purpose is squarely its intended function, not a misuse.
`toggle_devices.py --only-sai` produces this on any base map:
```
derived/20262-noSAI-only.hex (Arrow 2-in-1, t202, E10, SAI flag off)
derived/20313-noSAI-only.hex (Arrow 2-in-1, t203, E10, SAI flag off — same tune, see PERMUTATIONS.md)
```
Verified byte-identical to the stock `20262`/`20313` Arrow calibration except
the single SAI flag (`1 -> 0`). No effect on the O2 flag, so this doesn't
touch anything O2/closed-loop related — narrowly scoped to exactly the
resistor-solenoid scenario. This part carries the confidence level
`DEVICES.md` already assigns to that offset (~90%, corroborated two
independent ways).
**"Airbox removal, fuel-matched to Arrow 2-in-1" — no, not with what we
have.** `research/reference-maps/PERMUTATIONS.md` (full cross-diff of all 12
official mechanical-odo Bonneville calibrations) found that exhaust-delta
and fuel-delta bytes overlap heavily (4149 of 4809 fuel-delta bytes sit
inside the exhaust-delta footprint) — different mods' corrections land on
the *same* fuel-table cells, so byte-level composition isn't valid; it would
overwrite one correction with another rather than combining them. We also
don't have an airbox-removal reference for the Arrow-2-in-1 case at all —
the one community airbox/SAI/O2-delete file we know of
(`2009AIRBOXBONNY.hex`) is for **stock aftermarket silencers, not Arrow**,
and isn't currently in this repo to even re-derive a delta from.
**Bottom line:** flip the SAI flag on `20262`/`20313` now — that solves the
actual near-term problem (no DTC once the resistor goes in) and is
low-risk since it's using the flag for exactly what it does. Don't try to
DIY the airbox-removal fuel enrichment on top of Arrow 2-in-1 — that's
either a real dyno tune (DNK, since TTP's storefront is effectively wound
down) or the bigger table-level-arithmetic project noted in
`PERMUTATIONS.md`, not something the current byte-diff tooling can do
safely.
## Open questions this raises for our own reversing
- Isolate whether `0x5369C`/`0x536AB` (or the lambda curve at `0x52260`) are
what actually changes fueling behavior, separate from the three device-
enable bytes — would resolve the discrepancy between "DEVICES.md says
open-loop" and "forums say checkbox is CEL-only."
- If we can get a second community NO-SAI/NO-O2 reference `.hex` (ideally
Arrow-specific) independent of `20188Map2009AIRBOXBONNY.hex`, diffing three
maps instead of two would separate device-flag bytes from fuel/trim bytes
more confidently than the current single-diff derivation.
## FOUND: tuneecu.net's own custom-map archive (Aug 2026, supersedes below)
The `2009AIRBOXBONNY.hex` we'd been treating as unreachable was never behind
a forum paywall — it's sitting in **`tuneecu.net`'s own site**, one click
from the page the user asked us to check
(`TuneECU_En/mapedit.html` -> nav -> `Links&Downloads` -> `Map_Database.html`
-> `Twin_Custom_Tune_list.html`, the community/custom-map equivalent of the
official `Twin_OEM_Tune_list.html` catalogue `maps.json` was built from).
Downloaded directly, no forum account needed:
- `20188Map2009AIRBOXBONNY.hex` — the file `DEVICES.md` was built from.
Re-diffed against a fresh `20188Map.hex`: **reproduces exactly** — same
1428-byte/0.36% diff, same three device-flag bytes, same two trim bytes.
`DEVICES.md`'s findings are now independently re-verified, not resting on
an unreachable prior-session file.
- `20500MapAirboxBonnie_LCD.hex` — a second delete map, **Arrow 2-in-2 +
airbox removal + no SAI + no O2**, closer to what we actually want than
the aftermarket-silencer one. Caveat: **LCD odometer**, wrong ECU
generation for the mechanical-odo T100 — and confirmed *not* portable:
reading its bytes at the mechanical-odo device-flag offset (`0x53801`)
gives garbage (`255`, not `0`/`1`), because the calibration-metadata
pointer table (`fe`, `TABLES.md`) is per-map-signature, not a fixed
address across ECU generations. Useful as a *second data point on the
delta shape*, not as a byte-for-byte reference for our bike.
- `904bigvalvegasflowed790cam44mmTBshortExhaust.hex` — a built-engine
(big-valve head, cams, 44mm throttle bodies) custom tune. Not relevant to
a stock-engine delete/exhaust question, noted for completeness.
Both usable files are now in `reference-maps/` (not gitignore-lost this
time — the `.hex` gitignore rule is at the samplez repo root, these are
committed-adjacent working files, just excluded from git; keep them on disk
locally for future sessions).
## What the real device-flag file tells us about composing Arrow + delete
With `20188Map2009AIRBOXBONNY.hex` in hand, checked precisely which of its
1428 changed bytes overlap with the Arrow 2-in-1 exhaust delta
(`20187`->`20262`, 7548 bytes, from `PERMUTATIONS.md`):
- **1344 of 1428 delete-delta bytes (94%) overlap the exhaust delta** — as
expected from the earlier finding, both land on the same main VE/fuel
table cells.
- **The 7 bytes outside the fuel-table regions** are the ones that actually
matter, and they split cleanly into two groups:
| Bytes | In exhaust delta too? | Composable? |
|---|---|---|
| `0x53801`, `0x53818`, `0x53819` (SAI, O2×2 device flags) | **No — untouched by the Arrow delta** (both maps hold `1` there) | **Yes, cleanly.** Confirms `toggle_devices.py` applied to `20262`/`20313` is a sound, conflict-free composition — this is exactly what `derived/20262-noSAI-noO2.hex` already does. |
| `0x5369C` (104→69 under Arrow, vs. 255→117 under delete), `0x536AB` (125→133 under Arrow, vs. 128→138 under delete) | **Yes — both maps change these two idle/open-loop trim bytes, to different values** | **Yes, both — see correction below.** Initially treated as a conflict because `0x5369C` was read as unsigned; in signed space there's no conflict, both compose additively. |
**Update (Aug 2026): both trim bytes compose, not just one.** The
"genuine conflict" on `0x5369C` was an artifact of unsigned byte reading —
see the full correction further down this document
(`TABLES.md` established the table is signed). With correct signed
arithmetic, `0x5369C` composes the same way `0x536AB` does, saturating at
`+127` rather than needing to be excluded. `compose_arrow_delete.py` now
composes both.
- The three device flags: safe, already done, zero risk of stepping on the
Arrow calibration.
- The two idle-trim bytes: both now composed with signed arithmetic.
- The remaining fuller table-level VE arithmetic project
from `PERMUTATIONS.md`, if the main-table overlap (the 1344 bytes) is worth
resolving too, is still open — that's a materially bigger project than
these two isolated bytes.
- The big 1344-byte overlap in the main VE tables remains the real unsolved
part — same conclusion as `PERMUTATIONS.md`: needs 16-bit-cell arithmetic
composition, not byte-patching, to combine "richer for Arrow" and "richer
for no-O2-feedback" into one number per cell rather than picking one.
## The two conflicting bytes, resolved (Aug 2026)
Dug into `0x5369C` and `0x536AB` individually rather than treating them as
one problem. Pulled the same two addresses across all four E10/E25 x
production/aftermarket stock pairs (`20187`/`20191` production,
`20188`/`20192` aftermarket) to see whether each byte behaves like a small
linear trim or something else:
| Byte | 20187 (prod E10) | 20191 (prod E25) | 20188 (after E10) | 20192 (after E25) | 20262 (Arrow) | delete |
|---|---|---|---|---|---|---|
| `0x5369C` | 104 | 104 | **255** | **255** | 69 | 117 |
| `0x536AB` | 125 | 125 | 128 | 128 | 133 | 138 |
**`0x536AB` is a normal trim byte** — smoothly increasing, tightly clustered
values regardless of exhaust family or fuel type. Composed it additively:
`arrow(133) + [delete(138) - aftermarket_stock(128)] = 143`.
**`0x5369C` — correction, Aug 2026: this conclusion was wrong, built on an
unsigned reading.** At the time, `255` looked like an exhaust-family
sentinel distinct from the ~69-104 held elsewhere, so the byte was left
untouched rather than composed. `TABLES.md` later established this whole
table is **signed** two's-complement (-128..127), confirmed from two
independent code paths in the TuneECU app itself. Redone in signed space,
unsigned `255` is simply signed **-1** — an entirely ordinary, near-neutral
value. There is no sentinel, no regime conflict, no reason to exclude it.
Composed the same way `0x536AB` always was: `stock188=-1`, `delete=+117`,
`delta=+118`; `arrow=+69 + 118 = +187`, clamped to the signed max `+127`.
The correction genuinely wants this cell pushed to its richest possible
value at this RPM point — clamping to `+127` is the right outcome, not a
sign the byte doesn't compose. `compose_arrow_delete.py` now composes both
bytes; `derived/20262-arrow-delete-composed.hex` regenerated accordingly.
Built the result: `research/reference-maps/compose_arrow_delete.py`, output
in `derived/20262-arrow-delete-composed.hex` and
`derived/20313-arrow-delete-composed.hex` — Arrow 2-in-1 E10, all three
device flags off, the one genuinely composable trim byte combined, the
one non-composable trim byte deliberately left alone with the reasoning
recorded in the script's docstring.
**What's still outstanding:** the ~1340-byte overlap in the main VE fuel
tables (`PERMUTATIONS.md`) — this session resolved the two *isolated*
device-adjacent bytes, not the big fuel-table composition question, which
still needs 16-bit-cell arithmetic rather than a two-byte spot fix.
## Minor: FAQ confirms device checkboxes are conditionally available
`tuneecu.net/FAQ.html` (German) has a direct question: "Why can't I disable
my lambda sensor under Map Edit, or the checkbox won't uncheck?" Answer
(old Windows version specific): some options need a double-click to take
effect, "but there are also options that are not executable [at all]" —
i.e. device toggles can be locked/unavailable depending on model/ECU
variant. Consistent with `DEVICES.md`'s note that the device-layout logic
(`x.a()`) branches per ECU variant, and with this session's discovery of a
device flag at `0x53822` on the America map that may not even exist as a
concept on the Bonneville. Also: site-wide notice that tuneecu.net "will no
longer be updated after September 2024" — the whole project (not just TTP)
is in wind-down/archive mode, worth knowing before relying on it long-term.
## Major find: a documented no-dyno road-tuning method (Aug 2026)
Buried in the crawled mirror: `Dr_Feinbeins_kleine_Abstimmfibel/t5_kleine_abstimmfibel.htm`
("Dr. Feinbein's little tuning primer", German, dated 2003, credited to
TuneBoy/TuneEdit author Wayne Macdonald's tooling). It's a complete,
concrete methodology for correcting the main fuel table **on the road,
without a dyno**, using a wideband O2 sensor and a datalogger. Summarized in
English (this project's translation, not an official one):
**The tables it names** (TuneEdit/TuneBoy terms — same Keihin/Sagem
architecture TuneECU works with, though possibly not 1:1 identical naming
to TuneECU's own UI):
- **FuelMap** — intake air mass in mg, indexed by throttle position (TP)
and RPM. This is our `F1-F4` main fuel tables.
- **AF1** — target air/fuel ratio at part load, indexed by load and RPM.
Example given: 13.0-13.5:1 for good part-throttle drivability.
- **AF2** — target AFR at full load. Example: 12.5:1 for max power (richer
than stoich 14.7:1, leaner than part-load — the classic power-vs-driveability
split). Note "full load" isn't the same as "throttle fully open" — at low
RPM, full load is reached well before 100% throttle.
- **Fuel%trim table** — a **separate 2D correction table** (author states
"15x24" values) overlaid on the FuelMap, used *specifically* for this
road-tuning workflow. **This is distinct from the "Idle Fuel Trim (CO)"
1D/32-entry array we identified above** — two different named trim
concepts in the same architecture, easy to conflate. Fuel%trim is reset
to 0 before each measurement pass and has a **"Commit trims to main
tables"** function that permanently bakes the correction into the FuelMap
and zeroes the trim table again — i.e. it's explicitly a *working*
table, not persistent tune data.
**The method, condensed:**
1. Fit a wideband O2 sensor (temporary controller, e.g. under the seat) —
either replacing the stock narrowband sensor, or via a bung welded into
the exhaust on bikes with no stock sensor. **Disconnect the stock
sensor and run a tune with closed-loop trim disabled** for the duration
— you're substituting your own live AFR reading for the ECU's.
2. Start from a base tune with **AF1 and AF2 both set flat to 13.0** (rich
enough to be measurement-safe, no lean risk) and **Fuel%trim table
zeroed**.
3. Mark the throttle grip at positions roughly matching the FuelMap's TP
breakpoints. On a quiet straight road, hold each throttle position
steady through the RPM range (3rd gear, steady climb from ~1500rpm to
redline), 3 measurement passes per throttle position, logging RPM,
current TP row + offset to the next row, target AFR, and measured AFR.
4. In a spreadsheet, for each logged point, note where it falls between
two Fuel%trim table breakpoints (interpolate by the offset), and enter
the measured-vs-target AFR discrepancy into that Fuel%trim cell.
5. **"Commit trims to main tables"** — bakes the correction into the
permanent FuelMap, zeroes Fuel%trim again, flash back to the bike,
repeat. Author reports 3-4 iterations converges to only small residual
corrections.
6. Once the FuelMap itself is corrected, set real AF1/AF2 target curves
(not the flat 13.0 placeholder) and do a final commit — that's the
finished road tune.
7. **A dyno session afterward is optional, not required** — author notes
it should only need fine-tuning via Fuel%trim at that point, "not much
should come out of it" if the road-tuning was done properly.
**Why this matters for the airbox-removal plan:** this is a real,
documented alternative to paying for TTP/DNK — the hardware cost is a
wideband O2 sensor + controller + a cheap datalogger/laptop setup, not a
tuner's fee. It directly targets the exact problem airbox removal creates
(the main FuelMap no longer matches real airflow) using the manufacturer's
own table structure, correcting *measured reality* rather than guessing at
composed deltas the way this project's own `PERMUTATIONS.md`/
`compose_arrow_delete.py` work has been attempting. Worth serious
consideration as the actual path forward once the airbox mod happens,
independent of whether a commercial tune is ever obtained.
**Caveat resolved — confirmed present in the current Android app.**
`TuneECU_En/android.html` describes, in TuneECU's own current UI terms,
the identical mechanism the 2003 German guide used:
> "From the 'F' or 'I' screen, the corresponding corrections **F Trim**
> table can be displayed by swiping towards the right." ... "**F Trim
> global**: Use the F Trim table for all 'F' tables." ... "**Commit
> trims**: Apply each Trim table to the F & I table and reset the F Trim
> tables." ... "**Import table PCIII/V**: Import a table PCIII or V in the
> trim tables."
Same names (F Trim / I Trim, not "Fuel%trim" — terminology drifted slightly
from the 2003 TuneEdit doc but the mechanism is identical), same "commit"
semantics (bake trim into the main table, reset trim to zero), and it works
for both F (fuel) and I (ignition) tables, on the actively-maintained
Android app, not a defunct Windows tool. **The road-tuning method above is
directly usable today**, not archaeology — this is the real, current path
to a properly airbox-matched fuel table without paying a tuner.
Also from `android.html`'s UI walkthrough, confirms the **Devices** menu's
full generic list (bike-dependent which ones actually show/work, per the
FAQ note above): **SAI, O2 probe, Immobilizer** (example given: Ducati
Monster), **Traction control, instrument-cluster deactivation** (example:
Daytona 675 — lets the engine start without the dash connected). This is a
strong candidate identity for the unexplained `0x53822` flag found on the
America dyno tune last session — likely traction control or
instrument-cluster-deactivate on that model, not something that exists as
a concept on the Bonneville at all. Not confirmed, but a much better lead
than "unknown."
## Major find: a real dyno tune on our exact ECU generation, airbox removed (Aug 2026)
The full `Twin_Custom_Tune_list.html` listing (8 Bonneville/Thruxton/America
entries total, not just the 3 found via keyword search) contains one entry
much closer to what we actually want than `2009AIRBOXBONNY.hex`:
**`20184dynoTuneSteveO2-Disable.hex`** — America/Speedmaster, **mechanical
odometer**, base map `20184` (same `t201` mechanical-odo family as our
Bonneville, `ecu='0'`). Description: "Open pipe aftermarket K&N air filter
short slash cut foran pipe's and the air box **intake snorkel removed**,
**O2-probes disabled**, tune read from 08 America, mechanical odometer." A
real, named tuner's ("Steve") dyno-derived tune, not a hand-toggled stock
map — airbox snorkel removed + open pipes + O2 disabled, on our exact ECU
family.
[tuneecu.net: Twin_Custom_Tune_list.html](https://tuneecu.net/Twin_Custom_Tune_list.html)
Base map `20184` itself isn't live on tuneecu.fr any more; diffed against
the closest available stock baseline, `20186` (America, aftermarket
silencers, mechanical odo — same family, later revision, same relationship
as `20187`->`20191` for Bonneville).
**Confirms device flags a third time, independently:** `SAI=1` (left ON —
this tuner chose not to delete SAI, only O2), `O2 sensor 1/2 = 0` (both
off). Same offsets, third different base map, third confirmation beyond
`DEVICES.md`'s original 90%/80%.
**Revises the "two conflicting trim bytes" model from earlier this
session — it undersold the complexity.** This more aggressive tune (open
pipes, not just aftermarket mufflers) doesn't touch just `0x5369C`/`0x536AB`
in isolation — it rewrites **nearly every byte from `0x53690` to
`0x536AF`** (a ~32-byte span). That means `0x5369C`/`0x536AB` aren't
standalone scalar trims; they're two cells inside a **larger fuel-trim
table** (glossary confirms TuneECU's own concept of "Idle fuel trim" and
"Off idle fuel trim" as dedicated percentage tables, distinct from the main
F/L tables — this is almost certainly one of those). The Bonneville-family
comparison only touched 2 of this table's ~32 cells because that delta was
conservative (aftermarket mufflers only); this dyno tune's more aggressive
mods (open pipes) moved most of the table.
**Also found a new device flag** at `0x53822` (index 33 past `0x53801`,
i.e. one slot past the two O2 sensors) that this tune also disabled —
purpose not yet identified; may not even apply to the Bonneville's specific
device list (America/Speedmaster could have a different `Devices` array
length/order — `x.a()` branch is per-ECU-variant per `DEVICES.md`).
**And a whole new region, `0x50010`-`0x50C21`, extensively rewritten** —
not yet mapped to any named parameter. Glossary terms that likely live here
given what changed: ignition timing, rev limiter, injector pulse
time/short-term fuel trim. Not analyzed further this session.
**What this means for "have the tune ready for airbox removal":** real
progress, but the earlier two-byte fix (`compose_arrow_delete.py`) is now
known to be an incomplete model, not a finished one — it correctly handles
the 3 device flags and gets lucky on `0x536AB` being a small/isolated
correction for the *Bonneville* delta specifically, but a real
airbox-removal delta (per this more representative reference) touches a
32-byte trim table plus a further ~50-byte region neither previous
composition attempt accounted for. **Don't treat
`derived/20262-arrow-delete-composed.hex` as airbox-ready** — it isn't; it
was scoped to the SAI/O2 solenoid-removal problem specifically, not airbox
removal. Mapping the `0x53690-0x536AF` trim table's axis (RPM? gear? load
bin?) and the `0x50010-0x50C21` region is the actual next step before a
real airbox-removal composition is possible, and is a bigger project than
what's been done so far — worth scoping separately when picked back up.
## Second reference map search (Aug 2026): superseded by the find above, kept for history
Searched map-sharing threads, direct filename variants, and vendor sites for
a second independent NO-SAI/NO-O2 `.hex` to cross-diff against
`20188Map2009AIRBOXBONNY.hex`. Found nothing. One forum result was explicit:
> "There's just one map available for TuneECU for 2009 bikes or 2010 with the
> mechanical speedo... called [20188Map]2009AIRBOXBONNY.hex"
That file appears to be the **only** SAI/O2-delete map that ever circulated
for the mechanical-odo generation — originally derived from a British
Customs PowerCommander III map (no airbox, K&N pods, British Customs
mufflers). Everything else that surfaces (`20500`/`20505`/`20507`/`20516`,
`20498...LCD`) is confirmed LCD-odometer, wrong ECU generation.
**Caveat on our own `DEVICES.md` findings:** we don't currently have
`2009AIRBOXBONNY.hex` sitting in the repo (`.hex` is gitignored, and it
wasn't re-fetched this session) — `DEVICES.md`'s SAI/O2 offsets rest on a
prior analysis of a file we'd need to re-download to double-check. All its
known links are forum attachments behind the same `tollbit.*` paywall
blocking automated fetches here; getting it requires a forum login.
## Resolved: what physically removing O2 sensors actually changes
Closed-loop (O2-corrected) operation only runs in a narrow band: **idle and
steady low-to-mid throttle** (roughly <40% throttle, constant speed, warmed
up). **Acceleration and wide-open throttle are already open-loop on the
stock map** — the O2 sensor has zero influence there, stock or modified.
[motofomo.com: Open Loop vs Closed Loop](https://motofomo.com/open-loop-vs-closed-loop-fuel-injection/),
[racext.com: Understanding Fuel Injection and Tuning](https://racext.com/understanding-fuel-injection-and-tuning-open-vs-closed-loop/)
So **physically** removing/unplugging the sensors is a real behavioral
change (forces open-loop everywhere, richer/more consistent idle-cruise
fueling, cools the air-cooled top end, reduces snatchy off-idle response per
`RESEARCH.md` §9) — genuinely different from the checkbox-only CEL
suppression documented above. But two things temper "O2 delete = more
power":
1. **It only affects idle/cruise/part-throttle**, since WOT was already
open-loop. Peak-power gain from the O2 delete *by itself* is close to
zero — the actual power comes from the exhaust/airbox flow changes, and
O2 delete is what lets a richer fuel table for those changes stick
without the ECU fighting it.
2. **Unplugging without a matching richer map is a net negative**, not
neutral — you lose the live correction that was keeping idle/cruise from
running lean, with nothing filling in for it. Several sources describe
the ECU's adaptive/long-term trim quietly "learning" a custom map back
toward stock stoichiometric if the O2 sensor stays connected and active,
which is the flip-side risk (custom tune silently undone rather than
running lean).
[drdyno.com: O2 sensors and closed-loop systems](http://drdyno.com/AIM_2010-07.html)
Conclusion: SAI/O2 delete is a prerequisite that lets a richer tune hold, not
a horsepower mod on its own. The `derived/*-noSAI-noO2.hex` files in this
repo very likely only silence fault codes (per the checkbox finding above);
even a fully "correct" flag-and-fuel-table delete mainly buys cleaner
idle/cruise behavior, not peak power.
## SAI removal: actual risk profile (Aug 2026)
For someone who's already pulled the physical SAI plumbing (valves/hoses)
but left the solenoid wired up:
- **No engine-damage risk.** SAI only injects fresh air into the exhaust
port, downstream of combustion — it doesn't touch fueling, lubrication, or
cooling. There's nothing about removing it that can hurt the engine
mechanically.
- **The clicking solenoid is normal and harmless.** With plumbing removed
but the solenoid still electrically connected, "it will keep clicking
merrily away without throwing any ECU codes" — the ECU is still commanding
it on its usual duty cycle; there's just nothing downstream for it to
move. To actually silence it: replace the solenoid with a ~50 Ω resistor
(mimics the coil's electrical load so the ECU sees a normal circuit and
stops trying to drive it), or just leave the physical solenoid connected
even if unplumbed — either satisfies the ECU without the noise. Simply
unplugging the solenoid's connector, by contrast, opens the circuit and
*will* trigger a DTC — the software SAI-disable flag exists specifically
for that case, again consistent with the earlier CEL-suppression finding.
[triumphrat.net: Air injection removal](https://www.triumphrat.net/threads/air-injection-removal.846474/)
- **Deceleration popping cuts both ways depending on exhaust.** SAI air
helps combust unburned fuel present in the exhaust under deceleration. On
an aftermarket pipe (different backpressure/scavenging than stock), SAI
presence is reported as a *cause* of decel popping, not a cure — deleting
it is the fix, matching this project's own `RESEARCH.md` §9 guidance to
disable SAI before tuning for an aftermarket system.
[triumphrat.net: Bonneville Exhaust Popping Troubleshooting](https://www.triumphrat.net/threads/bonneville-exhaust-popping-troubleshooting-help.85412/)
- **Real risk is regulatory, not mechanical.** SAI is emissions hardware;
removing it is a tamper concern only where emissions testing/inspection
applies to the bike (varies by province/state; many jurisdictions exempt
older motorcycles). Not an engine-safety question.
- **One genuine fueling interaction, now moot for this bike:** if the O2
sensor sits downstream of the SAI injection point *and* SAI is still
actively pumping air (not just electrically connected with the plumbing
removed), the extra O2 in the exhaust can bias closed-loop trim toward
reading falsely lean, causing the ECU to over-fuel to compensate — this is
the exact mechanism behind `RESEARCH.md` §9's "SAI corrupts AFR readings
on a dyno." With the plumbing physically removed (no air actually being
injected any more, solenoid clicking into nothing), this interaction is
already eliminated regardless of what the software flag says.
## Practical: silencing a removed SAI solenoid with a resistor (Aug 2026)
For anyone who's already pulled the SAI plumbing (valves/hoses) and just
wants the leftover solenoid clicking to stop, without touching the ECU:
The ECU doesn't know or care whether it's driving a real solenoid coil or a
plain resistor — it only checks that the circuit shows plausible
resistance/current when it commands the output. Two resistor values show up
in the community as working substitutes for the solenoid itself:
| Resistor | Wattage | Notes |
|---|---|---|
| 50 Ω | **10 W** (must be this high, or it overheats) | Closer match to the solenoid coil's actual current draw |
| 470 Ω | 0.5 W | Simpler/smaller part, most commonly recommended, plenty of margin |
[triumphrat.net: Air injection solenoid replacement resistor needed](https://www.triumphrat.net/threads/air-injection-solenoid-replacement-resistor-needed.629554/)
Procedure: disconnect the battery negative terminal first (working on
ECU-monitored wiring), unplug the solenoid from the harness connector
(leave the harness-side connector), wire the resistor across the two
harness-side pins that used to feed the coil (push-fit into the connector
pins for a quick fix, or crimp/solder + heat-shrink for something
weatherproof/permanent), reconnect the battery, clear any DTC.
This is purely an electrical placebo for the ECU's continuity check — no
functional link to fueling, and separate from the SAI *software* enable
flag (`0x53801`), which per the earlier finding is about warning-light
suppression, not actuator behavior. A bare disconnected plug (no resistor)
*will* throw an open-circuit DTC; a resistor avoids that without needing the
software flag at all. The two are complementary, not substitutes — see next
section for why you likely still want the flag too.
## Dyno data: exhaust/intake stages, quantified (TTP, 865 EFI twin)
Triumph Twin Power publishes an actual dyno comparison for this engine family
that answers "how much does each stage actually gain":
| Stage | Mods | Peak power | Peak torque | Notes |
|---|---|---|---|---|
| Stock | SAI removed only | 60.64 BHP | 46.84 ft-lb | torque dip 3500–5500 rpm |
| Stage 1 (Tune 1) | Venturi/performance intake **cover** (airbox *not* fully removed) + TORS exhaust + matching TTP tune | 64.06 BHP (**+5.6%**) | 50.78 ft-lb (**+8.4%**) | max torque +15.9% @ 4200 rpm; balanced midrange |
| Stage 1.5 (Tune 2) | Stage 1 + **airbox internal baffle removed** (shell kept) + TORS + matching tune | 63.40 BHP (+4.5%) | 51.91 ft-lb (**+10.8%**) | max torque +16.8% @ 4200 rpm |
| **Stage 1.5 + 2-1 (Tune 2, 2-1)** | Stage 1.5 (baffle removed, airbox shell kept) + **2-1 exhaust system** + matching tune | **69.02 BHP (+13.8%)** | **54.38 ft-lb (+16.0%)** | **max torque +26.6% @ 4100 rpm** — closest published data point to a bare Arrow-2-1 + baffle-removal build |
| Stage 2 (Tune 12) | **Full airbox removal** + pod filters + short free-flow silencers + matching TTP tune | 69.31 BHP (**+14.3%**) | 54.14 ft-lb (**+15.6%**) | max torque +21.0% @ 4300 rpm; more top-end, less mid vs Stage 1.5+2-1 |
[triumphtwinpower.com: EFI Model Comparison](https://www.triumphtwinpower.com/triumph-twin-power-torque-model-comparison.php),
[triumphtwinpower.com: Breathe Evolution](https://www.triumphtwinpower.com/triumph-twin-power-breathe-evolution.php),
[triumphtwinpower.com: Bonneville/Thruxton airbox & exhaust tuning](https://www.triumphtwinpower.com/triumph-bonneville-thruxton-airbox-exhaust-tuning.php) (Aug 2026 addition — the Stage 1.5+2-1 row, closest published match to a 2-in-1 exhaust + baffle-only airbox mod, found via `triumph-twin-power-x-files.php`)
Key takeaway: **exhaust + intake cover + matched tune alone is real but
modest** (~5–8%); **baffle removal alone adds more torque than power** (Stage
1.5's torque gain nearly matches full removal, its power gain doesn't);
**pairing baffle removal with a 2-1 exhaust gets within a rounding error of
full airbox removal** (69.02 vs 69.31 BHP, 54.38 vs 54.14 ft-lb) —
i.e. per this data, **you may not need to go all the way to full pod-filter
airbox removal once a 2-1 exhaust is already fitted; the lighter baffle-only
mod gets nearly all of the same gain.** In all cases the number that matters
is "matched tune," not the hardware alone — TTP's own comparison doesn't show
standalone gains without their tune paired to each stage.
Their "Breathe Evolution" intake cover (a snorkel/cover swap, not full
removal) claims to track full pod-filter airbox removal almost exactly up to
~6000 rpm, with full removal only pulling ahead above that — i.e. most of the
"lose the airbox" gain is available without fully deleting it, up until
you're spending real time above 6k rpm.
**Airbox internal baffle removal — mechanical procedure**, per TTP's own
guide: 1–1.5 hours, requires removing seat, rear mudguard, side covers, air
intake, battery, and fuse box to access and slide out the baffle from the
airbox's right side. Tools: 3/4/5mm Allen keys, T30 Torx, large flat/cross
screwdrivers, 8/10mm sockets/spanners. **Warning specific to carbureted
bikes** (not this EFI bike, but worth knowing if cross-referencing): a
brittle sensor clip breaks easily during removal on carb models. The guide
itself doesn't address any fuel-map/ECU implications of the baffle removal —
purely a mechanical how-to, tuning is treated as a separate step (their own
tune, in TTP's case).

View File

@@ -0,0 +1,58 @@
# External research pass — verified findings only (Aug 2026)
A web-research agent was dispatched with a brief covering this project's
open questions. Its raw report contained real value mixed with fabricated
detail (invented a table-category split not present in a cited source,
guessed at least one dead URL). Everything below has been **independently
re-verified by this project directly against the actual source** — the
raw report and the dispatch brief that produced it have been deleted; this
is the distilled, trustworthy result, not a summary of the report.
## Confirmed
**`0x53822` device flag — Purge Control Valve, strengthened.** TuneECU's
own `tests.html` (this project's site mirror) lists a distinct, real
testable entry: *"Purge Control Valve (Only bikes with charcoal
canister) — Activate the purge valve, listen for a very quiet noise,"*
right alongside its own separate SAI entry. Confirms Purge Valve is a real
device on this ECU family with its own dedicated path — genuinely
strengthens (doesn't prove) the leading hypothesis for this byte. Also
confirms "Air Flap (675 Daytona)" is Daytona-675-specific, per the same
list. Full detail and remaining uncertainty: `reference-maps/DEVICES.md`.
**Wideband sensor bung masking is real and worth designing around.**
Confirmed via a real installer source (RB Racing): long/angled 18mm bungs
and thread-reducing adapters (18mm→12mm) can prevent the sensor tip from
reaching the core exhaust gas stream, causing delayed/inaccurate
readings — *"Many manufacturers use long 18mm O2 slanted bungs. These
'mask' the 18mm O2 sensor signal"*; thread reducers are *"junk... too long
masking the signal."* Practical implication: check bung depth/angle
against the sensor's spec before assuming any available bung (stock or
new) works. Folded into `TUNING_IMPL_PLAN.md` Phase 2.
**Zeitronix ZT-3 — a third real wideband option.** Sold by Woolich Racing
as a tuning-package wideband kit. Adds to the existing list (Innovate
LM-2/MTX-L, AEM X-Series UEGO). Folded into `TUNING_IMPL_PLAN.md` Phase 2.
## Explicitly not carried forward (checked and rejected, or unverifiable)
- A claimed "Speed-Density vs. Alpha-N" table split in a Keihin SH7054
XDF listing — checked the actual source, no such split exists there;
the page instead lists a 3-cylinder table set, meaning the wrong
product (likely a Triumph triple's XDF, not this twin) was cited.
Don't reuse this claim.
- A specific Woolich Racing product URL for wideband bung fitment — the
exact URL doesn't resolve to a real distinct page; the general product
line exists but this project couldn't confirm it covers the correct
(Keihin, air-cooled) ECU generation rather than the later Bosch/
liquid-cooled T100. Same wrong-generation risk this project has hit
repeatedly elsewhere — don't cite this source without re-confirming
ECU generation first.
- A Reddit thread claimed to discuss map `20262` availability — could not
be fetched or verified at all (Reddit blocks this project's tooling).
- Claims about TTP's "TuneLoader" licensing mechanism and Ultimate Twin
Performance's remap-service pricing — plausible, not independently
checked.
- A Manitoba emissions-inspection regulation citation — checked, the
specific regulation number cited does not match what's actually
findable; not relied on for anything, not detailed further here.

View File

@@ -0,0 +1,201 @@
# TuneECU's Recovery mode — traced (Aug 2026)
Started from `menu_recovery` in the decompile per `docs/ROADMAP.md` B2, to
understand TuneECU's field-hardened recovery procedure (multi-year,
multi-brand bug-fix history — Ducati, Aprilia Dorsoduro/Shiver, Walbro,
5DM/7SM ECUs all had recovery-specific fixes 2019-2021, per
`strings.xml`'s changelog) before this project ever attempts its own
upload/write path (`docs/ROADMAP.md` B2/B4).
**Priority is Triumph/Keihin recovery specifically, but the trace so far
is mostly generic app architecture — the useful cross-brand lessons below
are a side effect of that, not a separate investigation.**
## What "Recovery" actually is: not a separate feature, a relabeled one
`MainActivity.java:30039`: a single menu item (`R.id.menu_prog`) swaps its
own label between "Reprogram" (`menu_program`) and "Recovery"
(`menu_recovery`) based on a flag `U9`. **Recovery is the same
reprogram/write function as normal flashing** — same code path
(`case R.id.menu_prog:` at `MainActivity.java:29129`), gated by the same
`U9` condition that also swaps the label. There is no separate "Recovery"
KWP2000 sequence sitting in its own function; whatever's different happens
inside branches of the ordinary write path once `U9` is true.
## What decides `U9` — and it's not live ECU status, it's file validation
Traced `U9`'s assignment (`MainActivity.java:9433`, inside a large
response/state-dispatch switch, `case 10`):
```java
if (i8 == 0 && (k7 & 65280) == 2048) { // (k7 & 0xFF00) == 0x0800
z11 = true;
}
U9 = z11;
```
`k7` comes from `com.tuneecu.l.zc(bArrP8)` (`MainActivity.java:11011`),
where `bArrP8` is the output of `p8(str, true)` — **`p8()` is the same map
*file* decoder this project already reverse-engineered** (it's the
function `reconstruct_rom.py`'s docstring already cites for the
unpack-directory format). So `k7` isn't a live ECU status code at all —
**it's a property of the currently-*loaded map file***.
Opened `l.zc()` itself (`l.java:6710`) and it's doing work this project's
own tooling already replicates:
- Checks the same reversed magic-header pattern already documented in
`reference-maps/README.md` (`bytes[0:4] & 0xFF00FFE0 == 0x18001360`) —
returns an error code if it doesn't match.
- Runs the **same signature/`Qd` directory lookup** `table_map.py`'s
`resolve()` already implements (`sc()` → `s.a` directory → `Qd` →
`c.a[Qd*48]` calibration metadata).
**So the practical meaning of `U9` (show "Recovery" instead of
"Reprogram") is: does the currently-open map file decode successfully and
resolve to a valid calibration signature, of a type/size in a particular
range** (the `0x0800`-high-byte check on whatever `zc()` returns) — not
"the app detected the ECU is stuck via a live diagnostic read." This lines
up exactly with the community-documented procedure found earlier
(`COMMUNITY_TUNING.md` and forum research): *"a matching map must
definitely be opened, preferably a matching OEM map"* before recovery
works. The map has to be there and valid; the app isn't sensing the ECU's
internal fault state, it's checking that you're prepared with a complete
known-good image before it'll offer to write one.
## The generalizable lesson (this is the actual cross-brand takeaway)
**The design isn't "cleverly detect exactly where a failed transfer left
off and resume it." It's "if you have a complete, valid, known-good image,
do a full rewrite."** That's a far more robust strategy than resume-logic
— no need to reconstruct partial transfer state, no assumptions about
where exactly a previous attempt died, just a full verified overwrite.
This is the pattern worth carrying into any future `tunie` write-path work
(`docs/ROADMAP.md` B4), regardless of ECU brand: **always have a complete,
validated base image ready before attempting a write, and treat "recovery"
as "do the same full write again," not as a distinct clever-resume
code path.**
**Counter-lesson, equally important:** the changelog's multi-year,
per-brand recovery bug list (5DM/7SM, Dorsoduro/Shiver 750, Walbro, Ducati
— all separately broken and separately fixed, 2019-2021) shows that even
with this simple, robust *design*, the *implementation details differ
enough per ECU family that a shared strategy still needed years of
per-brand empirical patching* to actually work reliably. "Full rewrite
instead of resume" is the right architectural idea to borrow; assuming it
works identically across ECU families without validating per-family is
exactly the mistake that produced years of TuneECU's own bug list. Applies
directly to this project: don't assume whatever works for Keihin
generalizes to Sagem/Walbro/Bosch without separately checking each.
## What happens after the confirmation dialog — traced through to the fork
Followed dialog id 43's positive-button click all the way through:
`P9(...20, 3, 43)` → confirm → `s6.onClick()` (`MainActivity.java:6239`) →
`H8(true, 43)` → `H8`'s `case 27: case 43:` (`MainActivity.java:9527`)
shows a **second** confirmation dialog — the same standard `write_caution`
warning a normal first-time reprogram shows, action code 12 → confirm
again → `H8`'s `case 12:` (`MainActivity.java:9446`), **the actual fork**:
```java
case 12:
if (!U9) {
com.tuneecu.m.fg = true; // normal path: flag read by the ordinary write routine
} else {
com.tuneecu.m.af(); // recovery path
}
break;
```
`af()` (`m.java:6348`) is short and concrete:
```java
public static void af() {
Zf = true; Yf = true; nf = true; Vf = true; // mode flags for the write routine
MainActivity.U9 = false; // clear the recovery flag
Ue(d.MODE_NULL); // reset the connection state machine
Jg.P9(null, null, Ig.ac(c.PLUG_SWITCH), 0, 20, 2, 15); // prompt: cycle the ignition
Qe();
Ig.Yb(9600, true); // reconnect at 9600 baud, with a control byte (3)
}
```
**This independently confirms, from the code, exactly what the community
procedure described from experience** (`COMMUNITY_TUNING.md`/forum
research): reset the connection, prompt the user to cycle the ignition
switch (`PLUG_SWITCH`/`UNPLUG_SWITCH` are literal enum message keys for
this), then reconnect — but **at 9600 baud, not the normal K-line rate**.
That's a genuinely new, concrete, useful fact this project didn't have
before: recovery mode reconnects at a different baud rate, strongly
suggesting a **slow/5-baud-style re-init** rather than the normal fast
init — which `tunie` already has support for (`--init slow`,
`ELM_INIT_SLOW` in `triumph.py`), for the unrelated reason of "fast init
timed out." The mechanism recovery leans on may be the same one `tunie`
already implements for a different trigger condition.
**Where the trace stops:** what happens *after* the reconnect completes —
the actual KWP2000 frames of the write/upload itself — isn't traced. The
`Zf`/`Yf`/`nf`/`Vf` flags `af()` sets are presumably read by the same
underlying write routine the normal `fg`-flag path uses, modifying its
behavior (e.g. possibly skipping parts of SecurityAccess if a session is
assumed already partially open) rather than being a wholly separate write
implementation — consistent with the top-level finding that Recovery
reuses the normal reprogram machinery rather than duplicating it. Tracing
into that shared write routine itself is real further work, not attempted
here.
## Independent real-world confirmation (Aug 2026)
The file-based `U9` detection this document traced — Recovery only becomes
available when a valid map is loaded, not from a live ECU-fault check —
is independently confirmed by real users, not just the static code trace.
From the "TuneECU For Dummies" thread (triumphrat.net):
> "you will NEED to have a map opened up in the TuneECU program when you
> go to reconnect to the bike or it will NOT initiate the recovery mode...
> try to connect, and then click OK when the recovery option is offered."
Matches the traced mechanism exactly — recovery is gated on a loaded,
valid file, confirmed from both directions (static code and real usage).
Also from the same source, a disconnect-ordering caution worth carrying
into any future `tunie` write-path work: **disconnect via the software
menu before turning off ignition**, not the other way around — one user
reported turning off ignition first "closes the program mode on the ECU"
incorrectly and the bike wouldn't start afterward until sorted out. Not
independently traced in the code here, but a real reported failure mode
worth respecting.
## What's still untraced
- ~~The exact KWP2000 frames sent *during* the write itself~~ — **traced
further, see `WRITE_PATH.md`**: the shared routine both paths feed into
(`sc()` → `Fc()` → a 5-baud slow-init bit-bang) is now documented there.
That trace stops at the post-slow-init handoff (`z.ec(...)`, unopened),
which is the next link if this gets picked up again.
- Whether there's *also* a live-ECU-side signal (e.g. a specific negative
response during `StartCommunication`) that independently indicates a
stuck programming session, separate from the file-based `U9` check
found here. Plausible — the community procedure's "cycle ignition,
reconnect" step suggests the ECU's live response does matter somehow —
but not confirmed from what's traced so far. Real next step if this
gets picked up again: trace what happens between "ignition cycled,
reconnect" and the recovery menu becoming available, since that's where
a live-status check would live if one exists.
- Byte-level meaning of the `0x0800` high-byte check on `zc()`'s return
value — confirmed it's a classification of some kind (map type/size
family), not confirmed exactly what distinguishes it from a normal
map's classification.
## Relevance to this project's own plans
- **Near-term** (`TUNING_IMPL_PLAN.md` step 2): unaffected — still use
TuneECU's app for the actual ROM dump, this doesn't change that.
- **Longer-term** (`docs/ROADMAP.md` B2/B4, if `tunie` ever implements its
own upload/write path): the "full rewrite over clever resume" pattern
found here is directly actionable design guidance, and cheap to adopt —
it's simpler to implement than resume logic would have been anyway.
The per-brand-patching counter-lesson argues for validating any write
path thoroughly against Keihin specifically before assuming it's solved
in general, exactly matching this project's existing "get a spare ECU
first" caution in `safety.py`.

View File

@@ -0,0 +1,531 @@
# Tuning the 2010 T100 — master reference
One place that answers "how do we actually tune this bike, any way we
want" — indexes everything scattered across this project's research files
into the actual decision tree: what's possible, what's proven, what's
still open. Read this first; follow the links for the technical depth
behind each claim.
**The bike:** 2010 Triumph Bonneville T100, 865cc air-cooled twin, Keihin
ECU (Renesas SH7054), **mechanical odometer** — this generation detail
matters constantly (see "the one recurring trap" below).
**Current real-world state:** SAI plumbing physically removed, solenoid
left electrically connected (clicking, harmless — [resistor fix
below](#stop-the-sai-solenoid-clicking)). Exhaust: Arrow 2-in-1 in hand,
not yet fitted. Airbox: still stock, removal planned for later. Fuel:
Canadian premium, ≤10% ethanol (E10). ECU not yet connected to
(`tunie info` never run) — ["Phase 1" in the project's own
`../STATUS.md`](../STATUS.md), still blocked on the Triumph diagnostic
connector cable.
## Two goals, and they sometimes pull in different directions
**Primary:** tune my own bike correctly for whatever mods actually go on
it — exhaust now, airbox later, whatever comes after that. Accuracy and
correctness win when this goal is in tension with the one below.
**Secondary:** make the tuning process non-invasive and simple enough that
this gets open-sourced and other riders can use these tools without
cutting/drilling/welding anything on their bike they can't easily undo.
These aren't always aligned, and it's worth being explicit about it rather
than letting decisions quietly optimize for one without saying so:
- The bung-reuse / device-flag-decoupling approach two sections down is a
case where both goals point the same way — no permanent modification
*and* it doesn't compromise the primary tune's correctness. When that's
possible, it's the obvious choice.
- Elsewhere they can conflict: e.g. two wideband sensors (one per
cylinder, pre-collector) gives better tuning accuracy (goal 1) but costs
more hardware and setup complexity than one sensor post-collector (goal
2). No blanket rule — flag the trade-off explicitly at each decision
point rather than silently picking one goal's answer.
- The open-sourcing goal also means the eventual tooling (Phase 0/4 in
`TUNING_IMPL_PLAN.md`) should stay agnostic to *which* hardware path a
given rider picked, rather than hard-coding this build's specific
choices — already the design intent there, worth keeping true as it
gets built out.
---
## Is the no-drilling ECU-streamed plan actually achievable? (Aug 2026 status check)
Short answer: **architecturally yes, nothing found so far says it can't
work — but it is not yet proven end-to-end, and it all sits behind the
one blocker this whole project has had since before this plan existed.**
Broken down honestly, piece by piece:
| Piece | Status |
|---|---|
| No drilling — reuse stock O2 bungs for wideband | **Solid.** M18x1.5 thread standard confirmed for both sensor types and this Triumph twin family specifically. |
| Wideband sensor + controller hardware | **Solid.** Off-the-shelf, well-understood (Innovate/AEM), just needs buying and wiring — execution risk only, no research risk. |
| ECU exposes RPM/TPS/coolant live | **Mechanism confirmed**, specific record IDs **not yet confirmed for this bike**. Recovered one candidate record array from the decompile (the Keihin-family "default" case), but which `Oe()` switch case actually applies to a Bonneville twin specifically hasn't been traced through the ECU-type-selection logic — it's a good candidate, not a verified one. |
| Wideband AFR into the onboard logger | **Solid**, standard ADC-reads-a-voltage problem, no open questions. |
| TuneECU's F Trim / Commit trims workflow | **Confirmed from documentation** (`android.html`), never operated hands-on — no bike has been connected yet, project-wide, per `../STATUS.md`. |
| Mapping log points to fuel-table cells | **Solid** — RPM/throttle axes already extracted and validated (`reference-maps/table_map.py`). |
| Polling-loop + onboard-unit integration code | **Not written yet.** Phase 0 of `TUNING_IMPL_PLAN.md` — straightforward extension of `tunie`'s existing transport, but zero lines of it exist right now. |
**The one hard blocker underneath all of it:** first ECU contact
(`tunie info`) has never happened — still stuck on the Triumph diagnostic
connector adapter, the same blocker `../STATUS.md` has tracked since
before this tuning plan existed. Record labeling, live-capture validation,
and confirming the F Trim workflow hands-on all require that connection to
exist first. Nothing about the plan is contradicted or looks unworkable —
it's just genuinely unverified past the paper-architecture stage until
that cable problem gets solved.
**So: achievable, yes. Achieved, not yet.** The path from here to "working
system" is: solve the cable → `tunie info` (resolves current stock map ID
as a side effect) → label the live records empirically → write the
polling-loop code (can actually start before the cable's solved, against
mocked data) → wire the wideband ADC → do a real capture → build the
first tune. No step in that chain currently looks like it won't work — but
none of them past "the cable exists" have actually been tried.
---
## The five ways to get a tune, ranked by effort/reliability
### 1. Pick an existing official TuneECU calibration (done, free, safest)
For Arrow 2-in-1 + E10 + mechanical odo, TuneECU's own catalogue already
has the validated answer: **map `20262` or `20313`** (same tune, two
catalogue reissues — see
[`reference-maps/PERMUTATIONS.md`](reference-maps/PERMUTATIONS.md) for the
proof they're identical bar one trivial byte). This is real engineering
data from Triumph/TuneECU, dyno-validated by them, zero DIY risk. Covers
exhaust + fuel type. **Does not cover SAI/O2 delete or airbox removal** —
TuneECU never ships those combined with an aftermarket exhaust, confirmed
by an exhaustive catalogue-wide keyword search (zero hits across all 1811
entries, any model) — see
[`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#existing-tunes-for-airbox-removal--2-1-exhaust-aug-2026).
### 2. Flip the SAI/O2 device flags on top of #1 (done, free, low risk)
`reference-maps/toggle_devices.py` flips the three device-enable bytes
(`0x53801` SAI, `0x53818`/`0x53819` O2×2 — found and independently
re-verified 3× across different reference maps, see
[`reference-maps/DEVICES.md`](reference-maps/DEVICES.md)) on any base map.
Already built: `reference-maps/derived/20262-noSAI-only.hex` (matches your
resistor-swap plan — see below) and `...-noSAI-noO2.hex`. Confirmed
byte-for-byte disjoint from the Arrow exhaust calibration, so this
composition is sound, not a guess
([`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#what-the-real-device-flag-file-tells-us-about-composing-arrow--delete)).
**Important, established this session:** this flag most likely only
**suppresses the fault-code/CEL logic** for a missing device — it does
*not* reliably change fueling behavior. Multiple forum sources describe it
this way, and TuneECU's own current Android UI still separately exposes
"Idle Fuel Trim (CO)" as *the* parameter specifically for "Triumph without
O²-Sensor" — i.e. the flag and the fueling correction are two different
things you both need, not one flag that does both.
### 3. Hand-compose a "good enough" trim correction (done, updated Aug 2026)
`reference-maps/compose_arrow_delete.py` sets **both** idle-trim bytes now
(`0x536AB` and `0x5369C`, part of the 32-entry "Idle Fuel Trim (CO)" table,
[`reference-maps/TABLES.md`](reference-maps/TABLES.md#fuelignition-trim-array--rpm-indexed-32-entries-0x53690-0x536af)) —
`0x5369C` was originally excluded as "exhaust-family-dependent," but that
was an artifact of reading the byte as unsigned; the table is signed, and
in signed space both bytes compose the same way (one saturates at `+127`).
**This composition is still scoped to the SAI/O2-delete case only — it is
explicitly NOT an airbox-removal tune.** A real dyno reference on the same
ECU family
(`20184dynoTuneSteveO2-Disable.hex`) showed a real airbox+open-pipe mod
rewrites nearly the *entire* 32-cell trim table plus a further ~50-byte
region we haven't mapped — see
[`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#major-find-a-real-dyno-tune-on-our-exact-ecu-generation-airbox-removed-aug-2026).
Output: `reference-maps/derived/20262-arrow-delete-composed.hex`.
### 4. Buy a matched commercial tune (real, currently blocked)
- **Triumph Twin Power (TTP)** had exact-match products (`Tune 11 2-1`:
airbox removal + 2-1 exhaust + O2/SAI removal) — but the storefront is
effectively wound down (founder retirement 2022, every SKU checked shows
"not in stock," hardware supply handed to a sister company). Don't count
on this.
- **DNK TuneWorks** ($275, mail-in or self-flash) — no airbox-specific
variant listed for the 865 air-cooled model, but they've built per-mod
variants off-catalogue before (per a customer review); worth emailing
directly. Watch for their *other*, wrong-model product page (liquid-cooled
2016+ T100) — easy to mix up.
- Full details: [`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#existing-tunes-for-airbox-removal--2-1-exhaust-aug-2026).
### 5. Road-tune it yourself, no dyno (documented, not yet tried, most promising for airbox)
A complete, concrete methodology exists and is **confirmed still usable in
the current TuneECU Android app** (not a dead Windows-only technique):
wideband O2 sensor + datalogger, drive fixed throttle positions through the
RPM range, log target-vs-measured AFR, feed the discrepancy into the **F
Trim** table (swipe right from the F/I table screen), **Commit trims**
(bakes correction into the permanent fuel table, resets trim to zero),
repeat 3-4 passes. A dyno session afterward becomes optional fine-tuning,
not a requirement. Full writeup, sourced:
[`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#major-find-a-documented-no-dyno-road-tuning-method-aug-2026).
**This is the actual answer to "how do we get airbox removal tuned
without paying a tuner."** It directly fixes what airbox removal breaks
(the fuel table no longer matching real airflow) using measured reality,
sidestepping the whole byte-composition problem in #3 entirely.
#### How it actually works, mechanically
The ECU's fuel table (`FuelMap`) stores intake air mass in mg for each
RPM/throttle-position cell. Hardware changes (exhaust, airbox) change the
real airflow at each cell without changing what's stored — the table goes
stale until someone re-measures it. A dyno does that by holding the bike at
load and reading a wideband sensor; this method does the same measurement
on the road.
1. **Get a truthful AFR reading.** The stock O2 sensor is narrowband — fine
for a 14.7:1 closed-loop target, useless for tuning richer WOT mixtures.
A wideband sensor + controller reads accurately across the whole range
actually being tuned (roughly 12.5-14.7:1).
2. **Flatten the baseline so it can't lie.** Before measuring, load a tune
with AF1/AF2 (target-AFR tables) both set flat to something safely rich
(13.0:1 in the original guide) and **F Trim** zeroed. This isolates "is
the fuel table wrong" from "was the target already weird here" — you
can't tell those apart if the target varies cell-to-cell too.
3. **Log real riding data.** Mark the throttle grip at the fuel table's
throttle breakpoints. On a quiet straight road, hold each mark steady,
sweep the RPM range, three passes per mark. Log RPM, table
row/cell, and measured-vs-target AFR at each point.
4. **Translate the log into corrections.** For each logged point, work out
the nearest fuel-table cell (interpolating between throttle
breakpoints) and how far off the AFR was — that error goes into the
**F Trim** table, a temporary zero-baseline overlay the same shape as
the main table.
5. **Commit and iterate.** **Commit trims** permanently bakes the F Trim
corrections into the real fuel table and zeroes F Trim again. Reflash,
repeat the same test. Converges in roughly 3-4 passes per the original
author.
6. **Finish it.** Once the base table is correct, replace the flat 13.0:1
placeholder with real AF1/AF2 targets (richer at full load for power,
leaner at part load for drivability/economy), one final commit.
7. **Dyno becomes optional** — at that point it's just fine-tuning via
F Trim on an already-correct base map, not the whole job.
#### Tools actually required — and why this isn't a simple process
Being honest about this rather than making it sound tidier than it is:
**Fabrication.** The wideband sensor needs a bung in the exhaust — either
weld one in (needs a welder, or a shop that will), or find/reuse an
existing bung (check whether Arrow's collector has a provision, or whether
the stock O2 bung is reusable). Not a bolt-on step.
**Electrical work.** Wideband controller needs switched 12V, ground, and
the sensor's connector wired in — same category of work as the SAI
resistor swap, just for a different, more sensitive sensor (wideband
sensors are also consumables that die if run rich/unheated wrong, adding a
"don't fry the $80 sensor" constraint the resistor job didn't have).
**A real integration gap, not just extra steps.** The German guide's slick
workflow — a single log row giving you table position *and* measured AFR
together, entered with one keypress — depended on a **TuneBoy-specific
cable with a built-in analog input**, a different product from TuneECU.
**It is not confirmed that TuneECU's cable has an equivalent.** Without it,
this becomes correlating *two separate logs* (the wideband's own
recording, and whatever position/RPM reference you have) after the fact,
by eye/timestamp — meaningfully messier and more error-prone than the
streamlined description implies. This is the single biggest unresolved
question before treating this as a smooth process: does TuneECU support
synced logging at all, or does every pass require manual correlation.
**A second software tool.** The wideband controller has its own logging
software/app (Innovate LogWorks, AEM's app, or onboard SD), separate from
TuneECU. Plus a spreadsheet to do the cell-correlation math by hand.
**Riding logistics, not just equipment.** Steady, repeatable throttle
positions at speed, on a quiet legal road, while a logger runs — solo,
that's watching a gauge/throttle marks/traffic/road all at once, which is
a real safety concern, not a paperwork one. A passenger to run the logger,
or a fully autonomous SD-logging setup that needs zero mid-ride
interaction, is the practical answer, not "just wear the gauge and go."
**Time.** Multiple ride-then-reflash cycles (3-4 minimum, per the guide),
each needing the bike, the logger, a suitable road, and a laptop/Android
device to reflash between passes. Realistically spread across more than
one outing, not an afternoon.
**Net honest assessment:** this is a real DIY-tuning project — fabrication
+ wiring + a second logging tool + an unresolved software-integration
question + genuine riding-safety logistics + several iteration cycles —
not a "flash a file and go" task. It's the most *correct* path (measures
your actual bike, not someone else's), but it's the highest-effort of the
five options in this document, not the easy one. Worth weighing again
against just paying DNK once/if they confirm an airbox variant (#4) —
that trades money for skipping all of the above.
---
## Better idea (Aug 2026): stream live data straight off the ECU with `tunie`
The messy "two separate logs correlated by hand" problem above assumed we
needed TuneECU's app/cable for the ECU side. We don't — `tunie` already has
verified, safe, read-only K-line access to this exact bike. Its own live
dashboard is built on the same mechanism, confirmed in the decompile:
- **Service `0x21` ReadDataByLocalIdentifier**, already implemented in
`tunie` (`src/tunie/kwp2000.py`, used today by `identify.py`'s one-shot
sweep) — same service TuneECU's live dashboard polls continuously via a
round-robin scheduler (`m.java`'s `se()`/`Oe()` functions).
- Records are **2-byte extended identifiers** (not the single-byte ones
`identify.py` currently sweeps), sent as `0x21 <hi> <lo>`. Recovered the
actual per-model record table for the Keihin-family default case (array
`ch` in `m.java:204`): decoded record values `0x0100`, `0x0001`,
`0x5103`, `0x0139`, `0x0007` (recurring across the 15 polled slots).
- **Not yet labeled** — which record is RPM vs. throttle position vs.
coolant temp isn't identified from the decompile alone (the field-name
mapping lives in UI code not yet fully traced). **Fastest way to finish
this isn't more static tracing — it's empirical, once the cable's
connected**: poll all 5-ish records in a loop with ignition on, rev the
engine / move the throttle by hand, and see which values respond to
what. RPM scales with engine speed, TPS jumps with throttle movement,
coolant temp changes slowly. Should take one bench session to label all
of them.
### What data we actually need, and where each piece comes from
| Data | Source | Status |
|---|---|---|
| RPM | ECU, live poll (`0x21`) | mechanism confirmed, record ID unlabeled |
| Throttle position (TPS) | ECU, live poll (`0x21`) | mechanism confirmed, record ID unlabeled |
| Coolant temp (exclude cold-engine data) | ECU, live poll (`0x21`) | mechanism confirmed, record ID unlabeled |
| Wideband AFR | **external hardware, no way around it** | ECU's own O2 is narrowband, useless for rich-mixture tuning regardless of any of the above |
Everything except the wideband AFR reading can come from the ECU itself,
via hardware already owned (the K-line/FTDI cable this project needs
anyway for `tunie info`) and code already 90% built (`tunie`'s existing
transport + safety layer, just needs a polling loop added instead of a
one-shot sweep, plus the 2-byte-record variant of `READ_DATA_BY_LOCAL_ID`).
**No new ECU-side hardware, no dependency on TuneECU's app, no uncertainty
about a proprietary analog-input cable** — that whole question from the
previous plan is moot.
### The "onboard live processing unit" idea — sound, and better than the alternatives
A small onboard computer (Pi Zero, ESP32-class board, etc.) running two
things in parallel, writing one synced timestamped log:
1. `tunie`'s KWP2000 poller hitting the labeled RPM/TPS/coolant records in
a tight loop over the K-line cable — safe by construction, same
read-only transport already verified against this bike's protocol.
2. An ADC channel reading the wideband controller's analog AFR output.
This **eliminates the correlation problem entirely** — one log, one
timestamp column, no post-ride guesswork matching a wideband recording
against hand-marked throttle positions. It also removes the riding-skill
burden from the original plan (no need to hit exact throttle marks by
feel — real TPS is captured directly, at whatever resolution the polling
loop achieves).
**Still required, unavoidably:** the wideband sensor + bung + controller
(external AFR hardware, see below) and someone actually riding the bike
through the RPM/throttle range while the logger runs — the bike still has
to physically experience the conditions being tuned for. What this
approach removes is the *ambiguity* in capturing that, not the ride itself.
**Concrete next steps, in order:**
1. Extend `tunie` with a 2-byte-record variant of `READ_DATA_BY_LOCAL_ID`
and a polling-loop mode (currently only does the one-shot `identify`
sweep). Software-only, doable now, no bike required.
2. First ECU contact (`tunie info`, per `../STATUS.md` — still blocked on
the connector cable) doubles as the session to empirically label which
record is which by watching values change.
3. Once labeled, wire in the wideband ADC channel alongside the KWP2000
poller on the same onboard unit.
4. Do the actual road/bench data capture, post-process off-bike into F
Trim corrections exactly as described above — just with a clean single
synced log instead of two to hand-correlate.
---
## Wideband O2 + datalogger setup plan (not yet acquired — hardware/shopping list)
The German guide's original hardware (Wayne Macdonald's proprietary
TuneBoy/TuneEdit cable with a built-in analog input) is **TuneBoy-specific,
not confirmed to exist for TuneECU** — this project uses TuneECU (Alain
Fontaine's tool), a different product built on the same underlying
Keihin/Sagem architecture. Don't assume TuneECU's cable has an equivalent
analog input; not verified either way.
**Practical path that works regardless:** use a self-contained wideband
controller with its own onboard/SD datalogging, rather than depending on
feeding the signal into TuneECU's cable directly. Correlate logs to fuel
table cells by throttle position + RPM (the German guide's own low-tech
method — marked throttle-grip positions, steady-state RPM sweeps) instead
of a live electrical link. Candidates, all with standalone logging:
- **Innovate LM-2** or **MTX-L** — two programmable analog outputs, widely
documented for this exact "log AFR, correlate to ECU table cells"
workflow on other platforms.
- **AEM X-Series UEGO** — 0-5V analog out + onboard SD logging, CAN option.
Both need: a bung in the exhaust for the sensor (weld-in, ideally before
any downpipe junction — check whether the Arrow 2-in-1's collector has a
provision, or where the stock O2 bung sits since it may be reusable), a
controller box (fits under the seat, same area the SAI solenoid used to
occupy), switched 12V power, and a way to read RPM alongside AFR (either
the wideband unit's own RPM input if it has one, or TuneECU's own live
Dashboard screen running in parallel, watched/logged manually).
**Setup steps, when the hardware is in hand** (this project can't do this
part — physical bung welding, sensor mounting, wiring):
1. Weld/fit the wideband bung, mount the sensor.
2. Mount the controller, wire switched 12V + ground + sensor.
3. Power on, let the sensor heat-soak per its instructions before trusting
readings.
4. Load a flat/rich baseline tune (per the German method: AF1/AF2 set to a
uniformly rich target, F Trim zeroed) so there's no lean risk during
logging.
5. Do the steady-state throttle sweeps, log AFR vs. target.
6. Enter corrections into TuneECU's **F Trim** table (swipe right from the
F table screen), **Commit trims**, reflash, repeat 3-4 passes.
7. Set real AF1/AF2 targets once the base fuel table is corrected.
This is genuinely the first step in this plan that requires physical
hardware acquisition and bike work — everything before it in this document
was achievable offline. Flag it to revisit once you're ready to buy parts.
## Stop the SAI solenoid clicking
Already physically removed the SAI plumbing; solenoid left wired, clicking
harmlessly (confirmed no DTC risk, no engine risk). Fix: swap the solenoid
for a **470 Ω / 0.5 W resistor** across the harness connector's two
solenoid pins (battery disconnected while wiring). Full procedure and
alternate 50 Ω/10 W option:
[`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#practical-silencing-a-removed-sai-solenoid-with-a-resistor-aug-2026).
Then flash `derived/20262-noSAI-only.hex` to suppress the resulting DTC —
device flag toggle is confirmed clean/disjoint from the Arrow calibration
(#2 above).
---
## After tuning: the wideband hardware isn't permanent
Once the F Trim corrections are validated and Committed into the permanent
FuelMap, the wideband sensors were a measurement tool, not a runtime
requirement — pull them and **plug the bungs with standard M18x1.5
threaded plugs, not a weld**, so they stay reusable for a future re-tuning
pass without welding again. Optional, not required: leave one wideband
installed permanently as an ongoing AFR gauge — worth it specifically
because a fully SAI/O2-deleted bike has zero live feedback watching for a
lean condition afterward. Detail: `TUNING_IMPL_PLAN.md` Phase 5.
## Best version yet: reuse the stock O2 bungs, make the O2-delete decision reversible too
**Instead of drilling anything, or permanently deciding O2 delete now:**
temporarily unscrew the stock narrowband O2 sensors from their existing
bungs, thread wideband sensors into those same stock locations for the
tuning sessions (M18x1.5 is standard — no adapter expected, though worth
confirming on the actual bike), then **put the stock narrowband sensors
back afterward**. Zero new holes, zero permanent exhaust modification, and
it reuses hardware the bike already has.
**This surfaces a real tension worth being explicit about, though:** the
original reason for wanting O2 delete wasn't the road-tuning project — it
was `RESEARCH.md` §9's finding that O2 delete lets the ECU run a richer,
cooler idle/cruise mixture and fixes snatchy off-idle response. That's a
**closed-loop** behavior change, at idle/steady-cruise. The wideband/F Trim
tuning process only ever corrects the **open-loop** main fuel table
(WOT/acceleration) — it doesn't touch idle/cruise closed-loop behavior at
all. So if the stock sensors go back in *and* the final flashed tune
re-enables the O2 device flag, you get an accurately-tuned WOT table for
the new exhaust, but the original snatchy/hot-idle problem O2 delete was
meant to fix **comes back** — the two projects don't automatically bundle.
**The actual best move: decouple the physical sensor from the software
flag.** Put the stock sensors back in the bungs either way (no permanent
mod, always reversible) — but the *final* flashed tune's O2 device flag is
an independent choice from whether a sensor happens to be physically
present:
- Flag **off**: ECU ignores whatever's in the bung, runs open-loop-derived
richer idle/cruise — gets the original SAI/O2-delete benefit, with a
stock sensor sitting there doing nothing (harmless).
- Flag **on**: full closed loop restored, snatchy/hot-idle behavior
returns, but with the WOT table now correctly matched to the exhaust.
Since nothing physical differs between these two states, **switching
between them later is just reflashing a different device-flag setting —
no hardware change either way, forever.** That's a stronger "no permanent
mods" outcome than just avoiding drilled holes: the O2-delete decision
itself becomes fully reversible, not just the sensor mounting.
**One session-scoped exception:** during the actual tuning rides
themselves, closed loop needs to be bypassed regardless of the final
plan — a wideband sensor occupying the narrowband's physical spot can't
also feed the ECU a valid narrowband signal, so the ECU can't run real
closed loop during that window. This matches the original German guide's
approach and is normal for any tuning session (dyno tuners do the same) —
it's a temporary session state, not a permanent change, and doesn't affect
which final flag setting gets chosen afterward.
**Practical note:** repeatedly swapping sensors in and out of the same
bung across multiple tuning passes is real wear — use anti-seize on the
threads to avoid the bung seizing after heat cycles.
## The one recurring trap: ECU generation
**Every dead end and wrong turn in this research traced back to the same
mistake: mixing mechanical-odometer and LCD-odometer map/ECU generations.**
They use different `ecu` type codes in `maps.json`, different absolute
byte offsets for the same logical parameter (confirmed: our device-flag
offset reads garbage on an LCD-odo file), and are not interchangeable in
any way. **Before using any map ID or byte offset found anywhere — forum,
vendor site, or this project's own derived files — check it's the
mechanical-odo family (`ecu='0'`, or explicitly labeled "Mechanical
odometer").** Wrong-generation map IDs that came up and got ruled out:
`20500`, `20505`, `20507`, `20516`, `20498...LCD`, `20500MapAirboxBonnie_LCD`.
---
## Local research assets (what's actually on disk)
- `reference-maps/*.hex` — real downloaded reference maps: stock
production/aftermarket (`20187`-`20192`), Arrow 2-in-1/2-in-2 all fuel
variants (`20262`-`20316`), the real SAI/O2 delete map
(`20188Map2009AIRBOXBONNY.hex`), the America dyno tune
(`20184dynoTuneSteveO2-Disable.hex`) plus its stock baseline
(`20185`/`20186`). Gitignored (`*.hex` at the samplez repo root) — these
are working files, not committed, so they won't survive a fresh clone;
re-download commands are in each research doc's history if needed again.
- `reference-maps/derived/` — our own composed candidates (SAI-only,
SAI+O2, the trim-composed Arrow variant). **None are known-flashable**
— the ECU flash checksum isn't solved (`docs/ROADMAP.md` Phase 3),
separate from everything in this document.
- `reference-maps/{decode_map,reconstruct_rom,table_map,toggle_devices,
compose_arrow_delete}.py` — the working toolchain: decrypt → flat ROM →
named tables → device-flag edit → trim-byte compose.
- `tuneecu_site_mirror/` — full local mirror of tuneecu.net's
documentation (built by `tuneecu_crawl.py`), including the German
road-tuning primer. The live site says it won't be updated after
September 2024 — this mirror is insurance against link rot.
- `PERMUTATIONS.md`, `TABLES.md`, `DEVICES.md`, `COMMUNITY_TUNING.md` — the
detailed research trail; this file is the index, those are the evidence.
## Open questions, if this gets picked up again
1. ~~Byte→percent scaling formula for the "Idle Fuel Trim (CO)" table~~ —
**upgraded, Aug 2026** (`reference-maps/TABLES.md`): found TuneECU's
own CO-Trim edit dialog and a live-value dispatcher, both independently
confirming the raw byte is **signed two's-complement (-128 to 127),
used directly with no scaling factor** — not the unsigned 0-255
interpretation used everywhere earlier in this project. This also
retroactively explains away the "255 looks like a sentinel" puzzle from
`PERMUTATIONS.md`: unsigned 255 is just signed -1, an ordinary
near-neutral value. Encoding confidence: low → medium-high. Still open:
the exact real-world unit (percent, or an ECU-internal unit close to
it) isn't separately confirmed. Matters for both project goals per the
reasoning below, now on firmer ground than when this was first flagged.
2. Map the ~50-byte region (`0x50010`-`0x50C21`) the America dyno tune
also rewrote — likely ignition/limiter/other trim, unmapped.
3. Identify `0x53822` — probably traction control or instrument-cluster
deactivate per the Android UI's device list, unconfirmed for this ECU
family.
4. Actually try the road-tuning method (#5) once the airbox mod happens —
the real next step, not more offline byte analysis.
5. First ECU contact (`tunie info`) — still blocked on the physical cable
adapter, per `../STATUS.md`. Would resolve which exact stock map is
currently flashed, a prerequisite fact this whole document has been
assuming rather than confirming.

View File

@@ -0,0 +1,436 @@
# Implementation plan: ECU-streamed, onboard-logged road tuning
Concrete engineering plan for the approach settled on in
[`TUNING_GUIDE.md`](TUNING_GUIDE.md#better-idea-aug-2026-stream-live-data-straight-off-the-ecu-with-tunie):
stream RPM/TPS/coolant live off the ECU via `tunie`'s existing read-only
KWP2000 transport, sample a wideband AFR sensor on the same onboard unit,
write one synced log, post-process off-bike into F Trim corrections. This
document is the task list; `TUNING_GUIDE.md` and `COMMUNITY_TUNING.md` are
the research backing each decision here.
**Status: not started.** Nothing in this plan has bike contact yet — Phase
0 is pure software and can begin immediately; everything after Phase 1
is blocked on the cable/connector work already tracked in `../STATUS.md`.
## The near-term validation game plan
Six concrete steps, checked against everything in this document and
`TUNING_GUIDE.md`, with the gaps filled in:
**0. Fix the cable.** Not one of the six steps below, but it's the actual
current blocker sitting in front of all of them — the Triumph diagnostic
connector adapter, tracked in `../STATUS.md` since before this tuning plan
existed. Nothing past this point has bike contact until it's solved.
**1. Prove basic ECU comms with `tunie` — `tunie info`.** Already the
correct first step per `../STATUS.md`; this plan doesn't change it. Gives
ECU identity, current map ID/DTCs, and — as a side effect — confirms the
cable/protocol work before adding live-poll complexity on top.
**2. Yank the current tune off the bike — real gap, needs a scope
decision, not just a step.** A ROM/map dump uses KWP2000 service `0x35`
RequestUpload, which `tunie` **deliberately does not implement** —
`safety.py` refuses it by construction, on purpose, as this whole
project's core safety guarantee (`README.md`: "Cannot write to the ECU by
construction"). Building this into `tunie` means consciously opening a
door that's been kept shut for a reason, not just adding a feature.
**Simpler alternative, use before building anything new:** TuneECU's own
Android app already has a working "Read Map" function
(`android.html`: "Read Map: Read the map from the ECU, only possible via
cable connection"). Use that for this one step — it's already built,
already proven, and its output (a `.hex` file) is exactly the input format
`decode_map.py`/`reconstruct_rom.py`/`table_map.py` already work with. No
reason to reimplement upload capability in `tunie` just to get a file
these tools already know how to read. Once dumped, cross-check its
signature/ID against `maps.json` to confirm exactly which stock
calibration is actually flashed — resolves an assumption this whole
research effort has been making (which map family applies) rather than
just producing a file.
**If "Read Map" ever fails partway through, don't panic-troubleshoot —
here's what's actually happening.** Traced TuneECU's Recovery mode
(`research/RECOVERY_MODE.md`) specifically so this is known *before* it's
needed rather than discovered live. Short version: Recovery is the same
reprogram function, relabeled — TuneECU checks whether the currently-open
map file decodes to a valid signature (same magic-header + directory
lookup `decode_map.py`/`table_map.py` already implement) and offers
Recovery instead of Reprogram if so. It is **not** detecting the ECU's
live fault state — it's checking you have a complete, valid base map ready.
Practical implication: **keep a matching OEM `.hex` (from `maps.json`,
already downloaded — `20187`/`20191` etc.) on hand before attempting the
dump**, so if the read fails partway, the documented community recovery
procedure is available: cycle ignition off/on, wait ~5s, open that
matching OEM map, reconnect, retry. Full trace, including what's still
unconfirmed (whether a live ECU-side signal is also involved), in
`RECOVERY_MODE.md`.
**3. Get live data, see what it actually gives us.** Two sub-parts, not
one:
- *What's there:* poll the candidate record list (Phase 0/1 below) and
see what responds at all.
- *Labeling, as its own deliberate task, not passive observation:*
ignition on/engine off, move the throttle by hand, watch which record
jumps → that's TPS. Start the engine, compare a record's value
against the tach → RPM. Let it idle and warm up, watch what climbs
slowly → coolant. **Contingency:** the candidate record list
(`0x0100`, `0x0001`, `0x5103`, `0x0139`, `0x0007`) is one plausible
branch recovered from the decompile, not a confirmed-correct one for
this specific bike (see Phase 0/1 above) — if none of them behave
sensibly, the fallback is a wider sweep of nearby record values and/or
going back to trace the `Oe()` switch case selection more rigorously
rather than assuming the guess was right.
- **Basic bench-test safety:** ignition-on/engine-off first, always;
if it gets to engine-running tests, that needs a stand and
ventilation considerations like any other bench run — not a research
question, just don't skip it.
**4. Design and build the onboard live-capture system.** Pi Zero (or
similar) running `tunie`'s poll loop + an ADC reading the wideband
controller's analog output, writing one synced log. CSV or SQLite both
fine for this — SQLite's nicer once you're joining/querying against table
geometry later, CSV is simpler to just eyeball. This is Phase 3 in this
document; **the wideband sensor/controller/mounting setup you already
flagged as probably-missing is real and belongs here** — see "Phase 2"
above (hardware, bung reuse, sensor count decision) for what that actually
involves; it's not just "buy a sensor," it's a genuine sub-project of its
own (mounting decision, wiring, warm-up handling, thread confirmation).
**5. Verify the captured data locally, build logs from it.** Sanity-check
against known references before trusting it: idle RPM should match a
handheld tach, TPS should track smoothly with a slow throttle sweep,
coolant should rise monotonically from cold start, AFR should read near
14.7 at a stable idle before any tune changes. This step is really "does
the whole capture chain produce believable numbers," which is a
prerequisite before trusting it for step 6.
**6. Compare live ride samples against the current map.** This is Phase 4
(post-processing) — map each logged point to a fuel-table cell using the
already-known RPM/throttle axes (`table_map.py`), see how far off the
stock table is from what's actually happening on the modified bike.
**What this game plan does *not* yet cover, worth naming explicitly:**
it's a validation/prototyping pass — proving the whole chain works and
seeing what the data looks like — not yet the actual iterative tuning
loop (F Trim corrections → Commit trims → reflash → repeat →
final AF1/AF2 targets, Phase 5 above). That's the next phase after this
one succeeds, not part of it.
---
## Phase 0 — software groundwork (no bike required, start now)
**Goal:** `tunie` can poll 2-byte extended local identifiers in a loop,
not just the one-shot single-byte sweep it does today.
- [ ] Add 2-byte record support to the local-identifier path. Today
`src/tunie/identify.py` sends `0x21 <1 byte>` (`LOCAL_ID_RECORDS`, single
byte). TuneECU's own dashboard sends `0x21 <hi> <lo>` for a wider record
space — confirmed in the decompile (`m.java`'s `se()`, records like
`0x0100`, `0x5103`, `0x0139`). Extend `Transport.request()` callers to
accept a 2-byte record variant; `safety.py`'s allowlist already permits
`0x21` generally (per `triumph.py`'s `OBSERVED_READ_SERVICES`), just
confirm it doesn't assume single-byte payload length anywhere.
- [ ] Add a **polling-loop mode**, distinct from `identify`'s one-shot
sweep: given a list of records, poll each in round-robin at the fastest
safe rate, timestamp every response, write to a log file (CSV or
similar). This is the core of what's needed — everything else in this
plan depends on it existing.
- [ ] Candidate record list to poll, from the recovered `ch` array
(Keihin-family default case, `m.java:204`): `0x0100`, `0x0001`,
`0x5103`, `0x0139`, `0x0007`. Unlabeled — Phase 1 resolves which is
which. Poll all of them; drop the unused ones once labeled.
- [ ] Write the labeling capture script: connects, polls all candidate
records continuously, dumps a live table to the terminal (record → raw
value, updating) so a person can watch and correlate against what
they're doing to the bike (revving, moving throttle by hand, waiting for
it to warm up).
- [ ] Decide the log format now, since it constrains the post-processing
script (Phase 4): one row per poll cycle, columns `timestamp_ms, rpm,
tps_raw, coolant_raw, <spare records>`. Keep raw values through this
layer — scaling/unit conversion happens once records are labeled and
their encoding is known (may not be linear; TPS especially could be a
raw ADC count needing calibration against the known throttle axis
breakpoints in `reference-maps/TABLES.md`).
**Acceptance:** a script exists that, given a connected bike, would print
a live-updating table of all candidate records with timestamps, with no
bike required to review/test the code path structurally (mock transport
for a dry run is enough to validate the polling loop and log-writing
logic before real hardware is available).
---
## Phase 1 — first ECU contact + record labeling (blocked on cable)
**Blocker:** the Triumph diagnostic connector adapter, per `../STATUS.md`
— unchanged blocker, not something this plan resolves.
**Goal:** know which of the candidate records is RPM, which is TPS, which
is coolant temp (and what any leftover ones are).
- [ ] Run `tunie info` for the first time — the original Phase-1 goal from
`../STATUS.md`, still the correct first step regardless of this plan.
Confirms cable/protocol work before adding the live-poll complexity.
- [ ] Ignition on, engine off (safe, no combustion risk): run the labeling
script from Phase 0. Move the throttle by hand — whichever record jumps
in response is TPS. Note idle values for the others.
- [ ] Engine running, idle: whichever record now shows a plausible RPM
value (four-figures, matches tach) is RPM. Blip the throttle to confirm
it tracks.
- [ ] Let the engine sit and warm up: whichever record climbs slowly over
minutes is coolant temp.
- [ ] Determine each record's raw→real-units scaling by comparing against
a known reference (tach for RPM, a multimeter on the TPS sensor wire or
the throttle's marked positions for TPS, a rough ambient/operating-temp
sanity check for coolant). Write the results into `triumph.py` or a new
`live_records.py` module, with the same "recovered from X, confidence Y"
documentation style the rest of this project uses.
**Acceptance:** a small table, committed to the repo, mapping record ID →
name → raw-to-real-units formula → confidence level, for RPM/TPS/coolant
at minimum.
---
## Phase 2 — wideband hardware (parallel-able with Phase 1, needs purchase)
Per `TUNING_GUIDE.md`'s shopping list — Innovate LM-2/MTX-L, AEM X-Series
UEGO, or **Zeitronix ZT-3** (a third real option, confirmed via an
external research pass Aug 2026 — Woolich Racing sells it as a
tuning-package wideband kit), all with a 0-5V analog output. This project
can't execute this phase (physical purchase, welding, wiring) — tracked
here so the software side knows what interface to design against.
- [ ] **Don't just thread the wideband into whatever bung is available —
masking risk, confirmed.** Long/angled stock-style bungs and
thread-reducing adapters (e.g. 18mm→12mm) can prevent the sensor tip
from reaching the core exhaust gas stream, giving delayed/inaccurate
readings — directly confirmed against a real installer's page
(RB Racing): *"Many manufacturers use long 18mm O2 slanted bungs. These
'mask' the 18mm O2 sensor signal"*; thread reducers are *"junk... the
threaded section is too long masking the signal."* Check bung depth/
angle against the sensor's own spec before assuming any available bung
works, whether stock or new.
- [ ] Check whether the Arrow 2-in-1's headers retain provisions at the
stock O2 bung locations before assuming a weld is needed — M18x1.5 is
the universal O2 sensor thread (narrowband and wideband both use it;
confirmed for this Triumph twin family specifically by the German
tuning primer), so if a bung survives, the wideband sensor threads
straight in, no fabrication required.
- [ ] Decide one sensor or two: the stock design has **two** O2 bungs
(one per cylinder, pre-collector — matches the ECU's per-cylinder
`F1`/`F2` fuel tables). One wideband sensor placed post-collector on the
2-in-1 gives a blended average AFR and loses per-cylinder correction
ability; two sensors (one per header, if those bungs survive on the
Arrow pipe) preserves it, at roughly double the sensor/controller cost.
Pick based on whether per-cylinder tuning resolution is worth it.
- [ ] Sensor + controller acquired, bung fitted, wired to switched 12V.
- [ ] Confirm the controller's analog-output voltage-to-AFR/lambda mapping
(from its manual) — needed for Phase 3's ADC scaling.
**O2 delete is not required by this method — that's specific to the
original German guide's setup, not ours.** The 2003 primer had the wideband
probe *physically replace* the stock narrowband sensor in its own port,
which is why it explicitly required disabling closed loop ("Ab jetzt muss
ein Tune ohne Lambdaregelung gefahren werden!" — "from now on a tune
without lambda regulation must be run") — the ECU's own narrowband signal
was gone, so closed loop couldn't function regardless. **Our plan doesn't
have that constraint**, because the wideband sensor sits in an independent
bung, feeding only our own logger — the stock O2 sensor(s) can stay fully
wired, active, and in closed loop the whole time if desired.
This matters because closed loop and the F Trim/road-tuning method operate
on *different parts of the map*: closed-loop O2 correction only runs at
idle and steady low-mid throttle; everything the road-tuning method is
actually correcting (WOT, hard acceleration, deceleration) is **already
open-loop on the stock ECU regardless of O2 presence**. So keeping O2
active doesn't get in the way of the tuning — if anything it's
complementary: O2 keeps handling live idle/cruise correction (one less
thing to get exactly right by hand), while the wideband-driven F Trim
process corrects the open-loop main fuel table, which O2 feedback never
touches, stock or modified.
**Practical options, now that this is a real choice rather than forced:**
1. **Keep both O2 sensors, wideband in a separate/reused bung.** Full
closed-loop behavior retained; wideband only used for tuning
measurement passes. Needs a bung location that doesn't conflict with
either stock sensor.
2. **Delete O2 (as originally planned for the SAI/O2 project), reuse the
freed bung(s) for the wideband.** Loses closed-loop idle/cruise
correction (richer, more consistent idle/cruise per the original
SAI/O2-delete motivation in `COMMUNITY_TUNING.md`, but no more live
auto-correction there either — needs the road-tuning method to get that
region right by hand instead of relying on O2 feedback).
3. **Hybrid** — delete/replace on one cylinder's bung for the wideband,
leave the other cylinder's O2 sensor active. Asymmetric, more complex,
probably not worth it unless bung availability forces it.
Decision isn't urgent — can be made once the Arrow pipe's actual bung
layout is known (Phase 2's first task above).
**For this build specifically, refined: option 2 (O2 delete), but via
temporary sensor swap rather than permanent bung reuse.** Instead of
pulling the stock narrowband sensors permanently, thread wideband sensors
into the *same stock bungs* for tuning sessions only, then put the stock
narrowband sensors back afterward — zero new holes, zero permanent
modification to the exhaust. The O2-delete *behavior* (richer/cooler
idle-cruise, the original motivation from `COMMUNITY_TUNING.md`) then
becomes a pure software choice — the ECU's device flag, independent of
whatever sensor happens to be physically sitting in the bung — rather than
something tied to permanently removing hardware. Full reasoning, including
the closed-loop-behavior tension this surfaces (WOT tuning and
idle/cruise-mixture behavior are separate axes, don't assume fixing one
fixes the other):
[`TUNING_GUIDE.md`](TUNING_GUIDE.md#best-version-yet-reuse-the-stock-o2-bungs-make-the-o2-delete-decision-reversible-too).
Session-scoped exception: closed loop still has to be bypassed *during*
the tuning rides themselves, since a wideband occupying the narrowband's
spot can't feed the ECU a valid narrowband signal at the same time — that's
temporary, not a permanent-mod question.
**For open-sourcing this to other riders who want to keep O2 active** —
worth supporting as first-class options, not assuming everyone deletes O2:
- **Considered and rejected: reusing the SAI injection point as a wideband
mounting location.** Doesn't work mechanically — SAI air enters through
openings cast into the cylinder head at the exhaust port junction (reed
valves live in the cam cover), not through a bolt-on fitting on a pipe.
There's no simple threaded port there to repurpose; doing this would mean
machining into the head casting. Not a real shortcut.
- **What does work: no-weld, clamp-on O2 bung adapters** — a standard
product (PLM, GlowShift, ProFlow, others), drill one hole, bolt on a
stainless clamp with a gasket, no welding. For O2-keeping riders on a
2-in-1: **one** clamp-on bung post-collector (single blended-AFR
reading, no per-cylinder resolution, but one new hole instead of two).
- **Zero-modification option, with a real cost:** clamp-on tailpipe-end
"sniffer" probes exist too — clamp around the exhaust tip, no drilling
at all. Trade-off: less accurate (ambient dilution, slower response,
less representative of true exhaust gas) — a real cost for zero
permanent modification, not a free option.
- **Design implication for the eventual tooling/writeup:** document this
as a menu (delete + reuse stock bungs / keep O2 + single clamp-on bung
post-collector / keep O2 + zero-modification tailpipe clamp / weld two
dedicated pre-collector bungs for full per-cylinder resolution) rather
than assuming one hardware path — the logging/post-processing pipeline
(Phase 3/4) doesn't care which one a given rider picks, it just needs an
AFR channel wired to the ADC either way.
**Don't wire the wideband's output back into the ECU's O2 input.** A
wideband sensor can't be wired directly into a narrowband-expecting ECU
pin at all (different sensor physics — pumped/Nernst cell needing its own
controller, vs. a simple heated-zirconia voltage source); some controllers
offer a "narrowband emulation" output to fake the ECU into thinking
nothing changed, but that mode discards the precision this whole project
needs, and the `0x21` diagnostic stream still wouldn't carry real AFR
either way. Since O2 delete is already part of the plan, the correct setup
is a fully standalone wideband — own bung, own controller, output only to
the Phase 3 logger's ADC, ECU's O2 circuit left disconnected/flagged off.
---
## Phase 3 — onboard unit integration
**Goal:** one small computer, riding along, producing one synced log per
session.
- [ ] Pick the board. Constraints: needs a USB port (or equivalent) for
the K-line/FTDI cable `tunie` already uses, an ADC input (built-in or via
a small ADC breakout) for the wideband's analog output, enough compute
to run `tunie`'s Python transport comfortably, and to survive vibration/
heat/vibration under the seat. A Raspberry Pi Zero 2 W (or similar) with
a cheap I2C ADC (e.g. ADS1115) is a reasonable default; an ESP32 is an
alternative if the KWP2000 polling loop gets ported to something more
embedded, but doing that port is extra work `tunie`'s existing Python
code doesn't need if a Pi-class board is used instead.
- [ ] Wire ADC channel to the wideband controller's analog output; confirm
voltage range matches the ADC's input range (may need a divider).
- [ ] Combine the two data sources into one process: KWP2000 poll loop
(Phase 0/1) and ADC sample loop, both timestamped against the same
clock, written to one log file per run (CSV: `timestamp_ms, rpm, tps,
coolant, afr`).
- [ ] Power: needs to run off the bike's switched 12V (same source as the
wideband controller) with a regulator appropriate to the board, or a
battery pack for bench testing before wiring it in permanently.
- [ ] Basic operational robustness: start logging automatically on power-up
(no need to interact with it mid-ride — this was a stated goal, since
operating a device while riding and marking throttle positions is a
safety concern per `TUNING_GUIDE.md`'s honest complexity assessment),
and fail safe (stop logging / flag clearly, don't crash silently) if the
K-line connection drops.
**Acceptance:** power the unit on, ride/rev through a test range, power
off, retrieve one CSV with all four channels populated and sensibly
timestamped.
---
## Phase 4 — post-processing pipeline (software, buildable now against synthetic data)
**Goal:** turn a captured log into F Trim table corrections, reusing the
table geometry already reverse-engineered (`reference-maps/table_map.py`'s
RPM axis, throttle axis, and table offsets).
- [ ] For each logged row, find the nearest fuel-table cell: RPM axis
lookup is direct (`table_map.py`'s `rpm_axis`, already extracted per-map);
TPS needs converting from whatever raw units Phase 1 lands on into the
table's throttle-axis units (`throttle_axis`, 0-1000 = 0-100.0%).
- [ ] For each cell with enough samples, compute the AFR error (measured
vs. the flat baseline target used during capture, per the road-tuning
method in `TUNING_GUIDE.md`) and produce a correction value.
- [ ] Output format: a per-cell correction table matching the F Trim
table's shape, ready to be entered into TuneECU's Map Edit F Trim screen
by hand (or, if worth the extra work later, generate a diff to apply
directly via the same decode/repack/encode pipeline
`toggle_devices.py`/`compose_arrow_delete.py` already use — flagged as
optional, manual entry into the app is the simpler and lower-risk MVP).
- [ ] This script can be written and unit-tested **now**, against
synthetic/fake log data, without waiting on any other phase — it only
needs the table geometry, which is already known.
**Acceptance:** given a CSV in the Phase 3 format, produces a per-cell
correction table.
---
## Phase 5 — the actual tuning loop (needs everything above, plus riding)
Execute the method from `TUNING_GUIDE.md`: flat rich baseline tune → ride
with the logger → Phase 4 processing → enter corrections into F Trim →
Commit trims → reflash → repeat 3-4 passes → set real AF1/AF2 targets →
done. Not further decomposed here; it's the method already documented, now
using the improved single-source logging instead of manual correlation.
### After tuning: what happens to the wideband hardware
The wideband sensors are a measurement tool, not a runtime dependency —
once the F Trim corrections are validated and committed into the permanent
FuelMap, the ECU doesn't need live AFR feedback to run correctly.
- [ ] Remove the wideband sensor(s).
- [ ] **Plug the freed bungs with standard M18x1.5 threaded plugs, not a
weld.** Keeps them reusable for a future re-tuning pass (e.g. after the
eventual airbox removal) without welding again.
- [ ] Optional, not required: leave **one** wideband sensor permanently
installed as an ongoing AFR gauge. Worth considering specifically
because this build ends up with no SAI and no O2 feedback at all —
zero live safety-net watching for a lean condition afterward (bad
injector, air leak, etc.). Common practice for fully O2-deleted setups;
doesn't need both sensors, just a dashboard readout.
---
## What can start today vs. what's blocked
| Phase | Blocked on | Can start now? |
|---|---|---|
| 0 — polling loop software | nothing | **yes** |
| 1 — record labeling | cable/connector (`../STATUS.md`) | no |
| 2 — wideband hardware | purchase + fabrication (not this project's job) | independently, whenever |
| 3 — onboard unit | Phase 1 (needs labeled records) + Phase 2 hardware | partially — board/ADC selection and wiring plan, yes; full integration, no |
| 4 — post-processing | table geometry only (already known) | **yes, against synthetic data** |
| 5 — tuning loop | everything | no |
**Recommended immediate next action:** Phase 0 (polling-loop code) and
Phase 4 (post-processing script skeleton) are both pure software, need no
hardware, and are the actual bottleneck-breakers — everything else is
either already blocked on the known cable issue or is a purchase/fabrication
task outside this project's scope.

View File

@@ -0,0 +1,219 @@
# The write/reprogram protocol — traced from the shared routine (Aug 2026)
Started by following the mode flags `af()` sets (`RECOVERY_MODE.md`) into
the actual write routine both normal reprogram and recovery share. This
turned into real protocol-level detail for `docs/ROADMAP.md` B4 (the
future write/flash path) — this is that research, started early because
the trace led here naturally, not because B4 is starting now.
**Nothing here changes `tunie`'s current posture.** `safety.py` still
refuses every service traced below. This is knowledge-gathering for when
that changes deliberately, per `safety.py`'s own standing instruction.
## The chain, start to finish
`fg = true` (set by `H8`'s `case 12:`, `RECOVERY_MODE.md`) is polled by
several per-response-tick handlers in `m.java` (found at lines 2130, 3436,
5682, 10274 — all structurally identical: `if (fg) { sc(false); }` in place
of the normal `se()` live-dashboard poll). So **entering write mode simply
means: stop polling live sensor data, start driving the write sequence
instead** — same tick loop, different branch, not a separate scheduler.
### `sc(boolean z)` (`m.java:9330`) — policy gate before any wire activity
- Walbro ECUs (`nf`) branch off entirely to `MODE_WALBRO_BREAK` — a
different family, different sequence, not traced here.
- **License/authorization check**: compares `MainActivity.Zb` (loaded
map's associated identifier) against `MainActivity.Lb`; mismatch (with
`i7 > 0`) aborts with `UNAUTHORIZED_MAP` and resets to `MODE_NULL`. This
is TuneECU's commercial licensing gate (paid maps tied to a registered
bike) — not a protocol safety check, irrelevant to `tunie` (which has no
such licensing model), but worth knowing it exists so it's not mistaken
for something protocol-relevant if this code is read again later.
- The `Of`-family branch (recall `Of = Rf | yf | wf | xf`, some ECU-family
flag combination) does a `k7` classification check, then just resets to
`MODE_NULL` and returns — **does not proceed to a write at all** via
this function. Unclear whether `Of` families use a completely different
write path elsewhere, or whether this is a genuine "not supported this
way" bail. Not traced further.
- Otherwise: if not `G5`/`H5` (a device-generation flag pair — plausibly
CAN-era vs. K-line-era Triumphs, unconfirmed), calls **`Fc(213)`** —
`213` = `0xD5`, **the Triumph K-line ECU address `triumph.py` already
documents**. If `G5`/`H5`, takes a different path (`Jg.D8(false)`,
not traced) — plausibly the CAN-bus-generation equivalent.
### `Fc(int address)` (`m.java:1271`) — connection/retry driver
- Resets a batch of protocol-state flags, **sets baud to 10400**
(`Ig.Yb(10400, true)`) — the normal K-line fast-init rate, matching
`tunie`'s own `kline.py` default.
- Retry counter `Ed`: gives up after 5 attempts with one of three error
dialogs (`ERR_FAILED` / `ERR_BAD_DEVICE` / `ERR_NO_ECU`) depending on
which flags are set, and resets `z.Td = 0`.
- Computes a UI status code (`i3`: 13 for address `0x43`, 9 for `0xD5`,
8 otherwise) shown via `MainActivity.Hb`, then **spins up a background
thread**: `new Thread(new a(address)).start()` — everything past this
point runs off the UI thread.
### `a` / `RunnableC0059a` (`m.java:273`) — the actual 5-baud slow-init bit-bang
This is the concrete protocol-level payoff of the trace. After a 100ms
sleep, it runs (on the UI thread, via `runOnUiThread`):
```java
int i2 = (address * 4) + 1025;
int i3 = 0;
byte b = 0;
while (i3 < 11) {
SystemClock.sleep(200L);
byte b2 = (byte) (i2 & 1);
if (b2 != b) {
z.Zb(b2); // toggles the K-line
}
i2 /= 2;
i3++;
b = b2;
}
z.Wd = 25;
z.ec(null, 3, false, false); // not traced further
```
**This is a standard ISO 14230 5-baud slow-init address transmission**,
bit-banged in software: 200ms per bit = exactly 5 bps, 11 iterations
covering a start bit + address byte + stop bit framing, encoded as
`(address * 4) + 1025` (for `0xD5`: `1877`) and shifted out LSB-first via
`z.Zb()` toggling the line directly. This is the same class of sequence
`tunie`'s `ELM_INIT_SLOW`/`--init slow` already implements for a different
trigger (fast-init timeout) — worth comparing bit-for-bit against
`kline.py`'s existing slow-init once this gets picked up seriously, since
they may already be equivalent or may reveal a discrepancy worth fixing.
## `z.ec()` traced — bottoms out in raw FTDI USB control transfers
`z.ec(null, 3, false, false)` (`z.java:1427`) — with `bArr=null`, the only
action is `Bd.v((byte)1)`. `Bd` (type `f.c.a.d`, `f.c.a` package) is
**TuneECU's own hand-rolled FTDI driver, talking directly to the Android
USB Host API** (`UsbDeviceConnection.controlTransfer` etc. — no external
SDK, they wrote their own FTDI D2XX-equivalent). Traced `v()` →
`w(boolean, boolean)`:
```java
private boolean w(boolean z, boolean z2) {
if (z) {
for (int i = 0; i < 6; i++) {
controlTransfer(0x40, 0, 1, ...); // FTDI SIO_RESET, purge RX -- x6
}
...
}
return z2 && controlTransfer(0x40, 0, 2, ...) == 0; // purge TX
}
```
`0x40`/`bRequest=0`/`wValue=1|2` is **FTDI's standard SIO_RESET_REQUEST**
with the documented RX-purge/TX-purge sub-codes (matches FTDI's own
AN232B-04 application note). `v((byte)1)` (our call) purges RX only,
repeated 6× (defensive retry against a known-flaky USB reset); recovery's
earlier `v((byte)3)` purges both RX and TX. **So this step isn't KWP2000
protocol logic at all — it's low-level USB hygiene**: clear out whatever
garbage may have accumulated on the wire during the slow-init bit-bang
before starting to listen for the ECU's response.
**Bonus, while in `f.c.a.d`:** found `A(byte, byte)`
(`controlTransfer(0x40, 11, ...)`) — `bRequest=11` is **FTDI's
SIO_SET_BITMODE_REQUEST**, i.e. FTDI bit-bang mode. This is almost
certainly what `z.Zb()` (the line-toggle call inside the 5-baud bit-bang
loop in `WRITE_PATH.md`'s earlier section) actually calls into — standard
technique for software 5-baud init on FTDI hardware, since normal UART
framing can't produce an arbitrary custom baud rate for just the wake-up
byte, so the driver drops into raw GPIO bit-bang mode to toggle TXD by
hand at the required timing. Worth checking whether `tunie`'s existing
`kline.py` slow-init already does the equivalent, or uses a different
technique (e.g. relying on the OS UART driver's own 5-baud support if the
FTDI chip/driver combo permits it directly).
## The response handler — found it, and the loop closes cleanly
Found what reads the response: `z.java`'s inner `Handler` class `a`
(`z.java:57`, the standard Android USB-receive-callback pattern) —
`handleMessage()` dispatches on a message type: `0` resets the `he`
timestamp and calls `Sb()` (receive setup), `1` is the actual data-received
case, logging the bytes then (for non-Walbro ECUs) calling **`m.Uc(ke, Nd,
Ld, Zd)`** — the core frame parser.
**`Uc()` (`m.java:4670`) is the ISO14230/KWP2000 response dispatcher, and
its very first branch is exactly the slow-init handshake this trace has
been building toward:**
```java
if (Cd == MODE_NULL) { // via the ordinal lookup table
if (bArr[i] == 0x55) { // ISO14230 sync byte
int kb1 = bArr[i+1];
if (kb1 == 0x08 || kb1 == 0xD9) { // recognized key byte 1 values
// ...reset ~20 session/ECU-family flags to false...
Fd = bArr[i+2] ^ 0xFF; // complement of key byte 2
Ue(d.MODE_INIT); // advance the state machine
}
}
}
```
This is the textbook ISO 14230-2 slow-init handshake, confirmed
end-to-end: the ECU replies to the 5-baud wake-up with **sync (`0x55`) +
key byte 1 + key byte 2**; the tester's required response is the
**bitwise complement of key byte 2**, computed right here (`Fd = kb2 ^
0xFF`) and presumably transmitted in whatever `MODE_INIT` does next (not
opened — natural following link, but the interesting part — recognizing
and correctly answering the handshake — is now confirmed). Full session
flag reset happens at the same moment, consistent with this being a clean
restart of connection state.
**`Uc()` is also the general-purpose ongoing frame dispatcher, not just
the wake-up handler** — later branches (`Cd` in other modes) pattern-match
various header byte sequences (e.g. `0x18 0xDA 0xF1...`, the CAN/ISO15765
extended-addressing header for the newer CAN-based Triumphs
`triumph.py` already notes) and route to a wide array of specific handler
functions (`Ic`, `Mc`, `Hc`, `Xe`, `bc`, `cc`, `Gc`, `Kc`, ...) — meaning
**one function handles both the K-line (`0xD5`/`0xF5`) and CAN-era
(`0x18DAxx`) Triumph protocols** in a single cascade, differentiated purely
by header pattern.
## Where the trace stops
The full initial connection sequence is now traced end-to-end: mode-flag
entry → policy gate → connection/retry driver → 5-baud bit-bang wake-up
(FTDI bit-bang mode) → USB buffer purge (FTDI reset/purge) → Handler
receives bytes → `Uc()` validates the sync+key-byte response and computes
the required handshake reply → state machine advances to `MODE_INIT`.
What `MODE_INIT` does with that computed complement byte (send it back,
await the ECU's own complement-of-tester-address reply, per standard
ISO14230 slow init) is the natural next thread, not yet pulled.
**This closes the loop this trace set out to close** — from a UI menu
click down to the literal handshake math — everything from here forward
is deeper (session establishment past the handshake, then SecurityAccess,
then the actual transfer) rather than a missing link in what's already
traced.
## Why this matters beyond Triumph (cross-pollination, per the original ask)
The `Of`-family bailout in `sc()` and the `nf` (Walbro)/`G5`/`H5` branches
all being *different code paths from the same entry point* reinforces the
same lesson `RECOVERY_MODE.md` already drew from the changelog: **the
high-level architecture (tick-loop flag-polling, shared write driver,
retry-with-backoff, slow-init wake-up) is generic across ECU
brands/families, but the specific sequence diverges by family at multiple
points along the way** (Walbro splits off immediately; `Of` families don't
even reach the wire; `G5`/`H5` skip the slow-init path entirely). A future
`tunie` write path built by generalizing from this trace should expect to
need Keihin-specific validation at each of those fork points, not assume
the Triumph/Keihin branch generalizes to other ECU families it hasn't been
checked against — same caution as before, now with concrete fork points
identified instead of just "expect divergence somewhere."
## Status
Real protocol detail recovered, genuinely useful for `docs/ROADMAP.md` B4
when that work starts for real. Still gated on the same prerequisites
`ROADMAP.md` already lists: validate the AES seed/key against a captured
real pair, and do this against a spare ECU before the bike's only one.
Nothing in this document changes that — it's ahead-of-time reconnaissance,
not a green light.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,16 @@
# Device-enable flags (SAI / O2 / lambda) — validated
**Aug 2026 update:** re-fetched `20188Map2009AIRBOXBONNY.hex` from its real
source — it's in `tuneecu.net`'s own custom-map archive
(`Twin_Custom_Tune_list.html`, not a forum attachment as previously assumed),
not gitignore-lost this time. Re-ran the diff against a freshly downloaded
stock `20188Map.hex` and **every claim below reproduced exactly**: same
1428-byte diff (0.36%), same three device-flag bytes, same two trim bytes.
The file now lives in this directory (`20188Map2009AIRBOXBONNY.hex`,
`20187/88/91/92Map.hex`) rather than resting on memory of a prior session.
See `../COMMUNITY_TUNING.md` for the new composability findings (device
flags vs. Arrow exhaust delta) this re-verification enabled.
## How they were found
Both stock reference maps (20187, 20188) have SAI and O2 **active**, so diffing
@@ -66,3 +77,80 @@ stock map can run lean/rough. A proper delete pairs flags-off with fuel enrichme
**Recommendation:** flash a complete, known-good delete map matching the hardware
(exhaust/filters), not hand-toggled flags on a stock map. The checkbox editor is
for understanding/building a map, not a one-click safe delete.
## `0x53822` — the extra flag found on the America dyno tune (Aug 2026)
Pushed on identifying this (index 33 past the `0x53801` base, one slot
past the two O2 sensors — see `COMMUNITY_TUNING.md`'s original finding on
`20184dynoTuneSteveO2-Disable.hex`). Real, bounded progress, not a full
resolution:
**Found the actual device name list** — `R.array.Devices` in
`arrays.xml:3-18`, 15 entries: `SAI, Exhaust Valve, O2 Sensor, 2nd
Throttle, Air Flap, EPC, O2 Sensor (2), Purge Valve, Idle Speed Control,
Traction Control, Immobilizer, Instruments, ABS, Speed Sensor (non ABS),
Race ECU`. This independently confirms and extends the existing SAI(0)/O2
Sensor(23)/O2 Sensor(2)(24) findings above — matches exactly, real data.
**Found the mechanism**: `l.java`'s `lc()` (`l.java:5742`) resolves the
per-device flat-ROM byte-boolean array from three packed nibble-encoded
constants (`x.t`/`x.s`/`x.q` in the decompile referenced by
`docs/ROADMAP.md`), looked up against `R.array.Devices` by index. **This
confirms flat-ROM byte position and `Devices[]` array index are not the
same thing** — SAI (array index 0) sits at byte 0, but O2 Sensor (array
index 2) sits at byte 23, O2 Sensor (2) (array index 6) sits at byte 24 —
a bike-specific sparse remapping, not a fixed formula.
**Where it stops:** the specific per-family constants that would decode
byte 33 precisely require tracing which branch of `x.java`'s ~46
assignments to the relevant classifier variable applies to Bonneville/T100
specifically. `docs/ROADMAP.md`'s existing note cites `i27=72` as the
anchor for this ECU family from a prior research pass, but that exact
comparison doesn't appear in this decompile snapshot — likely version
drift (this decompile's changelog shows APK updates through v6.4.36, June
2026; the `i27=72` note may predate that). Didn't find a reliable
replacement anchor without a much larger, low-confidence search across all
46 assignment sites.
**Best-supported guess, reasoning not proof:** narrowing the 12 unassigned
`Devices[]` names by plausibility for a 2010 mechanical-odo Bonneville/
America-class twin — **Purge Valve** (this project's own `system1.html`
research already documents a "Canister Purge Valve, California models
only" on this ECU family — a real, independently-corroborated candidate),
**Idle Speed Control** (the ISC stepper motor genuinely exists on this
engine), and **Immobilizer** (period-correct fuel-injected Triumphs
commonly used transponder immobilizers) are the most plausible of the 12;
**Exhaust Valve**, **2nd Throttle**, **ABS**, **Traction Control**, **Race
ECU** are implausible for this specific bike/era and can likely be ruled
out. Not confirmed — a genuine guess ranked by plausibility, not a finding.
**Update (Aug 2026) — independently verified, raises confidence further:**
checked `tests.html` in this project's own site mirror directly and found
a distinct, real testable menu entry: **"Purge Control Valve (Only bikes
with charcoal canister) — Activate the purge valve, listen for a very
quiet noise,"** listed right alongside its own separate "SAI" test entry.
This confirms Purge Valve is a real, independent device on this ECU
family with its own dedicated test/enable path, not a guess — genuinely
strengthens (does not yet prove) the byte-33 hypothesis. Also confirms
from the same list: "Air Flap (675 Daytona)" is explicitly Daytona-675-only,
matching this document's elimination reasoning above. (Surfaced by an
external research pass, then independently confirmed here directly
against this project's own source rather than trusted secondhand — see
`../EXTERNAL_RESEARCH_NOTES.md`.)
**What would actually resolve this:** a second real-world reference map
where the specific device at byte 33 changes state with a documented
description (the same technique that resolved SAI/O2 originally) would
settle it definitively without needing the `x.t`/`x.s`/`x.q` trace at all.
## Open question raised by community research (unresolved)
Multiple forum threads describe TuneECU's own SAI/O2 checkboxes (the same UI
these bytes drive) as **only suppressing the fault-code/CEL logic**, not
actually forcing open-loop fueling — see `../COMMUNITY_TUNING.md`. That's in
tension with "disabling O2 forces open-loop" above. Possible reconciliation:
the three device-enable bytes gate DTC logic only, and the *fueling* behavior
change lives in the other bytes the real delete-map diff also touched
(`0x5369C`, `0x536AB`) or in the lambda-target curve (`fe[2]`/`0x52260`,
`TABLES.md`). Not yet isolated — needs a second independent delete-map diff
to separate device flags from fuel/trim bytes.

View File

@@ -0,0 +1,100 @@
# Full mechanical-odo Bonneville permutation grid — cross-diff (Aug 2026)
Downloaded and cross-diffed every official mechanical-odo Bonneville
calibration TuneECU ships for the exhaust x fuel grid (up to VIN 739050):
stock production/aftermarket, Arrow 2-in-1, Arrow 2-in-2, each at E10 and
E25, across both catalog batches (`t202`/`t203`). 12 files, all reconstructed
to the flat 384 KB ROM with `reconstruct_rom.py` and diffed byte-for-byte.
IDs used: `20187 20188 20191 20192` (stock), `20262 20263 20264 20265`
(`t202` Arrow), `20313 20314 20315 20316` (`t203` Arrow).
## t202 vs t203: same tune, reissued
Every `t202`/`t203` pair with an identical catalogue description (e.g.
`20262` vs `20313`, both "Arrow 2 in 1, E10") differs by only **10 bytes**,
in two groups:
- 8 bytes at `0x4FFFA-B/E-F` and `0x5FFFA-B/E-F` — right at the `0x50000`/
`0x60000` block boundaries, almost certainly per-block ID/checksum
metadata (consistent with `DEVICES.md`/`README.md`'s prior finding that
the `caXX` footer bytes are map-ID metadata, not a calibration checksum).
- 2 bytes at `0x53626-27` — one real 16-bit calibration value changed. Small,
single-cell revision.
**Conclusion: `t202` and `t203` aren't different calibrations, just a
catalog-ID reissue with one tiny tweak.** Resolves the earlier open question
("which of 20262 vs 20313 is correct for us") — it doesn't matter, use
either.
## Fuel delta (E10 -> E25) is real, consistent, and localized
Matched pairs (same exhaust, VIN range, everything else equal, only fuel
spec differs):
| Pair | Exhaust | Diff |
|---|---|---|
| `20262` vs `20263` | Arrow 2-in-1 | 4809 bytes (1.22%) |
| `20264` vs `20265` | Arrow 2-in-2 | 4562 bytes (1.16%) |
| `20313` vs `20314` | Arrow 2-in-1 (t203) | 4809 bytes — **identical set** to `20262`/`20263` |
| `20315` vs `20316` | Arrow 2-in-2 (t203) | 4562 bytes — **identical set** to `20264`/`20265` |
| `20187` vs `20191` | stock production | only 60 bytes (0.02%) |
The E10->E25 delta for Arrow 2-in-1 vs Arrow 2-in-2 overlaps 93% (4455 of
4562-4809 bytes) — same fuel-table region touched regardless of exhaust,
which is exactly what you'd expect (ethanol compensation lives in the fuel
tables, not the exhaust-specific cells). The stock-production E10->E25 delta
being tiny (60 bytes) while the Arrow one is ~80x larger is odd at first
glance, but `20187` carries no VIN-range/fuel spec in its description at
all — it and `20191` likely aren't a clean matched pair the way the Arrow
E10/E25 pairs are (`20191` adds a VIN-range qualifier `20187` lacks). Treat
`20262`/`20263` as the trustworthy E10-vs-E25 reference, not `20187`/`20191`.
## Exhaust delta and fuel delta are NOT independent/disjoint
Checked whether the "stock -> Arrow 2-in-1" delta and the "E10 -> E25" delta
touch separate bytes (which would let us compose them by simple
copy/overlay):
- Exhaust delta (`20187` -> `20262`): 7548 bytes
- Fuel delta (`20262` -> `20263`): 4809 bytes
- **Overlap: 4149 bytes** — the large majority of the fuel delta's footprint
is inside the exhaust delta's footprint too.
**This means naive delta composition (baseline + delta A's changed bytes +
delta B's changed bytes) doesn't work cleanly** — both mods rewrite
overlapping cells in the same VE/fuel tables (expected: richness-for-exhaust
and richness-for-ethanol are both corrections to the same underlying fuel
surface, so of course they land on the same cells). You can't just XOR two
independent byte-level diffs onto a base map; you'd overwrite one mod's
change with the other's rather than combining them. Real composition would
need table-level math (read both as VE values, apply both corrections
arithmetically), not byte-patching — a materially bigger undertaking than
`toggle_devices.py`'s simple flag flip.
## What this means for "build our own Arrow + SAI/O2-delete tune"
**Good news:** the exhaust x fuel grid is *already solved* — TuneECU
engineers already shipped the exact validated combination you want
(`20262`/`20313`, Arrow 2-in-1, E10). No synthesis needed there; picking the
catalog entry is strictly better than trying to reconstruct it from deltas.
**Bad news, unchanged from `COMMUNITY_TUNING.md`:** none of these 12 files
vary the SAI/O2-delete axis — TuneECU never shipped an Arrow-exhaust variant
with devices off. The grid we now have doesn't help fill that gap, because
the gap isn't on this grid. We're still bottlenecked on the single external
`2009AIRBOXBONNY.hex` reference (not currently in the repo, needs
re-fetching) for that axis, and even that reference conflates "no airbox"
with "no SAI/no O2" in one diff (per `DEVICES.md`), which the exhaust-vs-fuel
overlap finding above suggests may be a *real* problem, not just a
theoretical one — table-level effects don't separate cleanly by simple
byte diffing when two mods land on the same cells.
**Revised recommendation:** don't try to hand-synthesize "Arrow 2-in-1 +
real SAI/O2 delete" from byte deltas — the overlap finding shows that's not
sound with the tooling we have (byte-diff, not semantic table-diff). Either:
1. Decode the fuel tables to actual VE values (`table_map.py`/`TABLES.md`
already locate them) and do the composition arithmetically, cell by cell,
rather than at the byte level — a real project, not a script tweak.
2. Or just use a real matched commercial tune (DNK, since TTP's storefront is
effectively defunct) rather than DIY-composing one.

View File

@@ -48,6 +48,91 @@ these richer. Exact dimensions still being confirmed.
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).
## Fuel/ignition trim array — RPM-indexed, 32 entries, `0x53690`-`0x536AF`
**Located and axis-mapped (Aug 2026).** A 32-byte array sitting just before
the device-flags region (`0x53801`, `TABLES.md` above / `DEVICES.md`), one
byte per **RPM breakpoint on the same 32-point RPM axis** as the main
tables (`fe[8]`: 0, 500, 900, 1000, ..., 10000). Not addressed via a `fe[]`
pointer for the maps checked (no `fe` field in `c.a[Qd*48]` equals `0x3690`
for `20187`'s `Qd=32`) — likely a fixed offset relative to the device-flags
struct rather than an independently relocatable table, unconfirmed.
Evidence for the RPM-axis mapping: diffing `20188Map2009AIRBOXBONNY.hex`
(the Bonneville SAI/O2 delete map) against stock `20188` shows **exactly 2
of the 32 cells changed — at RPM=2400 and RPM=7000** — with all 30 other
RPM-indexed cells byte-identical. That's `0x5369C` (index 12, RPM 2400) and
`0x536AB` (index 27, RPM 7000) from earlier sessions, now understood as two
specific points on this curve rather than free-floating bytes.
Cross-checked against `20184dynoTuneSteveO2-Disable.hex` (America,
open-pipe + airbox-snorkel-removed + O2-disable, real dyno tune) vs. stock
`20186`: **nearly every one of the 32 cells changed**, by large,
non-monotonic swings (e.g. RPM=1000: 231→63, RPM=4400: 235→5, RPM=5200:
25→204). Not a smooth curve shift — looks like cell-by-cell dyno-session
adjustment (consistent with a human tuner nudging individual RPM points
after successive pulls) rather than a single global correction. Confirms
this is a real, independently-tunable-per-cell array, not a scalar.
**Identity: confirmed as "Idle Fuel Trim (CO)".** Crawled tuneecu.net's full
docs site (`../tuneecu_site_mirror/`) and found the exact match in
`TuneECU_En/tests.html`, TuneECU's live-diagnostics parameter list:
> "Idle Fuel Trim (CO): lets you adjust the fuel richness at idle."
> Available for "**Triumph without O²-Sensor only**."
> (Counterpart: "Long Term Fuel Trim... Triumph models **with** O²-Sensor
> only" — the closed-loop equivalent for bikes that still have the sensor.)
This is a direct, official match to what we observed empirically: the table
is specifically the O2-delete-relevant trim, exactly why it's what the real
delete/dyno maps touch and the stock maps don't need to. `co_trim`/`ift_co`
string resources (`strings.xml`: "CO Trim" / "Idle Fuel Trim (CO)") back
this — "CO" = idle mixture richness, the classic idle-adjustment convention
carried over from carbureted-era tuning terminology.
**Encoding, upgraded confidence (Aug 2026):** found TuneECU's actual "CO
Trim" edit dialog (`MainActivity.java`'s `La()`) and its value-scaling
logic. Two independent code paths converge on the same answer:
1. The dialog's clamp/display logic (`j8()`) treats the value as a plain
signed range: `if (v < -128) v = -128; else if (v > 127) v = 127;`,
displayed as `Integer.toString(v)` — no fractional scaling.
2. A separate live-value dispatcher (`n.java`, a large switch handling
various PID-style codes) has a case doing manual two's-complement
conversion — `if (raw > 127) raw -= 256;` — immediately before storing
into the same field (`MainActivity.Mc`) the CO Trim dialog reads.
**Conclusion: the raw byte is signed two's-complement (-128 to 127), used
directly with no multiply/divide scaling factor** — not the unsigned 0-255
interpretation this project used everywhere earlier. **This retroactively
resolves the "255 looks like a sentinel" puzzle** from the exhaust-family
composability analysis (`COMMUNITY_TUNING.md`/`PERMUTATIONS.md`): unsigned
`255` is simply signed `-1` — an entirely ordinary, near-neutral value, not
a special case. Re-reading the earlier data with correct signs: the stock
aftermarket-silencer family sits near `-1` (neutral), production/Arrow
sits around `+69` to `+125` (already noticeably rich), and the real
SAI/O2 delete map pushes to `+117` — a large, deliberate enrichment,
exactly the physically-sensible story this parameter's name would predict.
The composability conclusion (can't cleanly merge Arrow's delta with the
delete map's delta) still holds — redone with correct signs, the combined
correction still saturates past the `+127` clamp — but now for an
intuitive reason (the needed correction is genuinely large) instead of an
unexplained sentinel.
**Not fully closed:** the *encoding* (signed int8, no scaling) is now
well-supported by two independent sites; the exact real-world *unit* (is
`+1` precisely "+1% CO", or a nearby-but-not-identical ECU-internal unit)
isn't separately confirmed by either site. Raised from low to medium-high
confidence — upgraded, not fully resolved.
**Practical implication for composing tunes:** don't treat this array as
"2 conflicting bytes" the way `compose_arrow_delete.py` originally did —
it's a full 32-cell curve. A conservative mod (mufflers only) may touch 2
cells; a more aggressive one (open pipes + airbox) touches nearly all 32.
Composing two independent tunes' curves here needs the same per-cell
arithmetic treatment as the main VE tables (`PERMUTATIONS.md`), not a
handful of spot fixes.
## SAI / O2 / lambda flags
Now findable via the localised flat-ROM diff (20187 vs 20188), which isolates small

View File

@@ -0,0 +1,65 @@
"""Flat-ROM checksum for the Triumph Keihin twin family -- reverse-engineered
and validated Aug 2026 (see ../WRITE_PATH.md for the full trace).
Source: `com.tuneecu.l.Ac(int, boolean)` in the decompiled TuneECU app,
called from MainActivity's map-info display ("Checksum : %04x", flagged
"*No-OEM"/"Error" on mismatch). This is a 16-bit sum-of-words checksum over
the calibration region of the flat ROM (0x50000-0x60000 for this ECU
family -- the exact same base/end this project's own `table_map.py` already
uses), stored in the last 2 bytes of that region.
Validated against 6 real, unmodified, official TuneECU downloads:
20187/20188/20191/20192/20262/20313 -- all computed == stored, exact.
One deliberate non-match, informative rather than a bug: the community
SAI/O2-delete file (20188Map2009AIRBOXBONNY.hex) has the exact same stored
checksum as its unmodified base (20188Map.hex) -- whoever built it edited
a few bytes by hand and never recomputed the checksum. TuneECU's own code
path for this looks like a *display/validity* check (flags "*No-OEM" /
"Error" in the UI) rather than a proven hard ECU-side write gate -- that
community file apparently worked for people despite the stale checksum,
which is consistent with this being an app-side sanity indicator rather
than (or in addition to) something the ECU's own bootloader independently
verifies during the real flash transfer. That ECU-side question is NOT
resolved by this -- see caveats below.
"""
from __future__ import annotations
CAL_BASE = 0x50000
CAL_END = 0x60000
def compute(rom: bytes, base: int = CAL_BASE, end: int = CAL_END) -> int:
"""16-bit sum-of-words checksum over rom[base:end-2], byte-swapped read
(matches `l.Ac()`'s `bArr[pos ^ 1] | (bArr[pos] << 8)` exactly)."""
length = (end - base) - 2
total = 0
i = 0
while i < length:
pos = base + i
total += rom[pos ^ 1] | (rom[pos] << 8)
i += 2
return total & 0xFFFF
def stored(rom: bytes, end: int = CAL_END) -> int:
return (rom[end - 2] << 8) | rom[end - 1]
def patch(rom: bytearray, base: int = CAL_BASE, end: int = CAL_END) -> None:
"""Recompute and write the checksum into rom[end-2:end] in place."""
value = compute(bytes(rom), base, end)
rom[end - 2] = (value >> 8) & 0xFF
rom[end - 1] = value & 0xFF
if __name__ == "__main__":
import sys
from reconstruct_rom import flat_rom
for path in sys.argv[1:] or ["20187Map.hex"]:
rom = flat_rom(open(path, "rb").read())
c, s = compute(rom), stored(rom)
print(f"{path:40s} computed={c:04x} stored={s:04x} {'MATCH' if c == s else 'MISMATCH'}")

View File

@@ -0,0 +1,110 @@
"""Compose an Arrow 2-in-1 + SAI/O2-delete candidate map.
Builds on `toggle_devices.py`'s clean, conflict-free device-flag toggle
(0x53801/0x53818/0x53819 -- verified disjoint from the Arrow exhaust delta,
see COMMUNITY_TUNING.md) and additionally resolves the two "Idle Fuel Trim
(CO)" bytes that conflict between the Arrow calibration and the real
20188Map2009AIRBOXBONNY.hex delete map.
**Aug 2026 correction:** both bytes are now composed. The original version
of this script left 0x5369C untouched, reasoning it was "exhaust-family
dependent" (stock aftermarket-silencer maps held unsigned 255, which looked
like a sentinel distinct from the ~104 held by production/Arrow maps). That
reasoning was built on an **unsigned** byte interpretation. `TABLES.md`
later established this whole table is **signed** two's-complement -128..127,
confirmed from two independent code paths in the TuneECU app. Redone in
signed space: unsigned 255 is simply signed -1 -- an entirely ordinary
near-neutral value, not a sentinel at all. There is no regime conflict; the
byte composes the same way 0x536AB always did, it just saturates.
0x5369C -- composed additively, signed. stock188=-1, delete=+117, delta
= +118. arrow=+69 + 118 = +187, clamped to the signed max +127. The
correction genuinely wants this cell pushed to its richest possible
value at this RPM point -- clamping to +127 is the correct outcome, not
a sign the byte is unusable, and it's a real, meaningfully different
result from the prior version's "leave at Arrow's +69" default.
0x536AB -- composed additively, signed, same as before (the arithmetic
is invariant to signed vs. unsigned interpretation as long as nothing
overflows the representable range, which this one doesn't): stock188
=-128, delete=-118, delta=+10, arrow=-123 + 10 = -113 -> byte 0x8F (143).
Usage:
python3 compose_arrow_delete.py 20262Map.hex 20188Map.hex \\
20188Map2009AIRBOXBONNY.hex out/20262-arrow-delete-composed.hex
"""
from __future__ import annotations
import sys
from decode_map import decode, encode
from toggle_devices import SAI, O2_1, O2_2, repack, unpack
TRIM_BYTES = (0x536AB, 0x5369C) # both now composed, signed arithmetic
def _s8(v: int) -> int:
return v - 256 if v >= 128 else v
def compose(arrow_raw: bytes, stock_after_raw: bytes, delete_raw: bytes) -> tuple[bytes, list[str]]:
arrow_dec = decode(arrow_raw)
arrow_rom, positions = unpack(arrow_dec)
arrow_rom = bytearray(arrow_rom)
stock_rom, _ = unpack(decode(stock_after_raw))
delete_rom, _ = unpack(decode(delete_raw))
notes = []
for addr, label in ((SAI, "SAI"), (O2_1, "O2 sensor 1"), (O2_2, "O2 sensor 2")):
before = arrow_rom[addr]
arrow_rom[addr] = 0
notes.append(f"0x{addr:05X} {label:<14} {before} -> 0 (device flag, conflict-free)")
for addr in TRIM_BYTES:
a_stock = _s8(stock_rom[addr])
a_delete = _s8(delete_rom[addr])
a_arrow = _s8(arrow_rom[addr])
delta = a_delete - a_stock
synth = max(-128, min(127, a_arrow + delta))
before = arrow_rom[addr]
after = synth & 0xFF
arrow_rom[addr] = after
notes.append(
f"0x{addr:05X} trim (signed) {a_arrow:+d} + delta {delta:+d} = {synth:+d}"
f" (byte {before} -> {after})"
)
repacked = repack(arrow_dec, bytes(arrow_rom), positions)
out = encode(repacked)
assert decode(out) == repacked, "round-trip failed"
return out, notes
def main() -> int:
if len(sys.argv) != 5:
print(
"usage: compose_arrow_delete.py <arrow.hex> <stock_aftermarket.hex> "
"<delete_reference.hex> <outfile.hex>",
file=sys.stderr,
)
return 2
arrow_path, stock_path, delete_path, out_path = sys.argv[1:5]
out, notes = compose(
open(arrow_path, "rb").read(),
open(stock_path, "rb").read(),
open(delete_path, "rb").read(),
)
open(out_path, "wb").write(out)
for n in notes:
print(" ", n)
print(f"wrote {out_path} ({len(out)} bytes)")
print("NOTE: flash checksum not patched -- not known to be flashable as-is.")
print("NOTE: main VE fuel-table overlap (~1340 bytes) still unresolved -- see COMMUNITY_TUNING.md.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,110 @@
"""Flip SAI/O2 device-enable flags in a TuneECU map, in place.
Byte-boolean array in the flat ROM: 0x53801 = SAI, 0x53818 / 0x53819 = the two
O2 sensors (1 = enabled, 0 = disabled). Found by diffing stock 20188 against the
community "NO SAI, NO O2 SENSORS" reference map -- see DEVICES.md for the full
derivation and confidence levels (SAI ~90%, O2 ~80%).
This edits the *download-format* map (decode -> flip bytes in the flat ROM ->
repack into the decoded map's own layout -> re-encode). It does NOT touch the
ECU flash checksum (out of scope, unsolved -- see the "Editing / export" section
of DEVICES.md), so the output is for the viewer / further analysis only. It is
NOT known to be flashable as-is.
Usage:
python3 toggle_devices.py 20262Map.hex out/20262-noSAI-noO2.hex
python3 toggle_devices.py --only-sai 20262Map.hex out/20262-noSAI.hex
"""
from __future__ import annotations
import argparse
from decode_map import decode, encode
SAI = 0x53801
O2_1 = 0x53818
O2_2 = 0x53819
FLAT_MARKER = 0x6F66
_LABELS = {SAI: "SAI", O2_1: "O2 sensor 1", O2_2: "O2 sensor 2"}
def _le(buf: bytes, o: int, n: int) -> int:
return int.from_bytes(buf[o : o + n], "little")
def unpack(decoded: bytes) -> tuple[bytearray, list[tuple[int, int, int]]]:
"""Reconstruct the flat ROM from a decoded map; also return the (packed_pos,
dest_offset, length) triples needed to repack it afterwards."""
i21 = _le(decoded, 28, 2)
if _le(decoded, i21 + 31, 2) != FLAT_MARKER:
raise ValueError(f"bad unpack marker at 0x{i21 + 31:X}")
count = decoded[i21 + 33]
entries = [
(_le(decoded, i21 + 34 + k * 8, 4), _le(decoded, i21 + 38 + k * 8, 4))
for k in range(count)
]
p = i21 + 34 + count * 8
rom = bytearray(b"\xff" * max(off + ln for off, ln in entries))
positions = []
for off, ln in entries:
rom[off : off + ln] = decoded[p : p + ln]
positions.append((p, off, ln))
p += ln
return rom, positions
def repack(decoded: bytes, rom: bytes, positions: list[tuple[int, int, int]]) -> bytes:
"""Inverse of unpack(): copy the (possibly modified) flat ROM back into the
decoded map's packed layout."""
out = bytearray(decoded)
for p, off, ln in positions:
out[p : p + ln] = rom[off : off + ln]
return bytes(out)
def toggle(raw: bytes, addresses: tuple[int, ...]) -> tuple[bytes, list[tuple[int, str, int, int]]]:
"""Return (re-encoded .hex bytes, [(address, label, before, after), ...])."""
decoded = decode(raw)
rom, positions = unpack(decoded)
changes = []
for addr in addresses:
before = rom[addr]
rom[addr] = 0
changes.append((addr, _LABELS.get(addr, f"0x{addr:X}"), before, 0))
repacked = repack(decoded, bytes(rom), positions)
out = encode(repacked)
assert decode(out) == repacked, "round-trip failed after repack"
return out, changes
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("infile")
ap.add_argument("outfile")
ap.add_argument("--only-sai", action="store_true", help="disable SAI only")
ap.add_argument("--only-o2", action="store_true", help="disable both O2 sensors only")
args = ap.parse_args()
if args.only_sai:
addrs = (SAI,)
elif args.only_o2:
addrs = (O2_1, O2_2)
else:
addrs = (SAI, O2_1, O2_2)
raw = open(args.infile, "rb").read()
out, changes = toggle(raw, addrs)
open(args.outfile, "wb").write(out)
for addr, label, before, after in changes:
print(f" 0x{addr:05X} {label:<14} {before} -> {after}")
print(f"wrote {args.outfile} ({len(out)} bytes)")
print("NOTE: flash checksum not patched -- not known to be flashable as-is.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,148 @@
"""Polite same-domain BFS crawler for tuneecu.net -- mirrors the docs site
locally so we can grep/read full raw pages instead of relying on one-page-
at-a-time AI-summarized fetches (which have already been shown to drop
detail, e.g. the glossary needing a second full pass).
No robots.txt on tuneecu.net (404s). Still polite: single-threaded, delay
between requests, capped page count, HTML only (binaries/zips are recorded
as leaf links but not fetched/recursed into -- there's no reason to mirror
the whole map-download tree, just discover what's there).
Usage:
python3 tuneecu_crawl.py # default seeds, depth 10
python3 tuneecu_crawl.py --max-pages 300
"""
from __future__ import annotations
import argparse
import re
import time
import urllib.error
import urllib.request
from collections import deque
from pathlib import Path
from urllib.parse import urljoin, urlparse
DOMAIN = "tuneecu.net"
OUT_DIR = Path(__file__).parent / "tuneecu_site_mirror"
SEEDS = [
"https://tuneecu.net/TuneECU_En/index.html",
"https://tuneecu.net/TuneECU_En/links.html",
"https://tuneecu.net/TuneECU_En/glossary.html",
"https://tuneecu.net/TuneECU_En/mapedit.html",
"https://tuneecu.net/Map_Database.html",
]
HREF_RE = re.compile(r'href\s*=\s*["\']([^"\']+)["\']', re.I)
HTML_EXTS = (".html", ".htm", "") # "" covers dir-style URLs, treated as html
NON_HTML_EXTS = (
".zip", ".hex", ".pdf", ".mp4", ".jpg", ".jpeg", ".png", ".gif",
".dat", ".md5", ".exe", ".bin", ".rar", ".7z", ".doc", ".docx",
)
# Leaf file tree -- thousands of per-model tune files/checksums, not docs.
# Record links into it (so we know what's there) but never recurse.
NO_RECURSE_PREFIXES = ("https://tuneecu.net/tunes_in_hex_and_dat/",)
def _is_html(url: str) -> bool:
path = urlparse(url).path.lower()
if any(path.endswith(ext) for ext in NON_HTML_EXTS):
return False
return True
def _no_recurse(url: str) -> bool:
return url.lower().startswith(NO_RECURSE_PREFIXES)
def _same_domain(url: str) -> bool:
return urlparse(url).netloc.lower().endswith(DOMAIN)
def _local_path(url: str) -> Path:
p = urlparse(url)
rel = (p.netloc + p.path).strip("/")
if not rel or rel.endswith("/"):
rel += "index.html"
return OUT_DIR / rel
def crawl(max_depth: int, max_pages: int, delay: float) -> None:
OUT_DIR.mkdir(exist_ok=True)
visited: set[str] = set()
prior = OUT_DIR / "_visited.txt"
if prior.exists():
visited |= {ln.strip() for ln in prior.read_text().splitlines() if ln.strip()}
print(f"preloaded {len(visited)} already-visited URLs, will skip re-fetching them")
all_links: set[str] = set() # every link seen, html or not
queue: deque[tuple[str, int]] = deque((s, 0) for s in SEEDS)
prior_links = OUT_DIR / "_all_links.txt"
if prior_links.exists():
all_links |= {ln.strip() for ln in prior_links.read_text().splitlines() if ln.strip()}
for u in sorted(all_links):
if _same_domain(u) and _is_html(u) and not _no_recurse(u) and u not in visited:
queue.append((u, 1))
print(f"seeded queue with {len(queue)} unfetched html links from prior run")
fetched = 0
while queue and fetched < max_pages:
url, depth = queue.popleft()
if url in visited or depth > max_depth:
continue
visited.add(url)
req = urllib.request.Request(url, headers={"User-Agent": "tunie-research-crawler/1.0"})
try:
with urllib.request.urlopen(req, timeout=20) as r:
body = r.read()
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc:
print(f" skip {url}: {exc}")
continue
fetched += 1
dest = _local_path(url)
try:
if dest.is_dir():
dest = dest / "index.html"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(body)
except (IsADirectoryError, NotADirectoryError, FileNotFoundError) as exc:
print(f" write skip {url}: {exc}")
print(f"[{fetched}/{max_pages}] depth={depth} {url} ({len(body)}B)")
try:
text = body.decode("utf-8", "replace")
except Exception:
text = ""
for href in HREF_RE.findall(text):
absu = urljoin(url, href).split("#")[0]
if not absu.startswith("http"):
continue
all_links.add(absu)
if (
_same_domain(absu)
and _is_html(absu)
and not _no_recurse(absu)
and absu not in visited
and depth + 1 <= max_depth
):
queue.append((absu, depth + 1))
time.sleep(delay)
(OUT_DIR / "_all_links.txt").write_text("\n".join(sorted(all_links)))
(OUT_DIR / "_visited.txt").write_text("\n".join(sorted(visited)))
print(f"\nFetched {fetched} pages, {len(visited)} visited, {len(all_links)} total links discovered.")
print(f"Mirror in {OUT_DIR}")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--max-depth", type=int, default=10)
ap.add_argument("--max-pages", type=int, default=250)
ap.add_argument("--delay", type=float, default=0.4)
args = ap.parse_args()
crawl(args.max_depth, args.max_pages, args.delay)