#!/usr/bin/env python3
"""Exercise all32 clubs/three tabs, goalie pages, sorting and both pads natively.
Requires a private finished-season SRAM and initialized gameplay state. A private
entry stub opens the actual production browser after loading that state. The
installed ROM/save files are never touched. Compares live rows to the source
league/playoff SRAM and confirms every battery byte remains unchanged.
"""
from pathlib import Path
import argparse,json,os,shutil,struct,subprocess
ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument('rom',type=Path);ap.add_argument('output',type=Path)
ap.add_argument('--save',type=Path,required=True);ap.add_argument('--state',type=Path,required=True)
a=ap.parse_args();here=Path(__file__).resolve().parent;out=a.output.resolve();out.mkdir(parents=True,exist_ok=True)
shutil.copyfile(here/'seasonend.s',out/'seasonend.s')
command='m68k-elf-as -m68000 -o /tmp/s.o seasonend.s && m68k-elf-ld -Ttext=0x2E3000 -e _start -o /tmp/s.elf /tmp/s.o && m68k-elf-nm /tmp/s.elf'
r=subprocess.run(['docker','run','--rm','--user',f'{os.getuid()}:{os.getgid()}','-v',str(out)+':/w','-w','/w','--entrypoint','sh','ghcr.io/stephane-d/sgdk','-c',command],capture_output=True,text=True,check=True)
syms={t[2]:int(t[0],16) for l in r.stdout.splitlines() if len(t:=l.split())==3}
asm=out/'driver.s';asm.write_text(f'''    .text
    .globl _start
_start:
    cmpi.w #0x55AA,0xFFF0C2
    beq.s enter
    jmp 0x2E1004
enter:
    clr.w 0xFFF0C2
    move.w #10,0xFFE532
    jsr 0x{syms['players']:X}
    move.l #0x444F4E45,0xFFF0C0
forever:
    bra.s forever
    .org 0x100
redraw:
    addq.w #1,0xFFF0C4
    jmp 0x2FC004
''')
subprocess.run([str(here.parent/'m68kasm.sh'),str(asm),'2FEA00',str(out/'code.bin')],check=True,stdout=subprocess.DEVNULL)
b=bytearray(a.rom.read_bytes());code=(out/'code.bin').read_bytes();assert all(x==255 for x in b[0x2fea00:0x2fea00+len(code)])
b[0x2fea00:0x2fea00+len(code)]=code;assert b[0x15ef2:0x15ef8]==bytes.fromhex('4eb9002e1004');b[0x15ef2:0x15ef8]=bytes.fromhex('4eb9002fea00')
# Count redraws in this private probe only: boundary presses must never call setup.
for p in range(0x2e3000,0x2e4000,2):
 if b[p:p+6]==bytes.fromhex('4eb9002fc004'):b[p:p+6]=bytes.fromhex('4eb9002feb00')
(out/'driver.bin').write_bytes(b)
frame=300;club=10;tab=sort=page=screen=0;cases=[];commands=[f'100 load {a.state.resolve()}']
def check(label):
 cases.append(dict(frame=frame,label=label,club=club,tab=tab,sort=sort,page=page,screen=screen))
def key(name,pad=1):
 global frame
 commands.append(f'{frame} press {pad} {name} 5');frame+=80
check('initial')
key('Up');check('bound-top')
key('Down');page=1;check('bottom')
key('Down',2);check('bound-bottom')
key('Up');page=0;check('top-again')
for step in range(32):
 for t in range(3):
  key('Down');page=1;check('goalies')
  key('Up');page=0;check('skaters')
  key('A',2 if step%2 else 1);tab=(tab+1)%3;check('tab')
 key('Right',2 if step%2 else 1);club=(club+1)%32;check('next-club')
for t in range(3):
 for s in range(3):
  key('C');sort=(sort+1)%3;check('sort')
 key('A');tab=(tab+1)%3;check('tab-sort')
key('Left',2);club=(club-1)%32;check('previous-club')
key('Right',2);club=(club+1)%32;check('right-after-left')
key('Start');screen=1;check('leaders')
key('A',2);tab=(tab+1)%3;check('leaders-tab')
key('C',2);sort=(sort+1)%3;check('leaders-sort')
key('C');sort=(sort+1)%3;check('leaders-sort')
key('C',2);sort=(sort+1)%3;check('leaders-sort')
key('B',2);screen=0;check('back-to-club')
key('Start');screen=1;check('leaders-again')
key('Start');cases.append(dict(frame=frame,label='done'))
commands.append(f'{frame+1} exit');(out/'commands.txt').write_text('\n'.join(commands)+'\n');(out/'cases.json').write_text(json.dumps(cases,indent=2)+'\n')
hook=out/'hook.py';hook.write_text('''from pathlib import Path
import json,hashlib,struct
p=Path(outdir);seed=Path(os.environ['BROWSER_SAVE']).read_bytes();logical=seed[1::2]
cases={r['frame']:r for r in json.loads((p/'cases.json').read_text())};results=[];last_redraw=[None]
def rw(a):return (wram[ram(a)]<<8)|wram[ram(a+1)]
def readbytes(a,n):return bytes(wram[ram(a+i)] for i in range(n))
def packgk(a,b):
 gp=(a[0]+b[0])&255;win=(a[1]+b[1])&255;ga=((a[2]<<4)|(a[3]>>4))+((b[2]<<4)|(b[3]>>4));sa=(((a[3]&15)<<8)|a[4])+(((b[3]&15)<<8)|b[4]);return bytes([gp,win,(ga>>4)&255,((ga<<4)|(sa>>8))&255,sa&255])
def test_tick(f):
 if f==101:
  for i,b in enumerate(seed):sram[i]=b
  wram[ram(0xf0c2)]=0x55;wram[ram(0xf0c3)]=0xaa
  wram[ram(0xf0c4)]=wram[ram(0xf0c5)]=0
 if f not in cases:return
 c=cases[f];assert bytes(sram)[:len(seed)]==seed,('viewer changed SRAM',c)
 redraw=rw(0xf0c4)
 if c['label'].startswith('bound-'):assert redraw==last_redraw[0],('redundant redraw at page boundary',c)
 last_redraw[0]=redraw
 if c['label']=='done':
  assert readbytes(0xf0c0,4)==b'DONE';results.append(c);(p/'results.json').write_text(json.dumps(results,indent=2)+'\\n');return
 assert rw(0xe532)==10,('viewer changed user club',c)
 assert rw(0xe6fc)==c['club'] and rw(0xe6fe)==c['screen'],('wrong club/screen',c,readbytes(0xe6f0,16).hex())
 assert rw(0xe6f4)==c['tab'] and rw(0xe6f6)==c['sort'],('wrong tab/sort',c)
 if c['screen']==0:
  assert rw(0xe6f2)==c['page'] and rw(0xe6f8)==c['club'],('wrong page/names',c)
  team=c['club'];tab=c['tab'];seeds=list(readbytes(0xf6c0,16));slot=seeds.index(team) if team in seeds else None
  league=logical[0x1220+100*team:0x1220+100*(team+1)];playoffs=logical[0x10+100*slot:0x10+100*(slot+1)] if slot is not None else bytes(100)
  if tab==1:expected=league
  elif tab==2:expected=playoffs
  else:
   expected=bytearray()
   for i in range(25):
    g,a,sog=struct.unpack_from('>BBH',league,i*4);g2,a2,sog2=struct.unpack_from('>BBH',playoffs,i*4);expected+=struct.pack('>BBH',(g+g2)&255,(a+a2)&255,(sog+sog2)&65535)
  assert readbytes(0xe600,100)==bytes(expected),('wrong skater totals',c)
  l=logical[0xe20+15*team:0xe20+15*(team+1)];q=logical[0x6b4+15*slot:0x6b4+15*(slot+1)] if slot is not None else bytes(15)
  goalies=l if tab==1 else q if tab==2 else b''.join(packgk(l[i:i+5],q[i:i+5]) for i in range(0,15,5))
  assert readbytes(0xe668,15)==goalies,('wrong goalie totals',c)
  entries=[readbytes(0xe680+i*4,4) for i in range(rw(0xe6f0))]
  keys=[]
  for entry in entries:
   slot=entry[0];g,a,s=struct.unpack_from('>BBH',expected,(slot-1)*4);value=((g+a)<<8)+g if c['sort']==0 else (g<<8)+(g+a) if c['sort']==1 else (a<<8)+(g+a);assert int.from_bytes(entry[2:4],'big')==value&65535
   keys.append(value&65535)
  assert keys==sorted(keys,reverse=True),('wrong sorted order',c)
 if c['label'] in ['initial','bottom','bound-top','bound-bottom','sort','leaders','leaders-sort','back-to-club']:
  size=core.retro_serialize_size();buf=C.create_string_buffer(size);assert core.retro_serialize(buf,size)
  rawstate=buf.raw;pattern=b'\\xff'*32+b'\\xee'*32;offset=rawstate.index(pattern)-0xb000;vram=rawstate[offset:offset+65536]
  # GPGX stores VRAM words in host order; the native plane has128 cells/row.
  base=0xc600;columns=[29,23,27];col=columns[c['sort']]+3*c['screen'];width=3 if c['sort']==0 else 1
  for start,count in [(columns[s]+3*c['screen'],3 if s==0 else 1) for s in range(3)]:
   for n in range(count):
    tile=int.from_bytes(vram[base+2*(start+n):base+2*(start+n)+2],'little')&2047
    glyph=vram[tile*32:tile*32+32];nibbles=[x for b in glyph for x in [b>>4,b&15]]
    if start==col:assert tile==0x582+n and 14 in nibbles and 1 not in nibbles,('missing selected header colour',c,start,n)
    else:assert 1 in nibbles and 14 not in nibbles,('unselected header coloured',c,start,n)
  assert rw(0xbd44)==0x006e,('wrong orange palette',c)
  # Both planes' row25 must contain only the native background, keeping a
  # complete blank row between18 player rows (7..24) and the help (26/27).
  row=0xc000+25*256
  for x in range(40):
   tile=int.from_bytes(vram[row+2*x:row+2*x+2],'little')
   assert tile&0x6000==0x6000,('text entered the help spacer row',c,x,hex(tile))
   front=int.from_bytes(vram[row+0x2000+2*x:row+0x2000+2*x+2],'little')
   assert front==0x7ff,('front-plane text entered the help spacer',c,x,hex(front))
 results.append(c)
 if c['label'] in ['initial','previous-club','back-to-club','leaders-tab','sort','leaders-sort','bound-top','bound-bottom'] or c['label']=='skaters' and c['club'] in [0,17,31] and c['tab']==2:
  raw,wd,ht,pitch=frame[0]
  if pixfmt[0]==1:im=Image.frombuffer('RGBA',(wd,ht),raw,'raw','BGRA',pitch,1).convert('RGB')
  else:
   pixels=bytearray()
   for y in range(ht):
    for (v,) in struct.iter_unpack('<H',raw[y*pitch:y*pitch+wd*2]):pixels+=bytes(((v>>11)<<3,((v>>5)&63)<<2,(v&31)<<3))
   im=Image.frombytes('RGB',(wd,ht),bytes(pixels))
  im.save(p/(str(f)+'.png'))
''')
env={k:v for k,v in os.environ.items() if k not in ['GPGX_SRM','GPGX_HOOK','GPGX_WAV']};env.update(GPGX_DEV='513',GPGX_HOOK=str(hook),BROWSER_SAVE=str(a.save.resolve()))
with (out/'log.txt').open('w') as log:subprocess.run(['python3',str(here/'test_core.py'),str(out/'driver.bin'),str(out/'commands.txt'),str(out)],env=env,check=True,stdout=log,stderr=log)
results=json.loads((out/'results.json').read_text());assert len(results)==len(cases);print('PASS',len(results),'native browser checks:32 clubs,three tabs,pages,sort,both pads,leaders,unchanged SRAM')
