#!/usr/bin/env python3
"""Downed pass targets/stick pickups: paired native instruction/selection/collision tests.

Synthetic reset fixtures specify skater posture and geometry, then call actual
68000 scans and the full native collision dispatch. No selected receiver, pickup,
body deflection, score or successful return is forced. ABI slices observe the
installed instructions with a private return after the displaced BTST location.
"""
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('--state',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)=='dc279e63f35b2607ebba4eb5fb439eeb47f36239bddcfaae7c3d24e29f99cb08'
env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')}
def assemble(p,source):
 (p/'driver.s').write_text(source);subprocess.run([str(h.parent/'m68kasm.sh'),str(p/'driver.s'),'2d5000',str(p/'code.bin')],check=True,stdout=subprocess.DEVNULL);return(p/'code.bin').read_bytes()
def run(p,profile,body,size,stubs=()):
 source=' .text\n .globl _start\n_start:\n move.w #0x2700,%sr\n movea.l #0xfffff900,%sp\n'+body+'\n move.l #0x444f574e,0xffff4ff0\nend: bra.s end\n';c=assemble(p,source);r=bytearray((a.rom if profile=='current' else a.baseline).read_bytes());assert len(c)<0x4000 and set(r[0x2d5000:0x2d5000+len(c)])=={255};r[0x2d5000:0x2d5000+len(c)]=c;struct.pack_into('>I',r,4,0x2d5000)
 for at in stubs:r[at:at+2]=bytes.fromhex('4e75')
 (p/'rom.bin').write_bytes(r);(p/'commands.txt').write_text(f'2000 dump ff4ff0 4 done\n2000 dump ff4000 {size} result\n2001 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=45)
 d={l.split()[1]:bytes.fromhex(' '.join(l.split()[2:])) for l in (p/'commands.out').read_text().splitlines()};assert d['done']==b'DOWN',('native return',p);return d['result']
falls=[0xd26,0xdd8,0x136a,0x145c,0x1776,0x17e8,0x185a,0x18cc,0x193e,0x1a00,0x1af4]
postures=[dict(name=f'fall-{q:04x}',anim=q,down=True) for q in falls]+[dict(name='board-flag',anim=0,p3=32,down=True),dict(name='standing',anim=0,down=False),dict(name='standing-windup',anim=0x7fc,p1=32,down=False),dict(name='standing-catch',anim=0x109c,p1=32,down=False),dict(name='standing-check',anim=0x1ac2,p1=32,down=False),dict(name='standing-stumble',anim=0x11e6,down=False),dict(name='unavailable',anim=0,p2=4,down=False),dict(name='keeper-dive',anim=0x2f4,role=0,down=False),dict(name='keeper-with-fall-flag',anim=0xd26,p3=32,role=0,down=False),dict(name='recovered',anim=0,down=False)]
abi_specs=[dict(s,at=at,ccr=ccr) for s in postures for at in [0xbd06,0x2ff1b0] for ccr in [0,31]]
def abi(item):
 profile,batch=item;ss=abi_specs[batch*16:batch*16+16];p=o/f'abi-{profile}-{batch:02}';p.mkdir(exist_ok=True);body=[]
 for j,s in enumerate(ss):
  regs=[0x11223300+i for i in range(8)];ars=[0xffff1000+256*i for i in range(7)];ars[2 if s['at']==0x14e5c else 1]=0xffffb04a;dest=0xffff4000+66*j
  body += [f' move.w #{s.get("role",4)},0xffffb07e',f' move.w #{s["anim"]},0xffffb0a2',f' move.b #{s.get("p1",0)},0xffffb0ac',f' move.b #{s.get("p2",0)},0xffffb0ad',f' move.b #{s.get("p3",0)},0xffffb0ae']
  body += [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)]+[f' move.w #{0x2700+s["ccr"]},%sr',f' jsr 0x{s["at"]:x}',f' move.w %sr,0x{dest:x}',f' movem.l %d0-%d7/%a0-%a6,0x{dest+2:x}',f' move.l %sp,0x{dest+62:x}']
 raw=run(p,profile,'\n'.join(body),66*len(ss),[q+6 for q in [0xbd06,0x2ff1b0]]);rows=[]
 for j,s in enumerate(ss):
  sr=struct.unpack_from('>H',raw,66*j)[0];values=list(struct.unpack_from('>15I',raw,66*j+2));sp=struct.unpack_from('>I',raw,66*j+62)[0];ars=[0xffff1000+256*i for i in range(7)];ars[2 if s['at']==0x14e5c else 1]=0xffffb04a
  unavailable=bool(s.get('p2',0)&4) or profile=='current' and s['down'];expect=(0x2700+s['ccr'])&~4;expect|=0 if unavailable else 4
  assert values==[0x11223300+i for i in range(8)]+ars and sp==0xfffff900,('register/stack ABI',profile,s,values,hex(sp));assert sr==expect,('BTST Z/other CCR',profile,s,hex(sr),hex(expect));rows.append(dict(profile=profile,spec=s,sr=sr,abi=True))
 return rows
with ThreadPoolExecutor(max_workers=3) as pool:abi_rows=[v for rows in pool.map(abi,[(v,b) for v in ['baseline','current'] for b in range((len(abi_specs)+15)//16)]) for v in rows]
# Actual native and cached receiver search, keeper/skater passer, both teams.
scan_specs=[dict(s,team=t,keeper=k,native=n) for s in postures if s.get('role',4)>0 for t in [0,1] for k in [False,True] for n in [False,True] if n or k]
def scan(item):
 profile,batch=item;ss=scan_specs[batch*8:batch*8+8];p=o/f'scan-{profile}-{batch:02}';p.mkdir(exist_ok=True);body=[]
 for j,s in enumerate(ss):
  base=s['team']*6;obj=0xffffb04a+128*base;passer=obj+128*(5 if s['keeper'] else 4);dest=0xffff4000+j*8
  body += [' lea 0xffffb04a,%a0',' move.w #383,%d0',f'cl{j}: clr.l (%a0)+',f' dbra %d0,cl{j}']
  for i in range(12):body += [f' move.w #-1,0x{0xffffb07e+128*i:x}',f' move.w #{i},0x{0xffffb09c+128*i:x}']
  body += [f' lea 0x{passer:x},%a3',f' move.w #{0 if s["keeper"] else 4},0x34(%a3)',' move.b #8,0x62(%a3)',f' move.w #10,0x{obj:x}',f' move.w #4,0x{obj+0x34:x}',f' move.w #{s["anim"]},0x{obj+0x58:x}',f' move.b #{8|s.get("p1",0)},0x{obj+0x62:x}',f' move.b #{s.get("p2",0)},0x{obj+0x63:x}',f' move.b #{s.get("p3",0)},0x{obj+0x64:x}',f' move.w #50,0x{obj+128:x}',f' move.w #4,0x{obj+128+0x34:x}',f' move.b #8,0x{obj+128+0x62:x}',' clr.w 0xffffb74a',' clr.w 0xffffb75e',' move.w #2,0xffffbedc',' clr.w 0xffffc2f2',' moveq #-1,%d4',' movea.l #0x1234,%a0',f' lea 0x{obj:x},%a1',' moveq #5,%d3',f' jsr 0x{0xbcf8 if s["native"] else 0x2ff180:x}',f' move.l %a0,0x{dest:x}',f' move.l %sp,0x{dest+4:x}']
 raw=run(p,profile,'\n'.join(body),8*len(ss),[0xbd64,0xbd6c]);rows=[]
 for j,s in enumerate(ss):
  target,sp=struct.unpack_from('>II',raw,8*j);blocked=bool(s.get('p2',0)&4) or profile=='current' and s['down'];expected=0xffffb04a+128*(s['team']*6+int(blocked));assert (target,sp)==(expected,0xfffff900),('actual native receiver scan',profile,s,hex(target),hex(expected),hex(sp));rows.append(dict(profile=profile,spec=s,target=target))
 return rows
with ThreadPoolExecutor(max_workers=3) as pool:scan_rows=[v for rows in pool.map(scan,[(v,b) for v in ['baseline','current'] for b in range((len(scan_specs)+7)//8)]) for v in rows]
# The whole native per-actor collision dispatch: GetHot, stick/body, pickup stats
# and controller assignment are live. Fixture at the current native stick hotspot.
collision_specs=[dict(s,team=t,body=False) for s in postures for t in [0,1]]+[dict(name='down-body-contact',anim=0xdd8,down=True,team=t,body=True) for t in [0,1]]
def collision(item):
 profile,batch=item;ss=collision_specs[batch*4:batch*4+4];p=o/f'collision-{profile}-{batch:02}';p.mkdir(exist_ok=True);body=[]
 for j,s in enumerate(ss):
  actor=s['team']*6+2;obj=0xffffb04a+128*actor;dest=0xffff4000+16*j
  body += [' lea 0xffffb04a,%a0',' move.w #383,%d0',f'cl{j}: clr.l (%a0)+',f' dbra %d0,cl{j}',f' lea 0x{obj:x},%a2',f' move.w #{actor},0x52(%a2)',f' move.w #{s.get("role",4)},0x34(%a2)',f' move.w #{s["anim"]},0x58(%a2)',' move.w #11,6(%a2)',f' move.b #{8|s.get("p1",0)|64*s["team"]},0x62(%a2)',f' move.b #{s.get("p2",0)},0x63(%a2)',f' move.b #{s.get("p3",0)},0x64(%a2)',' move.b #30,0x71(%a2)',f' move.w #{actor},0xffffc320',' move.w #-1,0xffffc322',' clr.l 0xffffc32c',' clr.l 0xffffc328',' clr.b 0xffffc2ea',' clr.b 0xffffc2ec',' clr.b 0xffffc2f2',' clr.b 0xffffc2f6',' clr.b 0xffffc2fa',' move.w #-1,0xffffb7aa',f' move.w #{actor},0xffffbee0',' move.w #1,0xffffbeda',' clr.l 0xfffff060',' clr.w 0xfffff736',' clr.w 0xffffb762',' clr.w 0xffffb776',' clr.w 0xffffb774',f' move.w #{4000 if s["body"] else 0},0xffffb772']
  if s['anim'] in falls:
   body += [' movea.l %a2,%a3',' move.w #8,0x5a(%a3)',' move.w #-1,0x5c(%a3)',' moveq #0,%d7',' jsr 0xaee4']
  if s['body']:body+=[' clr.w 0xffffb74a',' clr.w 0xffffb75e']
  else:body+=[' move.l %a2,-(%sp)',' jsr 0x106e0',' move.w %d0,0xffffb74a',' move.w %d1,0xffffb75e']
  body += [' lea 0xffffb74a,%a3',f' move.w #{actor*2},%d4',' jsr 0x14e10',f' move.w 0xffffb7aa,0x{dest:x}',f' move.w 0xffffb772,0x{dest+2:x}',f' move.w 0xffffb774,0x{dest+4:x}',f' move.w 0xffffbee0,0x{dest+6:x}',f' move.l %sp,0x{dest+8:x}',f' move.w 0xffffb74a,0x{dest+12:x}',f' move.w 0xffffb75e,0x{dest+14:x}']
 raw=run(p,profile,'\n'.join(body),16*len(ss));rows=[]
 for j,s in enumerate(ss):
  owner,vx,vy,target,sp,px,py=struct.unpack_from('>hhhhIhh',raw,16*j);blocked=bool(s.get('p2',0)&4) or profile=='current' and s['down'];actor=s['team']*6+2
  if not s['body']:assert owner==(-1 if blocked else actor),('native downed stick pickup',profile,s,owner,vx,vy,px,py)
  elif profile=='current':assert owner==-1 and (vx,vy)!=(4000,0),('downed body became intangible',profile,s,owner,vx,vy)
  assert sp==0xfffff900,('native collision stack',profile,s,hex(sp));rows.append(dict(profile=profile,spec=s,owner=owner,velocity=[vx,vy],target=target))
 return rows
with ThreadPoolExecutor(max_workers=3) as pool:collision_rows=[v for rows in pool.map(collision,[(v,b) for v in ['baseline','current'] for b in range((len(collision_specs)+3)//4)]) for v in rows]
# Cold-boot, real native co-op input; only an explicitly synthetic receiver fall.
def native_input(profile):
 p=o/('input-'+profile);p.mkdir(exist_ok=True);b=bytearray(a.seed.read_bytes()[1::2][:8192]);b[0xe20:0x1ef6]=bytes(0x10d6);struct.pack_into('>4s6H',b,0x1000,b'SESN',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);(p/'commands.txt').write_text('900 press 1 Down 5\n1000 press 1 Right 5\n1400 press 1 Up 5\n1500 press 1 Start 5\n1900 press 1 Start 5\n15502 exit\n');settings=env|dict(GPGX_DEV='513',GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(h/'testcases/downed_receiver_input_hook.py'))
 with(p/'log').open('w') as log:subprocess.run(['nice','-n','19','python3',str(h/'test_core.py'),str((a.rom if profile=='current' else a.baseline).resolve()),str(p/'commands.txt'),str(p)],env=settings,stdout=log,stderr=log,check=True,timeout=90)
 d=json.loads((p/'native-input.json').read_text());assert len(d['falls'])>=3,('no real in-flight passing/fall exercise',profile);bad=d['downed_ownership'];episodes=sum(i==0 or x['frame']!=bad[i-1]['frame']+1 or x['actor']!=bad[i-1]['actor'] for i,x in enumerate(bad))
 if profile=='baseline':assert bad,('old downed possession not reproduced',profile)
 else:assert not bad,('current downed possession',bad[:2])
 recovered=[f for f in d['falls'] if any(r['actor']==f['actor'] and r['frame']>f['frame']+120 and not r['down'] for r in d['receipts'])];assert recovered,('no native post-recovery possession',profile)
 return dict(profile=profile,synthetic_falls_after_real_pass=len(d['falls']),downed_possession_observations=len(bad),downed_possession_episodes=episodes,fallen_actors_later_observed_with_legal_possession=len(recovered),raw_sha256=sha(p/'native-input.json'))
with ThreadPoolExecutor(max_workers=2) as pool:played=list(pool.map(native_input,['baseline','current']))
(o/'results.json').write_text(json.dumps(dict(rom_sha256=sha(a.rom),baseline_sha256=sha(a.baseline),scope=__doc__,paired_ABI_contexts=len(abi_specs),paired_scan_contexts=len(scan_specs),paired_collision_contexts=len(collision_specs),ABI=abi_rows,scans=scan_rows,collisions=collision_rows,native_coop_input=played),indent=2)+'\n');print('PASS',len(abi_specs),'paired BTST ABI contexts;',len(scan_specs),'actual receiver searches;',len(collision_specs),'native stick/body collisions; downed targeting/pickup reproduced in parent, corrected, healthy/keeper/recovered cases retained')
