#!/usr/bin/env python3
"""Real 4-Way Play B/Y keeper tests, using an explicit native four-player capture.

The capture must be ordinary gameplay with devices1281 on both ports. Optional
historical dive captures supply only object/puck RAM; adapter hardware, game
loop and real controller reads remain those of the native four-player state.
No installed save/ROM files are changed. Normal passes may be intercepted/missed;
assert the requested native receiver, outgoing vector and control ownership.
"""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import argparse,json,os,subprocess,struct
ap=argparse.ArgumentParser(description=__doc__);ap.add_argument('rom',type=Path);ap.add_argument('output',type=Path);ap.add_argument('--state',type=Path,required=True);ap.add_argument('--fixtures-root',type=Path);ap.add_argument('--switch-on-b',action='store_true');ap.add_argument('--catch-handoff',action='store_true');a=ap.parse_args()
w=a.output.resolve();w.mkdir(parents=True,exist_ok=True);here=Path(__file__).resolve().parent
# The core can yield at a VBlank in the middle of the native P3/P4 bank swaps.
# Observe their boundaries privately so assertions compare physical owners,
# including a newly selected keeper before the bank is copied back.
(w/'banks.s').write_text('''    .text
    .globl _start
_start:
    move.w 0xFFFFC320,0xFFF0D6
    move.w #1,0xFFF0D4
    move.w 0xFFFFC328,-(%sp)
    move.w 0xFFFFC32C,0xFFFFC328
    jmp 0xAD70
    .org 0x40
    move.w 0xFFFFC320,0xFFFFC324
    move.w (%sp)+,0xFFFFC320
    clr.w 0xFFF0D4
    jmp 0xADD8
    .org 0x80
    move.w 0xFFFFC322,0xFFF0D8
    move.w #2,0xFFF0D4
    move.w 0xFFFFC32A,-(%sp)
    move.w 0xFFFFC32E,0xFFFFC32A
    jmp 0xAE0A
    .org 0xC0
    move.w 0xFFFFC322,0xFFFFC326
    move.w (%sp)+,0xFFFFC322
    clr.w 0xFFF0D4
    jmp 0xAE72
''')
subprocess.run([str(here.parent/'m68kasm.sh'),str(w/'banks.s'),'2EF400',str(w/'banks.bin')],check=True,stdout=subprocess.DEVNULL)
rom=bytearray(a.rom.read_bytes());code=(w/'banks.bin').read_bytes();assert set(rom[0x2ef400:0x2ef400+len(code)])=={255};rom[0x2ef400:0x2ef400+len(code)]=code
for addr,target,expected in [(0xad66,0x2ef400,'3f38c32831f8c32cc328'),(0xadce,0x2ef440,'31f8c320c32431dfc320'),(0xae00,0x2ef480,'3f38c32a31f8c32ec32a'),(0xae68,0x2ef4c0,'31f8c322c32631dfc322')]:
 assert rom[addr:addr+10]==bytes.fromhex(expected);rom[addr:addr+10]=bytes.fromhex('4ef9')+struct.pack('>I',target)+bytes.fromhex('4e714e71')
(w/'observed.bin').write_bytes(rom)
cases=[]
def case(side,pad,mode='pass',active=None,delay=10,duration=5,second=0):
 cases.append(dict(side=side,pad=pad,mode=mode,active=active or [1,2,3,4],delay=delay,duration=duration,second=second))
for side in [5,11]:
 for count in range(1,5):
  for pad in range(1,count+1):case(side,pad,active=list(range(1,count+1)))
 for pad in range(1,5):
  case(side,pad,'early',duration=1)
  case(side,pad,'pass',duration=1)
  case(side,pad,'mash')
  case(side,pad,'y')
  case(side,pad,'ypass')
 for first,second in [(1,4),(4,1),(2,3),(3,2)]:case(side,first,'duel',second=second)
 case(side,1,'simultaneous')
 case(side,1,'steal',second=4)
 for count in [1,2,3,4]:case(side,1,'hold',active=list(range(1,count+1)))
if a.catch_handoff:
 for side in (5,11):
  for count in range(1,5):
   for manualpad in range(1,count+1):
    for requester in range(1,count+1):
     case(side,requester,active=list(range(1,count+1)));cases[-1]['handoff_pad']=manualpad
  for manualpad in range(1,5):
   case(side,manualpad,'hold');cases[-1]['handoff_pad']=manualpad
if a.fixtures_root:
 dump=w/'dump.py';dump.write_text("from pathlib import Path\ndef test_tick(f):\n if f==100:Path(outdir,'workram.bin').write_bytes(bytes(wram))\n")
 for side,name in [(5,'home'),(11,'away')]:
  folder=w/f'dive-{side}';folder.mkdir(exist_ok=True);state=a.fixtures_root/f'goalie-recovery-20261024-145816/scenario-{name}/before-dive.state';assert state.exists();cmd=folder/'commands.txt';cmd.write_text(f'100 load {state.resolve()}\n101 exit\n');env={k:v for k,v in os.environ.items() if k not in ['GPGX_SRM','GPGX_HOOK','GPGX_WAV','GPGX_DEV2']};env.update(GPGX_DEV='513',GPGX_HOOK=str(dump))
  with (folder/'log.txt').open('w') as log:subprocess.run(['python3',str(here/'test_core.py'),str(a.rom.resolve()),str(cmd),str(folder)],env=env,check=True,stdout=log,stderr=log)
  for pad in range(1,5):case(side,pad,'dive',delay=1);cases[-1]['dive_wram']=str(folder/'workram.bin')
def run(item):
 index,spec=item;name=f"{index:03}-{spec['mode']}-g{spec['side']}-p{spec['pad']}-n{len(spec['active'])}";p=w/name;p.mkdir(exist_ok=True);(p/'spec.json').write_text(json.dumps(spec,indent=2)+'\n');end=250 if spec['mode']=='hold' else 400;cmd=p/'commands.txt';cmd.write_text(f'100 load {a.state.resolve()}\n{end} snap\n{end+1} exit\n')
 env={k:v for k,v in os.environ.items() if k not in ['GPGX_SRM','GPGX_HOOK','GPGX_WAV','GPGX_DEV2']};env.update(GPGX_DEV='1281',GPGX_HOOK=str(here/'four_goalie_hook.py'))
 with (p/'log.txt').open('w') as log:subprocess.run(['python3',str(here/'test_core.py'),str(w/'observed.bin'),str(cmd),str(p)],env=env,check=True,stdout=log,stderr=log,timeout=20)
 rows=[json.loads(s) for s in (p/'trace.jsonl').read_text().splitlines()];side=spec['side'];pad=spec['pad'];mode=spec['mode'];initial=rows[0][1]['controls']
 if mode in ['y','steal']:
  taken=next(((f,r) for f,r in rows if f>150 and not r['bank'] and r['controls'][pad-1]==side),None);assert taken,(name,'Y did not take keeper')
  for f,r in rows:
   if taken[0]<=f<=175 and not r['bank']:
    assert r['controls'][pad-1]==side and all(r['controls'][i]==initial[i] for i in range(4) if i!=pad-1),(name,'Y stole/changed another pad',f,r)
  return dict(case=name,taken=taken[0])
 caught=next(((f,r) for f,r in rows if r['owner']==side),None);assert caught,(name,'no native catch')
 if spec.get('handoff_pad'):
  settled=next(((t,r) for t,r in rows if t>=caught[0] and r['owner']==side and not r['bank'] and not r['flags']&0x0800 and r['controls'][spec['handoff_pad']-1]!=side),None)
  assert settled and settled[0]<=caught[0]+2,('manual keeper not returned to AI on catch',name,caught,settled)
  initial=settled[1]['controls'];caught=settled
  assert all(initial[i-1]<12 and initial[i-1]!=side for i in spec['active']),('invalid post-catch skater',name,initial)

 assert not caught[1]['flags']&0x0800,(name,'native catch took the keeper from a skater')
 # A video boundary may interrupt BC64 after ownership changes but before it
 # returns. Observe a completed native pass, after its synchronous target hook.
 passed=next(((f,r) for f,r in rows if f>caught[0] and r['passer']==side and r['owner']!=side and not r['whistle']&0x100 and r['transient']=='0000000000000000'),None)
 if mode=='hold':
  assert passed is None,(name,'passed without a request');assert all(not r['flags']&0x0800 and r['controls']==initial for f,r in rows if caught[0]<=f<=end and not r['bank']),(name,'unrequested goalie takeover')
  return dict(case=name,held=True,caught=caught[0])
 assert passed,(name,'no requested native pass')
 f,r=passed;target=r['controls'][pad-1] if a.switch_on_b and mode!='ypass' else initial[pad-1]
 assert r['target']==target,(name,'wrong requested receiver',target,passed)
 assert r['pressed'] is not None and f>r['pressed'],(name,'pass before button')
 if a.switch_on_b and mode!='ypass':
  assert target<12 and target//6==side//6,(name,'wrong team after B',target)
  if mode not in ('duel','simultaneous'):
   assert all(q['controls'][i]==initial[i] for t,q in rows if max(caught[0],r['pressed'])<=t<=f and not q['bank'] for i in range(4) if i!=pad-1),(name,'B stole another pad')
 else:
  assert all(q['controls']==initial for t,q in rows if max(caught[0],r['pressed'])<=t<=f and not q['bank']),(name,'request switched a skater')
 assert all(q['pending'][0] in [0,(pad-1)*2+1] for t,q in rows if max(caught[0],r['pressed'])<=t<=f),(name,'wrong physical pad stored in pending request')
 assert not r['flags']&0xA,(name,'pass during recovery')
 assert r['pending']==[0,0] and r['transient']=='0000000000000000',(name,'request leaked')
 gx,gy=r['gk'];tx,ty=r['objects'][target][:2] if a.switch_on_b else r['humans'][pad-1];vx,vy=r['velocity'];assert (tx-gx)*vx+(ty-gy)*vy>0,(name,'pass vector points away from requester')
 if mode=='dive':assert any(q['anim']==0x2f4 for t,q in rows if t<=f),(name,'fixture did not exercise a real dive')
 return dict(case=name,caught=caught[0],pressed=r['pressed'],passed=f,target=target,queued_recovery=mode=='dive')
with ThreadPoolExecutor(max_workers=3) as pool:results=list(pool.map(run,enumerate(cases)))
(w/'results.json').write_text(json.dumps(results,indent=2)+'\n');print('PASS',len(results),'native adapter keeper cases: pads1..4, teams, counts, hold, taps, queue priority, Y, no stealing, recovery')
