#!/usr/bin/env python3
"""Bottom-left shot HUD: native VDP ownership, clock/penalty protection, DMA.
Explicit VRAM-owner routine fixtures supplement the real native shot/replay,
colour, glass, ticker and Stars suites. They are not game/physics trials.
"""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import argparse,hashlib,json,struct,os,subprocess
from native_probe import NativeProbe
ap=argparse.ArgumentParser(description=__doc__);ap.add_argument('rom',type=Path);ap.add_argument('output',type=Path);ap.add_argument('--baseline',required=True,type=Path);ap.add_argument('--state',required=True,type=Path);ap.add_argument('--section',choices=['all','ownership','fonts','observers','gameplay','dma'],default='all');a=ap.parse_args();h=Path(__file__).resolve().parent;o=a.output.resolve();o.mkdir(parents=True,exist_ok=True);image=a.rom.read_bytes();old=a.baseline.read_bytes();assert hashlib.sha256(old).hexdigest()=='c32a79e34127757b52cd488c124c9247bf7b875088bac89956a6b7ca2b132cf6';assert image[0x2ed00c:0x2ed00e]==bytes.fromhex('6000');assert image[0x2ed800:0x2ee000]==old[0x2ed800:0x2ee000],'critical velocity code was overwritten';probe=NativeProbe(a.rom,h)
LOGO=list(range(0xa1c8,0xa1d6));FONTMAP=[0x87f4,0x87f5,0x87f6,0xa403,0x87f7,0x87f8,0x87f9];BAR=[0xa7e1,0xa7e2,0xa7e3,0xa7e4]+[0xa403]*3;PP=[0x843d,0x843d,0xa403,0x841f,0x8420,0x8430,0x8423,0x842b,0x8424,0x8420,0x8425,0x841e,0xa403,0xa403]
REFINED=image[0x258000:0x258002]==bytes.fromhex('6000')
if REFINED:FONTMAP=[0x87f4,0x87f5,0x87f6]+[0xa403]*4
def cmd(addr,read=False):return ((addr&0x3fff)<<16)|((addr>>14)&3)|(0 if read else 0x40000000)
def place(plane,row,col,words):plane[row*64+col:row*64+col+len(words)]=words
CLOCK=[0xa402,0x841f,0x8431,0x841e,0x8424,0x841e,0xa403,0xa403,0xa404]
def plane_with(badge):
 p=[0x7ff]*2048;place(p,23,0,[0xa3ff]+[0xa400]*7+[0xa401]);place(p,24,0,CLOCK);place(p,27,0,[0xa405]+[0xa406]*7+[0xa407])
 for row in [25,26]:place(p,row,0,[0xa402]+badge[(row-25)*7:(row-24)*7]+[0xa404])
 return p
def full_panel(p,clear=False):
 if clear:
  for row in range(18,23):place(p,row,0,[0x7ff]*8)
  return
 place(p,18,0,[0xa3ff]+[0xa400]*6+[0xa401]);place(p,19,0,[0xa402,0xa403]+BAR[:4]+[0xa403,0xa404]);place(p,20,0,[0xa402,0x841f,0x841e,0x841e,0x8438,0x843a,0x8435,0xa404]);place(p,21,0,([0xa402]+[0xa403]*6+[0xa404])if REFINED else[0xa402,0xa403,0x8424,0x8420,0x843a,0x843c,0x8435,0xa404]);place(p,22,0,[0xa405]+[0xa406]*6+[0xa407])
def ownership():
 scenarios=[dict(name='native-PP-transient-blank',badge=[0xa403]*14,classification=1,op='panel',native_pp=True),dict(name='logo-detect',badge=LOGO,classification=2,op='classify'),dict(name='logo-remove',badge=LOGO,classification=2,op='wipe'),dict(name='native-penalty-text',badge=PP,classification=0,op='wipe'),dict(name='corrupt-logo-prefix',badge=LOGO[:5]+[0x8441]+LOGO[6:],classification=0,op='wipe'),dict(name='owned-font-detect',badge=BAR+FONTMAP,classification=1,op='classify'),dict(name='transparent-normal',badge=[0x7ff]*14,classification=0,op='classify'),dict(name='transparent-replay',badge=[0x7ff]*14,classification=1,op='classify',held=1),dict(name='compact-actual-DMA',badge=LOGO,classification=2,op='panel'),dict(name='penalty-fallback',badge=PP,classification=0,op='panel'),dict(name='foreign-erase-keeps-penalty',badge=PP,classification=0,op='erase',panel=1),dict(name='erase-owned',badge=BAR+FONTMAP,classification=1,op='erase',panel=1),dict(name='fallback-to-compact',badge=LOGO,classification=2,op='panel',panel=2),dict(name='compact-to-penalty-fallback',badge=PP,classification=0,op='panel',panel=1),dict(name='erase-foreign-above',badge=PP,classification=0,op='erase',panel=2,foreign_above=True),dict(name='erase-owned-above',badge=PP,classification=0,op='erase',panel=2),dict(name='font-reload-after-native-clear',badge=LOGO,classification=2,op='panel',reused_font_cache=True)]
 def trial(z,cart=probe,negative=False):
  before=plane_with(z['badge']);expected=before.copy()
  if z.get('panel')==2 and not z.get('foreign_above'):full_panel(before);expected=before.copy()
  if z.get('foreign_above'):
   for row in range(18,23):place(before,row,0,[0x8441+(row-18)*8+k for k in range(8)])
   expected=before.copy()
  state=z.get('panel',0);action=z['op'];want_state=state
  if action=='wipe' and z['badge']==LOGO or action=='erase' and state==1 and z['classification']:
   for row in [25,26]:place(expected,row,1,[0xa403]*7)
  if action=='erase':
   want_state=0
   if state==2 and not z.get('foreign_above'):full_panel(expected,True)
  if action=='panel':
   if z['classification'] and (REFINED or not z.get('native_pp')):
    if state==2:full_panel(expected,True)
    place(expected,25,1,BAR);place(expected,26,1,FONTMAP);want_state=1
   else:full_panel(expected);want_state=2
  data=struct.pack('>2048H',*before);body=[' move.w #'+str(5 if z.get('native_pp')else 0)+',0xffffc6f2',' move.w #'+str(6 if z.get('native_pp')else 0)+',0xffffca56',' move.w #0x8104,0xc00004',' move.w #0x8f02,0xc00004',f' move.l #0x{cmd(0xe000):08x},0xc00004',' lea 0x230000,%a0',' move.w #2047,%d0','copy: move.w (%a0)+,0xc00000',' dbra %d0,copy',f' move.w #{z.get("held",0)},0xfffff64e',f' move.w #{state},0xfffff64c',' move.w #31,0xfffff644',' move.w #240,0xfffff646',' move.w #0xffff,0xffffe8f4',' move.w #0xffff,0xffffe8f6',' move.w #0xffff,0xffffe8f8',' movea.l #0xffff9000,%a5',' jsr 0x227004',' move.w %d0,0xffff5002']
  if z.get('reused_font_cache'):
   body += [' move.w #100,0xffffe8f6',' move.w #62,0xffffe8f8',' clr.w 0xfffff646',' moveq #100,%d0',' moveq #62,%d4',' jsr 0x227000',' move.w #240,0xfffff646']
  if action!='classify':body += [' moveq #100,%d0',' moveq #62,%d4',f' jsr 0x{dict(wipe=0x227008,panel=0x227000,erase=0x2ed010)[action]:x}']
  body += [' move.w 0xfffff64c,0xffff5000',' move.l %a5,0xffff5004',' lea 0xffff9000,%a0',' lea 0xffff5008,%a1',' moveq #5,%d0','queue: move.l (%a0)+,(%a1)+',' dbra %d0,queue',f' move.l #0x{cmd(0xe000,True):08x},0xc00004',' lea 0xffff4000,%a0',' move.w #2047,%d0','capture: move.w 0xc00000,(%a0)+',' dbra %d0,capture']
  raw=cart.run(o/('negative-'+z['name'] if negative else z['name']),'\n'.join(body),4128,data=data);actual=list(struct.unpack_from('>2048H',raw));pn,classification,queue_end=struct.unpack_from('>HHI',raw,4096)
  if not negative:assert classification==z['classification'],('actual native VDP classification',z,classification)
  assert actual==expected,('native penalty/clock/border/unowned cells changed',z,[(i,x,y) for i,(x,y) in enumerate(zip(actual,expected)) if x!=y][:16]);assert pn==want_state,('layout state',z,pn,want_state)
  queue=raw[4104:4128]
  if action=='panel' and z['classification'] and (REFINED or not z.get('native_pp')):
   expected_queue=struct.pack('>IHHIHH',0xffea00,64,0xfc20,0x260000+100*96,48,0xfe80)+(b''if REFINED else struct.pack('>IHH',0x277700+62*96,48,0xfee0))
   assert queue_end==0xffff9000+len(expected_queue) and queue[:len(expected_queue)]==expected_queue,('actual native font/bar DMA descriptors',z,hex(queue_end),queue.hex())
  return dict(spec=z,plane_sha256=hashlib.sha256(raw[:4096]).hexdigest(),layout=pn,clock_preserved=True,entire_unowned_plane_exact=True)
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,scenarios))
 # An always-available badge mutant must be rejected by the same ownership oracle.
 mutated=bytearray(image);target=0x227006+int.from_bytes(image[0x227006:0x227008],'big',signed=True);assert 0x227010<=target<0x228000;mutated[target:target+4]=bytes.fromhex('70024e75');path=o/'mutant-always-available.bin';path.write_bytes(mutated)
 try:trial(next(z for z in scenarios if z['name']=='penalty-fallback'),NativeProbe(path,h),True)
 except AssertionError as e:negative=str(e)
 else:raise AssertionError('negative control failed to detect overwritten native penalty text')
 # The fallback eraser must also refuse a rectangle now owned by native text.
 mutated=bytearray(image);target=0x227012+int.from_bytes(image[0x227012:0x227014],'big',signed=True);assert 0x227014<=target<0x228000;mutated[target:target+4]=bytes.fromhex('70014e75');path=o/'mutant-foreign-erase.bin';path.write_bytes(mutated)
 try:trial(next(z for z in scenarios if z['name']=='erase-foreign-above'),NativeProbe(path,h),True)
 except AssertionError as e:negative_erase=str(e);assert 'native penalty/clock/border/unowned cells changed' in negative_erase,negative_erase
 else:raise AssertionError('negative control missed destructive fallback erase')
 print('PASS',len(rows),'whole native VDP ownership/clock/penalty/layout/DMA cases and destructive negative control',flush=True);return dict(cases=rows,negative_control=negative,foreign_erase_negative_control=negative_erase)
def fonts():
 from shot_panel_left import FONT_BASE,FONT_END
 assert old[FONT_BASE:FONT_END]==b'\xff'*(FONT_END-FONT_BASE)
 bank=image[FONT_BASE:FONT_END];assert len(bank)==192000 and set((v>>4) for v in bank)<={3,4} and set((v&15) for v in bank)<={3,4};assert image[0x28ee00:0x2a0000]==old[0x28ee00:0x2a0000]
 # Independently decode the concrete numeric string's digit topology, spacing,
 # colour and unit distinction, rather than merely invoking the generator.
 for unit in range(2):
  samples=[]
  for value in range(1000):
   raw=bank[unit*96000+value*96:unit*96000+(value+1)*96];pixels=[[0]*24 for _ in range(8)]
   for tile in range(3):
    for y in range(8):
     row=raw[tile*32+y*4:tile*32+y*4+4]
     for k,b in enumerate(row):pixels[y][tile*8+2*k]=b>>4;pixels[y][tile*8+2*k+1]=b&15
   assert pixels[7]==[4]*24 and all(pixels[y][x]==4 for y in range(8) for x in range(3,24,4)),('font spacing/background',unit,value)
   for n,c in enumerate(f'{value:3d}'):
    bitrows=[sum((pixels[y][4*n+x]==3)<<(2-x) for x in range(3)) for y in range(7)]
    if c==' ':assert bitrows==[0]*7
    else:
     # Golden rows are explicit, independent of runtime index arithmetic.
     expected=[[7,5,5,5,5,5,7],[2,6,2,2,2,2,7],[7,1,1,7,4,4,7],[7,1,1,7,1,1,7],[5,5,5,7,1,1,1],[7,4,4,7,1,1,7],[7,4,4,7,5,5,7],[7,1,1,2,2,2,2],[7,5,5,7,5,5,7],[7,5,5,7,1,1,7]][int(c)];assert bitrows==expected,('wrong numeric glyph/leading digits',unit,value,n,bitrows,expected)
   unit_rows={'K':[5,5,6,4,6,5,5],'M':[5,7,7,5,5,5,5],'H':[5,5,5,7,5,5,5],'P':[6,5,5,6,4,4,4]}
   for n,c in enumerate(['KMH','MPH'][unit],3):
    actual=[sum((pixels[y][4*n+x]==3)<<(2-x) for x in range(3)) for y in range(7)];assert actual==unit_rows[c],('wrong speed unit glyph',unit,value,c,actual)
   if value in [0,1,9,10,99,100,999]:samples.append(dict(value=value,sha256=hashlib.sha256(raw).hexdigest()))
  assert samples
 print('PASS2000 compact speed strings, numeric glyphs/spacing/colour; reserved source bank and critical velocity bytes intact',flush=True);return dict(strings=2000,bank_sha256=hashlib.sha256(bank).hexdigest(),critical_velocity_exact=True)
def gameplay():
 rows=[]
 for name,rom in [('baseline',a.baseline),('current',a.rom)]:
  q=o/name;q.mkdir(exist_ok=True);(q/'commands.txt').write_text(f'100 load {a.state.resolve()}\n1601 exit\n');env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')};# Admit the native PP before its natural goal ends it. Setup, samples,
  #240-tick UI lifetime, all original assertions and1601 outer budget remain.
  source=(h/'testcases/shot_panel_penalty_hook.py').read_text()
  for before,after in [('if f==1000:','if f==600:'),('if f==1100:','if f==700:'),('if f in [1120,1160,1200]:','if f in [720,760,800]:'),('if f>1200 and not expiry_seen','if f>800 and not expiry_seen')]:
   assert before in source,before;source=source.replace(before,after,1)
  source=source.replace("if f==600:\n", "if f==600:\n  assert wram[ram(0xc2ee)]&32 and rw(0xc6f2)!=rw(0xca56),'actual native PP admission required'\n",1)
  (q/'hook.py').write_text(source);env.update(GPGX_DEV='513',GPGX_HOOK=str(q/'hook.py'))
  with(q/'log').open('w') as log:subprocess.run(['nice','-n','19','python3',str(h/'test_core.py'),str(rom.resolve()),str(q/'commands.txt'),str(q)],env=env,stdout=log,stderr=log,check=True,timeout=45)
  rows.append(dict(profile=name,observations=json.loads((q/'results.json').read_text())))
 assert rows[0]['observations'][0]==rows[1]['observations'][0],('native PP caption/timer/clock changed before explicit UI fixture',rows)
 print('PASS actual native queued minor/PP timer and above-clock panel/expiry with exact native initial-widget control',flush=True);return dict(cases=rows)
def dma():
 def trial(item,rom=a.rom,negative=False):
  value,unit=item
  q=o/(('negative-dma-' if negative else 'dma-')+str(value)+'-unit'+str(unit));q.mkdir(exist_ok=True);(q/'spec.json').write_text(json.dumps(dict(value=value,unit=unit)));(q/'commands.txt').write_text(f'100 load {a.state.resolve()}\n161 exit\n');env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')};env.update(GPGX_DEV='513',GPGX_HOOK=str(h/'testcases/shot_panel_dma_hook.py'))
  with(q/'log').open('w') as log:r=subprocess.run(['nice','-n','19','python3',str(h/'test_core.py'),str(rom.resolve()),str(q/'commands.txt'),str(q)],env=env,stdout=log,stderr=log,timeout=45)
  if r.returncode:raise AssertionError((q/'log').read_text())
  return json.loads((q/'results.json').read_text())
 with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,[(v,u)for v in[0,1,9,10,99,100,341,365,682,999,1000,65535]for u in([0,1]if REFINED else[0])]))
 assert any(x['crosses_64KB'] for z in rows for x in z['native_dma']) and any(x['crosses_128KB'] for z in rows for x in z['native_dma'])
 mutant=bytearray(image);needle=bytes.fromhex('41f900277700');at=mutant.find(needle,0x227000,0x228000);assert at>=0 and mutant[0x227000:0x228000].count(needle)==(2 if REFINED else 1);mutant[at+2:at+6]=bytes.fromhex('00260000');path=o/'mutant-wrong-unit-bank.bin';path.write_bytes(mutant)
 try:trial((365,1 if REFINED else 0),path,True)
 except AssertionError as e:negative=str(e);assert 'native DMA font source/unit/clamp' in negative,negative
 else:raise AssertionError('wrong native font source bank escaped negative control')
 print('PASS12 actual native font-DMA/clamp cases, including64KB/128KB boundaries and wrong-bank negative control',flush=True);return dict(cases=rows,negative_control=negative)
report=dict(rom_sha256=hashlib.sha256(image).hexdigest(),sections={})
for name,fn in [('ownership',ownership),('fonts',fonts),('gameplay',gameplay),('dma',dma)]:
 if a.section in ['all',name]:report['sections'][name]=fn();(o/'results.json').write_text(json.dumps(report,indent=2)+'\n')
if a.section in ['all','observers']:
 from verify_native_observers import verify
 report['sections']['observers']=verify(a.rom,h,o/'observers');(o/'results.json').write_text(json.dumps(report,indent=2)+'\n')
