#!/usr/bin/env python3
"""Separate penalty frequency: real native event admission, RNG, fight and menu.
Reset fixtures declare inputs, not queue results. Audio sink alone is stubbed in
reset tests; native AddPenalty, queue flags and accounting execute.
"""
from pathlib import Path
from menu_protocol import row_word,down_count,render_probe,requested_menu_expectations
from concurrent.futures import ThreadPoolExecutor
import argparse,json,os,subprocess,struct,hashlib,math
ap=argparse.ArgumentParser();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','awards','distribution','fights','injury','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);sha=lambda p:hashlib.sha256(p.read_bytes()).hexdigest();assert sha(a.baseline)=='8172a7e4b3d55754076d4f31de2def263d423bad832b2057538fa3b5c1ca86c7'
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)]


ORDINARY=[0x12,*range(0x16,0x26,2)]
def next_rng(seed):
 value=(seed*0xbb40e62d+1)&0xffffffff
 return value,((value>>8)&65535)*2>>16

def awards_suite():
 cases=[dict(event=event,rate=rate,team=team,guard='open') for event in range(2,62,2) for rate in [0,1,2,99] for team in [0,1]]
 cases += [dict(event=event,rate=rate,team=team,guard=guard) for event in ORDINARY for rate in [0,1,2] for team in [0,1] for guard in ['native-block','duplicate','full','paused','stopped','rules-off']]
 for i,z in enumerate(cases):z['rng']=(0x12345678^(i*0x171719))&0xffffffff
 def trial(item):
  profile,batch=item;ss=cases[batch*12:batch*12+12];p=o/f'awards-{profile}-{batch:03}';p.mkdir(exist_ok=True);body=[]
  for j,z in enumerate(ss):
   ob=OBJ+128*(6*z['team']+2);at=0xffff4000+160*j
   body += [' lea 0xffffb000,%a0',' move.w #0x1600/4-1,%d0',f'clear{j}: clr.l (%a0)+',f' dbra %d0,clear{j}',f' move.w #{z["rate"]},0xfffff006',f' move.w #{0 if z["guard"]=="rules-off" else 2},0xffffd056',f' move.b #{1 if z["guard"]=="stopped" else 0},0xffffc2ea',f' move.b #{128 if z["guard"]=="paused" else 0},0xffffc2ec',f' move.w #4,0x{ob+0x34:x}',f' move.w #{6*z["team"]+2},0x{ob+0x52:x}',f' move.b #{64 if z["team"] else 0},0x{ob+0x62:x}',f' move.b #{4 if z["guard"]=="native-block" else 16 if z["guard"]=="duplicate" else 0},0x{ob+0x63:x}',f' move.l #0x{z["rng"]:x},0xffffd066']
   if z['guard']=='full':
    body += [' lea 0xffffc3a4,%a0',' moveq #31,%d0',f'full{j}: move.w #0xc01,(%a0)+',f' dbra %d0,full{j}']
   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 += [f' move.w #{z["event"]},%d0',f' lea 0x{ob:x},%a3',' move.w #0x271f,%sr',' jsr 0x11f2c']+snap(at)
   body += [' lea 0xffffc3a4,%a0',f' lea 0x{at+66:x},%a1',' moveq #15,%d0',f'queue{j}: move.l (%a0)+,(%a1)+',f' dbra %d0,queue{j}',f' move.w 0xffffc300,0x{at+130:x}',f' move.w 0x{ob+0x62:x},0x{at+132:x}',f' move.w 0xffffb89c,0x{at+134:x}',f' move.l 0xffffd066,0x{at+136:x}']
  raw=run(p,profile,'\n'.join(body),len(ss)*160,[(0x1a294,bytes.fromhex('4e75'))]);return profile,batch,[raw[j*160:(j+1)*160] 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)+11)//12)]))
 lookup={(v,b):r for v,b,r in rows};report=[];native=filtered=injuries=draws=0
 for i,z in enumerate(cases):
  batch,j=divmod(i,12);old=lookup['baseline',batch][j];new=lookup['current',batch][j];context=decode(new[:66]);assert context['sp']==0xfffff900,('unbalanced native event frame',z,context)
  eligible=z['event'] in ORDINARY and z['guard']=='open' and z['rate'] in [1,2];rng,roll=next_rng(z['rng']);draw=eligible and z['rate']==1;reject=eligible and (z['rate']==2 or roll==1);expected_rng=rng if draw else z['rng'];actual_rng=int.from_bytes(new[136:140],'big');assert actual_rng==expected_rng,('wrong event RNG consumption',z,actual_rng,expected_rng)
  events=[int.from_bytes(new[k:k+2],'big')>>8 for k in range(66,130,2) if new[k:k+2]!=b'\x00\x00'];old_events=[int.from_bytes(old[k:k+2],'big')>>8 for k in range(66,130,2) if old[k:k+2]!=b'\x00\x00']
  if not eligible:assert new==old,('native default/invalid/zero-duration/PS/fight/guard ABI changed',z,new.hex(),old.hex());native+=1
  elif not reject:
   assert new[:136]==old[:136],('admitted half-rate event differs from native output/ABI',z,new.hex(),old.hex())
  elif z['event']==0x12:
   assert events==[0x14] and new[130:134]==b'\x00\x00'+bytes([64 if z['team'] else 0,0]),('injury stoppage did not become native no-penalty report14',z,events,new[130:136].hex());assert int.from_bytes(new[134:136],'big')==200;injuries+=1
  else:
   assert events==[] and new[130:136]==bytes(6)[:2]+bytes([64 if z['team'] else 0,0])+bytes(2),('suppressed ordinary call left queue/delayed flag/stat residue',z,events,new[130:136].hex());filtered+=1
  if reject:
   oldctx=decode(old[:66]);assert context['regs']==oldctx['regs'] if z['event']!=0x12 else context['regs'][1:]==oldctx['regs'][1:] and context['regs'][0]&65535==0x14,('suppression register contract',z,context,oldctx)
  draws+=draw;report.append(dict(spec=z,events=events,baseline_events=old_events,draw=draw,rejected=reject,rng=actual_rng))
 print('PASS',len(cases),'native event/queue/flags/ABI contexts;',native,'unchanged;',draws,'single RNG draws;',filtered,'fouls suppressed;',injuries,'native injury-only reports',flush=True);return dict(contexts=len(cases),unchanged_native=native,draws=draws,suppressed=filtered,injury_reports=injuries,cases=report)

def injury_suite():
 cases=[dict(rate=rate,team=team,keeper=keeper,seed=seed) for rate in [0,1,2,99] for team in [0,1] for keeper in [False,True] for seed in [1,0x12345678,0xdeadbeef]]
 def trial(item):
  profile,batch=item;ss=cases[batch*8:batch*8+8];p=o/f'injury-{profile}-{batch:02}';p.mkdir(exist_ok=True);body=[]
  for j,z in enumerate(ss):
   at=0xffff4000+160*j;ob=OBJ+128*(6*z['team']+2);nextseed,roll=next_rng(z['seed']);reject=not z['keeper'] and (z['rate']==2 or z['rate']==1 and roll);rules=0 if profile=='baseline' and reject else 2
   body += [' lea 0xffffb000,%a0',' move.w #0x1600/4-1,%d0',f'clear{j}: clr.l (%a0)+',f' dbra %d0,clear{j}',f' move.w #{z["rate"]},0xfffff006',f' move.w #{rules},0xffffd056',f' move.l #0x{z["seed"]:x},0xffffd066',f' move.w #{0 if z["keeper"] else 4},0x{ob+0x34:x}',f' move.w #{6*z["team"]+2},0x{ob+0x52:x}',f' move.b #{64 if z["team"] else 0},0x{ob+0x62:x}']
   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 += [f' lea 0x{ob:x},%a3',' move.w #0x271f,%sr',' jsr 0x1446a']+snap(at)+[' lea 0xffffc3a4,%a0',f' lea 0x{at+66:x},%a1',' moveq #15,%d0',f'queue{j}: move.l (%a0)+,(%a1)+',f' dbra %d0,queue{j}',f' move.w 0xffffc3ec,0x{at+130:x}',f' move.w 0xffffc300,0x{at+132:x}',f' move.w 0x{ob+0x62:x},0x{at+134:x}',f' move.l 0xffffd066,0x{at+136:x}']
  raw=run(p,profile,'\n'.join(body),160*len(ss),[(0x1a294,bytes.fromhex('4e75')),(0x124a6,bytes.fromhex('4e75'))]);return profile,batch,[raw[j*160:(j+1)*160] 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=[]
 for i,z in enumerate(cases):
  b,j=divmod(i,8);old=lookup['baseline',b][j];new=lookup['current',b][j];assert new[:136]==old[:136] and decode(new[:66])['sp']==0xfffff900,('native injury caller no-penalty oracle/ABI/stoppage mismatch',z,new.hex(),old.hex());rng,roll=next_rng(z['seed']);expected=rng if z['rate']==1 and not z['keeper'] else z['seed'];assert int.from_bytes(new[136:140],'big')==expected;assert int.from_bytes(new[130:132],'big')==4,('injury stoppage type lost',z,new.hex());report.append(dict(spec=z,queued_event=int.from_bytes(new[66:68],'big')>>8,native_off_rules_oracle_exact=True))
 print('PASS',len(cases),'actual native injury caller/report/stoppage/end/team/keeper/ABI contexts against native Rules-Off oracle',flush=True);return dict(contexts=len(cases),scope='Actual1446A caller and native queue/report. Only final injury bench continuation124A6 and audio sink are stopped after the outputs under test.',cases=report)

def distribution_suite():
 cases=[dict(event=ev,seed=seed,team=team) for ev in ORDINARY for seed in [1,0x12345678,0xdeadbeef] for team in [0,1]];iterations=2000
 def trial(item):
  i,z=item;p=o/f'draws-{i:03}';p.mkdir(exist_ok=True);ob=OBJ+128*(6*z['team']+2);body=[' lea 0xffffb000,%a0',' move.w #0x1600/4-1,%d0','clear: clr.l (%a0)+',' dbra %d0,clear',' move.w #1,0xfffff006',' move.w #2,0xffffd056',f' lea 0x{ob:x},%a3',' move.w #4,0x34(%a3)',f' move.w #{6*z["team"]+2},0x52(%a3)',f' move.b #{64 if z["team"] else 0},0x62(%a3)',f' move.l #0x{z["seed"]:x},0xffffd066',' moveq #0,%d6',' moveq #0,%d5',f' move.w #{iterations-1},%d7','draw: clr.w 0xffffc3a4',' clr.w 0xffffc300',' clr.w 0xffffb89c',' clr.b 0x63(%a3)',f' move.w #{z["event"]},%d0',' jsr 0x11f2c',f' cmpi.b #{z["event"]},0xffffc3a4',' bne.s rejected',' addq.w #1,%d6','rejected: dbra %d7,draw',' move.w %d6,0xffff4000',' move.l 0xffffd066,0xffff4004',f' move.l #0x{z["seed"]:x},0xffffd066',f' move.w #{iterations-1},%d7','oracle: moveq #2,%d0',' jsr 0x11086',' tst.w %d0',' bne.s not_zero',' addq.w #1,%d5','not_zero: dbra %d7,oracle',' move.w %d5,0xffff4002',' move.l 0xffffd066,0xffff4008'];raw=run(p,'current','\n'.join(body),12,[(0x1a294,bytes.fromhex('4e75'))]);actual,expected,seed,control=struct.unpack('>HHII',raw);assert actual==expected and seed==control,('50% does not match evolving native RNG oracle',z,actual,expected,hex(seed),hex(control));assert 0.45*iterations<actual<0.55*iterations,('frequency far from requested half',z,actual);return dict(spec=z,draws=iterations,admitted=actual,original_native_rng_oracle=expected,final_seed=seed)
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,enumerate(cases)))
 print('PASS',len(rows),'advancing native RNG series;',len(rows)*iterations,'actual event draws with matching independent native RNG control',flush=True);return dict(series=len(rows),native_event_draws=len(rows)*iterations,iterations=iterations,results=rows)

def fights_suite():
 cases=[dict(rate=rate,team=team,seed=seed,ccr=ccr) for rate in [0,1,2,99] for team in [0,1] for seed in [1,0x12345678,0xdeadbeef] for ccr in [0,31]]
 def trial(item):
  profile,batch=item;ss=cases[batch*12:batch*12+12];p=o/f'fight-{profile}-{batch:02}';p.mkdir(exist_ok=True);body=[]
  for j,z in enumerate(ss):
   at=0xffff4000+80*j
   body += [f' move.w #{z["rate"]},0xfffff006',f' move.l #0x{z["seed"]:x},0xffffd066',' clr.w 0xffffef70']
   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 += [f' lea 0x{OBJ+128*(6*z["team"]+2):x},%a3',f' move.w #{0x2700+z["ccr"]},%sr',' jsr 0x2004f0']+snap(at)+[f' move.w 0xffffef70,0x{at+66:x}',f' move.l 0xffffd066,0x{at+68:x}']
  # Preserve endpoint CCR while observing whether an entire fight starts.
  yes=bytes.fromhex('40e731fc0001ef7046df4e75');no=bytes.fromhex('40e731fc0000ef7046df4e75');raw=run(p,profile,'\n'.join(body),80*len(ss),[(0x2004fa,yes),(0x20013e,no)]);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,b) for v in ['baseline','current'] for b in range((len(cases)+11)//12)]))
 lookup={(v,b):r for v,b,r in rows};results=[]
 for i,z in enumerate(cases):
  batch,j=divmod(i,12);old=lookup['baseline',batch][j];new=lookup['current',batch][j];seed,roll=next_rng(z['seed']);expected=not(z['rate']==2 or z['rate']==1 and roll);ctx=decode(new[:66]);started=int.from_bytes(new[66:68],'big');actualseed=int.from_bytes(new[68:72],'big');assert started==expected and actualseed==(seed if z['rate']==1 else z['seed']) and ctx['sp']==0xfffff900,('whole fight gate/RNG/stack',z,started,hex(actualseed),ctx)
  if z['rate'] in [0,99]:assert new==old,('100/invalid full fight commit ABI changed',z,new.hex(),old.hex())
  elif expected:assert new[:68]==old[:68],('admitted half-rate fight commit ABI changed',z)
  else:
   regs=[0x11223300+k for k in range(8)]+[0xffff1800+k*256 for k in range(7)];regs[11]=OBJ+128*(6*z['team']+2);assert ctx['regs']==regs and ctx['sr']==0x2700+z['ccr'],('rejected whole fight partially changed actor registers/flags',z,ctx,regs)
  results.append(dict(spec=z,entire_fight_started=bool(started),seed=actualseed))
 print('PASS',len(cases),'paired whole-fight commit/end/team/RNG/ABI contexts; native fight awards remain paired',flush=True);return dict(contexts=len(cases),scope='Whole-fight gate after original HV chance; endpoint observers qualify start/reject ABI. Fighting award IDs are separately native-exact in awards tests.',cases=results)

FIELDS=[0xfffff0dc,0xfffff0e8,0xfffff0fc,0xfffff0fe,0xfffff006]
def encode(height,speed,mode,cone,rate=0):
 value=height+2*speed+4*mode+8*cone+32*rate;value|=0x80 if mode or cone or rate else 0x50;return bytes([value,value^255])
def decoded_record(tag,inv):
 if (tag^inv)!=255:return (1,0,0,0,1)
 if tag&0xfc==0x50:return (tag&1,(tag>>1)&1,0,0,0)
 if not 0x80<=tag<0xe0:return (1,0,0,0,1)
 return (tag&1,(tag>>1)&1,(tag>>2)&1,(tag>>3)&3,(tag>>5)&3)
def storage_suite():
 cases=[dict(view=view,raw=bytes([tag,tag^mask]).hex()) for view in [0,1] for tag in range(256) for mask in [255,254]]
 cases += [dict(view=view,choices=(height,speed,mode,cone,rate)) for view in [0,1] for height in [0,1] for speed in [0,1] for mode in [0,1] for cone in range(4) for rate in range(3)]
 def trial(batch):
  ss=cases[batch*12:batch*12+12];p=o/f'storage-{batch:03}';p.mkdir(exist_ok=True);body=[]
  for j,z in enumerate(ss):
   at=0xffff4000+100*j;rec=0x203ddd if z['view'] else 0x209a41;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 addr,val in zip(FIELDS,z['choices']):body += [f' move.w #{val},0x{addr: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}']
   for addr in FIELDS:body += [f' move.w #99,0x{addr:x}']
   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(at+20)
   for k,addr in enumerate(FIELDS):body += [f' move.w 0x{addr:x},0x{at+2*k:x}']
   for k,addr in enumerate([rec-2,rec,rec+2,rec+4 if z['view'] else rec+508]):body += [f' move.b 0x{addr:x},0x{at+10+k:x}']
   body += [' move.b #0,0xa130f1']
  raw=run(p,'current','\n'.join(body),100*len(ss));return batch,[raw[j*100:(j+1)*100] for j in range(len(ss))]
 with ThreadPoolExecutor(max_workers=3) as pool:rows=dict(pool.map(trial,range((len(cases)+11)//12)))
 results=[]
 for i,z in enumerate(cases):
  batch,j=divmod(i,12);r=rows[batch][j];values=struct.unpack_from('>5H',r);expected=z['choices'] if 'choices' in z else decoded_record(*bytes.fromhex(z['raw']));ctx=decode(r[20:86]);assert tuple(values)==tuple(expected),('five-choice legacy/extended/corrupt load',z,values,expected);assert ctx['regs']==[0x11223300+k for k in range(8)]+[0xffff1800+k*256 for k in range(7)] and ctx['sr']==0x271f and ctx['sp']==0xfffff900,('mapped load ABI',z,ctx);assert r[10]==0xa5 and r[13]==0x5a,('record alias',z,r.hex())
  if 'choices' in z:assert r[11:13]==encode(*expected),('combined write encoding',z,r[11:13].hex(),encode(*expected).hex())
  results.append(dict(spec=z,loaded=values,record=r[11:13].hex()))
 writers=[dict(view=view,row=row,gp=gp,state=state) for view in [0,1] for row in [26,28,30,32,34] for gp,state in [(0,1),(1,1),(1,3),(1,4)]];wresults=[]
 for batch in range((len(writers)+7)//8):
  ss=writers[batch*8:batch*8+8];p=o/f'five-writer-{batch:02}';p.mkdir(exist_ok=True);body=[]
  for j,z in enumerate(ss):
   at=0xffff4000+100*j;rec=0x203ddd if z['view'] else 0x209a41;body += [f' move.w #{z["view"]},0xfffff06c',f' move.w #{z["gp"]},0xffffe3e8',f' move.w #{z["state"]},0xffffe3f0']
   for addr,val in zip(FIELDS,[1,0,1,1,2]):body += [f' move.w #{val},0x{addr:x}']
   body += [' 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,34:1}[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(at+20)
   for k,addr in enumerate(FIELDS):body += [f' move.w 0x{addr:x},0x{at+2*k:x}']
   body += [' move.b #1,0xa130f1',f' move.b 0x{rec:x},0x{at+10:x}',f' move.b 0x{rec+2:x},0x{at+11:x}',' move.b #0,0xa130f1']
  raw=run(p,'current','\n'.join(body),100*len(ss))
  for j,z in enumerate(ss):
   r=raw[j*100:(j+1)*100];values=struct.unpack_from('>5H',r);expected=[1,0,1,1,2];locked=z['view']==1 and z['gp']>0
   if not locked:expected[[26,28,30,32,34].index(z['row'])]={26:0,28:1,30:0,32:3,34:1}[z['row']]
   assert tuple(values)==tuple(expected) and r[10:12]==encode(*expected),('writer clobbered companion/lock',z,values,expected,r[10:12].hex());ctx=decode(r[20:86]);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,34:1}[z['row']];regs[7]=(regs[7]&0xffff0000)|row_word(a.rom,z['row']);assert ctx['regs']==regs and ctx['sp']==0xfffff900,('five-setting writer ABI',z,ctx);wresults.append(dict(spec=z,loaded=values,locked=locked))
 print('PASS',len(cases),'exhaustive combined-record/tag/complement/full-ABI contexts;',len(writers),'five-setting companion/lock writers',flush=True);return dict(record_contexts=len(cases),writer_contexts=len(writers),records=results,writers=wresults)
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 battery(gp=0,choices=((1,0,1,1,2),(0,1,0,2,1),(1,1,1,3,0)),native=(0,1,1,2,1),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/'rate-renderer';p.mkdir(exist_ok=True);code=asm(p,render_probe(""" .text
 .globl _start
_start:
 cmpi.w #34,%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,[34]),0x2fea00);b=bytearray(a.rom.read_bytes());assert set(b[0x2fea00:0x2fea00+len(code)])=={255} and b[0xf7bf4:0xf7bfa]==bytes.fromhex('4ef900011ba4');b[0x2fea00:0x2fea00+len(code)]=code;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/penalty_rate_menu_hook.py')));reports.append(dict(name=name,observations=json.loads((p/'results.json').read_text())));return(p/'battery.srm').read_bytes()
 # Exact season row34 takes17Down; native New Playoffs skips row6, so16.
 down=[f'{900+60*i} press 1 Down 5' for i in range(down_count(a.rom,17))];wanted=[dict(height=1,speed=0,mode=1,cone=1,rate=2),dict(height=0,speed=1,mode=0,cone=2,rate=1),dict(height=1,speed=1,mode=1,cone=3,rate=0)]
 for dev in [513,1281]:
  menu('fresh-'+str(dev),bytes(65536),down+['2200 snap','2301 exit'],{2200:dict(rate=0,height=0,speed=0,mode=0,cone=0,rules=2)},dev,True)
  raw=menu('choices-'+str(dev),battery(0,((0,0,0,0,0),)*3),down+['2300 press 1 Right 5','2450 press 1 Right 5','2600 press 1 Right 5','2750 press 1 Left 5','2900 snap','3001 exit'],{2200:dict(rate=0,rules=2,record='50af'),2400:dict(rate=1,rules=2,record='a05f'),2550:dict(rate=2,rules=2,record='c03f'),2700:dict(rate=0,rules=2,record='50af'),2900:dict(rate=2,rules=2,record='c03f')},dev,True)
  menu('choices-reboot-'+str(dev),raw,down+['2301 exit'],{2200:dict(rate=2,rules=2,record='c03f')},dev,True)
  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','2450 press 1 Right 5','2600 snap','2701 exit'],{2200:dict(wanted[0],gp=1),2400:dict(wanted[0],gp=1),2600:dict(wanted[0],gp=1)},dev,True)
  for record in ['50af','8d72','e01f','a05e']:
   tag,inv=bytes.fromhex(record);vals=decoded_record(tag,inv);want=dict(zip(['height','speed','mode','cone','rate'],vals));menu('legacy-corrupt-'+record+'-'+str(dev),battery(1,bad=record),['1100 press 1 Right 5','1300 press 1 Left 5','1501 exit'],{1000:want,1200:dict(wanted[1],slot=2),1400:want},dev)
  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,16))]
  raw=menu('native-'+str(dev),raw,nav+['2900 press 1 Right 5','3100 snap','3201 exit'],{1600:dict(rate=1,view=0,full_goalie='123456789a'),2850:dict(rate=1,view=0),3100:dict(rate=2,view=0,global_record='d629',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(rate=2,view=0,global_record='d629',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),battery(1),lines,{3100:dict(wanted[1],slot=2,gp=1),3900:dict(rate=0,mode=0,cone=0,height=0,speed=0,slot=2,gp=0),4300:dict(wanted[0],slot=1),4600:dict(rate=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],gp=1,record='cd32'))
  menu('first-game-locked-'+str(dev),raw,down+['2300 press 1 Left 5','2450 press 1 Right 5','2601 exit'],{2200:dict(wanted[0],gp=1),2400:dict(wanted[0],gp=1),2550:dict(wanted[0],gp=1)},dev,True)
 print('PASS',len(reports),'native percentage menu/render/slot/reboot/legacy/lock/restart/global/goalie-storage/first-match lifecycle cases',flush=True);return dict(menu_cases=reports)

def input_suite():
 p=o/'input-observer';p.mkdir(exist_ok=True);code=asm(p,(h/'testcases/penalty_rate_input_probe.s').read_text(),0x2d9000);b=bytearray(a.rom.read_bytes());assert set(b[0x2d9000:0x2d9000+len(code)])=={255};b[0x2d9000:0x2d9000+len(code)]=code;assert b[0x11f78:0x11f7e]==bytes.fromhex('4ef9002ec800') and b[0x11fec:0x11ff2]==bytes.fromhex('4cdf03024e75');b[0x11f78:0x11f7e]=bytes.fromhex('4ef9002d9000');b[0x11fec:0x11ff2]=bytes.fromhex('4ef9002d9200');observed=p/'rom.bin';observed.write_bytes(b)
 cases=[dict(rate=rate,count=count,adapter=adapter,end=180000 if rate==1 else 60000) for rate,count,adapter in [(0,1,False),(1,2,False),(2,4,True)]]
 def trial(item):
  i,z=item;p=o/f'input-{i}';p.mkdir(exist_ok=True);raw=battery(0,((0,0,0,0,z['rate']),)*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/penalty_rate_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)))
 assert all(r['native_eligible_fouls']>0 for r in results),('actual native foul coverage missing',[(r['spec'],r['native_eligible_fouls']) for r in results]);assert results[2]['admitted']==0 and results[0]['suppressed']==0;assert results[1]['admitted'] and results[1]['suppressed'],('half-rate live input must exercise both outcomes',results[1])
 print('PASS',len(results),'cold-boot real-input games;',sum(r['native_eligible_fouls'] for r in results),'actual native foul decisions; all rates/P1-P4 input; no injected game outcomes',flush=True);return dict(scope='Automated real controller input; only observer counter initialized. No game positions, fouls, queues, outcomes or RNG injected. Private preserving probes can affect native HV timing. Not a human hardware playtest.',cases=results)

sections={}
if a.section in ['all','awards']:sections['awards']=awards_suite()
if a.section in ['all','injury']:sections['injury']=injury_suite()
if a.section in ['all','distribution']:sections['distribution']=distribution_suite()
if a.section in ['all','fights']:sections['fights']=fights_suite()
if a.section in ['all','storage']:sections['storage']=storage_suite()
if a.section in ['all','menu']:sections['menu']=menu_suite()
if a.section in ['all','input']:sections['input']=input_suite()
if a.section=='all':assert set(sections)=={'awards','distribution','fights','injury','storage','menu','input'}
(o/'results.json').write_text(json.dumps(dict(rom_sha256=sha(a.rom),baseline_sha256=sha(a.baseline),complete=a.section=='all',sections=sections),indent=2)+'\n')
