#!/usr/bin/env python3
"""Native global Crit Shots persistence, strict Off and native release regressions."""
from pathlib import Path
import argparse,json,os,struct,subprocess
ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument('rom',type=Path);ap.add_argument('output',type=Path);ap.add_argument('--seed',type=Path,required=True);ap.add_argument('--state',type=Path,required=True)
a=ap.parse_args();here=Path(__file__).resolve().parent;out=a.output.resolve();out.mkdir(parents=True,exist_ok=True);reports=[]
def run(name,raw,lines,hook,dev='513',extra=None):
 p=out/name;p.mkdir(exist_ok=True)
 if raw is not None:(p/'battery.srm').write_bytes(raw)
 (p/'commands.txt').write_text('\n'.join(lines)+'\n');(p/'hook.py').write_text(hook)
 env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')};env.update(GPGX_DEV=dev,GPGX_HOOK=str(p/'hook.py'))
 if raw is not None:env['GPGX_SRM']=str(p/'battery.srm')
 if extra:env.update(extra)
 with (p/'log.txt').open('w') as log:subprocess.run(['nice','-n','19','python3',str(here/'test_core.py'),str(a.rom.resolve()),str(p/'commands.txt'),str(p)],env=env,stdout=log,stderr=log,check=True,timeout=40)
 result=json.loads((p/'result.json').read_text());reports.append(dict(name=name,**result));return (p/'battery.srm').read_bytes() if raw is not None else None
common='''from pathlib import Path
import json,struct
p=Path(outdir);rows=[]
def rw(a):return (wram[ram(a)]<<8)|wram[ram(a+1)]
def observe(f,wanted,slot=None,replay=0):
 b=bytes(sram)[1:65536:2]
 assert rw(0xf0ec)==wanted,('crit preference',f,rw(0xf0ec),wanted)
 assert rw(0xf0de)==replay,('replay preference changed',f,rw(0xf0de),replay)
 if slot is not None:assert rw(0xf06a)==slot,(f,rw(0xf06a),slot)
 assert [rw(x) for x in [0xd050,0xd052,0xd056,0xd072,0xf004]]==[0,0,2,2,0],('game settings changed',f)
 assert b[0x1fff]==sum(b[:0x1ffe])%256 and b[0x3fff]==sum(b[0x2000:0x3ffe])%256,('checksum',f)
 rows.append(dict(frame=f,value=wanted,slot=rw(0xf06a),record=b[0x2e1c:0x2e20].hex()))
 (p/'result.json').write_text(json.dumps(dict(observations=rows),indent=2)+'\\n')
'''
# Unstarted slots exercise global preference edits; played-season lock has its own regression.
b=bytearray(a.seed.read_bytes()[1:16384:2]);b[0xe20:0x1ffe]=bytes(0x11de);banks=[]
for slot,length in [(1,84),(2,31),(3,7)]:
 c=bytearray(b);struct.pack_into('>4s6H',c,0x1000,b'SESN',0,11,0,length,0,3);struct.pack_into('>4s4H',c,0x1ea0,b'S3SV',1,slot,7,1)
 struct.pack_into('>H',c,0x1eca,0xff00);struct.pack_into('>H',c,0x1eba,1)
 c[0x1ef0:0x1ef4]=b'PC\x01\xfe';c[0x1ef4:0x1ef6]=bytes([0,255]);c[0x1ff6:0x1ffd]=bytes([0x54,0,0,2,2,0,0x5a]);banks.append(c)
logical=bytearray(banks[0])+b''.join(banks)
logical[0x2e18:0x2e1c]=b'RP\x00\xff';logical[0x2e1c:0x2e20]=b'CT\x00\xff'
def pack(b):
 for off in range(0,len(b),8192):b[off+0x1fff]=sum(b[off:off+0x1ffe])%256;b[off+0x1ffe]=b[off+0x1fff]^255
 raw=bytearray(len(b)*2);raw[1::2]=b;return raw
raw=pack(logical)
lines=[f'{1000+80*i} press 1 Down 5' for i in range(11)]+['2050 snap','2100 press 1 Right 5','2250 snap']
lines += [f'{2300+70*i} press 1 Up 5' for i in range(11)]
lines += ['3250 press 1 Right 5','3400 press 1 Right 5','3701 exit']
hook=common+'''def test_tick(f):
 if f==900:observe(f,0,1)
 if f==2200:observe(f,1,1)
 if f==3350:observe(f,1,2)
 if f==3650:observe(f,1,3)
'''
raw=run('new-season-off-switch-slots',raw,lines,hook)
hook=common+'''def test_tick(f):
 if f==900:observe(f,1,3)
 if f==2200:observe(f,0,3)
'''
raw=run('cold-on',raw,[*[f'{1000+80*i} press 1 Down 5' for i in range(11)],'2100 press 1 Left 5','2200 snap','2201 exit'],hook)
run('cold-on-confirm',raw,['1101 exit'],common+'''def test_tick(f):
 if f==1000:observe(f,0,3)
''')
# Absent/corrupt Crit records independently default Off.
for name,record in [('tag',b'XX\x01\xfe'),('enum',b'CT\x09\xf6'),('complement',b'CT\x01\x01'),('absent',b'\x00'*4)]:
 b=bytearray(raw[1::2]);b[0x2e1c:0x2e20]=record
 run('invalid-'+name,pack(b),['1101 exit'],common+'''def test_tick(f):
 if f==1000:observe(f,1,3)
''')

# Both records are independent, including corrupt-record fallbacks.
b=bytearray(raw[1::2]);b[0x2e18:0x2e1c]=b'RP\x01\xfe';b[0x2e1c:0x2e20]=b'CT\x01\xfe'
run('both-off-cold',pack(b),['1101 exit'],common+"def test_tick(f):\n if f==1000:observe(f,1,3,replay=1)\n")
b[0x2e1c:0x2e20]=b'XX\x01\xfe'
run('bad-crit-replay-off',pack(b),['1101 exit'],common+"def test_tick(f):\n if f==1000:observe(f,1,3,replay=1)\n")
b[0x2e1c:0x2e20]=b'CT\x01\xfe';b[0x2e18:0x2e1c]=b'XX\x01\xfe'
run('crit-off-bad-replay',pack(b),['1101 exit'],common+"def test_tick(f):\n if f==1000:observe(f,1,3,replay=1)\n")
# A no-input boot must not write defaults or normalize malformed preferences.
for name,coldraw in [('read-only-on',raw),('read-only-corrupt',pack(b))]:
 after=run(name,coldraw,['1101 exit'],common+"def test_tick(f):\n if f==1000:observe(f,"+('1' if 'corrupt' in name else '0')+",3,replay="+('1' if 'corrupt' in name else '0')+")\n")
 assert after==coldraw,(name,'boot altered stored bytes')
# Navigate out of a locked league into native playoffs/shootout and back: global Off stays.
b=bytearray(raw[1::2]);b[0x2e1c:0x2e20]=b'CT\x01\xfe'
hook=common+"""def test_tick(f):
 if f in (900,1220,1440,1660,1880,2100,2320):
  assert rw(0xf0ec)==1,('global value lost on native mode',f)
  rows.append(dict(frame=f,crit=rw(0xf0ec),playmode=rw(0xd048),season=rw(0xf06c)))
  (p/'result.json').write_text(json.dumps(dict(observations=rows))+'\\n')
"""
navigation=['1100 press 1 Left 5','1320 press 1 Left 5','1540 press 1 Left 5','1760 press 1 Left 5']
if a.rom.read_bytes()[0x22c000:0x22c002]!=b'\xff\xff':navigation+=['1980 press 1 Left 5','2200 press 1 Left 5','2341 exit']
elif a.rom.read_bytes()[0x229000:0x229002]!=b'\xff\xff':navigation+=['1980 press 1 Left 5','2121 exit']
else:navigation+=['1901 exit']
run('native-mode-navigation',pack(b),navigation,hook)
assert {3,4}<={x['playmode'] for x in reports[-1]['observations']},'Native playoffs/shootout were not visited'

# Native direct shot tail: all difficulties, both teams, CPU/human, On/Off.
# The actual native critical SFX entry is instrumented only to count executions.
driver=out/'native-tail';driver.mkdir(exist_ok=True)
source=(here/'testcases/critical_driver.s').read_text()
source=source.replace('    movea.l #0xFFF800,%sp','    movea.l #0xFFF800,%sp\n    move.w 0x2D9E00,0xFFF0EC\n    move.w 0x2D9E02,0xFFF07A\n    clr.w 0xFFF060\n    st 0xFFF0B0\n    clr.w 0xFFF618')
source=source.replace('move.b #0,0x62(%a3)','move.b 0x2D9E05,0x62(%a3)')
(driver/'driver.s').write_text(source)
subprocess.run([str(here.parent/'m68kasm.sh'),str(driver/'driver.s'),'2FEA00',str(driver/'driver-code.bin')],check=True,stdout=subprocess.DEVNULL)
(driver/'sound.s').write_text('.text\n.globl _start\n_start:\n addq.w #1,0xFFF618\n move.w #123,22(%sp)\n jmp 0x2FF606\n')
subprocess.run([str(here.parent/'m68kasm.sh'),str(driver/'sound.s'),'2D9000',str(driver/'sound-code.bin')],check=True,stdout=subprocess.DEVNULL)
base=bytearray(a.rom.read_bytes());code=(driver/'driver-code.bin').read_bytes();sound=(driver/'sound-code.bin').read_bytes()
for addr,blob in [(0x2FEA00,code),(0x2D9000,sound)]:
 assert all(x==255 for x in base[addr:addr+len(blob)]);base[addr:addr+len(blob)]=blob
assert base[0x2FF600:0x2FF606]==bytes.fromhex('3f7c007b0016')
base[0x2FF600:0x2FF606]=bytes.fromhex('4ef9002d9000');struct.pack_into('>I',base,4,0x2FEA00)
for team,slot in [(10,13),(25,13)]:base[0x2FF800+26*team+slot]=100
for diff in range(3):
 for human in (False,True):
  for away in (False,True):
   for option in (0,1):
    name=f'tail-d{diff}-human{int(human)}-away{int(away)}-off{option}';p=out/name;p.mkdir(exist_ok=True)
    data=bytearray(base);struct.pack_into('>HHH',data,0x2D9E00,option,diff,(8 if human else 0)+(64 if away else 0));(p/'driver.bin').write_bytes(data)
    (p/'commands.txt').write_text('1000 dump fff600 12 result\n1000 dump ffd066 4 rng\n1000 dump fff060 2 flash\n1000 dump fff0b0 1 eligible\n1000 dump fff618 2 sound\n1001 exit\n')
    env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')};env['GPGX_DEV']='513'
    with (p/'log.txt').open('w') as log:subprocess.run(['python3',str(here/'test_core.py'),str(p/'driver.bin'),str(p/'commands.txt'),str(p)],env=env,stdout=log,stderr=log,check=True,timeout=40)
    d={line.split()[1]:bytes.fromhex(' '.join(line.split()[2:])) for line in (p/'commands.out').read_text().splitlines()}
    ordinary,critical,errors=struct.unpack_from('>3H',d['result']);sounds=int.from_bytes(d['sound'],'big')
    assert d['result'][8:12]==b'CRIT' and ordinary+critical==10000 and errors==0,(name,d)
    if option:
     assert critical==sounds==0 and d['rng']==bytes.fromhex('12345678') and d['flash']==b'\0\0' and d['eligible']==b'\0',(name,d)
    else:
     assert critical>0 and sounds==critical,(name,critical,sounds)
     if diff==2 and human:assert critical==10000,(name,critical)
    reports.append(dict(name=name,ordinary=ordinary,critical=critical,sound_entries=sounds,native_velocity_errors=errors))

# Real native controller windups, charge/classifier/effects: Off on every mode,
# including all four Easy pads. Keep production code unmodified for these cases.
setup=(here/'shot_panel_hook.py').read_text().split('def test_tick(f):')[0]
body=(here/'shot_panel_hook.py').read_text().split('def test_tick(f):')[1].split(" if spec['kind'] in ['timeout','pause'] and f>=released:")[0]
for diff,pad in [(0,1),(1,1),(2,1),(2,2),(2,3),(2,4)]:
 hook=setup+'def setup_tick(f):'+body+f"""
def test_tick(f):
 if f<=200:setup_tick(f)
 if f==100:
  ww(0xf0ec,1);ww(0xf07a,{diff});ww(0xf0de,1)
 if released is not None:
  assert rw(0xf644)==31 and rw(0xb04a+shooter*128+0x58)==0x7fc,('not full forehand',f)
  assert rb(0xf0b0)==0 and rw(0xf060)==0,('Off critical effects',f,rb(0xf0b0),rw(0xf060))
  if f>=released+8:
   snap('crit-off-native-shot');(p/'result.json').write_text(json.dumps(dict(difficulty={diff},pad={pad},native_release=True,full_forehand=True,critical=False,red_puck=False))+'\\n');raise SystemExit(0)
 assert f<800,('native Off shot timeout',released)
"""
 run(f'native-off-d{diff}-pad{pad}',None,[f'100 load {a.state.resolve()}','1001 exit'],hook,'1281',dict(SHOT_PANEL_CASE=json.dumps(dict(kind='goal',pad=pad))))
(out/'results.json').write_text(json.dumps(reports,indent=2)+'\n')
print('PASS',len(reports),'Crit cases: pre-match menu, cold boots/slots/native modes, independent records, no boot writes, native On/Off CPU/human and full controller releases')
