#!/usr/bin/env python3
"""Actual Allstar identities/positions/ratings/photos, native getter ABI and pad input.

Pure routine probes explicitly set zero hot/cold and control flags. They are not
matches or strength trials. Physical USB hardware remains a manual playtest.
"""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import argparse,json,hashlib,struct,subprocess,sys,os
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',type=Path,required=True);ap.add_argument('--section',choices=['all','data','overall','abi','keeper','input','presentation'],default='all');a=ap.parse_args();h=Path(__file__).resolve().parent;o=a.output.resolve();o.mkdir(parents=True,exist_ok=True);b=a.rom.read_bytes();parent=a.baseline.read_bytes();sha=lambda p:hashlib.sha256(p.read_bytes()).hexdigest();assert sha(a.baseline)=='49c8b23b940a9c5368580dfcad11626a3ffbd4a51cdcae10ca8c9ce01f642487';assert b[0x22c000:0x22c002]==bytes.fromhex('6000');probe=NativeProbe(a.rom,h)
meta=json.loads((h/'allstar_catalog.json').read_text())['players'];original=[];selected=[]
def records(ptr):
 off=ptr+int.from_bytes(b[ptr:ptr+2],'big');out=[]
 while b[off:off+2]!=b'\0\2':
  n=int.from_bytes(b[off:off+2],'big');assert 2<=n<64;name=b[off+2:off+n].rstrip(b'\0').decode('latin1');out.append(dict(name=name,record=off,size=n+8,bytes=b[off:off+n+8],metadata=meta[name]));off+=n+8;assert len(out)<=26
 return out
for team in range(32):
 ptr=struct.unpack_from('>I',b,0x30e+4*team)[0];row=records(ptr);assert len(row)==26
 for slot,z in enumerate(row):original.append(z|dict(team=team,slot=slot,uid=team*26+slot,team_record=ptr))
for team,ptr in enumerate([0x22d000,0x22d800]):
 row=records(ptr);assert len(row)==26
 for slot,z in enumerate(row):
  uid=struct.unpack_from('>H',b,0x22cd00+2*(26*team+slot))[0];assert uid<832;selected.append(z|dict(team=team,slot=slot,uid=uid,team_record=ptr))

def data_suite():
 assert b[0x30e:0x40e]==parent[0x30e:0x40e]and b[0x2f0000:0x2f8000]==parent[0x2f0000:0x2f8000],'ordinary club records/tables changed'
 ids=set();lines=[]
 for z in selected:
  assert z['bytes']==original[z['uid']]['bytes'];assert z['metadata']['nhl_id']not in ids;ids.add(z['metadata']['nhl_id'])
  assert b[0x22c900+26*z['team']+z['slot']]==parent[0x2ff800+z['uid']],'Crit did not follow actual player'
  oldptr=struct.unpack_from('>I',parent,0x38e+4*original[z['uid']]['team'])[0];source=[struct.unpack_from('>I',parent,oldptr+6*i)[0]for i in range(26)if struct.unpack_from('>H',parent,oldptr+6*i+4)[0]==original[z['uid']]['slot']];newptr,newslot=struct.unpack_from('>IH',b,0x22e000+z['team']*160+6*z['slot']);assert source==[newptr]and newslot==z['slot'],'portrait did not follow actual player'
 for team,ptr in enumerate([0x22d000,0x22d800]):
  row=selected[team*26:team*26+26];counts={pos:sum(z['metadata']['position']==pos for z in row)for pos in ['G','D','C','LW','RW']};assert counts==dict(G=3,D=8,C=5,LW=5,RW=5)
  assert struct.unpack_from('>I',b,0x22c800+4*team)[0]==ptr;assert struct.unpack_from('>I',b,0x22c880+4*team)[0]==0x22e000+160*team;assert b[0x22cc40+4*team:0x22cc44+4*team]==bytes([0,1,3,0])
  off=ptr+struct.unpack_from('>H',b,ptr+6)[0]
  for i in range(8):
   ln=list(b[off+8*i:off+8*i+8]);assert len(set(ln[:6]))==6 and ln[7]==0;roles=[row[k-1]['metadata']['position']for k in ln[:6]];assert roles==['G','D','D','LW','C','RW'],('native line positions',team,i,ln,roles);lines.append(dict(team=team,line=i,players=[row[k-1]['name']for k in ln[:6]]))
 for team in range(2,32):assert b[0x22c800+4*team:0x22c804+4*team]==b[0x30e+4*team:0x312+4*team]and b[0x22c880+4*team:0x22c884+4*team]==b[0x38e+4*team:0x392+4*team]
 print('PASS52 unique native identities,3G/8D/5C/5LW/5RW per team,16valid lines, actual player Crit/portrait links and unchanged ordinary records',flush=True)
 return dict(unique_players=len(ids),lineups=lines,players=[{k:z[k]for k in ['team','slot','uid','name']}for z in selected])

def overall_suite():
 # Capture native pre-cap D0 as well as its unchanged clamp/return path.
 p=o/'overall-probe';p.mkdir(exist_ok=True);src=' .text\n .globl _start\n_start:\n move.w %d0,0xffff3fe0\n cmp.w %d1,%d0\n blt.s done\n move.w %d0,%d1\n subq.w #1,%d0\ndone: jmp 0xfab18\n';(p/'raw.s').write_text(src);subprocess.run([str(h.parent/'m68kasm.sh'),str(p/'raw.s'),'2D4C00',str(p/'raw.bin')],stdout=subprocess.DEVNULL,check=True);trace=(p/'raw.bin').read_bytes();assert b[0xfab0e:0xfab14]==bytes.fromhex('b0416d000006');patches=[(0xfab0e,bytes.fromhex('b0416d000006'),bytes.fromhex('4ef9002d4c00')),(0x2d4c00,b'\xff'*len(trace),trace)]
 allplayers=original+selected
 def batch(i):
  ss=allplayers[i*32:(i+1)*32];body=[' lea 0xffffc870,%a0',' moveq #103,%d0','clear: clr.l (%a0)+',' dbra %d0,clear',' lea 0xffffc6ce,%a2']
  for j,z in enumerate(ss):
   dest=0xffff4000+10*j;mask=0x1b0f000a if z['metadata']['position']=='G'else 0x1fba000a;body += [f' move.l #0x{z["team_record"]:x},0x1e(%a2)',f' move.w #{z["slot"]},%d0',f' move.l #0x{mask:x},%d4',' jsr 0xfa9f8',f' move.w 0xffff3fe0,0x{dest:x}',f' move.w %d0,0x{dest+2:x}',f' move.w %d1,0x{dest+4:x}',' mulu.w #100,%d0',' divu.w %d1,%d0',f' move.w %d0,0x{dest+6:x}',f' move.w #{i*32+j},0x{dest+8:x}']
  raw=probe.run(o/'native-overall'/str(i),'\n'.join(body),10*len(ss),patches);result=[]
  for j,z in enumerate(ss):
   score,n,d,pct,index=struct.unpack_from('>5H',raw,10*j);assert index==i*32+j and pct==100*n//d and 0<=pct<=99;result.append(dict(uid=z['uid'],name=z['name'],position=z['metadata']['position'],nhl_id=z['metadata']['nhl_id'],raw=score,overall=pct,allstar=(i*32+j>=832)))
  return result
 with ThreadPoolExecutor(max_workers=3)as pool:rows=sum(pool.map(batch,range((len(allplayers)+31)//32)),[])
 assert len(rows)==884
 for r in rows[832:]:assert(r['raw'],r['overall'])==(rows[r['uid']]['raw'],rows[r['uid']]['overall']),'Allstar ratings differ from actual original player'
 unique={}
 for r in rows[:832]:
  old=unique.get(r['nhl_id'])
  if old is None or(r['position']!='F',r['overall'],r['raw'])>(old['position']!='F',old['overall'],old['raw']):unique[r['nhl_id']]=r
 selected_ids={z['metadata']['nhl_id']for z in selected}
 for pos,n in [('G',6),('D',16),('C',10),('LW',10),('RW',10)]:
  top=sorted([r for r in unique.values()if r['position']==pos],key=lambda r:(-r['overall'],-r['raw'],r['nhl_id']))[:n];assert{r['nhl_id']for r in top}=={z['metadata']['nhl_id']for z in selected if z['metadata']['position']==pos},('Allstar did not select the actual native best by position',pos)
 # Exercise real native roster-count helpers against the new records.
 body=[]
 for team,ptr in enumerate([0x22d000,0x22d800]):
  body += [' lea 0xffffc6ce,%a2',f' move.l #0x{ptr:x},0x1e(%a2)']
  for k,fn in enumerate([0x9f40,0x9f5c,0x9f7e,0x9f9a]):body += [f' jsr 0x{fn:x}',f' move.w %d0,0x{0xffff4000+8*team+2*k:x}']
 counts=probe.run(o/'native-counts','\n'.join(body),16);assert struct.unpack('>8H',counts)==(3,18,15,26)*2
 print('PASS884 actual native raw/percentage ratings, independently native-ranked52player selection and native3G/15F/26 counts',flush=True);return dict(players=rows,native_counts=[3,18,15,26],hot_cold_fixture='zero; no Easy/Momentum override')

GETTERS=[(8,'a0',0x30e,0x22c800),(12,'a1',0x30e,0x22c800),(16,'a2',0x30e,0x22c800),(20,'a5',0x30e,0x22c800),(24,'a0',0x38e,0x22c880),(28,'a0',0x2ff800,0x22c900)]
def target(entry):return 0x22c000+entry+2+int.from_bytes(b[0x22c002+entry:0x22c004+entry],'big',signed=True)
gk_fn=target(32);assert b[gk_fn:gk_fn+4]==bytes.fromhex('40e7207c');GKTAB=int.from_bytes(b[gk_fn+4:gk_fn+8],'big');GETTERS.append((32,'a0',GKTAB,0x22cc40));GETTERS.append((68,'a1',GKTAB,0x22cc40))
CANARY=bytes((i*37+(i>>8)*11+19)&255 for i in range(0x4860))
def abi_batch(p,specs,cart=probe):
 body=[' lea 0x22F000,%a0',' lea 0xffffb000,%a1',' move.w #0x4860/4-1,%d0','copy: move.l (%a0)+,(%a1)+',' dbra %d0,copy'];expected=[]
 for j,z in enumerate(specs):
  entry,flags,irq,ccr=z;active=flags==(1,1,0);table=0x22c800 if active else 0x30e;ptr=struct.unpack_from('>I',b,table)[0];ptr1=struct.unpack_from('>I',b,table+4)[0];word=[0,0x7fff,0x8001][ccr%3];body += [f' move.w #{flags[0]},0xfffff850',f' move.w #{flags[1]},0xfffff84c',f' move.w #{flags[2]},0xfffff06c',f' move.w #{word},0xffffc6ce',f' move.l #0x{ptr:x},0xffffc6ec']
  regs=[0x12340004,0x23450004,0x22330002,0x34568001,0x22330004,ptr1+(ccr&1),0x22330006,0x22330007]+[0xffff1000+256*i for i in range(7)];regs[10]=0xffffc6ce
  if entry in[36,40]:regs[0]=0x12348001
  def init():return[f' move.l #0x{v:x},%d{k}'for k,v in enumerate(regs[:8])]+[f' movea.l #0x{v:x},%a{k}'for k,v in enumerate(regs[8:])]+[f' move.w #{0x2000+(irq<<8)+ccr},%sr']
  def snap(at):return[f' movem.l %d0-%d7/%a0-%a6,0x{at:x}',f' move.w %sr,0x{at+60:x}',f' move.l %sp,0x{at+62:x}']
  at=0xffff4000+144*j;body+=init()+[f' jsr 0x{0x22c000+entry:x}']+snap(at)
  for k,a0 in enumerate([0xf850,0xf84c,0xf06c,0xc6ce,0xc6ec,0xc6ee]):body+=[f' move.w 0xffff{a0:04x},0x{at+132+2*k:x}']
  if entry in [g[0]for g in GETTERS]:
   _,reg,ordinary,special=next(g for g in GETTERS if g[0]==entry);control=[f' movea.l #0x{special if active else ordinary:x},%{reg}']
  else:
   control=[f' movea.l #0x{table:x},%'+('a1'if entry in[40,44,48,52]else'a0')]
   control+=dict([(36,[' asl.w #2,%d0']),(40,[' asl.w #2,%d0']),(44,[' asl.w #2,%d3']),(48,[' movea.l (%a1,%d0.w),%a1']),(52,[' movea.l (%a1,%d1.w),%a1']),(56,[' move.w (%a2),%d0']),(60,[' movea.l 0x1e(%a2),%a1']),(64,[' cmp.l (%a0,%d0.w),%d5'])])[entry]
  body+=init()+control+snap(at+66);expected.append((flags,word,ptr))
 body+=[' lea 0xffffb000,%a0',' lea 0xffff5800,%a1',' move.w #0x4860/4-1,%d0','capture: move.l (%a0)+,(%a1)+',' dbra %d0,capture'];raw=cart.run(p,'\n'.join(body),0x1800+len(CANARY),data=CANARY,data_address=0x22F000)
 for j,z in enumerate(specs):
  actual,control=raw[j*144:j*144+66],raw[j*144+66:j*144+132];assert actual==control,('native Allstar getter changes original ABI',z,actual.hex(),control.hex());flags,word,ptr=expected[j];assert struct.unpack_from('>6H',raw,j*144+132)==(*flags,word,ptr>>16,ptr&65535),('getter changed fixture/control state',z)
 want=bytearray(CANARY);flags,word,ptr=expected[-1]
 for at,val in[(0xf850,flags[0]),(0xf84c,flags[1]),(0xf06c,flags[2]),(0xc6ce,word),(0xc6ec,ptr>>16),(0xc6ee,ptr&65535)]:struct.pack_into('>H',want,at-0xb000,val)
 assert raw[0x1800:]==want,'getter changed unrelated complete native RAM/canary'
 return len(specs)

def abi_suite():
 entries=[g[0]for g in GETTERS]+[36,40,44,48,52,56,60,64];specs=[(e,f,i,c)for e in entries for f in[(0,0,0),(1,1,0)]for i in range(8)for c in range(32)]+[(e,f,7,c)for e in entries for f in[(1,0,0),(1,1,1),(99,1,0),(1,99,0)]for c in[0,31]]
 with ThreadPoolExecutor(max_workers=3)as pool:counts=list(pool.map(lambda i:abi_batch(o/'abi'/str(i),specs[i*32:(i+1)*32]),range((len(specs)+31)//32)))
 negative=[]
 for name,mutant in [('forced-ordinary',False),('dropped-SR',True)]:
  blob=bytearray(b);start=target(8);end=target(12)
  if mutant:
   at=b.find(bytes.fromhex('46df'),start,end);assert at>=0;blob[at:at+2]=bytes.fromhex('548f');sample=[(8,(1,1,0),7,0)]
  else:
   at=b.find(bytes.fromhex('207c0022c800'),start,end);assert at>=0;blob[at+2:at+6]=bytes.fromhex('0000030e');sample=[(8,(1,1,0),7,0)]
  path=o/(name+'.bin');path.write_bytes(blob)
  try:abi_batch(o/('negative-'+name),sample,NativeProbe(path,h))
  except AssertionError as e:assert 'native Allstar getter changes original ABI'in str(e);negative.append(dict(mutant=name,caught=str(e)))
  else:raise AssertionError(('Allstar negative control escaped',name))
 print('PASS',sum(counts),'complete native GPR/SR/IRQ/SP, guard and full RAM getter contexts;2active negative controls',flush=True);return dict(contexts=sum(counts),negative_controls=negative)

def keeper_suite():
 p=o/'keeper-accounting';p.mkdir(exist_ok=True)
 with(p/'suite.log').open('w')as f:subprocess.run(['python3',str(h/'verify_goalie_attack.py'),str(a.rom.resolve()),str(p),'--allstar'],stdout=f,stderr=f,check=True)
 return json.loads((p/'results.json').read_text())

def input_suite():
 p=o/'input';p.mkdir(exist_ok=True)
 with(p/'suite.log').open('w')as f:subprocess.run(['python3',str(h/'verify_quickgame.py'),str(a.rom.resolve()),str(p),'--allstar'],stdout=f,stderr=f,check=True)
 return json.loads((p/'results.json').read_text())

def presentation_suite():
 # Exact original clubs and both sets of their own uniforms remain untouched.
 assert b[0x2f0000:0x2f8000]==parent[0x2f0000:0x2f8000]
 uniform_indices=[3,4,5,8,9,10,11,12,13,14];palettes=[]
 for team,ptr in enumerate([0x22d000,0x22d800]):
  source=struct.unpack_from('>I',parent,0x30e+4*team)[0]
  for bank in range(2):
   colors=struct.unpack_from('>16H',b,ptr+0x0c+bank*32);before=struct.unpack_from('>16H',parent,source+0x0c+bank*32)
   for k in range(16):
    if k not in uniform_indices:assert colors[k]==before[k],('uniform changed skin/neutral entry',team,bank,k)
   for k in uniform_indices:
    v=colors[k]
    assert (v in [0,0x222,0x444])if team==0 else(0<v<=0xe and v%2==0),('uniform is not monochrome black/red',team,bank,k,hex(v))
   palettes.append(dict(team=team,bank=bank,colors=[f'{v:04X}'for v in colors]))
 # Independent actual native player-overall evaluation, without using the builder's formula.
 avgs=[]
 for team,ptr in enumerate([0x22d000,0x22d800]):
  body=[' lea 0xffffc870,%a0',' moveq #103,%d0','clear: clr.l (%a0)+',' dbra %d0,clear',' lea 0xffffc6ce,%a2',f' move.l #0x{ptr:x},0x1e(%a2)']
  for slot in range(26):
   mask=int.from_bytes(b[0x19582 if slot<3 else 0x19420:(0x19582 if slot<3 else 0x19420)+4],'big');body += [f' move.w #{slot},%d0',f' move.l #0x{mask:x},%d4',' jsr 0xfa9f8',' mulu.w #100,%d0',' divu.w %d1,%d0',f' move.w %d0,0x{0xffff4000+2*slot:x}']
  raw=probe.run(o/'presentation-native-ratings'/str(team),'\n'.join(body),52);avgs.append(sum(struct.unpack('>26H',raw))//26)
 assert tuple(avgs)==struct.unpack_from('>2H',b,0x22cd68),('team overview is not actual selected-roster mean',avgs)
 # Preserve the original static-getter oracle for invalid/Allstar guards.
 # The newly requested ordinary-club display metric is independently measured
 # with actual native FA9F8 for every original player, not the builder formula.
 ordinary_avgs=[]
 from allstar import catalogue as original_catalogue
 original_players=original_catalogue(bytearray(b))
 for club in range(32):
  ptr=struct.unpack_from('>I',b,0x30e+4*club)[0]
  body=[' lea 0xffffc870,%a0',' moveq #103,%d0','ordinary_clear: clr.l (%a0)+',' dbra %d0,ordinary_clear',' lea 0xffffc6ce,%a2',f' move.l #0x{ptr:x},0x1e(%a2)']
  for slot in range(26):
   player=original_players[26*club+slot];assert player['team']==club and player['slot']==slot
   address=0x19582 if player['position']=='G'else 0x19420
   mask=int.from_bytes(b[address:address+4],'big')
   body += [f' move.w #{slot},%d0',f' move.l #0x{mask:x},%d4',' jsr 0xfa9f8',' mulu.w #100,%d0',' divu.w %d1,%d0',f' move.w %d0,0x{0xffff4000+2*slot:x}']
  raw=probe.run(o/'presentation-original-native-ratings'/str(club),'\n'.join(body),52)
  ordinary_avgs.append(sum(struct.unpack('>26H',raw))//26)
 new_roster_metric=b[0x25800c:0x258010]==bytes.fromhex('60004df2')
 calibrated_club_grade=b'OVM2'in b[0x25ce00:0x25d000]
 if calibrated_club_grade:assert b[0x1e92b4:0x1e92d4]==parent[0x1e92b4:0x1e92d4],'original club grades changed'
 # Fresh ordinary rosters use the independently native original club grade.
 # Allstar still uses selected-player mean; transfer deltas qualify in team_ovr.
 native=parent[0xfe172:0xfe18e];assert native.hex().startswith('48e77ffe302a0028')and native[-2:]==bytes.fromhex('4e75')
 specs=[((1,1,0),team,True,irq,ccr)for team in range(2)for irq in range(8)for ccr in range(32)]
 specs += [(flags,club,False,irq,ccr)for flags in [(0,0,0),(1,0,0),(1,1,1),(99,1,0),(1,99,0),(1,1,0)]for club in range(32)for irq in [0,7]for ccr in [0,31]]
 def batch(i):
  cases=specs[i*24:(i+1)*24];body=[]
  for k,(flags,club,virtual,irq,ccr)in enumerate(cases):
   ptr=[0x22d000,0x22d800][club]if virtual else struct.unpack_from('>I',b,0x30e+4*club)[0];cache=0xffffc6ce if club%2==0 else 0xffffca32;body += [f' move.w #{flags[0]},0xfffff850',f' move.w #{flags[1]},0xfffff84c',f' move.w #{flags[2]},0xfffff06c',f' move.l #0x{ptr:x},0x{cache+0x1e:x}',f' move.w #{club},0x{cache+0x28:x}'];regs=[0x12348001+q*0x10000 for q in range(8)]+[0xffff1000+q*256 for q in range(7)];regs[10]=cache
   init=[f' move.l #0x{v:x},%d{q}'for q,v in enumerate(regs[:8])]+[f' movea.l #0x{v:x},%a{q}'for q,v in enumerate(regs[8:])]+[f' move.w #{0x2000+(irq<<8)+ccr},%sr']
   def snap(at):return[f' movem.l %d0-%d7/%a0-%a6,0x{at:x}',f' move.w %sr,0x{at+60:x}',f' move.l %sp,0x{at+62:x}']
   dest=0xffff4000+132*k
   ordinary_mean=new_roster_metric and not calibrated_club_grade and not virtual and all(0<=x<=1 for x in flags)and flags!=(1,1,0)
   reference=[f' move.w #{avgs[club]},%d0']if virtual else[f' move.w #{ordinary_avgs[club]},%d0']if ordinary_mean else[' jsr 0x2d4c00']
   body+=init+[' jsr 0xfe172']+snap(dest)+init+reference+snap(dest+66)
  raw=probe.run(o/'presentation-abi'/str(i),'\n'.join(body),132*len(cases),patches=[(0x2d4c00,b'\xff'*len(native),native)])
  for k,c in enumerate(cases):assert raw[132*k:132*k+66]==raw[132*k+66:132*k+132],('Allstar team-overall native ABI/guard/regression',c,raw[132*k:132*k+132].hex())
  return len(cases)
 with ThreadPoolExecutor(max_workers=3)as pool:counts=list(pool.map(batch,range((len(specs)+23)//24)))
 print('PASS Allstar A black/B red both uniform banks; unchanged32clubs/skin/neutral; actual native52player mean ratings',avgs,'and',sum(counts),'team-overall native ABI/guard/all32ordinary-club contexts',flush=True)
 return dict(team_overall=avgs,ordinary_base_roster_means=ordinary_avgs,ordinary_ratings_independent_native_FA9F8=True,native_player_ratings=52,team_overall_contexts=sum(counts),uniforms=palettes,ordinary_clubs_unchanged=32,scope='Native routine and exact ROM palette data; actual match screenshots are separately opened and reviewed.')

report=dict(rom_sha256=sha(a.rom),baseline_sha256=sha(a.baseline),sections={},scope=__doc__)
for key,fn in [('data',data_suite),('overall',overall_suite),('abi',abi_suite),('keeper',keeper_suite),('input',input_suite),('presentation',presentation_suite)]:
 if a.section in['all',key]:report['sections'][key]=fn();(o/'results.json').write_text(json.dumps(report,indent=2)+'\n')
