#!/usr/bin/env python3
"""Paired native penalty-controller regressions; synthetic fixtures, real 68000/pads.

Tests the installed capture/setup instructions, ABI and full ref/shot/return path.
No live emulator/save is touched. Integration requests a synthetic breakaway
penalty on a real four-pad capture; it is not an unmodified natural foul replay.
"""
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);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)=='b28fa2c8f42bd792f3118bc3d71df2fed5f959957d4adc60f372f2d4863c2038','exact r10 baseline required'
env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')}
def assemble(p,source,origin=0x2d9000):
 (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/'driver.bin').write_bytes(rom);(p/'commands.txt').write_text(commands)
 with(p/'log.txt').open('w') as log:subprocess.run(['python3',str(h/'test_core.py'),str(p/'driver.bin'),str(p/'commands.txt'),str(p)],env=env|(extra or {}),stdout=log,stderr=log,check=True,timeout=45)
positive=[dict(team=t,count=n,pad=p,victim=(p+2)%5,kind='normal') for t in range(2) for n in range(1,5) for p in range(n)]
guards=[dict(team=t,count=4,pad=2,victim=4,kind=k) for t in range(2) for k in ('cpu','inactive','wrong-side','shootout','not-penalty','bad-magic','bad-pad','disconnected','wrong-team')]
cases=positive+guards

def abi(item):
 version,k,s=item;candidate=version=='current';p=o/f'abi-{version}-{k:02}';p.mkdir(exist_ok=True);base=s['team']*6;victim=base+s['victim'];shooter=base;goalie=11 if not base else 5;kind=s['kind']
 body=[' .text',' .globl _start','_start:',' move.w #0x2700,%sr',' movea.l #0xfff800,%sp',' lea 0xffb04a,%a0',' move.w #383,%d0','clear: clr.l (%a0)+',' dbra %d0,clear']
 for i in range(12):
  b=0xffb04a+i*128;body += [f' move.w #{0 if i%6==5 else 4},0x{b+0x34:x}',f' move.w #{i},0x{b+0x52:x}',f' move.b #{64*(i//6)},0x{b+0x62:x}']
 controls=[65535]*4;sides=[s['team']+1 if j<s['count'] else 0 for j in range(4)]
 for pad in range(s['count']):
  controls[pad]=victim if pad==s['pad'] else base+next(i for i in range(5) if base+i!=victim and base+i not in controls)
  body += [f' ori.b #8,0x{0xffb04a+128*controls[pad]+0x62:x}']
 if kind=='cpu':body += [f' andi.b #0xf7,0x{0xffb04a+128*victim+0x62:x}']
 if kind=='inactive':sides[s['pad']]=0
 if kind=='wrong-side':sides[s['pad']]=2-s['team']
 for pad in range(4):body += [f' move.w #{controls[pad]},0x{0xffc320+2*pad:x}',f' move.w #{sides[pad]},0x{0xffc328+2*pad:x}']
 body += [' move.w #1,0xffd046',' move.w #1,0xffd056',' clr.l 0xfff654',' clr.w 0xfff658',' clr.b 0xffc2fa']
 regs=[0x11223300+i for i in range(8)];regs[0]=(regs[0]&0xffff0000)|victim;addresses=[0xff1000+256*i for i in range(7)]
 for i,v in enumerate(regs):body += [f' move.l #0x{v:x},%d{i}']
 for i,v in enumerate(addresses):body += [f' movea.l #0x{v:x},%a{i}']
 body += [' move.w #0x271f,%sr',' jsr 0x121c2',' move.w %sr,0xff4080',' movem.l %d0-%d7/%a0-%a6,0xff4040',' move.l %sp,0xff4084',' move.l 0xfff654,0xff4000',' move.w 0xfff658,0xff4004',f' move.w #{s["team"]},0xffd404',f' move.w #{shooter},0xffd406',f' move.w #{goalie},0xffd408',f' move.b #{0 if kind=="not-penalty" else 4},0xffc2f2',f' move.b #{int(kind=="shootout")},0xffc2fa']
 if kind=='bad-magic':body += [' move.w #0x4321,0xfff654']
 if kind=='bad-pad':body += [' move.w #2,0xfff656']
 if kind=='disconnected':body += [f' clr.w 0x{0xffc328+2*s["pad"]:x}'];sides[s['pad']]=0
 if kind=='wrong-team':body += [f' move.w #{1-s["team"]},0xffd404']
 for i in range(12):body += [f' move.w #{4 if i==shooter else 0 if i==goalie else -1},0x{0xffb04a+128*i+0x34:x}']
 body += [' lea 0xffb74a,%a3',' moveq #-1,%d6',' jsr 0xf20c',' move.l 0xffc320,0xff4008',' move.l 0xffc324,0xff400c',' move.l 0xffc328,0xff4010',' move.l 0xffc32c,0xff4014',' move.l %sp,0xff4088',' move.w 0xfff654,0xff408c',' lea 0xffb04a,%a0',' lea 0xff4100,%a1',' move.w #383,%d0','objects: move.l (%a0)+,(%a1)+',' dbra %d0,objects',' move.l #0x50534f57,0xff3ff0','done: bra.s done']
 code=assemble(p,'\n'.join(body)+'\n');r=bytearray((a.rom if candidate else a.baseline).read_bytes());assert len(code)<0x1800 and set(r[0x2d9000:0x2d9000+len(code)])=={255};r[0x2d9000:0x2d9000+len(code)]=code;struct.pack_into('>I',r,4,0x2d9000)
 for at in [0x121cc,0xf26c]:r[at:at+2]=bytes.fromhex('4e75')
 run(p,r,'100 dump ff3ff0 4 done\n100 dump ff4000 1792 result\n101 exit\n')
 d={l.split()[1]:bytes.fromhex(' '.join(l.split()[2:])) for l in (p/'commands.out').read_text().splitlines()};assert d['done']==b'PSOW';raw=d['result'];record=list(struct.unpack_from('>3H',raw));controlled=list(struct.unpack_from('>4H',raw,8));got_sides=list(struct.unpack_from('>4H',raw,16))
 expected_regs=regs+addresses;expected_regs[9]=0xffffb04a
 assert list(struct.unpack_from('>15I',raw,0x40))==expected_regs,('capture ABI',version,s)
 assert int.from_bytes(raw[0x80:0x82],'big')==(0x2714 if victim==0 else 0x2710),('native capture CCR',version,s,raw[0x80:0x82].hex())
 assert int.from_bytes(raw[0x84:0x88],'big')==int.from_bytes(raw[0x88:0x8c],'big')==0xfff800,('native stack',version,s)
 assert got_sides==sides,('side setting changed',version,s,got_sides,sides)
 if candidate:
  captured=kind not in ('cpu','inactive','wrong-side');assert record==([0x5053,s['pad']*2+1,s['team']+1] if captured else [0,0,0]),('capture',s,record)
  assert raw[0x8c:0x8e]==b'\0\0',('stale award record',s)
  if kind=='normal':
   expected=[65535]*4;expected[s['pad']]=shooter;assert controlled==expected,('wrong physical shooter',s,controlled,expected)
 return dict(version=version,case=k,spec=s,record=record,controls=controlled,sides=got_sides,objects=raw[0x100:].hex())

with ThreadPoolExecutor(max_workers=3) as pool:unit=list(pool.map(abi,[(v,k,s) for v in ('baseline','current') for k,s in enumerate(cases)]))
for k,s in enumerate(cases):
 old=next(x for x in unit if x['version']=='baseline' and x['case']==k);new=next(x for x in unit if x['version']=='current' and x['case']==k)
 if s['kind']!='normal':assert (old['controls'],old['objects'])==(new['controls'],new['objects']),('guard changed native result',s,old['controls'],new['controls'])
 else:
  ob=bytes.fromhex(old['objects']);nb=bytes.fromhex(new['objects']);assert all(x==y or i%128==0x62 and not((x^y)&~10) for i,(x,y) in enumerate(zip(ob,nb))),('nonownership actor field changed',s)
for x in unit:x['objects_sha256']=hashlib.sha256(bytes.fromhex(x.pop('objects'))).hexdigest()

# Instrument only this private ROM. The published bytes are tested above.
probes={}
for version,rom in [('baseline',a.baseline),('current',a.rom)]:
 p=o/('probe-'+version);p.mkdir(exist_ok=True);source=(h/'testcases/penalty_owner_probe.s').read_text()
 if version=='baseline':source=source.replace(' jsr 0x2da804',' jsr 0xfd22\n move.w 0xffffd408,%d0')
 code=assemble(p,source);r=bytearray(rom.read_bytes());assert len(code)<0x400 and set(r[0x2d9000:0x2d9000+len(code)])=={255};r[0x2d9000:0x2d9000+len(code)]=code;assert r[0x15ef2:0x15ef8]==bytes.fromhex('4eb9002e1004');r[0x15ef2:0x15ef8]=bytes.fromhex('4eb9002d9000');r[0xf20c:0xf214]=bytes.fromhex('4eb9002d90044e71');probes[version]=r

def integration(item):
 version,k,s=item;p=o/f'game-{version}-{k:02}';p.mkdir(exist_ok=True);(p/'spec.json').write_text(json.dumps(s));run(p,probes[version],f'100 load {a.state.resolve()}\n6001 exit\n',dict(GPGX_DEV='1281',GPGX_HOOK=str(h/'testcases/penalty_owner_hook.py')))
 result=json.loads((p/'results.json').read_text());assignment=result['assignment'];assert assignment and assignment['penalty']==60,('native award/setup not reached',version,s)
 if version=='current':
  assert assignment['observed'][s['pad']]==s['team']*6 and sum(x==s['team']*6 for x in assignment['observed'])==1,('native setup physical pad',s,assignment)
  assert assignment['record']==[0x5053,2*s['pad']+1,s['team']+1],('native captured pad',s,assignment)
  assert result['return_to_game'],('native attempt did not finish',s)
  assert result['movement'] and max(abs(x['position'][1]-result['movement'][0]['position'][1]) for x in result['movement'])>16,('selected physical pad cannot skate shooter',s)
  expected=[s['team']+1]*s['count']+[0]*(4-s['count']);assert all(x['sides']==expected for x in result['trace']),('sides not restored',s)
 return dict(version=version,spec=s,assignment=assignment,returned=result['return_to_game'],controller_movement_samples=len(result['movement']))
with ThreadPoolExecutor(max_workers=3) as pool:games=list(pool.map(integration,[(v,k,s) for v in ('baseline','current') for k,s in enumerate(positive)]))
report=dict(rom_sha256=sha(a.rom),baseline_sha256=sha(a.baseline),state_sha256=sha(a.state),abi_cases=unit,integration_cases=games,scope=__doc__,baseline_wrong_shooter_cases=sum(x['assignment']['observed'][x['spec']['pad']]!=x['spec']['team']*6 for x in games if x['version']=='baseline'))
(o/'results.json').write_text(json.dumps(report,indent=2)+'\n');print('PASS',len(unit),'paired native capture/setup/guard ABI cases and',len(games),'paired synthetic-award/full ref/setup/physical-input/return integrations')
