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