#!/usr/bin/env python3
"""All four native human splits, physical input ports, keeper requests and inactive pads.

Uses private cold batteries and an explicitly hashed passive P3/P4 bank observer.
No controller-side/actor ownership words or successful responses are injected.
"""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import argparse,os,json,hashlib,struct,subprocess
ap=argparse.ArgumentParser(description=__doc__);ap.add_argument('rom',type=Path);ap.add_argument('output',type=Path);ap.add_argument('--allstar',action='store_true');a=ap.parse_args()
h=Path(__file__).resolve().parent;o=a.output.resolve();o.mkdir(parents=True,exist_ok=True)
has_allstar=a.rom.read_bytes()[0x22c000:0x22c002]!=b'\xff\xff'
left_inputs=['1100 press 1 Left 5']+(['1250 press 1 Left 5']if has_allstar and not a.allstar else [])
if a.allstar:assert has_allstar

sha=lambda p:hashlib.sha256(p.read_bytes()).hexdigest()
# Extend the previously established bank observer to save the temporary side too.
s=(h/'testcases/manual_keeper_banks.s').read_text().replace('    move.w 0xFFFFC320,0xFFF0D6','    move.w 0xFFFFC328,0xFFF852\n    move.w 0xFFFFC320,0xFFF0D6').replace('    move.w 0xFFFFC322,0xFFF0D8','    move.w 0xFFFFC32A,0xFFF854\n    move.w 0xFFFFC322,0xFFF0D8')
(o/'banks.s').write_text(s);subprocess.run([str(h.parent/'m68kasm.sh'),str(o/'banks.s'),'2EF400',str(o/'banks.bin')],check=True,stdout=subprocess.DEVNULL)
rom=bytearray(a.rom.read_bytes());code=(o/'banks.bin').read_bytes();assert rom[0x2ef400:0x2ef400+len(code)]==b'\xff'*len(code);rom[0x2ef400:0x2ef400+len(code)]=code
for at,target,expected in [(0xad66,0x2ef400,'3f38c32831f8c32cc328'),(0xadce,0x2ef440,'31f8c320c32431dfc320'),(0xae00,0x2ef480,'3f38c32a31f8c32ec32a'),(0xae68,0x2ef4c0,'31f8c322c32631dfc322')]:
 assert rom[at:at+10]==bytes.fromhex(expected);rom[at:at+10]=bytes.fromhex('4ef9')+struct.pack('>I',target)+bytes.fromhex('4e714e71')
(o/'observed.bin').write_bytes(rom)
def run(c,device=1281):
 split,pad,button=c;p=o/(f'split{split}-pad{pad+1}-{button}'+(f'-device{device}' if device!=1281 else ''));p.mkdir(exist_ok=True)
 (p/'battery.srm').write_bytes(bytes(65536))
 lines=left_inputs+['1400 press 1 Down 5']+[f'{1550+150*i} press 1 Right 5'for i in range(split)]+['2300 press 1 Up 5']+[f'{2500+700*i} press 1 Start 5'for i in range(5)]+['9500 exit']
 (p/'commands.txt').write_text('\n'.join(lines)+'\n');env={k:v for k,v in os.environ.items()if not k.startswith('GPGX_')};env.update(GPGX_DEV=str(device),GPGX_DEV2=str(device),GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(h/'testcases/quickgame_input_hook.py'),QUICK_CASE=json.dumps(dict(split=split,pad=pad,button=button)),EXPECT_ALLSTAR=str(int(a.allstar)))
 with(p/'log').open('w')as log:subprocess.run(['python3',str(h/'test_core.py'),str(o/'observed.bin'),str(p/'commands.txt'),str(p)],env=env,stdout=log,stderr=log,check=True,timeout=50)
 return json.loads((p/'result.json').read_text())
cases=[(s,0,'none')for s in range(4)]+[(s,p,b)for s in range(4)for p in range(4)for b in ['Right','Left','Y','B']]
with ThreadPoolExecutor(max_workers=3)as pool:results=list(pool.map(run,cases))
(o/'raw-results.json').write_text(json.dumps(results,indent=2)+'\n')
checks=[]
for split in range(4):
 base=next(r for r in results if r['case']==dict(split=split,pad=0,button='none'));sides=base['rows'][0]['sides']
 for pad,side in enumerate(sides):
  rows={b:next(r for r in results if r['case']==dict(split=split,pad=pad,button=b))['rows']for b in ['Right','Left','Y','B']}
  actor=base['rows'][0]['actors'][pad]
  if side:
   assert rows['Right'][3]['actors'][pad]==rows['Left'][3]['actors'][pad]==actor,('direction observation actor changed before60frames',split,pad)
   delta=rows['Right'][3]['objects'][actor][0]-rows['Left'][3]['objects'][actor][0]
   assert delta>15,('physical direction did not reach its native actor',split,pad,actor,delta)
   assert any(r['actors'][pad]!=actor for r in rows['B'][2:4]),('B did not switch own skater by60frames',split,pad,actor,rows['B'])
   keeper=5 if side==1 else 11
   assert any(r['actors'][pad]==keeper for r in rows['Y'][2:]),('physical Y did not take own keeper',split,pad,keeper,rows['Y'])
   for b,rr in rows.items():
    assert all(r['sides']==sides for r in rr),('physical sides changed',split,pad,b)
    assert all(r['actors'][pad]<12 and r['actors'][pad]//6==side-1 for r in rr),('pad took opposite side',split,pad,b)
   checks.append(dict(split=split,pad=pad+1,side=side,direction_delta=delta,keeper=keeper,status='pass'))
  else:
   for b,rr in rows.items():assert all(r['sides'][pad]==0 for r in rr),('inactive pad obtained a side',split,pad,b)
   checks.append(dict(split=split,pad=pad+1,side=0,status='inactive side remains unassigned'))
two_pad=run((0,0,'none'),513)
assert two_pad['rows'][0]['sides']==[1,2,0,0] and all(r['sides']==[1,2,0,0] for r in two_pad['rows'])
# Inspect every actual Quickgame list, rather than a log label for removed actions.
image=a.rom.read_bytes();menu_rows=[]
for offset in range(0,40,4):
 ptr=struct.unpack_from('>I',image,0x22B000+offset)[0];i=ptr+8;names=[]
 while image[i:i+2]==b'\x00\x14':
  name=image[i+2:i+20].decode('ascii');target=struct.unpack_from('>I',image,i+20)[0]
  assert target not in [0x2fd000,0x2fa800,0x2f9000,0x2fa000],('forbidden exhibition action',offset,name)
  names.append(name.strip());i+=24
 assert image[i:i+4]==bytes.fromhex('0004ff00') and names
 menu_rows.append(dict(list_index=offset//4,names=names))
report=dict(two_pad_sides=two_pad['rows'][0]['sides'],actual_menu_rows=menu_rows,rom_sha256=sha(a.rom),observer_rom_sha256=sha(o/'observed.bin'),observer_assembly_sha256=sha(o/'banks.s'),hook_sha256=sha(h/'testcases/quickgame_input_hook.py'),cases=len(results),checks=checks,scope=__doc__)
(o/'results.json').write_text(json.dumps(report,indent=2)+'\n');print('PASS',len(results),'native cold-boot physical-input cases;',len(checks),'split/pad checks')
