#!/usr/bin/env python3
"""Optional keeper model: native entry/ABI, reach, actual saved shots and persistence.
Explicit fixtures provide inputs and geometry, never scoring outcomes.
"""
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('--state',type=Path);ap.add_argument('--section',choices=['all','velocity','policy','provenance','reach','save','pickup','accounting','observer','physics','storage','menu','input'],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)=='3d1f54ae96b0308163474b8c138be9ed880fa96d2213e871a8e77e71bdbcccc5'
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 snapshot(at):
 return [f' movem.l %d0-%d7/%a0-%a6,0x{at:x}',f' move.w %sr,0x{at+60:x}',f' move.l %sp,0x{at+62:x}']

def abi(raw):return dict(regs=list(struct.unpack_from('>15I',raw)),sr=int.from_bytes(raw[60:62],'big'),sp=int.from_bytes(raw[62:66],'big'))

def registers(a2=0xffffb2ca,a3=0xffffb74a,d0=0x12340003,d1=0x432100fc):
 regs=[0x11223300+i for i in range(8)]+[0xffff1000+256*i for i in range(7)];regs[0]=d0;regs[1]=d1;regs[10]=a2;regs[11]=a3
 lines=[f' move.l #0x{x:x},%d{i}' for i,x in enumerate(regs[:8])]+[f' movea.l #0x{x:x},%a{i}' for i,x in enumerate(regs[8:])];return lines,regs

def live_setup():
 return [' clr.w 0xffffd412',' clr.w 0xffffc2ea',' clr.w 0xffffc2ec',' clr.w 0xffffc2f2',' clr.w 0xffffc2f6',' clr.w 0xffffc2f8',' clr.w 0xffffc2fa']

def guard_lines(kind):
 return {'stopped':[' bset #0,0xffffc2ea'],'whistle':[' bset #4,0xffffc2ea'],'shootout':[' bset #0,0xffffc2fa'],'special':[' bset #2,0xffffc2fa'],'penalty':[' move.w #1,0xffffd412'],'pause':[' bset #0,0xffffc2ec'],'replay':[' bset #7,0xffffc2ec'],'forced':[' bset #3,0xffffc2f6'],'fight':[' bset #2,0xffffc2f2']}.get(kind,[])

def velocity_suite():
 kinds=['live','stopped','whistle','shootout','special','penalty','pause','replay','forced','fight']
 cases=[dict(on=on,event=event,side=side,variant=v,kind=kind,ccr=ccr) for on in [0,1,99] for event in [0,1,2,3,4,5,6] for side in [-1,1] for v in [0,1] for kind in ['live'] for ccr in [0,31]]
 cases += [dict(on=1,event=event,side=side,variant=1,kind=kind,ccr=31) for event in range(1,6) for side in [-1,1] for kind in kinds[1:]]
 cases += [dict(on=1,event=event,side=side,variant=v,kind='live',ccr=31,cap=cap) for event in range(1,6) for side in [-1,1] for v in [0,1] for cap in [0,2,2046,2048,3000,10000,32766]]
 cases += [dict(on=1,event=event,side=1,variant=0,kind='live',ccr=31,delay=delay) for event in [4,5] for delay in [0,10,20,64,127,128,255]]
 def trial(batch):
  ss=cases[batch*12:batch*12+12];body=[];wanted=[]
  for j,z in enumerate(ss):
   at=0xffff4000+j*80;gb=0xffffb2ca;body+=live_setup()+guard_lines(z['kind'])+[f' move.w #{z["on"]},0xfffff012',f' move.w #{z["event"]},0xfffff01c',f' move.w #{(z.get("cap",10000)&65534)|z["variant"]},0xfffff01e',f' move.b #{z.get("delay",10)},0x{gb+0x5e:x}',f' move.w #{240*z["side"]},0x{gb+0x14:x}',' move.w #0x4567,0xffffb772',' move.w #0x2345,0xffffb774',' move.w #0x3456,0xffffb776']
   init,regs=registers();body+=init+[f' move.w #{0x2700+z["ccr"]},%sr',' jsr 0x22100c']+snapshot(at)+[f' move.w 0xffffb772,0x{at+66:x}',f' move.w 0xffffb774,0x{at+68:x}',f' move.w 0xffffb776,0x{at+70:x}',f' move.w 0xfffff01c,0x{at+72:x}',f' move.b 0x{gb+0x5e:x},0x{at+74:x}'];wanted.append(regs)
  raw=run(o/f'velocity-{batch}','\n'.join(body),80*len(ss));out=[]
  for j,z in enumerate(ss):
   r=raw[80*j:80*j+80];ctx=abi(r);assert ctx['regs']==wanted[j] and ctx['sp']==0xfffff900 and ctx['sr']==0x2708+(z['ccr']&16),('velocity whole ABI',z,ctx)
   vx,vy,vz,event=struct.unpack_from('>hhhH',r,66);active=z['on']==1 and 1<=z['event']<=5 and z['kind']=='live' and z.get('cap',10000)>=2048
   if active:
    x,y=vector(z['event'],z['variant'],z['side'],z.get('cap',10000));want=(x,y,0)
    assert abs(vx)+abs(vy)<=z.get('cap',10000),('rebound adds horizontal energy',z,vx,vy)
   else:want=(0x0367,-0x3bb,0x3456)
   assert (vx,vy,vz)==want,('native rebound stores/vector/guard',z,[vx,vy,vz],want)
   expected_delay=max(z.get('delay',10),64 if z['event']==4 else 24 if z['event']==5 else 0) if active else z.get('delay',10)
   assert r[74]==expected_delay,('keeper recovery window/long-lock preservation',z,r[74],expected_delay)
   assert event==(0 if z['on']==1 else z['event']),('consumed event only while enabled',z,event)
   out.append(z|dict(velocity=[vx,vy,vz],all_registers_SR_stack_exact=True))
  return out
 with ThreadPoolExecutor(max_workers=3) as pool:rows=sum(pool.map(trial,range((len(cases)+11)//12)),[])
 print('PASS',len(rows),'native velocity-store/vector/guard complete ABI cases',flush=True);return dict(cases=rows)

def policy_suite():
 cases=[dict(distance=d,traffic=t,roll=r) for d in [1,159,160,300] for t in [0,1,3] for r in range(100)]
 def trial(batch):
  ss=cases[batch*20:batch*20+20];body=[]
  for j,z in enumerate(ss):body += [f' move.w #{z["roll"]},%d0',f' move.w #{z["distance"]},%d1',f' move.w #{z["traffic"]},%d2',' jsr 0x22101c',f' move.w %d0,0x{0xffff4000+2*j:x}']
  raw=run(o/f'policy-{batch}','\n'.join(body),2*len(ss));return [(z,int.from_bytes(raw[2*j:2*j+2],'big')) for j,z in enumerate(ss)]
 with ThreadPoolExecutor(max_workers=3) as pool:rows=sum(pool.map(trial,range((len(cases)+19)//20)),[])
 distributions=[]
 for dist in [1,159,160,300]:
  for traffic in [0,1,3]:
   counts=[sum(z['distance']==dist and z['traffic']==traffic and cat==i for z,cat in rows) for i in range(6)];expected=([10,40,15,15,15,5] if dist<160 else [20,40,15,10,10,5])
   if traffic:expected[0]-=5;expected[1]-=5;expected[4]+=10
   assert counts==expected,('all100rolls/outcome bounds',dist,traffic,counts,expected);distributions.append(dict(distance=dist,traffic=traffic,counts=counts))
 print('PASS',len(rows),'native category boundaries and explicit distance/traffic distributions',flush=True);return dict(contexts=len(rows),distributions=distributions)


def provenance_suite():
 cases=[dict(on=on,team=team,sign=sign,human=human,distance=distance,screen=screen,kind='live') for on in [0,1] for team in [0,1] for sign in [-1,1] for human in [False,True] for distance in [80,160,300] for screen in [0,1,3,5]]
 cases += [dict(on=on,team=0,sign=1,human=True,distance=160,screen=3,kind=kind) for on in [0,1,99] for kind in ['stopped','whistle','shootout','special','penalty','pause','replay','forced','fight','inactive','invalid-actor','zero-distance']]
 def trial(batch,profile):
  ss=cases[batch*8:batch*8+8];body=[]
  for j,z in enumerate(ss):
   actor=z['team']*6+2;obj=0xffffb04a+128*actor;at=0xffff4000+j*96
   body+=live_setup()+guard_lines(z['kind'])
   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' clr.w 0x{0xffffb04a+128*i:x}',f' clr.w 0x{0xffffb05e+128*i:x}']
   for i,idx in enumerate([i for i in range(12) if i!=actor and i%6!=5][:z['screen']]):body += [f' move.w #4,0x{0xffffb07e+128*idx:x}',f' move.w #{(i%3-1)*10},0x{0xffffb04a+128*idx:x}',f' move.w #{z["sign"]*252},0x{0xffffb05e+128*idx:x}']
   role=-1 if z['kind']=='inactive' else 4;idx=12 if z['kind']=='invalid-actor' else actor;dist=0 if z['kind']=='zero-distance' else z['distance']
   body += [f' move.w #{role},0x{obj+0x34:x}',f' move.w #{idx},0x{obj+0x52:x}',f' move.b #{64*z["team"]+128*(z["sign"]==1)+8*z["human"]},0x{obj+0x62:x}',f' move.w #{z["on"]},0xfffff012',' move.w #0x1111,0xfffff014',' move.w #0x2222,0xfffff016',' move.w #0x3333,0xfffff018',' move.w #0x4444,0xfffff01a',' move.w #0x5555,0xfffff01c',' move.w #0x6666,0xfffff01e',' move.w #-1,0xffffb7aa',' move.l #0x12345678,0xffffd066']
   init,regs=registers(a3=obj,d0=0x11220000+dist);body+=init+[' move.w #0x271f,%sr',f' jsr 0x{0x2ff300 if profile=="parent" else 0x221000:x}']+snapshot(at)+[f' move.l 0xfffff000,0x{at+66:x}',f' move.l 0xffffd066,0x{at+70:x}']
   for i in range(7):body += [f' move.w 0x{0xfffff012+2*i:x},0x{at+74+2*i:x}']
  return run(o/f'provenance-{profile}-{batch}','\n'.join(body),96*len(ss),profile=profile)
 def pair(batch):
  old=trial(batch,'parent');new=trial(batch,'current');out=[]
  for j,z in enumerate(cases[batch*8:batch*8+8]):
   before=old[96*j:96*j+96];after=new[96*j:96*j+96];assert after[:74]==before[:74],('shot wrapper changed native blue-line effects/RNG/full ABI',z,after.hex(),before.hex());assert abi(after)['sp']==0xfffff900
   values=struct.unpack_from('>7H',after,74);eligible=z['on']==1 and z['kind']=='live'
   if eligible:assert values[1:]==(0x4752,z['team']*6+2,z['distance'],min(z['screen'],3),0,0),('actual release provenance/traffic',z,values)
   elif z['on']==1:assert values[1]==0 and values[5:]==(0,0),('invalid shot created provenance or stale event',z,values)
   else:assert after[74:88]==before[74:88],('Off/invalid must preserve every metadata byte',z,values)
   out.append(z|dict(metadata=list(values),native_rng_effects_registers_SR_stack_exact=True))
  return out
 with ThreadPoolExecutor(max_workers=3) as pool:rows=sum(pool.map(pair,range((len(cases)+7)//8)),[])
 print('PASS',len(rows),'paired native shot-release/traffic/guard/RNG/full ABI cases',flush=True);return dict(cases=rows)


def reach_suite():
 offsets=[(0,0),(11,0),(12,0),(15,0),(16,0),(20,0),(21,0),(22,0),(14,14),(15,15),(0,20),(0,22),(-20,0),(0,-20)]
 cases=[dict(on=on,team=team,sign=sign,human=human,agility=agility,offset=xy,kind='live',height=6,animation=0) for on in [0,1] for team in [0,1] for sign in [-1,1] for human in [False,True] for agility in [0,30] for xy in offsets]
 cases += [dict(on=1,team=t,sign=sg,human=False,agility=30,offset=(20,0),kind=kind,height=6,animation=0) for t in [0,1] for sg in [-1,1] for kind in ['stopped','whistle','shootout','special','penalty','pause','replay','forced','fight','blue-line','native-radius','cooldown']]
 cases += [dict(on=1,team=t,sign=sg,human=False,agility=30,offset=xy,kind='live',height=height,animation=anim) for t in [0,1] for sg in [-1,1] for xy in [(20,0),(15,15)] for height in [6,15,16] for anim in [0x250,0x2a2]]
 for rom in [a.rom,a.baseline]:assert rom.read_bytes()[0x2d4f00:0x2d4f0a]==b'\xff'*10,'private save-observer reservation occupied'
 def trial(batch,profile):
  ss=cases[batch*8:batch*8+8];body=[]
  for j,z in enumerate(ss):
   idx=6*z['team']+5;obj=0xffffb04a+128*idx;at=0xffff4000+j*96;anim=z['animation'];shift=(0 if not anim else (-6 if anim==0x250 else 6)*(1 if z['sign']<0 else -1));dx,dy=z['offset'];flags=64*z['team']+128*(z['sign']<0)+8*z['human']
   body += [' lea 0xffffb000,%a0',' move.w #1023,%d0',f'clear{j}: clr.l (%a0)+',f' dbra %d0,clear{j}']+live_setup()+guard_lines(z['kind'])
   body += [f' move.w #{z["on"]},0xfffff012',' clr.w 0xffff3fe0',f' move.w #{244*z["sign"]},0x{obj+0x14:x}',f' move.w #{idx},0x{obj+0x52:x}',f' move.w #{anim},0x{obj+0x58:x}',f' move.b #{flags},0x{obj+0x62:x}',f' move.b #{z["agility"]},0x{obj+0x68:x}',f' move.w #{shift+dx},0xffffb74a',f' move.w #{244*z["sign"]+dy},0xffffb75e',f' move.w #{z["height"]},0xffffb762',' move.w #-1,0xffffb7aa',' move.w #1,0xffffbeda',f' move.w #{idx},0xffffbee0',' move.w #0x5555,0xffffbf64',' move.l #0x55555555,0xffffbf66',' move.w #0xaaaa,0xffffbf6a',' clr.w 0xfffff002']
   if z['kind']=='blue-line':body += [' move.b #40,0xfffff002']
   if z['kind']=='native-radius':body += [' bset #1,0xffffc2f8']
   if z['kind']=='cooldown':body += [f' move.b #16,0x{obj+0x5e:x}']
   init,regs=registers(a2=obj);body+=init+[f' move.w #{idx*2},%d4',' move.w #0x271f,%sr',' jsr 0x14e10']+snapshot(at)+[f' move.w 0xffff3fe0,0x{at+66:x}',f' move.w 0xffffbf64,0x{at+68:x}',f' move.l 0xffffbf66,0x{at+70:x}',f' move.w 0xffffbf6a,0x{at+74:x}',f' move.w 0xfffff002,0x{at+76:x}',f' move.w 0xffffb7aa,0x{at+78:x}']
  return run(o/f'reach-{profile}-{batch}','\n'.join(body),96*len(ss),[(0x15466,bytes.fromhex('4ef9002d4f00')),(0x2d4f00,bytes.fromhex('33fc0001ffff3fe04e75'))],profile=profile)
 def pair(batch):
  old=trial(batch,'parent');new=trial(batch,'current');out=[]
  for j,z in enumerate(cases[batch*8:batch*8+8]):
   before=old[96*j:96*j+96];after=new[96*j:96*j+96];ctx=abi(after);assert ctx['regs']==abi(before)['regs'] and ctx['sp']==0xfffff900,('native envelope complete registers/stack',z,ctx)
   active=z['on']==1 and z['kind']=='live'
   if not active:assert after==before,('Off or native special/blue-line/cooldown changed envelope or ABI',z,after.hex(),before.hex())
   hit,radius,radius2,minimum=struct.unpack_from('>HHIh',after,66)
   if active:
    assert (radius,radius2,minimum)==(21,441,-21),('optional circular envelope',z,radius,radius2,minimum)
    expected=z['height']<=15 and sum(q*q for q in z['offset'])<=441
    assert bool(hit)==expected,('actual native circular reach/height collision',z,hit,expected)
   out.append(z|dict(save_envelope_contact=hit,radius=radius,radius_squared=radius2,all_registers_stack_exact=True))
  return out
 with ThreadPoolExecutor(max_workers=3) as pool:rows=sum(pool.map(pair,range((len(cases)+7)//8)),[])
 print('PASS',len(rows),'paired actual native collision/envelope geometry, height, special and full ABI cases',flush=True);return dict(cases=rows,private_observer='Native15466 save action is replaced only by a contact marker; all collision and envelope arithmetic runs normally. This suite measures reach, not a fabricated catch or goal.')


def memory_fixture():
 assert a.state and a.state.is_file(),'--state is required for initialized native save/physics fixtures'
 p=o/'native-memory';p.mkdir(exist_ok=True);(p/'commands.txt').write_text(f'100 load {a.state.resolve()}\n101 dump ff0000 65536 ram\n102 exit\n')
 with (p/'log').open('w') as log:subprocess.run(['python3',str(h/'test_core.py'),str(a.baseline.resolve()),str(p/'commands.txt'),str(p)],env=env|dict(GPGX_DEV='1281'),stdout=log,stderr=log,check=True,timeout=30)
 values={t[1]:bytes.fromhex(' '.join(t[2:])) for line in (p/'commands.out').read_text().splitlines() if (t:=line.split())};assert len(values['ram'])==65536
 raw=values['ram'];(p/'native-ram.bin').write_bytes(raw);return raw

def rng(seed,size):
 value=(seed*0xbb40e62d+1)&0xffffffff
 return value,((value>>8)&65535)*size>>16

def category(roll,distance,traffic):
 amounts=[10,40,15,15,15,5] if distance<160 else [20,40,15,10,10,5]
 if traffic:amounts[0]-=5;amounts[1]-=5;amounts[4]+=10
 total=0
 for i,amount in enumerate(amounts):
  total+=amount
  if roll<total:return i
 raise AssertionError('roll outside native range')

def vector(cat,variant,sign,cap=10000):
 vx,vy={1:(0x2000,0x800),2:(0x800,-0x1000),3:(0x1400,-0x2000),4:(0x100,0xc00),5:(0x200,0x2000)}[cat]
 total=abs(vx)+abs(vy)
 if total>cap:vx=vx*cap//total;vy=(abs(vy)*cap//total)*(-1 if vy<0 else 1)
 return vx*(-1 if variant else 1),vy*sign

def save_setup(z,payload):
 idx=6*z['team']+5;shooter=6*(1-z['team'])+2;gb=0xffffb04a+128*idx;sb=0xffffb04a+128*shooter
 body=[' movea.l #0xfffffe00,%sp',' lea 0x22F000,%a0',' lea 0xffffb000,%a1',' move.w #0x4810/4-1,%d0','copy_native: move.l (%a0)+,(%a1)+',' dbra %d0,copy_native']+live_setup()+guard_lines(z['kind'])+[' clr.l 0xffffc328',' clr.l 0xffffc32c',' move.l #-1,0xffffc320',' move.l #-1,0xffffc324',' clr.w 0xffffd052',' clr.w 0xfffff002',' clr.w 0xfffff736',' clr.l 0xfffff058',' clr.l 0xfffff05c',' clr.w 0xffffbf80',' move.w #1,0xfffff07a',' clr.l 0xfffff800',' clr.l 0xfffff804',' clr.l 0xfffff808',' move.w #0x474b,0xfffff80c']
 for obj in [gb,0xffffb74a]:body += [f' lea 0x{obj:x},%a0',' moveq #31,%d0',f'clear{obj:x}: clr.l (%a0)+',f' dbra %d0,clear{obj:x}']
 body += [f' move.w #{244*z["sign"]},0x{gb+0x14:x}',f' move.w #{idx},0x{gb+0x52:x}',f' move.w #0x197,0x{gb+6:x}',f' move.b #{64*z["team"]+128*(z["sign"]<0)},0x{gb+0x62:x}',f' move.b #30,0x{gb+0x68:x}',f' move.b #20,0x{gb+0x6c:x}',f' move.w #4,0x{sb+0x34:x}',f' move.w #{shooter},0x{sb+0x52:x}',f' move.b #{64*(1-z["team"])+128*(z["sign"]>0)},0x{sb+0x62:x}',f' move.b #9,0x{sb+0x66:x}',' move.w #3,0xffffb74a',f' move.w #{236*z["sign"]},0xffffb75e',' move.w #6,0xffffb762',f' move.w #{z.get("vx",5000)},0xffffb772',f' move.w #{z.get("vy",10000)*z["sign"]},0xffffb774',' move.w #-1,0xffffb7aa',f' move.w #{shooter},0xffffbed8',f' move.w #{shooter if z["kind"]!="later-pass" else idx},0xffffbeda',' move.w #-1,0xffffbee0',f' move.w #{z["on"]},0xfffff012',f' move.w #{0 if z["kind"]=="no-record" else 0x4752},0xfffff014',f' move.w #{shooter if z["kind"]!="old-actor" else 99},0xfffff016',f' move.w #{z["distance"]},0xfffff018',f' move.w #{z["traffic"]},0xfffff01a',' clr.l 0xfffff01c',f' move.w #{z["distance"] if z["kind"]!="distance-cleared" else 0},0xfffff000',' move.w #0x1000,0xffffc2ee',' clr.w 0xffffc2f4',f' move.l #0x{z["seed"]:x},0xffffd066']
 init,regs=registers(a2=gb);body+=init+[f' move.w #{idx*2},%d4',' move.w #0x271f,%sr'];regs[4]=(regs[4]&0xffff0000)|(idx*2)
 return body,regs

def save_suite():
 fullram=memory_fixture();payload=fullram[0xb000:0xf810];cases=[]
 for distance,traffic in [(80,0),(160,1)]:
  for cat in range(6):
   for variant in ([0] if cat==0 else [0,1]):
    seed=next(n for n in range(1,100000) if (lambda n1:(lambda n2:category(n2[1],distance,traffic)==cat and (not cat or rng(n2[0],2)[1]==variant))(rng(n1[0],100)))(rng(n,1)))
    for team in [0,1]:
     for sign in [-1,1]:cases.append(dict(on=1,kind='live',team=team,sign=sign,distance=distance,traffic=traffic,seed=seed,category=cat,variant=variant))
 cases += [dict(on=1,kind='live',team=team,sign=sign,distance=80,traffic=0,seed=0x12345678,category=None,variant=None,vx=1000,vy=2000) for team in [0,1] for sign in [-1,1]]
 cases += [dict(on=on,kind=kind,team=team,sign=sign,distance=80,traffic=0,seed=0x12345678,category=None,variant=None) for on,kind in [(0,'live'),(99,'live'),(1,'no-record'),(1,'old-actor'),(1,'later-pass'),(1,'distance-cleared'),(1,'whistle')] for team in [0,1] for sign in [-1,1]]
 helper=o/'save-oracle';helper.mkdir(exist_ok=True)
 code=asm(helper,''' .text
 .globl _start
_start:
 move.w %sr,-(%sp)
 movem.l %d0-%d7/%a0-%a6,-(%sp)
 move.w #100,%d0
 jsr 0x11086
 tst.w 0xffff3fe2
 beq.s caught
 moveq #2,%d0
 jsr 0x11086
 movem.l (%sp)+,%d0-%d7/%a0-%a6
 move.w (%sp)+,%sr
 jmp 0x1555a
caught:
 movem.l (%sp)+,%d0-%d7/%a0-%a6
 move.w (%sp)+,%sr
 jmp 0x15628
 .org 0x100
velocity:
 move.b %d0,0x28(%a3)
 move.b %d1,0x2a(%a3)
 move.w %sr,-(%sp)
 move.w 0xffff3fe4,0x28(%a3)
 move.w 0xffff3fe6,0x2a(%a3)
 move.b 0xffff3fe9,0x5e(%a2)
 clr.w 0x2c(%a3)
 move.w (%sp)+,%sr
 rts
''',0x2d4c00)
 for rom in [a.rom,a.baseline]:assert rom.read_bytes()[0x2d4c00:0x2d4c00+len(code)]==b'\xff'*len(code) and rom.read_bytes()[0x22F000:0x22F000+len(payload)]==b'\xff'*len(payload)
 def trial(item,profile):
  i,z=item;body,regs=save_setup(z,payload);patches=[(0x22F000,payload),(0x15496,bytes.fromhex('303c12344e71'))]
  if profile=='parent' and z['category'] is not None:
   cat=z['category'];vx,vy=vector(cat,z['variant'],z['sign']) if cat else (0,0);body += [f' move.w #{cat},0xffff3fe2',f' move.w #{vx},0xffff3fe4',f' move.w #{vy},0xffff3fe6',f' move.w #{64 if cat==4 else 24 if cat==5 else 10},0xffff3fe8']
   patches += [(0x2d4c00,code),(0x155fc,bytes.fromhex('4ef9002d4c004e71')),(0x15580,bytes.fromhex('4eb9002d4d004e71'))]
  body += [' jsr 0x14e10']+snapshot(0xffff4000)+[' lea 0xffffb000,%a0',' lea 0xffff4042,%a1',' move.w #0x4810/4-1,%d0','snapshot_native: move.l (%a0)+,(%a1)+',' dbra %d0,snapshot_native']
  return run(o/f'save-{profile}-{i}','\n'.join(body),66+len(payload),patches,profile=profile),regs
 def pair(item):
  i,z=item;(before,regs),(after,_)=trial(item,'parent'),trial(item,'current');ctx=abi(after);assert ctx['sp']==0xfffffe00 and ctx['regs']==regs and after[:66]==before[:66],('complete native save ABI',z,ctx,abi(before));old=bytearray(before[66:]);new=bytearray(after[66:]);raw=bytes(new);rw=lambda a:int.from_bytes(raw[a-0xb000:a-0xb000+2],'big');signed=lambda a:int.from_bytes(raw[a-0xb000:a-0xb000+2],'big',signed=True)
  # Only the explicit envelope and new feature's own private record may differ.
  for lo,hi in ([(0xbf64,0xbf6c),(0xf012,0xf020)] if z['on']==1 else []):old[lo-0xb000:hi-0xb000]=new[lo-0xb000:hi-0xb000]
  assert new==old,('native same-outcome save/state/physics/RNG comparison',z,[hex(0xb000+n) for n,(x,y) in enumerate(zip(new,old)) if x!=y][:40])
  cat=z['category'];idx=6*z['team']+5;gb=0xb04a+128*idx;shooting=0xca32 if z['team']==0 else 0xc6ce;defending=0xc6ce if z['team']==0 else 0xca32
  if cat is not None:
   assert rw(0xf014)==(0x474c if cat else 0) and rw(0xf01c)==0,('shot/event lifecycle',z)
   if cat:assert rw(0xf01a)==9,('pending parry retains actual credited roster',z,rw(0xf01a))
   if cat==0:assert rw(0xb7aa)==idx and rw(gb+0x48)==180,('native catch/keeper possession window',z,rw(0xb7aa),rw(gb+0x48))
   else:assert rw(0xb7aa)>=0x8000 and [signed(x) for x in [0xb772,0xb774,0xb776]]==[*vector(cat,z['variant'],z['sign']),0] and raw[gb+0x5e-0xb000]==(64 if cat==4 else 24 if cat==5 else 10) and rw(gb+0x46)==4,('native rebound ownership/phase/cooldown/vector',z)
  if z['kind']!='whistle':assert rw(shooting)==int.from_bytes(payload[shooting-0xb000:shooting-0xb000+2],'big')+1 and raw[defending+0xe8-0xb000]==payload[defending+0xe8-0xb000]+1 and raw[shooting+0xe8+9-0xb000]==payload[shooting+0xe8+9-0xb000]+1,('native SOG/keeper SA/scorer shot exactly once',z)
  return z|dict(owner=rw(0xb7aa),velocity=[signed(x) for x in [0xb772,0xb774,0xb776]],complete_native_registers_SR_stack_and_state_equal_to_prescribed_native_outcome=True)
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(pair,enumerate(cases)))
 print('PASS',len(rows),'complete native collision/save/catch/rebound/stat/RNG/ABI and stale-pass guards',flush=True);return dict(cases=rows,state_sha256=sha(a.state),native_RAM_sha256=hashlib.sha256(fullram).hexdigest(),oracle='Independent native same-outcome comparator controls only category/vector inputs and supplies the same native RNG draws. Native stats, ownership, timer, animation and save paths execute in both images. Fixed private HV input isolates ABI; real-input gameplay retains actual HV.')





def pickup_suite():
 cases=[dict(on=on,index=idx,ccr=c,shot=marker) for on in [0,1,99] for idx in range(12) for c in [0,31] for marker in [0,0x4752,0x474c]]
 def trial(batch):
  ss=cases[batch*12:batch*12+12];body=[];expected=[]
  for j,z in enumerate(ss):
   dest=0xffff4000+72*j;init,regs=registers();regs[0]=(regs[0]&0xffff0000)|z['index'];body += [f' move.w #{z["index"]},0xffffb31c',f' move.w #{z["on"]},0xfffff012',f' move.w #{z["shot"]},0xfffff014']+init+[f' move.w #{0x2700+z["ccr"]},%sr',' jsr 0x221024']+snapshot(dest)+[f' move.w 0xffffb7aa,0x{dest+66:x}',f' move.w 0xfffff014,0x{dest+68:x}'];expected.append(regs)
  raw=run(o/f'pickup-{batch}','\n'.join(body),72*len(ss));out=[]
  for j,z in enumerate(ss):
   b=raw[72*j:72*j+72];ctx=abi(b);assert ctx['regs']==expected[j] and ctx['sp']==0xfffff900 and ctx['sr']==(0x2700|(z['ccr']&16)|(4 if z['index']==0 else 0)),('accepted native pickup register/CCR/stack',z,ctx);owner,marker=struct.unpack_from('>HH',b,66);assert owner==z['index'] and marker==(0 if z['on']==1 else z['shot']),('only accepted On possession invalidates pending shot',z,owner,marker);out.append(z)
  return out
 with ThreadPoolExecutor(max_workers=3) as pool:rows=sum(pool.map(trial,range((len(cases)+11)//12)),[])
 print('PASS',len(rows),'accepted pickup owner/shot lifecycle and full native register/CCR/stack cases',flush=True);return dict(cases=rows)


def accounting_suite():
 fullram=memory_fixture();payload=fullram[0xb000:0xf810];p=o/'accounting-oracle';p.mkdir(exist_ok=True)
 code=asm(p,''' .text
 .globl _start
_start:
 btst #0,0xffffc2ea
 beq.s active
 jmp 0x15464
active: jmp 0x12f3a
 .org 0x40
skip:
 move.w %sr,-(%sp)
 clr.w 0xfffff014
 bclr #4,0xffffc2ee
 bclr #3,0xffffc2fe
 move.w (%sp)+,%sr
 rts
''',0x2d4c00)
 kinds=['valid','off','invalid-option','missing','armed','no-goal','stopped','whistle','pause','replay','shootout','special','penalty','forced','fight','distance-clear-valid','distance-other','actor-invalid','shot-other','touch-other','other-club','roster-invalid','roster-other']
 cases=[dict(team=t,kind=k,ccr=c,gate=g) for t in [0,1] for k in kinds for c in [0,31] for g in [0,1]]
 def trial(item,profile):
  i,z=item;t=z['team'];actor=6*t+2;sb=0xffffb04a+128*actor;club=0xffffc6ce+0x364*t;body,_=save_setup(dict(team=1-t,sign=1,kind='live',on=1,distance=80,traffic=0,seed=1),payload)
  # The test provides goal-log/SOG inputs, never a goal or match result.
  body += [' clr.w 0xffffc472',f' move.b #{t*128},0xffffc470',' move.b #9,0xffffc471',f' move.w #9,0x{club+0x18:x}',f' move.b #9,0x{sb+0x66:x}',' move.w #0x474c,0xfffff014',' move.w #9,0xfffff01a',' bset #0,0xffffc2f4',' bset #3,0xffffc2fe',f' move.w #{z["gate"]*4096},0xffffc2ee']
  edits={'off':[' clr.w 0xfffff012'],'invalid-option':[' move.w #99,0xfffff012'],'missing':[' clr.w 0xfffff014'],'armed':[' move.w #0x4752,0xfffff014'],'no-goal':[' bclr #0,0xffffc2f4'],'distance-clear-valid':[' clr.w 0xfffff000'],'distance-other':[' move.w #81,0xfffff018'],'actor-invalid':[' move.w #12,0xfffff016'],'shot-other':[' move.w #4,0xffffbed8'],'touch-other':[' move.w #5,0xffffbeda'],'other-club':[],'roster-invalid':[' move.w #26,0xfffff01a'],'roster-other':[' move.w #8,0xfffff01a']}
  body+=guard_lines(z['kind'])+edits.get(z['kind'],[]);init,regs=registers(a2=club if z['kind']!='other-club' else 0xffffc6ce+0x364*(1-t));body+=init+[f' move.w #{0x2700+z["ccr"]},%sr',f' jsr 0x{0x221020 if profile=="current" else 0x2d4c40 if z["kind"] in ["valid","distance-clear-valid"] else 0x2d4c00:x}']+snapshot(0xffff4000)+[' lea 0xffffb000,%a0',' lea 0xffff4042,%a1',' move.w #0x4810/4-1,%d0','snap: move.l (%a0)+,(%a1)+',' dbra %d0,snap']
  return run(o/f'accounting-{profile}-{i}','\n'.join(body),66+len(payload),[(0x22F000,payload),(0x2d4c00,code)],profile)
 def pair(item):
  i,z=item;before=trial(item,'parent');after=trial(item,'current');assert after==before,('duplicate SOG suppression or complete native guarded ABI/state contract',z,abi(after),abi(before),[hex(0xb000+n) for n,(x,y) in enumerate(zip(after[66:],before[66:])) if x!=y][:32]);return z|dict(complete_native_registers_SR_stack_and_state=True)
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(pair,enumerate(cases)))
 print('PASS',len(rows),'paired full-native goal-SOG accounting and provenance/mode/club/scorer guards',flush=True);return dict(cases=rows,method='Native initialized stat/roster context and declared goal-log inputs; normal and goal-log SOG branches execute. Only valid same-shot parry skips its already-counted SOG. No synthetic goals are counted as physics outcomes.')



def observer_suite():
 fullram=memory_fixture();payload=fullram[0xb000:0xf810];p=o/'observer-abi';p.mkdir(exist_ok=True);code=asm(p,(h/'testcases/alt_goalies_save_probe.s').read_text(),0x2d9000);cases=[]
 for cat in range(6):
  seed=next(n for n in range(1,100000) if category(rng(rng(n,1)[0],100)[1],80,0)==cat)
  for team in [0,1]:
   for sign in [-1,1]:cases.append(dict(on=1,kind='live',team=team,sign=sign,distance=80,traffic=0,seed=seed,category=cat))
 def trial(item,observer):
  i,z=item;body,regs=save_setup(z,payload);body+=[' clr.w 0xffffef80',' jsr 0x14e10']+snapshot(0xffff4000)+[' lea 0xffffb000,%a0',' lea 0xffff4042,%a1',' move.w #0x4810/4-1,%d0','snap: move.l (%a0)+,(%a1)+',' dbra %d0,snap'];patches=[(0x22F000,payload),(0x15496,bytes.fromhex('303c12344e71'))]
  if observer:patches += [(0x2d9000,code),(0x155fc,bytes.fromhex('4ef9002d90004e71'))]
  return run(o/f'observer-{int(observer)}-{i}','\n'.join(body),66+len(payload),patches)
 def pair(item):
  i,z=item;before=trial(item,False);after=trial(item,True);assert before[:66]==after[:66],('read-only save observer altered full native ABI',z,abi(before),abi(after));old=bytearray(before[66:]);new=bytearray(after[66:]);old[0xeb00-0xb000:0xef82-0xb000]=new[0xeb00-0xb000:0xef82-0xb000];assert old==new,('read-only save observer altered native state outside observation buffers',z,[hex(0xb000+n) for n,(x,y) in enumerate(zip(old,new)) if x!=y][:32]);raw=after[66:];pkt=raw[0xeb00-0xb000:0xeb70-0xb000];u=lambda k:int.from_bytes(pkt[k:k+2],'big');assert int.from_bytes(raw[0xef80-0xb000:0xef82-0xb000],'big')==1 and u(108)==0 and u(110)==65535 and int.from_bytes(pkt[104:108],'big')==sum(int.from_bytes(pkt[k:k+4],'big') for k in range(0,104,4))&0xffffffff,('observer complete packet/sequence/checksum',z);seed,roll=rng(int.from_bytes(pkt[36:40],'big'),100);assert category(roll,u(10),u(12))==z['category'] and u(78)==(0x474c if z['category'] else 0),('observer decoded native outcome',z);return z|dict(complete_native_registers_SR_stack_and_state_exact=True)
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(pair,enumerate(cases)))
 print('PASS',len(rows),'read-only save observer full native ABI/state equivalence and checked packets',flush=True);return dict(cases=rows)


def observed_save_image():
 p=o/'save-observer';p.mkdir(exist_ok=True);code=asm(p,(h/'testcases/alt_goalies_save_probe.s').read_text(),0x2d9000);data=bytearray(a.rom.read_bytes());assert data[0x2d9000:0x2d9000+len(code)]==b'\xff'*len(code);assert data[0x155fc:0x15604]==bytes.fromhex('4ef9002210084e71');data[0x2d9000:0x2d9000+len(code)]=code;data[0x155fc:0x15604]=bytes.fromhex('4ef9002d90004e71');path=p/'rom.bin';path.write_bytes(data);return path

def physics_suite():
 assert a.state and a.state.is_file(),'--state is required for real native physics fixtures'
 observed=observed_save_image()
 # Fixed, exhaustive grid declared before running. Every failure is retained.
 cases=[dict(team=t,orient=ori,sign=1,start_x=0,depth=180,shot_at=10,charge=8,skate_frames=0,diagonal_frames=0,end_frame=708,alt=1,seed=seed,screen=screen) for t in [0,1] for ori in [-1,1] for screen in [0,1] for seed in range(1,33)]
 def trial(item):
  i,z=item;p=o/f'physics-{i}';p.mkdir(exist_ok=True);(p/'spec.json').write_text(json.dumps(z));game_core(p,observed,[f'100 load {a.state.resolve()}','180 snap','250 snap','709 exit'],dict(GPGX_DEV='513',GPGX_HOOK=str(h/'testcases/alt_goalies_physics_hook.py')));r=json.loads((p/'results.json').read_text());assert r['actual_human_shot_seen'],('real C did not release a shot',z);assert r['classified']>=1,('no eligible actual native save',z)
  before=r['initial'];goal=r['first_goal'];assert r['final']['keeper_attacks']==before['keeper_attacks'],('ordinary rebounds counted as keeper attacks',z)
  if goal:
   defending=z['team'];shooting=1-defending;after=goal['stats'];idx=r['source_roster'];events=[e for e in r['events'] if 'category' in e and e['frame']<goal['frame']];assert events and events[0]['category'] in [4,5],('geometry scored outside prescribed goalward response',z,events,goal)
   assert after['score'][shooting]==before['score'][shooting]+1 and after['score'][defending]==before['score'][defending],('native goal credited to wrong club',z,goal)
   assert after['goals'][shooting][idx]==before['goals'][shooting][idx]+1 and after['goals'][defending][0]==before['goals'][defending][0]+1,('native scorer or keeper GA attribution',z,goal)
   assert after['SOG'][shooting]==before['SOG'][shooting]+1 and after['shots'][shooting][idx]==before['shots'][shooting][idx]+1 and after['shots'][defending][0]==before['shots'][defending][0]+1,('untouched goalward rebound duplicated native SOG/SA',z,goal)
  return r
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,enumerate(cases)))
 categories={c for r in rows for c in r['categories']};assert categories==set(range(6)),('actual native outcome coverage',categories)
 goals={(r['spec']['team'],r['spec']['orient']) for r in rows if r['first_goal']};assert goals=={(t,d) for t in [0,1] for d in [-1,1]},('natural goalward goals must cover both clubs/ends',goals)
 traffic={(r['spec']['team'],r['spec']['orient']) for r in rows for e in r['events'] if r['spec']['screen'] and e.get('traffic',0) and 'category' in e};assert traffic==goals,('real screen coverage for both clubs/ends',traffic)
 print('PASS',len(rows),'real C-shot/native physics scenes, all outcomes and both-end natural goal attribution with single SOG',flush=True);return dict(cases=rows,method='Declared geometry and RNG inputs end at frame108. Actual C input, native movement/collisions/clock/scoring then advance without outcome writes. Experimental scene coverage, not human playtests or win-rate matches.')



def input_suite():
 observed=observed_save_image();b=bytearray(observed.read_bytes());p=o/'input-banks';p.mkdir(exist_ok=True);code=asm(p,(h/'testcases/manual_keeper_banks.s').read_text(),0x2ef400);assert b[0x2ef400:0x2ef400+len(code)]==b'\xff'*len(code);b[0x2ef400:0x2ef400+len(code)]=code
 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')
 observed=p/'rom.bin';observed.write_bytes(b);cases=[dict(count=count,adapter=count>2,mode=mode,cone=0,height=i%2,speed=(i//2)%2,charge=i%2,pickup=(i//2)%2,end=35000) for i,(count,mode) in enumerate([(1,0),(1,1),(2,0),(2,1),(3,0),(4,1)])]
 def trial(item):
  i,z=item;p=o/f'input-{i}';p.mkdir(exist_ok=True);b=bytearray(battery(0,((z['height'],z['speed'],z['mode'],z['cone']),)*3)[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([0x70+z['pickup'],0x8f-z['pickup']])
  b[0x4d32:0x4d3a]=bytes.fromhex('738c738c738c738c');(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/alt_goalies_input_hook.py')));r=json.loads((p/'results.json').read_text());assert set(r['shot_pads'])==set(range(z['count'])),('configured pads must all release actual C shots',z,r['shot_pads']);assert r['classified']>0 and r['outlets'],('natural actual saves and requested keeper outlets',z,r['classified'],r['outlets']);return r
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,enumerate(cases)))
 print('PASS',len(rows),'cold boot actual P1-P4 C shots, native saves and requested keeper outlets with independent pass settings',flush=True);return dict(cases=rows,method='Read-only host observations and real controller input after cold boot. Only observer counter initialized; no actor/puck/owner/score/RNG fixture writes. Scripted pads, not physical hardware tests.')


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,0x72,0x73,255]]
 def trial(batch):
  ss=specs[batch*16:batch*16+16];body=[' move.b #1,0xa130f1']
  for j,z in enumerate(ss):
   rec=0x209a65+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,0xfffff012',' move.w #0x271f,%sr',' jsr 0x221010',f' move.w %sr,0x{dest:x}',f' move.w 0xfffff012,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 [0x72,0x73] and z['tag']^z['inv']==255 else 1;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[0x4d22:0x4d2a]=bytes.fromhex('619e609f619e609f');b[0x4d2a:0x4d32]=bytes.fromhex('718e708f718e708f');b[0x4d32:0x4d3a]=bytes.fromhex('728d728d728d728d');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,0xfffff012',' jsr 0x221014',' move.b #0,0xa130f1',' move.w 0xfffff012,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={0x4d32+2*slot,0x4d33+2*slot,0x5ffe,0x5fff};diff=[i for i,(x,y) in enumerate(zip(b,after)) if x!=y];assert set(diff)<=allowed and after[0x4d32+2*slot:0x4d34+2*slot]==bytes.fromhex('738c'),('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 #40,%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,[40]),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,alts=(0,1,0,1),bad=None):
  b=bytearray(battery(gp)[1::2]);b[0x4d22:0x4d2a]=bytes.fromhex('619e619e619e619e');b[0x4d2a:0x4d32]=bytes.fromhex('718e718e718e718e');b[0xe20:0xe25]=b[0x2e20:0x2e25]=bytes.fromhex('123456789a')
  for slot,value in enumerate(alts):b[0x4d32+slot*2:0x4d34+slot*2]=bytes([0x72+value,0x8d-value])
  if bad:b[0x4d34:0x4d36]=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;expected['pickup']=0
   elif name.startswith('restart-'):expected['charge']=0 if int(frame) in [3900,4600] else 1;expected['pickup']=expected['charge']
   else:expected['charge']=1;expected['pickup']=1
  requested_menu_expectations(a.rom,name,checks)
  if name.startswith(('fresh-','corrupt-')) or name.startswith('restart-'):
   for frame,want in checks.items():
    if name.startswith(('fresh-','corrupt-')) or int(frame) in [3900,4600]:want['alt']=1
  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/alt_goalies_menu_hook.py')));rows.append(dict(name=name,observations=json.loads((p/'results.json').read_text())));return(p/'battery.srm').read_bytes()
 # Native New Playoffs skips row6;19Down reaches the same row40 as20 in seasons.
 down=[f'{900+50*i} press 1 Down 5' for i in range(down_count(a.rom,20))]
 for dev in [513,1281]:
  menu('fresh-'+str(dev),bytes(65536),down+['2250 snap','2401 exit'],{2200:dict(alt=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(alt=0,full_goalie='123456789a'),2400:dict(alt=1,alt_records='728d738c728d728d',record='8d72'),2600:dict(alt=0,alt_records='728d728d728d728d'),2800:dict(alt=1,record='8d72',full_goalie='123456789a')},dev,True)
  menu('reboot-'+str(dev),raw,['1301 exit'],{1200:dict(alt=1,alt_records='728d738c728d728d')},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(alt=1,slot=1),1200:dict(alt=0,slot=2),1400:dict(alt=1,slot=3),1600:dict(alt=0,view=0),1800:dict(alt=1,slot=3),2000:dict(alt=0,slot=2),2200:dict(alt=1,slot=1)},dev)
  menu('slots-reboot-'+str(dev),raw,['1301 exit'],{1200:dict(alt=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(alt=1,gp=1),2400:dict(alt=1,gp=1),2600:dict(alt=1,gp=1)},dev,True)
  for bad in ['0000','738d','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(alt=0,gp=1),1400:dict(alt=0,gp=1)},dev)
  native=[f'{1000+200*i} press 1 Right 5' for i in range(3)]+[f'{1800+40*i} press 1 Down 5' for i in range(down_count(a.rom,19))]
  raw=menu('native-'+str(dev),initial(1),native+['2900 press 1 Right 5','3101 exit'],{2850:dict(alt=0,view=0),3000:dict(alt=1,view=0,alt_records='738c738c728d738c',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(alt=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(alt=1,slot=2,gp=1),3900:dict(alt=0,slot=2,gp=0),4300:dict(alt=1,slot=1),4600:dict(alt=0,slot=2),4900:dict(alt=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(alt=1),3300:dict(alt=1)},dev,False,dict(alt=1,gp=1))
  menu('completed-lock-'+str(dev),raw,down+['2300 press 1 Left 5','2601 exit'],{2200:dict(alt=1,gp=1),2500:dict(alt=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)

sections={}
if a.section in ['all','velocity']:sections['velocity']=velocity_suite()
if a.section in ['all','policy']:sections['policy']=policy_suite()
if a.section in ['all','provenance']:sections['provenance']=provenance_suite()
if a.section in ['all','reach']:sections['reach']=reach_suite()
if a.section in ['all','save']:sections['save']=save_suite()
if a.section in ['all','pickup']:sections['pickup']=pickup_suite()
if a.section in ['all','accounting']:sections['accounting']=accounting_suite()
if a.section in ['all','observer']:sections['observer']=observer_suite()
if a.section in ['all','physics']:sections['physics']=physics_suite()
if a.section in ['all','input']:sections['input']=input_suite()
if a.section in ['all','storage']:sections['storage']=storage_suite()
if a.section in ['all','menu']:sections['menu']=menu_suite()
expected_sections={'velocity','policy','provenance','reach','save','pickup','accounting','observer','physics','storage','menu','input'} if a.section=='all' else {a.section}
assert set(sections)==expected_sections,('incomplete alternative keeper qualification',set(sections),expected_sections)
(o/'results.json').write_text(json.dumps(dict(rom_sha256=sha(a.rom),baseline_sha256=sha(a.baseline),sections=sections),indent=2)+'\n')
