"""Reject live changes; explicitly qualify partial native replay record decoding.
A68C..A79A is byte-exact native replay-record decoder, not live motion logic.
CPU register serialization offsets are independently native-calibrated. No
position tolerance is used: the completed component must equal the same actual
packed replay record, while the other CPU has not yet committed that component.
"""
from copy import deepcopy

def committed(ctx,actor,axis):
 index=(ctx['a3']-0xffffb04a)//128;pc=ctx['pc']
 if actor<index:return True
 if actor>index:return False
 if pc==0xa798:return False # A3/A0 already advanced; DBRA has not run yet.
 return pc>(0xa6be if axis==0 else 0xa6e8)

def component(ctx,actor,axis):
 word=int.from_bytes(bytes.fromhex(ctx['payload'])[4*actor:4*actor+4],'big');v=(word if axis==0 else word>>10)&1023
 if v&512:v-=1024
 if ctx['flip']:v=-v
 return v*(ctx['direction']if axis else 1)

def compare(current,parent,current_ui,parent_ui):
 assert len(current)==len(parent)==len(current_ui)==len(parent_ui)==600,'incomplete native600-frame comparison'
 differences=[];live=0
 for i,(now,old,nc,oc)in enumerate(zip(current,parent,current_ui,parent_ui)):
  if not(0xa502<=nc['state']<=0xa506 and 0xa502<=oc['state']<=0xa506):live+=1
  if now==old:continue
  assert nc['state']==oc['state']==0xa502 and nc['counter']==oc['counter']==0,'live or active-playback native state changed'
  assert nc['pause']==oc['pause']and nc['pause']!=0,'not the same native paused replay context'
  assert all(0xa69e<=c['pc']<=0xa798 for c in[nc,oc]),'difference outside native replay decoder'
  assert nc['a4']==oc['a4']and nc['payload']==oc['payload']and nc['flip']==oc['flip']and nc['direction']==oc['direction'],'native replay source record changed'
  assert len(bytes.fromhex(nc['payload']))==64
  for c in[nc,oc]:
   index=(c['a3']-0xffffb04a)//128;assert 0<=index<16 and(c['a3']-0xffffb04a)%128==0,'not a native object decoder pointer'
   assert c['a0']-c['a4']==4*(index+(c['pc']==0xa794)),'native source/object cursors disagree'
  fields={k for k in now if now[k]!=old[k]};assert fields and fields<={'positions','puck'},('non-decoder gameplay state changed',fields)
  checked=[]
  for field in sorted(fields):
   pairs=enumerate(zip(now[field],old[field]))if field=='positions'else[(14,(now[field],old[field]))]
   for actor,(np,op)in pairs:
    for axis,(nv,ov)in enumerate(zip(np,op)):
     if nv==ov:continue
     nd,od=committed(nc,actor,axis),committed(oc,actor,axis);assert nd!=od,('different value at same native component-commit phase',i+1,actor,axis)
     context,value=(nc,nv)if nd else(oc,ov);assert value==component(context,actor,axis),('committed component does not equal actual packed native record',i+1,actor,axis,value,component(context,actor,axis))
     checked.append(dict(actor=actor,axis=axis,current=nv,parent=ov,current_written=nd,parent_written=od,decoded_component=value))
  assert checked
  differences.append(dict(frame=i+1,fields=sorted(fields),current_PC=hex(nc['pc']),reference_PC=hex(oc['pc']),record_pointer=hex(nc['a4']),identical_native_record=True,partially_committed_components=checked))
 return dict(raw_exact=not differences,live_exact=True,live_frames_checked=live,native_decoder_partial_samples=differences)

def negative_controls():
 row=dict(positions=[[0,0]for _ in range(12)],puck=[0,0],clock=290,score=[0,0]);old=[deepcopy(row)for _ in range(600)];new=deepcopy(old);payload=bytearray(64);payload[12:16]=(10<<10).to_bytes(4,'big');ctx=dict(state=0xa502,counter=0,pause=0x4100,pc=0xa6c6,a3=0xffffb1ca,a0=0xffff100c,a4=0xffff1000,payload=payload.hex(),flip=0,direction=1);left=[deepcopy(ctx)for _ in range(600)];right=deepcopy(left);left[250].update(pc=0xa6a2,a3=0xffffb24a,a0=0xffff1010);new[250]['positions'][3][1]=10;assert compare(new,old,left,right)['native_decoder_partial_samples']
 cases=[]
 for name in ['live-position','replay-score','replay-clock','wrong-record','outside-decoder','wrong-decoded-component','same-commit-phase','mixed-live-replay','large-replay-jump']:
  n,b,l,r=deepcopy(new),deepcopy(old),deepcopy(left),deepcopy(right)
  if name=='live-position':l[250]['state']=r[250]['state']=0
  elif name=='replay-score':n[250]['score'][0]=1
  elif name=='replay-clock':n[250]['clock']=289
  elif name=='wrong-record':l[250]['payload']='ff'*64
  elif name=='outside-decoder':l[250]['pc']=0x103d0
  elif name=='wrong-decoded-component':n[250]['positions'][3][1]=11
  elif name=='same-commit-phase':r[250]=deepcopy(l[250])
  elif name=='mixed-live-replay':r[250]['state']=0
  else:n[250]['positions'][3][1]=12
  try:compare(n,b,l,r)
  except AssertionError as e:cases.append(dict(name=name,rejected=str(e)))
  else:raise AssertionError(('negative escaped',name))
 try:compare_live(new,old)
 except AssertionError as e:cases.append(dict(name='strict-AutoOff-position',rejected=str(e)))
 else:raise AssertionError('strict live negative escaped')
 return cases

def compare_live(current,parent):
 assert len(current)==len(parent)==600,'incomplete native600-frame live comparison'
 assert current==parent,'AutoReplayOff live native state changed'
 return dict(raw_exact=True,samples=600)

def observer_source():
 return """
_presentation_original_tick=test_tick
_presentation_rows=[]
def test_tick(f):
 _presentation_original_tick(f)
 if os.environ.get('MAINTENANCE_NO_AUTOREPLAY')=='1'and 100<=f<=108:ww(0xf0de,1)
 if f>108:
  import struct
  n=core.retro_serialize_size();b=C.create_string_buffer(n);assert core.retro_serialize(b,n)and b.raw.startswith(b'GENPLUS-GX 1.7.6')
  pc=struct.unpack_from('<I',b.raw,144468)[0];a4=struct.unpack_from('<I',b.raw,144452)[0]
  _presentation_rows.append(dict(frame=f-108,state=rw(0xf738),counter=rw(0xf73a),pause=rw(0xc2ec),pc=pc,a0=struct.unpack_from('<I',b.raw,144436)[0],a3=struct.unpack_from('<I',b.raw,144448)[0],a4=a4,payload=bytes(wram[ram(a4+k)]for k in range(64)).hex()if 0xa69e<=pc<=0xa798 else'',flip=rw(0xc2f4)&16,direction=direction,auto=rw(0xf0de)))
 if f==708:(p/'presentation_trace.json').write_text(json.dumps(_presentation_rows))
"""

def verify_state_abi(rom,h,o):
 from pathlib import Path
 import subprocess,struct,os,json,hashlib
 h=Path(h);o=Path(o);o.mkdir(exist_ok=False)
 D=[0x11223300+i for i in range(8)];A=[0xffff1000+256*i for i in range(7)]+[0xffff9800]
 source=' .text\n .globl _start\n_start:\n move.w #0x2700,%sr\n'+'\n'.join(f' move.l #0x{x:x},%d{i}'for i,x in enumerate(D))+'\n'+'\n'.join(f' movea.l #0x{x:x},%a{i}'for i,x in enumerate(A))+'\nloop: bra.s loop\n'
 (o/'driver.s').write_text(source);subprocess.run([str(h.parent/'m68kasm.sh'),str(o/'driver.s'),'2d5000',str(o/'code.bin')],check=True,stdout=subprocess.DEVNULL);code=(o/'code.bin').read_bytes();b=bytearray(Path(rom).read_bytes());assert b[0x2d5000:0x2d5000+len(code)]==b'\xff'*len(code);b[0x2d5000:0x2d5000+len(code)]=code;struct.pack_into('>I',b,4,0x2d5000);(o/'rom.bin').write_bytes(b);(o/'commands.txt').write_text('200 exit\n')
 (o/'hook.py').write_text("from pathlib import Path\ndef test_tick(f):\n if f!=150:return\n n=core.retro_serialize_size();b=C.create_string_buffer(n);assert core.retro_serialize(b,n);Path(outdir,'raw.state').write_bytes(b.raw)\n")
 env={k:v for k,v in os.environ.items()if not k.startswith('GPGX_')};env.update(GPGX_DEV='513',GPGX_HOOK=str(o/'hook.py'))
 with(o/'log').open('w')as log:subprocess.run(['python3',str(h/'test_core.py'),str(o/'rom.bin'),str(o/'commands.txt'),str(o)],env=env,check=True,stdout=log,stderr=log,timeout=30)
 raw=(o/'raw.state').read_bytes();assert raw.startswith(b'GENPLUS-GX 1.7.6');signature=struct.pack('<16I',*(D+A));at=raw.find(signature);assert at==144404 and raw.find(signature,at+1)<0;pc=struct.unpack_from('<I',raw,144468)[0];assert pc==0x2d5000+len(code)-2
 result=dict(GPR_offset=at,A0_offset=at+32,A3_offset=at+44,A4_offset=at+48,PC_offset=144468,all16_GPR_native_known_values=True,native_decoder_SHA256=hashlib.sha256(Path(rom).read_bytes()[0xa68c:0xa79c]).hexdigest(),scope='Declared private reset-vector/scratch-driver calibration only; actual scene runs keep their exact uninstrumented ROM.')
 (o/'results.json').write_text(json.dumps(result,indent=2)+'\n');return result
