# build_season.py in.bin out.bin  -- adds the season-mode code (season.s, assembled with ../m68kasm.sh) to a patched ROM.
import sys, struct, subprocess, os
here = os.path.dirname(os.path.abspath(__file__))
src, dst = sys.argv[1], sys.argv[2]
assert all(arg in ['--crit','--goalie-season-stats','--menu-settings','--menu-defaults','--legacy-icing-default','--legacy-mini-schedule','--legacy-mini-eight','--pass-height','--pass-speed','--directional','--penalty-rate','--pass-charge','--pickup-delay','--alt-goalies','--strict-so','--ai-shot-plan','--faceoff-dots','--momentum','--quickgame','--allstar','--quickgame-cpu','--transfers','--refinements'] for arg in sys.argv[3:]), 'unknown season build option'
open(os.path.join(here, 'features.inc'), 'w').write('| generated by build_season.py\n        .equ WITH_CRIT,%d\n        .equ WITH_GK_SEASON_STATS,%d\n        .equ WITH_ICING_MENU,%d\n        .equ WITH_PREF_DEFAULTS,%d\n        .equ ICING_DEFAULT,%d\n        .equ WITH_DIVISION_MINI,%d\n        .equ MINI_GAMES,%d\n        .equ WITH_PASS_HEIGHT,%d\n        .equ WITH_PASS_SPEED,%d\n        .equ WITH_DIRECTIONAL,%d\n        .equ WITH_PENALTY_RATE,%d\n        .equ WITH_PASS_CHARGE,%d\n        .equ WITH_PICKUP_DELAY,%d\n        .equ WITH_ALT_GOALIES,%d\n        .equ WITH_STRICT_SO,%d\n        .equ WITH_AI_SHOT_PLAN,%d\n        .equ WITH_FACEOFF_DOTS,%d\n        .equ WITH_MOMENTUM,%d\n' % ('--crit' in sys.argv[3:], '--goalie-season-stats' in sys.argv[3:], '--menu-settings' in sys.argv[3:], '--menu-defaults' in sys.argv[3:], '--legacy-icing-default' in sys.argv[3:], '--legacy-mini-schedule' not in sys.argv[3:],8 if '--legacy-mini-eight' in sys.argv[3:] else 7,'--pass-height' in sys.argv[3:],'--pass-speed' in sys.argv[3:],'--directional' in sys.argv[3:],'--penalty-rate' in sys.argv[3:],'--pass-charge' in sys.argv[3:],'--pickup-delay' in sys.argv[3:],'--alt-goalies' in sys.argv[3:],'--strict-so' in sys.argv[3:],'--ai-shot-plan' in sys.argv[3:],'--faceoff-dots' in sys.argv[3:],'--momentum' in sys.argv[3:]))
with open(os.path.join(here,'features.inc'),'a') as features:
    features.write('        .equ WITH_QUICKGAME,%d\n' % ('--quickgame' in sys.argv[3:]))
    features.write('        .equ WITH_ALLSTAR,%d\n' % ('--allstar' in sys.argv[3:]))
    features.write('        .equ WITH_QUICKGAME_CPU,%d\n' % ('--quickgame-cpu' in sys.argv[3:]))
    features.write('        .equ WITH_TRANSFERS,%d\n' % ('--transfers' in sys.argv[3:]))
    features.write('        .equ WITH_REFINEMENTS,%d\n' % ('--refinements' in sys.argv[3:]))
d = bytearray(open(src, 'rb').read())
ORG = 0x2F8000
# team strength 0..255 from the ROM's ratings (top 12 skaters + best goalie), for the simulated league games
def tptr(t): return struct.unpack('>I', d[0x30E + 4 * t:0x312 + 4 * t])[0]
score = []
for t in range(32):
    a = tptr(t); h = struct.unpack('>6H', d[a:a + 12]); i = a + h[0]; sk = []; gl = []
    while i < a + h[2] - 2:
        n = struct.unpack('>H', d[i:i + 2])[0]; r = [int(c, 16) for c in d[i + n + 1:i + n + 8].hex()]; i += n + 8
        if r[6] == 0 and r[8] == 0 and r[9] == 0: gl.append(r[3] + r[4] + r[1])
        else: sk.append(r[1] + r[2] + r[3] + r[4] + r[5] + r[8] + r[9] + r[12])
    sk.sort(reverse=True)
    score.append(sum(sk[:12]) / 12 + 1.5 * (max(gl) if gl else 9))
lo, hi = min(score), max(score)
st = [int(255 * (x - lo) / (hi - lo)) for x in score]
open(os.path.join(here, 'strength.inc'), 'w').write('| generated by build_season.py: team strength 0..255 (ANA..WPG)\nstrength:\n' +
    ''.join('        .byte   %s\n' % ','.join(str(v) for v in st[k:k + 8]) for k in range(0, 32, 8)) + '        .even\n')
print('strength', st)
# scorer / assist weights per roster slot (goalies 0), for the simulated games
pw = {'g': [], 'a': []}
for t in range(32):
    a = tptr(t); h = struct.unpack('>6H', d[a:a + 12]); i = a + h[0]; wg = []; wa = []
    while i < a + h[2] - 2:
        n = struct.unpack('>H', d[i:i + 2])[0]; r = [int(c, 16) for c in d[i + n + 1:i + n + 8].hex()]; i += n + 8
        if r[6] == 0 and r[8] == 0 and r[9] == 0: wg.append(0); wa.append(0); continue
        g = r[3] + r[9] + r[5] + r[8]           # off. awareness, accuracy, shot power, stickhandling
        a_ = r[12] + r[3] + r[8] + r[1]          # passing, off. awareness, stickhandling, agility
        wg.append(min(255, g * g // 6)); wa.append(min(255, a_ * a_ // 6))
    while len(wg) < 26: wg.append(0); wa.append(0)
    pw['g'].append(wg[:26]); pw['a'].append(wa[:26])
def table(lbl, rows):
    out = lbl + ':\n'
    for r in rows: out += '        .word   %d\n        .byte   %s\n' % (sum(r), ','.join(map(str, r)))
    return out
open(os.path.join(here, 'pweights.inc'), 'w').write('| generated by build_season.py: per team word total + 26 slot weights\n' +
    table('pw_goal', pw['g']) + table('pw_ast', pw['a']) + '        .even\n')
# goalies per team: starter (line 1 G), backup (first other goalie), number of goalies (they come first in the roster)
gk = []
for t in range(32):
    a = tptr(t); h = struct.unpack('>6H', d[a:a + 12]); i = a + h[0]; n_g = 0
    while i < a + h[2] - 2:
        n = struct.unpack('>H', d[i:i + 2])[0]; r = [int(c, 16) for c in d[i + n + 1:i + n + 8].hex()]; i += n + 8
        if r[6] == 0 and r[8] == 0 and r[9] == 0: n_g += 1
        else: break
    st_ = d[a + h[3]] - 1
    assert 0 <= st_ < n_g <= 3, (t, st_, n_g)
    gk.append((st_, [g for g in range(n_g) if g != st_][0], n_g))
open(os.path.join(here, 'gk.inc'), 'w').write('| generated by build_season.py: per team starter, backup (roster index), number of goalies, 0\ngktab:\n' +
    ''.join('        .byte   %d,%d,%d,0\n' % g for g in gk))
print('goalies', gk)
# schedule: a fixed shuffle of opponent offsets 1..31 (opponent = (your team + offset) mod 32)
import random
rng = random.Random(1994); off = list(range(1, 32)); rng.shuffle(off)
open(os.path.join(here, 'sched.inc'), 'w').write('| generated by build_season.py: opponent offsets for game k mod 31\nsched:\n' +
    ''.join('        .byte   %s\n' % ','.join(str(v) for v in off[k:k + 8]) for k in range(0, 31, 8)) + '        .even\n')
# Use the same division membership as the standings and playoffs.
import re
text=open(os.path.join(here,'standings.s')).read()
divs=list(map(int,re.search(r'divs:.*?\.byte\s+([0-3,]+)',text).group(1).split(',')))
assert len(divs)==32 and all(divs.count(i)==8 for i in range(4))
for filename in ['table.s','playoffs.s']:
    other=open(os.path.join(here,filename)).read()
    assert re.search(r'\.byte\s+'+','.join(map(str,divs)),other), 'division maps disagree'
mini=[[None]*8 for _ in range(32)]
for division in range(4):
    circle=[i for i,dv in enumerate(divs) if dv==division]
    rounds=[]
    for k in range(7):
        pairs=list(zip(circle[:4],reversed(circle[4:])))
        rounds.append(pairs)
        circle=[circle[0],circle[-1],*circle[1:-1]]
    rounds.append(rounds[3])
    for k,pairs in enumerate(rounds):
        for home,away in pairs:mini[home][k]=away;mini[away][k]=home
for team,row in enumerate(mini):
    assert len(set(row[:7]))==7 and len(set(row))==7
    assert team not in row and all(divs[v]==divs[team] for v in row)
    assert all(mini[other][k]==team for k,other in enumerate(row))
open(os.path.join(here,'minisched.inc'),'w').write('| generated:32 teams x8 rounds;7 divisional opponents plus round4 return\nmini_schedule:\n'+''.join('        .byte '+','.join(map(str,row))+'\n' for row in mini))
if '--legacy-mini-schedule' not in sys.argv[3:]:
    origin=0x2DBE00
    subprocess.run([os.path.join(here,'..','m68kasm.sh'),os.path.join(here,'minischedule.s'),'%X'%origin,os.path.join(here,'minischedule.bin')],check=True)
    module=open(os.path.join(here,'minischedule.bin'),'rb').read()
    assert len(module)<=0x200 and d[origin:origin+len(module)]==b'\xff'*len(module),'Mini helper overlaps reserved ROM'
    d[origin:origin+len(module)]=module
subprocess.run([os.path.join(here, '..', 'm68kasm.sh'), os.path.join(here, 'season.s'), '%X' % ORG, os.path.join(here, 'season.bin')], check=True)
code = open(os.path.join(here, 'season.bin'), 'rb').read()
assert all(b == 0xFF for b in d[ORG:ORG + len(code)]), 'free space not free'
d[ORG:ORG + len(code)] = code
HOOK = 0x7C2E                                  # jsr $FF88E on the game-over path
assert d[HOOK:HOOK + 6] == bytes.fromhex('4eb9000ff88e'), d[HOOK:HOOK + 6].hex()
d[HOOK:HOOK + 6] = bytes.fromhex('4eb9') + struct.pack('>I', ORG)
# season menu (locks Home/Away/Players to the schedule)
MORG = 0x22A000 if '--quickgame' in sys.argv[3:] else 0x2F9800
MENTRY = 0x2F9800
subprocess.run([os.path.join(here, '..', 'm68kasm.sh'), os.path.join(here, 'seasonmenu.s'), '%X' % MORG, os.path.join(here, 'seasonmenu.bin')], check=True)
mc = open(os.path.join(here, 'seasonmenu.bin'), 'rb').read()
assert all(b == 0xFF for b in d[MORG:MORG + len(mc)]) and len(mc) <= (0x1000 if '--quickgame' in sys.argv[3:] else 0x800), 'season menu: no room'
d[MORG:MORG + len(mc)] = mc
if MORG != MENTRY:
    assert d[MENTRY:MENTRY+14]==b'\xff'*14
    d[MENTRY:MENTRY+6]=bytes.fromhex('4ef9')+struct.pack('>I',MORG)
    d[MENTRY+8:MENTRY+14]=bytes.fromhex('4ef9')+struct.pack('>I',MORG+8)
assert d[0xF7BAA:0xF7BB0] == bytes.fromhex('4eb9002fe000'), 'fix 8 missing in the base ROM'
d[0xF7BAA:0xF7BB0] = bytes.fromhex('4eb9') + struct.pack('>I', MENTRY)
assert d[0xF7ABA:0xF7AC0] == bytes.fromhex('4eb9002095d6')
d[0xF7ABA:0xF7AC0] = bytes.fromhex('4eb9') + struct.pack('>I', MENTRY + 8)   # writehook = _start + 8 (bsr.w + bra.w)
# more menu hooks: team rows and labels (addresses of the labels from the symbol list)
sym = subprocess.run(['docker', 'run', '--rm', '-v', here + ':/w', '-w', '/w', '--entrypoint', 'sh', 'ghcr.io/stephane-d/sgdk', '-c',
    'm68k-elf-as -m68000 -o /tmp/m.o seasonmenu.s && m68k-elf-ld -Ttext=0x%X -e _start -o /tmp/m.elf /tmp/m.o && m68k-elf-nm /tmp/m.elf' % MORG],
    capture_output=True, text=True, check=True).stdout
SYM = {l.split()[2]: int(l.split()[0], 16) for l in sym.splitlines() if len(l.split()) == 3}
assert SYM['writehook'] == MORG + 8, hex(SYM['writehook'])
assert d[0xF7C72:0xF7C78] == bytes.fromhex('227cffffd048')
d[0xF7C72:0xF7C78] = bytes.fromhex('4eb9') + struct.pack('>I', SYM['teamrow'])
assert d[0xF80BC:0xF80C2] == bytes.fromhex('4eb900011a48')
d[0xF80BC:0xF80C2] = bytes.fromhex('4eb9') + struct.pack('>I', SYM['labelhook'])
# (tested and dropped: opening the stats screens from the main menu row itself — they need the scoreboard screen setup)
# Left/Right on the Game row chooses another unplayed opponent.
assert d[0xF75C2:0xF75C8] == bytes.fromhex('0c780004d048')
d[0xF75C2:0xF75C8] = bytes.fromhex('4eb9') + struct.pack('>I', SYM['mainlr'])
assert d[0x1732C:0x17332] == bytes.fromhex('4eb9000fcc76')
d[0x1732C:0x17332] = bytes.fromhex('4eb9') + struct.pack('>I', SYM['skipmatch'])
# standings screen, replacing "Other Scores" in the pause / post-game menus
SORG = 0x2F9000
subprocess.run([os.path.join(here, '..', 'm68kasm.sh'), os.path.join(here, 'standings.s'), '%X' % SORG, os.path.join(here, 'standings.bin')], check=True)
sc = open(os.path.join(here, 'standings.bin'), 'rb').read()
assert SORG >= ORG + len(code) and all(b == 0xFF for b in d[SORG:SORG + len(sc)]), 'free space not free'
d[SORG:SORG + len(sc)] = sc
old = b'\x00\x14   Other Scores   \x00\x00\x80\xd4'
new = b'\x00\x14 Season Standings ' + struct.pack('>I', SORG)
n = d.count(old); assert n >= 1, 'menu entry not found'
d[:] = d.replace(old, new); print('menu entries replaced:', n)
# scoring leaders, replacing "Record Holders"
LORG = 0x2FA000
subprocess.run([os.path.join(here, '..', 'm68kasm.sh'), os.path.join(here, 'leaders.s'), '%X' % LORG, os.path.join(here, 'leaders.bin')], check=True)
lc = open(os.path.join(here, 'leaders.bin'), 'rb').read()
assert all(b == 0xFF for b in d[LORG:LORG + len(lc)]) and LORG + len(lc) <= 0x2FB000, 'leaders: no room'
d[LORG:LORG + len(lc)] = lc
old = b'\x00\x14  Record Holders  \x00\x0f\xbc\x14'
new = b'\x00\x14 Scoring Leaders  ' + struct.pack('>I', LORG)
n = d.count(old); assert n >= 1, 'Record Holders entry not found'
d[:] = d.replace(old, new); print('leaders entries replaced:', n)
# one team's players (opened from the standings)
TORG = 0x2FB000
subprocess.run([os.path.join(here, '..', 'm68kasm.sh'), os.path.join(here, 'teamstats.s'), '%X' % TORG, os.path.join(here, 'teamstats.bin')], check=True)
tc = open(os.path.join(here, 'teamstats.bin'), 'rb').read()
assert all(b == 0xFF for b in d[TORG:TORG + len(tc)]) and TORG + len(tc) <= 0x2FC000, 'team stats: no room'
d[TORG:TORG + len(tc)] = tc
# league table with the playoff tree's team banners, after every season game (hook $17336)
LTORG = 0x2FC000
subprocess.run([os.path.join(here, '..', 'm68kasm.sh'), os.path.join(here, 'table.s'), '%X' % LTORG, os.path.join(here, 'table.bin')], check=True)
lt = open(os.path.join(here, 'table.bin'), 'rb').read()
assert all(b == 0xFF for b in d[LTORG:LTORG + len(lt)]) and LTORG + len(lt) <= 0x2FC800, 'league table: no room'
d[LTORG:LTORG + len(lt)] = lt
# playoffs after the season: the season's 16 teams in the game's own playoff tree; Light is single-game, full is best of 7
PORG = 0x2FC800
subprocess.run([os.path.join(here, '..', 'm68kasm.sh'), os.path.join(here, 'playoffs.s'), '%X' % PORG, os.path.join(here, 'playoffs.bin')], check=True)
pc = open(os.path.join(here, 'playoffs.bin'), 'rb').read()
assert all(b == 0xFF for b in d[PORG:PORG + len(pc)]) and PORG + len(pc) <= 0x2FD000, 'playoffs: no room'
d[PORG:PORG + len(pc)] = pc
assert d[0x17D8E:0x17D96] == bytes.fromhex('227c00005576d2c1'), d[0x17D8E:0x17D96].hex()
d[0x17D8E:0x17D96] = bytes.fromhex('4eb9') + struct.pack('>I', PORG) + bytes.fromhex('4e71')
assert d[0x17D4A:0x17D52] == bytes.fromhex('31c1ceee31c0cee8'), d[0x17D4A:0x17D52].hex()
d[0x17D4A:0x17D52] = bytes.fromhex('4eb9') + struct.pack('>I', PORG + 4) + bytes.fromhex('4e71')
assert d[0x17336:0x1733E] == bytes.fromhex('4a78d0486700e128'), d[0x17336:0x1733E].hex()
d[0x17336:0x1733E] = bytes.fromhex('4ef9') + struct.pack('>I', LTORG) + bytes.fromhex('4e71')
# "New Season" in the pre-game menu: a copy of the Regular Season pre-game list with one more entry,
# and the list pointer (table at $131CC, entry 0) moved to the copy
NORG = 0x2FA800
subprocess.run([os.path.join(here, '..', 'm68kasm.sh'), os.path.join(here, 'newseason.s'), '%X' % NORG, os.path.join(here, 'newseason.bin')], check=True)
nc = open(os.path.join(here, 'newseason.bin'), 'rb').read()
assert all(b == 0xFF for b in d[NORG:NORG + len(nc)]) and NORG + len(nc) <= 0x2FAC00, 'new season: no room'
d[NORG:NORG + len(nc)] = nc
LIST0 = struct.unpack('>I', d[0x131CC:0x131D0])[0]
assert LIST0 == 0x19A84, hex(LIST0)
i = LIST0 + 8; items = []
while d[i:i + 2] == b'\x00\x14': items.append(bytes(d[i:i + 24])); i += 24
assert d[i:i + 4] == bytes.fromhex('0004ff00')
items.append(b'\x00\x14' + b'  Abandon Season  ' + struct.pack('>I', NORG))
NL = 0x2FAC00
blob = bytes(d[LIST0:LIST0 + 8]) + b''.join(items) + bytes(d[i:i + 4])
assert all(b == 0xFF for b in d[NL:NL + len(blob)])
d[NL:NL + len(blob)] = blob
d[0x131CC:0x131D0] = struct.pack('>I', NL)
print('pre-game menu:', len(items), 'entries')
# Shared statistics input: in Two - Teammates both normal pads can navigate.
IORG = 0x2FD500
subprocess.run([os.path.join(here, '..', 'm68kasm.sh'), os.path.join(here, 'statsinput.s'), '%X' % IORG, os.path.join(here, 'statsinput.bin')], check=True)
ic = open(os.path.join(here, 'statsinput.bin'), 'rb').read()
assert len(ic) <= 0x100 and all(b == 0xFF for b in d[IORG:IORG + len(ic)]), 'statistics input: no room'
d[IORG:IORG + len(ic)] = ic
# Persistent circle-round selection; SRAM $1212..$121F, RAM $FFF020..$FFF045.
OORG = 0x2FD600
subprocess.run([os.path.join(here, '..', 'm68kasm.sh'), os.path.join(here, 'opponents.s'), '%X' % OORG, os.path.join(here, 'opponents.bin')], check=True)
oc = open(os.path.join(here, 'opponents.bin'), 'rb').read()
assert len(oc) <= 0x600 and all(b == 0xFF for b in d[OORG:OORG + len(oc)]), 'opponents: no room'
d[OORG:OORG + len(oc)] = oc
# Three season snapshots, with legacy active window unchanged. Full32-bit SRAM copy helpers.
BORG = 0x2AA000
subprocess.run([os.path.join(here, '..', 'm68kasm.sh'), os.path.join(here, 'slots.s'), '%X' % BORG, os.path.join(here, 'slots.bin')], check=True)
bc = open(os.path.join(here, 'slots.bin'), 'rb').read()
assert len(bc) <= 0x1000 and all(b == 0xFF for b in d[BORG:BORG+len(bc)]), 'season slots: no room'
d[BORG:BORG+len(bc)] = bc
# Season-only playoff pre-game list: same restart confirmation as the league.
plist = struct.unpack_from('>I', d, 0x131CC+20)[0]
assert plist == 0x19B38
j = plist+8
while d[j:j+2] == b'\x00\x14': j +=24
assert d[j:j+4] == bytes.fromhex('0004ff00')
pblob = bytes(d[plist:j]) + b'\x00\x14  Abandon Season  ' + struct.pack('>I',NORG) + bytes(d[j:j+4])
assert BORG+len(bc) <= 0x2AB000 and all(b==255 for b in d[0x2AB000:0x2AB000+len(pblob)])
d[0x2AB000:0x2AB000+len(pblob)] = pblob
# In-game pause lists (normal, after timeout, intermission) also get Abandon Season;
# newseason.s ignores it outside a season game. jdc 2026-10-25-r6.
PL = 0x2AB100
for src, refs in [(0x19700, [0x7D1C]), (0x1988C, [0x7D44, 0x9D84]),
                  (0x19C04, [0x131D0, 0x131D4, 0x131D8, 0x131E4, 0x131E8, 0x131EC])]:
    j = src + 8
    while d[j:j+2] == b'\x00\x14': j += 24
    assert d[j:j+4] == bytes.fromhex('0004ff00')
    blob = bytes(d[src:j]) + b'\x00\x14  Abandon Season  ' + struct.pack('>I', NORG) + bytes(d[j:j+4])
    assert PL + len(blob) <= 0x2AC000 and all(b == 255 for b in d[PL:PL+len(blob)])
    d[PL:PL+len(blob)] = blob
    for r in refs:
        assert struct.unpack_from('>I', d, r)[0] == src, hex(r)
        struct.pack_into('>I', d, r, PL)
    PL += (len(blob) + 3) & ~3
# Standalone native playoffs keep their own context (slots.s ctxhook, r9).
assert d[0xFE6B2:0xFE6B8] == bytes.fromhex('4eb90001a206')
d[0xFE6B2:0xFE6B8] = bytes.fromhex('4eb9') + struct.pack('>I', BORG + 20)
assert d[0x13134:0x1313C] == bytes.fromhex('41fa009620700000')
d[0x13134:0x1313C] = bytes.fromhex('4eb9002aa0104e71')
assert d[0x1B0:0x1BC] == bytes.fromhex('5241f8200020000100203fff'), 'unexpected SRAM descriptor'
d[0x1B8:0x1BC] = struct.pack('>I', 0x20FFFF)
s = sum(struct.unpack('>%dH' % ((len(d) - 0x200) // 2), d[0x200:])) & 0xffff
d[0x18E:0x190] = struct.pack('>H', s)
open(dst, 'wb').write(d); print('season ROM', dst, len(code), 'bytes of code at', hex(ORG))
