"""Compose a Low-throttle fuel correction on top of an already-composed map. Second layer in a two-step derivation: 20262Map.hex -> compose_arrow_delete.py -> 20262-arrow-delete-composed.hex (layer 1) -> compose_lowthrottle.py -> 20262-arrow-delete-lowthrottle-composed.hex (layer 2) Layer 2 is additive on top of layer 1's own values, not a fresh edit of stock 20262 -- it composes with whatever layer 1 already did (SAI/O2 off, idle-CO trim), the same "respect what's already there" principle used throughout this project. Motivation (2026-09-10 session): real-world reports of stalling/choking under ~10% throttle and while blipping the throttle at low speed, after snorkel removal with no main-table correction for the extra airflow. Confirmed via a real percentage-delta pulled from a genuine reference file that also had its snorkel removed: `20184dynoTuneSteveO2-Disable.hex` vs its own stock baseline `20186Map.hex`, in the "Low-throttle fuel" tables (cyl 1 @ fe=15, cyl 2 @ fe=16 -- see TABLES.md), restricted to the throttle columns actually implicated by the reported symptom (raw throttle-axis value <= 100, i.e. <= 10.0%). Method: percentage delta, not flat additive -- VE/fuel-mass values scale with magnitude, and Arrow's own low-throttle table already differs from stock (it has its own exhaust-specific correction baked in), so the right operation is "apply the same proportional richening the reference delete map needed" on top of whatever value is already there, not overwrite it. pct(row, col) = (delete_ref[row][col] - stock_ref[row][col]) / stock_ref[row][col] new(row, col) = round(base[row][col] * (1 + pct(row, col))) Usage: python3 compose_lowthrottle.py \\ derived/20262-arrow-delete-composed.hex \\ 20186Map.hex 20184dynoTuneSteveO2-Disable.hex \\ derived/20262-arrow-delete-lowthrottle-composed.hex """ from __future__ import annotations import struct import sys import checksum from decode_map import decode, encode from table_map import resolve from toggle_devices import repack, unpack LOW_THROTTLE_TABLES = ("Low-throttle fuel — cyl 1", "Low-throttle fuel — cyl 2") MAX_SANE_VALUE = 20000 # real VE values in every reference file top out ~11000 def _table_by_name(resolved: dict, name: str) -> dict: for t in resolved["tables"]: if t["name"] == name: return t raise KeyError(name) def compose(base_raw: bytes, stock_raw: bytes, delete_raw: bytes, base_path: str, stock_path: str, delete_path: str) -> tuple[bytes, list[str]]: base_dec = decode(base_raw) base_rom, positions = unpack(base_dec) base_rom = bytearray(base_rom) base_resolved = resolve(base_path) stock_resolved = resolve(stock_path) delete_resolved = resolve(delete_path) throttle_axis = base_resolved["throttle_axis"] low_cols = [i for i, v in enumerate(throttle_axis) if v <= 100] notes = [f"low-throttle columns touched (raw<=100): {[throttle_axis[c] for c in low_cols]}"] for name in LOW_THROTTLE_TABLES: base_t = _table_by_name(base_resolved, name) stock_t = _table_by_name(stock_resolved, name) delete_t = _table_by_name(delete_resolved, name) off = base_t["offset"] # this file's OWN resolved offset, not another file's rows, cols = base_t["rows"], base_t["cols"] changed = 0 pct_min, pct_max = None, None for row in range(rows): for col in low_cols: stock_v = stock_t["data"][row][col] delete_v = delete_t["data"][row][col] base_v = base_t["data"][row][col] pct = (delete_v - stock_v) / stock_v if stock_v else 0.0 new_v = round(base_v * (1 + pct)) new_v = max(0, min(65535, new_v)) if new_v > MAX_SANE_VALUE: notes.append( f" WARNING {name} row={row} col={col}: " f"computed {new_v} exceeds sanity bound {MAX_SANE_VALUE}" ) if new_v != base_v: changed += 1 pct_min = pct if pct_min is None else min(pct_min, pct) pct_max = pct if pct_max is None else max(pct_max, pct) byte_off = off + (row * cols + col) * 2 struct.pack_into(">H", base_rom, byte_off, new_v) pct_range = f"{pct_min:+.1%} to {pct_max:+.1%}" if changed else "no change" notes.append(f"{name} (0x{off:05X}): {changed} cells touched, delta range {pct_range}") before_sum = checksum.stored(bytes(base_rom)) checksum.patch(base_rom) after_sum = checksum.compute(bytes(base_rom)) notes.append(f"checksum: stored {before_sum:04x} (stale) -> patched {after_sum:04x}") repacked = repack(base_dec, bytes(base_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_lowthrottle.py " " ", file=sys.stderr, ) return 2 base_path, stock_path, delete_path, out_path = sys.argv[1:5] out, notes = compose( open(base_path, "rb").read(), open(stock_path, "rb").read(), open(delete_path, "rb").read(), base_path, stock_path, delete_path, ) open(out_path, "wb").write(out) for n in notes: print(" ", n) print(f"wrote {out_path} ({len(out)} bytes)") print("NOTE: only the under-~10%-throttle columns of the two Low-throttle") print(" fuel tables were touched. Main fuel tables (>10% throttle)") print(" remain untouched stock-Arrow calibration.") return 0 if __name__ == "__main__": raise SystemExit(main())