#!/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=1281 sets the pad type like jdc's RetroArch remap. Battery RAM: GPGX_SRM=file (16 KB RetroArch .srm or 8 KB) 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('~/.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 == 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)
def aud(l, r): pass
def audb(d, n): return n
def poll(): pass
def state(port, dev, idx, id_):
    return 1 if port == 0 and dev == 1 and held.get(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'))     # e.g. 1281 = jdc's RetroArch setting (6-button pad); 0 = core default
if dev:
    core.retro_set_controller_port_device(0, dev); core.retro_set_controller_port_device(1, dev)
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
while True:
    f += 1
    while ci < len(cmds) and int(cmds[ci][0]) <= f:
        c = cmds[ci]; ci += 1
        if c[1] == 'press': held[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] == '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 srm and sram: open(srm, 'wb').write(bytes(sram))
            print(snaps, 'snaps'); sys.exit(0)
    core.retro_run()
    for k in list(held):
        held[k] -= 1
        if held[k] <= 0: del held[k]
