"""Native input/lifecycle fixtures shared by the painted-dot qualification suite."""
from pathlib import Path
from menu_protocol import down_count,render_probe
import json,struct
class Scenarios:
 def __init__(self,args,here,out,asm,core,pack,native_run=None,registers=None):self.a=args;self.h=here;self.o=out;self.asm=asm;self.core=core;self.pack=pack;self.native_run=native_run;self.registers=registers
 def battery(self,gp=0,dots=(0,1,0,1),others=1,bad=None):
  base=bytearray(self.a.seed.read_bytes()[1:16384:2]);base[0xe20:0x1ffe]=bytes(0x11de);banks=[]
  for slot,length in [(1,84),(2,31),(3,7)]:
   b=bytearray(base);struct.pack_into('>4s6H',b,0x1000,b'SESN',gp,11 if gp else 0,gp,length,0 if gp else slot,3);struct.pack_into('>4s4H',b,0x1ea0,b'S3SV',1,slot,7,1);b[0x1ef0:0x1ef6]=b'PC\x01\xfe\x01\xfe';b[0x1ff6:0x1ffe]=bytes([0x55,0,0,2,2,0,0x5a,0]);b[0x1eee:0x1ef0]=bytes([0x50,0xaf]);banks.append(b)
  b=bytearray(banks[0])+b''.join(banks);b[0x2e18:0x2e20]=b'RP\x01\xfeCT\x01\xfe';b[0x4d20:0x4d22]=bytes([0x50,0xaf]);b[0xe20:0xe25]=b[0x2e20:0x2e25]=bytes.fromhex('123456789a')
  for off,tag in [(0x4d22,0x60),(0x4d2a,0x70),(0x4d32,0x72),(0x4d3a,0x74),(0x4d42,0x76)]:b[off:off+8]=bytes([tag+others,(tag+others)^255])*4
  for slot,value in enumerate(dots):b[0x4d4a+2*slot:0x4d4c+2*slot]=bytes([0x78+value,0x87-value])
  if bad:b[0x4d4c:0x4d4e]=bytes.fromhex(bad)
  return self.pack(b)
 def menu(self):
  from menu_protocol import refinements_enabled
  if refinements_enabled(self.a.rom):
   from permanent_rules_test import verify
   return verify(self.a.rom,self.o,lambda gp,value:self.battery(gp,dots=(value,)*4,others=0),'dots')
  p=self.o/'menu-observer';p.mkdir(exist_ok=True);code=self.asm(p,render_probe(""" cmpi.w #46,%d7
 bne.s original
 move.w %sr,-(%sp)
 addq.w #1,0xffffef90
 move.w 0xffffb028,0xffffef92
 move.l %a1,0xffffef94
 move.w (%sp)+,%sr
original: jmp 0x11ba4
""",self.a.rom,[46]),0x2fea00);b=bytearray(self.a.rom.read_bytes());assert b[0x2fea00:0x2fea00+len(code)]==b'\xff'*len(code) and b[0xf7bf4:0xf7bfa]==bytes.fromhex('4ef900011ba4');b[0x2fea00:0x2fea00+len(code)]=code;b[0xf7bf4:0xf7bfa]=bytes.fromhex('4ef9002fea00');rom=p/'rom.bin';rom.write_bytes(b);rows=[]
  def game(name,raw,commands,checks,dev,render=False,finish=False,others=1):
   for frame,expected in checks.items():
    for field in ['plan','strict','alt','charge','pickup']:
     expected[field]=0 if name.startswith('fresh-') or name.startswith('restart-') and int(frame) in [3900,4600] else others
   q=self.o/name;q.mkdir(exist_ok=True);(q/'battery.srm').write_bytes(raw);(q/'spec.json').write_text(json.dumps(dict(name=name,checks=checks,render=render,finish=finish)));self.core(q,rom,commands,dict(GPGX_DEV=str(dev),GPGX_SRM=str(q/'battery.srm'),GPGX_HOOK=str(self.h/'testcases/faceoff_dots_menu_hook.py')));report=json.loads((q/'results.json').read_text());assert len(report)>=len(checks);rows.append(dict(name=name,observations=report));return(q/'battery.srm').read_bytes()
  down=[f'{900+50*i} press 1 Down 5' for i in range(down_count(self.a.rom,23))]
  for dev in [513,1281]:
   game('fresh-'+str(dev),bytes(65536),down+['2250 snap','2401 exit'],{2200:dict(dots=0)},dev,True,others=0)
   raw=game('choices-'+str(dev),self.battery(dots=(0,0,0,0)),down+['2300 press 1 Right 5','2450 snap','2500 press 1 Left 5','2700 press 1 Right 5','2901 exit'],{2200:dict(dots=0,full_goalie='123456789a'),2400:dict(dots=1,dots_records='7887798678877887'),2600:dict(dots=0,dots_records='7887788778877887'),2800:dict(dots=1,full_goalie='123456789a')},dev,True)
   game('reboot-'+str(dev),raw,['1301 exit'],{1200:dict(dots=1,dots_records='7887798678877887')},dev)
   nav=['1100 press 1 Right 5','1300 press 1 Right 5','1500 press 1 Right 5','1700 press 1 Left 5','1900 press 1 Left 5','2100 press 1 Left 5','2301 exit']
   raw=game('slots-'+str(dev),self.battery(),nav,{1000:dict(dots=1,slot=1),1200:dict(dots=0,slot=2),1400:dict(dots=1,slot=3),1600:dict(dots=0,view=0),1800:dict(dots=1,slot=3),2000:dict(dots=0,slot=2),2200:dict(dots=1,slot=1)},dev)
   game('slots-reboot-'+str(dev),raw,['1301 exit'],{1200:dict(dots=1,slot=1)},dev)
   game('locked-'+str(dev),self.battery(1),down+['2300 press 1 Left 5','2500 press 1 Right 5','2701 exit'],{2200:dict(dots=1,gp=1),2400:dict(dots=1,gp=1),2600:dict(dots=1,gp=1)},dev,True)
   for bad in ['0000','7987','00ff','ffff']:game('corrupt-'+bad+'-'+str(dev),self.battery(1,bad=bad),['1100 press 1 Right 5','1300 press 1 Left 5','1501 exit'],{1000:dict(dots=0,gp=1),1400:dict(dots=0,gp=1)},dev)
   native=[f'{1000+200*i} press 1 Right 5' for i in range(3)]+[f'{1800+40*i} press 1 Down 5' for i in range(down_count(self.a.rom,22))]
   raw=game('native-'+str(dev),self.battery(1),native+['2900 press 1 Right 5','3101 exit'],{2850:dict(dots=0,view=0),3000:dict(dots=1,view=0,dots_records='7986798678877986',full_goalie='123456789a')},dev,True)
   game('native-reboot-'+str(dev),raw,[f'{1000+200*i} press 1 Right 5' for i in range(3)]+['1701 exit'],{1600:dict(dots=1,view=0,full_goalie='123456789a')},dev)
   restart=['1100 press 1 Right 5','1300 press 1 Start 5']+[f'{1700+i*100} press 1 Down 5' for i in range(8)]+['2600 press 1 C 5','2900 press 1 Start 5','3200 press 1 C 5','3500 press 1 A 5','3500 press 1 C 5','4100 press 1 Left 5','4500 press 1 Right 5','4700 press 1 Right 5','5101 exit']
   game('restart-'+str(dev),self.battery(1,dots=(0,1,1,1)),restart,{3100:dict(dots=1,slot=2,gp=1),3900:dict(dots=0,slot=2,gp=0),4300:dict(dots=1,slot=1),4600:dict(dots=0,slot=2),4900:dict(dots=1,slot=3)},dev)
   raw=game('first-game-'+str(dev),self.battery(),['1500 press 1 Start 5','1900 press 1 Start 5','16001 exit'],{1200:dict(dots=1),3300:dict(dots=1)},dev,finish=dict(dots=1,gp=1))
   game('completed-lock-'+str(dev),raw,down+['2300 press 1 Left 5','2601 exit'],{2200:dict(dots=1,gp=1),2500:dict(dots=1,gp=1)},dev,True)
  print('PASS',len(rows),'native Faceoff Dots menu/render/boot/slot/lock/restart/real completion cases',flush=True);return dict(cases=rows)
 def foul_image(self):
  p=self.o/'foul-observer';p.mkdir(exist_ok=True);code=self.asm(p,(self.h/'testcases/faceoff_dots_foul_probe.s').read_text(),0x2d9800);b=bytearray(self.a.rom.read_bytes());assert b[0x2d9800:0x2d9800+len(code)]==b'\xff'*len(code) and b[0x15ed4:0x15eda]==bytes.fromhex('4eb9002e0000');b[0x2d9800:0x2d9800+len(code)]=code;b[0x15ed4:0x15eda]=bytes.fromhex('4eb9002d9800');rom=p/'rom.bin';rom.write_bytes(b);return rom,code
 def flow(self):
  from concurrent.futures import ThreadPoolExecutor
  rom,code=self.foul_image();cases=[dict(side=side,on=on,device=dev) for side in [0,1] for on in [0,1] for dev in [513,1281]]
  def trial(item):
   i,z=item;p=self.o/f'flow-{i}';p.mkdir(exist_ok=True);(p/'spec.json').write_text(json.dumps(z));(p/'battery.srm').write_bytes(self.battery(dots=(z['on'],)*4,others=0));self.core(p,rom,['1500 press 1 Start 5','1900 press 1 Start 5','15001 exit'],dict(GPGX_DEV=str(z['device']),GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(self.h/'testcases/faceoff_dots_flow_hook.py')));r=json.loads((p/'results.json').read_text());assert r['native_minor_queued'] and len(r['faces'])==50;assert any(x['lines']==([6,4] if z['side']==0 else [4,6]) for x in r['faces']),('actual PP2/PK2 unit selection',z,r['faces'][-1]);return r
  with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,enumerate(cases)))
  print('PASS8fresh saved-choice actual native minor/formation contexts, bothclubs/devices/choices',flush=True);return dict(cases=rows,method='Only pre-foul physical carrier/puck, controllers and second-unit energy are declared; native admission, penalty, point and formation execute. No result/point/formation/clock/RNG edits.')
 def lifetime(self):
  from concurrent.futures import ThreadPoolExecutor
  starts=[];source=(self.h/'testcases/shootout_start_hook.py').read_text();source=source.replace("        n=core.retro_serialize_size();buf=", "        assert rw(0xf848)==1,('Faceoff Dots On shootout capture',rw(0xf848))\n        n=core.retro_serialize_size();buf=")
  for side in [1,2]:
   p=self.o/f'lifetime-shootout-source-{side}';p.mkdir(exist_ok=True);(p/'battery.srm').write_bytes(self.battery(dots=(1,)*4));(p/'hook.py').write_text(source);case=dict(coop=False,side=side,force_at=1906,first_live_setup=True,teammates=2,period_choice=0);self.core(p,self.a.rom,['1500 press 1 Start 5','1900 press 1 Start 5','10000 exit'],dict(GPGX_DEV='513',GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(p/'hook.py'),SHOOTOUT_CASE=json.dumps(case)));events=json.loads((p/'events.json').read_text());first=next(x for x in events if x['event']=='first-live-no-input');assert first['active_buttons']==[] and first['controls'][0]==first['shooter'];starts.append(dict(side=side,state=str(p/'first-attempt.state'),first=first))
  p=self.o/'lifetime-observer';p.mkdir(exist_ok=True);code=self.asm(p,""" move.w %sr,-(%sp)
 addq.w #1,0xffffef90
 move.w (%sp)+,%sr
 jmp 0x2f9000
 .org 0x40
leaders: move.w %sr,-(%sp)
 addq.w #1,0xffffef92
 move.w (%sp)+,%sr
 jmp 0x2fa000
""",0x2d9000);b=bytearray(self.a.rom.read_bytes())
  for old,new in [(0x2f9000,0x2d9000),(0x2fa000,0x2d9040)]:
   oldb=struct.pack('>I',old);assert b.count(oldb)>=7;b=b.replace(oldb,struct.pack('>I',new))
  assert b[0x2d9000:0x2d9000+len(code)]==b'\xff'*len(code);b[0x2d9000:0x2d9000+len(code)]=code;observed=p/'rom.bin';observed.write_bytes(b)
  cases=[dict(kind='ordinary',state=str(self.a.state.resolve()),down=i,canary=canary) for canary in [False,True] for i in [8,9,10,11,12,13]]+[dict(kind='shootout',state=x['state'],down=0) for x in starts]
  def trial(item):
   i,z=item;q=self.o/f'lifetime-{i}';q.mkdir(exist_ok=True);(q/'spec.json').write_text(json.dumps(z));(q/'battery.srm').write_bytes(self.battery(1,dots=(1,)*4));commands=[f'100 load {z["state"]}','160 press 1 Start 5']+[f'{400+40*j} press 1 Down 5' for j in range(z['down'])]+['1100 press 1 C 5','1300 snap','1500 press 1 Start 5','1750 snap','1800 press 1 Start 5','2101 exit'];self.core(q,observed,commands,dict(GPGX_DEV='513',GPGX_SRM=str(q/'battery.srm'),GPGX_HOOK=str(self.h/'testcases/faceoff_dots_lifetime_hook.py')));r=json.loads((q/'results.json').read_text());assert r['canary_intact'] and r['paused_seen'] and r['resumed_seen'],('On/canary allocation/pause lifetime',z,r);return r
  with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(trial,enumerate(cases)))
  assert any(r['spec']['kind']=='ordinary' and r['views_seen'][0] for r in rows),'actual Standings not entered';assert any(r['spec']['kind']=='ordinary' and r['views_seen'][1] for r in rows),'actual Leaders not entered'
  print('PASS14actual pause/stats/special-attempt option lifetimes including invalid allocation canaries',flush=True);return dict(cases=rows,real_native_shootout_starts=starts,method='Declared initial preference word/canary after legacy-state restore, backed by On records; native views/pause/resume. No score/point/player/RNG edits during lifetime.')
 def observer(self):
  """Unarmed test-wrapper ABI against a declared native-callee output stub."""
  from concurrent.futures import ThreadPoolExecutor
  import hashlib
  p=self.o/'observer-ABI-code';p.mkdir(exist_ok=True);code=self.asm(p,(self.h/'testcases/faceoff_dots_foul_probe.s').read_text(),0x2d9800);stub=self.asm(p,' addq.l #1,%d0\n not.l %d1\n addq.l #2,%a0\n rts',0x2e0000);original=self.a.rom.read_bytes()[0x2e0000:0x2e0000+len(stub)]
  payload=bytearray((i*37+(i>>8)*11+19)&255 for i in range(0x4850));payload[0xef90-0xb000:0xef92-0xb000]=b'\0\0';payload=bytes(payload);cases=[dict(irq=i,ccr=c) for i in range(8) for c in range(32)]
  def trial(batch,observed):
   ss=cases[batch*16:batch*16+16];body=[' lea 0x22F000,%a0',' lea 0xffffb000,%a1',' move.w #0x4850/4-1,%d0','copy: move.l (%a0)+,(%a1)+',' dbra %d0,copy']
   for j,z in enumerate(ss):
    init,regs=self.registers(3,-4);body+=init+[f' move.w #{0x2000+(z["irq"]<<8)+z["ccr"]},%sr',f' jsr 0x{0x2d9800 if observed else 0x2e0000:x}',f' movem.l %d0-%d7/%a0-%a6,0x{0xffff4000+j*66:x}',f' move.w %sr,0x{0xffff4000+j*66+60:x}',f' move.l %sp,0x{0xffff4000+j*66+62:x}']
   body+=[' lea 0xffffb000,%a0',f' lea 0x{0xffff4000+66*len(ss):x},%a1',' move.w #0x4850/4-1,%d0','capture: move.l (%a0)+,(%a1)+',' dbra %d0,capture'];patches=[(0x22F000,payload),(0x2e0000,stub,original)]
   if observed:patches.append((0x2d9800,code))
   return self.native_run(self.o/f'observer-ABI-{observed}-{batch}','\n'.join(body),66*len(ss)+len(payload),patches)
  def pair(batch):
   direct,observed=trial(batch,False),trial(batch,True);assert direct==observed,('unarmed wrapper changed forwarded GPR/SR/IRQ/SP/RAM/RNG',batch);ss=cases[batch*16:batch*16+16]
   for j,z in enumerate(ss):
    regs=list(struct.unpack_from('>15I',observed,j*66));_,expected=self.registers(3,-4);expected[0]+=1;expected[1]^=0xffffffff;expected[8]+=2;sr,sp=struct.unpack_from('>HI',observed,j*66+60);assert regs==expected and sr==0x2008+(z['irq']<<8) and sp==0xfffff900,('must forward callee output, not old caller state',z,regs,sr,sp)
   assert observed[66*len(ss):]==payload,('unarmed metadata/callee stub wrote game RAM',batch);return dict(contexts=ss,output_sha256=hashlib.sha256(observed).hexdigest())
  with ThreadPoolExecutor(max_workers=3) as pool:rows=list(pool.map(pair,range(16)))
  print('PASS256unarmed native-foul observer forwarded-callee full ABI/RAM contexts',flush=True);return dict(batches=rows,scope='Test wrapper only. Crowd callee is explicitly stubbed to change registers/flags; live armed flow separately executes real Crowd and native minor11F2C. Not a claim of identical whole-game observer timing.')
