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

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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