#!/usr/bin/env python3
"""Real shot/goal/replay HUD lifetime, size, native glyph and controller checks.

The explicit native four-pad fixture supplies game/hardware state; only private
copies run. A normal controller shot crosses the goal line in goal cases. The
timeout/pause fixtures hold the released puck away from the net to isolate UI.
"""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import argparse,json,os,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();out=a.output.resolve();out.mkdir(parents=True,exist_ok=True);here=Path(__file__).resolve().parent
_PENALTY_ADMISSION="# Fixture admission only: wait for the queued real native minor/fade to finish.\n# Original queue, shot setup, native release deadline, replay pixel assertions,\n# both negative controls and outer2801-frame command budget stay unchanged.\n_penalty_setup_offset=None\ndef test_tick(f):\n global _penalty_setup_offset\n if f==101:ww(0xf006,2) # declared fixture isolation: block additional fouls; direct queued minor stays native\n if f==180:\n  ww(0xc3a4,0x1680);ww(0xc3a6,0);ww(0xc3e6,0);wram[ram(0xc2ea)]|=5\n if f==1000:\n  caption_row=romdata[0x12af0]if romdata[0x12af0]in[20,21]else 25\n  assert words(vram(),caption_row,1,7)!=[0xa403]*7,'actual native PP caption missing'\n  snap('native-power-play')\n if f>=1100 and _penalty_setup_offset is None:\n  if not(rb(0xc2ea)&0x15) and not(rb(0xc2ec)&0x81) and rw(0xc468)>0 and rw(0xf0d4)==0:\n   n=core.retro_serialize_size();b=C.create_string_buffer(n);assert core.retro_serialize(b,n);pc=struct.unpack_from('<I',b.raw,144468)[0]\n   if not 0xad66<=pc<0xae80:\n    assert wram[ram(0xc2ee)]&32 and [rw(0xc6f2),rw(0xca56)]==[5,6],'queued native5v6 PP not active at actual shot setup'\n    _penalty_setup_offset=f-100\n    (p/'penalty-admission.json').write_text(json.dumps(dict(native_ready_at=f,relative_shot_fixture_start=100,flags=[rw(0xc2ea),rw(0xc2ec)],clock=rw(0xc468),PC=hex(pc),native_penalty_queue_unchanged=True,additional_foul_admission_disabled_for_fixture=True,actual_manpower=[rw(0xc6f2),rw(0xca56)],no_flag_clear_to_force_play=True,original_native_shot_replay_deadlines_retained=True,outer_command_deadline=2801),indent=2)+'\\n')\n  assert _penalty_setup_offset is not None or f<1700,'native penalty did not resume within bounded setup budget'\n if _penalty_setup_offset is not None:_penalty_replay_tick(f-_penalty_setup_offset)\n"
cases=[dict(kind='critical',pad=1),dict(kind='skip',pad=1),dict(kind='short',pad=1,duration=5),dict(kind='goal',pad=2),dict(kind='goal',pad=3),dict(kind='goal',pad=4),dict(kind='timeout',pad=1),dict(kind='pause',pad=1),dict(kind='critical',pad=2,penalty=True)]
def run(spec,rom=None,negative=False):
 spec=spec|dict(replay_visual=True)
 rom=rom or a.rom
 p=out/(('negative-' if negative else '')+f"{spec['kind']}-pad{spec['pad']}"+('-penalty'if spec.get('penalty')else '')+('-after-lines'if spec.get('after_line_editor')else ''));p.mkdir(exist_ok=True);cmd=p/'commands.txt';shift=2000 if spec.get('after_line_editor')else 0;prefix=['100 load '+str(a.state.resolve())]
 if shift:prefix+=['170 press 1 Start 5','350 press 1 Down 5','440 press 1 Down 5','530 press 1 Down 5','700 press 1 C 5','1150 press 1 Start 5','1400 press 1 Start 5','1750 press 1 Start 5']
 cmd.write_text('\n'.join(prefix)+f'\n{shift+2801} exit\n')
 hook=here/'shot_panel_hook.py'
 if spec.get('penalty'):
  hook=p/'native-penalty-ready-hook.py';hook.write_text((here/'shot_panel_hook.py').read_text()+'\n'+_PENALTY_ADMISSION)
 if shift:
  hook=p/'after-line-editor-hook.py';hook.write_text('from pathlib import Path\n'+f'exec(compile(Path({str(here/"shot_panel_hook.py")!r}).read_text(),{str(here/"shot_panel_hook.py")!r},"exec"),globals())\n'+f'_after_line_tick=test_tick\ndef test_tick(f):\n if f>={shift+100}:_after_line_tick(f-{shift})\n')
 if shift:hook.write_text(hook.read_text()+"\n_feedback_original_tick=test_tick\n_feedback_passed=None\n_feedback_clock=None\ndef test_tick(f):\n global _feedback_passed,_feedback_clock\n if _feedback_passed is None:\n  try:_feedback_original_tick(f)\n  except SystemExit as e:\n   if e.code:raise\n   _feedback_passed=f;_feedback_clock=rw(0xc468)\n  return\n if f>_feedback_passed+40 and 0<rw(0xc468)<_feedback_clock-2 and not(rb(0xc2ec)&0x81) and not(rb(0xc2ea)&0x51) and rw(0xf738)==0xa500:\n  snap('actual-play-resumed')\n  (p/'native-progress.json').write_text(json.dumps(dict(status='passed',HUD_checks_passed_at=_feedback_passed,active_play_at=f,goal_clock=_feedback_clock,active_clock=rw(0xc468),native_replay_phase=hex(rw(0xf738)),flags=[hex(rw(0xc2ea)),hex(rw(0xc2ec))],scope='Existing original strict HUD/replay checks plus observed natural post-goal active play and clock advance; no further actor/puck/score/RNG writes after original fixture setup.'),indent=2)+'\\n')\n  print('PASS actual post-replay active clock progress',f,_feedback_clock,rw(0xc468),flush=True);sys.exit(0)\n assert f<_feedback_passed+1000,('native game never resumed after replay',f,hex(rw(0xf738)),rw(0xc468),hex(rw(0xc2ea)),hex(rw(0xc2ec)))\n")
 env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')};env.update(GPGX_DEV='1281',GPGX_DEV2='1281',GPGX_HOOK=str(hook),SHOT_PANEL_CASE=json.dumps(spec))
 with (p/'log.txt').open('w') as log:subprocess.run(['python3',str(here/'test_core.py'),str(rom.resolve()),str(cmd),str(p)],env=env,stdout=log,stderr=log,check=not negative,timeout=45)
 if negative:
  failure=json.loads((p/'replay-visual-failure.json').read_text())
  if negative=='pp-only':
   assert 'above-clock power-play/fallback frame differs from before replay'in failure['issues'],('missing PP snapshot escaped its own replay oracle',failure)
   assert not any(x in failure['issues']for x in['REPLAY pixels overwritten or not in allocated VCR bank','lower-left frame/clock/map differs from before replay','recorded coloured charge bar changed during replay']),('PP negative damaged unrelated original replay HUD',failure)
  else:assert len(failure['issues'])>=3,('old R7 must fail actual title,clock/frame and coloured bar',failure)
  return failure
 if shift:assert (p/'native-progress.json').exists(),'post-LineEditor replay never returned to active native clock progress'
 result=json.loads((p/'result.json').read_text());proof=p/'replay-visual.json'
 if spec['kind'] not in ['timeout','pause']:assert proof.exists();result['replay_visual']=json.loads(proof.read_text());assert result['replay_visual']['frames_checked']>=40
 return result
import hashlib
assert hashlib.sha256(a.baseline.read_bytes()).hexdigest()=='6e66c0afe2496eb3f6a688c29888ca62b17aee0b0f51efec96d11dce5e842b31'
negative=run(dict(kind='critical',pad=1),a.baseline,True)
if a.rom.read_bytes()[0x25d000:0x25d002]==bytes.fromhex('6000'):
 code=a.rom.read_bytes();assert code[0x9cd8:0x9cdc]==bytes.fromhex('61007258'),'fading close entry9CD8 overwritten'
 assert code[0x9cdc:0x9ce2]==bytes.fromhex('4ef90025d014'),'automatic/manual replay alternate close entry9CDC not preserved'
 cases += [dict(kind='critical',pad=1,after_line_editor=True),dict(kind='goal',pad=2,after_line_editor=True)]
with ThreadPoolExecutor(max_workers=3) as pool:results=list(pool.map(run,cases))
PP_negative=None
if a.rom.read_bytes()[0x2ec008:0x2ec00c]==b'PPFR':
 mutant=bytearray(a.rom.read_bytes());matches=[i for i in range(0x2ec010,0x2ec400-3)if mutant[i:i+2]==bytes.fromhex('7612')and mutant[i+2]==0x61]
 assert len(matches)==2,('guarded native capture/restore first-row instructions',matches)
 for at in matches:mutant[at:at+2]=bytes.fromhex('7617')
 mutant[0x18e:0x190]=(sum(int.from_bytes(mutant[i:i+2],'big')for i in range(0x200,len(mutant),2))&65535).to_bytes(2,'big')
 path=out/'mutant-missing-PP-replay-snapshot.bin';path.write_bytes(mutant)
 PP_negative=run(dict(kind='critical',pad=2,penalty=True),path,'pp-only')
from replay_hud_contract import verify as verify_contract
ABI=verify_contract(a.rom,here,out/'native-contract')
(out/'results.json').write_text(json.dumps(dict(rom_sha256=hashlib.sha256(a.rom.read_bytes()).hexdigest(),cases=results,parent_negative=negative,missing_PP_snapshot_negative=PP_negative,native_contract=ABI),indent=2)+'\n');print('PASS',len(results),'native replay-title/preserved-panel cases,',sum(r['checks'] for r in results),'VRAM checks: crit/short/all pads/replay/skip/timeout/pause')
