#!/usr/bin/env python3
"""Independent pass speed: paired native intercept, ABI, menu and co-op input.

All saves are private copies. Native first-game completion uses an explicit puck
at the goal line, not a fake score/save. Physics fixtures supply solver inputs;
no shot result, intercept or selected velocity is fabricated.
"""
from pathlib import Path
from menu_protocol import row_word,down_count,render_probe,requested_menu_expectations
from concurrent.futures import ThreadPoolExecutor
import argparse,json,struct,subprocess,os,hashlib
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)=='0f7f080a30b52e536d7107ffc75044c21c1c617aa5d0007ad6203349604e2a69'
env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')}
def asm(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 core(p,rom,commands,settings=None):
 (p/'commands.txt').write_text('\n'.join(commands)+'\n')
 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|(settings or {}),stdout=log,stderr=log,check=True,timeout=90)
def dumps(p):return {l.split()[1]:bytes.fromhex(' '.join(l.split()[2:])) for l in (p/'commands.out').read_text().splitlines()}
def pack(b):
 for n in range(0,len(b),8192):b[n+0x1fff]=sum(b[n:n+0x1ffe])&255;b[n+0x1ffe]=b[n+0x1fff]^255
 r=bytearray(len(b)*2);r[1::2]=b;return r
# Paired direct native clamp call contract atBF54, including both CCR extremes.
abi_specs=[(role,time,ccr) for role in [0,4] for time in [-1,0,1,2,3,4,12,23,24,40] for ccr in [0,31]]
def clamp_abi(item):
 profile,mode=item;p=o/('clamp-abi-'+profile);p.mkdir(exist_ok=True);b=[' .text',' .globl _start','_start:',' move.w #0x2700,%sr',' movea.l #0xfffff900,%sp']
 for j,(role,time,ccr) in enumerate(abi_specs):
  dest=0xffff4000+j*68;b += [f' move.w #{mode},0xfffff0e8',' move.w #0x003f,0xffffb776',f' move.w #{role},0xffffb07e']
  for i in range(8):b += [f' move.l #0x{0x12340000+i*0x10001:x},%d{i}']
  for i in range(7):b += [f' movea.l #0x{0xffffa000+i*0x100:x},%a{i}']
  b += [' lea 0xffffb04a,%a3',f' move.w #{time},%d2',f' move.w #{0x2700|ccr},%sr',' jsr 0xbf54',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}',f' move.w 0xffffb776,0x{dest+66:x}']
 b += [' move.l #0x43414249,0xffff5ff0','end: bra.s end'];c=asm(p,'\n'.join(b)+'\n',0x2d5000);r=bytearray((a.baseline if profile=='baseline' else a.rom).read_bytes());assert len(c)<0x4000 and set(r[0x2d5000:0x2d5000+len(c)])=={255} and r[0xbf5a:0xbf5c]==bytes.fromhex('4e71');r[0xbf5a:0xbf5c]=bytes.fromhex('4e75');r[0x2d5000:0x2d5000+len(c)]=c;struct.pack_into('>I',r,4,0x2d5000);(p/'rom.bin').write_bytes(r);core(p,p/'rom.bin',['2000 dump ff5ff0 4 done',f'2000 dump ff4000 {68*len(abi_specs)} result','2001 exit']);d=dumps(p);assert d['done']==b'CABI';return profile,d['result']
with ThreadPoolExecutor(max_workers=3) as pool:clamp=dict(pool.map(clamp_abi,[('baseline',0),('Normal',0),('invalid2',2),('invalid-negative',65535)]))
for profile in ['Normal','invalid2','invalid-negative']:assert clamp[profile]==clamp['baseline'],('direct clamp register/CCR/stack/velocity ABI',profile)

# Controls run the original native solver at lower input power, not copied math.
specs=[]
for role in [0,4]:
 for direction in [-1,1]:
  for distance in [50,90,160,240]:
   for velocity in [-10000,-7000,0,4000]:
    for vector in range(3):
     dx=direction*distance;dy=0 if vector==0 else direction*distance//2*(1 if vector==1 else -1);vx=direction*velocity if vector!=2 else 0;vy=0 if vector==0 else direction*velocity//2 if vector==1 else direction*velocity
     specs.append(dict(role=role,dx=dx,dy=dy,vx=vx,vy=vy,power=[100,180,252][vector]))
def physics(profile,height,speed,batch,parent=None):
 p=o/f'physics-{profile}-height{height}-{batch}';p.mkdir(exist_ok=True);subset=specs[64*batch:64*(batch+1)];b=[' .text',' .globl _start','_start:',' move.w #0x2700,%sr',' movea.l #0xfffff900,%sp']
 for j,s in enumerate(subset):
  dest=0xffff4000+j*84;power=s['power']//4
  if profile=='slow-control':
   slower=max(1,power*4//5);nvx=s['vx']*240>>16;nvy=s['vy']*240>>16
   if nvx*nvx+nvy*nvy<slower*slower:power=slower
  b += [f' move.w #{height},0xfffff0dc',f' move.w #{speed},0xfffff0e8',' clr.w 0xfffff0ea',' move.w #0x003f,0xffffb776',f' move.w #{s["power"]},0xffffbede',' lea 0xffffb04a,%a3',' lea 0xffffb0ca,%a0',f' move.w #{s["role"]},0x34(%a3)',f' move.w #{s["vx"]},0x28(%a0)',f' move.w #{s["vy"]},0x2a(%a0)',f' move.w #{power},%d5']
  if profile=='slow-control':b += [f' move.w #{parent[j][3]//120},0xffffef7e']
  b += [f' bsr.w pass{j}',f' move.w %sr,0x{dest+12:x}',f' movem.l %d0-%d7/%a0-%a6,0x{dest+14:x}',f' move.l %sp,0x{dest+74:x}',f' move.w 0xffffb772,0x{dest:x}',f' move.w 0xffffb774,0x{dest+2:x}',f' move.w 0xffffb776,0x{dest+4:x}',f' move.w %d2,0x{dest+6:x}',f' move.w 0x44(%a0),0x{dest+8:x}',f' move.w 0x46(%a0),0x{dest+10:x}',f' move.w 0x40(%a0),0x{dest+78:x}',f' move.w 0xffffb7a8,0x{dest+80:x}',f' move.w 0xfffff0ea,0x{dest+82:x}',f' bra.s after{j}',f'pass{j}: move.w #{s["dy"]},-(%sp)',f' move.w #{s["dx"]},-(%sp)',' jmp 0xbee8',f'after{j}:']
 b += [' move.l #0x53504544,0xffff5ff0','end: bra.s end'];c=asm(p,'\n'.join(b)+'\n',0x2d5000);r=bytearray((a.baseline if profile in ['baseline','slow-control'] else a.rom).read_bytes());assert len(c)<0x4000 and set(r[0x2d5000:0x2d5000+len(c)])=={255}
 if profile=='slow-control':
  cc=asm(p,""" .text
 .globl _start
_start:
 jsr 0x2ef00c
 cmp.w 0xffffef7e,%d2
 bge.s done
 move.w 0xffffef7e,%d2
done: rts
""",0x2d9400);assert set(r[0x2d9400:0x2d9400+len(cc)])=={255};r[0x2d9400:0x2d9400+len(cc)]=cc;r[0xbf54:0xbf5a]=bytes.fromhex('4eb9002d9400')
 r[0x2d5000:0x2d5000+len(c)]=c;struct.pack_into('>I',r,4,0x2d5000);(p/'rom.bin').write_bytes(r);core(p,p/'rom.bin',['3000 dump ff5ff0 4 done',f'3000 dump ff4000 {84*len(subset)} result','3001 exit']);d=dumps(p);assert d['done']==b'SPED';raw=[d['result'][j*84:(j+1)*84] for j in range(len(subset))];return dict(profile=profile,height=height,speed=speed,batch=batch,rows=[list(struct.unpack('>6h',x[:12])) for x in raw],raw=raw)
with ThreadPoolExecutor(max_workers=3) as pool:parents=list(pool.map(lambda q:physics('baseline',q[0],0,q[1]),[(v,b) for v in [0,1] for b in range(3)]))
profiles=[]
for par in parents:
 height,batch=par['height'],par['batch']
 with ThreadPoolExecutor(max_workers=3) as pool:part=list(pool.map(lambda q:physics(q[0],height,q[1],batch,par['rows']),[('Normal',0),('Slow',1),('invalid2',2),('invalid-negative',65535),('slow-control',1)]))
 control=part[-1]
 for current in part[:-1]:
  for j,(b,n) in enumerate(zip(par['rows'],current['rows'])):
   raw=current['raw'][j]
   if current['speed']!=1:
    assert raw[:82]==par['raw'][j][:82],('Normal complete register/CCR/stack/physics/timer ABI',current['profile'],height,specs[batch*64+j],b,n)
   else:
    expected=control['rows'][j];assert n[:2]+n[3:]==expected[:2]+expected[3:],('native lower-power intercept control',height,specs[batch*64+j],b,n,expected)
    assert n[2]==b[2],('independent original lift/fraction',height,specs[batch*64+j],b,n)
    assert n[0]*n[0]+n[1]*n[1]<=b[0]*b[0]+b[1]*b[1],('Slow must not create a faster pass',height,specs[batch*64+j],b,n)
    assert n[3]>=b[3] and int.from_bytes(raw[82:84],'big')==b[3]//120,('arrival floor/original-time cache',b,n)
    assert raw[78:82]==control['raw'][j][78:82],('native receiver/puck catch timers',b,n)
 for current in [par]+part:
  current.pop('raw');profiles.append(current)


# Actual native row renderer: the new fifteenth row is logicalD7=28.
p=o/'renderer';p.mkdir(exist_ok=True);c=asm(p,render_probe(""" .text
 .globl _start
_start:
 cmpi.w #28,%d7
 bne.s original
 move.w %sr,-(%sp)
 addq.w #1,0xFFF080
 move.l %a1,0xFFF084
 move.w 0xFFB028,0xFFF08C
 move.w (%sp)+,%sr
original: jmp 0x11BA4
""",a.rom,[28]),0x2fea00);r=bytearray(a.rom.read_bytes());assert set(r[0x2fea00:0x2fea00+len(c)])=={255} and r[0xf7bf4:0xf7bfa]==bytes.fromhex('4ef900011ba4');r[0x2fea00:0x2fea00+len(c)]=c;r[0xf7bf4:0xf7bfa]=bytes.fromhex('4ef9002fea00');(o/'observed.bin').write_bytes(r)
def battery(gp=0,modes=(0,0,1),bad=None,heights=(1,0,1)):
 base=bytearray(a.seed.read_bytes()[1:16384:2]);base[0xe20:0x1ffe]=bytes(0x11de);banks=[]
 for slot,length in [(1,84),(2,31),(3,7)]:
  q=bytearray(base);struct.pack_into('>4s6H',q,0x1000,b'SESN',gp,11 if gp else 0,gp,length,0 if gp else slot,3);struct.pack_into('>4s4H',q,0x1ea0,b'S3SV',1,slot,7,1);q[0x1ef0:0x1ef6]=b'PC\x01\xfe\x01\xfe';q[0x1ff6:0x1ffe]=bytes([0x55,0,0,2,2,0,0x5a,0]);v=0x50+heights[slot-1]+2*modes[slot-1];q[0x1eee:0x1ef0]=bytes([v,v^255]);banks.append(q)
 b=bytearray(banks[0])+b''.join(banks);b[0x2e18:0x2e20]=b'RP\x01\xfeCT\x01\xfe';b[0x4d20:0x4d22]=b'\x51\xae'
 if bad is not None:
  for bank in [0,0x2000]:b[bank+0x1eee:bank+0x1ef0]=bytes.fromhex(bad)
 return pack(b)

def menu_row(rom,target,start,end):
 target=row_word(rom,target)
 highest=int.from_bytes(rom.read_bytes()[0xf777a:0xf777c],'big')-2
 up=(highest-target)//2;assert up>=0
 count=(highest+2)//2+2;firstup=end-35*(up-1) if up else end+35;step=min(25,(firstup-start-20)//(count-1));assert step>=10
 assert start+step*(count-1)+10<firstup,'independent Down and Up edges required'
 return [f'{start+step*i} press 1 Down 5' for i in range(count)]+[f'{end-35*(up-1-i)} press 1 Up 5' for i in range(up)]
hook=h/'testcases/pass_speed_menu_hook.py';reports=[]
def menu(name,raw,commands,checks,dev=513,render=False,finish=False):
 requested_menu_expectations(a.rom,name,checks)
 if name.startswith('corrupt-'):
  for frame,want in checks.items():
   if int(frame) in [1000,1400]:want['height']=1
 p=o/name;p.mkdir(exist_ok=True);(p/'battery.srm').write_bytes(raw);(p/'spec.json').write_text(json.dumps(dict(name=name,checks=checks,render=render,finish=finish)));core(p,o/'observed.bin',commands,dict(GPGX_DEV=str(dev),GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(hook)));d=json.loads((p/'results.json').read_text());reports.append(dict(name=name,observations=d));return(p/'battery.srm').read_bytes()
for dev in [513,1281]:
 down=menu_row(a.rom,28,900,1840)
 menu('fresh-'+str(dev),bytes(65536),down+['1920 snap','2001 exit'],{1900:dict(mode=0,height=0,slot=1)},dev,True)
 raw=menu('toggle-'+str(dev),battery(),down+['2000 press 1 Right 5','2120 snap','2200 press 1 Left 5','2400 press 1 Right 5','2600 press 1 Down 5','2801 exit'],{1900:dict(mode=0,height=1,slot=1),2100:dict(mode=1,height=1,record='53ac'),2300:dict(mode=0,height=1,record='51ae'),2500:dict(mode=1,height=1,record='53ac')},dev,True)
 before=raw[1::2];assert before[0x1eee:0x1ef0]==b'\x53\xac' and before[0x1fff]==sum(before[:0x1ffe])%256
 raw=menu('slots-'+str(dev),raw,['1100 press 1 Right 5','1300 press 1 Right 5','1500 press 1 Left 5','1700 press 1 Left 5','1901 exit'],{1000:dict(mode=1,height=1,slot=1),1200:dict(mode=0,height=0,slot=2),1400:dict(mode=1,height=1,slot=3),1600:dict(mode=0,height=0,slot=2),1800:dict(mode=1,height=1,slot=1)},dev)
 menu('reboot-'+str(dev),raw,['1301 exit'],{1200:dict(mode=1,height=1,slot=1)},dev)
 menu('locked-'+str(dev),battery(1,(1,0,1)),down+['2000 press 1 Left 5','2200 press 1 Right 5','2401 exit'],{1900:dict(mode=1,height=1,slot=1),2100:dict(mode=1,height=1,record='53ac'),2300:dict(mode=1,height=1,record='53ac')},dev,True)
 native=[f'{1000+200*i} press 1 Right 5' for i in range(3)]+menu_row(a.rom,28,1800,2800)+['3000 press 1 Right 5','3200 press 1 Left 5','3401 exit']
 raw=menu('native-'+str(dev),battery(1,(1,0,1)),native,{1600:dict(mode=0,height=1,view=0),2900:dict(mode=0,height=1,view=0),3100:dict(mode=1,height=1,global_record='53ac'),3300:dict(mode=0,height=1,global_record='51ae')},dev)
 menu('native-reboot-'+str(dev),raw,[f'{1000+200*i} press 1 Right 5' for i in range(3)]+['1701 exit'],{1600:dict(mode=0,height=1,view=0,global_record='51ae')},dev)
 for bad in ['0000','50ae','40bf','54ab']:
  menu('corrupt-'+bad+'-'+str(dev),battery(1,(1,1,0),bad),['1100 press 1 Right 5','1300 press 1 Left 5','1501 exit'],{1000:dict(mode=0,height=0,slot=1),1200:dict(mode=1,height=0,slot=2),1400:dict(mode=0,height=0,slot=1)},dev)
 raw=menu('first-game-'+str(dev),battery(0,(1,0,1)),['1500 press 1 Start 5','1900 press 1 Start 5','16001 exit'],{1200:dict(mode=1,height=1),3300:dict(mode=1,height=1)},dev,False,True)
 menu('first-game-locked-'+str(dev),raw,down+['2000 press 1 Left 5','2200 press 1 Right 5','2401 exit'],{1900:dict(mode=1,height=1,gp=1),2100:dict(mode=1,height=1),2300:dict(mode=1,height=1)},dev,True)
 lines=['1100 press 1 Right 5','1300 press 1 Start 5']+[f'{1700+i*100} press 1 Down 5' for i in range(8)]+['2600 press 1 C 5','2900 press 1 Start 5','3200 press 1 C 5','3500 press 1 A 5','3500 press 1 C 5','4100 press 1 Left 5','4500 press 1 Right 5','4700 press 1 Right 5','5101 exit']
 menu('restart-'+str(dev),battery(1,(1,1,0)),lines,{3100:dict(mode=1,height=0,slot=2,gp=1),3900:dict(mode=0,height=0,slot=2,gp=0),4300:dict(mode=1,height=1,slot=1),4600:dict(mode=0,height=0,slot=2),4900:dict(mode=0,height=1,slot=3)},dev)
 # Both actual menu writers must preserve the companion setting in the same record.
 lines=menu_row(a.rom,26,900,1840)+['2000 press 1 Right 5','2200 press 1 Left 5','2400 press 1 Down 5','2600 press 1 Right 5','2800 press 1 Left 5','3001 exit']
 menu('independent-rows-'+str(dev),battery(0,(1,0,1)),lines,{1900:dict(mode=1,height=1),2100:dict(mode=1,height=0,record='52ad'),2300:dict(mode=1,height=1,record='53ac'),2700:dict(mode=0,height=1,record='51ae'),2900:dict(mode=1,height=1,record='53ac')},dev)
 # Native global preferences survive a complete slot circle and cold boot, with goalie bytes intact.
 raw=battery(0,(1,0,1));b=bytearray(raw[1::2]);b[0xe20:0xe25]=b[0x2e20:0x2e25]=bytes.fromhex('123456789a');raw=pack(b)
 lines=[f'{1000+200*i} press 1 Right 5' for i in range(3)]+menu_row(a.rom,28,1800,2800)+['3000 press 1 Right 5','3200 press 1 Up 5','3400 press 1 Right 5',]+[f'{3600+70*i} press 1 Up 5' for i in range(down_count(a.rom,13))]+[f'{4600+200*i} press 1 Left 5' for i in range(3)]+[f'{5200+200*i} press 1 Right 5' for i in range(3)]+['5901 exit']
 raw=menu('global-circle-'+str(dev),raw,lines,{1600:dict(mode=0,height=1,view=0,full_goalie='123456789a'),3100:dict(mode=1,height=1,global_record='53ac',full_goalie='123456789a'),3500:dict(mode=1,height=0,global_record='52ad',full_goalie='123456789a'),5100:dict(mode=1,height=1,slot=1,full_goalie='123456789a'),5700:dict(mode=1,height=0,view=0,global_record='52ad',full_goalie='123456789a',bank_checksums=[True]*4)},dev)
 menu('global-circle-reboot-'+str(dev),raw,[f'{1000+200*i} press 1 Right 5' for i in range(3)]+['1701 exit'],{1600:dict(mode=1,height=0,view=0,global_record='52ad',full_goalie='123456789a')},dev)
# Paired real menu negative control: r4's old global height record aliases Full goalie data.
storage=[]
for label,rom in [('parent-r4',a.baseline),('current',a.rom)]:
 p=o/('storage-'+label);p.mkdir(exist_ok=True);raw=battery();b=bytearray(raw[1::2]);b[0xe20:0xe25]=b[0x2e20:0x2e25]=bytes.fromhex('123456789a');b[0x4d20:0x4d22]=bytes.fromhex('50af');(p/'battery.srm').write_bytes(pack(b));(p/'spec.json').write_text(json.dumps(dict(name=label,checks={1600:dict(height=0,view=0),3000:dict(height=1,full_goalie='51ae56789a' if label=='parent-r4' else '123456789a')},render=False,finish=False,legacy_storage=label=='parent-r4')));lines=[f'{1000+200*i} press 1 Right 5' for i in range(3)]+menu_row(rom,26,1800,2780)+['2900 press 1 Right 5','3101 exit'];core(p,rom,lines,dict(GPGX_DEV='513',GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(hook)));storage.append(dict(profile=label,observations=json.loads((p/'results.json').read_text())))

# Controller-driven co-op passes, observed before native XY work with no game-state injection.
p=o/'pass-probe';p.mkdir(exist_ok=True);c=asm(p,(h/'testcases/pass_speed_probe.s').read_text(),0x2d9000);r=bytearray(a.rom.read_bytes());assert set(r[0x2d9000:0x2d9000+len(c)])=={255} and r[0xbf70:0xbf78]==bytes.fromhex('3002e7400440000a');r[0x2d9000:0x2d9000+len(c)]=c;r[0xbf70:0xbf78]=bytes.fromhex('4ef9002d90004e71');(o/'pass-observed.bin').write_bytes(r)
def native_input(item):
 mode,height=item;p=o/f'native-input-{mode}-{height}';p.mkdir(exist_ok=True);(p/'battery.srm').write_bytes(battery(0,(mode,0,1),heights=(height,0,1)));(p/'spec.json').write_text(json.dumps(dict(mode=mode,height=height)));core(p,o/'pass-observed.bin',['900 press 1 Down 5','1000 press 1 Right 5','1400 press 1 Up 5','1500 press 1 Start 5','1900 press 1 Start 5','30002 exit'],dict(GPGX_DEV='513',GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(h/'testcases/pass_speed_input_hook.py')));d=json.loads((p/'native-input.json').read_text());human=[e for e in d['events'] if e['human']];assert len(human)>=3,('insufficient native human targeted passes',mode,height,human);changed=[e for e in human if e['time']>e['original_time']]
 if mode==1:assert changed,('Slow not exercised by native human input',mode,height,human)
 return dict(mode=mode,height=height,targeted_passes=len(d['events']),human_targeted_passes=len(human),human_slower_arrivals=len(changed),maximum_human_lift=max(e['vertical_velocity'] for e in human),private_probe_preserves_original_native_path=True)
with ThreadPoolExecutor(max_workers=2) as pool:played=list(pool.map(native_input,[(m,v) for m in [0,1] for v in [0,1]]))
(o/'results.json').write_text(json.dumps(dict(rom_sha256=sha(a.rom),baseline_sha256=sha(a.baseline),direct_clamp_ABI_contexts=len(abi_specs)*3,native_solver_contexts=len(specs),height_modes=2,profiles=profiles,Normal_invalid_complete_ABI_contexts=3*len(specs)*2,Slow_native_control_contexts=len(specs)*2,menu_cases=reports,paired_storage_control=storage,native_coop_input=played),indent=2)+'\n');print('PASS',len(specs),'contexts x2heights x6profiles;',len(reports),'native menu/lifecycle cases;4co-op input games')
