#!/usr/bin/env python3
"""Optional human B pickup-delay: native cooldown, collision, ABI and actual input.

Explicit reset fixtures provide geometry/timers/input, never pickup or scoring
outcomes. Production uses native skater5E/ABAA/14E54, no collision or frame hook.
"""
from pathlib import Path
from menu_protocol import row_word,down_count,render_probe,requested_menu_expectations
from concurrent.futures import ThreadPoolExecutor
import argparse,hashlib,json,os,struct,subprocess,math
ap=argparse.ArgumentParser(description=__doc__);ap.add_argument('rom',type=Path);ap.add_argument('output',type=Path);ap.add_argument('--baseline',type=Path,required=True);ap.add_argument('--seed',type=Path,required=True);ap.add_argument('--section',choices=['all','release','collision','storage','menu','input','passes'],default='all');a=ap.parse_args();h=Path(__file__).resolve().parent;o=a.output.resolve();o.mkdir(exist_ok=True,parents=True);env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')};sha=lambda p:hashlib.sha256(p.read_bytes()).hexdigest();assert sha(a.baseline)=='5e3d4aedcf006f2c92dacdae965f79b15dfd078fae6a0862f77b6520a60d1eee'
current_bytes=bytearray(a.rom.read_bytes());parent_bytes=a.baseline.read_bytes()
# Alt Goalies adds one separately qualified accepted-possession lifecycle hook.
# Every other collision byte remains protected by this earlier feature's gate.
if current_bytes[0x15288:0x15290]==bytes.fromhex('4eb9002210244e71'):
 assert parent_bytes[0x15288:0x15290]==bytes.fromhex('302a005231c0b7aa')
 current_bytes[0x15288:0x15290]=parent_bytes[0x15288:0x15290]
for lo,hi in [(0xbc64,0xbdd8),(0xbe26,0xbfb6),(0x14e10,0x15466),(0xabaa,0xabb6),(0x2ff500,0x2ff800)]:assert current_bytes[lo:hi]==parent_bytes[lo:hi],('native CPU pass/physics/collision/countdown/render code changed outside separately declared accepted-possession hook',hex(lo))
def asm(p,body,origin=0x2d5000):
 (p/'driver.s').write_text(('' if '.globl _start' in body else ' .text\n .globl _start\n_start:\n')+body+'\n');subprocess.run([str(h.parent/'m68kasm.sh'),str(p/'driver.s'),f'{origin:X}',str(p/'code.bin')],stdout=subprocess.DEVNULL,check=True);return(p/'code.bin').read_bytes()

def run(p,body,size,patches=(),profile='current'):
 p.mkdir(exist_ok=True,parents=True);b=bytearray((a.baseline if profile=='parent' else a.rom).read_bytes());code=asm(p,' move.w #0x2700,%sr\n movea.l #0xfffff900,%sp\n'+body+'\n move.l #0x50444c59,0xffff3ff0\nend: bra.s end');assert len(code)<0x4000 and b[0x2d5000:0x2d5000+len(code)]==b'\xff'*len(code);b[0x2d5000:0x2d5000+len(code)]=code;struct.pack_into('>I',b,4,0x2d5000)
 for at,v in patches:b[at:at+len(v)]=v
 (p/'rom.bin').write_bytes(b);(p/'commands.txt').write_text(f'1500 dump ff3ff0 4 done\n1500 dump ff4000 {size} result\n1501 exit\n')
 with(p/'log').open('w') as f:subprocess.run(['python3',str(h/'test_core.py'),str(p/'rom.bin'),str(p/'commands.txt'),str(p)],env=env|({'GPGX_SRM':str(p/'battery.srm')} if (p/'battery.srm').exists() else {}),stdout=f,stderr=f,check=True,timeout=90)
 rows={t[1]:bytes.fromhex(' '.join(t[2:])) for l in(p/'commands.out').read_text().splitlines() if(t:=l.split())};assert rows['done']==b'PDLY',('native return',p);return rows['result']

def pack(b):
 for off in range(0,len(b),8192):b[off+0x1fff]=sum(b[off:off+0x1ffe])&255;b[off+0x1ffe]=b[off+0x1fff]^255
 raw=bytearray(2*len(b));raw[1::2]=b;return raw

def encode(height,speed,mode,cone):
 value=(height+2*speed+4*mode+8*cone)|(0x80 if mode or cone else 0x50);return bytes([value,value^255])

def battery(gp=0,choices=((1,0,1,1),(0,1,0,2),(1,1,1,3)),native=(0,1,1,2),bad=None):
 base=bytearray(a.seed.read_bytes()[1:16384:2]);base[0xe20:0x1ffe]=bytes(0x11de);banks=[]
 for slot,length in [(1,84),(2,31),(3,7)]:
  b=bytearray(base);struct.pack_into('>4s6H',b,0x1000,b'SESN',gp,11 if gp else 0,gp,length,0 if gp else slot,3);struct.pack_into('>4s4H',b,0x1ea0,b'S3SV',1,slot,7,1);b[0x1ef0:0x1ef6]=b'PC\x01\xfe\x01\xfe';b[0x1ff6:0x1ffe]=bytes([0x55,0,0,2,2,0,0x5a,0]);b[0x1eee:0x1ef0]=encode(*choices[slot-1]);banks.append(b)
 b=bytearray(banks[0])+b''.join(banks);b[0x2e18:0x2e20]=b'RP\x01\xfeCT\x01\xfe';b[0x4d20:0x4d22]=encode(*native)
 if bad is not None:
  for bank in [0,0x2000]:b[bank+0x1eee:bank+0x1ef0]=bytes.fromhex(bad)
 return pack(b)

def game_core(p,rom,commands,settings):
 (p/'commands.txt').write_text('\n'.join(commands)+'\n')
 with(p/'log').open('w') as log:subprocess.run(['nice','-n','19','python3',str(h/'test_core.py'),str(rom.resolve()),str(p/'commands.txt'),str(p)],env=env|settings,stdout=log,stderr=log,check=True,timeout=100)

def branch(b,at):
 assert b[at:at+2]==bytes.fromhex('6000');return at+2+int.from_bytes(b[at+2:at+4],'big',signed=True)

def release_suite():
 kinds=['On','Off','invalid','CPU','wrong-owner','inactive','shootout','penalty-shot','whistle','forced','pause','replay','fight','menu']
 specs=[dict(team=team,keeper=k,kind=kind,shift=shift,ccr=ccr) for team in [0,1] for k in [False,True] for kind in kinds for shift in [0,3] for ccr in [0,31]]
 timers=[0,1,5,6,16,127,128,255];roles=[5,3,4,2,1,0]*2
 def trial(batch):
  ss=specs[batch*12:batch*12+12];body=[]
  for j,z in enumerate(ss):
   dest=0xffff4000+80*j;idx=z['team']*6+(5 if z['keeper'] else 4);ob=0xffffb04a+128*idx
   for i in range(12):body += [f' move.w #{roles[i]},0x{0xffffb07e+128*i:x}',f' move.w #{i},0x{0xffffb09c+128*i:x}',f' move.b #{timers[(i+z["shift"])%8]},0x{0xffffb0a8+128*i:x}']
   body += [f' lea 0x{ob:x},%a3',f' move.b #{8+64*z["team"]},0x62(%a3)',' clr.b 0x63(%a3)',f' move.w #{idx},0xffffb7aa',' clr.w 0xffffd412',' clr.w 0xffffc2ea',' clr.w 0xffffc2ec',' clr.w 0xffffc2f2',' clr.w 0xffffc2f6',' clr.w 0xffffc2fa',f' move.w #{0 if z["kind"]=="Off" else 99 if z["kind"]=="invalid" else 1},0xfffff010']
   body += {'CPU':[' bclr #3,0x62(%a3)'],'wrong-owner':[' move.w #-1,0xffffb7aa'],'inactive':[' move.w #-1,0x34(%a3)'],'shootout':[' bset #0,0xffffc2fa'],'penalty-shot':[' move.w #1,0xffffd412'],'whistle':[' bset #2,0xffffc2f2'],'forced':[' bset #3,0xffffc2f6'],'pause':[' bset #0,0xffffc2ec'],'replay':[' bset #4,0xffffc2ea'],'fight':[' bset #0,0x63(%a3)'],'menu':[' bset #7,0xffffc2ec']}.get(z['kind'],[])
   for i in range(8):body += [f' move.l #0x{0x11223300+i:x},%d{i}']
   for i in range(7):
    if i!=3:body += [f' movea.l #0x{0xffff1800+256*i:x},%a{i}']
   body += [f' move.w #{0x2700|z["ccr"]},%sr',' jsr 0x220000',f' move.w %sr,0x{dest:x}',f' movem.l %d0-%d7/%a0-%a6,0x{dest+2:x}',f' move.l %sp,0x{dest+62:x}']
   for i in range(12):body += [f' move.b 0x{0xffffb0a8+128*i:x},0x{dest+66+i:x}']
  raw=run(o/f'release-{batch}','\n'.join(body),80*len(ss));out=[]
  for j,z in enumerate(ss):
   r=raw[80*j:80*j+80];idx=z['team']*6+(5 if z['keeper'] else 4);expectedregs=[0x11223300+i for i in range(8)]+[0xffffb04a+128*idx if i==3 else 0xffff1800+256*i for i in range(7)]
   assert int.from_bytes(r[:2],'big')==0x2700|z['ccr'] and list(struct.unpack('>15I',r[2:62]))==expectedregs and int.from_bytes(r[62:66],'big')==0xfffff900,('release complete SR/register/stack contract',z,r.hex())
   before=[timers[(i+z['shift'])%8] for i in range(12)];after=[6 if z['kind']=='On' and roles[i]>0 and 0<=v<6 else v for i,v in enumerate(before)];assert list(r[66:78])==after,('native cooldown/keeper/guard/long-lock contract',z,list(r[66:78]),after);out.append(z|dict(before=before,after=after,complete_ABI=True))
  return out
 with ThreadPoolExecutor(max_workers=3) as pool:rows=sum(pool.map(trial,range((len(specs)+11)//12)),[])
 print('PASS',len(rows),'human-release cooldown/keeper/guard/complete ABI cases',flush=True);return dict(cases=rows)

def collision_suite():
 specs=[dict(team=team,keeper=keeper,on=on,ticks=ticks,body=body) for team in [0,1] for keeper in [False,True] for on in [0,1] for ticks in [0,1,5,6,7] for body in [False,True]]
 def trial(batch):
  ss=specs[batch*4:batch*4+4];body=[]
  for j,z in enumerate(ss):
   base=z['team']*6;actor=base+(5 if z['keeper'] else 2);obj=0xffffb04a+128*actor;passer=base+4;po=0xffffb04a+128*passer;dest=0xffff4000+20*j
   body += [' lea 0xffffb04a,%a0',' move.w #383,%d0',f'clear{j}: clr.l (%a0)+',f' dbra %d0,clear{j}']
   for i in range(12):body += [f' move.w #{0 if i%6==5 else 4},0x{0xffffb07e+128*i:x}',f' move.w #{i},0x{0xffffb09c+128*i:x}']
   body += [f' lea 0x{po:x},%a3',f' move.b #{8+64*z["team"]},0x62(%a3)',f' move.w #{passer},0xffffb7aa',f' move.w #{z["on"]},0xfffff010',' clr.w 0xffffd412',' clr.w 0xffffc2ea',' clr.w 0xffffc2ec',' clr.w 0xffffc2f2',' clr.w 0xffffc2f6',' clr.w 0xffffc2fa',' jsr 0x220000',f' lea 0x{obj:x},%a3',' moveq #1,%d7']
   for _ in range(z['ticks']):body += [' jsr 0xabaa']
   body += [f' lea 0x{obj:x},%a2',' move.w #11,6(%a2)',f' move.b #{8+64*z["team"]},0x62(%a2)',' move.b #30,0x71(%a2)',f' move.w #{actor},0xffffc320',' move.w #-1,0xffffc322',' clr.l 0xffffc328',' clr.l 0xffffc32c',' move.w #-1,0xffffb7aa',f' move.w #{actor},0xffffbee0',' move.w #1,0xffffbeda',' clr.l 0xfffff060',' clr.w 0xfffff736',' clr.w 0xffffb762',' clr.w 0xffffb776',' clr.w 0xffffb774',f' move.w #{4000 if z["body"] else 0},0xffffb772']
   if z['body']:body += [' clr.w 0xffffb74a',' clr.w 0xffffb75e']
   else:body += [' move.l %a2,-(%sp)',' jsr 0x106e0',' move.w %d0,0xffffb74a',' move.w %d1,0xffffb75e']
   body += [' lea 0xffffb74a,%a3',f' move.w #{actor*2},%d4',' jsr 0x14e10',f' move.w 0xffffb7aa,0x{dest:x}',f' move.w 0xffffb772,0x{dest+2:x}',f' move.w 0xffffb774,0x{dest+4:x}',f' move.l %sp,0x{dest+6:x}',f' move.b 0x{obj+0x5e:x},0x{dest+10:x}']
  raw=run(o/f'collision-{batch}','\n'.join(body),20*len(ss),[(0xabb6,bytes.fromhex('4e75'))]);rows=[]
  for j,z in enumerate(ss):
   r=raw[20*j:20*j+20];owner,vx,vy,sp=struct.unpack_from('>hhhI',r);actor=z['team']*6+(5 if z['keeper'] else 2);active=z['on']==1 and not z['keeper'] and z['ticks']<6
   assert sp==0xfffff900,('native collision/countdown stack',z,sp)
   if not z['body']:assert owner==(-1 if active else actor),('native pickup admission after exact cooldown',z,owner,r.hex())
   elif active:assert owner==-1 and (vx,vy)==(4000,0),('cooldown must preserve documented native initial body-contact suppression',z,owner,vx,vy)
   rows.append(z|dict(owner=owner,velocity=[vx,vy],remaining=r[10]))
  return rows
 with ThreadPoolExecutor(max_workers=3) as pool:rows=sum(pool.map(trial,range((len(specs)+3)//4)),[])
 print('PASS',len(rows),'actual native countdown/stick/body/keeper collision cases',flush=True);return dict(cases=rows)


def storage_suite():
 specs=[dict(slot=slot,tag=t,inv=t^255) for slot in range(4) for t in range(256)]+[dict(slot=slot,tag=t,inv=t) for slot in range(4) for t in [0,0x70,0x71,255]]
 def trial(batch):
  ss=specs[batch*16:batch*16+16];body=[' move.b #1,0xa130f1']
  for j,z in enumerate(ss):
   rec=0x209a55+4*z['slot'];dest=0xffff4000+j*8
   body += [f' move.w #{int(z["slot"]>0)},0xfffff06c',f' move.w #{z["slot"]},0xfffff06a',f' move.b #{z["tag"]},0x{rec:x}',f' move.b #{z["inv"]},0x{rec+2:x}',' move.w #99,0xfffff010',' move.w #0x271f,%sr',' jsr 0x220004',f' move.w %sr,0x{dest:x}',f' move.w 0xfffff010,0x{dest+2:x}',f' move.l %sp,0x{dest+4:x}']
  raw=run(o/f'storage-{batch}','\n'.join(body),8*len(ss));out=[]
  for j,z in enumerate(ss):
   sr,value,sp=struct.unpack_from('>HHI',raw,8*j);want=z['tag']&1 if z['tag'] in [0x70,0x71] and z['tag']^z['inv']==255 else 0;assert value==want and sr==0x271f and sp==0xfffff900,('load/invalid fallback/ABI',z,value,sr,sp);out.append(z|dict(value=value))
  return out
 with ThreadPoolExecutor(max_workers=3) as pool:rows=sum(pool.map(trial,range((len(specs)+15)//16)),[])
 # Full byte-level storage separation: distinct sentinels across all four banks;
 # invoke actual put for each context, require only its own record/checksum move.
 checks=[]
 for slot in range(4):
  q=o/f'storage-put-{slot}';q.mkdir(exist_ok=True);raw=battery(1);b=bytearray(raw[1::2]);b[0x4d2a:0x4d32]=bytes.fromhex('708f708f708f708f');b=bytearray(pack(b)[1::2]);q.joinpath('before.srm').write_bytes(pack(b));physical=pack(b)
  body=[' move.b #1,0xa130f1',f' move.w #{int(slot>0)},0xfffff06c',f' move.w #{slot},0xfffff06a',' move.w #1,0xfffff010',' jsr 0x220008',' move.b #0,0xa130f1',' move.w 0xfffff010,0xffff4000']
  # run() writes its explicit battery fixture and reads back all64KB SRAM.
  q.joinpath('battery.srm').write_bytes(physical);run(q,'\n'.join(body),2);after=q.joinpath('battery.srm').read_bytes()[1::2];allowed={0x4d2a+2*slot,0x4d2b+2*slot,0x5ffe,0x5fff};diff=[i for i,(x,y) in enumerate(zip(b,after)) if x!=y];assert set(diff)<=allowed and after[0x4d2a+2*slot:0x4d2c+2*slot]==bytes.fromhex('718e'),('put overwrites another record/goalie/profile',slot,diff);assert after[0x5fff]==sum(after[0x4000:0x5ffe])%256 and after[0x5ffe]==after[0x5fff]^255,('Light checksum',slot);checks.append(dict(slot=slot,changed=diff))
 print('PASS',len(rows),'record validation/ABI contexts and four complete64KB separated writes',flush=True);return dict(loads=rows,writes=checks)

def menu_suite():
 p=o/'renderer';p.mkdir(exist_ok=True);c=asm(p,render_probe(""" cmpi.w #38,%d7
 bne.s original
 move.w %sr,-(%sp)
 addq.w #1,0xffffef90
 move.w 0xffffb028,0xffffef92
 move.l %a1,0xffffef94
 move.w (%sp)+,%sr
original: jmp 0x11ba4
""",a.rom,[38]),0x2fea00);b=bytearray(a.rom.read_bytes());assert set(b[0x2fea00:0x2fea00+len(c)])=={255} and b[0xf7bf4:0xf7bfa]==bytes.fromhex('4ef900011ba4');b[0x2fea00:0x2fea00+len(c)]=c;b[0xf7bf4:0xf7bfa]=bytes.fromhex('4ef9002fea00');observed=p/'rom.bin';observed.write_bytes(b);rows=[]
 def initial(gp=0,pickups=(0,1,0,1),bad=None):
  b=bytearray(battery(gp)[1::2]);b[0x4d22:0x4d2a]=bytes.fromhex('619e619e619e619e');b[0xe20:0xe25]=b[0x2e20:0x2e25]=bytes.fromhex('123456789a')
  for slot,value in enumerate(pickups):b[0x4d2a+slot*2:0x4d2c+slot*2]=bytes([0x70+value,0x8f-value])
  if bad:b[0x4d2c:0x4d2e]=bytes.fromhex(bad)
  return pack(b)
 def menu(name,raw,commands,checks,dev=513,render=False,finish=False):
  for frame,expected in checks.items():
   if name.startswith('fresh-'):expected['charge']=0
   elif name.startswith('restart-'):expected['charge']=0 if int(frame) in [3900,4600] else 1
   else:expected['charge']=1
  requested_menu_expectations(a.rom,name,checks)
  p=o/name;p.mkdir(exist_ok=True);(p/'battery.srm').write_bytes(raw);(p/'spec.json').write_text(json.dumps(dict(name=name,checks=checks,render=render,finish=finish)));game_core(p,observed,commands,dict(GPGX_DEV=str(dev),GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(h/'testcases/pickup_delay_menu_hook.py')));rows.append(dict(name=name,observations=json.loads((p/'results.json').read_text())));return(p/'battery.srm').read_bytes()
 highest=int.from_bytes(a.rom.read_bytes()[0xf777a:0xf777c],'big')-2
 down=[f'{900+45*i} press 1 Down 5' for i in range(highest//2)]
 down += [f'{2000+35*i} press 1 Up 5' for i in range((int.from_bytes(a.rom.read_bytes()[0xf777a:0xf777c],'big')-2-row_word(a.rom,38))//2)]
 for dev in [513,1281]:
  menu('fresh-'+str(dev),bytes(65536),down+['2250 snap','2401 exit'],{2200:dict(pickup=0)},dev,True)
  raw=menu('choices-'+str(dev),initial(0,(0,0,0,0)),down+['2300 press 1 Right 5','2450 snap','2500 press 1 Left 5','2700 press 1 Right 5','2901 exit'],{2200:dict(pickup=0,full_goalie='123456789a'),2400:dict(pickup=1,pickup_records='708f718e708f708f',record='8d72'),2600:dict(pickup=0,pickup_records='708f708f708f708f'),2800:dict(pickup=1,record='8d72',full_goalie='123456789a')},dev,True)
  menu('reboot-'+str(dev),raw,['1301 exit'],{1200:dict(pickup=1,pickup_records='708f718e708f708f')},dev)
  nav=['1100 press 1 Right 5','1300 press 1 Right 5','1500 press 1 Right 5','1700 press 1 Left 5','1900 press 1 Left 5','2100 press 1 Left 5','2301 exit']
  raw=menu('slots-'+str(dev),initial(),nav,{1000:dict(pickup=1,slot=1),1200:dict(pickup=0,slot=2),1400:dict(pickup=1,slot=3),1600:dict(pickup=0,view=0),1800:dict(pickup=1,slot=3),2000:dict(pickup=0,slot=2),2200:dict(pickup=1,slot=1)},dev)
  menu('slots-reboot-'+str(dev),raw,['1301 exit'],{1200:dict(pickup=1,slot=1)},dev)
  menu('locked-'+str(dev),initial(1),down+['2300 press 1 Left 5','2500 press 1 Right 5','2701 exit'],{2200:dict(pickup=1,gp=1),2400:dict(pickup=1,gp=1),2600:dict(pickup=1,gp=1)},dev,True)
  for bad in ['0000','718f','00ff','ffff']:
   menu('corrupt-'+bad+'-'+str(dev),initial(1,bad=bad),['1100 press 1 Right 5','1300 press 1 Left 5','1501 exit'],{1000:dict(pickup=0,gp=1),1400:dict(pickup=0,gp=1)},dev)
  native=[f'{1000+200*i} press 1 Right 5' for i in range(3)]+[f'{1800+35*i} press 1 Down 5' for i in range(highest//2-1)]
  native += [f'{2650+40*i} press 1 Up 5' for i in range((int.from_bytes(a.rom.read_bytes()[0xf777a:0xf777c],'big')-2-row_word(a.rom,38))//2)]
  raw=menu('native-'+str(dev),initial(1),native+['2900 press 1 Right 5','3101 exit'],{2850:dict(pickup=0,view=0),3000:dict(pickup=1,view=0,pickup_records='718e718e708f718e',full_goalie='123456789a')},dev,True)
  menu('native-reboot-'+str(dev),raw,[f'{1000+200*i} press 1 Right 5' for i in range(3)]+['1701 exit'],{1600:dict(pickup=1,view=0,full_goalie='123456789a')},dev)
  lines=['1100 press 1 Right 5','1300 press 1 Start 5']+[f'{1700+i*100} press 1 Down 5' for i in range(8)]+['2600 press 1 C 5','2900 press 1 Start 5','3200 press 1 C 5','3500 press 1 A 5','3500 press 1 C 5','4100 press 1 Left 5','4500 press 1 Right 5','4700 press 1 Right 5','5101 exit']
  menu('restart-'+str(dev),initial(1,(0,1,1,1)),lines,{3100:dict(pickup=1,slot=2,gp=1),3900:dict(pickup=0,slot=2,gp=0),4300:dict(pickup=1,slot=1),4600:dict(pickup=0,slot=2),4900:dict(pickup=1,slot=3)},dev)
  raw=menu('first-game-'+str(dev),initial(),['1500 press 1 Start 5','1900 press 1 Start 5','16001 exit'],{1200:dict(pickup=1),3300:dict(pickup=1)},dev,False,dict(pickup=1,gp=1))
  menu('completed-lock-'+str(dev),raw,down+['2300 press 1 Left 5','2601 exit'],{2200:dict(pickup=1,gp=1),2500:dict(pickup=1,gp=1)},dev,True)
 print('PASS',len(rows),'native menu/render/boot/slot/lock/restart/first-match cases',flush=True);return dict(cases=rows)


def input_suite():
 p=o/'input-probes';p.mkdir(exist_ok=True);b=bytearray(a.rom.read_bytes());custom=branch(b,0x2e6808);assisted=branch(b,0x2e8218);clone=b.find(bytes.fromhex('48e7fcc008b80002c2ec'),assisted,0x2e8800);assert clone>=0
 c=asm(p,f' .equ CUSTOM_CONT,0x{custom+10:x}\n .equ CHARGE_CONT,0x{clone+10:x}\n'+(h/'testcases/pickup_delay_input_probe.s').read_text(),0x2d9000);q=p/'banks';q.mkdir(exist_ok=True);bank=asm(q,(h/'testcases/manual_keeper_banks.s').read_text(),0x2ef400)
 for at,code in [(0x2d9000,c),(0x2ef400,bank)]:assert set(b[at:at+len(code)])=={255};b[at:at+len(code)]=code
 for at,target in [(0xbc64,0x2d9300),(custom,0x2d9350),(clone,0x2d9380)]:
  assert b[at:at+10]==bytes.fromhex('48e7fcc008b80002c2ec');b[at:at+10]=bytes.fromhex('4ef9')+struct.pack('>I',target)+bytes.fromhex('4e714e71')
 assert b[0xbdd8:0xbde0]==bytes.fromhex('3038b7723238b774');b[0xbdd8:0xbde0]=bytes.fromhex('4ef9002d94004e71')
 for at,target,expected in [(0xad66,0x2ef400,'3f38c32831f8c32cc328'),(0xadce,0x2ef440,'31f8c320c32431dfc320'),(0xae00,0x2ef480,'3f38c32a31f8c32ec32a'),(0xae68,0x2ef4c0,'31f8c322c32631dfc322')]:
  assert b[at:at+10]==bytes.fromhex(expected);b[at:at+10]=bytes.fromhex('4ef9')+struct.pack('>I',target)+bytes.fromhex('4e714e71')
 q=p/'release';q.mkdir(exist_ok=True);release=asm(q,(h/'testcases/pickup_delay_release_probe.s').read_text(),0x2d5000);assert b[0x2d5000:0x2d5000+len(release)]==b'\xff'*len(release);b[0x2d5000:0x2d5000+len(release)]=release;at=b.find(bytes.fromhex('4eb900220000'),0x2e6800,0x2e7000);assert at>=0 and b.find(bytes.fromhex('4eb900220000'),at+6,0x2e7000)<0;b[at:at+6]=bytes.fromhex('4eb9002d5000')
 observed=p/'rom.bin';observed.write_bytes(b);cases=[dict(count=1,adapter=False,mode=0,cone=0,height=0,speed=0,end=25000),dict(count=1,adapter=False,mode=1,cone=0,height=0,speed=0,end=25000),dict(count=2,adapter=False,mode=1,cone=0,height=1,speed=1,end=25000),dict(count=3,adapter=True,mode=1,cone=0,height=1,speed=0,end=25000),dict(count=4,adapter=True,mode=1,cone=0,height=0,speed=1,end=25000),dict(count=4,adapter=True,mode=0,cone=0,height=1,speed=1,end=25000)]
 for i,z in enumerate(cases):z['charge']=i%2
 def trial(item):
  i,z=item;p=o/f'input-{i}';p.mkdir(exist_ok=True);raw=battery(0,((z['height'],z['speed'],z['mode'],z['cone']),)*3);b=bytearray(raw[1::2])
  for off in [0,0x2000,0x4000,0x6000]:b[off+0x1ef2:off+0x1ef4]=bytes([z['count'],z['count']^255])
  for off in [0x4d22,0x4d24,0x4d26,0x4d28]:b[off:off+2]=bytes([0x60+z['charge'],0x9f-z['charge']])
  for off in [0x4d2a,0x4d2c,0x4d2e,0x4d30]:b[off:off+2]=bytes.fromhex('718e')
  (p/'battery.srm').write_bytes(pack(b));(p/'spec.json').write_text(json.dumps(z));game_core(p,observed,['1500 press 1 Start 5','1900 press 1 Start 5','9000 snap',f'{z["end"]+1} exit'],dict(GPGX_DEV='1281' if z['adapter'] else '513',GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(h/'testcases/pickup_delay_input_hook.py')));return json.loads((p/'results.json').read_text())
 with ThreadPoolExecutor(max_workers=3) as pool:results=list(pool.map(trial,enumerate(cases)))
 for d in results:
  assert set(d['delay_pads'])==set(range(d['spec']['count'])),('configured pads not all actually delayed',d['spec'],d['delay_pads'])
  if d['spec']['charge']:
   assert set(d['charged_pads'])==set(range(d['spec']['count'])),('configured pads not all charged',d['spec'],d['charged_pads'])
   assert 30 in d['charge_ticks'] and any(0<=n<10 for n in d['charge_ticks']),('both tap and fully held passes required',d['spec'],d['charge_ticks'])
  else:assert d['charged_passes']==0,('Pickup Delay must not turn ChargeOn',d['spec'])
  assert d['keeper_requests']>0,('native immediate requested keeper outlet coverage',d['spec'])
 events=[e for d in results for e in d['events'] if e.get('pickup_delay_eligible') and e['mode']==1]
 assert {e['aim'] for e in events}==set(range(8)),('delayed actual input must cover all eight directional aims',{e['aim'] for e in events})
 print('PASS',len(results),'cold-boot actual input games, independent charge and delayed P1-P4 releases',flush=True);return dict(cases=results)

def passes_suite():
 specs=[dict(team=t,aim=aim,mode=m,height=h,speed=v,human=u,keeper=k,on=on,charge=c,pad=aim//2) for t in [0,1] for aim in [0,2,4,6] for m in [0,1] for h,v in [(0,0),(1,1)] for u,k in [(True,False),(True,True),(False,False),(False,True)] for on in [0,1] for c in [0,1]]
 def trial(item):
  profile,batch=item;ss=specs[batch*8:batch*8+8];body=[]
  for j,z in enumerate(ss):
   idx=z['team']*6+(5 if z['keeper'] else 4);obj=0xffffb04a+128*idx;receiver=z['team']*6;enemy=(1-z['team'])*6;ux,uy=[(0,1),(1,0),(0,-1),(-1,0)][z['aim']//2];dest=0xffff4000+112*j
   body += [' lea 0xffffb04a,%a0',' move.w #383,%d0',f'clear{j}: clr.l (%a0)+',f' dbra %d0,clear{j}']
   for i in range(12):body += [f' move.w #-1,0x{0xffffb07e+128*i:x}',f' move.w #{i},0x{0xffffb09c+128*i:x}',f' move.b #{64 if i>=6 else 0},0x{0xffffb0ac+128*i:x}']
   body += [f' lea 0x{obj:x},%a3',f' move.w #{0 if z["keeper"] else 4},0x34(%a3)',f' move.b #{64*z["team"]+(8 if z["human"] else 0)},0x62(%a3)',' move.b #20,0x6e(%a3)',' move.w #10,(%a3)',' move.w #-100,0x14(%a3)',' move.w #400,0x28(%a3)',' move.w #-300,0x2a(%a3)',' move.w #10,0xffffb74a',' move.w #-100,0xffffb75e',f' move.w #4,0x{0xffffb07e+128*receiver:x}',f' move.w #{10+200*ux},0x{0xffffb04a+128*receiver:x}',f' move.w #{-100+200*uy},0x{0xffffb05e+128*receiver:x}',f' move.w #4,0x{0xffffb07e+128*enemy:x}',f' move.w #-150,0x{0xffffb04a+128*enemy:x}',f' move.w #150,0x{0xffffb05e+128*enemy:x}',f' move.b #20,0x{0xffffb0a8+128*enemy:x}',f' move.w #{idx},0xffffb7aa',f' move.w #{z["aim"]},0xffffbedc',' move.w #-1,0xffffbee0',' clr.l 0xfffff058',' clr.l 0xfffff05c',' clr.l 0xfffff064',' clr.w 0xfffff736',' clr.w 0xffffd046',' clr.w 0xffffd412',' clr.w 0xffffc2ea',' clr.w 0xffffc2ec',' clr.w 0xffffc2f2',' clr.w 0xffffc2f6',' clr.w 0xffffc2fa',' clr.l 0xffffc320',' clr.l 0xffffc324',' clr.l 0xffffc328',' clr.l 0xffffc32c',f' move.w #{idx},0x{0xffffc320+2*z["pad"]:x}',f' move.w #{z["team"]+1 if z["human"] else 0},0x{0xffffc328+2*z["pad"]:x}',f' move.w #{0 if z["pad"]>=2 else -1},0xffffc318',f' move.w #{z["mode"]},0xfffff0fc',' clr.w 0xfffff0fe',f' move.w #{z["height"]},0xfffff0dc',f' move.w #{z["speed"]},0xfffff0e8',f' move.w #{z["on"]},0xfffff010',f' move.w #{z["charge"]},0xfffff008',f' move.l %a3,0x{0xfffff110+8*z["pad"]:x}',f' move.w #30,0x{0xfffff114+8*z["pad"]:x}',f' move.w #1,0x{0xfffff116+8*z["pad"]:x}',' move.l #0x12345678,0xffffd066',' moveq #8,%d0',' moveq #0,%d1',' moveq #16,%d2',' moveq #0,%d3',f' moveq #{2*(z["pad"]%2)},%d4',' moveq #0,%d5',f' jsr 0x{0xbc40 if z["human"] else 0xbc64:x}']
   for k,at in enumerate([0xb772,0xb774,0xb776,0xbee0,0xbeda,0xb7aa,0xc320,0xc322,0xc324,0xc326,0xbede,0xb7a8]):body += [f' move.w 0x{0xffff0000+at:x},0x{dest+2*k:x}']
   body += [f' move.l 0xffffd066,0x{dest+24:x}',f' move.w %sr,0x{dest+28:x}',f' movem.l %d0-%d7/%a0-%a6,0x{dest+30:x}',f' move.l %sp,0x{dest+90:x}']
   for i in range(12):body += [f' move.b 0x{0xffffb0a8+128*i:x},0x{dest+94+i:x}']
  raw=run(o/f'passes-{profile}-{batch}','\n'.join(body),112*len(ss),[(0xbdd8,bytes.fromhex('4ef90000be20'))],profile);return profile,batch,[raw[112*j:112*j+106] for j in range(len(ss))]
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,[(p,i) for p in ['current','parent'] for i in range((len(specs)+7)//8)]))
 data={(p,i):r for p,i,r in rows};out=[]
 for i,z in enumerate(specs):
  batch,j=divmod(i,8);new,old=data['current',batch][j],data['parent',batch][j];assert new[:94]==old[:94],('actual full native pass physics/receiver/control/RNG/SR/regs/stack changed',z,new.hex(),old.hex())
  expected=list(old[94:106]);idx=z['team']*6+(5 if z['keeper'] else 4);receiver=z['team']*6
  if z['human'] and z['on']:expected[receiver]=6
  assert list(new[94:106])==expected and new[94+idx]==16,('full native pass cooldown scope/passer/native long lock',z,list(new[94:106]),expected)
  out.append(z|dict(complete_native_pass_ABI=True,velocity=list(struct.unpack_from('>3h',new)),target=int.from_bytes(new[6:8],'big',signed=True),cooldowns=expected))
 print('PASS',len(out),'paired complete native human/CPU passes, independent mode/charge/lift/speed/physical-pad physics and control',flush=True);return dict(cases=out)

sections={}
if a.section in ['all','release']:sections['release']=release_suite()
if a.section in ['all','collision']:sections['collision']=collision_suite()
if a.section in ['all','storage']:sections['storage']=storage_suite()
if a.section in ['all','menu']:sections['menu']=menu_suite()
if a.section in ['all','input']:sections['input']=input_suite()
if a.section in ['all','passes']:sections['passes']=passes_suite()
(o/'results.json').write_text(json.dumps(dict(rom_sha256=sha(a.rom),baseline_sha256=sha(a.baseline),sections=sections),indent=2)+'\n')
