patch.py

Download file

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

1import struct,sys,os2src=sys.argv[1]; dst=sys.argv[2]; opts=set(sys.argv[3:])3d=bytearray(open(src,'rb').read())4def W(*ws): return b''.join(struct.pack('>H',w&0xffff) for w in ws)5def L(v): return struct.pack('>I',v)6def asm(base,items):7    # items: bytes | ('lbl',name) | ('bra',opword,name)  (16-bit displacement branches)8    pos=base; labels={}; out=[]9    for it in items:10        if isinstance(it,tuple) and it[0]=='lbl': labels[it[1]]=pos11        elif isinstance(it,tuple): pos+=412        else: pos+=len(it)13    code=bytearray(); pos=base14    for it in items:15        if isinstance(it,tuple) and it[0]=='lbl': continue16        if isinstance(it,tuple):17            disp=labels[it[2]]-(pos+2); code+=W(it[1],disp); pos+=418        else: code+=it; pos+=len(it)19    asm.labels=labels                              # addresses of the labels of the last call20    return bytes(code)21def put(addr,code,expect=None):22    if expect is not None: assert d[addr:addr+len(expect)]==expect,(hex(addr),d[addr:addr+len(expect)].hex())23    d[addr:addr+len(code)]=code24HOOK1=0x2FF000; HOOK2=0x2FF08025assert all(b==0xff for b in d[HOOK1:HOOK1+0x200]), "free space not free"26# ---- Fix 1: pass target selection prefers the other human teammate ----27if 'nosel' not in opts:28    put(HOOK1, asm(HOOK1,[29        W(0xC3C1),            # muls.w d1,d130        W(0xC5C2),            # muls.w d2,d231        W(0xD481),            # add.l d1,d232        W(0xD480),            # add.l d0,d2       (orig cost = dist^2 + angle penalty)33        W(0x082B,3,0x62),     # btst #3,$62(a3)   passer human-controlled?34        ('bra',0x6700,'cmp'),35        W(0x0829,3,0x62),     # btst #3,$62(a1)   candidate human-controlled?36        ('bra',0x6700,'cmp'),37        W(0x7400),            # moveq #0,d2       -> best possible cost38        ('lbl','cmp'),39        W(0xB484),            # cmp.l d4,d240        ('bra',0x6200,'done'),# bhi41        W(0x2802),            # move.l d2,d442        W(0x2049),            # movea.l a1,a043        ('lbl','done'),44        W(0x4E75)]))          # rts45    orig=bytes.fromhex('c3c1c5c2d481d480b484620000062802 2049'.replace(' ',''))46    put(0xBD44, W(0x4EB9)+L(HOOK1)+W(0x6000,0xBD56-0xBD4C)+W(0x4E71,0x4E71,0x4E71,0x4E71), orig)47# ---- Fix 2: bigger stick pickup reach for the human who is the pass target ----48if 'norecv' not in opts:49    R=int(next((o.split('=')[1] for o in opts if o.startswith('r=')),'24'))50    put(HOOK2, asm(HOOK2,[51        W(0x343C,0x000E),           # move.w #14,d252        W(0x263C)+L(0xC4),          # move.l #196,d353        W(0x082A,3,0x62),           # btst #3,$62(a2)   human-controlled?54        ('bra',0x6700,'std'),55        W(0x382A,0x0052),           # move.w $52(a2),d456        W(0xB878,0xBEE0),           # cmp.w $bee0.w,d4  is he the pass target?57        ('bra',0x6600,'std'),58        W(0x343C,R),                # move.w #R,d259        W(0x263C)+L(R*R),           # move.l #R*R,d360        ('lbl','std'),61        W(0xB042), ('bra',0x6E00,'fail'),   # cmp.w d2,d0 ; bgt62        W(0x4442),                          # neg.w d263        W(0xB042), ('bra',0x6D00,'fail'),   # cmp.w d2,d0 ; blt64        W(0xD26A,0x0014), W(0x926B,0x0014), # add.w $14(a2),d1 ; sub.w $14(a3),d165        W(0xB242), ('bra',0x6D00,'fail'),   # cmp.w d2,d1 ; blt  (d2 negative)66        W(0x4442),                          # neg.w d267        W(0xB242), ('bra',0x6E00,'fail'),   # cmp.w d2,d1 ; bgt68        W(0xC1C0), W(0xC3C1), W(0xD081),    # d0 = dx^2+dy^269        W(0x2203),                          # move.l d3,d170        W(0x4A2B,0x005E),                   # tst.b $5e(a3)71        ('bra',0x6F00,'cmp2'),              # ble72        W(0xE489),                          # lsr.l #2,d173        ('lbl','cmp2'),74        W(0xB081), ('bra',0x6200,'fail'),   # cmp.l d1,d0 ; bhi75        W(0x4EF9)+L(0x14EC8),               # jmp pickup76        ('lbl','fail'),77        W(0x4EF9)+L(0x14ED0)]))             # jmp no-pickup path78    put(0x14E84, W(0x4EF9)+L(HOOK2), bytes.fromhex('b07c000e6e000046'))79# ---- Fix 3: goalie passes: never blind, softer short passes ----80HOOK4=0x2FF14081if 'nogk' not in opts:82    GMIN=int(next((o.split('=')[1] for o in opts if o.startswith('gmin=')),'3'))83    GADD=int(next((o.split('=')[1] for o in opts if o.startswith('gadd=')),'1'))84    # Goalie aims like a skater (stick direction, +-45 deg cone, other human preferred inside it).85    # Only if nobody is in that direction: instead of a blind pass straight out, pick the other86    # human (co-op) or else the nearest teammate, regardless of direction.87    HOOK5=0x2FF18088    put(HOOK5, asm(HOOK5,[89        W(0x4A84), ('bra',0x6A00,'found'),          # tst.l d4 ; bpl found90        W(0x4A6B,0x0034), ('bra',0x6600,'blind'),   # skater -> original blind pass91        W(0x7605),                                   # moveq #5,d392        W(0x327C,0xB04A),                            # movea.w #$b04a,a193        W(0x0C6B,0x0006,0x0052), ('bra',0x6D00,'loop'),  # cmpi.w #6,$52(a3) ; blt94        W(0xD2FC,0x0300),                            # adda.w #$300,a1 (team 2)95        ('lbl','loop'),96        W(0xB7C9), ('bra',0x6700,'next'),            # cmpa.l a1,a3 ; beq97        W(0x4A69,0x0034), ('bra',0x6F00,'next'),     # goalie, or off the ice (penalty box: forum 2026-10-28)98        W(0x0829,2,0x63), ('bra',0x6600,'next'),     # unavailable99        W(0x3211), W(0x9278,0xB74A),                 # d1 = x - puck x100        W(0x3429,0x0014), W(0x9478,0xB75E),          # d2 = y - puck y101        W(0xC3C1), W(0xC5C2), W(0xD481),             # d2 = dx^2+dy^2102        W(0x082B,3,0x62), ('bra',0x6700,'cmp'),      # passer human?103        W(0x0829,3,0x62), ('bra',0x6700,'cmp'),      # candidate human?104        W(0x7400),                                   # -> other human wins105        ('lbl','cmp'),106        W(0xB484), ('bra',0x6200,'next'),            # cmp.l d4,d2 ; bhi107        W(0x2802), W(0x2049),                        # d4=d2 ; a0=a1108        ('lbl','next'),109        W(0xD2FC,0x0080),110        ('bra',0x51CB,'loop'),                       # dbra d3,loop111        W(0x4A84), ('bra',0x6B00,'blind'),112        ('lbl','found'),113        W(0x4EF9)+L(0xBD64),114        ('lbl','blind'),115        W(0x4EF9)+L(0xBD6C)]))116    put(0xBD5E, W(0x4EF9)+L(HOOK5), bytes.fromhex('4a846b00000a'))117    # flight time d2: game truncates it, so short passes fly up to 2x speed. Goalie: round up + minimum.118    put(HOOK4, asm(HOOK4,[119        W(0xB47C,0x0018),            # cmp.w #$18,d2120        ('bra',0x6300,'ok'),         # bls121        W(0x7418),                   # moveq #$18,d2122        ('lbl','ok'),123        W(0x4A6B,0x0034),            # tst.w $34(a3)  passer goalie?124        ('bra',0x6600,'skater'),125        W(0x0642,GADD),              # addi.w #GADD,d2126        W(0xB47C,GMIN),              # cmp.w #GMIN,d2127        ('bra',0x6C00,'done'),       # bge128        W(0x343C,GMIN),              # move.w #GMIN,d2129        ('lbl','done'),130        W(0x4E75),131        ('lbl','skater'),            # forum 2026-10-28 "rocket pass": a meeting due in 1.x eighths132        W(0xB47C,2),                 # was truncated to 1 and the puck flew ~2x pass speed;133        ('bra',0x6C00,'done'),       # a skater's pass now takes at least 2/8 s134        W(0x7402),                   # moveq #2,d2135        ('bra',0x6000,'done')]))136    # co-op (fix 13): a pass from one human to the other flies faster (flight time d2 * CPS / 100, at least 2 frames),137    # so the CPU has less time to cut it off. $FFBEE0 = the pass target (object index), +$62 bit 3 = human.138    CPS=int(next((o.split('=')[1] for o in opts if o.startswith('cps=')),'100'))139    HOOK13=0x2FE400140    entry=HOOK4141    if CPS!=100:142        assert all(b==0xff for b in d[HOOK13:HOOK13+0x40])143        put(HOOK13, asm(HOOK13,[144            W(0x4EB9)+L(HOOK4),          # jsr HOOK4 (cap at $18, goalie rules)145            W(0x082B,3,0x62),            # btst #3,$62(a3)   passer human?146            ('bra',0x6700,'out'),147            W(0x2F09),                   # move.l a1,-(a7)148            W(0x3038,0xBEE0),            # move.w $BEE0.w,d0  target149            ('bra',0x6B00,'rest'),       # bmi150            W(0x0C40,0x000B),            # cmpi.w #11,d0151            ('bra',0x6200,'rest'),       # bhi152            W(0xEF48),                   # lsl.w #7,d0153            W(0x43F9)+L(0xFFB04A),       # lea $FFB04A,a1154            W(0x0831,3,0x0062),          # btst #3,$62(a1,d0.w)  target human?155            ('bra',0x6700,'rest'),156            W(0x0282)+L(0xFFFF),         # andi.l #$FFFF,d2157            W(0xC4FC,CPS),               # mulu.w #CPS,d2158            W(0x84FC,100),               # divu.w #100,d2159            W(0x0282)+L(0xFFFF),         # andi.l #$FFFF,d2160            W(0xB47C,0x0002),            # cmp.w #2,d2161            ('bra',0x6C00,'rest'),       # bge162            W(0x7402),                   # moveq #2,d2163            ('lbl','rest'),164            W(0x225F),                   # movea.l (a7)+,a1165            ('lbl','out'),166            W(0x4E75)]))167        entry=HOOK13168    put(0xBF54, W(0x4EB9)+L(entry)+W(0x4E71,0x4E71), bytes.fromhex('b47c0018630000047418'))169# ---- Fix 4: long shots (e.g. from the blue line) score a bit more often ----170# Goalie stops everything below height 15 within 12-15 px, and the crossbar is at 13, so a set goalie171# was basically unbeatable from distance (about 0.3% of blue-line shots went in).172# - Shot hook ($C408): distance to the net is stored in free RAM $FFF000. For long shots173#   (distance >= bld) the game's own RNG ($11086) decides once per shot, with chance blp %,174#   that the goalie misses it ($FFF002 set).175# - Goalie reach hook ($15032): while $FFF002 is set the goalie does not touch the puck.176#   Puck still has to be under the bar and between the posts.177# - Lob hook ($C51A): long shots get vz -= vz>>blz, so fewer of them sail over the bar.178# - Pickup hook ($150F6): any player getting the puck clears both flags.179if 'nobl' not in opts:180    DST=int(next((o.split('=')[1] for o in opts if o.startswith('bld=')),'160'))181    P=int(next((o.split('=')[1] for o in opts if o.startswith('blp=')),'15'))182    Z=int(next((o.split('=')[1] for o in opts if o.startswith('blz=')),'3'))183    T=int(next((o.split('=')[1] for o in opts if o.startswith('blt=')),'40'))184    HS=0x2FF300; HR=0x2FF340; HP=0x2FF380; HZ=0x2FF3C0185    assert all(b==0xff for b in d[0x2FF300:0x2FF400]), "free space not free"186    put(HS, asm(HS,[187        W(0x3600),                    # move.w d0,d3          (original)188        W(0x31C3,0xF000),             # move.w d3,$f000.w     shot distance189        W(0x4238,0xF002),             # clr.b $f002.w190        W(0xB67C,DST),                # cmp.w #DST,d3191        ('bra',0x6D00,'out'),         # blt192        W(0x303C,100),                # move.w #100,d0193        W(0x4EB9)+L(0x11086),         # jsr rnd(0..99)194        W(0xB07C,P),                  # cmp.w #P,d0195        ('bra',0x6C00,'out'),         # bge196        W(0x11FC,T,0xF002),           # move.b #T,$f002.w     goalie will miss this one (countdown)197        ('lbl','out'),198        W(0x3003),                    # move.w d3,d0199        W(0x0838,4,0xC2EA),           # btst #4,$c2ea.w       (original; flags used after return)200        W(0x4E75)]))201    put(0xC408, W(0x4EB9)+L(HS)+W(0x4E71), bytes.fromhex('360008380004c2ea'))202    put(HR, asm(HR,[203        W(0x205F),                    # movea.l (a7)+,a0      (original)204        W(0x4A38,0xF002),             # tst.b $f002.w205        ('bra',0x6700,'norm'),206        W(0x5338,0xF002),             # subq.b #1,$f002.w     (counts while the puck is near a goalie)207        W(0x4EF9)+L(0x150C2),         # jmp: puck not in reach208        ('lbl','norm'),209        W(0x0C78,0x000F,0xB762),      # cmpi.w #$f,$b762.w    (original)210        W(0x4EF9)+L(0x1503A)]))       # jmp back (flags kept)211    put(0x15032, W(0x4EF9)+L(HR)+W(0x4E71), bytes.fromhex('205f0c78000fb762'))212    put(HP, asm(HP,[213        W(0x4A6A,0x0034),             # tst.w $34(a2)         goalie?214        ('bra',0x6600,'take'),215        W(0x4A38,0xF002),             # tst.b $f002.w216        ('bra',0x6700,'take'),217        W(0x588F),                    # addq.l #4,a7218        W(0x4EF9)+L(0x15464),         # goalie can't grab it: return like the original abort219        ('lbl','take'),220        W(0x42B8,0xF000),             # clr.l $f000.w221        W(0x08AA,0,0x62),             # bclr #0,$62(a2)       (original)222        W(0x4E75)]))223    put(0x150F6, W(0x4EB9)+L(HP), bytes.fromhex('08aa00000062'))224    if Z>0:225        put(HZ, asm(HZ,[226            W(0x0C78,DST,0xF000),     # cmpi.w #DST,$f000.w227            ('bra',0x6D00,'st'),      # blt228            W(0x3F01),                # move.w d1,-(a7)229            W(0x3203),                # move.w d3,d1230            W(0xE049|((Z&7)<<9)),     # lsr.w #Z,d1231            W(0x9641),                # sub.w d1,d3232            W(0x321F),                # move.w (a7)+,d1233            ('lbl','st'),234            W(0x31C3,0xB776),         # move.w d3,$b776.w     (original)235            W(0x08B8,4,0xC2F6),       # bclr #4,$c2f6.w       (original; Z flag used after return)236            W(0x4E75)]))237        put(0xC51A, W(0x4EB9)+L(HZ)+W(0x4E71,0x4E71), bytes.fromhex('31c3b77608b80004c2f6'))238# ---- Fix 5: off-screen arrow (e.g. goalie in shootouts) drawn 32 px too far left ----239# $164D6 places the arrow at (obj x - camera x) + $100, i.e. sprite x 128 + 128 = centre of the original240# 256 px wide screen. This hack runs 320 px (H40), centre 160, so the arrow needs + $120.241if 'noarrow' not in opts:242    put(0x16590, W(0x0640,0x0120), bytes.fromhex('06400100'))243# ---- Fix 6: Fighting setting, menu Off/On only, stored where nothing overwrites it ----244# The hack keeps the Fighting option in $FFDF00, but that word lies inside its own event log245# ($DEF6 + $DEF4, 14 bytes per entry, written at $1EFC12). After the first entry the setting holds246# player data (e.g. $0D12) and fights are on or off regardless of the menu. Value 0 = no fights247# ($2001DE), 2 = arcade (lower thresholds). Menu showed 0 as "Also On".248# - Every $DF00.w reference -> $F004.w (free RAM). The menu reaches it via (a2,d7)+$EA6 with249#   a2=$D048, d7=$12 -> new constant $F004-$D048-$12 = $1FAA.250# - Option count 3 -> 2 (both count tables) and value 0 labelled "Off": menu is Off / On.251if 'nofight' not in opts:252    for a,old in [(0x2001DE,'4a78df00'),(0x200AF0,'31c0df00')]:253        put(a, bytes.fromhex(old[:4])+W(0xF004), bytes.fromhex(old))254    for a in (0x2002C2,0x20043E,0x2004B4,0x200802):255        put(a, W(0x0C78,2,0xF004), bytes.fromhex('0c780002df00'))256    for a in (0x2095E0,0x209600,0x209624):257        put(a, W(0x0647,0x1FAA), bytes.fromhex('06470ea6'))258    for a in (0x2095C0,0x2095D4):259        put(a, W(2), bytes.fromhex('0003'))260    put(0x20956E, b'Off'+b' '*17, b'Also On'+b' '*13)261# ---- Fix 8: Fighting shows "N/A" in the menu when Penalties is Off (fights need penalties on, see $2001DE/$D056) ----262# The menu's value printer ($F7BAA: jsr $20961A) already prints "N/A" ($F7CEE) for Per. Length in shootouts.263# Hook that call: after it, option $12 (Fighting) with Penalties ($D056) = 0 → print "N/A" the same way.264FIGHTROW=0x10 if 'nouserrec' not in opts else 0x12265HOOK8=0x2FE000   # $2FE000-: small menu hooks ($2FF400- is used by the season project)266if 'nofightna' not in opts and 'nofight' not in opts:267    assert all(b==0xff for b in d[HOOK8:HOOK8+0x40])268    put(HOOK8, asm(HOOK8,[269        W(0x4EB9)+L(0x20961A),        # jsr $20961A          (original)270        W(0x0C47,FIGHTROW),           # cmpi.w #FIGHTROW,d7  Fighting row? ($12, or $10 with fix 11)271        ('bra',0x6600,'out'),         # bne out272        W(0x4A78,0xD056),             # tst.w $D056.w        Penalties273        ('bra',0x6600,'out'),         # bne out274        W(0x588F),                    # addq.l #4,a7         drop our return address275        W(0x227C)+L(0xF7CEE),         # movea.l #$F7CEE,a1   "N/A"276        W(0x4EF9)+L(0x11BA4),         # jmp $11BA4           print it (returns to the menu loop)277        ('lbl','out'),278        W(0x4E75)]))                  # rts279    put(0xF7BAA, W(0x4EB9)+L(HOOK8), bytes.fromhex('4eb90020961a'))280# ---- Fix 9: Y on a 6-button pad takes your goalie (Goalie Ctrl Manual, your team without the puck) ----281# The hack's 6-button reader turns Y into B ($1FFC02). ygoalie.s (assembled at $2FE040 by build.sh) keeps that when282# your team has the puck (Y = pass) and otherwise asks the input handler ($B258) to switch to the goalie the way283# the game's own manual-goalie switch does ($B3E4: $B86A, then $C0BC/$C0DA).284YG=0x2FE040285if 'noygoalie' not in opts:286    yb=open(os.path.join(os.path.dirname(os.path.abspath(__file__)),'ygoalie.bin'),'rb').read()287    assert all(b==0xff for b in d[YG:YG+len(yb)]) and len(yb)<0x1C0288    d[YG:YG+len(yb)]=yb289    put(0x1FFC02, W(0x4EF9)+L(YG)+W(0x4E71), bytes.fromhex('080200016600ffe8'))290    put(0xB258, W(0x4EF9)+L(YG+0xC0), bytes.fromhex('4ef90020011a'))291# ---- Fix 10: start-up defaults (jdc): Per. Length 5 minutes, Goalie Ctrl Manual, Line Changes Auto ----292# $17C72 copies 9 words from $17C8E to $D048-$D058 (Play Mode, Players, Home, Away, Per. Length 0=5 1=7 2=10 3=20,293# Goalie Ctrl 0=Manual, User Records, Penalties, Line Changes 0=On 1=Off 2=Auto). The main menu builder ($F73B0) then sets294# Line Changes from $D072 (kept between games, 0 after power-on), so $D072 gets the default too.295DEF10=0x2FE200296if 'nodefaults' not in opts:297    put(0x17C8E+8, W(0), W(2))                        # Per. Length 10 -> 5 minutes298    put(0x17C8E+10, W(0), W(0))                       # Goalie Ctrl Manual (already)299    put(0x17C8E+16, W(2), W(0))                       # Line Changes On -> Auto300    assert all(b==0xff for b in d[DEF10:DEF10+0x20])301    put(DEF10, W(0x4EB9)+L(0x200ADA)+W(0x31FC,2,0xD072)+W(0x4E75))   # jsr copy / move.w #2,$D072.w / rts302    put(0x17C86, W(0x4EB9)+L(DEF10), bytes.fromhex('4eb900200ada'))303# ---- Fix 11: User Records out of the main menu (jdc) ----304# The menu ties a row to its setting by d7 = 2 * row: value $D048 + d7 (accessors $2095D6 write, $2095F6 read,305# $20961A read for printing), the option count at $2095AE + d7 ($2095C2 with the 4-player adaptor), the value strings via the offsets at $2091FE + d7, the306# labels in the 16-byte list at $20915A. Rows 6-9 were User Records, Penalties, Line Changes, Fighting. Now the three tables307# list Penalties, Line Changes, Fighting, User Records, the accessors map rows 6-9 to their settings, and the menu has308# 9 rows: cursor limit $F777A ($14 -> $12), window top limit $F7758/$F7762 (4 -> 3). User Records stays Off, unseen.309HOOK11=0x2FE220310if 'nouserrec' not in opts:311    def swap4(base, size):                         # entries 6..9 -> 7, 8, 9, 6312        e=[bytes(d[base+size*k:base+size*(k+1)]) for k in range(6,10)]313        d[base+size*6:base+size*10]=e[1]+e[2]+e[3]+e[0]314    assert d[0x2091BC:0x2091C8]==b'User Records'315    swap4(0x20915A, 16)                            # labels (word $0010 + 14 characters)316    swap4(0x2091FE, 2)                             # value string offsets317    swap4(0x2095AE, 2)                             # option counts (after fix 6): $2095AE normally,318    swap4(0x2095C2, 2)                             # $2095C2 with the 4-player adaptor ($F79AA)319    fo = struct.unpack('>H', d[0x2095E2:0x2095E4])[0]   # Fighting's offset from fix 6 ($1FAA) or the original $EA6320    assert all(b==0xff for b in d[HOOK11:HOOK11+0x80])321    code = asm(HOOK11,[322        ('lbl','map'),                             # d1 = offset of row d7's setting from $D048323        W(0x3207),                    # move.w d7,d1324        W(0x0C41,0x000C),             # cmpi.w #$C,d1325        ('bra',0x6500,'m9'),          # bcs m9 (rows 0-5 unchanged)326        W(0x0C41,0x0012),             # cmpi.w #$12,d1327        ('bra',0x6600,'m1'),          # bne m1328        W(0x723C&0xFF00|0x0C),        # moveq #$C,d1         row 9 -> User Records329        ('bra',0x6000,'m9'),330        ('lbl','m1'),331        W(0x5441),                    # addq.w #2,d1         rows 6-8 -> Penalties, Line Changes, Fighting332        W(0x0C41,0x0012),             # cmpi.w #$12,d1333        ('bra',0x6600,'m9'),334        W(0x0641,fo),                 # addi.w #fo,d1        Fighting lives at $D048 + $12 + fo335        ('lbl','m9'),336        W(0x4E75),                    # rts337        ('lbl','wr'),                 # $2095D6: d3 -> setting of row d7, then tst.w d2338        W(0x2F01),                    # move.l d1,-(a7)339        ('bra',0x6100,'map'),         # bsr map340        W(0x3583,0x1000),             # move.w d3,(a2,d1.w)341        W(0x221F),                    # move.l (a7)+,d1342        W(0x4A42),                    # tst.w d2343        W(0x4E75),344        ('lbl','rd'),                 # $2095F6: d3 = setting of row d7, then cmpi.w #2,$D048345        W(0x2F01),346        ('bra',0x6100,'map'),347        W(0x3632,0x1000),             # move.w (a2,d1.w),d3348        W(0x221F),349        W(0x0C78,0x0002,0xD048),      # cmpi.w #2,$D048.w350        W(0x4E75),351        ('lbl','pr'),                 # $20961A: d3 = setting of row d7 (a0 = $D048), a0 = $2091FE352        W(0x2F01),353        ('bra',0x6100,'map'),354        W(0x3630,0x1000),             # move.w (a0,d1.w),d3355        W(0x221F),356        W(0x207C)+L(0x2091FE),        # movea.l #$2091FE,a0357        W(0x4E75)])358    put(HOOK11, code)359    L11=asm.labels360    put(0x2095D6, W(0x4EF9)+L(L11['wr']), bytes.fromhex('0c4700106f00'))361    put(0x2095F6, W(0x4EF9)+L(L11['rd']), bytes.fromhex('0c4700106f00'))362    put(0x20961A, W(0x4EF9)+L(L11['pr']), bytes.fromhex('0c4700106f00'))363    put(0xF777A, W(0x0012), W(0x0014))364    put(0xF7758, W(0x0003), W(0x0004))365    put(0xF7762, W(0x0003), W(0x0004))366    put(0xF7C08, W(0x0003), W(0x0004))            # the down arrow is hidden at the last window position367# ---- Fix 12: jdc's intro picture instead of the centre-ice screen, no title screen (intro.s, introimg.py) ----368INTRO=0x2FE280; INTRODATA=0x2A0000369if 'nointro' not in opts:370    here=os.path.dirname(os.path.abspath(__file__))371    ib=open(os.path.join(here,'intro.bin'),'rb').read()372    pic=open(os.path.join(here,'data','intro.bin'),'rb').read()373    assert all(b==0xff for b in d[INTRO:INTRO+len(ib)]) and INTRO+len(ib) <= 0x2FE400374    assert all(b==0xff for b in d[INTRODATA:INTRODATA+len(pic)])375    d[INTRO:INTRO+len(ib)]=ib376    d[INTRODATA:INTRODATA+len(pic)]=pic377    put(0x771E, W(0x4EB9)+L(INTRO), bytes.fromhex('4eb9000fed70'))378    put(0x172F4, W(0x4EB9)+L(INTRO+4), bytes.fromhex('4eb9000ff042'))379# ---- Fix 7: rosters (rosters/<ABBR>.json) ----380# Team table: 32 longs at $30E -> team record. Record header 6 words: +0 offset to players ($92),381# +2 palette ($0C), +4 offset to name strings (players end + 2, preceded by word $0002), +6 lines ($52),382# +8/+10 small fields. Lines: 8 x 8 bytes {G, LD, RD, LW, C, RW, extra, 0}, 1-based player indices;383# order = ?, Sc1, Sc2, Chk, PP1, PP2, PK1, PK2 (verified in the in-game Line Editor).384# Player: word len (2 + name, padded even), name, BCD jersey, 7 bytes = 14 rating nibbles.385# Players are goalies, forwards, defense; keep the team's G/F/D counts (bitmask at +$4C not decoded).386# The rebuilt record goes to free ROM and the team pointer is moved there.387import json388ROSTER_BASE=0x2F0000389if 'noroster' not in opts:390    rdir=os.path.join(os.path.dirname(os.path.abspath(__file__)),'rosters')391    ORIGP=[struct.unpack('>I',d[0x30E+4*t:0x312+4*t])[0] for t in range(32)]392    tptr=lambda t: ORIGP[t]393    def team_players(t):394        a=tptr(t); h=struct.unpack('>6H',d[a:a+12]); i=a+h[0]; out=[]395        while i<a+h[2]-2:396            n=struct.unpack('>H',d[i:i+2])[0]; out.append((d[i+2:i+n].rstrip(b'\x00').decode('latin1'),bytes(d[i+n:i+n+8]))); i+=n+8397        return out398    allp={}399    for t in range(32):400        for nm,b in team_players(t): allp.setdefault(nm,b)401    pos=ROSTER_BASE402    for fn in sorted(os.listdir(rdir)) if os.path.isdir(rdir) else []:403        R=json.load(open(os.path.join(rdir,fn)))404        if 'team' not in R: continue          # e.g. overrides.json (read by gen.py)405        t=R['team']; a=tptr(t)406        h=list(struct.unpack('>6H',d[a:a+12])); old=team_players(t)407        cnt=lambda pl,g: sum(1 for _,b in pl if b[4:6]==b'\x00\x00' and (b[3]&0x0f)==0) # unused, informational408        plist=R['goalies']+R['forwards']+R['defense']409        assert len(plist)==len(old),(fn,len(plist),len(old))410        body=b''; idx={}411        for k,(nm,jer,src) in enumerate(plist):412            idx[nm]=k+1413            rat=allp[nm][1:8] if src=='rom' else bytes.fromhex(src)414            assert len(rat)==7,(nm,src)415            nb=nm.encode('latin1');416            if len(nb)%2: nb+=b'\x00'417            body+=W(2+len(nb))+nb+bytes([int(jer,16)])+rat418        lines=b''.join(bytes([idx[n] for n in ln]+[0]) for ln in R['lines'])419        assert len(lines)==64420        names_old=a+h[2]421        nxt=min([ORIGP[x] for x in range(32) if ORIGP[x]>a]+[names_old+0x100])422        tail=bytes(d[names_old-2:nxt])            # $0002 + names + rest of record423        head=bytearray(d[a:a+h[0]]); head[h[3]:h[3]+64]=lines424        rec=bytes(head)+body+tail425        h2=h[0]+len(body)+2; rec=rec[:4]+W(h2)+rec[6:]426        assert all(b==0xff for b in d[pos:pos+len(rec)+2]),'roster space not free'427        d[pos:pos+len(rec)]=rec428        d[0x30E+4*t:0x312+4*t]=L(pos)429        print('roster',fn,'team',t,'->',hex(pos),len(rec),'bytes')430        pos=(pos+len(rec)+0xF)&~0xF431# ---- fix header checksum ----432s=sum(struct.unpack('>%dH'%((len(d)-0x200)//2),d[0x200:]))&0xffff433d[0x18E:0x190]=struct.pack('>H',s)434open(dst,'wb').write(d); print('ok checksum',hex(s))435