#!/usr/bin/env python3
"""Shootout progress/start regression: real 68000 boundary fixtures and native pads.

Unit fixtures set explicit approach/shot states, with a private return observer.
Integration only shortens the initial tied OT clock and selects controller side;
all later movement, attempt endings, ownership and goals are native. No visible
frontend or user saves are used. This is not a physical-controller playtest.
"""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import argparse,hashlib,json,os,struct,subprocess
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);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)=='aa035e55db8b43d1d38f3b178a739cc0edfab1fb8ef7837de718248fde5721f1'
env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')}
def assemble(p,source,origin):
 (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,rom,commands,extra=None):
 (p/'commands.txt').write_text(commands)
 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|(extra or {}),stdout=log,stderr=log,check=True,timeout=65)
def dumps(p):return {l.split()[1]:bytes.fromhex(' '.join(l.split()[2:])) for l in (p/'commands.out').read_text().splitlines()}
# Explicit positive/negative examples, not a second implementation of the helper.
examples=[
 dict(name='forward',ys=[101<<16],phase=1,want=1,still=0),
 dict(name='one-subpixel-forward',ys=[(100<<16)+1],phase=1,previous_still=11,want=1,still=0),
 dict(name='eight-unit-boundary',ys=[92<<16],phase=1,want=1,still=1),
 dict(name='eight-unit-plus-subpixel-reverse',ys=[(92<<16)-1],phase=1,want=3),
 dict(name='stopped-eleven-observations',ys=[100<<16]*11,phase=1,want=1,still=11),
 dict(name='stopped-twelve-observations',ys=[100<<16]*12,phase=1,want=3,still=12),
 dict(name='moving-below-highwater',ys=[(99<<16)+i*1000 for i in range(16)],phase=1,want=1,still=0),
 dict(name='lost-unshot-forward',ys=[101<<16,102<<16,103<<16],phase=1,owner=-1,want=1,still=0),
 dict(name='backward-facing-forward-puck',ys=[101<<16,102<<16],phase=1,facing=128,want=1,still=0),
 dict(name='first-reaction-idle',ys=[100<<16]*30,phase=0,motion=0,want=0,still=0),
 dict(name='first-actual-movement',ys=[101<<16],phase=0,motion=1,want=1,still=0),
 dict(name='first-unowned-centre',ys=[100<<16]*30,phase=0,owner=-1,want=0,still=0),
 dict(name='shot-windup',ys=[101<<16],phase=1,flags=36,want=2,still=0),
 dict(name='original-shot-bounces-backward',ys=[90<<16]*20,phase=2,flags=36,want=2,still=0),
 dict(name='outside-goalline',ys=[265<<16],phase=1,x=22,want=3),
 dict(name='mouth-goal-native-collision',ys=[265<<16],phase=1,x=21,want=1,still=0),
 dict(name='announcement',ys=[90<<16],phase=1,countdown=1,want=1,still=0),
 dict(name='pause',ys=[90<<16],phase=1,view=1,want=1,still=0),
 dict(name='foreign-view',ys=[90<<16],phase=1,view=128,want=1,still=0),
 dict(name='game-over',ys=[90<<16],phase=1,stop=16,want=1,still=0),
 dict(name='ordinary-play-clears',ys=[90<<16],phase=1,flags=0,want=1,magic=0),
 dict(name='keeper-role-rejected',ys=[90<<16],phase=1,role=0,want=1,magic=0),
 dict(name='negative-actor-rejected',ys=[90<<16],phase=1,actor=65535,want=1,magic=0),
 dict(name='large-actor-rejected',ys=[90<<16],phase=1,actor=12,want=1,magic=0),
 dict(name='previous-actor-reinitializes',ys=[100<<16],phase=3,record_actor=4,motion=0,want=0,still=0),
 dict(name='already-failed',ys=[101<<16],phase=3,flags=20,want=3),
 dict(name='already-queued',ys=[101<<16],phase=3,flags=84,want=3)]
# Confirmed2026-11-04 custom rule replaces conflicting puck progress examples.
# Nonconflicting native retouch, countdown, ABI, storage and physical input tests remain.
player_blue_line=a.rom.read_bytes()[0x2daa36:0x2daa3a]==bytes.fromhex('22280014')
if player_blue_line:examples=json.loads((h/'testcases/penalty_blue_line_cases.json').read_text())
cases=[dict(s,direction=d) for d in [1,-1] for s in examples]
regs=[0x11223300+i for i in range(8)];ars=[0xff1000+256*i for i in range(7)]
def unit(item):
 version,batch=item;current=version=='current';p=o/f'unit-{version}-{batch:02}';p.mkdir(exist_ok=True);ss=cases[batch*8:batch*8+8];r=bytearray((a.rom if current else a.baseline).read_bytes());b=[' .text',' .globl _start','_start:',' move.w #0x2700,%sr',' movea.l #0xfffff900,%sp']
 for j,s in enumerate(ss):
  actor=s.get('actor',0 if s['direction']==1 else 6);obj=0xffffb04a+128*(actor%12);dest=0xffff4000+j*88;owner=s.get('owner',actor);ys=s['ys']
  b += [f' move.b #{s.get("flags",4)},0xffffc2f2',f' move.b #{s.get("stop",1)},0xffffc2ea',f' move.b #{s.get("view",0)},0xffffc2ec',' move.b #4,0xffffc2fa',f' move.w #{actor},0xffffd406',' move.w #11,0xffffd408',f' move.w #{s.get("countdown",-1)},0xffffc31a',f' move.w #{owner},0xffffb7aa',' move.w #1000,0xffffd454',' move.w #100,0xffffc31c',f' move.w #{s.get("role",4)},0x{obj+0x34:x}',f' move.b #{128 if s["direction"]==1 else 0},0x{obj+0x62:x}',f' move.w #{s.get("facing",0)},0x{obj+0x54:x}',f' move.l #{s.get("motion",1)},0x{obj+0x28:x}',f' clr.w 0x{obj:x}',f' clr.w 0x{obj+0x14:x}',' move.w #0x5352,0xfffff65c',f' move.w #{s.get("record_actor",actor)},0xfffff65e',f' move.w #{s["phase"]},0xfffff660',f' move.w #{s.get("previous_still",0)},0xfffff662',f' move.l #{s.get("last",100<<16)},0xfffff664',' clr.l 0xfffff668',f' move.l #{s.get("last",100<<16)},0xfffff66c',f' move.w #{s.get("x",0)},0xffffb74a']
  if player_blue_line:
   b += [f' move.l #0x{(s["direction"]*s.get("puck",100<<16))&0xffffffff:x},0xffffb75e']
  for step,y in enumerate(ys):
   if player_blue_line:
    b += [f' move.l #0x{(s["direction"]*y)&0xffffffff:x},0x{obj+0x14:x}',f' move.l #{step<<16},0x{obj:x}']
   else:b += [f' move.l #0x{(s["direction"]*y)&0xffffffff:x},0xffffb75e']
   b += [f' move.l #0x{v:x},%d{i}' for i,v in enumerate(regs)]+[f' movea.l #0x{v:x},%a{i}' for i,v in enumerate(ars)]+[' move.w #0x271f,%sr',' jsr 0xf2f4']
  b += [' lea 0xffff4e00,%a0',f' lea 0x{dest:x},%a1',' move.w #32,%d0',f'copy{j}: move.w (%a0)+,(%a1)+',f' dbra %d0,copy{j}',' lea 0xfffff65c,%a0',' move.w #9,%d0',f'ram{j}: move.w (%a0)+,(%a1)+',f' dbra %d0,ram{j}',' move.w 0xffffc2f2,(%a1)+']
 b+=[' move.l #0x5352554c,0xffff4ff0','end: bra.s end'];code=assemble(p,'\n'.join(b)+'\n',0x2d5000);assert len(code)<0x4000 and set(r[0x2d5000:0x2d5000+len(code)])=={255};r[0x2d5000:0x2d5000+len(code)]=code;struct.pack_into('>I',r,4,0x2d5000)
 # Observe precisely after the displaced instructions, before native continuation.
 stub=bytes.fromhex('40f9ffff4e0048f97fffffff4e0223cfffff4e3e4c9f00014e75')
 assert len(stub)==26;r[0xf2fc:0xf2fc+len(stub)]=stub;(p/'rom.bin').write_bytes(r);run(p,p/'rom.bin',f'100 dump ff4ff0 4 done\n100 dump ff4000 {88*len(ss)} result\n101 exit\n');ds=dumps(p);assert ds['done']==b'SRUL';raw=ds['result'];out=[]
 for j,s in enumerate(ss):
  q=raw[j*88:(j+1)*88];sr=struct.unpack_from('>H',q)[0];got=list(struct.unpack_from('>15I',q,2));sp=struct.unpack_from('>I',q,62)[0];actor=s.get('actor',0 if s['direction']==1 else 6);owner=s.get('owner',actor)&65535;expected=list(regs+ars);expected[0]=(expected[0]&0xffff0000)|owner
  assert got==expected and sp==0xfffff8fa,('displaced native registers/stack',version,s,got,hex(sp));assert sr==(0x2710|(8 if owner&32768 else 4 if owner==0 else 0)),('MOVE.W CCR/X',version,s,hex(sr))
  magic,rec,phase,still,high,sx,sy,last,flags=struct.unpack_from('>4HI2HIH',q,66)
  if current:
   assert phase==s['want'],('approach result',s,phase,still,high);assert magic==s.get('magic',0x5352),('guard record',s,magic)
   if player_blue_line:assert still==s.get('expected_latch',s.get('latch',1)),('first-crossing latch',s,still)
   elif 'still' in s:assert still==s['still'],('stop counter',s,still)
   assert bool(flags&0x1000)==(bool(s.get('flags',4)&16) or s['want']==3),('native end flag',s,hex(flags))
  out.append(dict(profile=version,spec=s,phase=phase,still=still,high=high,flags=flags,abi=True))
 return out
with ThreadPoolExecutor(max_workers=3) as pool:unit_rows=[r for rows in pool.map(unit,[(v,b) for v in ['baseline','current'] for b in range((len(cases)+7)//8)]) for r in rows]
# Real native shot state machine: private queue observer, original watcher unmodified.
def shot_machine(version):
 p=o/('native-shot-'+version);p.mkdir(exist_ok=True);r=bytearray((a.rom if version=='current' else a.baseline).read_bytes());b=[' .text',' .globl _start','_start:',' move.w #0x2700,%sr',' movea.l #0xfffff900,%sp',' 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']
 # Windup owns; shot becomes loose; ownership recovered; repeat call queues only once.
 for j,(owner,flags,mode) in enumerate([(4,36,4),(-1,36,4),(4,36,36),(4,116,36)]):
  b += [f' move.w #{owner},0xffffb7aa',f' move.b #{flags},0xffffc2f2',f' move.b #{mode},0xffffc2fa',' move.w #100,0xffffc31c',' jsr 0xf2f4',f' move.w 0xffff4ff0,0x{0xffff4000+8*j:x}',f' move.w 0xffff4ff2,0x{0xffff4002+8*j:x}',f' move.w 0xffffc2f2,0x{0xffff4004+8*j:x}',f' move.w 0xffffc2fa,0x{0xffff4006+8*j:x}']
 b+=[' move.l #0x4e415456,0xffff4ff4','end: bra.s end'];code=assemble(p,'\n'.join(b)+'\n',0x2d5000);r[0x2d5000:0x2d5000+len(code)]=code;struct.pack_into('>I',r,4,0x2d5000);r[0x11f62:0x11f70]=bytes.fromhex('5279ffff4ff033c0ffff4ff24e75');(p/'rom.bin').write_bytes(r);run(p,p/'rom.bin','100 dump ff4ff4 4 done\n100 dump ff4000 32 result\n101 exit\n');d=dumps(p);assert d['done']==b'NATV';rows=[list(struct.unpack_from('>4H',d['result'],8*j)) for j in range(4)];assert [x[0] for x in rows]==[0,0,1,1] and rows[2][1]==10,('native retouch/single miss',version,rows);return dict(profile=version,rows=rows)
shots=[shot_machine(v) for v in ['baseline','current']]
def capture(item):
 version,side=item;p=o/f'start-{version}-{side}';p.mkdir(exist_ok=True);b=bytearray(a.seed.read_bytes()[1::2][:8192]);b[0xe20:0x1ef6]=bytes(0x10d6);b[0x1000:0x1004]=b'SESN';struct.pack_into('>6H',b,0x1004,0,0,0,84,1,3);b[0x1fff]=sum(b[:0x1ffe])%256;b[0x1ffe]=b[0x1fff]^255;raw=bytearray(16384);raw[1::2]=b;(p/'battery.srm').write_bytes(raw);case=dict(coop=False,side=side,force_at=1906,first_live_setup=True,teammates=2,period_choice=0);rom=a.rom if version=='current' else a.baseline;run(p,rom,'1500 press 1 Start 5\n1900 press 1 Start 5\n10000 exit\n',dict(GPGX_DEV='513',GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(h/'testcases/shootout_start_hook.py'),SHOOTOUT_CASE=json.dumps(case)));events=json.loads((p/'events.json').read_text());first=next(e for e in events if e['event']=='first-live-no-input');fixture=next(e for e in events if e['event']=='fixture');assert first['active_buttons']==[] and first['controls'][0]==first['shooter'];return dict(profile=version,side=side,elapsed=first['frame']-fixture['frame'],live=first,state=str(p/'first-attempt.state'))
with ThreadPoolExecutor(max_workers=2) as pool:starts=list(pool.map(capture,[(v,s) for v in ['baseline','current'] for s in [1,2]]))
# The late-game fixture precedes variable native announcement/setup work.
# Validate the actual complete60-unit countdown, rather than an incidental
# frontend elapsed180 that varies when presentation instructions move VBlank.
for start in [x for x in starts if x['profile']=='current']:
 events=json.loads((o/f'start-current-{start["side"]}'/'events.json').read_text())
 countdown=[e for e in events if e['event']=='countdown' and e['shooter']==start['live']['shooter'] and e['side']==start['side']-1]
 assert [e['countdown'] for e in countdown]==list(range(60,-1,-1))+[65535],('native automatic countdown changed/skipped',start,countdown)
 assert all(e['active_buttons']==[] for e in countdown),('auto-start used input',start)
 start['native_countdown_values']=[e['countdown'] for e in countdown]
 start['countdown_frontend_frames']=countdown[-1]['frame']-countdown[0]['frame']

assert next(x for x in starts if x['profile']=='baseline' and x['side']==1)['elapsed']>600
# Direction is mapped per shooting end; no state manipulation after loading.
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)])]
if player_blue_line:patterns.append(('postblue-lateral-reverse',[[0,'forward',200]]))
def play(item):
 start,(name,actions)=item;version=start['profile'];side=start['side'];p=o/f'play-{version}-{side}-{name}';p.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];spec=dict(name=name,actions=actions,player_blue_line=player_blue_line,side=side);(p/'spec.json').write_text(json.dumps(spec));run(p,a.rom if version=='current' else a.baseline,f'100 load {start["state"]}\n901 exit\n',dict(GPGX_DEV='513',GPGX_HOOK=str(h/'testcases/shootout_rules_input_hook.py')));q=json.loads((p/'result.json').read_text());end=q['first_end'];assert end,('native attempt did not finish',version,side,name);rows=q['trace'][:end['frame']+1];failed=next((x for x in rows if x['phase']==3),None)
 if version=='current':
  if player_blue_line:
   if name in ['backwards','forward-reverse','coast-stop','initial-idle']:assert not failed,('legal custom-rule approach rejected',side,name,failed)
   if name in ['shot','shot-mash'] and failed:
    assert failed['blue_latch']==1 and failed['body_normalized']<failed['body_previous'],('shot ended without actual prohibited post-blue body retreat',side,name,failed)
   if name=='postblue-lateral-reverse':
    assert failed and failed['blue_latch']==1 and failed['body_normalized']<failed['body_previous'],('post-blue actual retreat did not miss',side,name,failed)
  else:
   if name in ['backwards','forward-reverse','coast-stop']:assert failed,('invalid approach retained',side,name,end)
   if name in ['initial-idle','shot','shot-mash']:assert not failed,('legal approach rejected',side,name,failed)
  if name=='diagonal':assert not failed or failed['frame']>200,('moving diagonal approach rejected',side,failed)
 # Attempt score changes exactly once by native advancement, not direct host writes.
 return dict(profile=version,side=side,case=name,end_frame=end['frame'],counts=end['counts'],tally=end['tally'],rule_failure=failed,trace_sha256=sha(p/'result.json'))
with ThreadPoolExecutor(max_workers=3) as pool:played=list(pool.map(play,[(s,c) for s in starts for c in patterns]))
(o/'results.json').write_text(json.dumps(dict(rom_sha256=sha(a.rom),baseline_sha256=sha(a.baseline),scope=__doc__,paired_native_boundary_cases=len(cases),unit_cases=unit_rows,native_shot_retouch=shots,no_input_starts=starts,native_input_cases=played,limitations=[('Custom player rule: native body centre strictly past88 latches; any sampled16.16 retreat misses; lateral/stopped travel is legal within native timeout.'if player_blue_line else'Rule tolerances:8 world units retracement,12 consecutive non-forward observations; native first-move reaction grace.'),'No full NHL shootout eligibility/roster or spin detection implementation is claimed.']),indent=2)+'\n');print('PASS',len(cases),'paired native boundary/ABI contexts, native shot retouch/single ending, both-end no-input starts and',len(played),'native input cases')
