#!/usr/bin/env python3
"""Build the complete experimental ROM to an explicit output; never installs it.
Input: the plain patched v5.1W ROM (without season mode). Requires Docker/SGDK.
The normal season build provides Mini, opponent selection and statistics fixes.
"""
from pathlib import Path
import argparse, hashlib, json, os, re, shutil, struct, subprocess, tempfile

here = Path(__file__).resolve().parent
tools = here.parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('plain_rom', type=Path)
parser.add_argument('output_rom', type=Path)
parser.add_argument('--version', default=json.loads((here / 'release.json').read_text())['version'], help='release date YYYY-MM-DD, optionally -r2/-r3 for later builds that day')
parser.add_argument('--legacy-cosmetic-ui',action='store_true',help='retain r2 transfer amounts and unframed six-row main-menu help')
parser.add_argument('--legacy-refinements',action='store_true',help='retain the preceding menus/defaults/HUD/warning and suppressed season presentation')
parser.add_argument('--legacy-allstar-cpu',action='store_true',help='retain the preceding Quickgame-CPU/Allstar-human-only allocation')
parser.add_argument('--legacy-framed-pp-replay',action='store_true',help='retain the preceding90byte replay snapshot without the above-clock PP frame')
parser.add_argument('--legacy-friendly-reception',action='store_true',help='retain native loose-puck lunge during an armed friendly pass reception')
parser.add_argument('--legacy-replay-hud',action='store_true',help='reproduce R7 shared REPLAY/speed patterns and replay-cleared clock')
parser.add_argument('--legacy-transfers',action='store_true',help='reproduce qualified November2 without preseason transfer code/data')
parser.add_argument('--legacy-neon-marker',action='store_true',help='retain the preceding dark native P3 green')
parser.add_argument('--legacy-team-ovr',action='store_true',help='retain static ordinary-club reputation versus transferred roster mean')
parser.add_argument('--legacy-footer-width',action='store_true',help='reproduce the preceding fixed40-column footer centring for exact rollback')
parser.add_argument('--legacy-downed-y',action='store_true',help='retain the preceding knocked-down skater gate before manual Y keeper selection')
parser.add_argument('--legacy-main-menu',action='store_true',help='retain the preceding main-menu CRC/repeat paths')
parser.add_argument('--legacy-games-url',action='store_true',help='retain the preceding startup credits URL')
parser.add_argument('--legacy-menu-background',action='store_true',help='retain R7 shared startup/menu NHL94 image')
parser.add_argument('--legacy-quickgame-cpu',action='store_true',help='retain R7 uppercase human-only Quickgame menu')
parser.add_argument('--legacy-allstar',action='store_true',help='rebuild exact R6 without conditional Allstar teams')
parser.add_argument('--legacy-quickgame',action='store_true',help='rebuild exact R5 without human-versus-human exhibition mode')
parser.add_argument('--legacy-defensive-brake',action='store_true',help='retain r4 containment target and automatic charge overriding arrival brake')
parser.add_argument('--legacy-shot-panel-location',action='store_true',help='retain r4 top-right shot display and native NHL badge')
parser.add_argument('--legacy-momentum-option',action='store_true',help='retain r4 permanently enabled crowd momentum and original menu')
parser.add_argument('--legacy-finisher-position',action='store_true',help='retain r4 distant-receiver hold and its documented geometry/catch limitation')
parser.add_argument('--no-finish-hold',action='store_true',help='retain native receiver tracking while a distant finishing pass approaches')
parser.add_argument('--no-pass-continuity',action='store_true',help='retain the preceding coverage/support decisions while a pass is in flight')
parser.add_argument('--no-goalie-offense',action='store_true',help='reproduce native keeper attack counters contaminating defensive GA/SA')
parser.add_argument('--no-pass-plan',action='store_true',help='retain receiver AI arming instead of planning the accepted pass at launch')
parser.add_argument('--no-centered-cross',action='store_true',help='require a keeper commitment before the safe far-post cross choice')
parser.add_argument('--no-smart-aim',action='store_true',help='retain the original native target choice and unconditional eligible buffered release')
parser.add_argument('--no-catch-opening',action='store_true',default=False,help='retain buffered release when the native shot target is covered')
parser.add_argument('--no-hard-tactics',action='store_true',help='reproduce r3 without the additional native Hard tactical decisions')
parser.add_argument('--no-ai-support-spots',action='store_true',help='retain r3 far-wing support spots')
parser.add_argument('--no-ai-steering',action='store_true',help='retain the native12tick steering target interval')
parser.add_argument('--no-ai-freecross',action='store_true',help='retain r3 pass consideration without deliberate open cross-ice finishes')
parser.add_argument('--no-ai-keeper-pass',action='store_true',help='retain r3 keeper tracking during an approaching lateral enemy pass')
parser.add_argument('--legacy-hard-crowd',action='store_true',help='reproduce r4 fixed Hard rating handicap')
parser.add_argument('--legacy-credits',action='store_true',help='reproduce r4 automatic credit timeout')
parser.add_argument('--no-goalie-season-stats',action='store_true',help='reproduce legacy reserve-goalie skater contamination and starter-only keeper accounting')
parser.add_argument('--no-coach-lines',action='store_true',help='retain native CPU forward-unit selection')
parser.add_argument('--no-coach-goalie',action='store_true',help='retain the native default CPU goalie selection')
parser.add_argument('--no-pwrplay-clear',action='store_true',help='reproduce native lost team pointers on PP side switch')
parser.add_argument('--no-rewind-wrap',action='store_true',help='reproduce r9 native rewind boundary overflow for comparison')
parser.add_argument('--no-ai-attack',action='store_true',help='retain r4 native attacking support/decisions for paired tests')
parser.add_argument('--no-ai-playbook',action='store_true',help='retain r8 pursuit, point-shot and pass-receiver decisions')
parser.add_argument('--no-ai-gap',action='store_true',help='retain r7 defensive pair positioning for paired comparison')
parser.add_argument('--no-ai-defence', action='store_true', help='retain r3 native weak-side winger positioning for paired AI tests')
parser.add_argument('--legacy-goalie-lateral',action='store_true',help='retain midpoint carry tracking and the full stale lateral-save hold')
parser.add_argument('--legacy-fair-pickup',action='store_true',help='reproduce r5 first-pad preference on unassigned co-op puck pickups')
parser.add_argument('--legacy-penalty-owner',action='store_true',help='retain first-pad assignment for a fouled cooperative penalty shooter')
parser.add_argument('--legacy-onetimer-contact',action='store_true',help='reproduce r9 immediate owner-only human buffered shot release')
parser.add_argument('--legacy-contact-atomic',action='store_true',help='retain interruptible buffered human contact-to-release transition')
parser.add_argument('--legacy-easy-stats',action='store_true',help='retain r8 Easy percentage bonuses and native fatigue')
parser.add_argument('--legacy-marker-repair',action='store_true',help='reproduce r9 shared palette edits, P1/net color regression and previous P2/P3 order')
parser.add_argument('--legacy-faceoff-dots',action='store_true',help='reproduce Nov1r3 without optional painted-dot faceoffs')
parser.add_argument('--legacy-ai-shot-plan',action='store_true',help='reproduce Nov1r2 without optional Hard shot planning')
parser.add_argument('--legacy-strict-so',action='store_true',help='reproduce Nov1 without optional additional shootout roster/spin rules')
parser.add_argument('--legacy-alt-goalies',action='store_true',help='reproduce r11 without the optional reach/rebound goalkeeper model')
parser.add_argument('--legacy-pickup-delay',action='store_true',help='reproduce r10 without optional human pass pickup-delay menu')
parser.add_argument('--legacy-pass-charge',action='store_true',help='reproduce r9 without optional human hold/release passing')
parser.add_argument('--legacy-pad-colors',action='store_true',help='retain r8 controller-marker colors')
parser.add_argument('--legacy-penalty-rate',action='store_true',help='reproduce r7 without the separate Penaltys frequency menu')
parser.add_argument('--legacy-directional',action='store_true',help='reproduce r6 without optional human Directional passing and geometric cone settings')
parser.add_argument('--legacy-pass-speed',action='store_true',help='reproduce r4 without the independent Normal/Slow targeted-pass speed menu')
parser.add_argument('--legacy-pass-height',action='store_true',help='reproduce r3 without the optional Normal/Low targeted-pass height menu')
parser.add_argument('--legacy-downed-receiver',action='store_true',help='retain legacy pass targeting/stick pickup of downed skaters')
parser.add_argument('--legacy-penalty-blue-line',action='store_true',help='retain preceding puck progress/stillness rule instead of player blue-line entry and retreat miss')
parser.add_argument('--legacy-shootout-rules',action='store_true',help='retain permissive native pre-shot puck progress')
parser.add_argument('--legacy-shootout-start',action='store_true',help='retain the long human countdown shortened by input')
parser.add_argument('--legacy-net-contact',action='store_true',help='retain the native sensitive goal-net release threshold')
parser.add_argument('--legacy-manual-keeper-pass',action='store_true',help='retain immediate switch instead of a manually controlled keeper pass')
parser.add_argument('--legacy-mini-eight',action='store_true',help='reproduce r11 eight Mini games including the divisional return fixture')
parser.add_argument('--legacy-mini-schedule',action='store_true',help='reproduce r8 eight league-wide circle rounds for Mini')
parser.add_argument('--legacy-overtime',action='store_true',help='reproduce r7 fixed ten-minute overtime')
parser.add_argument('--legacy-whistle-momentum',action='store_true',help='reproduce r7 momentum credit after the whistle')
parser.add_argument('--legacy-icing-default',action='store_true',help='reproduce r6 Icing Off boot/new-format defaults')
parser.add_argument('--legacy-goalie-catch',action='store_true',help='retain manual keeper control from before a catch and switch the requester before the outlet')
parser.add_argument('--legacy-menu-defaults',action='store_true',help='retain r5 Normal/Replay On/Crit On boot defaults')
parser.add_argument('--legacy-icing-menu',action='store_true',help='reproduce r4 without the Icing row, rule switch or Edmonton defaults')
parser.add_argument('--legacy-outlet-deadline',action='store_true',help='retain the keeper timeout race against a newly accepted pass request')
parser.add_argument('--legacy-stars-hud',action='store_true',help='reproduce shot-panel erasure over the native Stars window and residual meters')
parser.add_argument('--legacy-b-switch',action='store_true',help='retain delayed/predicted/goalie-consumed B switching')
parser.add_argument('--legacy-crit-lock',action='store_true',help='reproduce editable Crit Shots in a played season')
parser.add_argument('--legacy-crit-value-draw',action='store_true',help='reproduce r2 missing initial Crit On/Off value rendering')
parser.add_argument('--no-crit-option',action='store_true',help='reproduce the preceding eleven-row menu without the global Crit option')
parser.add_argument('--no-crit', action='store_true', help='reproduce the preceding profile without critical shots')
parser.add_argument('--no-crit-flash', action='store_true', help='keep critical shots without the red puck flash')
parser.add_argument('--no-crit-sound', action='store_true', help='keep the ordinary shot sound on critical releases')
parser.add_argument('--legacy-crit-shots', action='store_true', help='retain all-shot critical chance and the preceding glass sound')
parser.add_argument('--no-onetimer', action='store_true', help='retain the native receiver windup during a pass')
parser.add_argument('--no-crowd', action='store_true', help='retain the preceding build without crowd momentum meters')
parser.add_argument('--no-player-cards', action='store_true', help='retain the preceding roster-index portrait associations')
args = parser.parse_args()
with_pass_height = not args.legacy_pass_height and not args.no_crit_option and not args.legacy_icing_menu
with_pass_speed = with_pass_height and not args.legacy_pass_speed
with_directional = with_pass_speed and not args.legacy_directional
with_penalty_rate = with_directional and not args.legacy_penalty_rate
with_pass_charge = with_penalty_rate and not args.legacy_pass_charge
with_pickup_delay = with_pass_charge and not args.legacy_pickup_delay
with_alt_goalies = with_pickup_delay and not args.legacy_alt_goalies
with_strict_so = with_alt_goalies and not args.legacy_strict_so
with_ai_shot_plan = with_strict_so and not args.legacy_ai_shot_plan
with_faceoff_dots = with_ai_shot_plan and not args.legacy_faceoff_dots
with_momentum = with_faceoff_dots and not args.legacy_momentum_option
with_allstar = not args.legacy_allstar and not args.legacy_quickgame
with_quickgame_cpu=with_allstar and not args.legacy_quickgame_cpu
with_transfers=with_allstar and with_momentum and not args.legacy_transfers
with_refinements=with_transfers and not args.legacy_refinements
with_cosmetic_ui=with_refinements and not args.legacy_cosmetic_ui
with_allstar_cpu=with_refinements and with_quickgame_cpu and not args.legacy_allstar_cpu
with_pp_snapshot=with_refinements and not args.legacy_framed_pp_replay and not args.legacy_replay_hud and not args.legacy_shot_panel_location
with_finisher_position = not (args.legacy_finisher_position or args.no_hard_tactics or args.no_ai_playbook)
with_friendly_reception = with_refinements and with_finisher_position and not args.no_ai_freecross and not args.legacy_friendly_reception
with_easy_stats = with_penalty_rate and not args.legacy_easy_stats
with_pad_colors = with_penalty_rate and not args.legacy_pad_colors
if args.no_hard_tactics:
    args.no_ai_support_spots=args.no_ai_steering=args.no_ai_freecross=args.no_ai_keeper_pass=True
if (args.no_ai_attack or args.no_ai_playbook) and not args.no_hard_tactics:
    parser.error('legacy attack/playbook builds require --no-hard-tactics')

assert re.fullmatch(r"\d{4}-\d{2}-\d{2}(?:-r(?:[2-9]|[1-9][0-9]+))?", args.version), "invalid release version"
src, output = args.plain_rom.resolve(), args.output_rom.resolve()
assert src != output, 'keep the input ROM unchanged'
base = src.read_bytes()
data = bytearray(base)

def patch(address, old, new):
    assert len(old) == len(new)
    assert data[address:address + len(old)] == old, f'unexpected bytes at {address:06X}'
    data[address:address + len(new)] = new

def checksum():
    struct.pack_into('>H', data, 0x18E, sum(struct.unpack(f'>{(len(data)-0x200)//2}H', data[0x200:])) & 65535)

# Net release: light contact retains the native solid-net collision; only
# a heavy hit may take the existing dislodgement/event path.
if not args.legacy_net_contact:
    for at,old,new in [(0x14B6C,0x2000,0x3000),(0x14B74,0xE000,0xD000),(0x14B7C,0x2000,0x3000),(0x14B84,0xE000,0xD000)]:
        patch(at,struct.pack('>H',old),struct.pack('>H',new))

# Native rewind undo must use the existing circular-buffer advance.
if not args.no_rewind_wrap:
    patch(0xA51C,bytes.fromhex('d8fc0062'),bytes.fromhex('610000e2'))

with tempfile.TemporaryDirectory(prefix='nhl94-experimental-') as temp:
    work = Path(temp)
    def assemble(name, origin):
        shutil.copy2(here / (name + '.s'), work / (name + '.s'))
        source=work/(name+'.s');source.write_text(f'    .equ WITH_COSMETIC_UI,{int(with_cosmetic_ui)}\n    .equ WITH_REFINEMENTS,{int(with_refinements)}\n'+source.read_text())
        if name=='grey_panels':
            source.write_text(f'    .equ WITH_NATIVE_FOOTER_WIDTH,{int(not args.legacy_footer_width)}\n'+source.read_text())
        if name in ('replaymenu','seasonplayers','pass_mode','finished','allstar','coach_goalie','goalie_season','goalie_offense','strict_so','seasonend','coach_lines'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_TRANSFERS,{int(with_transfers)}\n'+source.read_text())
        if name=='transfers_ui':
            assert data[0x22A000:0x22A002]==bytes.fromhex('6100')
            force=0x22A002+int.from_bytes(data[0x22A002:0x22A004],'big',signed=True)
            assert 0x22A000<=force<0x22B000
            source=work/(name+'.s');source.write_text(f'    .equ MENU_FORCE,0x{force:X}\n'+source.read_text())
        if name=='transfers':
            shutil.copy2(here/'transfers_store.s',work/'transfers_store.s')
            shutil.copy2(here/'transfers_tx.s',work/'transfers_tx.s')
        if name=='transfers_runtime':
            shutil.copy2(here/'transfers_lines.s',work/'transfers_lines.s')
            shutil.copy2(here/'transfers_getters.s',work/'transfers_getters.s')
            shutil.copy2(here/'transfers_season.s',work/'transfers_season.s')
            shutil.copy2(here/'transfers_cards.s',work/'transfers_cards.s')
        if name in ('mtap','seasonplayers','fourgoalie'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_QUICKGAME,{int(not args.legacy_quickgame)}\n'+source.read_text())
        if name in ('pass_mode','replaymenu','seasonplayers'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_FACEOFF_DOTS,{int(with_faceoff_dots)}\n'+source.read_text())
        if name in ('pass_mode','replaymenu','seasonplayers'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_AI_SHOT_PLAN,{int(with_ai_shot_plan)}\n'+source.read_text())
        if name=='shot_plan':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_PLAN_AIM_HINTS,{int(not args.no_smart_aim and not args.no_hard_tactics)}\n'+source.read_text())
        if name in ('pass_mode','replaymenu','seasonplayers','shootout','shootout_rules'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_STRICT_SO,{int(with_strict_so)}\n'+source.read_text())
        if name in ('pass_mode','replaymenu','seasonplayers','shootout'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_ALT_GOALIES,{int(with_alt_goalies)}\n'+source.read_text())
        if name in ('pass_mode','replaymenu','seasonplayers'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_PICKUP_DELAY,{int(with_pickup_delay)}\n'+source.read_text())
        if name in ('pass_mode','replaymenu','seasonplayers'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_PASS_CHARGE,{int(with_pass_charge)}\n'+source.read_text())
        if name in ('pass_mode','replaymenu','seasonplayers'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_PENALTY_RATE,{int(with_penalty_rate)}\n'+source.read_text())
        if name=='onetimer':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_CONTACT_ATOMIC,{int(with_penalty_rate and not args.legacy_contact_atomic and not args.legacy_onetimer_contact)}\n'+source.read_text())
        if name=='shootout_rules':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_PLAYER_BLUE_LINE,{int(not args.legacy_penalty_blue_line)}\n    .equ WITH_ATTEMPT_RULES,{int(not args.legacy_shootout_rules)}\n    .equ WITH_AUTO_START,{int(not args.legacy_shootout_start)}\n'+source.read_text())
        if name=='shootout':
            source=work/(name+'.s');source.write_text(f'    .equ LEGACY_OVERTIME,{int(args.legacy_overtime)}\n'+source.read_text())
        if name=='crowd':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_EASY_STATS,{int(with_easy_stats)}\n'+source.read_text())
            source=work/(name+'.s');source.write_text(f'    .equ LEGACY_WHISTLE_MOMENTUM,{int(args.legacy_whistle_momentum)}\n'+source.read_text())
        if name=='menubg':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_MENU_78,{int(not args.legacy_menu_background)}\n'+source.read_text())
        if name=='quickgame':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_QUICKGAME_CPU,{int(with_quickgame_cpu)}\n'+source.read_text())
            source.write_text(f'    .equ WITH_ALLSTAR_CPU,{int(with_allstar_cpu)}\n'+source.read_text())
        if name in ('replay','replaytitle','shot_panel_left','shotmeter'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_REPLAY_HUD,{int(not args.legacy_replay_hud and not args.legacy_shot_panel_location)}\n'+source.read_text())
        if name in ('replay','replaytitle'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_FRAMED_PP_REPLAY,{int(with_pp_snapshot)}\n'+source.read_text())
        if name=='shotmeter':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_LEFT_PANEL,{int(not args.legacy_shot_panel_location)}\n'+source.read_text())
        if name in ('crowd','shotmeter'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_STARS_HUD,{int(not args.legacy_stars_hud)}\n'+source.read_text())
        if name in ('switch','fourgoalie'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_MANUAL_KEEPER_PASS,{int(not args.legacy_manual_keeper_pass)}\n'+source.read_text())
        if name in ('switch','fourgoalie','outlet'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_IMMEDIATE_B,{int(not args.legacy_b_switch)}\n'+source.read_text())
        if name in ('goalie_ai','outlet'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_CATCH_AI,{int(not args.legacy_goalie_catch)}\n'+source.read_text())
        if name in ('crowd','replaymenu','seasonplayers'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_PREF_DEFAULTS,{int(not args.legacy_menu_defaults)}\n'+source.read_text())
        if name in ('fourgoalie','outlet'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_OUTLET_DEADLINE,{int(not args.legacy_outlet_deadline)}\n'+source.read_text())
        if name in ('replaymenu','seasonplayers','pass_height','passcone'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_DIRECTIONAL,{int(with_directional)}\n'+source.read_text())
        if name in ('replaymenu','seasonplayers','pass_height'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_PASS_SPEED,{int(with_pass_speed)}\n'+source.read_text())
        if name in ('replaymenu','seasonplayers'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_PASS_HEIGHT,{int(with_pass_height)}\n'+source.read_text())
            source=work/(name+'.s');source.write_text(f'    .equ ICING_DEFAULT,{int(args.legacy_icing_default)}\n'+source.read_text())
            source=work/(name+'.s');source.write_text(f'    .equ WITH_ICING_MENU,{int(not args.legacy_icing_menu)}\n'+source.read_text())
        if name=='seasonplayers':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_CRIT_LOCK,{int(not args.legacy_crit_lock)}\n'+source.read_text())
        if name in ('replaymenu','seasonplayers','pass_mode','crowd'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_MOMENTUM,{int(with_momentum)}\n'+source.read_text())
        if name in ('replaymenu','critical_slap','critical','seasonplayers'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_CRIT_OPTION,{int(not args.no_crit_option)}\n'+source.read_text())
        if name=='ai_gap':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_GAP_BRAKE,{int(not args.legacy_defensive_brake and not args.no_hard_tactics)}\n'+source.read_text())
        if name in ('ai_attack','ai_defence','ai_gap','ai_playbook'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_PASS_CONTINUITY,{int(not args.no_pass_continuity and not args.no_hard_tactics)}\n'+source.read_text())
        if name in ('goalie_season','crowd'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_GK_OFFENSE,{int(not args.no_goalie_offense and not args.no_hard_tactics and not args.no_goalie_season_stats)}\n'+source.read_text())
        if name in ('ai_playbook','crowd'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_PASS_PLAN,{int(not args.no_pass_plan and not args.no_hard_tactics and not args.no_ai_playbook)}\n'+source.read_text())
        if name in ('ai_smart_aim','ai_playbook'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_CATCH_OPENING,{int(not args.no_catch_opening and not args.no_smart_aim and not args.no_hard_tactics)}\n'+source.read_text())
        if name=='ai_freecross':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_CENTERED_CROSS,{int(not args.no_centered_cross and not args.no_hard_tactics)}\n'+source.read_text())
        if name in ('ai_playbook','ai_smart_aim'):
            source.write_text(f'    .equ WITH_FRIENDLY_RECEPTION,{int(with_friendly_reception)}\n'+source.read_text())
        if name=='finisher_position':
            source=work/(name+'.s');source.write_text(f'    .equ FINISH_POSITION_ENABLED,{int(not args.no_finish_hold)}\n'+source.read_text())
            source.write_text(f'    .equ WITH_FRIENDLY_RECEPTION,{int(with_friendly_reception)}\n'+source.read_text())
        if name=='ai_playbook':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_FINISH_POSITION,{int(with_finisher_position)}\n    .equ WITH_AI_FREECROSS,{int(not args.no_ai_freecross)}\n    .equ WITH_FINISH_HOLD,{int((not args.no_finish_hold or with_finisher_position) and not args.no_hard_tactics)}\n'+source.read_text())
        if name in ('ai_attack','ai_defence','ai_gap'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_REAL_ACTORS,{int(not args.no_hard_tactics)}\n'+source.read_text())
        if name in ('ai_attack','onetimer'):
            source=work/(name+'.s');source.write_text(f'    .equ WITH_AI_PLAYBOOK,{int(not args.no_ai_playbook)}\n'+source.read_text())
        if name=='crowd' and args.legacy_hard_crowd:
            source=work/(name+'.s');source.write_text('    .equ LEGACY_HARD_CROWD,1\n'+source.read_text())
        if name=='credits' and args.legacy_credits:
            source=work/(name+'.s');source.write_text('    .equ LEGACY_CREDITS,1\n'+source.read_text())
        if name in ('critical_sound','critical_slap') and args.no_crit_flash:
            source = work / (name + '.s')
            source.write_text('    .equ WITH_CRIT_FLASH,0\n' + source.read_text())
        if name == 'trail':
            source = work / (name + '.s')
            source.write_text(f'    .equ TILES_ADDR,{__import__("trail").TILES}\n' + source.read_text())
        if name == 'onetimer':
            source=work/(name+'.s');source.write_text(f'    .equ WITH_HUMAN_CONTACT,{int(not args.legacy_onetimer_contact)}\n'+source.read_text())
            source = work / (name + '.s')
            source.write_text(f'    .equ NEAR_PX,{__import__("onetimer").NEAR_PX}\n' + source.read_text())
        if name == 'critical_sound' and args.legacy_crit_shots:
            source = work / (name + '.s')
            source.write_text('    .equ CRIT_SOUND_ID,14\n' + source.read_text())
        subprocess.run([str(tools / 'm68kasm.sh'), str(work / (name + '.s')), f'{origin:X}', str(work / (name + '.bin'))], check=True)
        return (work / (name + '.bin')).read_bytes()

    if not args.legacy_shootout_rules or not args.legacy_shootout_start:
        code=assemble('shootout_rules',0x2DAA00)
        assert len(code)<=0x400,'shootout helper overlaps downed-receiver module'
        patch(0x2DAA00,b'\xff'*len(code),code)
        patch(0xF280,bytes.fromhex('31fc0190c31a'),bytes.fromhex('4eb9002daa04'))
        if not args.legacy_shootout_start:
            patch(0xF298,bytes.fromhex('0064'),bytes.fromhex('003c'))
        if not args.legacy_shootout_rules:
            patch(0xF2F4,bytes.fromhex('48a780003038b7aa'),bytes.fromhex('4ef9002daa004e71'))

    if not args.legacy_penalty_owner:
        code=assemble('penalty_owner',0x2DA800)
        assert len(code)<=0x800
        patch(0x2DA800,b'\xff'*len(code),code)
        patch(0x121C2,bytes.fromhex('31c0d406227cffffb04a'),bytes.fromhex('4eb9002da8004e714e71'))
        patch(0xF20C,bytes.fromhex('61000b143038d408'),bytes.fromhex('4eb9002da8044e71'))

    if not args.legacy_icing_menu:
        patch(0x17C92,bytes.fromhex('001e'),bytes.fromhex('000a'))  # Edmonton; fresh boot only
        code=assemble('icing',0x2DBA00)
        assert len(code)<0x200
        patch(0x2DBA00,b'\xff'*len(code),code)
        patch(0x101EE,bytes.fromhex('303c000c61001d38'),bytes.fromhex('4ef9002dba004e71'))

    # Boot credits retain the first picture and return to the original startup.
    credits = (tools / ('data/boot-credits-legacy.bin' if args.legacy_games_url else 'data/boot-credits.bin')).read_bytes()
    assert len(credits) < 0x8000
    patch(0x2D0000, b'\xFF' * len(credits), credits)
    code = assemble('credits', 0x2D8000)
    assert len(code) < 0x400
    patch(0x2D8000, b'\xFF' * len(code), code)
    patch(0x771E, bytes.fromhex('4eb9002fe280'), bytes.fromhex('4eb9002d8000'))

    # Keep AI goalie pickups automatic; explicit Y still takes him. Correct
    # co-op Y's controller offset (D4 already contains 0 or 2, not 0 or 1).
    y = assemble('ygoalie', 0x2FE040)
    assert len(y) < 0x1C0 and data[0x2FE100:0x2FE108] == bytes.fromhex('48e7c0403004d040')
    data[0x2FE040:0x2FE040+len(y)] = y
    if not args.legacy_goalie_catch:
        code=assemble('goalie_catch_control',0x2DBC00)
        assert len(code)<=0x400
        patch(0x2DBC00,b'\xff'*len(code),code)
    guard = assemble('goalie_ai', 0x2FE400)
    patch(0x2FE400, b'\xFF' * len(guard), guard)
    patch(0x15322, bytes.fromhex('b078c3206700013c'), bytes.fromhex('4ef9002fe4004e71'))

    # A diving catch (animation $02F4) originally overwrote the normal 180-tick
    # possession timer with just 5. Once he gets up, a nearby opponent blocks
    # the AI pass and that tiny remaining window expires into a faceoff.
    # Give diving catches the same window as standing catches; native recovery,
    # passing, opponent checks and eventual hold-too-long whistle still apply.
    patch(0x1531C, bytes.fromhex('357c00050048'), bytes.fromhex('357c00b40048'))

    # Exactly 0:40, every period. Original voice is already velocity 127;
    # a quiet unison voice adds approximately 2 dB. Keep SFX below music data.
    patch(0x15DFE, bytes.fromhex('0c78003dc468'), bytes.fromhex('0c780028c468'))
    patch(0x15E08, bytes.fromhex('0c78003cc468'), bytes.fromhex('0c780028c468'))
    sequence = bytearray.fromhex('00 c0 1d 00 00 c1 1d 00')
    note=81 if with_refinements else 74
    for pulse in range(5 if with_refinements else 3):
        sequence += bytes((0 if pulse == 0 else 8, 0x90, note, 127))
        sequence += bytes((0, 0x91, note, 40, 10, 0x80, note, 0, 0, 0x81, note, 0))
    sequence += bytes.fromhex('00 00 ff ff')
    assert all(v == 255 for v in data[0x190E:0x2000])
    patch(0x1960, b'\xFF' * len(sequence), sequence)
    patch(0x2C650, struct.pack('>I', 0x2C844), struct.pack('>I', 0x1960))

    # SFX 2 is also selected when a penalty expires. Suppress that duplicate
    # warning via the native negative-ID silent path, keeping player release,
    # final-five-second penalty ticks (SFX 1), stack cleanup and the 0:40 warning.
    patch(0x1295E, bytes.fromhex('3ebc0002'), bytes.fromhex('3ebcffff'))

    # Five-minute periods only: ordinary penalties 1 minute, injury/fighting 2.
    # Keep the award's original five-minute major classification, then shorten
    # its duration before stats and seconds are written (see penalty.s).
    code = assemble('penalty', 0x2FE500)
    patch(0x2FE500, b'\xFF' * len(code), code)
    table = bytearray(data[0x18E0C:0x1913A])
    for event in range(2, 0x3E, 2):
        offset = int.from_bytes(table[event:event+2], 'big')
        minutes = table[offset+1]
        if 0 < minutes < 128:
            table[offset+1] = 2 if table[offset] == 40 or minutes >= 4 else 1
    patch(0x2FE600, b'\xFF' * len(table), table)
    for operand in [0x9372, 0x11FB2, 0x120A6, 0x1243E, 0x1247A, 0x126A6, 0x127B4, 0x1280C, 0x1883A]:
        address = operand - 2
        old = bytes(data[address:address+6])
        register = (int.from_bytes(old[:2], 'big') >> 9) & 7
        assert old[2:] == bytes.fromhex('00018e0c') and register in [0, 1, 3]
        target = {0: 0x2FE500, 1: 0x2FE518, 3: 0x2FE530}[register]
        patch(address, old, b'\x4e\xb9' + struct.pack('>I', target))
    patch(0x1229A, bytes.fromhex('48e7c078610027f4'), bytes.fromhex('4ef9002fe5484e71'))
    # jdc 2026-10-25-r10: "Season Edition" intro picture (data/intro-season-edition.png ->
    # introimg.py -> data/intro-season.bin, same 38118-byte format as fix 12) and no EA
    # logo: $17A18 keeps its VDP set-up but skips drawing ($17A74 bra.s $17AA0) and waits 0.
    old_intro = (tools / 'data/intro.bin').read_bytes()
    new_intro = (tools / 'data/intro-season.bin').read_bytes()
    assert len(new_intro) == len(old_intro) and data[0x2A0000:0x2A0000 + len(old_intro)] == old_intro
    data[0x2A0000:0x2A0000 + len(new_intro)] = new_intro
    patch(0x17A74, bytes.fromhex('247c'), bytes.fromhex('602a'))
    patch(0x17AAA, bytes.fromhex('31fc0050d066'), bytes.fromhex('31fc0000d066'))
    # jdc 2026-10-26-r14: the same picture as the main menu's background (menubg.s, palette 2 greys).
    if not args.legacy_menu_background:
        menu_picture=(tools/'data/menu-background-78.bin').read_bytes()
        assert len(menu_picture)<=0x10000
        patch(0x240000,b'\xff'*len(menu_picture),menu_picture)
    code = assemble('menubg', 0x2EA000)
    assert len(code) < 0x400
    patch(0x2EA000, b'\xFF' * len(code), code)
    patch(0xF8258, bytes.fromhex('31c4d43206440024'), bytes.fromhex('4eb9002ea0004e71'))
    checksum()
    plain = work / 'plain-test.bin'
    plain.write_bytes(data)
    season = work / 'season-test.bin'
    subprocess.run(['python3', str(tools / 'season/build_season.py'), str(plain), str(season)] + ([] if args.no_crit else ['--crit']) + ([] if args.no_goalie_season_stats else ['--goalie-season-stats']) + ([] if args.legacy_icing_menu else ['--menu-settings']) + ([] if args.legacy_menu_defaults else ['--menu-defaults']) + (['--legacy-icing-default'] if args.legacy_icing_default else []) + (['--legacy-mini-schedule'] if args.legacy_mini_schedule else []) + (['--legacy-mini-eight'] if args.legacy_mini_eight else []) + (['--pass-height'] if with_pass_height else []) + (['--pass-speed'] if with_pass_speed else []) + (['--directional'] if with_directional else []) + (['--penalty-rate'] if with_penalty_rate else []) + (['--pass-charge'] if with_pass_charge else []) + (['--pickup-delay'] if with_pickup_delay else []) + (['--alt-goalies'] if with_alt_goalies else []) + (['--strict-so'] if with_strict_so else []) + (['--ai-shot-plan'] if with_ai_shot_plan else []) + (['--faceoff-dots'] if with_faceoff_dots else []) + (['--momentum'] if with_momentum else []) + ([] if args.legacy_quickgame else ['--quickgame']) + (['--allstar'] if with_allstar else []) + (['--quickgame-cpu'] if with_quickgame_cpu else []) + (['--transfers'] if with_transfers else []) + (['--refinements'] if with_refinements else []), check=True)
    data = bytearray(season.read_bytes())

    if not args.no_ai_playbook:
        from ai_playbook import install as install_ai_playbook
        install_ai_playbook(data,assemble,patch)
        if with_finisher_position:
            code=assemble('finisher_position',0x225000)
            assert 0<len(code)<0x1000
            patch(0x225000,b'\xff'*len(code),code)
            if with_friendly_reception:
                friendly=assemble('friendly_reception',0x225800)
                assert len(friendly)<0x7F0
                patch(0x225800,b'\xff'*len(friendly),friendly)
                patch(0x225FF0,b'\xff'*4,b'FRCP')

    if not args.legacy_defensive_brake and not args.no_hard_tactics:
        code=assemble('defensive_brake',0x228000)
        assert len(code)<=0x1000,'defensive brake exceeds reserved bank'
        patch(0x228000,b'\xff'*len(code),code)

    if not args.no_ai_gap:
        from ai_gap import install as install_ai_gap
        install_ai_gap(data, assemble, patch)

    if not args.no_coach_lines:
        from coach_lines import install as install_coach_lines
        install_coach_lines(data,assemble,patch)
    if not args.no_coach_goalie:
        from coach_goalie import install as install_coach_goalie
        install_coach_goalie(data,assemble,patch)
    if not args.no_pwrplay_clear:
        from pwrplay_clear import install as install_pwrplay_clear
        install_pwrplay_clear(data,assemble,patch,with_refinements=with_refinements)

    # Obtain linked addresses from the same source/includes just assembled.
    command = 'm68k-elf-as -m68000 -o /tmp/s.o season.s && m68k-elf-ld -Ttext=0x2F8000 -e _start -o /tmp/s.elf /tmp/s.o && m68k-elf-nm /tmp/s.elf'
    result = subprocess.run(['docker', 'run', '--rm', '--user', f'{os.getuid()}:{os.getgid()}', '-v', str(tools / 'season') + ':/w', '-w', '/w', '--entrypoint', 'sh', 'ghcr.io/stephane-d/sgdk', '-c', command], capture_output=True, text=True, check=True)
    symbols = {t[2]: int(t[0], 16) for line in result.stdout.splitlines() if len(t := line.split()) == 3}
    names = {'GOALS_FN':'goals', 'DIST_FN':'dist', 'GKSIM_FN':'gksim', 'SAVE_HOOK':'save', 'ADDREAL_FN':'addreal', 'GKREAL_FN':'gkreal','GKPUT_FN':'gkput','GK_TAB':'gktab'}
    (work / 'season-symbols.inc').write_text(''.join(f'    .equ {k},0x{symbols[v]:X}\n' for k,v in names.items()))
    code = assemble('simulate', 0x2FD000)
    patch(0x2FD000, b'\xFF' * len(code), code)
    if not args.no_goalie_season_stats:
        code=assemble('goalie_season',0x2D8C80)
        assert 0<len(code)<=0xC0,'keeper season accounting overlaps steering reservation'
        patch(0x2D8C80,b'\xff'*len(code),code)
    if not args.no_goalie_offense and not args.no_hard_tactics and not args.no_goalie_season_stats:
        code=assemble('goalie_offense',0x2DF400)
        assert 0<len(code)<=0x400
        patch(0x2DF400,b'\xff'*len(code),code)
        patch(0x14964,bytes.fromhex('302a001810c0'),bytes.fromhex('4ef9002df404'))
        patch(0x1301c,bytes.fromhex('4240102b0066'),bytes.fromhex('4ef9002df408'))
    # jdc r21: season playoff statistics apart from the league (season.s rowload/rowsave/gkput call +0/+4)
    # 2026-10-28: season penalty minutes per player (NHL94 forum report), called by season.s.
    from forumfix import install as install_forumfix   # NHL94 forum fixes, 2026-10-28
    install_forumfix(data, assemble, patch)

    if with_pass_height:
        code=assemble('pass_height',0x2DE400)
        assert len(code)<=(0x200 if with_pass_speed else 0x400)
        patch(0x2DE400,b'\xff'*len(code),code)
        patch(0xBF5E,bytes.fromhex('11c2b776b47c000c6d00000811fc000cb776'),bytes.fromhex('4ef9002de400')+bytes.fromhex('4e71')*6)

    if with_pass_speed:
        code=assemble('pass_speed',0x2DE600)
        assert len(code)<=0x200
        patch(0x2DE600,b'\xff'*len(code),code)
        patch(0xBF54,bytes.fromhex('4eb9002ef00c'),bytes.fromhex('4eb9002de600'))

    if with_directional:
        code=assemble('pass_mode',0x2E6800)
        assert len(code)<=0x800,'directional mode/cone/settings exceeds reservation'
        patch(0x2E6800,b'\xff'*len(code),code)
        patch(0xBC40,bytes.fromhex('080200046600001e'),bytes.fromhex('4ef9002e68004e71'))

    if with_pass_charge:
        code=assemble('pass_charge',0x2E8200)
        assert len(code)<=0x1800,'charge helper overlaps manual keeper module'
        patch(0x2E8200,b'\xff'*len(code),code)
        patch(0xB760,bytes.fromhex('4eb9000fee60'),bytes.fromhex('4eb9002e8200'))

    if with_momentum:
        code=assemble('momentum',0x226000)
        assert 0<len(code)<0x1000
        patch(0x226000,b'\xff'*len(code),code)
    if with_faceoff_dots:
        code=assemble('faceoff_dots',0x224000)
        assert len(code)<=0x1000,'faceoff-dot helper exceeds reservation'
        patch(0x224000,b'\xff'*len(code),code)
        patch(0x1255A,bytes.fromhex('610000224278c3e6'),bytes.fromhex('4eb9002240004e71'))
    if with_ai_shot_plan:
        code=assemble('shot_plan',0x223000)
        assert len(code)<=0x1000,'shot-plan helper exceeds reservation'
        patch(0x223000,b'\xff'*len(code),code)
        patch(0xE412,bytes.fromhex('584f3038b75e'),bytes.fromhex('4ef900223000'))
        patch(0xE10C,bytes.fromhex('41fa001e08380002c2f2'),bytes.fromhex('4ef9002230044e714e71'))

    if with_strict_so:
        code=assemble('strict_so',0x222000)
        assert len(code)<=0x1000,'additional shootout helper exceeds reservation'
        patch(0x222000,b'\xff'*len(code),code)

    if with_alt_goalies:
        code=assemble('alt_goalies',0x221000)
        assert len(code)<=0x1000,'alternate keeper module exceeds reservation'
        patch(0x221000,b'\xff'*len(code),code)
        patch(0xC408,bytes.fromhex('4eb9002ff3004e71'),bytes.fromhex('4eb9002210004e71'))
        patch(0x2FF354,bytes.fromhex('0c78000fb7624ef90001503a'),bytes.fromhex('4ef9002210044e714e714e71'))
        patch(0x155FC,bytes.fromhex('b078b7726d00ff58'),bytes.fromhex('4ef9002210084e71'))
        patch(0x15580,bytes.fromhex('174000281741002a'),bytes.fromhex('4eb90022100c4e71'))
        patch(0x15288,bytes.fromhex('302a005231c0b7aa'),bytes.fromhex('4eb9002210244e71'))

    if with_pickup_delay:
        code=assemble('pickup_delay',0x220000)
        assert len(code)<=0x1000,'pickup delay exceeds its isolated ROM reservation'
        patch(0x220000,b'\xff'*len(code),code)

    if with_penalty_rate:
        code=assemble('penalty_rate',0x2EC800)
        assert len(code)<=0x800,'penalty rate helper overlaps shotmeter module'
        patch(0x2EC800,b'\xff'*len(code),code)
        patch(0x11F78,bytes.fromhex('067800c8b89c'),bytes.fromhex('4ef9002ec800'))
        patch(0x2004F0,bytes.fromhex('284b7005267cffffb04a'),bytes.fromhex('4ef9002ec8044e714e71'))

    if not args.legacy_fair_pickup:
        code=assemble('fair_pickup',0x2EFC00)
        assert len(code)<=0x400
        patch(0x2EFC00,b'\xff'*len(code),code)
        patch(0x1538A,bytes.fromhex('b07c00065dc1'),bytes.fromhex('4ef9002efc00'))

    if not args.legacy_downed_receiver:
        code=assemble('downed_receiver',0x2DAE00)
        assert len(code)<=0x200
        patch(0x2DAE00,b'\xff'*len(code),code)
        for at in (0xBD06,0x2FF1B0):
            patch(at,bytes.fromhex('082900020063'),bytes.fromhex('4eb9002dae00'))
        patch(0x150F6,bytes.fromhex('4eb9002ff380'),bytes.fromhex('4ef9002dae04'))

    code = assemble('pim', 0x2EE000)
    assert len(code) < 0x1000
    patch(0x2EE000, b'\xFF' * len(code), code)
    code = assemble('postats', 0x2E2000)
    assert len(code) < 0x800
    patch(0x2E2000, b'\xFF' * len(code), code)
    address = symbols['save']
    old = struct.pack('>HhHh', 0x6100, symbols['addreal']-(address+2), 0x6100, symbols['gkreal']-(address+6))
    patch(address, old, bytes.fromhex('4eb9002fd0044e71'))
    start = struct.unpack_from('>I', data, 0x131CC)[0]
    assert start == 0x2FAC00
    i, items = start+8, []
    while data[i:i+2] == b'\x00\x14':
        items.append(bytes(data[i:i+24])); i += 24
    assert len(items) == 8 and data[i:i+4] == bytes.fromhex('0004ff00')
    entry = b'\x00\x14' + b'  Simulate Match  ' + struct.pack('>I', 0x2FD000)
    blob = data[start:start+8] + items[0] + entry + b''.join(items[1:]) + data[i:i+4]
    assert all(v == 255 for v in data[i+4:start+len(blob)])
    data[start:start+len(blob)] = blob
    if not args.legacy_stars_hud:
        code=assemble('stars_hud',0x2DB600)
        assert len(code)<0x200
        patch(0x2DB600,b'\xff'*len(code),code)
        patch(0x18514,bytes.fromhex('08f80007c2ea'),bytes.fromhex('4eb9002db600'))
    # B switches on press to another available skater, retaining native passing
    # and explicit Y. Two ordinary pads only; multitap retains native controls.
    if args.legacy_b_switch:
        code = assemble('switch', 0x2FD900)
        assert len(code) <= 0x200
        patch(0x2FD900, b'\xFF' * len(code), code)
    else:
        code = assemble('switch', 0x2DB000)
        assert len(code) < 0x400
        patch(0x2DB000, b'\xFF' * len(code), code)
        bridge=bytes.fromhex('60000006600000084ef9002db0004ef9002db004')
        patch(0x2FD900, b'\xFF'*len(bridge), bridge)
    patch(0xB1B6, bytes.fromhex('082b00030063660008ca'), bytes.fromhex('4ef9002fd9004e714e71'))
    patch(0xB2DA, bytes.fromhex('080300046700007c'), bytes.fromhex('4ef9002fd9044e71'))
    # Glass: 50% per hard, high puck impact on the flat far-end panes.
    # Native art only fits the far end; preserve near-end, side/corner behavior.
    # The sprite hook places shards/missing pane at the hit pane; sirens stay centred.
    code = assemble('glass', 0x2FDB00)
    assert len(code) < 0x500
    patch(0x2FDB00, b'\xFF' * len(code), code)
    original = bytes.fromhex('0c6b011800146d0000440c53000b6d0000800c5300126e0000780c6b1f00002a6d00006e')
    patch(0x14BE0, original, bytes.fromhex('4ef9002fdb00') + bytes.fromhex('4e71') * 15)
    patch(0x10320, bytes.fromhex('4253377c012c0014'), bytes.fromhex('4ef9002fdb044e71'))
    # AI keeper with a human team: hold until B, then pass to the requesting pad.
    # Preserve native manual/Y, CPU-only and special-mode paths. Exact target and
    # checked pass vector override only our synchronous native BC64 call.
    # jdc 2026-10-26-r16: Three / Four - Teammates with 4-Way Play (mtap.s).
    if not args.legacy_quickgame:
        # Private exhibition lists retain actual match screens, with no simulation,
        # suspended-season tables/leaders or destructive Abandon Season action.
        cursor=0x22B000+40
        table=bytearray()
        for offset in range(0,40,4):
            source=struct.unpack_from('>I',data,0x131CC+offset)[0]
            i=source+8;entries=[]
            while data[i:i+2]==b'\x00\x14':
                entry=bytes(data[i:i+24]);target=struct.unpack_from('>I',entry,20)[0]
                if target not in (0x2FD000,0x2FA800,0x2F9000,0x2FA000):entries.append(entry)
                i+=24
            assert data[i:i+4]==bytes.fromhex('0004ff00')
            blob=bytes(data[source:source+8])+b''.join(entries)+bytes(data[i:i+4])
            table+=struct.pack('>I',cursor)
            patch(cursor,b'\xff'*len(blob),blob)
            cursor+=len(blob)
        assert cursor<=0x22C000
        patch(0x22B000,b'\xff'*len(table),table)
    if not args.legacy_quickgame:
        code=assemble('quickgame',0x229000)
        assert len(code)<0x1000
        patch(0x229000,b'\xff'*len(code),code)
        patch(0x7C2E,bytes.fromhex('4eb9002f8000'),bytes.fromhex('4eb900229010'))
        patch(0x17336,bytes.fromhex('4ef9002fc000'),bytes.fromhex('4ef900229014'))
        patch(0x13134,bytes.fromhex('4eb9002aa010'),bytes.fromhex('4eb900229018'))
        patch(0xF280,bytes.fromhex('4eb9002daa04'),bytes.fromhex('4eb900229020'))
        patch(0x7C68,bytes.fromhex('4eb900011340'),bytes.fromhex('4eb900229024'))
    code = assemble('mtap', 0x2EA400)
    assert len(code) < 0x200
    patch(0x2EA400, b'\xFF' * len(code), code)
    patch(0x17FE0, bytes.fromhex('4cdf030f4e75'), bytes.fromhex('4ef9002ea400'))
    for a in (0xF7EA4, 0xF7EFC, 0x2093A0, 0x2093F8):
        patch(a, b'Three               ', b'Three - Teammates   ')
    for a in (0xF7EBA, 0xF7F12, 0x2093B6, 0x20940E):
        patch(a, b'Four                ', b'Four - Teammates    ')
    if not args.legacy_outlet_deadline:
        code = assemble('outlet_deadline', 0x2DB400)
        assert len(code) < 0x200
        patch(0x2DB400, b'\xFF' * len(code), code)
    code = assemble('outlet', 0x2FDC00)
    assert len(code) < 0x400
    patch(0x2FDC00, b'\xFF' * len(code), code)
    patch(0xD75C, bytes.fromhex('4a6b00486a000008'), bytes.fromhex('4ef9002fdc004e71'))
    patch(0xBCDA, bytes.fromhex('7605327cb04a0c6b00060052'), bytes.fromhex('4ef9002fdc044e714e714e71'))
    patch(0xBDBA, bytes.fromhex('4a78b774082b00070062'), bytes.fromhex('4ef9002fdc084e714e71'))
    # Adapter input reuses the two native control banks for pads3/4. Keep the
    # original two-pad switch/Y code; dispatch adapter keeper requests by C318.
    code = assemble('fourgoalie', 0x2EA600)
    assert len(code) < 0xA00
    patch(0x2EA600, b'\xFF' * len(code), code)
    patch(0xB1B6, bytes.fromhex('4ef9002fd9004e714e71'), bytes.fromhex('4ef9002ea6004e714e71'))
    patch(0xB2DA, bytes.fromhex('4ef9002fd9044e71'), bytes.fromhex('4ef9002ea6044e71'))
    patch(0x1FFC02, bytes.fromhex('4ef9002fe0404e71'), bytes.fromhex('4ef9002ea6084e71'))
    patch(0xB258, bytes.fromhex('4ef9002fe100'), bytes.fromhex('4ef9002ea60c'))
    code = assemble('seasonplayers', 0x2EB000)
    assert len(code) < 0x1000
    patch(0x2EB000, b'\xFF' * len(code), code)
    patch(0xF7782, bytes.fromhex('0c780001d048'), bytes.fromhex('4eb9002eb018'))
    patch(0xF7ABA, bytes.fromhex('4eb9002f9808'), bytes.fromhex('4eb9002eb02c'))
    patch(0xF7BAA, bytes.fromhex('4eb9002f9800'), bytes.fromhex('4eb9002eb030'))
    # Overtime uses the selected period length, then the native on-rink shootout.
    code = assemble('shootout', 0x2AC000)
    assert len(code) <= 0x2000
    patch(0x2AC000, b'\xFF'*len(code), code)
    patch(0x7742, bytes.fromhex('50f8d6b442b8dea4'), bytes.fromhex('4ef9002ac01c4e71'))
    patch(0x7814, bytes.fromhex('610000380c780003c466'), bytes.fromhex('4ef9002ac0004e714e71'))
    patch(0x7C3E, bytes.fromhex('0c780003c46666000008'), bytes.fromhex('4ef9002ac0044e714e71'))
    patch(0x7B30, bytes.fromhex('08380000c2ea66002e9c'), bytes.fromhex('4ef9002ac0084e714e71'))
    patch(0xECE2, bytes.fromhex('4eb9000fc4c0'), bytes.fromhex('4eb9002ac00c'))
    patch(0xF3DC, bytes.fromhex('4eb9000fc516'), bytes.fromhex('4eb9002ac010'))
    patch(0x1485E, bytes.fromhex('347cc6ce43ea0364'), bytes.fromhex('4ef9002ac0144e71'))
    patch(0x12F30, bytes.fromhex('08380000c2ea6600252c'), bytes.fromhex('4ef9002ac0184e714e71'))
    patch(0x18642, bytes.fromhex('0c780003c46666000032'), bytes.fromhex('4ef9002ac0204e714e71'))
    if not args.no_crit:
        from critical import install
        players = install(data, assemble, patch)
        if not args.no_crit_flash:
            from critical_flash import install as install_flash
            install_flash(data, assemble, patch)
        if not args.no_crit_sound:
            from critical_sound import install as install_sound
            install_sound(data, assemble, patch, with_flash=not args.no_crit_flash)
        if not args.legacy_crit_shots:
            from critical_slap import install as install_slap
            install_slap(data, assemble, patch, with_flash=not args.no_crit_flash, with_sound=not args.no_crit_sound)
        (output.parent / (output.stem + '.crit.json')).parent.mkdir(parents=True, exist_ok=True)
        (output.parent / (output.stem + '.crit.json')).write_text(json.dumps({'scale':100, 'chance_divisor':1000, 'power_bonus_percent':25, 'players':players}, indent=2) + '\n')
    if not args.no_crit:
        from trail import install as install_trail
        install_trail(data, assemble, patch)
    from replay import install as install_replay
    install_replay(data, assemble, patch)
    # jdc 2026-10-26-r26: co-op passes - a human receiver counts within +-90 deg (passcone.s in the
    # base hack's free bytes after its $2FF000 scorer), nearest human wins among several.
    code = assemble('passcone', 0x2FF02A)
    assert len(code) <= 0x56
    patch(0x2FF02A, b'\xFF' * len(code), code)
    patch(0xBD3A, bytes.fromhex('b0bc00010000'), bytes.fromhex('4eb9002ff02a'))
    patch(0x2FF01C, bytes.fromhex('7400'), bytes.fromhex('e08a'))
    patch(0x2FF1E2, bytes.fromhex('7400'), bytes.fromhex('e08a'))
    if with_directional:
        patch(0x2FF1D8,bytes.fromhex('08290003006267000004e08a'),bytes.fromhex('4ef9002e6818')+bytes.fromhex('4e71')*3)
    # jdc 2026-10-26-r27: shot power meter; r29: speed as a HUD-plane score-box panel.
    code = assemble('shotmeter', 0x2ED000)
    assert len(code) < 0x800
    patch(0x2ED000, b'\xFF' * len(code), code)
    if not args.legacy_shot_panel_location:
        from shot_panel_left import install as install_left_panel
        install_left_panel(data,patch)
        helper=assemble('shot_panel_left',0x227000)
        assert len(helper)<0x1000
        patch(0x227000,b'\xff'*len(helper),helper)
    # jdc 2026-10-26-r23: big chrome "REPLAY" during the automatic replay (replaytitle.py -> .s).
    assert data[0xBB4F6:0xBB4F8]==bytes.fromhex('004c'), 'native VCR allocation must remain76tiles'
    code = assemble('replaytitle', 0x2EC000)
    assert len(code) < 0x800
    patch(0x2EC000, b'\xFF' * len(code), code)
    from seasonend import install as install_seasonend
    install_seasonend(data, assemble, patch)
    from finished import install as install_finished
    install_finished(data, assemble, patch)
    if not args.no_onetimer:
        from onetimer import install as install_onetimer
        install_onetimer(data, assemble, patch)
    if with_pad_colors:
        from pad_colors import install as install_pad_colors
        install_pad_colors(data, assemble, patch,legacy=args.legacy_marker_repair,neon=not args.legacy_neon_marker)
    if with_easy_stats and not args.no_crowd:
        from easy_stats import install as install_easy_stats
        install_easy_stats(data, assemble, patch)
    if not args.no_crowd:
        from crowd import install as install_crowd
        install_crowd(data, assemble, patch)
        from replaymenu import install as install_replaymenu
        install_replaymenu(data, assemble, patch, with_crit_option=not args.no_crit_option, legacy_crit_value_draw=args.legacy_crit_value_draw,with_icing=not args.legacy_icing_menu,with_height=with_pass_height,with_speed=with_pass_speed,with_directional=with_directional,with_penalty_rate=with_penalty_rate,with_pass_charge=with_pass_charge,with_pickup_delay=with_pickup_delay,with_alt_goalies=with_alt_goalies,with_strict_so=with_strict_so,with_ai_shot_plan=with_ai_shot_plan,with_faceoff_dots=with_faceoff_dots,with_momentum=with_momentum,with_transfers=with_transfers,with_refinements=with_refinements)
    if not args.no_player_cards:
        from player_cards import install as install_cards
        cards = install_cards(data, patch)
        output.parent.mkdir(parents=True, exist_ok=True)
        output.with_suffix('.cards.json').write_text(json.dumps(cards, ensure_ascii=False, indent=2) + '\n')
    if not args.no_ai_defence:
        from ai_defence import install as install_ai_defence
        install_ai_defence(data, assemble, patch)
    if not args.no_ai_attack:
        from ai_attack import install as install_ai_attack
        install_ai_attack(data,assemble,patch)
    from ai_tactics import install as install_ai_tactics
    install_ai_tactics(data,assemble,patch,support=not args.no_ai_support_spots,steering=not args.no_ai_steering,cross=not args.no_ai_freecross,keeper=not args.no_ai_keeper_pass,owner=not args.no_pass_continuity and not args.no_hard_tactics)

    if not args.no_smart_aim and not args.no_hard_tactics:
        code=assemble('ai_smart_aim',0x2DF000)
        assert 0<len(code)<=0x400,'smart aim overlaps keeper accounting'
        patch(0x2DF000,b'\xff'*len(code),code)
        patch(0xC30C,bytes.fromhex('610002583f3c0005'),bytes.fromhex('4ef9002df0004e71'))

    if not args.no_pass_plan and not args.no_hard_tactics and not args.no_ai_playbook:
        owner_code=assemble('ai_pass_owner',0x2DD680)
        if data[0x2DD680:0x2DD680+len(owner_code)]==b'\xff'*len(owner_code):
            patch(0x2DD680,b'\xff'*len(owner_code),owner_code)
        else:
            assert data[0x2DD680:0x2DD680+len(owner_code)]==owner_code
        code=assemble('ai_pass_plan',0x2DF800)
        assert 0<len(code)<=0x800
        patch(0x2DF800,b'\xff'*len(code),code)
        patch(0xBFB6,bytes.fromhex('31c1b7744e75'),bytes.fromhex('4ef9002df800'))

    if not args.legacy_goalie_lateral:
        # Native carried-puck target: actual puck X, not the carrier/stick midpoint.
        patch(0xD8F8,bytes.fromhex('e240'),bytes.fromhex('4e71'))
        # Reuse dead padding behind the glass JMP for a nearby taken-branch veneer.
        code=assemble('goalie_anticipation',0x2DB800)
        assert 0<len(code)<=0x400,'keeper anticipation overlaps unused reservation'
        patch(0x2DB800,b'\xff'*len(code),code)
        patch(0x14BE6,bytes.fromhex('4e714e714e71'),bytes.fromhex('4ef9002db800'))
        patch(0xD650,bytes.fromhex('7e14'),(0x14BE6-0xD650).to_bytes(2,'big',signed=True))

    allstar_report=None
    if with_allstar:
        from allstar import install as install_allstar
        allstar_report=install_allstar(data,patch,assemble,symbols['gktab'])
        if with_allstar_cpu:
            patch(0x22CD6C,b'\xff'*4,b'ASCP')
            allstar_report['CPU_option']='1 vs CPU; P1 Allstar A/home, entirely CPU Allstar B/away. Quickgame human-only; all four human splits retained in both modes.'

    transfers_report=None
    if with_refinements:
        # jdc Nov2: native powerplay text/countdown ABOVE the fixed shot/clock.
        # Native club/timer text inside the matching72px frame; accounting stays native.
        patch(0x8398,bytes.fromhex('6002d5b802420007'),bytes.fromhex('4eb9002580184e71'))
        for at,old_y,new_y in [(0x12AF0,25,20),(0x12AFA,26,21),(0x12B1E,25,20)]:
            assert data[at-2:at]==bytes.fromhex('bf01'),('native PP formatter coordinate',hex(at))
            patch(at,bytes([old_y]),bytes([new_y]))
        # jdc: one small Momentum bar directly BELOW each actual team name.
        # Native eight-cell score frame grows upward by one row; real labels,
        # goals and accounting still use the original formatter.
        patch(0x12C7C,b'\x17',b'\x16') # top23->22
        patch(0x12C80,bytes.fromhex('7205'),bytes.fromhex('7206'))
        patch(0x12C8E,b'\x1a',b'\x19') # home26->25; bar26
        patch(0x12CA0,b'\x18',b'\x17') # away24->23; bar24
        compact=assemble('compact_momentum',0x259000)
        assert len(compact)<=0x1000
        patch(0x259000,b'\xff'*len(compact),compact)
        from menu_ui import install as install_menu_ui
        install_menu_ui(data,assemble,patch,with_cosmetic_ui=with_cosmetic_ui)
        from refinement_font import install as install_refinement_font
        install_refinement_font(data,patch,with_cosmetic_ui=with_cosmetic_ui)
        code=assemble('refinements',0x258000)
        assert len(code)<=0x1000,'refinements module exceeds its reserved ROM block'
        patch(0x258000,b'\xff'*len(code),code)
    if with_transfers:
        from transfers import install as install_transfers
        transfers_report=install_transfers(data,patch,assemble)

    if with_refinements and with_transfers and not args.legacy_main_menu:
        from main_menu import install as install_main_menu
        install_main_menu(data,assemble,patch)

    if with_refinements and with_transfers and not args.legacy_team_ovr:
        from team_ovr import install as install_team_ovr
        install_team_ovr(data,assemble,patch)

    if not args.legacy_downed_y:
        from downed_y import install as install_downed_y
        install_downed_y(data,assemble,patch)

    from grey_panels import install as install_grey_panels
    install_grey_panels(data,assemble,patch)

    title = f'NHL 2026 - 94 Edition v5.1W - {args.version}'.encode('ascii')
    assert len(title) <= 48
    data[0x120:0x150] = title.ljust(48, b' ')
    data[0x150:0x180] = title.ljust(48, b' ')
    checksum()
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_bytes(data)
    if allstar_report:
        allstar_report['rom_sha256']=hashlib.sha256(data).hexdigest()
        output.with_suffix('.allstar.json').write_text(json.dumps(allstar_report,indent=2)+'\n')
    if transfers_report:
        transfers_report['rom_sha256']=hashlib.sha256(data).hexdigest()
        output.with_suffix('.transfers.json').write_text(json.dumps(transfers_report,indent=2)+'\n')
    print(json.dumps({'input_sha256':hashlib.sha256(base).hexdigest(), 'output':str(output), 'output_sha256':hashlib.sha256(data).hexdigest()}, indent=2))
