#!/usr/bin/env python3
"""Record an authenticated exact-ROM feature plan in isolated libretro instances.
The plan and save fixtures stay private. Public receipts contain hashes, chapter
scope and inputs, never private game saves. Run only after final release/QA.
"""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import argparse,json,hashlib,shutil,os,subprocess,wave,array,datetime,textwrap
parser=argparse.ArgumentParser();parser.add_argument('plan',type=Path);parser.add_argument('rom',type=Path);parser.add_argument('output',type=Path);parser.add_argument('--workers',type=int,default=2);args=parser.parse_args()
root=Path(__file__).resolve().parents[2];w=args.plan.resolve().parent;plan=json.loads(args.plan.read_text());out=args.output.resolve();out.mkdir(parents=True,exist_ok=True);sha=lambda p:hashlib.sha256(p.read_bytes()).hexdigest();assert sha(args.rom)==plan['ROM_sha256'];tools=root/'tools/experimental';font='/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'
def one(c):
 p=out/c['id'];p.mkdir(exist_ok=True)
 if(p/'result.json').exists():
  z=json.loads((p/'result.json').read_text());assert z['ROM_sha256']==plan['ROM_sha256']and sha(p/'clip.mp4')==z['video_sha256'];return z
 state=w/c['state'];srm=w/c['sram'];auth=c['source_auth'];assert sha(state)==auth['state_sha256']and sha(srm)==auth['paired_sram_sha256'];shutil.copyfile(srm,p/'battery.srm')
 # The core's actual nominal rate is queried again in the capture hook.
 fps=59.92274340431231;n=round(c['seconds']*fps);commands=[f'100 load {state}']
 if c['fixture']=='warning-clock41':commands += ['101 poke c466 00','101 poke c467 00','101 poke c468 00','101 poke c469 29','101 poke c46a 00','101 poke c46b 00']
 for b in c['buttons']:
  frame,button,duration,*pad=b;commands.append(f'{frame} press {pad[0]if pad else 1} {button} {duration}')
 commands.append(f'{100+n} exit');(p/'commands.txt').write_text('\n'.join(commands)+'\n')
 scope='Actual moving native core callbacks and physical-pad inputs. Authenticated paired state loaded at chapter cut; no actor/puck/score/RNG writes.'
 if c['fixture']:scope+=' DECLARED FIXTURE: '+c['fixture']+'.'
 env={k:v for k,v in os.environ.items()if not k.startswith('GPGX_')};env.update(GPGX_HOOK=str(root/'tools/video/native_capture.py'),GPGX_SRM=str(p/'battery.srm'),GPGX_WAV=str(p/'audio.wav'),GPGX_AUDIO_FROM='100',NHL94_VIDEO_FILE=str(p/'native.mkv'),NHL94_VIDEO_SCOPE=scope,GPGX_AUTO_CREDITS='0',GPGX_AUTO_MATCHUPS='0',GPGX_DEV='1281',GPGX_DEV2='1281')
 with(p/'core.log').open('w')as f:r=subprocess.run(['nice','-n','12','python3',str(tools/'test_core.py'),str(args.rom.resolve()),str(p/'commands.txt'),str(p/'snaps')],env=env,stdout=f,stderr=f,timeout=240)
 assert r.returncode==0,(c['id'],r.returncode);cap=json.loads((p/'native.capture.json').read_text());assert cap['status']=='passed'and cap['frames']==n,cap
 with wave.open(str(p/'audio.wav'))as f:
  audio_seconds=f.getnframes()/f.getframerate();a=array.array('h',f.readframes(f.getnframes()));audio=dict(rate=f.getframerate(),channels=f.getnchannels(),seconds=audio_seconds,peak=max(map(abs,a),default=0),nonzero=sum(x!=0 for x in a),clipped=sum(abs(x)>=32767 for x in a))
 assert abs(audio_seconds-cap['seconds'])<1/cap['fps'],(c['id'],audio_seconds,cap)
 (p/'title.txt').write_text(c['title']);(p/'description.txt').write_text('\n'.join(textwrap.wrap(c['description'],84)));(p/'footer.txt').write_text('2026-11-02-r2 | Native game + stereo sound | Chapter '+c['id'].split('-')[0])
 vf=f"scale=960:672:flags=neighbor,setsar=1,pad=960:816:0:104:color=0x10141c,drawtext=fontfile={font}:textfile={p/'title.txt'}:x=20:y=12:fontsize=26:fontcolor=white,drawtext=fontfile={font}:textfile={p/'description.txt'}:x=20:y=48:fontsize=19:fontcolor=0xc9d6e6:line_spacing=5,drawtext=fontfile={font}:textfile={p/'footer.txt'}:x=20:y=790:fontsize=15:fontcolor=0xb6c7df"
 cmd=['ffmpeg','-v','error','-y','-i',str(p/'native.mkv'),'-i',str(p/'audio.wav'),'-map','0:v','-map','1:a','-vf',vf,'-c:v','libx264','-preset','fast','-crf','21','-threads','2','-pix_fmt','yuv420p','-c:a','aac','-b:a','160k','-af',f'afade=t=in:d=0.015,afade=t=out:st={max(0,cap["seconds"]-0.02)}:d=0.02','-movflags','+faststart',str(p/'clip.mp4')]
 with(p/'encode.log').open('w')as f:subprocess.run(cmd,stdout=f,stderr=f,check=True,timeout=240)
 probe=json.loads(subprocess.check_output(['ffprobe','-v','error','-show_streams','-show_format','-of','json',str(p/'clip.mp4')]));assert any(x['codec_name']=='aac'for x in probe['streams']);v=next(x for x in probe['streams']if x['codec_name']=='h264');assert int(v['nb_frames'])==cap['frames'],('recording geometry/frame mismatch',c['id'],v,cap)
 subprocess.run(['ffmpeg','-v','error','-y','-ss',str(min(3,c['seconds']/2)),'-i',str(p/'clip.mp4'),'-frames:v','1',str(p/'review.png')],check=True,timeout=60)
 result=dict(**c,ROM_sha256=plan['ROM_sha256'],native_capture=cap,audio=audio,video_sha256=sha(p/'clip.mp4'),video_bytes=(p/'clip.mp4').stat().st_size,recorded_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),encoded_seconds=float(probe['format']['duration']),commands_sha256=sha(p/'commands.txt'),review_sha256=sha(p/'review.png'),scope=scope)
 (p/'result.json').write_text(json.dumps(result,indent=2)+'\n');print('RECORDED',c['id'],cap['frames'],'frames',audio['peak'],'audio peak',flush=True);return result
with ThreadPoolExecutor(max_workers=args.workers)as pool:results=list(pool.map(one,plan['chapters']))
(out/'recordings.json').write_text(json.dumps(dict(status='recorded-awaiting-review',ROM_sha256=plan['ROM_sha256'],chapters=results),indent=2)+'\n');print('DONE',len(results),'real native chapters; review and assemble next.',flush=True)
