#!/usr/bin/env python3
"""Optional pass height: native solver, real menu input, slot/lock/boot lifecycle.

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)=='40b3a5700ae7c3ce6f0536b878a22b2e7284a8e418231ed6a6ea4ce891fb3548'
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
# Full native lift ABI: the Low control changes only the original two cap bytes.
abi_specs=[(time,ccr) for time in [-1,0,4,6,7,11,12,13,24,25,127] for ccr in [0,31]]
def abi(item):
 profile,mode=item;p=o/('abi-'+profile);p.mkdir(exist_ok=True);b=[' .text',' .globl _start','_start:',' move.w #0x2700,%sr',' movea.l #0xfffff900,%sp']
 for j,(time,ccr) in enumerate(abi_specs):
  dest=0xffff4000+j*68;b += [f' move.w #{mode},0xfffff0dc',' move.w #0x003f,0xffffb776']
  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 += [f' move.w #{time},%d2',f' move.w #{0x2700|ccr},%sr',' jsr 0xbf5e',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 #0x41424921,0xffff4ff0','end: bra.s end'];c=asm(p,'\n'.join(b)+'\n',0x2d5000);r=bytearray((a.baseline if profile in ['Normal-control','Low-control'] else a.rom).read_bytes());assert len(c)<0x4000 and set(r[0x2d5000:0x2d5000+len(c)])=={255}
 if profile=='Low-control':
  assert r[0xbf65]==r[0xbf6d]==12;r[0xbf65]=r[0xbf6d]=6
 r[0xbf70:0xbf72]=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 ff4ff0 4 done',f'2000 dump ff4000 {68*len(abi_specs)} result','2001 exit']);d=dumps(p);assert d['done']==b'ABI!';return profile,d['result']
with ThreadPoolExecutor(max_workers=3) as pool:ab=dict(pool.map(abi,[('Normal-control',0),('Low-control',1),('Normal',0),('Low',1),('invalid2',2),('invalid-negative',65535)]))
for profile in ['Normal','Low','invalid2','invalid-negative']:assert ab[profile]==ab['Low-control' if profile=='Low' else 'Normal-control'],('lift registers/CCR/SP/fraction ABI',profile)

# Native timing/velocity arithmetic includes keeper minimum-time and moving targets.
specs=[dict(role=r,direction=d,distance=x,velocity=v) for r in [0,4] for d in [-1,1] for x in [50,90,160,240] for v in [-10000,-7000,0,4000]]
def physics(item):
 profile,mode=item;p=o/('physics-'+profile);p.mkdir(exist_ok=True);b=[' .text',' .globl _start','_start:',' move.w #0x2700,%sr',' movea.l #0xfffff900,%sp']
 for j,s in enumerate(specs):
  dest=0xffff4000+j*12;b += [f' move.w #{mode},0xfffff0dc',' lea 0xffffb04a,%a3',' lea 0xffffb0ca,%a0',f' move.w #{s["role"]},0x34(%a3)',' move.w #180,0xffffbede',f' move.w #{s["direction"]*s["velocity"]},0x28(%a0)',' clr.w 0x2a(%a0)',' moveq #45,%d5',f' bsr.w pass{j}',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' bra.s after{j}',f'pass{j}: clr.w -(%sp)',f' move.w #{s["direction"]*s["distance"]},-(%sp)',' jmp 0xbee8',f'after{j}:']
 b += [' move.l #0x4c494654,0xffff4ff0','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};r[0x2d5000:0x2d5000+len(c)]=c;struct.pack_into('>I',r,4,0x2d5000);(p/'rom.bin').write_bytes(r);core(p,p/'rom.bin',[f'2000 dump ff4ff0 4 done',f'2000 dump ff4000 {12*len(specs)} result','2001 exit']);d=dumps(p);assert d['done']==b'LIFT';return dict(profile=profile,mode=mode,rows=[list(x) for x in struct.iter_unpack('>6h',d['result'])])
with ThreadPoolExecutor(max_workers=3) as pool:ph=list(pool.map(physics,[('baseline',0),('Normal',0),('Low',1),('invalid2',2),('invalid-negative',65535)]))
for s in ph[1:]:
 for j,(b,n) in enumerate(zip(ph[0]['rows'],s['rows'])):
  assert b[:2]+b[3:]==n[:2]+n[3:],('horizontal speed/arrival/intercept',s['profile'],specs[j],b,n)
  assert n[2]==(min(1536,b[2]) if s['mode']==1 else b[2]),('vertical cap',s['profile'],specs[j],b,n)
# Observe the actual native row-value renderer, never add a fake label.
p=o/'renderer';p.mkdir(exist_ok=True);c=asm(p,render_probe(''' .text
 .globl _start
_start:
 cmpi.w #26,%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,[26]),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):
 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+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'\x50\xaf'
 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_height_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('fresh-') or name.startswith(('restart-','corrupt-')):
  for frame,want in checks.items():
   if name.startswith('fresh-') or name.startswith('restart-') and int(frame) in [3900,4600] or name.startswith('corrupt-') and name.split('-')[1] in ['0000','50ae','40bf'] and int(frame) in [1000,1400]:want['mode']=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,26,900,1840)
 menu('fresh-'+str(dev),bytes(65536),down+['1850 snap','1901 exit'],{1880:dict(mode=0,slot=1)},dev,True)
 raw=menu('toggle-'+str(dev),battery(),down+['1900 press 1 Right 5','2050 snap','2100 press 1 Left 5','2300 press 1 Right 5','2500 press 1 Down 5','2701 exit'],{1880:dict(mode=0,slot=1),2000:dict(mode=1,record='51ae'),2200:dict(mode=0,record='50af'),2400:dict(mode=1,record='51ae')},dev,True)
 before=raw[1::2];assert before[0x1eee:0x1ef0]==b'\x51\xae' 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,slot=1),1200:dict(mode=0,slot=2),1400:dict(mode=1,slot=3),1600:dict(mode=0,slot=2),1800:dict(mode=1,slot=1)},dev)
 menu('reboot-'+str(dev),raw,['1301 exit'],{1200:dict(mode=1,slot=1)},dev)
 menu('locked-'+str(dev),battery(1,(1,0,1)),down+['1900 press 1 Left 5','2100 press 1 Right 5','2301 exit'],{1880:dict(mode=1,slot=1),2000:dict(mode=1,record='51ae'),2200:dict(mode=1,record='51ae')},dev,True)
 native=[f'{1000+200*i} press 1 Right 5' for i in range(3)]+menu_row(a.rom,26,1800,2780)+['2900 press 1 Right 5','3100 press 1 Left 5','3301 exit']
 raw=menu('native-'+str(dev),battery(1,(1,0,1)),native,{1600:dict(mode=0,view=0),2800:dict(mode=0,view=0),3000:dict(mode=1,global_record='51ae'),3200:dict(mode=0,global_record='50af')},dev,False)
 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,view=0,global_record='50af')},dev)
 for bad in ['0000','50ae','40bf','52ad']:
  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,slot=1),1200:dict(mode=1,slot=2),1400:dict(mode=0,slot=1)},dev)
 # Native first completed game freezes the chosen mode across a real reboot.
 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),3300:dict(mode=1)},dev,False,True)
 menu('first-game-locked-'+str(dev),raw,down+['1900 press 1 Left 5','2100 press 1 Right 5','2301 exit'],{1880:dict(mode=1,gp=1),2000:dict(mode=1),2200:dict(mode=1)},dev,True)
 # Confirm Abandon Season on Light only, preserving Full and Mini preferences.
 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,slot=2,gp=1),3900:dict(mode=0,slot=2,gp=0),4300:dict(mode=1,slot=1),4600:dict(mode=0,slot=2),4900:dict(mode=0,slot=3)},dev)
# Actual controller-driven B passes: observe the installed cap before native XY work.
p=o/'pass-probe';p.mkdir(exist_ok=True);c=asm(p,(h/'testcases/pass_height_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(mode):
 p=o/('native-input-'+str(mode));p.mkdir(exist_ok=True);(p/'battery.srm').write_bytes(battery(0,(mode,0,1)));(p/'spec.json').write_text(json.dumps(dict(mode=mode,observation_end=50001)));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','50002 exit'],dict(GPGX_DEV='513',GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(h/'testcases/pass_height_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 passes',mode,human);long=[e for e in human if e['time']>6];assert long,('no native human pass reaches low cap',mode,human);return dict(mode=mode,observation_end=50001,original_15501_prefix_retained=True,targeted_passes=len(d['events']),human_targeted_passes=len(human),human_passes_above_six_eighths=len(long),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,[0,1]))
(o/'results.json').write_text(json.dumps(dict(rom_sha256=sha(a.rom),baseline_sha256=sha(a.baseline),paired_lift_ABI_contexts=len(abi_specs)*4,native_solver_contexts=len(specs),physics_profiles=ph,menu_cases=reports,native_coop_input=played),indent=2)+'\n');print('PASS',len(specs),'solver contexts x5profiles and',len(reports),'native render/input/slot/boot/lock/restart/standalone cases')
