#!/usr/bin/env python3
"""Additional overtime-shootout rules, actual native roster/input and persistence.
Fixtures declare inputs/eligibility, never count synthetic outcomes as matches.
"""
from pathlib import Path
from menu_protocol import row_word,down_count,render_probe
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','capture','selection','spin','setup','storage','menu','gameplay','input','lifetime'],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)=='331a12164cd1102d4dbbaecea21d7ea12d7f710b79d9c6eaa971cb7d31bc1840'

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 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 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 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

_native=None
def native_payload():
 global _native
 if _native is None:_native=memory_fixture()[0xb000:0xf840]
 return _native

def roster(team):
 b=a.rom.read_bytes();at=int.from_bytes(b[0x30e+4*team:0x312+4*team],'big');head=struct.unpack_from('>6H',b,at);pos=at+head[0];players=[]
 while pos<at+head[2]-2:
  n=int.from_bytes(b[pos:pos+2],'big');traits=[int(c,16) for c in b[pos+n+1:pos+n+8].hex()];players.append(traits);pos+=n+8
 keepers=0
 for r in players:
  if r[6]==r[8]==r[9]==0:keepers+=1
  else:break
 assert 1<=keepers<=4 and keepers<len(players)<=26,(team,len(players),keepers)
 preferred=[x-1 for x in b[at+head[3]:at+head[3]+6]][::-1][:5]
 return dict(pointer=at,count=len(players),keepers=keepers,preferred=preferred)

def mask_for(r,pattern):
 states=[-2]*r['count'];ng=r['keepers']
 if pattern in ['injured','mixed']:states[ng]=-3;states[ng+1]=-4
 if pattern in ['minor','mixed']:
  for i in range(ng+2,r['count']):states[i]=120+i
 if pattern=='none':states=[-3]*r['count']
 return sum(1<<i for i in range(ng,r['count']) if states[i] not in [-3,-4]),states

def copy_native():
 return [' movea.l #0xfffffe00,%sp',' lea 0x230000,%a0',' lea 0xffffb000,%a1',' move.w #0x4840/4-1,%d0','copy_native: move.l (%a0)+,(%a1)+',' dbra %d0,copy_native']

def context(z):
 team=z['team'];clubs=[roster(team),roster(31-team)];body=[f' move.w #{z.get("on",1)},0xfffff046',f' move.w #{z.get("state",1)},0xfffff090',f' move.b #{z.get("flag",1)},0xffffc2fa',f' move.w #{z.get("id",team)},0xffffc330',f' move.w #{31-team},0xffffc332'];meta=[]
 for side,(club,base) in enumerate(zip(clubs,[0xc6ce,0xca32])):
  mask,states=mask_for(club,z.get('pattern','available'));meta.append(dict(club=team if side==0 else 31-team,mask=mask,count=mask.bit_count(),roster=club))
  pointer=z.get('pointer',club['pointer']) if side==0 else club['pointer'];body += [f' move.l #0x{pointer:x},0x{0xffff0000+base+0x1e:x}']
  for i,value in enumerate(states):body += [f' move.w #{value},0x{0xffff0000+base+0x66+2*i:x}']
 return body,meta

def capture_suite():
 payload=native_payload();cases=[dict(team=t,pattern=p) for t in range(32) for p in ['available','injured','minor','mixed','none']]
 cases += [dict(team=t,pattern='available',**extra) for t in [0,15,31] for extra in [dict(on=0),dict(on=99),dict(state=0),dict(state=2),dict(flag=0),dict(id=32),dict(pointer=0),dict(pointer=0x300000),dict(pointer=roster((t+1)%32)['pointer'])]]
 def trial(item,profile):
  i,z=item;body=copy_native();extra,meta=context(z);body+=extra+[' lea 0xfffff820,%a0',' moveq #7,%d0','sentinel: move.l #0xa55a5aa5,(%a0)+',' dbra %d0,sentinel'];init,regs=registers();body+=init+[' move.w #0x271f,%sr',f' jsr 0x{0x222000 if profile=="current" else 0x15464:x}']+snapshot(0xffff4000)+[' lea 0xffffb000,%a0',' lea 0xffff4042,%a1',' move.w #0x4840/4-1,%d0','snap: move.l (%a0)+,(%a1)+',' dbra %d0,snap'];return run(o/f'capture-{profile}-{i}','\n'.join(body),66+len(payload),[(0x230000,payload)],profile),meta,regs
 def pair(item):
  i,z=item;(before,_,_),(after,meta,regs)=trial(item,'parent'),trial(item,'current');assert before[:66]==after[:66] and abi(after)['regs']==regs and abi(after)['sr']==0x271f and abi(after)['sp']==0xfffffe00,('complete native capture ABI',z,abi(after),abi(before));old,new=bytearray(before[66:]),bytearray(after[66:]);block=bytes(new[0xf820-0xb000:0xf840-0xb000]);old[0xf820-0xb000:0xf840-0xb000]=block;assert old==new,('capture changed native game/keeper/score/RNG fields',z,[hex(0xb000+n) for n,(x,y) in enumerate(zip(old,new)) if x!=y][:24])
  active=z.get('on',1)==1 and z.get('state',1)==1 and z.get('flag',1)&1 and z.get('id',z['team'])<=31 and 'pointer' not in z and all(x['count'] for x in meta)
  if z.get('on',1)!=1:assert after==before,('Off/invalid option changed native state',z)
  elif active:
   magic,home,away,limit,last,di,rotation,homeid,awayid,check,inv,info=struct.unpack('>HII2HII5H',block);np0=info&31;np1=(info>>5)&31;ng0=(info>>10)&7;ng1=(info>>13)&7;assert magic==0x5355 and [home,away]==[x['mask'] for x in meta] and limit==min(x['count'] for x in meta),('real native roster/injury/minor eligibility masks',z,block.hex(),meta);assert [np0,np1]==[x['roster']['count'] for x in meta] and [ng0,ng1]==[x['roster']['keepers'] for x in meta],('actual roster-derived keeper counts, including every actual keeper',z);assert [homeid,awayid]==[z['team'],31-z['team']] and last==65535 and di==rotation==0 and check==sum(int.from_bytes(block[k:k+2],'big') for k in [2,4,6,8,10,22,24,30])%65536 and inv==check^65535,('captured static checksum/reset/mutable separation',z,block.hex())
  else:assert block[:2]==b'\0\0',('invalid/degenerate capture must invalidate; quarantined metadata is not an active pool',z,block.hex())
  return z|dict(active=bool(active),eligible=meta,complete_native_registers_SR_stack_and_other_RAM_exact=True)
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(pair,enumerate(cases)))
 print('PASS',len(rows),'all32 actual roster keeper/injury/minor/guard capture contexts, full native ABI/RAM/RNG unchanged',flush=True);return dict(cases=rows)

def selection_suite():
 payload=native_payload();cases=[]
 for team in [10,14,30]:
  for pattern in ['available','mixed']:
   for pref in ['native','duplicates','invalid']:
    z=dict(team=team,pattern=pattern,pref=pref);_,meta=context(z);limit=min(x['count'] for x in meta)
    for side in [0,1]:
     for attempt in list(range(2*limit+2))+[65535]:cases.append(z|dict(side=side,attempt=attempt))
 cases += [dict(team=15,pattern='available',pref='native',side=side,attempt=0,guard=guard) for side in [0,1] for guard in ['off','bad-option','wrong-state','wrong-mode','wrong-team','bad-magic','bad-check','bad-inverse','zero-limit','large-limit']]
 def trial(batch):
  ss=cases[batch*8:batch*8+8];body=copy_native();wanted=[]
  for j,z in enumerate(ss):
   extra,meta=context(z);body+=extra+[' jsr 0x222000'];pref=[]
   for side in [0,1]:
    values=meta[side]['roster']['preferred'] if z['pref']=='native' else [3,3,25,26,65535] if z['pref']=='duplicates' else [65535,31,26,0,1];pref.append(values)
    for k,value in enumerate(values):body += [f' move.w #{value},0x{0xffffd57a+14*side+2*k:x}']
   edits={'off':[' clr.w 0xfffff046'],'bad-option':[' move.w #99,0xfffff046'],'wrong-state':[' clr.w 0xfffff090'],'wrong-mode':[' clr.b 0xffffc2fa'],'wrong-team':[' addq.w #1,0xffffc330'],'bad-magic':[' clr.w 0xfffff820'],'bad-check':[' not.w 0xfffff83a'],'bad-inverse':[' not.w 0xfffff83c'],'zero-limit':[' clr.w 0xfffff82a'],'large-limit':[' move.w #27,0xfffff82a']};body+=edits.get(z.get('guard'),[]);init,regs=registers(d0=0x11220000|z['attempt'],d1=0x43210000|z['side']);body+=init+[' move.w #0x271f,%sr',' jsr 0x22201c']+snapshot(0xffff4000+80*j)+[f' move.l 0xffffd066,0x{0xffff4000+80*j+66:x}'];mask=meta[z['side']]['mask'];order=[]
   for v in pref[z['side']]+list(range(26)):
    if 0<=v<26 and mask&(1<<v) and v not in order:order.append(v)
   expected=-1 if z.get('guard') else order[z['attempt']%min(x['count'] for x in meta)];regs[0]=expected&0xffffffff;wanted.append((regs,expected))
  raw=run(o/f'selection-{batch}','\n'.join(body),80*len(ss),[(0x230000,payload)]);rows=[]
  for j,z in enumerate(ss):
   b=raw[80*j:80*j+80];ctx=abi(b);regs,expected=wanted[j];assert ctx['regs']==regs and ctx['sp']==0xfffffe00 and ctx['sr']&0x270f==(0x2708 if expected<0 else 0x2700),('pure native selection output/full other registers/stack/result CCR',z,ctx,regs);assert b[66:70]==payload[0xd066-0xb000:0xd06a-0xb000],('selector consumed RNG',z);rows.append(z|dict(selected=expected))
  return rows
 with ThreadPoolExecutor(max_workers=3) as pool:rows=sum(pool.map(trial,range((len(cases)+7)//8)),[])
 print('PASS',len(rows),'complete native preferred/bench uniqueness cycles, unequal-pool reuse and selector guard/ABI contexts',flush=True);return dict(cases=rows)


def record_fixture(meta):
 info=meta[0]['roster']['count']|(meta[1]['roster']['count']<<5)|(meta[0]['roster']['keepers']<<10)|(meta[1]['roster']['keepers']<<13);limit=min(x['count'] for x in meta);b=bytearray(struct.pack('>HII2HII5H',0x5355 if limit else 0,meta[0]['mask'],meta[1]['mask'],limit,65535,0,0,meta[0]['club'],meta[1]['club'],0,65535,info));check=sum(int.from_bytes(b[k:k+2],'big') for k in [2,4,6,8,10,22,24,30])&65535;struct.pack_into('>HH',b,26,check,check^65535);return bytes(b)

def record_lines(block):
 return [f' move.l #0x{int.from_bytes(block[i:i+4],"big"):08x},0x{0xfffff820+i:x}' for i in range(0,32,4)]

def spin_suite():
 # Explicit boundary/negative examples, rather than another implementation of wrapping.
 frac=0xe666
 examples=[
  dict(name='cw-full',angles=[i<<16 for i in range(8)]+[0],miss=8,rotation=0x80000),
  dict(name='ccw-full',angles=[0]+[i<<16 for i in range(7,0,-1)]+[0],miss=8,rotation=-0x80000),
  dict(name='cw-wrap-start7',angles=[7<<16]+[i<<16 for i in range(7)]+[7<<16],miss=8,rotation=0x80000),
  dict(name='cuts-return',angles=[0,1<<16,2<<16,1<<16,0,7<<16,6<<16,7<<16,0],miss=None,rotation=0),
  dict(name='stationary-facing',angles=[frac]*12,miss=None,rotation=0),
  dict(name='ambiguous-half-turns',angles=[0,4<<16,0,4<<16,0],miss=None,rotation=0),
  dict(name='fraction-short-of-full',angles=[(frac+(i<<16))&0x7ffff for i in range(8)]+[frac-1],miss=None,rotation=0x7ffff),
  dict(name='fraction-exact-full',angles=[(frac+(i<<16))&0x7ffff for i in range(8)]+[frac],miss=8,rotation=0x80000),
  dict(name='fraction-ccw-short',angles=[(frac-(i<<16))&0x7ffff for i in range(8)]+[frac+1],miss=None,rotation=-0x7ffff),
  dict(name='fraction-ccw-exact',angles=[(frac-(i<<16))&0x7ffff for i in range(8)]+[frac],miss=8,rotation=-0x80000),
  dict(name='old-integer-false-positive',angles=[(frac+(i<<16))&0x7ffff for i in range(8)]+[0],miss=None,rotation=0x80000-frac),
  dict(name='wide-unambiguous-steps',angles=[0,3<<16,6<<16,1<<16],miss=3,rotation=9<<16),
 ]
 cases=[dict(e,actor=actor,guard='live') for actor in range(12) for e in examples]
 cases += [dict(examples[0],actor=actor,guard=guard,miss=None,rotation=0) for actor in [0,6] for guard in ['off','invalid-option','wrong-state','wrong-mode','changed-team','bad-magic','bad-check','released','keeper','inactive','negative-actor','invalid-actor']]
 payload=native_payload()
 def trial(item,profile):
  i,z=item;extra,meta=context(dict(team=10,pattern='available'));block=record_fixture(meta);body=copy_native()+extra+record_lines(block);actor=z['actor'];obj=0xffffb04a+128*actor;body += [f' move.w #{actor},0xffffd406',f' move.w #4,0x{obj+0x34:x}',' clr.w 0xffffc2f2'];edits={'off':[' clr.w 0xfffff046'],'invalid-option':[' move.w #99,0xfffff046'],'wrong-state':[' clr.w 0xfffff090'],'wrong-mode':[' clr.b 0xffffc2fa'],'changed-team':[' addq.w #1,0xffffc330'],'bad-magic':[' clr.w 0xfffff820'],'bad-check':[' not.w 0xfffff83a'],'released':[' bset #5,0xffffc2f2'],'keeper':[f' clr.w 0x{obj+0x34:x}'],'inactive':[f' move.w #-1,0x{obj+0x34:x}'],'negative-actor':[' move.w #-1,0xffffd406'],'invalid-actor':[' move.w #12,0xffffd406']};body+=edits.get(z['guard'],[])
  init,regs=registers()
  for n,angle in enumerate(z['angles']):
   body += [f' move.l #0x{angle:x},0x{obj+0x54:x}']+init+[' move.w #0x271f,%sr',f' jsr 0x{0x222008 if profile=="current" else 0x15464:x}',f' move.w %d0,0x{0xffff3f00+2*n:x}']
  body+=snapshot(0xffff4000)+[' lea 0xffffb000,%a0',' lea 0xffff4042,%a1',' move.w #0x4840/4-1,%d0','snap: move.l (%a0)+,(%a1)+',' dbra %d0,snap']
  # Export the small returned-result sequence after the native state/ABI snapshot.
  body += [' lea 0xffff3f00,%a0',f' lea 0x{0xffff4000+66+len(payload):x},%a1',f' moveq #{len(z["angles"])-1},%d0','steps: move.w (%a0)+,(%a1)+',' dbra %d0,steps'];return run(o/f'spin-{profile}-{i}','\n'.join(body),66+len(payload)+2*len(z['angles']),[(0x230000,payload)],profile),regs
 def pair(item):
  i,z=item;(old,_),(new,regs)=trial(item,'parent'),trial(item,'current');ctx=abi(new);want=[int(z['miss'] is not None and n>=z['miss']) for n in range(len(z['angles']))];results=list(struct.unpack_from('>'+str(len(want))+'H',new,66+len(payload)));assert results==want,('full16.16 turn boundary/negative/guard result',z,results,want);regs[0]=want[-1];assert ctx['regs']==regs and ctx['sp']==0xfffffe00 and ctx['sr']&0x270f==(0x2700 if want[-1] else 0x2704),('pure turn result/full other registers/stack/CCR contract',z,ctx)
  before,after=bytearray(old[66:66+len(payload)]),bytearray(new[66:66+len(payload)]);b=bytes(after[0xf820-0xb000:0xf840-0xb000]);before[0xf82c-0xb000:0xf836-0xb000]=after[0xf82c-0xb000:0xf836-0xb000];assert before==after,('turn helper wrote native owner/score/shot/RNG or immutable pool fields',z,[hex(0xb000+n) for n,(x,y) in enumerate(zip(before,after)) if x!=y][:24]);rotation=int.from_bytes(b[18:22],'big',signed=True);assert rotation==z['rotation'],('full precision signed turn total',z,rotation);return dict(name=z['name'],actor=z['actor'],guard=z['guard'],step_results=results,rotation=rotation,other_native_RAM_and_registers_exact=True)
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(pair,enumerate(cases)))
 print('PASS',len(rows),'full16.16 facing-history boundaries, both rotations/all actors/cuts/guards/native ABI and state isolation',flush=True);return dict(cases=rows,outer_enforcer=outer_spin_suite())

def outer_spin_suite():
 payload=native_payload();cases=[dict(on=on,turn=turn,guard=guard) for on in [0,1] for turn in [-1,1] for guard in ['live','pause','announcement','shot','already-ended']]
 player_blue_line=a.rom.read_bytes()[0x2daa36:0x2daa3a]==bytes.fromhex('22280014')
 if player_blue_line:cases=[z|dict(zone='after')for z in cases]+[dict(on=on,turn=turn,guard='live',zone='before')for on in[0,1]for turn in[-1,1]]
 # Observe the exact displaced native boundary. Original miss watcher is tested
 # independently below with only its queue routine replaced by a counter.
 stub=bytes.fromhex('40f9ffff4e0048f97fffffff4e0223cfffff4e3e4c9f00014e75')
 def trial(z):
  extra,meta=context(dict(team=10,on=z['on']));body=copy_native()+extra+record_lines(record_fixture(meta));body += [' move.w #0,0xffffd406',' move.w #0,0xffffb7aa',' move.w #4,0xffffb07e',' move.b #128,0xffffb0ac',' move.b #1,0xffffc2ea',' clr.b 0xffffc2ec',' move.b #4,0xffffc2f2',' move.w #-1,0xffffc31a',' move.w #1000,0xffffd454',' move.w #100,0xffffc31c',' move.w #0x5352,0xfffff65c',' clr.w 0xfffff65e',' move.w #1,0xfffff660',' clr.w 0xfffff662',' move.l #0x00640000,0xfffff664',' move.l #0x00640000,0xfffff66c'];edits={'pause':[' move.b #128,0xffffc2ec'],'announcement':[' move.w #1,0xffffc31a'],'shot':[' move.b #36,0xffffc2f2'],'already-ended':[' move.b #20,0xffffc2f2',' move.w #3,0xfffff660']};body+=edits.get(z['guard'],[])
  if player_blue_line:
   pos=(100 if z['zone']=='after'else 40)<<16
   body += [f' move.l #{pos},0xffffb05e',f' move.w #{int(z["zone"]=="after")},0xfffff662',f' move.l #{pos},0xfffff664',f' move.l #{pos},0xfffff66c']
  init,regs=registers()
  for j in range(9):
   body += [f' move.l #0x{((j*z["turn"])%8)<<16:x},0xffffb09e',f' move.l #0x{(101+j)<<16:x},0xffffb75e']+init+[' move.w #0x271f,%sr',' jsr 0xf2f4',f' move.w 0xfffff660,0x{0xffff3e00+4*j:x}',f' move.w 0xffffc2f2,0x{0xffff3e02+4*j:x}']
  body += [' lea 0xffff4e00,%a0',' lea 0xffff4000,%a1',' move.w #32,%d0','snapshot_boundary: move.w (%a0)+,(%a1)+',' dbra %d0,snapshot_boundary',' lea 0xffff3e00,%a0',' moveq #8,%d0','snapshot_steps: move.l (%a0)+,(%a1)+',' dbra %d0,snapshot_steps'];q=o/f'outer-{z["on"]}-{z["turn"]}-{z["guard"]}';raw=run(q,'\n'.join(body),102,[(0x230000,payload),(0xf2fc,stub)]);sr=int.from_bytes(raw[:2],'big');got=list(struct.unpack_from('>15I',raw,2));sp=int.from_bytes(raw[62:66],'big');regs[0]=(regs[0]&0xffff0000);assert got==regs and sr==0x2714 and sp==0xfffffdfa,('full outer enforcer native displaced ABI',z,got,regs,sr,sp);steps=[list(struct.unpack_from('>HH',raw,66+4*j)) for j in range(9)];expected=3 if z['guard']=='already-ended' or z['on'] and z['guard']=='live' and(not player_blue_line or z['zone']=='after') else 2 if z['guard']=='shot' else 1;assert steps[-1][0]==expected and bool(steps[-1][1]&0x1000)==(expected==3),('outer optional spin single native miss flag/guards',z,steps)
  if z['on'] and z['guard']=='live' and(not player_blue_line or z['zone']=='after'):assert all(x[0]==1 for x in steps[:8]) and steps[8][0]==3,('full turn early rejection',z,steps)
  return z|dict(steps=steps,native_boundary_registers_SR_stack_exact=True)
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,cases))
 # Existing native F2FC watcher is unchanged: verify a spin-ended attempt queues
 # one ordinary miss; the only observer replaces queue dispatch with a counter.
 body=copy_native()+[' clr.w 0xfffff65c',' move.w #4,0xffffd406',' move.w #4,0xffffb27e',' move.w #-1,0xffffc31a',' move.w #11,0xffffd408',' move.b #1,0xffffc2ea',' clr.b 0xffffc2ec',' move.w #1000,0xffffd454',' clr.w 0xffff4ff0']
 for j in range(2):body += [' move.w #4,0xffffb7aa',f' move.b #{20 if j==0 else 84},0xffffc2f2',' move.b #4,0xffffc2fa',' move.w #100,0xffffc31c',' jsr 0xf2f4',f' move.w 0xffff4ff0,0x{0xffff4000+4*j:x}',f' move.w 0xffff4ff2,0x{0xffff4002+4*j:x}']
 raw=run(o/'outer-native-single-miss','\n'.join(body),8,[(0x230000,payload),(0x11f62,bytes.fromhex('5279ffff4ff033c0ffff4ff24e75'))]);queued=list(struct.unpack('>4H',raw));assert queued==[1,10,1,10],('native spin miss queued more than once or wrong event',queued)
 print('PASS',len(rows),'full outer enforcer native ABI/guard boundaries and native single-miss queue',flush=True);return dict(cases=rows,single_miss_queue=queued)

def setup_suite():
 payload=native_payload();cases=[dict(team=team,side=side,pattern=pattern,attempt=attempt,guard='live') for team in [10,14,30] for side in [0,1] for pattern in ['available','mixed'] for attempt in [0,4,5,22,23,24,45,65535]]
 cases += [dict(team=10,side=side,pattern='available',attempt=6,guard=guard) for side in [0,1] for guard in ['off','invalid-option','wrong-state','wrong-mode','bad-magic','bad-check']]
 def trial(item,profile):
  i,z=item;extra,meta=context(z);block=record_fixture(meta);body=copy_native()+extra+record_lines(block)+[f' move.w #{z["side"]},0xffffd594',' move.w #2,0xffffd586',f' move.w #{z["attempt"]},0x{0xfffff09c+2*z["side"]:x}'];prefs=[]
  for side in [0,1]:
   values=meta[side]['roster']['preferred'];prefs.append(values)
   for k,value in enumerate(values):body += [f' move.w #{value},0x{0xffffd57a+14*side+2*k:x}']
  edits={'off':[' clr.w 0xfffff046'],'invalid-option':[' move.w #99,0xfffff046'],'wrong-state':[' clr.w 0xfffff090'],'wrong-mode':[' clr.b 0xffffc2fa'],'bad-magic':[' clr.w 0xfffff820'],'bad-check':[' not.w 0xfffff83a']};body+=edits.get(z['guard'],[])
  init,regs=registers();body+=init+[' move.w #0x271f,%sr',f' jsr 0x{0x222004 if profile=="current" else 0xfc4c0:x}']+snapshot(0xffff4000)+[' lea 0xffffb000,%a0',' lea 0xffff4042,%a1',' move.w #0x4840/4-1,%d0','snap: move.l (%a0)+,(%a1)+',' dbra %d0,snap'];return run(o/f'setup-{profile}-{i}','\n'.join(body),66+len(payload),[(0x230000,payload)],profile),meta,prefs
 def pair(item):
  i,z=item;(before,meta,prefs),(after,_,_)=trial(item,'parent'),trial(item,'current');assert before[:66]==after[:66] and abi(after)['sp']==0xfffffe00,('full original FC4C0 setup/RNG/ABI',z,abi(before),abi(after));old,new=bytearray(before[66:]),bytearray(after[66:]);word=lambda b,at:int.from_bytes(b[at-0xb000:at-0xb000+2],'big');expected=prefs[z['side']][2]
  if z['guard']=='live':
   pool=meta[z['side']]['mask'];order=[]
   for v in prefs[z['side']]+list(range(26)):
    if 0<=v<26 and pool&(1<<v) and v not in order:order.append(v)
   expected=order[z['attempt']%min(x['count'] for x in meta)]
  assert word(new,0xd40a)==expected,('native setup roster choice',z,word(new,0xd40a),expected);old[0xd40a-0xb000:0xd40c-0xb000]=new[0xd40a-0xb000:0xd40c-0xb000];assert old==new,('optional setup changed other native fields/RNG/keeper/presentation state',z,[hex(0xb000+n) for n,(x,y) in enumerate(zip(old,new)) if x!=y][:24]);return z|dict(selected=expected,complete_original_native_setup_RNG_ABI_and_other_RAM=True)
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(pair,enumerate(cases)))
 print('PASS',len(rows),'full unmodified native FC4C0 setup/presentation RNG with bounded eligible roster choice and guard ABI',flush=True);return dict(cases=rows)

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 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,0x74,0x75,255]]
 def trial(batch):
  ss=specs[batch*16:batch*16+16];body=[' move.b #1,0xa130f1']
  for j,z in enumerate(ss):
   rec=0x209a75+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,0xfffff046',' move.w #0x271f,%sr',' jsr 0x222010',f' move.w %sr,0x{dest:x}',f' move.w 0xfffff046,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=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('738c728d738c728d');b[0x4d3a:0x4d42]=bytes.fromhex('748b748b748b748b');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,0xfffff046',' jsr 0x222014',' move.b #0,0xa130f1',' move.w 0xfffff046,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={0x4d3a+2*slot,0x4d3b+2*slot,0x5ffe,0x5fff};diff=[i for i,(x,y) in enumerate(zip(b,after)) if x!=y];assert set(diff)<=allowed and after[0x4d3a+2*slot:0x4d3c+2*slot]==bytes.fromhex('758a'),('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():
 from menu_protocol import refinements_enabled
 if refinements_enabled(a.rom):
  from permanent_rules_test import verify
  def legacy_rules(gp,value):
   raw=bytearray(battery(gp)[1::2]);raw[0x4d3a:0x4d42]=bytes([0x74+value,0x8b-value])*4
   return pack(raw)
  return verify(a.rom,o,legacy_rules,'strict')
 p=o/'renderer';p.mkdir(exist_ok=True);c=asm(p,render_probe(""" cmpi.w #42,%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,[42]),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,stricts=(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')
  b[0x4d32:0x4d3a]=bytes.fromhex('738c738c738c738c')
  for slot,value in enumerate(stricts):b[0x4d3a+slot*2:0x4d3c+slot*2]=bytes([0x74+value,0x8b-value])
  if bad:b[0x4d3c:0x4d3e]=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;expected['alt']=0
   elif name.startswith('restart-'):expected['charge']=0 if int(frame) in [3900,4600] else 1;expected['pickup']=expected['charge'];expected['alt']=expected['charge']
   else:expected['charge']=1;expected['pickup']=1;expected['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/strict_so_menu_hook.py')));rows.append(dict(name=name,observations=json.loads((p/'results.json').read_text())));return(p/'battery.srm').read_bytes()
 down=[f'{900+50*i} press 1 Down 5' for i in range(down_count(a.rom,21))]
 for dev in [513,1281]:
  menu('fresh-'+str(dev),bytes(65536),down+['2250 snap','2401 exit'],{2200:dict(strict=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(strict=0,full_goalie='123456789a'),2400:dict(strict=1,strict_records='748b758a748b748b',record='8d72'),2600:dict(strict=0,strict_records='748b748b748b748b'),2800:dict(strict=1,record='8d72',full_goalie='123456789a')},dev,True)
  menu('reboot-'+str(dev),raw,['1301 exit'],{1200:dict(strict=1,strict_records='748b758a748b748b')},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(strict=1,slot=1),1200:dict(strict=0,slot=2),1400:dict(strict=1,slot=3),1600:dict(strict=0,view=0),1800:dict(strict=1,slot=3),2000:dict(strict=0,slot=2),2200:dict(strict=1,slot=1)},dev)
  menu('slots-reboot-'+str(dev),raw,['1301 exit'],{1200:dict(strict=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(strict=1,gp=1),2400:dict(strict=1,gp=1),2600:dict(strict=1,gp=1)},dev,True)
  for bad in ['0000','758b','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(strict=0,gp=1),1400:dict(strict=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,20))]
  raw=menu('native-'+str(dev),initial(1),native+['2900 press 1 Right 5','3101 exit'],{2850:dict(strict=0,view=0),3000:dict(strict=1,view=0,strict_records='758a758a748b758a',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(strict=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(strict=1,slot=2,gp=1),3900:dict(strict=0,slot=2,gp=0),4300:dict(strict=1,slot=1),4600:dict(strict=0,slot=2),4900:dict(strict=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(strict=1),3300:dict(strict=1)},dev,False,dict(strict=1,gp=1))
  menu('completed-lock-'+str(dev),raw,down+['2300 press 1 Left 5','2601 exit'],{2200:dict(strict=1,gp=1),2500:dict(strict=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 gameplay_suite():
 # Long native shootouts use declared timeout misses and one goal-line puck.
 # Outcome fixtures exercise participant loading/advancement, never AI strength.
 from menu_protocol import refinements_enabled
 permanent=refinements_enabled(a.rom)
 source=(h/'testcases/shootout_hook.py').read_text()
 source=source.replace("    events.append(dict(frame=f,event=event,**values))", """    if event=='attempt':
        actor=rw(0xd406);obj=0xb04a+128*actor
        values.update(strict=rw(0xf046),roster=rw(0xd40a),actual_roster=wram[ram(obj+0x66)],role=rw(obj+0x34),actor_bytes=raw(obj+0x34,76).hex(),pool=raw(0xf820,32).hex(),preferred=[rw(0xd57a+14*values['side']+2*k) for k in range(5)])
    events.append(dict(frame=f,event=event,**values))""")
 cases=[dict(side=side,teammates=n,on=on) for side in [1,2] for n in [1,4] for on in [0,1]]
 def trial(z):
  q=o/('gameplay-%(side)s-%(teammates)s-%(on)s'%z);q.mkdir(exist_ok=True);(q/'hook.py').write_text(source);b=bytearray(battery(0,((0,0,0,0),)*3,(0,0,0,0))[1::2]);b[0x4d3a:0x4d42]=bytes([0x74+z['on'],0x8b-z['on']])*4;(q/'battery.srm').write_bytes(pack(b));n=z['teammates'];lines=(['900 press 1 Down 5']+[f'{1000+i*100} press 1 Right 5' for i in range(n-1)]+['1400 press 1 Up 5'] if n>1 else [])+['1500 press 1 Start 5','1900 press 1 Start 5','60001 exit'];case=dict(coop=n>1,side=z['side'],pattern=['0'*25+'1','0'*26],force_at=1906,first_live_setup=True,teammates=max(2,n),period_choice=0);game_core(q,a.rom,lines,dict(GPGX_DEV='1281' if n==4 else '513',GPGX_SRM=str(q/'battery.srm'),GPGX_HOOK=str(q/'hook.py'),SHOOTOUT_CASE=json.dumps(case)));events=json.loads((q/'events.json').read_text());attempts=[x for x in events if x['event']=='attempt'];finish=next(x for x in events if x['event']=='finish');saved=next(x for x in events if x['event']=='saved');assert finish['counts']==[26,26] and finish['winner']==1 and finish['tally']==[1,0] and saved['crc'],('native long SO result',z,finish)
  assert len(attempts)==52
  # Keep both historical saved Off/On inputs. jdc's permanent-On loader must
  # normalize either one, while isolated Off helper/guard probes stay intact.
  effective_on=1 if permanent else z['on']
  for side in [0,1]:
   seq=[x for x in attempts if x['side']==side];assert [x['attempt'] for x in seq]==list(range(26));order=[]
   for x in seq:
    assert x['actual_roster']==x['roster'] and x['role']==4,('native actor/roster loader',z,x)
    assert x['strict']==effective_on,('saved rule normalization during actual shootout',z,x)
    if effective_on:
     block=bytes.fromhex(x['pool']);mask=int.from_bytes(block[2+4*side:6+4*side],'big');limit=int.from_bytes(block[10:12],'big');assert block[:2]==bytes.fromhex('5355') and mask&(1<<x['roster'])
     if not order:
      for v in x['preferred']+list(range(26)):
       if mask&(1<<v) and v not in order:order.append(v)
     assert x['roster']==order[x['attempt']%limit],('actual bench rotation/reuse',z,x,order,limit)
    else:assert x['roster']==x['preferred'][x['attempt']%5],('Off native preferred-five cycle',z,x)
   if effective_on:assert len(set(x['roster'] for x in seq[:limit]))==limit and seq[limit]['roster']==seq[0]['roster']
  return dict(spec=z,effective_rule=effective_on,attempts=attempts,finish=finish,saved=saved,declared_timeout_misses_and_one_native_goal=True)
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,cases))
 print('PASS',len(rows),'long native SO lifecycle/bench actor loads/uniqueness/reuse/Off and four-pad restoration cases',flush=True);return dict(cases=rows)


def live_starts():
 rows=[];source=(h/'testcases/shootout_start_hook.py').read_text();source=source.replace("        n=core.retro_serialize_size();buf=", "        assert rw(0xf046)==1 and rw(0xf820)==0x5355,('On shootout pool capture',rw(0xf046),raw(0xf820,32).hex())\n        n=core.retro_serialize_size();buf=")
 for side in [1,2]:
  q=o/('live-start-'+str(side));q.mkdir(exist_ok=True);b=bytearray(battery(0,((0,0,0,0),)*3,(0,0,0,0))[1::2]);b[0x4d3a:0x4d42]=bytes.fromhex('758a758a758a758a');(q/'battery.srm').write_bytes(pack(b));(q/'hook.py').write_text(source);case=dict(coop=False,side=side,force_at=1906,first_live_setup=True,teammates=2,period_choice=0);game_core(q,a.rom,['1500 press 1 Start 5','1900 press 1 Start 5','10000 exit'],dict(GPGX_DEV='513',GPGX_SRM=str(q/'battery.srm'),GPGX_HOOK=str(q/'hook.py'),SHOOTOUT_CASE=json.dumps(case)));events=json.loads((q/'events.json').read_text());first=next(x for x in events if x['event']=='first-live-no-input');assert first['active_buttons']==[] and first['controls'][0]==first['shooter'];rows.append(dict(side=side,state=str(q/'first-attempt.state'),first=first))
 return rows

def input_suite():
 player_blue_line=a.rom.read_bytes()[0x2daa36:0x2daa3a]==bytes.fromhex('22280014')
 starts=live_starts();source=(h/'testcases/shootout_rules_input_hook.py').read_text();source=source.replace('initial_counts=None','initial_counts=None;configured=False',1).replace('global ended,initial_counts','global ended,initial_counts,configured').replace(' if f<100:return',' if f<100:return\n if not configured:\n  wram[ram(0xf046)]=0;wram[ram(0xf047)]=s["on"];configured=True').replace("facedir=rw(0xb09e)","facedir=rw(obj+0x54),full_facing=int.from_bytes(bytes(wram[ram(obj+0x54+k)] for k in range(4)),'big'),rotation=int.from_bytes(bytes(wram[ram(0xf832+k)] for k in range(4)),'big',signed=True)")
 patterns=[('initial-idle',[]),('diagonal',[[0,'forward',100],[0,'Right',100]]),('backwards',[[0,'backward',150]]),('forward-reverse',[[0,'forward',25],[25,'backward',120]]),('coast-stop',[[0,'forward',12]]),('shot',[[0,'forward',80],[30,'C',4]]),('shot-mash',[[0,'forward',150]]+[[t,'C',3] for t in range(30,150,8)])]
 buttons=[['Up'],['Up','Right'],['Right'],['Down','Right'],['Down'],['Down','Left'],['Left'],['Up','Left']]
 for step in [2,4,12]:
  for turn in [-1,1]:
   actions=[[0,'forward',30]]
   for i in range(8):
    for button in buttons[(turn*(i+1))%8]:actions.append([30+i*step,button,step])
   patterns.append(('circle-'+str(step)+'-'+str(turn),actions))
 if player_blue_line:patterns.append(('postblue-lateral-reverse',[[0,'forward',200]]))
 cases=[(start,on,name,actions) for start in starts for on in [0,1] for name,actions in patterns]
 def trial(item):
  start,on,name,actions=item;side=start['side'];q=o/f'input-{side}-{on}-{name}';q.mkdir(exist_ok=True);actions=[[t,('Up' if side==1 else 'Down') if k=='forward' else ('Down' if side==1 else 'Up') if k=='backward' else k,n] for t,k,n in actions];(q/'hook.py').write_text(source);(q/'spec.json').write_text(json.dumps(dict(name=name,on=on,actions=actions,player_blue_line=player_blue_line)));game_core(q,a.rom,[f'100 load {start["state"]}','901 exit'],dict(GPGX_DEV='513',GPGX_HOOK=str(q/'hook.py')));result=json.loads((q/'result.json').read_text());end=result['first_end'];assert end,('native input never advances attempt',side,on,name);rows=result['trace'][:end['frame']+1];failed=next((x for x in rows if x['phase']==3),None)
  if player_blue_line:
   if name in ['backwards','forward-reverse','coast-stop','initial-idle']:assert not failed,('legal pre-blue motion rejected',side,on,name,failed)
   if name in ['shot','shot-mash']and failed:assert failed['blue_latch']==1 and failed['body_normalized']<failed['body_previous'],('shot falsely ended without post-blue body retreat',side,on,name,failed)
   if name=='postblue-lateral-reverse':assert failed and failed['blue_latch']==1 and failed['body_normalized']<failed['body_previous'],('actual post-blue retreat not missed',side,on,name,failed)
  else:
   if name in ['backwards','forward-reverse','coast-stop']:assert failed,('existing progress guard missing',side,on,name)
   if name in ['initial-idle','shot','shot-mash']:assert not failed,('legal native input falsely rejected',side,on,name,failed)
  if name=='diagonal':assert not failed or failed['frame']>200,('moving diagonal falsely rejected',side,on,failed)
  first=result['trace'][0]['counts'];assert sum(end['counts'])==sum(first)+1,('native first attempt counted more than once',side,on,name,first,end)
  return dict(side=side,on=on,name=name,first_end=end,rule_failure=failed,trace_sha256=sha(q/'result.json'))
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,cases))
 print('PASS',len(rows),'real native On/Off both-end movement/shot/circle input scenes and no-input starts',flush=True);return dict(starts=starts,cases=rows,limitations=['Native direction-button circles often end first through existing forward-puck rules; do not claim a new spin-only positive without an observed full body rotation.'])

def lifetime_suite():
 starts=live_starts();q=o/'lifetime-observer';q.mkdir(exist_ok=True);code=asm(q,""" move.w %sr,-(%sp)
 addq.w #1,0xffffef90
 move.w (%sp)+,%sr
 jmp 0x2f9000
 .org 0x40
leaders: move.w %sr,-(%sp)
 addq.w #1,0xffffef92
 move.w (%sp)+,%sr
 jmp 0x2fa000
""",0x2d9000);b=bytearray(a.rom.read_bytes())
 for old,new in [(0x2f9000,0x2d9000),(0x2fa000,0x2d9040)]:
  oldb=struct.pack('>I',old);assert b.count(oldb)>=7;b=b.replace(oldb,struct.pack('>I',new))
 assert b[0x2d9000:0x2d9000+len(code)]==b'\xff'*len(code);b[0x2d9000:0x2d9000+len(code)]=code;observed=q/'rom.bin';observed.write_bytes(b)
 cases=[dict(kind='ordinary',state=str(a.state.resolve()),down=i) for i in [8,9,10,11,12,13]]+[dict(kind='shootout',state=x['state'],down=0) for x in starts]
 def trial(item):
  i,z=item;p=o/f'lifetime-{i}';p.mkdir(exist_ok=True);(p/'spec.json').write_text(json.dumps(z));(p/'battery.srm').write_bytes(battery(1));lines=[f'100 load {z["state"]}','160 press 1 Start 5']+[f'{400+40*j} press 1 Down 5' for j in range(z['down'])]+['1100 press 1 C 5','1300 snap','1500 press 1 Start 5','1750 snap','1800 press 1 Start 5','2101 exit'];game_core(p,observed,lines,dict(GPGX_DEV='513',GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(h/'testcases/strict_so_lifetime_hook.py')));r=json.loads((p/'results.json').read_text());assert r['canaries_intact'] and r['paused_seen'] and r['resumed_seen'],('On memory lifetime/pause-resume',z,r);return r
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,enumerate(cases)))
 assert any(x['spec']['kind']=='ordinary' and x['views_seen'][0] for x in rows),'actual Season Standings not entered'
 assert any(x['spec']['kind']=='ordinary' and x['views_seen'][1] for x in rows),'actual Scoring Leaders not entered'
 print('PASS',len(rows),'On ordinary canaries/actual standings-leaders views and both-end real shootout metadata pause-resume lifetimes',flush=True);return dict(cases=rows)

sections={}
if a.section in ['all','capture']:sections['capture']=capture_suite()
if a.section in ['all','selection']:sections['selection']=selection_suite()
if a.section in ['all','spin']:sections['spin']=spin_suite()
if a.section in ['all','setup']:sections['setup']=setup_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','gameplay']:sections['gameplay']=gameplay_suite()
if a.section in ['all','input']:sections['input']=input_suite()
if a.section in ['all','lifetime']:sections['lifetime']=lifetime_suite()
required={'capture','selection','spin','setup','storage','menu','gameplay','input','lifetime'} if a.section=='all' else {a.section}
assert set(sections)==required,('incomplete additional shootout qualification',set(sections),required)
(o/'results.json').write_text(json.dumps(dict(rom_sha256=sha(a.rom),baseline_sha256=sha(a.baseline),sections=sections),indent=2)+'\n')
