#!/usr/bin/env python3
# gpgx.py ROM cmds.txt [out_dir] -- headless test run in Genesis Plus GX (the libretro core RetroArch uses), no window.
# MAME's VDP is less exact: something can look right in MAME and wrong in RetroArch (2026-10-22: the division table).
# Same command file as run.sh/h.lua: "<frame> press <pad> <Start|Up|Down|Left|Right|A|B|C|X|Y|Z|Mode> <frames>",
# "<frame> snap", "<frame> dump <hexaddr> <len> [label]", "<frame> poke <hexaddr> <hexbyte>", "<frame> exit".
# GPGX_DEV=513 sets normal 6-button pads; 1281 is 6-button + 4-WayPlay. Battery RAM: GPGX_SRM=file (16 KB legacy / 64 KB three-slot RetroArch .srm) is loaded before the start and written back at exit.
# Snapshots go to out_dir (default work/gpgx) as NNNN.png, dumps to cmds.out.
import ctypes as C, os, sys, struct
from PIL import Image

CORE = os.path.expanduser(os.environ.get('GPGX_CORE', '~/.local/share/Steam/steamapps/common/RetroArch/cores/genesis_plus_gx_libretro.so'))
rom, cmdf = sys.argv[1], sys.argv[2]
outdir = sys.argv[3] if len(sys.argv) > 3 else os.path.expanduser('~/.config/retroarch/roms/claude/work/gpgx')
os.makedirs(outdir, exist_ok=True)
for f in os.listdir(outdir):
    if f.endswith('.png'): os.remove(os.path.join(outdir, f))
cmds = []
for l in open(cmdf):
    t = l.split()
    if t and not t[0].startswith('#'): cmds.append(t)
cmds.sort(key=lambda t: int(t[0]))
out = open(os.path.splitext(cmdf)[0] + '.out', 'w')

core = C.CDLL(CORE)
ENV = C.CFUNCTYPE(C.c_bool, C.c_uint, C.c_void_p)
VID = C.CFUNCTYPE(None, C.c_void_p, C.c_uint, C.c_uint, C.c_size_t)
AUD = C.CFUNCTYPE(None, C.c_int16, C.c_int16)
AUDB = C.CFUNCTYPE(C.c_size_t, C.c_void_p, C.c_size_t)
POLL = C.CFUNCTYPE(None)
STATE = C.CFUNCTYPE(C.c_int16, C.c_uint, C.c_uint, C.c_uint, C.c_uint)
pixfmt = [1]                                   # 0 = 0RGB1555, 1 = XRGB8888, 2 = RGB565
sysdir = C.c_char_p(os.path.dirname(os.path.abspath(rom)).encode())
held = {}                                      # retropad id -> frames left
frame = [None]

def env(cmd, data):
    if cmd == 35 and os.environ.get('GPGX_PRINT_PADS'):
        class Desc(C.Structure):
            _fields_=[('name',C.c_char_p),('id',C.c_uint)]
        class Info(C.Structure):
            _fields_=[('types',C.POINTER(Desc)),('count',C.c_uint)]
        info=C.cast(data,C.POINTER(Info))
        for port in range(8):
            if not info[port].count: break
            print('Pad port',port,[(info[port].types[i].name.decode(),info[port].types[i].id) for i in range(info[port].count)],flush=True)
        return True
    if cmd == 10:                              # SET_PIXEL_FORMAT
        pixfmt[0] = C.cast(data, C.POINTER(C.c_int))[0]; return True
    if cmd in (9, 31):                         # GET_SYSTEM_DIRECTORY / GET_SAVE_DIRECTORY
        C.cast(data, C.POINTER(C.c_char_p))[0] = sysdir.value; return True
    if cmd == 3:                               # GET_CAN_DUPE
        C.cast(data, C.POINTER(C.c_bool))[0] = True; return True
    return False
def vid(data, w, h, pitch):
    if data: frame[0] = (C.string_at(data, pitch * h), w, h, pitch)
audio_chunks=[]
def recording_audio():
    return os.environ.get('GPGX_WAV') and globals().get('f',0)>=int(os.environ.get('GPGX_AUDIO_FROM','100'))
def aud(l,r):
    if recording_audio():audio_chunks.append(struct.pack('<hh',l,r))
def audb(d,n):
    if recording_audio():audio_chunks.append(C.string_at(d,n*4))
    return n
def poll(): pass
def state(port, dev, idx, id_):
    return 1 if dev == 1 and held.get((port, id_), 0) > 0 else 0
cbs = [ENV(env), VID(vid), AUD(aud), AUDB(audb), POLL(poll), STATE(state)]
core.retro_set_environment(cbs[0]); core.retro_set_video_refresh(cbs[1]); core.retro_set_audio_sample(cbs[2])
core.retro_set_audio_sample_batch(cbs[3]); core.retro_set_input_poll(cbs[4]); core.retro_set_input_state(cbs[5])
core.retro_init()

class GameInfo(C.Structure):
    _fields_ = [('path', C.c_char_p), ('data', C.c_void_p), ('size', C.c_size_t), ('meta', C.c_char_p)]
romdata = open(rom, 'rb').read()
buf = C.create_string_buffer(romdata, len(romdata))
gi = GameInfo(os.path.abspath(rom).encode(), C.cast(buf, C.c_void_p), len(romdata), None)
assert core.retro_load_game(C.byref(gi)), 'load failed'
dev = int(os.environ.get('GPGX_DEV', '0'))     # 513 = normal six-button, 1281 = 4-WayPlay; 0 = core default
if dev:
    core.retro_set_controller_port_device(0, dev); core.retro_set_controller_port_device(1, int(os.environ.get('GPGX_DEV2', dev)))   # GPGX_DEV2: port 2 alone
core.retro_get_memory_data.restype = C.c_void_p
core.retro_get_memory_size.restype = C.c_size_t
core.retro_serialize_size.restype = C.c_size_t
def mem(kind):
    p, n = core.retro_get_memory_data(kind), core.retro_get_memory_size(kind)
    return (C.c_ubyte * n).from_address(p) if p and n else None
sram, wram = mem(0), mem(2)                   # RETRO_MEMORY_SAVE_RAM, RETRO_MEMORY_SYSTEM_RAM (68k RAM, 16-bit words, host order)
srm = os.environ.get('GPGX_SRM')
if srm and os.path.exists(srm) and sram:
    d = open(srm, 'rb').read()
    for i in range(min(len(d), len(sram))): sram[i] = d[i]
def ram(addr):                                 # $FFxxxx -> index in GPGX's work RAM (bytes swapped in each word)
    return (addr & 0xFFFF) ^ 1
IDS = {'B': 0, 'A': 1, 'Mode': 2, 'Start': 3, 'Up': 4, 'Down': 5, 'Left': 6, 'Right': 7, 'C': 8, 'Y': 9, 'X': 10, 'Z': 11}
snaps = 0; ci = 0; f = 0
credits_acknowledged = False; credits_ack_started = False
matchups_inputs=[]; last_matchups_start=-1000
hook_path = os.environ.get('GPGX_HOOK')
if hook_path: exec(compile(open(hook_path).read(), hook_path, 'exec'), globals())
while True:
    f += 1
    while ci < len(cmds) and int(cmds[ci][0]) <= f:
        c = cmds[ci]; ci += 1
        if c[1] == 'press': held[(int(c[2])-1, IDS[c[3]])] = int(c[4])
        elif c[1] == 'poke': wram[ram(int(c[2], 16))] = int(c[3], 16)
        elif c[1] == 'dump':
            a = int(c[2], 16); out.write('%d %s %s\n' % (f, c[4] if len(c) > 4 else c[2], ' '.join('%02x' % wram[ram(a + k)] for k in range(int(c[3]))))); out.flush()
        elif c[1] == 'snap' and frame[0]:
            data, w, h, pitch = frame[0]
            if pixfmt[0] == 1: im = Image.frombuffer('RGBA', (w, h), data, 'raw', 'BGRA', pitch, 1).convert('RGB')
            else:
                px = bytearray()
                for y in range(h):
                    row = data[y * pitch:y * pitch + 2 * w]
                    for (v,) in struct.iter_unpack('<H', row):
                        px += bytes(((v >> 11) << 3, ((v >> 5) & 63) << 2, (v & 31) << 3))
                im = Image.frombytes('RGB', (w, h), bytes(px))
            im.save(os.path.join(outdir, '%04d.png' % snaps)); snaps += 1
        elif c[1] == 'load':
            data = open(c[2], 'rb').read(); saved = C.create_string_buffer(data, len(data))
            assert core.retro_unserialize(saved, len(data)), 'State load failed'
        elif c[1] == 'state':                  # "<frame> state file": GPGX savestate (has VRAM, VSRAM, VDP registers)
            n = core.retro_serialize_size(); b = C.create_string_buffer(n)
            core.retro_serialize(b, n); open(c[2], 'wb').write(b.raw)
        elif c[1] == 'exit':
            if os.environ.get('GPGX_WAV'):
                import wave
                class Geometry(C.Structure):
                    _fields_=[('base_width',C.c_uint),('base_height',C.c_uint),('max_width',C.c_uint),('max_height',C.c_uint),('aspect',C.c_float)]
                class Timing(C.Structure):
                    _fields_=[('fps',C.c_double),('sample_rate',C.c_double)]
                class AvInfo(C.Structure):
                    _fields_=[('geometry',Geometry),('timing',Timing)]
                av=AvInfo();core.retro_get_system_av_info(C.byref(av))
                with wave.open(os.environ['GPGX_WAV'],'wb') as wav:
                    wav.setnchannels(2);wav.setsampwidth(2);wav.setframerate(round(av.timing.sample_rate));wav.writeframes(b''.join(audio_chunks))
            if srm and sram:
                # SRAM is exposed as a fixed64KB buffer; persist the ROM's declared physical span.
                first,last=struct.unpack_from('>II',romdata,0x1B4)
                span=(last-first+2) if romdata[0x1B0:0x1B2]==b'RA' else len(sram)
                open(srm, 'wb').write(bytes(sram)[:min(span,len(sram))])
            print(snaps, 'snaps'); sys.exit(0)
    # Boot-menu suites explicitly acknowledge the new indefinite credits card.
    # Dedicated credits/input tests disable this with GPGX_AUTO_CREDITS=0.
    credit_wait = wram[ram(0xd066)]==0x43 and wram[ram(0xd067)]==0x52 and wram[ram(0xb004)]==0xc0
    if f<900 and not credits_acknowledged and os.environ.get('GPGX_AUTO_CREDITS','1')!='0':
        if credit_wait:
            held[(0,IDS['Start'])]=2;credits_ack_started=True
        elif credits_ack_started:
            credits_acknowledged=True
            print('Native Start acknowledged boot credits at frame',f,flush=True)
    # Explicit real-button acknowledgement of the restored native Matchups.
    # The authenticated GenPlusGX call stack must contain the live return from
    # JSR1732C; stale stack memory alone never authorizes input. No RAM writes.
    # Presentation/visual drivers disable this via GPGX_AUTO_MATCHUPS=0.
    if f>900 and f%10==0 and f-last_matchups_start>=120 and os.environ.get('GPGX_AUTO_MATCHUPS','1')!='0' and romdata[0x258000:0x258002]==bytes.fromhex('6000'):
        candidates=[a for a in range(0xff00,0xfffd,2) if (wram[ram(a)],wram[ram(a+1)],wram[ram(a+2)],wram[ram(a+3)])==(0,1,0x73,0x32)]
        if candidates:
            n=core.retro_serialize_size(); saved=C.create_string_buffer(n); assert core.retro_serialize(saved,n)
            assert saved.raw.startswith(b'GENPLUS-GX 1.7.6'), 'unverified native Matchups stack ABI'
            native_sp=struct.unpack_from('<I',saved.raw,144464)[0]
            live=[a for a in candidates if 0xffff0000<=native_sp<=0xffff0000+a]
            if live:
                held[(0,IDS['Start'])]=2;last_matchups_start=f
                matchups_inputs.append(dict(frame=f,stack_pointer=hex(native_sp),return_addresses=[hex(a)for a in live],input='P1 Start 2 frames'))
                import json
                open(os.path.join(outdir,'native-matchups-inputs.json'),'w').write(json.dumps(matchups_inputs,indent=2)+'\n')
    if hook_path: test_tick(f)
    core.retro_run()
    for k in list(held):
        held[k] -= 1
        if held[k] <= 0: del held[k]
