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