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:
727
tunie/research/COMMUNITY_TUNING.md
Normal file
727
tunie/research/COMMUNITY_TUNING.md
Normal 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).
|
||||
58
tunie/research/EXTERNAL_RESEARCH_NOTES.md
Normal file
58
tunie/research/EXTERNAL_RESEARCH_NOTES.md
Normal 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.
|
||||
201
tunie/research/RECOVERY_MODE.md
Normal file
201
tunie/research/RECOVERY_MODE.md
Normal 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`.
|
||||
531
tunie/research/TUNING_GUIDE.md
Normal file
531
tunie/research/TUNING_GUIDE.md
Normal 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.
|
||||
436
tunie/research/TUNING_IMPL_PLAN.md
Normal file
436
tunie/research/TUNING_IMPL_PLAN.md
Normal 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.
|
||||
219
tunie/research/WRITE_PATH.md
Normal file
219
tunie/research/WRITE_PATH.md
Normal 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.
|
||||
BIN
tunie/research/reference-maps/20184dynoTuneSteveO2-Disable.hex
Normal file
BIN
tunie/research/reference-maps/20184dynoTuneSteveO2-Disable.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/20185Map.hex
Normal file
BIN
tunie/research/reference-maps/20185Map.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/20186Map.hex
Normal file
BIN
tunie/research/reference-maps/20186Map.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/20187Map.hex
Normal file
BIN
tunie/research/reference-maps/20187Map.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/20188Map.hex
Normal file
BIN
tunie/research/reference-maps/20188Map.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/20188Map2009AIRBOXBONNY.hex
Normal file
BIN
tunie/research/reference-maps/20188Map2009AIRBOXBONNY.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/20191Map.hex
Normal file
BIN
tunie/research/reference-maps/20191Map.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/20192Map.hex
Normal file
BIN
tunie/research/reference-maps/20192Map.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/20262Map.hex
Normal file
BIN
tunie/research/reference-maps/20262Map.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/20313Map.hex
Normal file
BIN
tunie/research/reference-maps/20313Map.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/20500MapAirboxBonnie_LCD.hex
Normal file
BIN
tunie/research/reference-maps/20500MapAirboxBonnie_LCD.hex
Normal file
Binary file not shown.
@@ -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.
|
||||
|
||||
100
tunie/research/reference-maps/PERMUTATIONS.md
Normal file
100
tunie/research/reference-maps/PERMUTATIONS.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
65
tunie/research/reference-maps/checksum.py
Normal file
65
tunie/research/reference-maps/checksum.py
Normal 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'}")
|
||||
110
tunie/research/reference-maps/compose_arrow_delete.py
Normal file
110
tunie/research/reference-maps/compose_arrow_delete.py
Normal 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())
|
||||
Binary file not shown.
BIN
tunie/research/reference-maps/derived/20262-noO2-only.hex
Normal file
BIN
tunie/research/reference-maps/derived/20262-noO2-only.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/derived/20262-noSAI-noO2.hex
Normal file
BIN
tunie/research/reference-maps/derived/20262-noSAI-noO2.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/derived/20262-noSAI-only.hex
Normal file
BIN
tunie/research/reference-maps/derived/20262-noSAI-only.hex
Normal file
Binary file not shown.
Binary file not shown.
BIN
tunie/research/reference-maps/derived/20313-noO2-only.hex
Normal file
BIN
tunie/research/reference-maps/derived/20313-noO2-only.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/derived/20313-noSAI-noO2.hex
Normal file
BIN
tunie/research/reference-maps/derived/20313-noSAI-noO2.hex
Normal file
Binary file not shown.
BIN
tunie/research/reference-maps/derived/20313-noSAI-only.hex
Normal file
BIN
tunie/research/reference-maps/derived/20313-noSAI-only.hex
Normal file
Binary file not shown.
110
tunie/research/reference-maps/toggle_devices.py
Normal file
110
tunie/research/reference-maps/toggle_devices.py
Normal 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())
|
||||
148
tunie/research/tuneecu_crawl.py
Normal file
148
tunie/research/tuneecu_crawl.py
Normal 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)
|
||||
Reference in New Issue
Block a user