#!/usr/bin/env python3
"""Optional human directional passes and continuous targeting-cone geometry.

Reset fixtures supply inputs, never selected receivers or pass outcomes. Native
angle, scan, intercept, assignment and RNG routines execute. Real controller input and native menu persistence are qualified separately.
"""
from pathlib import Path
from menu_protocol import row_word,down_count,render_probe,requested_menu_expectations,refinements_enabled
from concurrent.futures import ThreadPoolExecutor
import argparse,json,os,subprocess,struct,hashlib,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','cone','passes','scans','records','menu','input','dispatch'],default='all');a=ap.parse_args();h=Path(__file__).resolve().parent;o=a.output.resolve();o.mkdir(parents=True,exist_ok=True);sha=lambda p:hashlib.sha256(p.read_bytes()).hexdigest();assert sha(a.baseline)=='548615ad01079d6a93fc62a0409b4a8d6ec4e53676f8a629081f8cc34da712fa'
env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')};OBJ=0xffffb04a

def asm(p,source,origin=0x2d5000):
 (p/'driver.s').write_text(source);subprocess.run([str(h.parent/'m68kasm.sh'),str(p/'driver.s'),f'{origin:x}',str(p/'code.bin')],check=True,stdout=subprocess.DEVNULL);return(p/'code.bin').read_bytes()
def run(p,profile,body,size,patches=()):
 b=bytearray((a.baseline if profile=='baseline' else a.rom).read_bytes());code=asm(p,' .text\n .globl _start\n_start:\n move.w #0x2700,%sr\n movea.l #0xfffff900,%sp\n'+body+'\n move.l #0x4d4f4445,0xffff4ff0\nend: bra.s end\n');assert len(code)<0x4000 and set(b[0x2d5000:0x2d5000+len(code)])=={255};b[0x2d5000:0x2d5000+len(code)]=code;struct.pack_into('>I',b,4,0x2d5000)
 for at,new in patches:b[at:at+len(new)]=new
 (p/'rom.bin').write_bytes(b);(p/'commands.txt').write_text(f'3000 dump ff4ff0 4 done\n3000 dump ff4000 {size} result\n3001 exit\n')
 with(p/'log').open('w') as log:subprocess.run(['nice','-n','19','python3',str(h/'test_core.py'),str(p/'rom.bin'),str(p/'commands.txt'),str(p)],env=env,stdout=log,stderr=log,check=True,timeout=90)
 rows={l.split()[1]:bytes.fromhex(' '.join(l.split()[2:])) for l in (p/'commands.out').read_text().splitlines()};assert rows['done']==b'MODE',('native return',p);return rows['result']

def snap(at):return [f' move.w %sr,0x{at:x}',f' movem.l %d0-%d7/%a0-%a6,0x{at+2:x}',f' move.l %sp,0x{at+62:x}']
def decode(b):return dict(sr=int.from_bytes(b[:2],'big'),regs=list(struct.unpack('>15I',b[2:62])),sp=int.from_bytes(b[62:66],'big'))
vectors=[(0,1),(1,1),(1,0),(1,-1),(0,-1),(-1,-1),(-1,0),(-1,1)]

def cone_suite():
 points=[(300,0),(300,79),(300,80),(300,81),(300,-80),(300,-81),(300,172),(300,173),(300,174),(300,-173),(300,-174),(300,299),(300,300),(300,301),(0,300),(-300,0)]
 cases=[]
 for aim,(ux,uy) in enumerate(vectors):
  for cone in [1,2,3]:
   for forward,side in points:
    dx=forward*ux-side*uy;dy=forward*uy+side*ux;cases.append(dict(aim=aim,cone=cone,dx=dx,dy=dy,human=True,role=4,guard=None,buddy=False,ccr=31))
 guards=[]
 for aim in range(8):
  for kind in ['classic','CPU','inactive','shootout','penalty','invalid-cone','human-buddy-classic']:
   for ccr in [0,31]:guards.append(dict(aim=aim,cone=0 if kind in ['classic','human-buddy-classic'] else 99 if kind=='invalid-cone' else 2,dx=120,dy=-170,human=kind!='CPU',role=-1 if kind=='inactive' else 4,guard=kind if kind in ['shootout','penalty'] else None,buddy=kind=='human-buddy-classic',ccr=ccr))
 ss=cases+guards
 def trial(item):
  profile,batch=item;sub=ss[batch*24:batch*24+24];p=o/f'cone-{profile}-{batch:02}';p.mkdir(exist_ok=True);body=[]
  for j,z in enumerate(sub):
   body += [f' move.w #{z["cone"]},0xfffff0fe',' clr.w 0xfffff0fc',f' move.w #{z["aim"]},0xffffbedc',' clr.w 0xffffc2f2',' clr.w 0xffffc2fa',f' move.b #{8 if z["human"] else 0},0xffffb1ac',f' move.w #{z["role"]},0xffffb17e',f' move.b #{8 if z["buddy"] else 0},0xffffb0ac',f' move.w #{z["dx"]},%d0',f' move.w #{z["dy"]},%d1',' jsr 0x10676',' move.w %d0,0xffffef70']
   if z['guard']:body += [f' move.b #{1 if z["guard"]=="shootout" else 4},0x{0xffffc2fa if z["guard"]=="shootout" else 0xffffc2f2:x}']
   for i in range(8):body += [f' move.l #0x{0x12340000+i*0x101:x},%d{i}']
   for i in range(7):body += [f' movea.l #0x{0xffff1800+256*i:x},%a{i}']
   body += [' move.w 0xffffef70,%d0',f' move.w #{z["dx"]},%d1',f' move.w #{z["dy"]},%d2',' lea 0xffffb04a,%a1',' lea 0xffffb14a,%a3',f' move.w #{0x2700|z["ccr"]},%sr',' jsr 0xbd2a']+snap(0xffff4000+68*j)
  raw=run(p,profile,'\n'.join(body),68*len(sub),[(0xbd40,bytes.fromhex('4e75'))]);return profile,batch,[raw[68*j:68*j+66] for j in range(len(sub))]
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,[(v,b) for v in ['baseline','current'] for b in range((len(ss)+23)//24)]))
 lookup={(v,b):r for v,b,r in rows};report=[]
 for i,z in enumerate(ss):
  b,j=divmod(i,24);old=lookup['baseline',b][j];new=lookup['current',b][j];n=decode(new);base=decode(old)
  assert n['regs']==base['regs'] and n['sp']==base['sp']==0xfffff900,('cone reg/stack contract',z,n,base)
  if i>=len(cases):assert new==old,('native Default/guard ABI',z,n,base)
  else:
   ux,uy=vectors[z['aim']];angle=abs(math.degrees(math.atan2(z['dx']*uy-z['dy']*ux,z['dx']*ux+z['dy']*uy)));expected=angle<=[15,30,45][z['cone']-1]+1e-9;accepted=bool(n['sr']&4) or bool(n['sr']&1);assert accepted==expected,('continuous-angle boundary',z,angle,n,expected);assert n['sr']&0xfff0==base['sr']&0xfff0,('upper SR/X changed',z,n,base)
  report.append(dict(spec=z,original_sr=base['sr'],current_sr=n['sr']))
 print('PASS',len(cases),'native continuous-angle contexts;',len(guards),'paired unchanged Default/guard ABI contexts',flush=True);return dict(geometric_contexts=len(cases),unchanged_native_ABI_contexts=len(guards),cases=report)

def passes_suite():
 cases=[dict(team=team,aim=aim,mode=mode,height=height,speed=speed,human=human,keeper=keeper,cone=0) for team in [0,1] for aim in range(8) for mode in [0,1] for height,speed in [(0,0),(0,1),(1,0),(1,1)] for human,keeper in [(True,False),(False,False),(True,True),(False,True)]]
 # Start at full native dopass; only skip the final animation/audio after all
 # native target/cursor/intercept/free-vector outputs have actually been produced.
 def trial(item):
  profile,batch=item;ss=cases[batch*8:batch*8+8];q=o/f'passes-{profile}-{batch:03}';q.mkdir(exist_ok=True);body=[]
  for j,z in enumerate(ss):
   base=6*z['team'];idx=base+(5 if z['keeper'] else 4);ob=OBJ+128*idx;ux,uy=vectors[z['aim']];dest=0xffff4000+100*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{OBJ+128*i+0x34:x}',f' move.w #{i},0x{OBJ+128*i+0x52:x}',f' move.b #{64 if i>=6 else 0},0x{OBJ+128*i+0x62:x}',f' move.w #11,0x{OBJ+128*i+6:x}']
   for k,d in [(0,70),(1,150)]:
    b=OBJ+128*(base+k);body += [f' move.w #4,0x{b+0x34:x}',f' move.w #{10+d*ux},0x{b:x}',f' move.w #{-100+d*uy},0x{b+0x14:x}']
   body += [f' lea 0x{ob:x},%a3',f' lea 0x{0xffffc6ce if z["team"]==0 else 0xffffca32:x},%a2',f' move.w #{0 if z["keeper"] else 4},0x34(%a3)',f' move.b #{(64 if z["team"] else 0)|(8 if z["human"] else 0)},0x62(%a3)',' move.b #20,0x6e(%a3)',' move.w #10,(%a3)',' move.w #-100,0x14(%a3)',' move.w #4000,0x28(%a3)',' move.w #-3000,0x2a(%a3)',' move.w #10,0xffffb74a',' move.w #-100,0xffffb75e',f' move.w #{idx},0xffffb7aa',' move.w #3,0xffffbee0',f' move.w #{z["aim"]},0xffffbedc',f' move.w #{z["mode"]},0xfffff0fc',f' move.w #{z["cone"]},0xfffff0fe',f' move.w #{z["height"]},0xfffff0dc',f' move.w #{z["speed"]},0xfffff0e8',' clr.l 0xfffff058',' clr.l 0xfffff05c',' clr.l 0xfffff064',' clr.w 0xfffff736',' clr.w 0xffffc2ea',' clr.w 0xffffc2ec',' clr.w 0xffffc2f2',' clr.w 0xffffc2f6',' clr.w 0xffffc2fa',' clr.w 0xffffd046',' clr.l 0xffffc320',' clr.l 0xffffc324',' clr.l 0xffffc328',' clr.l 0xffffc32c',f' move.w #{idx},0xffffc320',f' move.w #{z["team"]+1 if z["human"] else 0},0xffffc328',' move.l #0x12345678,0xffffd066',' moveq #0,%d0',' moveq #0,%d1',f' moveq #{16 if z["human"] else 0},%d2',' moveq #0,%d3',' moveq #0,%d4',' moveq #0,%d5',f' jsr 0x{0xbc40 if z["human"] else 0xbc64:x}']
   for k,addr in enumerate([0xb772,0xb774,0xb776,0xbee0,0xbeda,0xb7aa,0xc320,0xc322]):body += [f' move.w 0x{0xffff0000+addr:x},0x{dest+2*k:x}']
   body += [f' move.l 0xffffd066,0x{dest+16:x}',f' move.w 0xffffbede,0x{dest+20:x}',f' move.w 0xffffb7a8,0x{dest+22:x}',f' move.l %sp,0x{dest+24:x}']+snap(dest+28)
  raw=run(q,profile,'\n'.join(body),100*len(ss),[(0xbdd8,bytes.fromhex('4ef90000be20'))]);return profile,batch,[raw[j*100:(j+1)*100] for j in range(len(ss))]
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,[(v,b) for v in ['baseline','current'] for b in range((len(cases)+7)//8)]))
 lookup={(v,b):rows for v,b,rows in rows};report=[];native=0;directional=0
 for i,z in enumerate(cases):
  b,j=divmod(i,8);old=lookup['baseline',b][j];new=lookup['current',b][j];vals=struct.unpack_from('>8hI2hI',new);vx,vy,vz,target,passer,owner,ctrl,other,rng,power,timer,sp=vals
  assert sp==0xfffff900 and decode(new[28:94])['sp']==0xfffff900,('full dopass stack',z,vals)
  if z['mode']!=1 or not z['human']:
   assert new==old,('Assisted or CPU complete native output/register/RNG ABI',z,new.hex(),old.hex());native+=1
  else:
   uv=[(0,200),(141,141),(200,0),(141,-141),(0,-200),(-141,-141),(-200,0),(-141,141)][z['aim']];expected=[math.trunc(v*power*1024/3000) for v in uv]
   if z['speed']==1:expected=[math.trunc(v*4/5) for v in expected]
   idx=6*z['team']+(5 if z['keeper'] else 4);assert [vx,vy]==expected and vz==(0x300 if z['height']==1 else 0x600),('actual full native directional power/aim/lift',z,vals,expected);assert target==-1 and passer==idx and ctrl==idx and rng==0x12345678,('directional receiver/cursor/RNG isolation',z,vals);directional+=1
  report.append(dict(spec=z,velocity=[vx,vy,vz],target=target,passer=passer,owner=owner,control=ctrl,rng=rng,power=power,stack=sp))
 print('PASS',len(cases),'full native dopass contexts;',native,'unchanged complete native ABI;',directional,'untargeted human directional vectors',flush=True);return dict(contexts=len(cases),unchanged_native_contexts=native,human_directional_contexts=directional,cases=report)

def dispatch_suite():
 # Observe native/custom endpoints, preserving their incoming complete ABI.
 # These stubs qualify dispatch only; passes_suite executes actual pass physics.
 b=a.rom.read_bytes();assert b[0x2e6808:0x2e680a]==bytes.fromhex('6000');custom=0x2e680a+int.from_bytes(b[0x2e680a:0x2e680c],'big',signed=True)
 cases=[dict(d0=d0,release=release,special=special,kind=kind,mode=mode) for d0 in [0,8] for release in [False,True] for special in [False,True] for kind in ['human','CPU','inactive','keeper','penalty','shootout'] for mode in [0,1,99]]
 cases += [dict(d0=5,release=True,special=False,kind='human',mode=mode)for mode in(0,1)]
 def trial(item):
  profile,batch=item;ss=cases[batch*12:batch*12+12];p=o/f'dispatch-{profile}-{batch:03}';p.mkdir(exist_ok=True);body=[]
  for j,z in enumerate(ss):
   at=0xffff4000+80*j;body+=[' clr.w 0xffffef70',' move.w #6,0xffffbedc',' move.w #3,0xffffbee0',f' move.w #{z["mode"]},0xfffff0fc',' clr.w 0xfffff0fe',f' move.b #{8 if z["special"] else 0},0xffffc2f6',f' move.b #{8 if z["kind"]!="CPU" else 0},0xffffb2ac',f' move.w #{-1 if z["kind"]=="inactive" else 0 if z["kind"]=="keeper" else 4},0xffffb27e',f' move.w #{0x400 if z["kind"]=="penalty" else 0},0xffffc2f2',f' move.w #{0x100 if z["kind"]=="shootout" else 0},0xffffc2fa']
   for k in range(8):body += [f' move.l #0x{0x11223300+k:x},%d{k}']
   for k in range(7):body += [f' movea.l #0x{0xffff1800+k*256:x},%a{k}']
   body += [' lea 0xffffb24a,%a3',f' move.w #{z["d0"]},%d0',f' move.w #{16 if z["release"] else 0},%d2',' move.w #0x271f,%sr',' jsr 0xbc40']+snap(at)+[f' move.w 0xffffef70,0x{at+66:x}',f' move.w 0xffffbedc,0x{at+68:x}',f' move.w 0xffffbee0,0x{at+70:x}']
  stub=lambda n:bytes.fromhex('40e731fc')+n.to_bytes(2,'big')+bytes.fromhex('ef7046df4e75')
  patches=[(0xbc64,stub(1))]+([(custom,stub(2))] if profile=='current' else [])
  raw=run(p,profile,'\n'.join(body),80*len(ss),patches);return profile,batch,[raw[j*80:(j+1)*80] for j in range(len(ss))]
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,[(v,i) for v in ['baseline','current'] for i in range((len(cases)+11)//12)]))
 lookup={(v,i):r for v,i,r in rows};report=[];custom_count=0
 for i,z in enumerate(cases):
  batch,j=divmod(i,12);old=lookup['baseline',batch][j];new=lookup['current',batch][j];emit=z['release'] or z['special'] or not(z['d0']&8);is_custom=emit and z['kind'] in ['human','keeper'] and z['mode']==1 and not z['special'];expected=2 if is_custom else 1 if emit else 0
  assert int.from_bytes(new[66:68],'big')==expected,('wrong B dispatcher endpoint',z,new.hex(),expected)
  assert new[:66]==old[:66] and new[68:]==old[68:],('B dispatcher changed native gate registers/CCR/SP/aim/target',z,new.hex(),old.hex())
  custom_count+=is_custom;report.append(dict(spec=z,endpoint=expected,unchanged_native_gate_ABI=True))
 print('PASS',len(cases),'paired actual B-input dispatch/gate/guard ABI contexts;',custom_count,'ordinary human Directional endpoints',flush=True);return dict(contexts=len(cases),human_directional_endpoints=custom_count,scope='Endpoint observations only; real full pass physics qualified separately.',cases=report)

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 menu_suite():
 p=o/'renderer';p.mkdir(exist_ok=True);c=asm(p,render_probe(""" .text
 .globl _start
_start:
 cmpi.w #30,%d7
 bne.s cone
 move.w %sr,-(%sp)
 addq.w #1,0xfffff080
 move.l %a1,0xfffff084
 move.w 0xffffb028,0xfffff088
 move.w (%sp)+,%sr
 bra.s original
cone:
 cmpi.w #32,%d7
 bne.s original
 move.w %sr,-(%sp)
 addq.w #1,0xfffff090
 move.l %a1,0xfffff094
 move.w 0xffffb028,0xfffff098
 move.w (%sp)+,%sr
original: jmp 0x11ba4
""",a.rom,[30, 32]),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=o/'menu-observed.bin';observed.write_bytes(b);reports=[]
 def menu(name,raw,commands,checks,dev=513,render=False,finish=False):
  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/pass_mode_menu_hook.py')));reports.append(dict(name=name,observations=json.loads((p/'results.json').read_text())));return(p/'battery.srm').read_bytes()
 # Exact season row32 takes16Down. Native New Playoffs (D048=2) skips
 # row6 at F77AE, so15Down reaches row32; read-only native cursor verified.
 down=[f'{900+60*i} press 1 Down 5' for i in range(down_count(a.rom,16))]
 wanted=[dict(height=1,speed=0,mode=1,cone=1),dict(height=0,speed=1,mode=0,cone=2),dict(height=1,speed=1,mode=1,cone=3)]
 for dev in [513,1281]:
  menu('fresh-'+str(dev),bytes(65536),down+['2200 snap','2301 exit'],{2200:dict(mode=0,cone=0,height=0,speed=0)},dev,True)
  raw=menu('choices-'+str(dev),battery(0,((0,0,0,0),(0,0,0,0),(0,0,0,0))),down+['2300 press 1 Right 5','2450 press 1 Right 5','2600 press 1 Right 5','2750 press 1 Up 5','2900 press 1 Right 5','3050 snap','3100 press 1 Left 5','3250 press 1 Right 5','3401 exit'],{2200:dict(mode=0,cone=0,record='50af'),2400:dict(mode=0,cone=1,record='8877'),2550:dict(mode=0,cone=2,record='906f'),2700:dict(mode=0,cone=3,record='9867'),3000:dict(mode=1,cone=3,record='9c63'),3200:dict(mode=0,cone=3,record='9867'),3350:dict(mode=1,cone=3,record='9c63')},dev,True)
  menu('choices-reboot-'+str(dev),raw,['1301 exit'],{1200:dict(mode=1,cone=3,record='9c63')},dev)
  raw=menu('slots-'+str(dev),battery(),['1100 press 1 Right 5','1300 press 1 Right 5','1500 press 1 Left 5','1700 press 1 Left 5','1901 exit'],{1000:dict(wanted[0],slot=1),1200:dict(wanted[1],slot=2),1400:dict(wanted[2],slot=3),1600:dict(wanted[1],slot=2),1800:dict(wanted[0],slot=1)},dev)
  menu('slots-reboot-'+str(dev),raw,['1301 exit'],{1200:dict(wanted[0],slot=1)},dev)
  menu('locked-'+str(dev),battery(1),down+['2300 press 1 Left 5','2500 press 1 Up 5','2700 press 1 Left 5','2900 press 1 Right 5','3101 exit'],{2200:dict(wanted[0],gp=1),2400:dict(wanted[0],gp=1),2800:dict(wanted[0],gp=1),3000:dict(wanted[0],gp=1)},dev,True)
  for bad in ['0000','8c72','e01f','5ca3']:
   menu('corrupt-'+bad+'-'+str(dev),battery(1,bad=bad),['1100 press 1 Right 5','1300 press 1 Left 5','1501 exit'],{1000:dict(mode=0,cone=0,height=int(refinements_enabled(a.rom)),speed=0),1200:dict(wanted[1],slot=2),1400:dict(mode=0,cone=0,height=int(refinements_enabled(a.rom)),speed=0)},dev)
  # Native global record is in the shared Light gap and must neither overwrite
  # Full goalie data nor inherit any played-season lock.
  raw=battery(1);b=bytearray(raw[1::2]);b[0xe20:0xe25]=b[0x2e20:0x2e25]=bytes.fromhex('123456789a');raw=pack(b)
  nav=[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(down_count(a.rom,15))]
  raw=menu('native-'+str(dev),raw,nav+['2900 press 1 Right 5','3100 press 1 Up 5','3300 press 1 Left 5','3501 exit'],{1600:dict(mode=1,cone=2,height=0,speed=1,view=0,full_goalie='123456789a'),2850:dict(mode=1,cone=2,view=0),3000:dict(mode=1,cone=3,global_record='9e61',full_goalie='123456789a'),3400:dict(mode=0,cone=3,global_record='9a65',full_goalie='123456789a')},dev)
  menu('native-reboot-'+str(dev),raw,[f'{1000+200*i} press 1 Right 5' for i in range(3)]+['1701 exit'],{1600:dict(mode=0,cone=3,height=0,speed=1,view=0,global_record='9a65',full_goalie='123456789a')},dev)
  # Existing proven restart dialog sequence: reset Light only, then verify Full/Mini.
  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),battery(1),lines,{3100:dict(wanted[1],slot=2,gp=1),3900:dict(mode=0,cone=0,height=0,speed=0,slot=2,gp=0),4300:dict(wanted[0],slot=1),4600:dict(mode=0,cone=0,height=0,speed=0,slot=2),4900:dict(wanted[2],slot=3)},dev)
  raw=menu('first-game-'+str(dev),battery(0),['1500 press 1 Start 5','1900 press 1 Start 5','16001 exit'],{1200:wanted[0],3300:wanted[0]},dev,False,dict(wanted[0],record='8d72',gp=1))
  menu('first-game-locked-'+str(dev),raw,down+['2300 press 1 Left 5','2500 press 1 Up 5','2700 press 1 Left 5','2901 exit'],{2200:dict(wanted[0],gp=1),2400:dict(wanted[0],gp=1),2800:dict(wanted[0],gp=1)},dev,True)
 print('PASS',len(reports),'native menu/render/slot/reboot/lock/restart/global/storage/real-completion cases',flush=True);return dict(menu_cases=reports)

def records_suite():
 choices=[(height,speed,mode,cone) for height in [0,1] for speed in [0,1] for mode in [0,1] for cone in range(4)];cases=[dict(view=view,choices=c) for view in [0,1] for c in choices]
 for view in [0,1]:
  for raw in ['0000','8c72','e01f','5ca3','50af','51ae','52ad','53ac','807f','9f60']:cases.append(dict(view=view,raw=raw))
 def trial(item):
  batch=item;ss=cases[batch*8:batch*8+8];p=o/f'record-{batch:02}';p.mkdir(exist_ok=True);body=[]
  for j,z in enumerate(ss):
   rec=0x203ddd if z['view'] else 0x209a41;dest=0xffff4000+j*100;body += [f' move.w #{z["view"]},0xfffff06c',' move.b #1,0xa130f1',f' move.b #0xa5,0x{rec-2:x}',f' move.b #0x5a,0x{rec+4 if z['view'] else rec+508:x}']
   if 'choices' in z:
    for address,value in zip([0xfffff0dc,0xfffff0e8,0xfffff0fc,0xfffff0fe],z['choices']):body += [f' move.w #{value},0x{address:x}']
    body += [f' lea 0x{rec:x},%a0',' jsr 0x2e6814']
   else:
    raw=bytes.fromhex(z['raw']);body += [f' move.b #{raw[0]},0x{rec:x}',f' move.b #{raw[1]},0x{rec+2:x}']
   body += [' clr.w 0xfffff0dc',' clr.w 0xfffff0e8',' clr.l 0xfffff0fc']
   for k in range(8):body += [f' move.l #0x{0x11223300+k:x},%d{k}']
   for k in range(7):body += [f' movea.l #0x{0xffff1800+k*256:x},%a{k}']
   body += [' move.w #0x271f,%sr',' jsr 0x2e6810']+snap(dest+16)
   for k,address in enumerate([0xfffff0dc,0xfffff0e8,0xfffff0fc,0xfffff0fe]):body += [f' move.w 0x{address:x},0x{dest+2*k:x}']
   for k,address in enumerate([rec-2,rec,rec+2,rec+4 if z['view'] else rec+508]):body += [f' move.b 0x{address:x},0x{dest+8+k:x}']
   body += [' move.b #0,0xa130f1']
  raw=run(p,'current','\n'.join(body),len(ss)*100);return batch,[raw[j*100:(j+1)*100] for j in range(len(ss))]
 with ThreadPoolExecutor(max_workers=3) as pool:r=dict(pool.map(trial,range((len(cases)+7)//8)))
 report=[]
 for i,z in enumerate(cases):
  b,j=divmod(i,8);raw=r[b][j];values=struct.unpack_from('>4H',raw);context=decode(raw[16:82]);assert context['regs']==[0x11223300+k for k in range(8)]+[0xffff1800+k*256 for k in range(7)] and context['sr']==0x271f and context['sp']==0xfffff900,('mapped load full ABI',z,context);assert raw[8]==0xa5 and raw[11]==0x5a,('record overflow into snapshot/global bytes',z,raw.hex())
  if 'choices' in z:expected=z['choices'];assert raw[9:11]==encode(*expected),('canonical combined encoding',z,raw[9:11].hex())
  else:
   tag,inv=bytes.fromhex(z['raw']);valid=(tag^inv)==255 and (tag&0xe0==0x80 or tag&0xfc==0x50);expected=(tag&1,(tag>>1)&1,(tag>>2)&1 if tag&0xe0==0x80 else 0,(tag>>3)&3 if tag&0xe0==0x80 else 0) if valid else (int(refinements_enabled(a.rom)),0,0,0)
  assert tuple(values)==tuple(expected),('all four loaded choices/legacy/corrupt defaults',z,values,expected);report.append(dict(spec=z,loaded=values,record=raw[9:11].hex()))
 # Real native setting writers must preserve the three companion values while
 # respecting both season lock and separate native-mode preferences.
 writers=[dict(view=view,row=row,gp=gp,state=state) for view in [0,1] for row in [26,28,30,32] for gp,state in [(0,1),(1,1),(1,3),(1,4)]];wreport=[]
 for batch in range((len(writers)+7)//8):
  ss=writers[batch*8:batch*8+8];p=o/f'writer-{batch:02}';p.mkdir(exist_ok=True);body=[]
  for j,z in enumerate(ss):
   rec=0x203ddd if z['view'] else 0x209a41;dest=0xffff4000+100*j
   body += [f' move.w #{z["view"]},0xfffff06c',f' move.w #{z["gp"]},0xffffe3e8',f' move.w #{z["state"]},0xffffe3f0',' move.w #1,0xfffff0dc',' clr.w 0xfffff0e8',' move.w #1,0xfffff0fc',' move.w #1,0xfffff0fe',' move.b #1,0xa130f1',f' lea 0x{rec:x},%a0',' jsr 0x2e6814',' move.b #0,0xa130f1']
   for k in range(8):body += [f' move.l #0x{0x11223300+k:x},%d{k}']
   for k in range(7):body += [f' movea.l #0x{0xffff1800+k*256:x},%a{k}']
   selected={26:0,28:1,30:0,32:3}[z['row']];body += [f' move.w #{selected},%d3',f' move.w #{row_word(a.rom,z["row"])},%d7',' move.w #0x271f,%sr',f' jsr 0x{0x2de40c if z["row"]==26 else 0x2de604 if z["row"]==28 else 0x2e680c:x}']+snap(dest+16)
   for k,address in enumerate([0xfffff0dc,0xfffff0e8,0xfffff0fc,0xfffff0fe]):body += [f' move.w 0x{address:x},0x{dest+2*k:x}']
   body += [' move.b #1,0xa130f1',f' move.b 0x{rec:x},0x{dest+8:x}',f' move.b 0x{rec+2:x},0x{dest+9:x}',' move.b #0,0xa130f1']
  raw=run(p,'current','\n'.join(body),100*len(ss))
  for j,z in enumerate(ss):
   data=raw[j*100:(j+1)*100];values=struct.unpack_from('>4H',data);expected=[1,0,1,1];locked=z['view']==1 and z['gp']>0
   if not locked:expected[[26,28,30,32].index(z['row'])]={26:0,28:1,30:0,32:3}[z['row']]
   assert tuple(values)==tuple(expected) and data[8:10]==encode(*expected),('writer clobbered companion setting or lock',z,values,expected,data[8:10].hex());context=decode(data[16:82]);regs=[0x11223300+k for k in range(8)]+[0xffff1800+k*256 for k in range(7)];regs[3]=(regs[3]&0xffff0000)|{26:0,28:1,30:0,32:3}[z['row']];regs[7]=(regs[7]&0xffff0000)|row_word(a.rom,z['row']);assert context['regs']==regs and context['sp']==0xfffff900,('writer register/SP contract',z,context,regs);wreport.append(dict(spec=z,loaded=values,record=data[8:10].hex(),locked=locked))
 print('PASS',len(cases),'combined-record/legacy/corrupt/full-load-ABI contexts;',len(writers),'native four-setting writer/lock/companion contexts',flush=True);return dict(record_contexts=len(cases),writer_contexts=len(writers),records=report,writers=wreport)

def scans_suite():
 cases=[dict(team=team,keeper=keeper,human=human,mode=mode,cone=cone,near=near,far=far) for team in [0,1] for keeper in [False,True] for human in [False,True] for mode in [0] for cone in range(4) for near in [(20,55),(40,48),(52,30)] for far in [False,True]]
 def trial(item):
  profile,batch=item;ss=cases[batch*8:batch*8+8];p=o/f'scan-{profile}-{batch:03}';p.mkdir(exist_ok=True);body=[]
  for j,z in enumerate(ss):
   base=6*z['team'];idx=base+(5 if z['keeper'] else 4);dest=0xffff4000+j*84;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{OBJ+128*i+0x34:x}',f' move.w #{i},0x{OBJ+128*i+0x52:x}']
   ob=OBJ+128*idx;near=OBJ+128*base;far=near+128
   body += [f' lea 0x{ob:x},%a3',f' move.w #{0 if z["keeper"] else 4},0x34(%a3)',f' move.b #{8 if z["human"] else 0},0x62(%a3)',f' move.w #4,0x{near+0x34:x}',f' move.w #{z["near"][0]},0x{near:x}',f' move.w #{z["near"][1]},0x{near+0x14:x}',f' move.b #8,0x{near+0x62:x}']
   if z['far']:body += [f' move.w #4,0x{far+0x34:x}',f' move.w #200,0x{far+0x14:x}']
   body += [' clr.w 0xffffb74a',' clr.w 0xffffb75e',' clr.w 0xffffbedc',' clr.w 0xffffc2f2',' clr.w 0xffffc2fa',' clr.l 0xfffff058',' clr.l 0xfffff05c',f' move.w #{z["mode"]},0xfffff0fc',f' move.w #{z["cone"]},0xfffff0fe',' move.w #3,0xffffbee0',' moveq #-1,%d4',' movea.l #0x1234,%a0',' jsr 0x2fdc04']+snap(dest)
   body += [f' move.w 0xffffef70,0x{dest+66:x}',f' move.w 0xffffbee0,0x{dest+68:x}']
  raw=run(p,profile,'\n'.join(body),84*len(ss),[(0xbd64,bytes.fromhex('31fc0001ef704e75')),(0xbd6c,bytes.fromhex('31fc0000ef704e75'))]);return profile,batch,[raw[j*84:(j+1)*84] for j in range(len(ss))]
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,[(v,b) for v in ['baseline','current'] for b in range((len(cases)+7)//8)]))
 lookup={(v,b):r for v,b,r in rows};report=[];guard=directional=geometry=0
 for i,z in enumerate(cases):
  b,j=divmod(i,8);old=lookup['baseline',b][j];new=lookup['current',b][j];ctx=decode(new[:66]);targeted=bool(int.from_bytes(new[66:68],'big'));target=ctx['regs'][8];base=6*z['team'];assert ctx['sp']==0xfffff900,('full native scan stack',z,ctx)
  if not z['human'] or z['mode']==0 and z['cone']==0:
   assert new==old,('CPU/Default complete native scan ABI',z,new.hex(),old.hex());guard+=1
  elif z['mode']==1:
   assert not targeted and target==0x1234 and new[68:70]==b'\xff\xff',('Directional selected a receiver',z,ctx,new.hex());directional+=1
  else:
   angle=math.degrees(math.atan2(*z['near']));inside=angle<=[15,30,45][z['cone']-1];expected=OBJ+128*(base if inside else base+1) if inside or z['far'] else 0x1234;assert targeted==bool(inside or z['far']) and target==expected,('actual vector cone receiver scan/keeper fallback',z,angle,ctx,targeted,hex(expected));geometry+=1
  report.append(dict(spec=z,targeted=targeted,pointer=target,stack=ctx['sp']))
 print('PASS',len(cases),'actual native receiver scans;',guard,'unchanged CPU/Default;',directional,'untargeted Directional;',geometry,'explicit geometric cone selections/fallbacks',flush=True);return dict(contexts=len(cases),unchanged_native_contexts=guard,directional_contexts=directional,explicit_cone_contexts=geometry,cases=report)

def input_suite():
 p=o/'input-probes';p.mkdir(exist_ok=True);b=bytearray(a.rom.read_bytes());assert b[0x2e6808:0x2e680a]==bytes.fromhex('6000');custom=0x2e680a+int.from_bytes(b[0x2e680a:0x2e680c],'big',signed=True);c=asm(p,f' .equ CUSTOM_CONT,0x{custom+10:x}\n'+(h/'testcases/pass_mode_input_probe.s').read_text(),0x2d9000);from pass_input_observer_checks import verify as observer_abi
 observer_proof=observer_abi(a.rom,c,h,p/'observer-ABI');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
 assert b[0xbc64:0xbc6e]==bytes.fromhex('48e7fcc008b80002c2ec');b[0xbc64:0xbc6e]=bytes.fromhex('4ef9002d93004e714e71');assert b[custom:custom+10]==bytes.fromhex('48e7fcc008b80002c2ec');b[custom:custom+10]=bytes.fromhex('4ef9002d93504e714e71')
 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')
 observed=p/'rom.bin';observed.write_bytes(b);cases=[dict(count=1,adapter=False,mode=0,cone=2,height=0,speed=0,end=35000),dict(count=1,adapter=False,mode=1,cone=1,height=0,speed=0,end=35000),dict(count=2,adapter=False,mode=1,cone=3,height=1,speed=1,end=35000),dict(count=3,adapter=True,mode=1,cone=2,height=1,speed=0,end=35000),dict(count=4,adapter=True,mode=1,cone=3,height=0,speed=1,end=35000),dict(count=4,adapter=True,mode=0,cone=1,height=1,speed=1,end=35000)]
 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])
  (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/pass_mode_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)))
 human=[e for d in results for e in d['events'] if e['human'] and not e['special']];directional=[e for e in human if e['mode']==1];assert {e['aim'] for e in directional if e.get('aim_source')=='D-pad'}==set(range(8)),('real input did not exercise all eight actual directional aims',{e['aim'] for e in directional})
 for d in results:
  assert d['input_aim_mismatches']==0 and d['keeper_requests']>0,('actual D-pad aim or requested keeper coverage',d['spec'],d['input_aim_mismatches'],d['keeper_requests'])
  if d['spec']['mode']==1:assert all(any(e.get('input_aim_matches') is True and e.get('aim_source')=='D-pad' and e['pad']==pad for e in d['events']) for pad in range(d['spec']['count'])),('each physical pad needs a declared owning D-pad pass',d['spec'])
  if d['spec']['mode']==1:assert d['directional_passes_followed_by_possession']>0,('no actual post-Directional native possession',d['spec'])
  assert set(d['human_pads'])==set(range(d['spec']['count'])),('configured physical pads not all exercised',d['spec'],d['human_pads'])
 print('PASS',len(results),'cold-boot real-input games;',len(human),'human passes;',len(directional),'Directional; all physical pads/eight aims',flush=True);return dict(scope='Automated real controller input; only private observer counter initialized. No game outcomes or geometry injected. Not a physical human playtest.',cases=results,human_passes=len(human),human_directional_passes=len(directional),native_release_passes=sum(e.get('native_release',False)for e in directional),observer_ABI=observer_proof)

sections={}
if a.section in ['all','dispatch']:sections['dispatch']=dispatch_suite()
if a.section in ['all','cone']:sections['cone']=cone_suite()
if a.section in ['all','passes']:sections['passes']=passes_suite()
if a.section in ['all','menu']:sections['menu']=menu_suite()
if a.section in ['all','records']:sections['records']=records_suite()
if a.section in ['all','scans']:sections['scans']=scans_suite()
if a.section in ['all','input']:sections['input']=input_suite()
if a.section=='all':assert set(sections)=={'dispatch','cone','passes','scans','records','menu','input'}
(o/'results.json').write_text(json.dumps(dict(rom_sha256=sha(a.rom),baseline_sha256=sha(a.baseline),section=a.section,complete=a.section=='all',sections=sections),indent=2)+'\n')
