experimental/build_test.py

Download file

Release 2026-11-04-r2 · SHA256 fb4770aaedd89f2cdf019fa413fbfccf55fbe026553d33890a2adce2d35646d2 · Click a line number for a direct link.

1#!/usr/bin/env python32"""Build the complete experimental ROM to an explicit output; never installs it.3Input: the plain patched v5.1W ROM (without season mode). Requires Docker/SGDK.4The normal season build provides Mini, opponent selection and statistics fixes.5"""6from pathlib import Path7import argparse, hashlib, json, os, re, shutil, struct, subprocess, tempfile89here = Path(__file__).resolve().parent10tools = here.parent11parser = argparse.ArgumentParser(description=__doc__)12parser.add_argument('plain_rom', type=Path)13parser.add_argument('output_rom', type=Path)14parser.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')15parser.add_argument('--legacy-cosmetic-ui',action='store_true',help='retain r2 transfer amounts and unframed six-row main-menu help')16parser.add_argument('--legacy-refinements',action='store_true',help='retain the preceding menus/defaults/HUD/warning and suppressed season presentation')17parser.add_argument('--legacy-allstar-cpu',action='store_true',help='retain the preceding Quickgame-CPU/Allstar-human-only allocation')18parser.add_argument('--legacy-framed-pp-replay',action='store_true',help='retain the preceding90byte replay snapshot without the above-clock PP frame')19parser.add_argument('--legacy-friendly-reception',action='store_true',help='retain native loose-puck lunge during an armed friendly pass reception')20parser.add_argument('--legacy-replay-hud',action='store_true',help='reproduce R7 shared REPLAY/speed patterns and replay-cleared clock')21parser.add_argument('--legacy-transfers',action='store_true',help='reproduce qualified November2 without preseason transfer code/data')22parser.add_argument('--legacy-neon-marker',action='store_true',help='retain the preceding dark native P3 green')23parser.add_argument('--legacy-team-ovr',action='store_true',help='retain static ordinary-club reputation versus transferred roster mean')24parser.add_argument('--legacy-footer-width',action='store_true',help='reproduce the preceding fixed40-column footer centring for exact rollback')25parser.add_argument('--legacy-downed-y',action='store_true',help='retain the preceding knocked-down skater gate before manual Y keeper selection')26parser.add_argument('--legacy-main-menu',action='store_true',help='retain the preceding main-menu CRC/repeat paths')27parser.add_argument('--legacy-games-url',action='store_true',help='retain the preceding startup credits URL')28parser.add_argument('--legacy-menu-background',action='store_true',help='retain R7 shared startup/menu NHL94 image')29parser.add_argument('--legacy-quickgame-cpu',action='store_true',help='retain R7 uppercase human-only Quickgame menu')30parser.add_argument('--legacy-allstar',action='store_true',help='rebuild exact R6 without conditional Allstar teams')31parser.add_argument('--legacy-quickgame',action='store_true',help='rebuild exact R5 without human-versus-human exhibition mode')32parser.add_argument('--legacy-defensive-brake',action='store_true',help='retain r4 containment target and automatic charge overriding arrival brake')33parser.add_argument('--legacy-shot-panel-location',action='store_true',help='retain r4 top-right shot display and native NHL badge')34parser.add_argument('--legacy-momentum-option',action='store_true',help='retain r4 permanently enabled crowd momentum and original menu')35parser.add_argument('--legacy-finisher-position',action='store_true',help='retain r4 distant-receiver hold and its documented geometry/catch limitation')36parser.add_argument('--no-finish-hold',action='store_true',help='retain native receiver tracking while a distant finishing pass approaches')37parser.add_argument('--no-pass-continuity',action='store_true',help='retain the preceding coverage/support decisions while a pass is in flight')38parser.add_argument('--no-goalie-offense',action='store_true',help='reproduce native keeper attack counters contaminating defensive GA/SA')39parser.add_argument('--no-pass-plan',action='store_true',help='retain receiver AI arming instead of planning the accepted pass at launch')40parser.add_argument('--no-centered-cross',action='store_true',help='require a keeper commitment before the safe far-post cross choice')41parser.add_argument('--no-smart-aim',action='store_true',help='retain the original native target choice and unconditional eligible buffered release')42parser.add_argument('--no-catch-opening',action='store_true',default=False,help='retain buffered release when the native shot target is covered')43parser.add_argument('--no-hard-tactics',action='store_true',help='reproduce r3 without the additional native Hard tactical decisions')44parser.add_argument('--no-ai-support-spots',action='store_true',help='retain r3 far-wing support spots')45parser.add_argument('--no-ai-steering',action='store_true',help='retain the native12tick steering target interval')46parser.add_argument('--no-ai-freecross',action='store_true',help='retain r3 pass consideration without deliberate open cross-ice finishes')47parser.add_argument('--no-ai-keeper-pass',action='store_true',help='retain r3 keeper tracking during an approaching lateral enemy pass')48parser.add_argument('--legacy-hard-crowd',action='store_true',help='reproduce r4 fixed Hard rating handicap')49parser.add_argument('--legacy-credits',action='store_true',help='reproduce r4 automatic credit timeout')50parser.add_argument('--no-goalie-season-stats',action='store_true',help='reproduce legacy reserve-goalie skater contamination and starter-only keeper accounting')51parser.add_argument('--no-coach-lines',action='store_true',help='retain native CPU forward-unit selection')52parser.add_argument('--no-coach-goalie',action='store_true',help='retain the native default CPU goalie selection')53parser.add_argument('--no-pwrplay-clear',action='store_true',help='reproduce native lost team pointers on PP side switch')54parser.add_argument('--no-rewind-wrap',action='store_true',help='reproduce r9 native rewind boundary overflow for comparison')55parser.add_argument('--no-ai-attack',action='store_true',help='retain r4 native attacking support/decisions for paired tests')56parser.add_argument('--no-ai-playbook',action='store_true',help='retain r8 pursuit, point-shot and pass-receiver decisions')57parser.add_argument('--no-ai-gap',action='store_true',help='retain r7 defensive pair positioning for paired comparison')58parser.add_argument('--no-ai-defence', action='store_true', help='retain r3 native weak-side winger positioning for paired AI tests')59parser.add_argument('--legacy-goalie-lateral',action='store_true',help='retain midpoint carry tracking and the full stale lateral-save hold')60parser.add_argument('--legacy-fair-pickup',action='store_true',help='reproduce r5 first-pad preference on unassigned co-op puck pickups')61parser.add_argument('--legacy-penalty-owner',action='store_true',help='retain first-pad assignment for a fouled cooperative penalty shooter')62parser.add_argument('--legacy-onetimer-contact',action='store_true',help='reproduce r9 immediate owner-only human buffered shot release')63parser.add_argument('--legacy-contact-atomic',action='store_true',help='retain interruptible buffered human contact-to-release transition')64parser.add_argument('--legacy-easy-stats',action='store_true',help='retain r8 Easy percentage bonuses and native fatigue')65parser.add_argument('--legacy-marker-repair',action='store_true',help='reproduce r9 shared palette edits, P1/net color regression and previous P2/P3 order')66parser.add_argument('--legacy-faceoff-dots',action='store_true',help='reproduce Nov1r3 without optional painted-dot faceoffs')67parser.add_argument('--legacy-ai-shot-plan',action='store_true',help='reproduce Nov1r2 without optional Hard shot planning')68parser.add_argument('--legacy-strict-so',action='store_true',help='reproduce Nov1 without optional additional shootout roster/spin rules')69parser.add_argument('--legacy-alt-goalies',action='store_true',help='reproduce r11 without the optional reach/rebound goalkeeper model')70parser.add_argument('--legacy-pickup-delay',action='store_true',help='reproduce r10 without optional human pass pickup-delay menu')71parser.add_argument('--legacy-pass-charge',action='store_true',help='reproduce r9 without optional human hold/release passing')72parser.add_argument('--legacy-pad-colors',action='store_true',help='retain r8 controller-marker colors')73parser.add_argument('--legacy-penalty-rate',action='store_true',help='reproduce r7 without the separate Penaltys frequency menu')74parser.add_argument('--legacy-directional',action='store_true',help='reproduce r6 without optional human Directional passing and geometric cone settings')75parser.add_argument('--legacy-pass-speed',action='store_true',help='reproduce r4 without the independent Normal/Slow targeted-pass speed menu')76parser.add_argument('--legacy-pass-height',action='store_true',help='reproduce r3 without the optional Normal/Low targeted-pass height menu')77parser.add_argument('--legacy-downed-receiver',action='store_true',help='retain legacy pass targeting/stick pickup of downed skaters')78parser.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')79parser.add_argument('--legacy-shootout-rules',action='store_true',help='retain permissive native pre-shot puck progress')80parser.add_argument('--legacy-shootout-start',action='store_true',help='retain the long human countdown shortened by input')81parser.add_argument('--legacy-net-contact',action='store_true',help='retain the native sensitive goal-net release threshold')82parser.add_argument('--legacy-manual-keeper-pass',action='store_true',help='retain immediate switch instead of a manually controlled keeper pass')83parser.add_argument('--legacy-mini-eight',action='store_true',help='reproduce r11 eight Mini games including the divisional return fixture')84parser.add_argument('--legacy-mini-schedule',action='store_true',help='reproduce r8 eight league-wide circle rounds for Mini')85parser.add_argument('--legacy-overtime',action='store_true',help='reproduce r7 fixed ten-minute overtime')86parser.add_argument('--legacy-whistle-momentum',action='store_true',help='reproduce r7 momentum credit after the whistle')87parser.add_argument('--legacy-icing-default',action='store_true',help='reproduce r6 Icing Off boot/new-format defaults')88parser.add_argument('--legacy-goalie-catch',action='store_true',help='retain manual keeper control from before a catch and switch the requester before the outlet')89parser.add_argument('--legacy-menu-defaults',action='store_true',help='retain r5 Normal/Replay On/Crit On boot defaults')90parser.add_argument('--legacy-icing-menu',action='store_true',help='reproduce r4 without the Icing row, rule switch or Edmonton defaults')91parser.add_argument('--legacy-outlet-deadline',action='store_true',help='retain the keeper timeout race against a newly accepted pass request')92parser.add_argument('--legacy-stars-hud',action='store_true',help='reproduce shot-panel erasure over the native Stars window and residual meters')93parser.add_argument('--legacy-b-switch',action='store_true',help='retain delayed/predicted/goalie-consumed B switching')94parser.add_argument('--legacy-crit-lock',action='store_true',help='reproduce editable Crit Shots in a played season')95parser.add_argument('--legacy-crit-value-draw',action='store_true',help='reproduce r2 missing initial Crit On/Off value rendering')96parser.add_argument('--no-crit-option',action='store_true',help='reproduce the preceding eleven-row menu without the global Crit option')97parser.add_argument('--no-crit', action='store_true', help='reproduce the preceding profile without critical shots')98parser.add_argument('--no-crit-flash', action='store_true', help='keep critical shots without the red puck flash')99parser.add_argument('--no-crit-sound', action='store_true', help='keep the ordinary shot sound on critical releases')100parser.add_argument('--legacy-crit-shots', action='store_true', help='retain all-shot critical chance and the preceding glass sound')101parser.add_argument('--no-onetimer', action='store_true', help='retain the native receiver windup during a pass')102parser.add_argument('--no-crowd', action='store_true', help='retain the preceding build without crowd momentum meters')103parser.add_argument('--no-player-cards', action='store_true', help='retain the preceding roster-index portrait associations')104args = parser.parse_args()105with_pass_height = not args.legacy_pass_height and not args.no_crit_option and not args.legacy_icing_menu106with_pass_speed = with_pass_height and not args.legacy_pass_speed107with_directional = with_pass_speed and not args.legacy_directional108with_penalty_rate = with_directional and not args.legacy_penalty_rate109with_pass_charge = with_penalty_rate and not args.legacy_pass_charge110with_pickup_delay = with_pass_charge and not args.legacy_pickup_delay111with_alt_goalies = with_pickup_delay and not args.legacy_alt_goalies112with_strict_so = with_alt_goalies and not args.legacy_strict_so113with_ai_shot_plan = with_strict_so and not args.legacy_ai_shot_plan114with_faceoff_dots = with_ai_shot_plan and not args.legacy_faceoff_dots115with_momentum = with_faceoff_dots and not args.legacy_momentum_option116with_allstar = not args.legacy_allstar and not args.legacy_quickgame117with_quickgame_cpu=with_allstar and not args.legacy_quickgame_cpu118with_transfers=with_allstar and with_momentum and not args.legacy_transfers119with_refinements=with_transfers and not args.legacy_refinements120with_cosmetic_ui=with_refinements and not args.legacy_cosmetic_ui121with_allstar_cpu=with_refinements and with_quickgame_cpu and not args.legacy_allstar_cpu122with_pp_snapshot=with_refinements and not args.legacy_framed_pp_replay and not args.legacy_replay_hud and not args.legacy_shot_panel_location123with_finisher_position = not (args.legacy_finisher_position or args.no_hard_tactics or args.no_ai_playbook)124with_friendly_reception = with_refinements and with_finisher_position and not args.no_ai_freecross and not args.legacy_friendly_reception125with_easy_stats = with_penalty_rate and not args.legacy_easy_stats126with_pad_colors = with_penalty_rate and not args.legacy_pad_colors127if args.no_hard_tactics:128    args.no_ai_support_spots=args.no_ai_steering=args.no_ai_freecross=args.no_ai_keeper_pass=True129if (args.no_ai_attack or args.no_ai_playbook) and not args.no_hard_tactics:130    parser.error('legacy attack/playbook builds require --no-hard-tactics')131132assert re.fullmatch(r"\d{4}-\d{2}-\d{2}(?:-r(?:[2-9]|[1-9][0-9]+))?", args.version), "invalid release version"133src, output = args.plain_rom.resolve(), args.output_rom.resolve()134assert src != output, 'keep the input ROM unchanged'135base = src.read_bytes()136data = bytearray(base)137138def patch(address, old, new):139    assert len(old) == len(new)140    assert data[address:address + len(old)] == old, f'unexpected bytes at {address:06X}'141    data[address:address + len(new)] = new142143def checksum():144    struct.pack_into('>H', data, 0x18E, sum(struct.unpack(f'>{(len(data)-0x200)//2}H', data[0x200:])) & 65535)145146# Net release: light contact retains the native solid-net collision; only147# a heavy hit may take the existing dislodgement/event path.148if not args.legacy_net_contact:149    for at,old,new in [(0x14B6C,0x2000,0x3000),(0x14B74,0xE000,0xD000),(0x14B7C,0x2000,0x3000),(0x14B84,0xE000,0xD000)]:150        patch(at,struct.pack('>H',old),struct.pack('>H',new))151152# Native rewind undo must use the existing circular-buffer advance.153if not args.no_rewind_wrap:154    patch(0xA51C,bytes.fromhex('d8fc0062'),bytes.fromhex('610000e2'))155156with tempfile.TemporaryDirectory(prefix='nhl94-experimental-') as temp:157    work = Path(temp)158    def assemble(name, origin):159        shutil.copy2(here / (name + '.s'), work / (name + '.s'))160        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())161        if name=='grey_panels':162            source.write_text(f'    .equ WITH_NATIVE_FOOTER_WIDTH,{int(not args.legacy_footer_width)}\n'+source.read_text())163        if name in ('replaymenu','seasonplayers','pass_mode','finished','allstar','coach_goalie','goalie_season','goalie_offense','strict_so','seasonend','coach_lines'):164            source=work/(name+'.s');source.write_text(f'    .equ WITH_TRANSFERS,{int(with_transfers)}\n'+source.read_text())165        if name=='transfers_ui':166            assert data[0x22A000:0x22A002]==bytes.fromhex('6100')167            force=0x22A002+int.from_bytes(data[0x22A002:0x22A004],'big',signed=True)168            assert 0x22A000<=force<0x22B000169            source=work/(name+'.s');source.write_text(f'    .equ MENU_FORCE,0x{force:X}\n'+source.read_text())170        if name=='transfers':171            shutil.copy2(here/'transfers_store.s',work/'transfers_store.s')172            shutil.copy2(here/'transfers_tx.s',work/'transfers_tx.s')173        if name=='transfers_runtime':174            shutil.copy2(here/'transfers_lines.s',work/'transfers_lines.s')175            shutil.copy2(here/'transfers_getters.s',work/'transfers_getters.s')176            shutil.copy2(here/'transfers_season.s',work/'transfers_season.s')177            shutil.copy2(here/'transfers_cards.s',work/'transfers_cards.s')178        if name in ('mtap','seasonplayers','fourgoalie'):179            source=work/(name+'.s');source.write_text(f'    .equ WITH_QUICKGAME,{int(not args.legacy_quickgame)}\n'+source.read_text())180        if name in ('pass_mode','replaymenu','seasonplayers'):181            source=work/(name+'.s');source.write_text(f'    .equ WITH_FACEOFF_DOTS,{int(with_faceoff_dots)}\n'+source.read_text())182        if name in ('pass_mode','replaymenu','seasonplayers'):183            source=work/(name+'.s');source.write_text(f'    .equ WITH_AI_SHOT_PLAN,{int(with_ai_shot_plan)}\n'+source.read_text())184        if name=='shot_plan':185            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())186        if name in ('pass_mode','replaymenu','seasonplayers','shootout','shootout_rules'):187            source=work/(name+'.s');source.write_text(f'    .equ WITH_STRICT_SO,{int(with_strict_so)}\n'+source.read_text())188        if name in ('pass_mode','replaymenu','seasonplayers','shootout'):189            source=work/(name+'.s');source.write_text(f'    .equ WITH_ALT_GOALIES,{int(with_alt_goalies)}\n'+source.read_text())190        if name in ('pass_mode','replaymenu','seasonplayers'):191            source=work/(name+'.s');source.write_text(f'    .equ WITH_PICKUP_DELAY,{int(with_pickup_delay)}\n'+source.read_text())192        if name in ('pass_mode','replaymenu','seasonplayers'):193            source=work/(name+'.s');source.write_text(f'    .equ WITH_PASS_CHARGE,{int(with_pass_charge)}\n'+source.read_text())194        if name in ('pass_mode','replaymenu','seasonplayers'):195            source=work/(name+'.s');source.write_text(f'    .equ WITH_PENALTY_RATE,{int(with_penalty_rate)}\n'+source.read_text())196        if name=='onetimer':197            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())198        if name=='shootout_rules':199            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())200        if name=='shootout':201            source=work/(name+'.s');source.write_text(f'    .equ LEGACY_OVERTIME,{int(args.legacy_overtime)}\n'+source.read_text())202        if name=='crowd':203            source=work/(name+'.s');source.write_text(f'    .equ WITH_EASY_STATS,{int(with_easy_stats)}\n'+source.read_text())204            source=work/(name+'.s');source.write_text(f'    .equ LEGACY_WHISTLE_MOMENTUM,{int(args.legacy_whistle_momentum)}\n'+source.read_text())205        if name=='menubg':206            source=work/(name+'.s');source.write_text(f'    .equ WITH_MENU_78,{int(not args.legacy_menu_background)}\n'+source.read_text())207        if name=='quickgame':208            source=work/(name+'.s');source.write_text(f'    .equ WITH_QUICKGAME_CPU,{int(with_quickgame_cpu)}\n'+source.read_text())209            source.write_text(f'    .equ WITH_ALLSTAR_CPU,{int(with_allstar_cpu)}\n'+source.read_text())210        if name in ('replay','replaytitle','shot_panel_left','shotmeter'):211            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())212        if name in ('replay','replaytitle'):213            source=work/(name+'.s');source.write_text(f'    .equ WITH_FRAMED_PP_REPLAY,{int(with_pp_snapshot)}\n'+source.read_text())214        if name=='shotmeter':215            source=work/(name+'.s');source.write_text(f'    .equ WITH_LEFT_PANEL,{int(not args.legacy_shot_panel_location)}\n'+source.read_text())216        if name in ('crowd','shotmeter'):217            source=work/(name+'.s');source.write_text(f'    .equ WITH_STARS_HUD,{int(not args.legacy_stars_hud)}\n'+source.read_text())218        if name in ('switch','fourgoalie'):219            source=work/(name+'.s');source.write_text(f'    .equ WITH_MANUAL_KEEPER_PASS,{int(not args.legacy_manual_keeper_pass)}\n'+source.read_text())220        if name in ('switch','fourgoalie','outlet'):221            source=work/(name+'.s');source.write_text(f'    .equ WITH_IMMEDIATE_B,{int(not args.legacy_b_switch)}\n'+source.read_text())222        if name in ('goalie_ai','outlet'):223            source=work/(name+'.s');source.write_text(f'    .equ WITH_CATCH_AI,{int(not args.legacy_goalie_catch)}\n'+source.read_text())224        if name in ('crowd','replaymenu','seasonplayers'):225            source=work/(name+'.s');source.write_text(f'    .equ WITH_PREF_DEFAULTS,{int(not args.legacy_menu_defaults)}\n'+source.read_text())226        if name in ('fourgoalie','outlet'):227            source=work/(name+'.s');source.write_text(f'    .equ WITH_OUTLET_DEADLINE,{int(not args.legacy_outlet_deadline)}\n'+source.read_text())228        if name in ('replaymenu','seasonplayers','pass_height','passcone'):229            source=work/(name+'.s');source.write_text(f'    .equ WITH_DIRECTIONAL,{int(with_directional)}\n'+source.read_text())230        if name in ('replaymenu','seasonplayers','pass_height'):231            source=work/(name+'.s');source.write_text(f'    .equ WITH_PASS_SPEED,{int(with_pass_speed)}\n'+source.read_text())232        if name in ('replaymenu','seasonplayers'):233            source=work/(name+'.s');source.write_text(f'    .equ WITH_PASS_HEIGHT,{int(with_pass_height)}\n'+source.read_text())234            source=work/(name+'.s');source.write_text(f'    .equ ICING_DEFAULT,{int(args.legacy_icing_default)}\n'+source.read_text())235            source=work/(name+'.s');source.write_text(f'    .equ WITH_ICING_MENU,{int(not args.legacy_icing_menu)}\n'+source.read_text())236        if name=='seasonplayers':237            source=work/(name+'.s');source.write_text(f'    .equ WITH_CRIT_LOCK,{int(not args.legacy_crit_lock)}\n'+source.read_text())238        if name in ('replaymenu','seasonplayers','pass_mode','crowd'):239            source=work/(name+'.s');source.write_text(f'    .equ WITH_MOMENTUM,{int(with_momentum)}\n'+source.read_text())240        if name in ('replaymenu','critical_slap','critical','seasonplayers'):241            source=work/(name+'.s');source.write_text(f'    .equ WITH_CRIT_OPTION,{int(not args.no_crit_option)}\n'+source.read_text())242        if name=='ai_gap':243            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())244        if name in ('ai_attack','ai_defence','ai_gap','ai_playbook'):245            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())246        if name in ('goalie_season','crowd'):247            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())248        if name in ('ai_playbook','crowd'):249            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())250        if name in ('ai_smart_aim','ai_playbook'):251            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())252        if name=='ai_freecross':253            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())254        if name in ('ai_playbook','ai_smart_aim'):255            source.write_text(f'    .equ WITH_FRIENDLY_RECEPTION,{int(with_friendly_reception)}\n'+source.read_text())256        if name=='finisher_position':257            source=work/(name+'.s');source.write_text(f'    .equ FINISH_POSITION_ENABLED,{int(not args.no_finish_hold)}\n'+source.read_text())258            source.write_text(f'    .equ WITH_FRIENDLY_RECEPTION,{int(with_friendly_reception)}\n'+source.read_text())259        if name=='ai_playbook':260            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())261        if name in ('ai_attack','ai_defence','ai_gap'):262            source=work/(name+'.s');source.write_text(f'    .equ WITH_REAL_ACTORS,{int(not args.no_hard_tactics)}\n'+source.read_text())263        if name in ('ai_attack','onetimer'):264            source=work/(name+'.s');source.write_text(f'    .equ WITH_AI_PLAYBOOK,{int(not args.no_ai_playbook)}\n'+source.read_text())265        if name=='crowd' and args.legacy_hard_crowd:266            source=work/(name+'.s');source.write_text('    .equ LEGACY_HARD_CROWD,1\n'+source.read_text())267        if name=='credits' and args.legacy_credits:268            source=work/(name+'.s');source.write_text('    .equ LEGACY_CREDITS,1\n'+source.read_text())269        if name in ('critical_sound','critical_slap') and args.no_crit_flash:270            source = work / (name + '.s')271            source.write_text('    .equ WITH_CRIT_FLASH,0\n' + source.read_text())272        if name == 'trail':273            source = work / (name + '.s')274            source.write_text(f'    .equ TILES_ADDR,{__import__("trail").TILES}\n' + source.read_text())275        if name == 'onetimer':276            source=work/(name+'.s');source.write_text(f'    .equ WITH_HUMAN_CONTACT,{int(not args.legacy_onetimer_contact)}\n'+source.read_text())277            source = work / (name + '.s')278            source.write_text(f'    .equ NEAR_PX,{__import__("onetimer").NEAR_PX}\n' + source.read_text())279        if name == 'critical_sound' and args.legacy_crit_shots:280            source = work / (name + '.s')281            source.write_text('    .equ CRIT_SOUND_ID,14\n' + source.read_text())282        subprocess.run([str(tools / 'm68kasm.sh'), str(work / (name + '.s')), f'{origin:X}', str(work / (name + '.bin'))], check=True)283        return (work / (name + '.bin')).read_bytes()284285    if not args.legacy_shootout_rules or not args.legacy_shootout_start:286        code=assemble('shootout_rules',0x2DAA00)287        assert len(code)<=0x400,'shootout helper overlaps downed-receiver module'288        patch(0x2DAA00,b'\xff'*len(code),code)289        patch(0xF280,bytes.fromhex('31fc0190c31a'),bytes.fromhex('4eb9002daa04'))290        if not args.legacy_shootout_start:291            patch(0xF298,bytes.fromhex('0064'),bytes.fromhex('003c'))292        if not args.legacy_shootout_rules:293            patch(0xF2F4,bytes.fromhex('48a780003038b7aa'),bytes.fromhex('4ef9002daa004e71'))294295    if not args.legacy_penalty_owner:296        code=assemble('penalty_owner',0x2DA800)297        assert len(code)<=0x800298        patch(0x2DA800,b'\xff'*len(code),code)299        patch(0x121C2,bytes.fromhex('31c0d406227cffffb04a'),bytes.fromhex('4eb9002da8004e714e71'))300        patch(0xF20C,bytes.fromhex('61000b143038d408'),bytes.fromhex('4eb9002da8044e71'))301302    if not args.legacy_icing_menu:303        patch(0x17C92,bytes.fromhex('001e'),bytes.fromhex('000a'))  # Edmonton; fresh boot only304        code=assemble('icing',0x2DBA00)305        assert len(code)<0x200306        patch(0x2DBA00,b'\xff'*len(code),code)307        patch(0x101EE,bytes.fromhex('303c000c61001d38'),bytes.fromhex('4ef9002dba004e71'))308309    # Boot credits retain the first picture and return to the original startup.310    credits = (tools / ('data/boot-credits-legacy.bin' if args.legacy_games_url else 'data/boot-credits.bin')).read_bytes()311    assert len(credits) < 0x8000312    patch(0x2D0000, b'\xFF' * len(credits), credits)313    code = assemble('credits', 0x2D8000)314    assert len(code) < 0x400315    patch(0x2D8000, b'\xFF' * len(code), code)316    patch(0x771E, bytes.fromhex('4eb9002fe280'), bytes.fromhex('4eb9002d8000'))317318    # Keep AI goalie pickups automatic; explicit Y still takes him. Correct319    # co-op Y's controller offset (D4 already contains 0 or 2, not 0 or 1).320    y = assemble('ygoalie', 0x2FE040)321    assert len(y) < 0x1C0 and data[0x2FE100:0x2FE108] == bytes.fromhex('48e7c0403004d040')322    data[0x2FE040:0x2FE040+len(y)] = y323    if not args.legacy_goalie_catch:324        code=assemble('goalie_catch_control',0x2DBC00)325        assert len(code)<=0x400326        patch(0x2DBC00,b'\xff'*len(code),code)327    guard = assemble('goalie_ai', 0x2FE400)328    patch(0x2FE400, b'\xFF' * len(guard), guard)329    patch(0x15322, bytes.fromhex('b078c3206700013c'), bytes.fromhex('4ef9002fe4004e71'))330331    # A diving catch (animation $02F4) originally overwrote the normal 180-tick332    # possession timer with just 5. Once he gets up, a nearby opponent blocks333    # the AI pass and that tiny remaining window expires into a faceoff.334    # Give diving catches the same window as standing catches; native recovery,335    # passing, opponent checks and eventual hold-too-long whistle still apply.336    patch(0x1531C, bytes.fromhex('357c00050048'), bytes.fromhex('357c00b40048'))337338    # Exactly 0:40, every period. Original voice is already velocity 127;339    # a quiet unison voice adds approximately 2 dB. Keep SFX below music data.340    patch(0x15DFE, bytes.fromhex('0c78003dc468'), bytes.fromhex('0c780028c468'))341    patch(0x15E08, bytes.fromhex('0c78003cc468'), bytes.fromhex('0c780028c468'))342    sequence = bytearray.fromhex('00 c0 1d 00 00 c1 1d 00')343    note=81 if with_refinements else 74344    for pulse in range(5 if with_refinements else 3):345        sequence += bytes((0 if pulse == 0 else 8, 0x90, note, 127))346        sequence += bytes((0, 0x91, note, 40, 10, 0x80, note, 0, 0, 0x81, note, 0))347    sequence += bytes.fromhex('00 00 ff ff')348    assert all(v == 255 for v in data[0x190E:0x2000])349    patch(0x1960, b'\xFF' * len(sequence), sequence)350    patch(0x2C650, struct.pack('>I', 0x2C844), struct.pack('>I', 0x1960))351352    # SFX 2 is also selected when a penalty expires. Suppress that duplicate353    # warning via the native negative-ID silent path, keeping player release,354    # final-five-second penalty ticks (SFX 1), stack cleanup and the 0:40 warning.355    patch(0x1295E, bytes.fromhex('3ebc0002'), bytes.fromhex('3ebcffff'))356357    # Five-minute periods only: ordinary penalties 1 minute, injury/fighting 2.358    # Keep the award's original five-minute major classification, then shorten359    # its duration before stats and seconds are written (see penalty.s).360    code = assemble('penalty', 0x2FE500)361    patch(0x2FE500, b'\xFF' * len(code), code)362    table = bytearray(data[0x18E0C:0x1913A])363    for event in range(2, 0x3E, 2):364        offset = int.from_bytes(table[event:event+2], 'big')365        minutes = table[offset+1]366        if 0 < minutes < 128:367            table[offset+1] = 2 if table[offset] == 40 or minutes >= 4 else 1368    patch(0x2FE600, b'\xFF' * len(table), table)369    for operand in [0x9372, 0x11FB2, 0x120A6, 0x1243E, 0x1247A, 0x126A6, 0x127B4, 0x1280C, 0x1883A]:370        address = operand - 2371        old = bytes(data[address:address+6])372        register = (int.from_bytes(old[:2], 'big') >> 9) & 7373        assert old[2:] == bytes.fromhex('00018e0c') and register in [0, 1, 3]374        target = {0: 0x2FE500, 1: 0x2FE518, 3: 0x2FE530}[register]375        patch(address, old, b'\x4e\xb9' + struct.pack('>I', target))376    patch(0x1229A, bytes.fromhex('48e7c078610027f4'), bytes.fromhex('4ef9002fe5484e71'))377    # jdc 2026-10-25-r10: "Season Edition" intro picture (data/intro-season-edition.png ->378    # introimg.py -> data/intro-season.bin, same 38118-byte format as fix 12) and no EA379    # logo: $17A18 keeps its VDP set-up but skips drawing ($17A74 bra.s $17AA0) and waits 0.380    old_intro = (tools / 'data/intro.bin').read_bytes()381    new_intro = (tools / 'data/intro-season.bin').read_bytes()382    assert len(new_intro) == len(old_intro) and data[0x2A0000:0x2A0000 + len(old_intro)] == old_intro383    data[0x2A0000:0x2A0000 + len(new_intro)] = new_intro384    patch(0x17A74, bytes.fromhex('247c'), bytes.fromhex('602a'))385    patch(0x17AAA, bytes.fromhex('31fc0050d066'), bytes.fromhex('31fc0000d066'))386    # jdc 2026-10-26-r14: the same picture as the main menu's background (menubg.s, palette 2 greys).387    if not args.legacy_menu_background:388        menu_picture=(tools/'data/menu-background-78.bin').read_bytes()389        assert len(menu_picture)<=0x10000390        patch(0x240000,b'\xff'*len(menu_picture),menu_picture)391    code = assemble('menubg', 0x2EA000)392    assert len(code) < 0x400393    patch(0x2EA000, b'\xFF' * len(code), code)394    patch(0xF8258, bytes.fromhex('31c4d43206440024'), bytes.fromhex('4eb9002ea0004e71'))395    checksum()396    plain = work / 'plain-test.bin'397    plain.write_bytes(data)398    season = work / 'season-test.bin'399    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)400    data = bytearray(season.read_bytes())401402    if not args.no_ai_playbook:403        from ai_playbook import install as install_ai_playbook404        install_ai_playbook(data,assemble,patch)405        if with_finisher_position:406            code=assemble('finisher_position',0x225000)407            assert 0<len(code)<0x1000408            patch(0x225000,b'\xff'*len(code),code)409            if with_friendly_reception:410                friendly=assemble('friendly_reception',0x225800)411                assert len(friendly)<0x7F0412                patch(0x225800,b'\xff'*len(friendly),friendly)413                patch(0x225FF0,b'\xff'*4,b'FRCP')414415    if not args.legacy_defensive_brake and not args.no_hard_tactics:416        code=assemble('defensive_brake',0x228000)417        assert len(code)<=0x1000,'defensive brake exceeds reserved bank'418        patch(0x228000,b'\xff'*len(code),code)419420    if not args.no_ai_gap:421        from ai_gap import install as install_ai_gap422        install_ai_gap(data, assemble, patch)423424    if not args.no_coach_lines:425        from coach_lines import install as install_coach_lines426        install_coach_lines(data,assemble,patch)427    if not args.no_coach_goalie:428        from coach_goalie import install as install_coach_goalie429        install_coach_goalie(data,assemble,patch)430    if not args.no_pwrplay_clear:431        from pwrplay_clear import install as install_pwrplay_clear432        install_pwrplay_clear(data,assemble,patch,with_refinements=with_refinements)433434    # Obtain linked addresses from the same source/includes just assembled.435    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'436    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)437    symbols = {t[2]: int(t[0], 16) for line in result.stdout.splitlines() if len(t := line.split()) == 3}438    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'}439    (work / 'season-symbols.inc').write_text(''.join(f'    .equ {k},0x{symbols[v]:X}\n' for k,v in names.items()))440    code = assemble('simulate', 0x2FD000)441    patch(0x2FD000, b'\xFF' * len(code), code)442    if not args.no_goalie_season_stats:443        code=assemble('goalie_season',0x2D8C80)444        assert 0<len(code)<=0xC0,'keeper season accounting overlaps steering reservation'445        patch(0x2D8C80,b'\xff'*len(code),code)446    if not args.no_goalie_offense and not args.no_hard_tactics and not args.no_goalie_season_stats:447        code=assemble('goalie_offense',0x2DF400)448        assert 0<len(code)<=0x400449        patch(0x2DF400,b'\xff'*len(code),code)450        patch(0x14964,bytes.fromhex('302a001810c0'),bytes.fromhex('4ef9002df404'))451        patch(0x1301c,bytes.fromhex('4240102b0066'),bytes.fromhex('4ef9002df408'))452    # jdc r21: season playoff statistics apart from the league (season.s rowload/rowsave/gkput call +0/+4)453    # 2026-10-28: season penalty minutes per player (NHL94 forum report), called by season.s.454    from forumfix import install as install_forumfix   # NHL94 forum fixes, 2026-10-28455    install_forumfix(data, assemble, patch)456457    if with_pass_height:458        code=assemble('pass_height',0x2DE400)459        assert len(code)<=(0x200 if with_pass_speed else 0x400)460        patch(0x2DE400,b'\xff'*len(code),code)461        patch(0xBF5E,bytes.fromhex('11c2b776b47c000c6d00000811fc000cb776'),bytes.fromhex('4ef9002de400')+bytes.fromhex('4e71')*6)462463    if with_pass_speed:464        code=assemble('pass_speed',0x2DE600)465        assert len(code)<=0x200466        patch(0x2DE600,b'\xff'*len(code),code)467        patch(0xBF54,bytes.fromhex('4eb9002ef00c'),bytes.fromhex('4eb9002de600'))468469    if with_directional:470        code=assemble('pass_mode',0x2E6800)471        assert len(code)<=0x800,'directional mode/cone/settings exceeds reservation'472        patch(0x2E6800,b'\xff'*len(code),code)473        patch(0xBC40,bytes.fromhex('080200046600001e'),bytes.fromhex('4ef9002e68004e71'))474475    if with_pass_charge:476        code=assemble('pass_charge',0x2E8200)477        assert len(code)<=0x1800,'charge helper overlaps manual keeper module'478        patch(0x2E8200,b'\xff'*len(code),code)479        patch(0xB760,bytes.fromhex('4eb9000fee60'),bytes.fromhex('4eb9002e8200'))480481    if with_momentum:482        code=assemble('momentum',0x226000)483        assert 0<len(code)<0x1000484        patch(0x226000,b'\xff'*len(code),code)485    if with_faceoff_dots:486        code=assemble('faceoff_dots',0x224000)487        assert len(code)<=0x1000,'faceoff-dot helper exceeds reservation'488        patch(0x224000,b'\xff'*len(code),code)489        patch(0x1255A,bytes.fromhex('610000224278c3e6'),bytes.fromhex('4eb9002240004e71'))490    if with_ai_shot_plan:491        code=assemble('shot_plan',0x223000)492        assert len(code)<=0x1000,'shot-plan helper exceeds reservation'493        patch(0x223000,b'\xff'*len(code),code)494        patch(0xE412,bytes.fromhex('584f3038b75e'),bytes.fromhex('4ef900223000'))495        patch(0xE10C,bytes.fromhex('41fa001e08380002c2f2'),bytes.fromhex('4ef9002230044e714e71'))496497    if with_strict_so:498        code=assemble('strict_so',0x222000)499        assert len(code)<=0x1000,'additional shootout helper exceeds reservation'500        patch(0x222000,b'\xff'*len(code),code)501502    if with_alt_goalies:503        code=assemble('alt_goalies',0x221000)504        assert len(code)<=0x1000,'alternate keeper module exceeds reservation'505        patch(0x221000,b'\xff'*len(code),code)506        patch(0xC408,bytes.fromhex('4eb9002ff3004e71'),bytes.fromhex('4eb9002210004e71'))507        patch(0x2FF354,bytes.fromhex('0c78000fb7624ef90001503a'),bytes.fromhex('4ef9002210044e714e714e71'))508        patch(0x155FC,bytes.fromhex('b078b7726d00ff58'),bytes.fromhex('4ef9002210084e71'))509        patch(0x15580,bytes.fromhex('174000281741002a'),bytes.fromhex('4eb90022100c4e71'))510        patch(0x15288,bytes.fromhex('302a005231c0b7aa'),bytes.fromhex('4eb9002210244e71'))511512    if with_pickup_delay:513        code=assemble('pickup_delay',0x220000)514        assert len(code)<=0x1000,'pickup delay exceeds its isolated ROM reservation'515        patch(0x220000,b'\xff'*len(code),code)516517    if with_penalty_rate:518        code=assemble('penalty_rate',0x2EC800)519        assert len(code)<=0x800,'penalty rate helper overlaps shotmeter module'520        patch(0x2EC800,b'\xff'*len(code),code)521        patch(0x11F78,bytes.fromhex('067800c8b89c'),bytes.fromhex('4ef9002ec800'))522        patch(0x2004F0,bytes.fromhex('284b7005267cffffb04a'),bytes.fromhex('4ef9002ec8044e714e71'))523524    if not args.legacy_fair_pickup:525        code=assemble('fair_pickup',0x2EFC00)526        assert len(code)<=0x400527        patch(0x2EFC00,b'\xff'*len(code),code)528        patch(0x1538A,bytes.fromhex('b07c00065dc1'),bytes.fromhex('4ef9002efc00'))529530    if not args.legacy_downed_receiver:531        code=assemble('downed_receiver',0x2DAE00)532        assert len(code)<=0x200533        patch(0x2DAE00,b'\xff'*len(code),code)534        for at in (0xBD06,0x2FF1B0):535            patch(at,bytes.fromhex('082900020063'),bytes.fromhex('4eb9002dae00'))536        patch(0x150F6,bytes.fromhex('4eb9002ff380'),bytes.fromhex('4ef9002dae04'))537538    code = assemble('pim', 0x2EE000)539    assert len(code) < 0x1000540    patch(0x2EE000, b'\xFF' * len(code), code)541    code = assemble('postats', 0x2E2000)542    assert len(code) < 0x800543    patch(0x2E2000, b'\xFF' * len(code), code)544    address = symbols['save']545    old = struct.pack('>HhHh', 0x6100, symbols['addreal']-(address+2), 0x6100, symbols['gkreal']-(address+6))546    patch(address, old, bytes.fromhex('4eb9002fd0044e71'))547    start = struct.unpack_from('>I', data, 0x131CC)[0]548    assert start == 0x2FAC00549    i, items = start+8, []550    while data[i:i+2] == b'\x00\x14':551        items.append(bytes(data[i:i+24])); i += 24552    assert len(items) == 8 and data[i:i+4] == bytes.fromhex('0004ff00')553    entry = b'\x00\x14' + b'  Simulate Match  ' + struct.pack('>I', 0x2FD000)554    blob = data[start:start+8] + items[0] + entry + b''.join(items[1:]) + data[i:i+4]555    assert all(v == 255 for v in data[i+4:start+len(blob)])556    data[start:start+len(blob)] = blob557    if not args.legacy_stars_hud:558        code=assemble('stars_hud',0x2DB600)559        assert len(code)<0x200560        patch(0x2DB600,b'\xff'*len(code),code)561        patch(0x18514,bytes.fromhex('08f80007c2ea'),bytes.fromhex('4eb9002db600'))562    # B switches on press to another available skater, retaining native passing563    # and explicit Y. Two ordinary pads only; multitap retains native controls.564    if args.legacy_b_switch:565        code = assemble('switch', 0x2FD900)566        assert len(code) <= 0x200567        patch(0x2FD900, b'\xFF' * len(code), code)568    else:569        code = assemble('switch', 0x2DB000)570        assert len(code) < 0x400571        patch(0x2DB000, b'\xFF' * len(code), code)572        bridge=bytes.fromhex('60000006600000084ef9002db0004ef9002db004')573        patch(0x2FD900, b'\xFF'*len(bridge), bridge)574    patch(0xB1B6, bytes.fromhex('082b00030063660008ca'), bytes.fromhex('4ef9002fd9004e714e71'))575    patch(0xB2DA, bytes.fromhex('080300046700007c'), bytes.fromhex('4ef9002fd9044e71'))576    # Glass: 50% per hard, high puck impact on the flat far-end panes.577    # Native art only fits the far end; preserve near-end, side/corner behavior.578    # The sprite hook places shards/missing pane at the hit pane; sirens stay centred.579    code = assemble('glass', 0x2FDB00)580    assert len(code) < 0x500581    patch(0x2FDB00, b'\xFF' * len(code), code)582    original = bytes.fromhex('0c6b011800146d0000440c53000b6d0000800c5300126e0000780c6b1f00002a6d00006e')583    patch(0x14BE0, original, bytes.fromhex('4ef9002fdb00') + bytes.fromhex('4e71') * 15)584    patch(0x10320, bytes.fromhex('4253377c012c0014'), bytes.fromhex('4ef9002fdb044e71'))585    # AI keeper with a human team: hold until B, then pass to the requesting pad.586    # Preserve native manual/Y, CPU-only and special-mode paths. Exact target and587    # checked pass vector override only our synchronous native BC64 call.588    # jdc 2026-10-26-r16: Three / Four - Teammates with 4-Way Play (mtap.s).589    if not args.legacy_quickgame:590        # Private exhibition lists retain actual match screens, with no simulation,591        # suspended-season tables/leaders or destructive Abandon Season action.592        cursor=0x22B000+40593        table=bytearray()594        for offset in range(0,40,4):595            source=struct.unpack_from('>I',data,0x131CC+offset)[0]596            i=source+8;entries=[]597            while data[i:i+2]==b'\x00\x14':598                entry=bytes(data[i:i+24]);target=struct.unpack_from('>I',entry,20)[0]599                if target not in (0x2FD000,0x2FA800,0x2F9000,0x2FA000):entries.append(entry)600                i+=24601            assert data[i:i+4]==bytes.fromhex('0004ff00')602            blob=bytes(data[source:source+8])+b''.join(entries)+bytes(data[i:i+4])603            table+=struct.pack('>I',cursor)604            patch(cursor,b'\xff'*len(blob),blob)605            cursor+=len(blob)606        assert cursor<=0x22C000607        patch(0x22B000,b'\xff'*len(table),table)608    if not args.legacy_quickgame:609        code=assemble('quickgame',0x229000)610        assert len(code)<0x1000611        patch(0x229000,b'\xff'*len(code),code)612        patch(0x7C2E,bytes.fromhex('4eb9002f8000'),bytes.fromhex('4eb900229010'))613        patch(0x17336,bytes.fromhex('4ef9002fc000'),bytes.fromhex('4ef900229014'))614        patch(0x13134,bytes.fromhex('4eb9002aa010'),bytes.fromhex('4eb900229018'))615        patch(0xF280,bytes.fromhex('4eb9002daa04'),bytes.fromhex('4eb900229020'))616        patch(0x7C68,bytes.fromhex('4eb900011340'),bytes.fromhex('4eb900229024'))617    code = assemble('mtap', 0x2EA400)618    assert len(code) < 0x200619    patch(0x2EA400, b'\xFF' * len(code), code)620    patch(0x17FE0, bytes.fromhex('4cdf030f4e75'), bytes.fromhex('4ef9002ea400'))621    for a in (0xF7EA4, 0xF7EFC, 0x2093A0, 0x2093F8):622        patch(a, b'Three               ', b'Three - Teammates   ')623    for a in (0xF7EBA, 0xF7F12, 0x2093B6, 0x20940E):624        patch(a, b'Four                ', b'Four - Teammates    ')625    if not args.legacy_outlet_deadline:626        code = assemble('outlet_deadline', 0x2DB400)627        assert len(code) < 0x200628        patch(0x2DB400, b'\xFF' * len(code), code)629    code = assemble('outlet', 0x2FDC00)630    assert len(code) < 0x400631    patch(0x2FDC00, b'\xFF' * len(code), code)632    patch(0xD75C, bytes.fromhex('4a6b00486a000008'), bytes.fromhex('4ef9002fdc004e71'))633    patch(0xBCDA, bytes.fromhex('7605327cb04a0c6b00060052'), bytes.fromhex('4ef9002fdc044e714e714e71'))634    patch(0xBDBA, bytes.fromhex('4a78b774082b00070062'), bytes.fromhex('4ef9002fdc084e714e71'))635    # Adapter input reuses the two native control banks for pads3/4. Keep the636    # original two-pad switch/Y code; dispatch adapter keeper requests by C318.637    code = assemble('fourgoalie', 0x2EA600)638    assert len(code) < 0xA00639    patch(0x2EA600, b'\xFF' * len(code), code)640    patch(0xB1B6, bytes.fromhex('4ef9002fd9004e714e71'), bytes.fromhex('4ef9002ea6004e714e71'))641    patch(0xB2DA, bytes.fromhex('4ef9002fd9044e71'), bytes.fromhex('4ef9002ea6044e71'))642    patch(0x1FFC02, bytes.fromhex('4ef9002fe0404e71'), bytes.fromhex('4ef9002ea6084e71'))643    patch(0xB258, bytes.fromhex('4ef9002fe100'), bytes.fromhex('4ef9002ea60c'))644    code = assemble('seasonplayers', 0x2EB000)645    assert len(code) < 0x1000646    patch(0x2EB000, b'\xFF' * len(code), code)647    patch(0xF7782, bytes.fromhex('0c780001d048'), bytes.fromhex('4eb9002eb018'))648    patch(0xF7ABA, bytes.fromhex('4eb9002f9808'), bytes.fromhex('4eb9002eb02c'))649    patch(0xF7BAA, bytes.fromhex('4eb9002f9800'), bytes.fromhex('4eb9002eb030'))650    # Overtime uses the selected period length, then the native on-rink shootout.651    code = assemble('shootout', 0x2AC000)652    assert len(code) <= 0x2000653    patch(0x2AC000, b'\xFF'*len(code), code)654    patch(0x7742, bytes.fromhex('50f8d6b442b8dea4'), bytes.fromhex('4ef9002ac01c4e71'))655    patch(0x7814, bytes.fromhex('610000380c780003c466'), bytes.fromhex('4ef9002ac0004e714e71'))656    patch(0x7C3E, bytes.fromhex('0c780003c46666000008'), bytes.fromhex('4ef9002ac0044e714e71'))657    patch(0x7B30, bytes.fromhex('08380000c2ea66002e9c'), bytes.fromhex('4ef9002ac0084e714e71'))658    patch(0xECE2, bytes.fromhex('4eb9000fc4c0'), bytes.fromhex('4eb9002ac00c'))659    patch(0xF3DC, bytes.fromhex('4eb9000fc516'), bytes.fromhex('4eb9002ac010'))660    patch(0x1485E, bytes.fromhex('347cc6ce43ea0364'), bytes.fromhex('4ef9002ac0144e71'))661    patch(0x12F30, bytes.fromhex('08380000c2ea6600252c'), bytes.fromhex('4ef9002ac0184e714e71'))662    patch(0x18642, bytes.fromhex('0c780003c46666000032'), bytes.fromhex('4ef9002ac0204e714e71'))663    if not args.no_crit:664        from critical import install665        players = install(data, assemble, patch)666        if not args.no_crit_flash:667            from critical_flash import install as install_flash668            install_flash(data, assemble, patch)669        if not args.no_crit_sound:670            from critical_sound import install as install_sound671            install_sound(data, assemble, patch, with_flash=not args.no_crit_flash)672        if not args.legacy_crit_shots:673            from critical_slap import install as install_slap674            install_slap(data, assemble, patch, with_flash=not args.no_crit_flash, with_sound=not args.no_crit_sound)675        (output.parent / (output.stem + '.crit.json')).parent.mkdir(parents=True, exist_ok=True)676        (output.parent / (output.stem + '.crit.json')).write_text(json.dumps({'scale':100, 'chance_divisor':1000, 'power_bonus_percent':25, 'players':players}, indent=2) + '\n')677    if not args.no_crit:678        from trail import install as install_trail679        install_trail(data, assemble, patch)680    from replay import install as install_replay681    install_replay(data, assemble, patch)682    # jdc 2026-10-26-r26: co-op passes - a human receiver counts within +-90 deg (passcone.s in the683    # base hack's free bytes after its $2FF000 scorer), nearest human wins among several.684    code = assemble('passcone', 0x2FF02A)685    assert len(code) <= 0x56686    patch(0x2FF02A, b'\xFF' * len(code), code)687    patch(0xBD3A, bytes.fromhex('b0bc00010000'), bytes.fromhex('4eb9002ff02a'))688    patch(0x2FF01C, bytes.fromhex('7400'), bytes.fromhex('e08a'))689    patch(0x2FF1E2, bytes.fromhex('7400'), bytes.fromhex('e08a'))690    if with_directional:691        patch(0x2FF1D8,bytes.fromhex('08290003006267000004e08a'),bytes.fromhex('4ef9002e6818')+bytes.fromhex('4e71')*3)692    # jdc 2026-10-26-r27: shot power meter; r29: speed as a HUD-plane score-box panel.693    code = assemble('shotmeter', 0x2ED000)694    assert len(code) < 0x800695    patch(0x2ED000, b'\xFF' * len(code), code)696    if not args.legacy_shot_panel_location:697        from shot_panel_left import install as install_left_panel698        install_left_panel(data,patch)699        helper=assemble('shot_panel_left',0x227000)700        assert len(helper)<0x1000701        patch(0x227000,b'\xff'*len(helper),helper)702    # jdc 2026-10-26-r23: big chrome "REPLAY" during the automatic replay (replaytitle.py -> .s).703    assert data[0xBB4F6:0xBB4F8]==bytes.fromhex('004c'), 'native VCR allocation must remain76tiles'704    code = assemble('replaytitle', 0x2EC000)705    assert len(code) < 0x800706    patch(0x2EC000, b'\xFF' * len(code), code)707    from seasonend import install as install_seasonend708    install_seasonend(data, assemble, patch)709    from finished import install as install_finished710    install_finished(data, assemble, patch)711    if not args.no_onetimer:712        from onetimer import install as install_onetimer713        install_onetimer(data, assemble, patch)714    if with_pad_colors:715        from pad_colors import install as install_pad_colors716        install_pad_colors(data, assemble, patch,legacy=args.legacy_marker_repair,neon=not args.legacy_neon_marker)717    if with_easy_stats and not args.no_crowd:718        from easy_stats import install as install_easy_stats719        install_easy_stats(data, assemble, patch)720    if not args.no_crowd:721        from crowd import install as install_crowd722        install_crowd(data, assemble, patch)723        from replaymenu import install as install_replaymenu724        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)725    if not args.no_player_cards:726        from player_cards import install as install_cards727        cards = install_cards(data, patch)728        output.parent.mkdir(parents=True, exist_ok=True)729        output.with_suffix('.cards.json').write_text(json.dumps(cards, ensure_ascii=False, indent=2) + '\n')730    if not args.no_ai_defence:731        from ai_defence import install as install_ai_defence732        install_ai_defence(data, assemble, patch)733    if not args.no_ai_attack:734        from ai_attack import install as install_ai_attack735        install_ai_attack(data,assemble,patch)736    from ai_tactics import install as install_ai_tactics737    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)738739    if not args.no_smart_aim and not args.no_hard_tactics:740        code=assemble('ai_smart_aim',0x2DF000)741        assert 0<len(code)<=0x400,'smart aim overlaps keeper accounting'742        patch(0x2DF000,b'\xff'*len(code),code)743        patch(0xC30C,bytes.fromhex('610002583f3c0005'),bytes.fromhex('4ef9002df0004e71'))744745    if not args.no_pass_plan and not args.no_hard_tactics and not args.no_ai_playbook:746        owner_code=assemble('ai_pass_owner',0x2DD680)747        if data[0x2DD680:0x2DD680+len(owner_code)]==b'\xff'*len(owner_code):748            patch(0x2DD680,b'\xff'*len(owner_code),owner_code)749        else:750            assert data[0x2DD680:0x2DD680+len(owner_code)]==owner_code751        code=assemble('ai_pass_plan',0x2DF800)752        assert 0<len(code)<=0x800753        patch(0x2DF800,b'\xff'*len(code),code)754        patch(0xBFB6,bytes.fromhex('31c1b7744e75'),bytes.fromhex('4ef9002df800'))755756    if not args.legacy_goalie_lateral:757        # Native carried-puck target: actual puck X, not the carrier/stick midpoint.758        patch(0xD8F8,bytes.fromhex('e240'),bytes.fromhex('4e71'))759        # Reuse dead padding behind the glass JMP for a nearby taken-branch veneer.760        code=assemble('goalie_anticipation',0x2DB800)761        assert 0<len(code)<=0x400,'keeper anticipation overlaps unused reservation'762        patch(0x2DB800,b'\xff'*len(code),code)763        patch(0x14BE6,bytes.fromhex('4e714e714e71'),bytes.fromhex('4ef9002db800'))764        patch(0xD650,bytes.fromhex('7e14'),(0x14BE6-0xD650).to_bytes(2,'big',signed=True))765766    allstar_report=None767    if with_allstar:768        from allstar import install as install_allstar769        allstar_report=install_allstar(data,patch,assemble,symbols['gktab'])770        if with_allstar_cpu:771            patch(0x22CD6C,b'\xff'*4,b'ASCP')772            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.'773774    transfers_report=None775    if with_refinements:776        # jdc Nov2: native powerplay text/countdown ABOVE the fixed shot/clock.777        # Native club/timer text inside the matching72px frame; accounting stays native.778        patch(0x8398,bytes.fromhex('6002d5b802420007'),bytes.fromhex('4eb9002580184e71'))779        for at,old_y,new_y in [(0x12AF0,25,20),(0x12AFA,26,21),(0x12B1E,25,20)]:780            assert data[at-2:at]==bytes.fromhex('bf01'),('native PP formatter coordinate',hex(at))781            patch(at,bytes([old_y]),bytes([new_y]))782        # jdc: one small Momentum bar directly BELOW each actual team name.783        # Native eight-cell score frame grows upward by one row; real labels,784        # goals and accounting still use the original formatter.785        patch(0x12C7C,b'\x17',b'\x16') # top23->22786        patch(0x12C80,bytes.fromhex('7205'),bytes.fromhex('7206'))787        patch(0x12C8E,b'\x1a',b'\x19') # home26->25; bar26788        patch(0x12CA0,b'\x18',b'\x17') # away24->23; bar24789        compact=assemble('compact_momentum',0x259000)790        assert len(compact)<=0x1000791        patch(0x259000,b'\xff'*len(compact),compact)792        from menu_ui import install as install_menu_ui793        install_menu_ui(data,assemble,patch,with_cosmetic_ui=with_cosmetic_ui)794        from refinement_font import install as install_refinement_font795        install_refinement_font(data,patch,with_cosmetic_ui=with_cosmetic_ui)796        code=assemble('refinements',0x258000)797        assert len(code)<=0x1000,'refinements module exceeds its reserved ROM block'798        patch(0x258000,b'\xff'*len(code),code)799    if with_transfers:800        from transfers import install as install_transfers801        transfers_report=install_transfers(data,patch,assemble)802803    if with_refinements and with_transfers and not args.legacy_main_menu:804        from main_menu import install as install_main_menu805        install_main_menu(data,assemble,patch)806807    if with_refinements and with_transfers and not args.legacy_team_ovr:808        from team_ovr import install as install_team_ovr809        install_team_ovr(data,assemble,patch)810811    if not args.legacy_downed_y:812        from downed_y import install as install_downed_y813        install_downed_y(data,assemble,patch)814815    from grey_panels import install as install_grey_panels816    install_grey_panels(data,assemble,patch)817818    title = f'NHL 2026 - 94 Edition v5.1W - {args.version}'.encode('ascii')819    assert len(title) <= 48820    data[0x120:0x150] = title.ljust(48, b' ')821    data[0x150:0x180] = title.ljust(48, b' ')822    checksum()823    output.parent.mkdir(parents=True, exist_ok=True)824    output.write_bytes(data)825    if allstar_report:826        allstar_report['rom_sha256']=hashlib.sha256(data).hexdigest()827        output.with_suffix('.allstar.json').write_text(json.dumps(allstar_report,indent=2)+'\n')828    if transfers_report:829        transfers_report['rom_sha256']=hashlib.sha256(data).hexdigest()830        output.with_suffix('.transfers.json').write_text(json.dumps(transfers_report,indent=2)+'\n')831    print(json.dumps({'input_sha256':hashlib.sha256(base).hexdigest(), 'output':str(output), 'output_sha256':hashlib.sha256(data).hexdigest()}, indent=2))832