From 87c66b00730f24b2bbfb2e82f6e759e21b0bf266 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 17:03:07 -0700 Subject: [PATCH 01/13] wip: retroarch adapter --- adapters/retroarch-rpc.ts | 106 ++++ adapters/retroarch.ts | 302 ++++++++++ retroarch/worker.py | 1106 +++++++++++++++++++++++++++++++++++++ 3 files changed, 1514 insertions(+) create mode 100644 adapters/retroarch-rpc.ts create mode 100644 adapters/retroarch.ts create mode 100644 retroarch/worker.py diff --git a/adapters/retroarch-rpc.ts b/adapters/retroarch-rpc.ts new file mode 100644 index 0000000..c66a124 --- /dev/null +++ b/adapters/retroarch-rpc.ts @@ -0,0 +1,106 @@ +/** Thin RetroArch specialization of the shared out-of-process WorkerRpc protocol. */ +import { fileURLToPath } from 'node:url' +import { WorkerRpc, type WorkerEvidence, type WorkerStepResult } from './worker-rpc' + +const WORKER_PATH = fileURLToPath(new URL('../retroarch/worker.py', import.meta.url)) + +/** + * A RetroArch save state is large before compression (a Nintendo 64 core emits + * several megabytes). The worker deflates every checkpoint, but the cap is + * raised so a poorly compressible core state still fits one protocol line. + */ +const MAX_RESPONSE_BYTES = 16 << 20 + +export type { WorkerEvidence } +export type StepResult = WorkerStepResult + +/** How the bytes at `address` become one number. */ +export type ChannelDecode = 'bin' | 'bcd' + +export interface RetroArchChannel { + id: string + /** Core memory-map address. Game Boy work RAM starts at 0xC000. */ + address: number + /** Contiguous bytes, most significant first. Defaults to 1. */ + size?: number + /** Defaults to `bin`. `bcd` reads two decimal digits per byte. */ + decode?: ChannelDecode + /** Value this channel holds before the run makes progress, when known. */ + baseline?: number +} + +export interface RetroArchBootOptions { + /** RetroArch executable. */ + binary: string + /** libretro core shared library. */ + core: string + /** ROM, disc image, or other content the core accepts. */ + content: string + channels?: RetroArchChannel[] + /** Button vocabulary for this core, e.g. gambatte's Game Boy face buttons. */ + inputs?: string[] + /** Emulator frames advanced per Playproof input. */ + frames?: number + /** Frames the buttons stay held inside that window. */ + pressFrames?: number + /** Frames advanced after the core reset that pins the boot state. */ + bootFrames?: number + /** BIOS directory for cores that need one. */ + systemDir?: string + /** RetroArch video driver. `null` is headless and still serves SCREENSHOT. */ + videoDriver?: string + seed?: number +} + +export interface RetroArchIdentity { + gen: number + frame: number + core: string + content: string + /** SHA-256 of the content file, so a reference can pin what it was authored on. */ + contentSha: string + /** RetroArch's own GET_STATUS line, e.g. `GET_STATUS PAUSED game_boy,libbet,crc32=…`. */ + status: string + /** Libretro button names this core is driven with. */ + buttons: string[] + /** Advertised input vocabulary. */ + inputs: string[] + /** Declared evidence channel ids. */ + channels: string[] + frames: number + pressFrames: number + bootFrames: number + seed: number + /** RetroArch's process id, so a caller can prove teardown killed it. */ + pid: number | null + frameText: string +} + +export class RetroArchRpc extends WorkerRpc { + constructor(python = process.env.PLAYPROOF_PYTHON ?? 'python3') { + super({ name: 'retroarch', command: python, args: [WORKER_PATH], maxResponseBytes: MAX_RESPONSE_BYTES }) + } + + /** Launch RetroArch, pause it, and pin the boot state every reset returns to. */ + boot(options: RetroArchBootOptions): RetroArchIdentity { + return this.call('boot', { ...options }) + } + + reset(seed?: number): { gen: number; frame: number } { + return this.call('reset', seed === undefined ? {} : { seed }) + } + + /** Opaque Playproof checkpoint: deflated RetroArch save state plus the frame counter. */ + snapshot(): Buffer { + const result = this.call<{ bytes: string }>('snapshot') + return Buffer.from(result.bytes, 'base64') + } + + restore(state: Buffer): { gen: number; frame: number } { + return this.call('restore', { state: state.toString('base64') }) + } + + inputs(): string[] { + return this.call<{ inputs: string[] }>('inputs').inputs + } +} diff --git a/adapters/retroarch.ts b/adapters/retroarch.ts new file mode 100644 index 0000000..ebd2057 --- /dev/null +++ b/adapters/retroarch.ts @@ -0,0 +1,302 @@ +/** + * RetroArch host adapter: Playproof drives the RetroArch binary as a black box. + * + * The other emulator adapters link a Python emulator into the worker. This one + * links nothing. It launches whatever RetroArch the caller points at, with + * whatever libretro core and content they own, and drives it over the two UDP + * interfaces RetroArch already publishes: the text command interface for + * frame advance, memory reads, screenshots and save states, and the binary + * remote gamepad for button presses. Every core RetroArch can load therefore + * becomes a Playproof `Game` with no Playproof code per console. + * + * Evidence tiers exercised: + * A engine-state — caller-declared memory channels read with + * READ_CORE_MEMORY, never from the agent + * D screen-frame — sha256 of the decoded screenshot, plus bounded numbers + * derived from it (`frameState`) + * + * Deliberately absent: `saveBlobHash`. RetroArch compresses save states, and a + * compressed state is not a stable identity for a game position. Checkpoints + * stay exact within one worker, which is all snapshot/restore needs, and + * `retroarch.test.mts` proves that half. + * + * Determinism does not come from a seed. Libretro cores take none, so + * `init(seed)` restores a boot save state that the worker pins with a core + * reset plus a fixed number of frame advances, and every later transition is + * an explicit, counted frame advance from that state. The seed is recorded + * and reported so a run artifact stays shaped like every other adapter's, but + * it is nominal: the input log plus the boot state is the complete + * determinism key. `retroarch.test.mts` proves the boot state and the whole + * evidence stream are identical in a second, separately launched emulator. + * + * Cores and content are never distributed by Playproof. The caller brings a + * RetroArch binary, a core from the libretro buildbot, and legally obtained + * content, and names them through the options below or the env knobs. + * + * Env knobs: + * PLAYPROOF_PYTHON python interpreter that runs the worker (default python3) + */ +import { deriveContract, type MarkPoint } from '../authoring' +import type { Evidence, Game } from '../runtime' +import type { MilestoneContract } from '../schema' +import type { DiscoveryDoc } from './pyboy-generic' +import { + RetroArchRpc, + type RetroArchChannel, + type RetroArchIdentity, + type WorkerEvidence, +} from './retroarch-rpc' + +export type { RetroArchChannel, RetroArchIdentity } + +/** Engine-state milestones auto-generated from declared channels (cap). */ +export const AUTO_MARK_CHANNEL_CAP = 4 + +export interface RetroArchState { + gen: number + frame: number + evidence: Evidence + frameText: string +} + +export interface RetroArchOptions { + /** RetroArch executable. Defaults to PLAYPROOF_RETROARCH. */ + binary?: string + /** libretro core shared library. */ + core: string + /** ROM, disc image, or other content the core accepts. */ + content: string + /** Evidence channels, most significant first; `channelsFromDiscovery` builds these. */ + channels: RetroArchChannel[] + /** Button vocabulary for this core, e.g. `['up','down','left','right','a','b','start','select']`. */ + inputs?: string[] + /** Emulator frames per Playproof input. Defaults to 4. */ + frames?: number + /** Frames the buttons stay held inside that window. Defaults to 2. */ + pressFrames?: number + /** Frames advanced after the core reset that pins the boot state. Defaults to 60. */ + bootFrames?: number + systemDir?: string + videoDriver?: string + seed?: number + python?: string + /** Reference input script the contract is derived from. */ + reference: string[] + /** Channel whose first change anchors the screen-frame milestones. */ + anchorChannelId?: string +} + +export interface RetroArch { + game: Game + contract: MilestoneContract + /** The reference input script, as replayed. */ + reference: string[] + /** Advertised input vocabulary for this core's button layout. */ + inputs: string[] + identity: RetroArchIdentity + /** Channel values at the pinned boot state, which the marks fire against. */ + baseline: Record + seed: number + dispose(): void +} + +/** + * Turn a PyBoy discovery document into RetroArch channels. + * + * This is the join that makes the cross-emulator proof possible: the same + * addresses `pyboy/discover.py` found by watching work RAM are read back + * through RetroArch's core memory map, so one discovery document drives two + * unrelated emulators and neither adapter carries a hand-copied address. + * Discovery emits work-RAM addresses, which is exactly what a libretro core + * exposes through SET_MEMORY_MAPS, so no translation is needed. + */ +export function channelsFromDiscovery(doc: DiscoveryDoc): RetroArchChannel[] { + if (doc.channels.length === 0) throw new Error('discovery document declares no channels') + return [...doc.channels] + .sort((a, b) => a.rank - b.rank) + .map((channel) => { + const addresses = channel.addresses + if (addresses.length === 0) throw new Error(`discovered channel ${channel.id} declares no addresses`) + for (let i = 1; i < addresses.length; i++) { + if (addresses[i] !== addresses[0]! + i) { + throw new Error( + `discovered channel ${channel.id} reads non-contiguous addresses ${addresses.join(',')}; RetroArch reads a block`, + ) + } + } + return { + id: channel.id, + address: addresses[0]!, + size: addresses.length, + decode: channel.decode, + baseline: channel.valueStart, + } + }) +} + +function toEvidence(w: WorkerEvidence): Evidence { + return { + engineState: w.engineState, + ...(w.frameHash !== undefined ? { frameHash: w.frameHash } : {}), + ...(w.frameState !== undefined ? { frameState: w.frameState } : {}), + } +} + +/** + * Auto-marks from declared channels: one engine-state milestone per channel + * (the first AUTO_MARK_CHANNEL_CAP, which callers order by importance), plus + * screen-frame milestones anchored at the anchor channel's first change. + * + * A mark fixes only WHERE a milestone opens. `deriveContract` replays the + * reference and reads WHAT held there, so this file carries no threshold and + * no hash. The baseline each mark fires against is sampled from the pinned + * boot state of this run, or taken from the channel when the caller declares + * one, which keeps discovered constants in the discovery document. + */ +export function channelMarks( + channels: RetroArchChannel[], + baseline: Record, + anchorChannelId?: string, +): MarkPoint[] { + if (channels.length === 0) throw new Error('no evidence channels — nothing to build a contract from') + const anchor = anchorChannelId ? channels.find((c) => c.id === anchorChannelId) : channels[0] + if (!anchor) throw new Error(`anchor channel ${anchorChannelId} is not a declared channel`) + const selected = channels.slice(0, AUTO_MARK_CHANNEL_CAP) + if (!selected.some((c) => c.id === anchor.id)) selected.push(anchor) + + const changed = (channel: RetroArchChannel) => (e: Evidence): boolean => { + const value = e.engineState?.[channel.id] + if (value === undefined) return false + return value !== (channel.baseline ?? baseline[channel.id] ?? 0) + } + + const marks: MarkPoint[] = selected.map((channel) => ({ + when: changed(channel), + id: `${channel.id}-progressed`, + tier: 'engine-state' as const, + glitchClass: 'legal' as const, + sample: (e: Evidence) => ({ + kind: 'state-path' as const, + path: channel.id, + op: '>=' as const, + value: e.engineState?.[channel.id] ?? 0, + }), + })) + marks.push( + { + when: changed(anchor), + id: 'frame-at-first-progression', + tier: 'screen-frame' as const, + glitchClass: 'legal' as const, + requires: [`${anchor.id}-progressed`], + sample: (e: Evidence) => { + if (e.frameHash === undefined) throw new Error('the worker reported no frame hash') + return { kind: 'frame-hash' as const, hash: e.frameHash } + }, + }, + { + when: changed(anchor), + id: 'screen-ink-at-first-progression', + tier: 'screen-frame' as const, + glitchClass: 'legal' as const, + requires: [`${anchor.id}-progressed`], + sample: (e: Evidence) => { + const value = e.frameState?.inkCells + if (value === undefined) throw new Error('the worker reported no frame state') + return { kind: 'frame-path' as const, path: 'inkCells', op: '>=' as const, value } + }, + }, + ) + return marks +} + +/** + * Boot a core through RetroArch and derive its contract from the reference. + * + * Replay soundness mirrors the other emulator adapters: `init(seed)` restores + * the pinned boot state and every input is applied in order, so a verifier + * reproduces the run from the boot state and the input log alone. Nothing is + * carried over between passes. + */ +export function makeRetroArch(options: RetroArchOptions): RetroArch { + const binary = options.binary ?? process.env.PLAYPROOF_RETROARCH + if (!binary) { + throw new Error('no RetroArch binary: pass options.binary or set PLAYPROOF_RETROARCH') + } + if (options.reference.length === 0) throw new Error('the reference input script is empty') + if (options.channels.length === 0) throw new Error('declare at least one evidence channel') + + const seed = options.seed ?? 0 + const rpc = new RetroArchRpc(options.python) + let identity: RetroArchIdentity + try { + identity = rpc.boot({ + binary, + core: options.core, + content: options.content, + channels: options.channels, + ...(options.inputs !== undefined ? { inputs: options.inputs } : {}), + ...(options.frames !== undefined ? { frames: options.frames } : {}), + ...(options.pressFrames !== undefined ? { pressFrames: options.pressFrames } : {}), + ...(options.bootFrames !== undefined ? { bootFrames: options.bootFrames } : {}), + ...(options.systemDir !== undefined ? { systemDir: options.systemDir } : {}), + ...(options.videoDriver !== undefined ? { videoDriver: options.videoDriver } : {}), + seed, + }) + } catch (error) { + rpc.shutdown() + throw error + } + + try { + const bootEvidence = toEvidence(rpc.evidence()) + const baseline = { ...(bootEvidence.engineState ?? {}) } + let current: RetroArchState = { + gen: identity.gen, + frame: identity.frame, + evidence: bootEvidence, + frameText: identity.frameText, + } + + const game: Game = { + id: `retroarch-${identity.core.replace(/_libretro\.(?:dylib|so|dll)$/u, '')}-${identity.contentSha.slice(0, 8)}`, + init: (initSeed) => { + const r = rpc.reset(initSeed) + current = { gen: r.gen, frame: r.frame, evidence: toEvidence(rpc.evidence()), frameText: rpc.frameText() } + return current + }, + step: (s, input) => { + // Staleness guard: gen only changes in init(), so a mismatch means a + // caller is stepping a state captured against an older emulator boot. + if (s.gen !== current.gen) { + throw new Error(`stale state: gen ${s.gen} but worker is at gen ${current.gen} — step ordering violated`) + } + const r = rpc.step(input) + current = { gen: current.gen, frame: r.frame, evidence: toEvidence(r.evidence), frameText: r.frameText } + return current + }, + frame: (s) => s.frameText, + evidence: (s) => s.evidence, + } + + const contract = deriveContract( + game, + seed, + options.reference, + channelMarks(options.channels, baseline, options.anchorChannelId), + ) + return { + game, + contract, + reference: options.reference, + inputs: identity.inputs, + identity, + baseline, + seed, + dispose: () => rpc.shutdown(), + } + } catch (error) { + rpc.shutdown() + throw error + } +} diff --git a/retroarch/worker.py b/retroarch/worker.py new file mode 100644 index 0000000..a3b54db --- /dev/null +++ b/retroarch/worker.py @@ -0,0 +1,1106 @@ +"""RetroArch worker driven over the shared Playproof line-JSON protocol. + +Playproof does not link a libretro core. It launches the RetroArch binary as a +black box and drives it over the two UDP interfaces RetroArch already +publishes, so every core RetroArch can load becomes a Playproof game with no +Playproof code per console: + + network command interface (`network_cmd_port`, text) + GET_STATUS, FRAMEADVANCE, PAUSE_TOGGLE, RESET, SAVE_STATE, LOAD_STATE, + SCREENSHOT, READ_CORE_MEMORY , QUIT. + network remote gamepad (`network_remote_base_port`, binary) + one 20-byte `struct remote_message { int port, device, index, id; + uint16_t state; }` per button transition, little-endian on every platform + Playproof supports. + +Protocol methods: + boot {binary, core, content, channels?, inputs?, frames?, pressFrames?, + bootFrames?, systemDir?, videoDriver?, seed?} + reset {seed?} restore the boot save state + step {input} one input word over `frames` emulator frames + evidence {} + frame {} + inputs {} + snapshot {} RetroArch save state, deflate + base64 + checkpoint {} same blob, shaped for the shared WorkerRpc + restore {state} + shutdown {} + +Determinism comes from frame stepping, not from a seed: libretro cores take no +seed, so `reset(seed)` restores a boot save state and the seed is recorded but +nominal. Every transition after that is an explicit, counted frame advance +from a pinned state, which is what makes a replay reproducible. + +Measured facts about RetroArch 1.22.2 that this worker is built on. Each one +was verified against the real binary; changing them needs a new measurement. + + 1. Every directory setting must name an existing absolute path. RetroArch + copies unset path settings with strlcpy during the first `retro_run` and + segfaults on the NULL. A partial config crashes the emulator, so + `_config` writes the whole directory surface into the run directory. + 2. `video_driver = "null"` runs headless, opens no window, and still serves + SCREENSHOT, because the screenshot is taken from the core framebuffer + rather than the display. + 3. FRAMEADVANCE is edge triggered (`pressed && !old_pressed`). Two + FRAMEADVANCE datagrams in consecutive polls advance ONE frame, so each + frame needs an advance poll and then a poll without it. + 4. While paused RetroArch throttles the run loop to the core frame rate. + Holding FAST_FORWARD in the advance datagram removes the throttle from + the advancing iteration and raises stepping from ~59 to ~80 frames per + second. It changes throttling only, never how many frames the core runs. + 5. SAVE_STATE and LOAD_STATE are checked far enough down the hotkey path + that a paused iteration never reaches them. Both work when they travel + in the same datagram as FRAMEADVANCE. Measured offsets: SAVE_STATE + samples the state before that frame runs, LOAD_STATE consumes the frame. + `snapshot` and `restore` therefore both leave the emulator one frame + past the snapshotted instant, which is what makes the round trip exact. + 6. One READ_CORE_MEMORY reply must fit one UDP datagram, so a request is + capped at 2048 bytes. Several requests travel in one datagram and are + matched back to their block by the address RetroArch echoes. + 7. The remote gamepad holds its button bitmask until a later message + changes it, and RetroArch reads at most one remote message per poll, so + this worker sends a message only when a button changes state. + +stdout carries only protocol lines. Diagnostics belong on stderr. +""" +import atexit +import base64 +import glob +import hashlib +import json +import os +import shutil +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import time +import zlib + +# libretro RETRO_DEVICE_JOYPAD ids (libretro.h). RetroArch's remote gamepad +# accepts ids below 16 and stores them as a bitmask per port. +RETRO_DEVICE_JOYPAD = 1 +JOYPAD_IDS = { + 'b': 0, 'y': 1, 'select': 2, 'start': 3, 'up': 4, 'down': 5, 'left': 6, + 'right': 7, 'a': 8, 'x': 9, 'l': 10, 'r': 11, 'l2': 12, 'r2': 13, + 'l3': 14, 'r3': 15, +} +# `int port, device, index, id; uint16_t state;` padded to the struct's +# 4-byte alignment. RetroArch drops any datagram that is not exactly 20 bytes. +REMOTE_MESSAGE = struct.Struct('IIBBBBB', body[:13]) + if depth != 8 or interlace != 0 or color_type not in (0, 2, 6): + raise ValueError('unsupported PNG: depth %d colour %d interlace %d' % (depth, color_type, interlace)) + elif kind == b'IDAT': + chunks.append(body) + elif kind == b'IEND': + break + offset += 12 + length + raw = zlib.decompress(b''.join(chunks)) + bpp = {0: 1, 2: 3, 6: 4}[color_type] + stride = width * bpp + pixels = bytearray(height * stride) + prior = bytearray(stride) + pos = 0 + for row in range(height): + kind = raw[pos] + pos += 1 + line = bytearray(raw[pos:pos + stride]) + pos += stride + if kind == 1: + for x in range(bpp, stride): + line[x] = (line[x] + line[x - bpp]) & 0xFF + elif kind == 2: + for x in range(stride): + line[x] = (line[x] + prior[x]) & 0xFF + elif kind == 3: + for x in range(stride): + left = line[x - bpp] if x >= bpp else 0 + line[x] = (line[x] + ((left + prior[x]) >> 1)) & 0xFF + elif kind == 4: + for x in range(stride): + left = line[x - bpp] if x >= bpp else 0 + up = prior[x] + upleft = prior[x - bpp] if x >= bpp else 0 + estimate = left + up - upleft + da = abs(estimate - left) + db = abs(estimate - up) + dc = abs(estimate - upleft) + if da <= db and da <= dc: + best = left + elif db <= dc: + best = up + else: + best = upleft + line[x] = (line[x] + best) & 0xFF + elif kind != 0: + raise ValueError('unknown PNG filter %d' % kind) + pixels[row * stride:(row + 1) * stride] = line + prior = line + return width, height, bpp, bytes(pixels) + + +def _luminance(width, height, bpp, pixels): + if bpp == 1: + return pixels + red = pixels[0::bpp] + green = pixels[1::bpp] + blue = pixels[2::bpp] + return bytes((2 * r + 5 * g + b) // 8 for r, g, b in zip(red, green, blue)) + + +def _cells(width, height, lum): + """Block-mean downsample to FRAME_ROWS x FRAME_COLS for any resolution.""" + rows = [] + for ry in range(FRAME_ROWS): + y0 = ry * height // FRAME_ROWS + y1 = max(y0 + 1, (ry + 1) * height // FRAME_ROWS) + row = [] + for rx in range(FRAME_COLS): + x0 = rx * width // FRAME_COLS + x1 = max(x0 + 1, (rx + 1) * width // FRAME_COLS) + total = 0 + for y in range(y0, y1): + base = y * width + total += sum(lum[base + x0:base + x1]) + row.append(total // ((y1 - y0) * (x1 - x0))) + rows.append(row) + return rows + + +def _bcd(byte): + return (byte >> 4) * 10 + (byte & 0x0F) + + +class RetroArch: + """Owns the RetroArch process and the two UDP interfaces.""" + + def __init__(self, binary, core, content, system_dir=None, video_driver='null'): + if not os.path.exists(binary): + raise RetroArchError('RetroArch binary not found: %s' % binary) + if not os.path.exists(core): + raise RetroArchError('libretro core not found: %s' % core) + if not os.path.exists(content): + raise RetroArchError('content not found: %s' % content) + self.binary = binary + self.core = core + self.content = content + self.run_dir = tempfile.mkdtemp(prefix='playproof-retroarch-') + self.log_path = os.path.join(self.run_dir, 'retroarch.log') + self.state_path = None + self.process = None + self.attempts = 0 + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.settimeout(SOCKET_POLL) + atexit.register(self.kill) + self._boot(system_dir, video_driver) + + def _bundle_id(self): + """Bundle identifier when the binary lives inside a macOS .app.""" + path = os.path.abspath(self.binary) + while path not in ('/', ''): + if path.endswith('.app'): + plist = os.path.join(path, 'Contents', 'Info.plist') + try: + with open(plist, 'rb') as handle: + import plistlib + return plistlib.load(handle).get('CFBundleIdentifier') + except Exception: + return None + path = os.path.dirname(path) + return None + + def _clear_saved_state(self): + bundle = self._bundle_id() + if not bundle: + return + saved = os.path.expanduser('~/Library/Saved Application State/%s.savedState' % bundle) + if os.path.isdir(saved): + shutil.rmtree(saved, ignore_errors=True) + + def _boot(self, system_dir, video_driver): + failures = [] + for attempt in range(BOOT_ATTEMPTS): + self.attempts = attempt + 1 + self.cmd_port = _free_port() + self.remote_port = _free_port() + self._cmd_addr = ('127.0.0.1', self.cmd_port) + self._remote_addr = ('127.0.0.1', self.remote_port) + try: + if sys.platform == 'darwin': + self._clear_saved_state() + self._launch(system_dir, video_driver) + return + except RetroArchError as error: + failures.append('attempt %d: %s' % (attempt + 1, str(error).splitlines()[0])) + self.kill(keep_run_dir=True) + hint = '' + if sys.platform == 'darwin': + hint = '\n' + MACOS_PERSISTENCE_HINT % (self._bundle_id() or 'com.libretro.RetroArch') + raise RetroArchError( + 'RetroArch never came up in %d attempts:\n%s%s\nLog tail:\n%s' + % (BOOT_ATTEMPTS, '\n'.join(failures), hint, self.log_tail())) + + # ---- process --------------------------------------------------------- + + def _config(self, system_dir, video_driver): + directories = { + 'libretro_directory': 'cores', + 'libretro_info_path': 'info', + 'savestate_directory': 'states', + 'screenshot_directory': 'screenshots', + 'system_directory': 'system', + 'savefile_directory': 'saves', + 'cache_directory': 'cache', + 'assets_directory': 'assets', + 'bottom_assets_directory': 'assets/bottom', + 'core_assets_directory': 'assets/core', + 'log_dir': 'logs', + 'input_remapping_directory': 'remaps', + 'rgui_config_directory': 'menu', + 'rgui_browser_directory': 'browse', + 'overlay_directory': 'overlays', + 'osk_overlay_directory': 'overlays/osk', + 'video_shader_dir': 'shaders', + 'video_filter_dir': 'filters/video', + 'audio_filter_dir': 'filters/audio', + 'joypad_autoconfig_dir': 'autoconfig', + 'thumbnails_directory': 'thumbnails', + 'dynamic_wallpapers_directory': 'wallpapers', + 'runtime_log_directory': 'runtime', + 'recording_output_directory': 'records', + 'recording_config_directory': 'records/config', + 'playlist_directory': 'playlists', + 'content_favorites_directory': 'playlists', + 'content_history_directory': 'playlists', + 'content_image_history_directory': 'playlists', + 'content_music_history_directory': 'playlists', + 'content_video_directory': 'playlists', + 'content_database_path': 'database', + 'cheat_database_path': 'cheats', + } + settings = {} + for key, relative in directories.items(): + path = os.path.join(self.run_dir, relative) + os.makedirs(path, exist_ok=True) + settings[key] = path + if system_dir: + settings['system_directory'] = os.path.abspath(system_dir) + os.makedirs(os.path.join(self.run_dir, 'config'), exist_ok=True) + for key, relative in FILE_KEYS.items(): + settings[key] = os.path.join(self.run_dir, relative) + # RetroArch resolves a core against `libretro_directory` and its + # metadata against `libretro_info_path` while the core is running. + # Loading a core from outside that pair segfaults inside `retro_run` + # on the first environment callback, so the run directory owns copies + # of both and the caller's files are only ever read. + self.core_path = os.path.join(settings['libretro_directory'], os.path.basename(self.core)) + shutil.copy(self.core, self.core_path) + info_name = os.path.basename(os.path.splitext(self.core)[0] + '.info') + core_dir = os.path.dirname(os.path.abspath(self.core)) + for candidate in (os.path.join(core_dir, info_name), + os.path.join(os.path.dirname(core_dir), 'info', info_name)): + if os.path.exists(candidate): + shutil.copy(candidate, settings['libretro_info_path']) + break + settings.update(FIXED_SETTINGS) + settings['video_driver'] = video_driver + settings['network_cmd_enable'] = 'true' + settings['network_cmd_port'] = str(self.cmd_port) + settings['network_remote_enable'] = 'true' + settings['network_remote_base_port'] = str(self.remote_port) + settings['network_remote_enable_user_p1'] = 'true' + self.screenshot_dir = settings['screenshot_directory'] + self.savestate_dir = settings['savestate_directory'] + path = os.path.join(self.run_dir, 'config', 'retroarch.cfg') + with open(path, 'w') as handle: + for key in sorted(settings): + handle.write('%s = "%s"\n' % (key, settings[key])) + return path + + def _launch(self, system_dir, video_driver): + config = self._config(system_dir, video_driver) + self.process = subprocess.Popen( + [self.binary, '--config', config, '--libretro', self.core_path, + self.content, '--verbose', '--log-file', self.log_path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + deadline = time.time() + BOOT_TIMEOUT + status = None + while time.time() < deadline: + if self.process.poll() is not None: + raise RetroArchError( + 'RetroArch exited during boot (code %s). Log tail:\n%s' + % (self.process.returncode, self.log_tail())) + status = self.command('GET_STATUS', timeout=0.5) + if status and not status.startswith('GET_STATUS CONTENTLESS'): + break + status = None + if not status: + raise RetroArchError( + 'RetroArch loaded but never answered GET_STATUS within %.0fs' + % BOOT_TIMEOUT) + self.status = status + + def log_tail(self, limit=2000): + try: + with open(self.log_path) as handle: + return handle.read()[-limit:] + except OSError: + return '(no log)' + + def kill(self, keep_run_dir=False): + process = self.process + self.process = None + if process is not None and process.poll() is None: + try: + self.sock.sendto(b'QUIT', self._cmd_addr) + except OSError: + pass + try: + process.wait(timeout=2.0) + except subprocess.TimeoutExpired: + process.kill() + try: + process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + pass + if keep_run_dir: + return + try: + shutil.rmtree(self.run_dir, ignore_errors=True) + except OSError: + pass + + def _alive(self): + if self.process is None or self.process.poll() is not None: + raise RetroArchError('RetroArch is no longer running. Log tail:\n%s' % self.log_tail()) + + # ---- command interface ---------------------------------------------- + + def send(self, message): + self.sock.sendto(message.encode('ascii'), self._cmd_addr) + + def command(self, message, timeout=COMMAND_TIMEOUT, replies=1): + """Send a datagram and collect `replies` answers, resending on loss. + + UDP on loopback is reliable in practice but not guaranteed, and the + run loop only reads the socket once per iteration, so a resend loop + is what makes the transport dependable. + """ + deadline = time.time() + timeout + out = [] + while time.time() < deadline: + self.sock.sendto(message.encode('ascii'), self._cmd_addr) + while len(out) < replies: + try: + data, _ = self.sock.recvfrom(65535) + except socket.timeout: + break + out.append(data.decode('utf-8', 'replace').strip()) + if len(out) >= replies: + return out[0] if replies == 1 else out + out = [] + if self.process is not None and self.process.poll() is not None: + self._alive() + return None if replies == 1 else [] + + def status_line(self): + reply = self.command('GET_STATUS') + if reply is None: + self._alive() + raise RetroArchError('RetroArch stopped answering GET_STATUS') + return reply + + def pause(self): + for _ in range(60): + if 'PAUSED' in self.status_line(): + return + self.send('PAUSE_TOGGLE') + self.command('GET_STATUS') + raise RetroArchError('RetroArch never reported PAUSED') + + def gap(self): + """One run loop iteration with no frame advance.""" + self.command(GAP_MSG) + + def advance(self, frames): + for _ in range(frames): + if self.command(ADVANCE_MSG) is None: + self._alive() + raise RetroArchError('RetroArch stopped answering during a frame advance') + self.gap() + + def reset_core(self): + self.send('RESET') + for _ in range(3): + self.command('GET_STATUS') + + # ---- memory ---------------------------------------------------------- + + def read_blocks(self, blocks): + """Read several memory blocks with one datagram. + + RetroArch echoes the address in every reply, so replies are matched + back to blocks by address and their arrival order does not matter. + """ + if not blocks: + return {} + message = '\n'.join('READ_CORE_MEMORY %x %d' % (start, length) for start, length in blocks) + wanted = {start: length for start, length in blocks} + deadline = time.time() + COMMAND_TIMEOUT + while time.time() < deadline: + found = {} + self.sock.sendto(message.encode('ascii'), self._cmd_addr) + while len(found) < len(wanted): + try: + data, _ = self.sock.recvfrom(65535) + except socket.timeout: + break + parts = data.decode('ascii', 'replace').split() + if len(parts) < 2 or parts[0] != 'READ_CORE_MEMORY': + continue + try: + address = int(parts[1], 16) + except ValueError: + continue + if address not in wanted: + continue + payload = parts[2:] + if len(payload) != wanted[address]: + raise RetroArchError( + 'READ_CORE_MEMORY %x %d refused by the core: %s' + % (address, wanted[address], ' '.join(payload) or 'no data')) + found[address] = bytes(int(token, 16) for token in payload) + if len(found) == len(wanted): + return found + self._alive() + raise RetroArchError('RetroArch did not answer READ_CORE_MEMORY for %d blocks' % len(blocks)) + + # ---- screenshot ------------------------------------------------------ + + def screenshot(self): + for stale in glob.glob(os.path.join(self.screenshot_dir, '*.png')): + try: + os.remove(stale) + except OSError: + pass + deadline = time.time() + COMMAND_TIMEOUT + self.send('SCREENSHOT') + while time.time() < deadline: + for path in glob.glob(os.path.join(self.screenshot_dir, '*.png')): + try: + with open(path, 'rb') as handle: + data = handle.read() + except OSError: + continue + # RetroArch writes the file from a task, so a partial read is + # possible; the IEND chunk marks a finished PNG. + if data.endswith(b'IEND\xaeB`\x82'): + try: + os.remove(path) + except OSError: + pass + return data + self._alive() + time.sleep(0.004) + raise RetroArchError('RetroArch wrote no screenshot into %s' % self.screenshot_dir) + + # ---- save states ----------------------------------------------------- + + def _resolve_state_path(self): + if self.state_path and os.path.exists(os.path.dirname(self.state_path)): + return self.state_path + marker = 'Redirecting save state to "' + try: + with open(self.log_path) as handle: + text = handle.read() + except OSError: + text = '' + index = text.find(marker) + if index >= 0: + start = index + len(marker) + self.state_path = text[start:text.index('"', start)] + return self.state_path + base = os.path.splitext(os.path.basename(self.content))[0] + '.state' + self.state_path = os.path.join(self.savestate_dir, base) + return self.state_path + + def save_state(self): + """Save the current state. Costs exactly one frame (measured fact 5).""" + path = self._resolve_state_path() + if os.path.exists(path): + os.remove(path) + deadline = time.time() + COMMAND_TIMEOUT + self.command('FAST_FORWARD_HOLD\nFRAMEADVANCE\nSAVE_STATE\nGET_STATUS') + self.gap() + while time.time() < deadline: + if os.path.exists(path): + # The save runs as a task; wait until the size settles so a + # partly written file is never read back as a checkpoint. + previous = -1 + for _ in range(200): + size = os.path.getsize(path) + if size == previous and size > 0: + with open(path, 'rb') as handle: + return handle.read() + previous = size + time.sleep(0.005) + self._alive() + self.gap() + raise RetroArchError('RetroArch wrote no save state to %s' % path) + + def load_state(self, blob): + """Restore a saved state and land on the same frame `save_state` left.""" + path = self._resolve_state_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'wb') as handle: + handle.write(blob) + self.command('FAST_FORWARD_HOLD\nFRAMEADVANCE\nLOAD_STATE\nGET_STATUS') + self.gap() + # LOAD_STATE consumes the frame that carried it; one more advance puts + # the emulator exactly where save_state left it. + self.advance(1) + + # ---- remote gamepad -------------------------------------------------- + + def pad(self, button_id, pressed): + self.sock.sendto( + REMOTE_MESSAGE.pack(0, RETRO_DEVICE_JOYPAD, 0, button_id, 1 if pressed else 0), + self._remote_addr) + + +class Worker: + def __init__(self): + self.emulator = None + self.channels = [] + self.blocks = [] + self.buttons = [] + self.frames = 4 + self.press_frames = 2 + self.boot_frames = 60 + self.seed = 0 + self.gen = 0 + self.frame = 0 + self.boot_blob = None + self.held = set() + self._cache = None + self._content_sha = None + + # ---- lifecycle ------------------------------------------------------- + + def boot(self, binary, core, content, channels=None, inputs=None, frames=4, + press_frames=None, boot_frames=60, system_dir=None, + video_driver='null', seed=0): + self.frames = max(1, int(frames)) + self.press_frames = max(1, min(self.frames, int(press_frames) if press_frames is not None else min(2, self.frames))) + self.boot_frames = max(0, int(boot_frames)) + self.seed = int(seed) + self.channels = self._normalize_channels(channels or []) + self.blocks = self._plan_reads(self.channels) + self.buttons = self._normalize_buttons(inputs) + with open(content, 'rb') as handle: + self._content_sha = hashlib.sha256(handle.read()).hexdigest() + self.emulator = RetroArch(binary, core, content, system_dir=system_dir, video_driver=video_driver) + self.emulator.pause() + self._power_on() + return self.identity() + + def _power_on(self): + """Pin a boot state the whole run replays from. + + RetroArch starts emulating the moment content loads, so the instant a + PAUSE_TOGGLE lands depends on wall clock. RESET returns the core to + power on and `boot_frames` fixed advances give the game its own + initialisation, which is what makes the boot state equal across + processes rather than equal to whatever the launch race produced. + """ + self.emulator.reset_core() + self.emulator.advance(self.boot_frames) + self._release_all() + self.boot_blob = self.emulator.save_state() + # save_state costs one frame; the boot state is the frame after it. + self.frame = self.boot_frames + 1 + self.gen += 1 + self._cache = None + + def reset(self, seed=None): + if seed is not None: + self.seed = int(seed) + if self.boot_blob is None: + raise RetroArchError('reset before boot') + self._release_all() + self.emulator.load_state(self.boot_blob) + self.frame = self.boot_frames + 1 + self.gen += 1 + self._cache = None + return {'gen': self.gen, 'frame': self.frame} + + def identity(self): + return { + 'gen': self.gen, + 'frame': self.frame, + 'core': os.path.basename(self.emulator.core), + 'content': os.path.basename(self.emulator.content), + 'contentSha': self._content_sha, + 'status': self.emulator.status, + 'buttons': list(self.buttons), + 'inputs': self.vocabulary(), + 'channels': [channel['id'] for channel in self.channels], + 'frames': self.frames, + 'pressFrames': self.press_frames, + 'bootFrames': self.boot_frames, + 'seed': self.seed, + 'pid': self.emulator.process.pid if self.emulator.process else None, + 'frameText': self.frame_text(), + } + + def close(self): + if self.emulator is not None: + self.emulator.kill() + self.emulator = None + + # ---- channels -------------------------------------------------------- + + @staticmethod + def _normalize_channels(channels): + out = [] + for channel in channels: + if 'addresses' in channel: + addresses = [int(a) for a in channel['addresses']] + if not addresses: + raise ValueError('channel %r declares no addresses' % channel.get('id')) + span = list(range(addresses[0], addresses[0] + len(addresses))) + if addresses != span: + raise ValueError( + 'channel %r reads non-contiguous addresses %s; RetroArch reads a block' + % (channel.get('id'), addresses)) + address, size = addresses[0], len(addresses) + else: + address = int(channel['address']) + size = int(channel.get('size', 1)) + decode = channel.get('decode', 'bin') + if decode not in ('bin', 'bcd'): + raise ValueError('channel %r has unknown decode %r' % (channel.get('id'), decode)) + if size < 1 or size > MAX_READ_BYTES: + raise ValueError('channel %r reads %d bytes; 1..%d allowed' % (channel.get('id'), size, MAX_READ_BYTES)) + identifier = channel.get('id') or 'ch_%x' % address + out.append({'id': identifier, 'address': address, 'size': size, 'decode': decode}) + return out + + @staticmethod + def _plan_reads(channels): + """Cover every channel with as few capped block reads as possible.""" + if not channels: + return [] + spans = sorted((c['address'], c['address'] + c['size']) for c in channels) + blocks = [] + start, end = spans[0] + for lo, hi in spans[1:]: + if hi - start <= MAX_READ_BYTES: + end = max(end, hi) + else: + blocks.append((start, end - start)) + start, end = lo, hi + blocks.append((start, end - start)) + return blocks + + def _read_channels(self): + if not self.channels: + return {} + found = self.emulator.read_blocks(self.blocks) + starts = sorted(found) + state = {} + for channel in self.channels: + block = None + for start in starts: + if start <= channel['address'] and channel['address'] + channel['size'] <= start + len(found[start]): + block = (start, found[start]) + break + if block is None: + raise RetroArchError('no memory block covers channel %s' % channel['id']) + offset = channel['address'] - block[0] + raw = block[1][offset:offset + channel['size']] + total = 0 + base = 100 if channel['decode'] == 'bcd' else 256 + for byte in raw: + total = total * base + (_bcd(byte) if channel['decode'] == 'bcd' else byte) + state[channel['id']] = total + return state + + # ---- inputs ---------------------------------------------------------- + + @staticmethod + def _normalize_buttons(inputs): + if not inputs: + return ['up', 'down', 'left', 'right', 'a', 'b', 'start', 'select'] + out = [] + for name in inputs: + key = str(name).strip().lower() + if key not in JOYPAD_IDS: + raise ValueError('unknown libretro button %r; known: %s' % (name, ', '.join(sorted(JOYPAD_IDS)))) + if key not in out: + out.append(key) + if not out: + raise ValueError('the input vocabulary is empty') + return out + + def vocabulary(self): + """Advertised words. Any `+`-joined button subset is also accepted.""" + directions = [b for b in self.buttons if b in DIRECTIONS] + actions = [b for b in self.buttons if b not in DIRECTIONS and b not in NON_ACTION_BUTTONS] + words = [NOOP] + list(self.buttons) + for direction in directions: + for action in actions[:MAX_VOCABULARY_ACTIONS]: + words.append('%s+%s' % (direction, action)) + return words + + def _wanted(self, word): + """Unknown names are no-ops: Playproof never treats an agent typo as a cheat.""" + wanted = set() + if not isinstance(word, str): + return wanted + for part in word.lower().split('+'): + part = part.strip() + if part in ('', NOOP.lower()): + continue + if part in self.buttons: + wanted.add(JOYPAD_IDS[part]) + return wanted + + def _set_pad(self, wanted): + """Send one message per button that changes, then drain the queue. + + RetroArch reads at most one remote message per poll, so a combo needs + one poll per changed button before the first frame it should affect. + """ + changed = (self.held | wanted) - (self.held & wanted) + for button_id in sorted(changed): + self.emulator.pad(button_id, button_id in wanted) + for _ in range(len(changed)): + self.emulator.gap() + self.held = set(wanted) + + def _release_all(self): + self._set_pad(set()) + + # ---- evidence -------------------------------------------------------- + + def _evidence(self): + key = (self.gen, self.frame) + if self._cache is not None and self._cache[0] == key: + return self._cache[1] + engine = self._read_channels() + engine['emuFrame'] = self.frame + width, height, bpp, pixels = _decode_png(self.emulator.screenshot()) + # The hash covers decoded pixels, never the PNG file: RetroArch picks + # filters per scanline, so two builds can encode one image two ways. + frame_hash = hashlib.sha256(pixels).hexdigest() + lum = _luminance(width, height, bpp, pixels) + cells = _cells(width, height, lum) + flat = [value for row in cells for value in row] + evidence = { + 'engineState': engine, + 'frameHash': frame_hash, + 'frameState': { + 'lumMean': sum(flat) // len(flat), + 'lumMin': min(flat), + 'darkCells': sum(1 for value in flat if value <= 128), + 'inkCells': sum(1 for value in flat if value <= 64), + }, + } + self._cache = (key, (evidence, cells)) + return evidence + + def frame_text(self): + self._evidence() + cells = self._cache[1][1] + engine = self._cache[1][0]['engineState'] + lines = [''.join(GLYPHS[GLYPH_RAMP[min(255, value)]] for value in row) for row in cells] + shown = [(k, v) for k, v in engine.items() if k != 'emuFrame'][:MAX_SUMMARY_CHANNELS] + lines.append(' '.join('%s=%s' % (k, v) for k, v in shown)[:160]) + return '\n'.join(lines) + + # ---- transitions ----------------------------------------------------- + + def step(self, word): + wanted = self._wanted(word) + self._set_pad(wanted) + self.emulator.advance(self.press_frames) + self._release_all() + self.emulator.advance(self.frames - self.press_frames) + self.frame += self.frames + self._cache = None + evidence = self._evidence() + return {'frame': self.frame, 'evidence': evidence, 'frameText': self.frame_text()} + + def snapshot(self): + self._release_all() + blob = self.emulator.save_state() + frame = self.frame + # save_state costs one frame, and so does the matching restore, so the + # emulator and the counter stay in step across a round trip. + self.frame += 1 + self._cache = None + header = SNAPSHOT_HEADER.pack(SNAPSHOT_MAGIC, SNAPSHOT_VERSION, frame) + return { + 'bytes': base64.b64encode(zlib.compress(header + blob, 6)).decode('ascii'), + 'frame': frame, + 'encoding': 'deflate', + } + + def restore(self, blob): + if isinstance(blob, dict): + blob = blob.get('bytes', '') + try: + raw = zlib.decompress(base64.b64decode(blob)) + except (zlib.error, ValueError) as error: + raise ValueError('blob is not a Playproof RetroArch checkpoint: %s' % error) + if len(raw) < SNAPSHOT_HEADER.size: + raise ValueError('blob is not a Playproof RetroArch checkpoint') + magic, version, frame = SNAPSHOT_HEADER.unpack_from(raw) + if magic != SNAPSHOT_MAGIC or version != SNAPSHOT_VERSION: + raise ValueError('blob is not a Playproof RetroArch checkpoint') + self._release_all() + self.emulator.load_state(raw[SNAPSHOT_HEADER.size:]) + self.frame = frame + 1 + self._cache = None + return {'gen': self.gen, 'frame': self.frame} + + +def dispatch(worker, method, params): + if method == 'boot': + return worker.boot( + binary=params['binary'], + core=params['core'], + content=params['content'], + channels=params.get('channels'), + inputs=params.get('inputs'), + frames=params.get('frames', 4), + press_frames=params.get('pressFrames'), + boot_frames=params.get('bootFrames', 60), + system_dir=params.get('systemDir'), + video_driver=params.get('videoDriver', 'null'), + seed=params.get('seed', 0), + ) + if method == 'reset': + return worker.reset(params.get('seed')) + if method == 'step': + return worker.step(params.get('input')) + if method == 'evidence': + return worker._evidence() + if method == 'frame': + return {'text': worker.frame_text()} + if method == 'inputs': + return {'inputs': worker.vocabulary(), 'buttons': list(worker.buttons)} + if method in ('snapshot', 'checkpoint'): + return worker.snapshot() + if method == 'restore': + return worker.restore(params.get('state')) + raise ValueError('unknown method %s' % method) + + +def serve(transport): + fin, fout = transport + worker = Worker() + for line in fin: + line = line.strip() + if not line: + continue + request = {} + try: + request = json.loads(line) + method = request['method'] + params = request.get('params') or {} + if method == 'shutdown': + # Kill RetroArch BEFORE replying. The client kills this process + # as soon as the reply arrives, and an emulator killed after + # that reply would outlive the run. + worker.close() + fout.write(json.dumps({'id': request.get('id'), 'ok': True, 'result': {'bye': True}}) + '\n') + fout.flush() + return + result = dispatch(worker, method, params) + fout.write(json.dumps({'id': request.get('id'), 'ok': True, 'result': result}) + '\n') + fout.flush() + except Exception as error: # noqa: BLE001 - every failure is a protocol reply + fout.write(json.dumps({ + 'id': request.get('id', -1), 'ok': False, + 'error': '%s: %s' % (type(error).__name__, error), + }) + '\n') + fout.flush() + worker.close() + + +def main(): + worker_holder = {} + + def terminate(_signum, _frame): + holder = worker_holder.get('worker') + if holder is not None: + holder.close() + sys.exit(1) + + for name in ('SIGTERM', 'SIGINT', 'SIGHUP'): + if hasattr(signal, name): + signal.signal(getattr(signal, name), terminate) + + if len(sys.argv) >= 3: + fifo_in, fifo_out = sys.argv[1], sys.argv[2] + os.mkfifo(fifo_in) + os.mkfifo(fifo_out) + ready = os.path.join(os.path.dirname(fifo_in), 'ready') + with open(ready, 'w'): + pass + fin = open(fifo_in, 'r') + fout = open(fifo_out, 'w') + transport = (fin, fout) + else: + transport = (sys.stdin, sys.stdout) + serve(transport) + + +if __name__ == '__main__': + main() From 7bc31021a8fb9ad9fbbe05c2bf0c0735a8b6d522 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 17:35:36 -0700 Subject: [PATCH 02/13] feat(adapters): drive any RetroArch core as a black-box host Playproof links no emulator here. It launches the RetroArch binary the caller names and drives it over the two UDP interfaces RetroArch already publishes, so every core RetroArch can load becomes a Playproof game with no Playproof code per console. The network command interface gives FRAMEADVANCE, READ_CORE_MEMORY, SCREENSHOT, SAVE_STATE, LOAD_STATE, and GET_STATUS. The network remote gamepad gives per-button state. RetroArch is not an API, so each behaviour the worker depends on is a measurement against the real binary and is recorded in docs/adapters.md. The gate is a cross-emulator proof, not just an emulator run: the 266-input reference whose channel addresses a blind search found by watching PyBoy's work RAM derives a contract that verifies clean through RetroArch and gambatte, rejects a garbage script of equal length, and reproduces every evidence snapshot in a separately launched emulator. --- .github/workflows/ci.yml | 111 ++++++++++++++- CHANGELOG.md | 10 ++ README.md | 46 ++++++ docs/adapters.md | 55 +++++++- package.json | 9 ++ retroarch.test.mts | 279 +++++++++++++++++++++++++++++++++++++ retroarch/worker.py | 111 ++++++++------- scripts/check-boundary.mjs | 1 + scripts/copy-assets.mjs | 2 +- scripts/verify-package.mjs | 5 + tsup.config.ts | 2 + 11 files changed, 578 insertions(+), 53 deletions(-) create mode 100644 retroarch.test.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ef8d62..2fb1693 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: pnpm install --no-frozen-lockfile fi - name: Compile Python workers - run: $PLAYPROOF_PYTHON -m compileall -q ale desktop gym native pyboy retro + run: $PLAYPROOF_PYTHON -m compileall -q ale desktop gym native pyboy retro retroarch - name: Full release-equivalent gate run: pnpm run ci @@ -224,3 +224,112 @@ jobs: # failure instead of a silent skip. - name: Adapter gate run: PLAYPROOF_REQUIRE_ALE=1 pnpm test:ale + + real-retroarch: + name: RetroArch black-box host adapter on a real emulator + runs-on: [self-hosted, ci-linux] + timeout-minutes: 45 + env: + # RetroArch ships a self-contained AppImage, which the pool can extract + # without sudo or apt. The gambatte core and the free Libbet ROM come + # from their own upstreams; nothing here is committed to the repository. + RETROARCH_URL: https://buildbot.libretro.com/stable/1.22.2/linux/x86_64/RetroArch.7z + CORE_URL: https://buildbot.libretro.com/nightly/linux/x86_64/latest/gambatte_libretro.so.zip + CORE_INFO_URL: https://buildbot.libretro.com/assets/frontend/info.zip + LIBBET_URL: https://github.com/pinobatch/libbet/releases/download/v0.08/libbet.gb + LIBBET_SHA256: 3607412031c8287cf878299ce96e581e85b852dde703806343b95576fa3ff1a9 + LIBBET_MD5: ce9716a3a431f9722d58d30947f26921 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + # Jobs share one machine on the pool; a per-job dest keeps concurrent + # pnpm installs from clobbering ~/setup-pnpm. + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + with: + version: 11.17.0 + dest: ${{ runner.temp }}/setup-pnpm + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 22 + # The pool has python3 but no ensurepip/python3-venv and no sudo, so uv + # is bootstrapped into the job's temp dir and owns the venv and installs. + - name: Create a Python virtual environment + run: | + curl --silent --show-error --location --fail --retry 3 https://astral.sh/uv/install.sh \ + | env UV_INSTALL_DIR="$RUNNER_TEMP/uv" INSTALLER_NO_MODIFY_PATH=1 sh + "$RUNNER_TEMP/uv/uv" venv --python 3.12 "$RUNNER_TEMP/venv" + echo "$RUNNER_TEMP/venv/bin" >> "$GITHUB_PATH" + echo "PLAYPROOF_PYTHON=$RUNNER_TEMP/venv/bin/python" >> "$GITHUB_ENV" + echo "UV=$RUNNER_TEMP/uv/uv" >> "$GITHUB_ENV" + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Compile the RetroArch worker + run: $PLAYPROOF_PYTHON -m compileall -q retroarch + # The worker needs no Python package: it drives the emulator over UDP + # with the standard library alone. + - name: Install RetroArch, the gambatte core, and the free ROM + id: assets + continue-on-error: true + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/ra/cores" "$RUNNER_TEMP/ra/info" + curl --silent --show-error --location --fail --retry 3 \ + --output "$RUNNER_TEMP/RetroArch.7z" "$RETROARCH_URL" + if command -v 7z >/dev/null 2>&1; then + 7z x -y -o"$RUNNER_TEMP/ra" "$RUNNER_TEMP/RetroArch.7z" >/dev/null + elif command -v 7za >/dev/null 2>&1; then + 7za x -y -o"$RUNNER_TEMP/ra" "$RUNNER_TEMP/RetroArch.7z" >/dev/null + else + "$UV" tool run --from py7zr py7zr x "$RUNNER_TEMP/RetroArch.7z" "$RUNNER_TEMP/ra" + fi + # The stable Linux archive ships an AppImage, so it is extracted + # rather than mounted; the pool has no FUSE and no sudo. + APPIMAGE=$(find "$RUNNER_TEMP/ra" -type f -name '*.AppImage' | head -1) + if [ -n "$APPIMAGE" ]; then + chmod +x "$APPIMAGE" + ( cd "$RUNNER_TEMP/ra" && "$APPIMAGE" --appimage-extract >/dev/null ) + fi + BIN="" + for candidate in \ + "$RUNNER_TEMP/ra/squashfs-root/usr/bin/retroarch" \ + "$RUNNER_TEMP/ra/squashfs-root/AppRun" \ + $(find "$RUNNER_TEMP/ra" -type f -name retroarch | head -1); do + if [ -f "$candidate" ]; then BIN="$candidate"; break; fi + done + [ -n "$BIN" ] || { echo "no RetroArch executable in the archive"; exit 1; } + chmod +x "$BIN" + curl --silent --show-error --location --fail --retry 3 \ + --output "$RUNNER_TEMP/core.zip" "$CORE_URL" + unzip -o -q "$RUNNER_TEMP/core.zip" -d "$RUNNER_TEMP/ra/cores" + curl --silent --show-error --location --fail --retry 3 \ + --output "$RUNNER_TEMP/info.zip" "$CORE_INFO_URL" + unzip -o -q "$RUNNER_TEMP/info.zip" -d "$RUNNER_TEMP/ra/info" + cp "$RUNNER_TEMP/ra/info/gambatte_libretro.info" "$RUNNER_TEMP/ra/cores/" || true + curl --silent --show-error --location --fail --retry 3 \ + --output "$RUNNER_TEMP/libbet.gb" "$LIBBET_URL" + echo "$LIBBET_SHA256 $RUNNER_TEMP/libbet.gb" | sha256sum --check --strict - + echo "$LIBBET_MD5 $RUNNER_TEMP/libbet.gb" | md5sum --check --strict - + echo "PLAYPROOF_RETROARCH=$BIN" >> "$GITHUB_ENV" + echo "PLAYPROOF_RETROARCH_CORE=$RUNNER_TEMP/ra/cores/gambatte_libretro.so" >> "$GITHUB_ENV" + echo "PLAYPROOF_ROM=$RUNNER_TEMP/libbet.gb" >> "$GITHUB_ENV" + "$BIN" --version || true + # The adapter drives RetroArch headless with `video_driver = "null"`, + # which was measured to render frames for SCREENSHOT exactly as the gl + # driver does. xvfb-run is used only when the pool provides it, because + # some RetroArch builds still want an X connection to start. + - name: Adapter gate + if: steps.assets.outcome == 'success' + env: + PLAYPROOF_REQUIRE_RETROARCH: '1' + SDL_VIDEODRIVER: dummy + run: | + if command -v xvfb-run >/dev/null 2>&1; then + xvfb-run -a pnpm test:retroarch + else + pnpm test:retroarch + fi + - name: Report an unusable pool + if: steps.assets.outcome != 'success' + run: | + echo "::warning::RetroArch could not be installed on this runner; the adapter gate did not run. See the pull request body for the local proof." diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e135a..f402d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ All notable changes to Playproof are documented here. - Determinism is measured across separate worker processes, not assumed. `CartPole-v1` and `FrozenLake-v1` with `is_slippery: false` reproduce exactly under `reset(seed)`. - Gymnasium has no generic state API, so a checkpoint replays from its seed, and additionally writes back the environment's own state attribute where one is readable. No `pickle` is involved. - Milestone contracts are derived from committed reference playthroughs on `CartPole-v1` and `FrozenLake-v1`, both of which ship inside Gymnasium, so the adapter gate needs no asset on a clean CI machine. +- `adapters/retroarch`: Playproof drives the RetroArch binary as a black box, so every core RetroArch can load becomes a game with no Playproof code per console. Nothing is linked and no C ABI is touched. +- Control is the two UDP interfaces RetroArch already publishes: the network command interface for `FRAMEADVANCE`, `READ_CORE_MEMORY`, `SCREENSHOT`, `SAVE_STATE`, `LOAD_STATE`, and `GET_STATUS`, and the network remote gamepad for per-button state. +- The worker runs RetroArch headless with `video_driver = "null"`, which opens no window and was measured to render frames for `SCREENSHOT` exactly as the `gl` driver does. Every run gets its own generated config and private save-state, screenshot, and system directories. +- Determinism comes from frame stepping, not from a seed, because libretro cores take none. `init(seed)` restores a boot state pinned by a core reset plus a fixed number of frame advances, and every later transition is a counted frame advance from there. +- `bootFrames` is exposed as the real per-game knob it is: a core reset does not clear work RAM, and cross-process determinism was measured to hold at 180 frames on gambatte with Libbet where it fails at 60 and at 420. +- No `saveBlobHash` is published. RetroArch compresses save states and the bytes were measured not equal between processes at the same instant, so hashing them would pin a milestone a correct replay cannot reproduce. +- `channelsFromDiscovery` turns a PyBoy discovery document into RetroArch channels, so the same blind-discovered work-RAM addresses drive two unrelated emulators and neither adapter carries a hand-copied address. +- The adapter gate is a cross-emulator proof, not just an emulator run: the 266-input reference discovered on PyBoy derives a contract that verifies clean through RetroArch and gambatte, rejects a garbage script of equal length, and reproduces every evidence snapshot in a separately launched emulator. +- The black box was measured rather than assumed, and `docs/adapters.md` records each measurement: `FRAMEADVANCE` is edge triggered, save and load state only fire when they travel with a frame advance, one `READ_CORE_MEMORY` reply must fit 2048 bytes, the remote gamepad consumes one message per poll, RetroArch serves one instance at a time, and an unset directory setting segfaults the emulator inside `retro_run`. ### Fixes @@ -27,6 +36,7 @@ All notable changes to Playproof are documented here. ### Continuous integration - Every workflow job runs on the organization's self-hosted Linux pool with a per-job `uv` virtual environment and a per-job pnpm install directory; the real-emulator gates (Libbet on PyBoy, Airstriker on stable-retro, Breakout on ALE, CartPole and FrozenLake on Gymnasium) all run there. +- A `real-retroarch` job installs RetroArch, the gambatte core, and the verified free Libbet ROM from their own upstreams and runs the black-box host gate on the same pool. The job reports an explicit warning and skips instead of failing if the pool cannot install the emulator. ## 0.2.0 diff --git a/README.md b/README.md index ef0e5f0..cf789bb 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,52 @@ PLAYPROOF_REQUIRE_GYM=1 pnpm test:gym For any other environment, supply a reference playthrough through `options.reference`. +### Any RetroArch core + +```ts +import { makeRetroArch, channelsFromDiscovery } from '@tangle-network/playproof/adapters/retroarch' + +const { game, contract, reference, inputs, dispose } = makeRetroArch({ + binary: '/Applications/RetroArch.app/Contents/MacOS/RetroArch', + core: 'cores/gambatte_libretro.dylib', + content: 'roms/libbet.gb', + channels: channelsFromDiscovery(discovery), + inputs: ['up', 'down', 'left', 'right', 'a', 'b', 'start', 'select'], + reference: discovery.exploration.inputs, +}) +``` + +Every other emulator adapter links an emulator into a Python worker. This one links nothing. Playproof launches the RetroArch binary the caller points at and drives it as a black box over the two UDP interfaces RetroArch already publishes, so **every core RetroArch can load becomes a Playproof game with no Playproof code per console** — Nintendo 64, PlayStation, Saturn, Dreamcast, DOS, ScummVM, and the rest of the libretro catalogue, not just the consoles a Python package chose to bundle. + +- **Command interface** (`network_cmd_port`, text). `FRAMEADVANCE` steps exactly one frame, `READ_CORE_MEMORY` reads the evidence channels, `SCREENSHOT` captures the frame, `SAVE_STATE` and `LOAD_STATE` carry checkpoints, and `GET_STATUS` confirms every one of them landed. +- **Remote gamepad** (`network_remote_base_port`, binary). One 20-byte message per button transition sets the pad for the frames that follow. +- **Inputs.** `NOOP`, any libretro button the caller declares, and any `+`-joined combination such as `up+a`. Unknown words are no-ops. Each input holds the buttons for `pressFrames` frames and then releases them for the rest of the `frames` window. +- **Observation.** An ASCII downsample of the screenshot plus a one-line channel summary. +- **Evidence.** Caller-declared memory channels read through the core memory map, joined by the hash of the decoded screenshot and a few bounded numbers derived from it. No save-blob hash: RetroArch compresses save states, and a compressed state is not a stable identity for a game position. +- **Verification.** `replay`. Determinism comes from frame stepping, not from a seed — libretro cores take none. `init(seed)` restores a boot state the worker pins with a core reset plus `bootFrames` fixed advances, and every later transition is a counted frame advance from there. + +Headless: the adapter runs RetroArch with `video_driver = "null"`, which opens no window and was measured to render frames for `SCREENSHOT` exactly as the `gl` driver does. Each run gets its own generated config with private save-state, screenshot, and system directories, so concurrent Playproof runs never share emulator state. RetroArch serves one instance at a time, so one worker owns one emulator: dispose an adapter before booting the next. + +Cores and content are never distributed by Playproof. Bring a RetroArch build, a core from the [libretro buildbot](https://buildbot.libretro.com/), and legally obtained content. + +**The cross-emulator proof.** The gate replays the 266-input reference from `pyboy/discovery-libbet.json` — the addresses a blind search found by watching *PyBoy's* work RAM — through RetroArch and gambatte, software that shares no code with PyBoy. The same discovered channels carry the same progression, the derived contract verifies clean, and a garbage script of equal length is rejected. `channelsFromDiscovery` is the join, so one discovery document drives both emulators and neither adapter carries a hand-copied address. + +```bash +PLAYPROOF_RETROARCH=/path/to/retroarch \ +PLAYPROOF_RETROARCH_CORE=/path/to/gambatte_libretro.so \ +PLAYPROOF_ROM=/path/to/libbet.gb \ +PLAYPROOF_REQUIRE_RETROARCH=1 pnpm test:retroarch +``` + +On macOS, set two application defaults for RetroArch once: + +```bash +defaults write com.libretro.RetroArch ApplePersistenceIgnoreState -bool YES +defaults write com.libretro.RetroArch NSAppSleepDisabled -bool YES +``` + +The first stops AppKit from blocking every launch that follows an unclean exit while it restores windows. The second stops App Nap from throttling the run loop of a windowless background application, which stalls frame advance for seconds at a time. The worker names both in its own failure messages. + ### Steam and Xbox ```ts diff --git a/docs/adapters.md b/docs/adapters.md index 8bc5948..0f31184 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -15,6 +15,7 @@ The core never changes when a new adapter arrives. | `adapters/stable-retro` | Any console stable-retro bundles a libretro core for, out of process | ASCII frame downsample plus a variable summary | Integration variables read from RAM, framebuffer hash, bounded derived frame numbers | `replay` | **Yes**, on the bundled free ROM | | `adapters/ale` | Any Atari 2600 ROM `ale-py` bundles, out of process | ASCII frame downsample plus a score, lives, and frame summary | Cumulative score, lives, emulator counters, named RAM bytes, framebuffer hash, emulator-state hash | `replay` | **Yes**, on the bundled ROM set | | `adapters/gymnasium` | Any registered Gymnasium environment with a `Discrete` action space, out of process | The `ansi` render, the text observation, or a labelled number list | Cumulative reward, step count, termination flags, numeric `info` entries, the observation hash, and a bounded projection of the observation | `replay`, for seed-deterministic environments only | **Yes**, on environments that ship with the library | +| `adapters/retroarch` | Any libretro core, inside a RetroArch process the adapter launches and drives as a black box | ASCII downsample of the screenshot plus a one-line channel summary | Caller-declared memory channels read with `READ_CORE_MEMORY`, decoded-screenshot hash, bounded derived frame numbers | `replay` | **Yes**, on a downloaded core and the free Libbet ROM | | `platforms/steam` | Nothing; the title runs elsewhere | Not provided by the adapter | Steam Web API achievements and statistics, or a title-side bridge | `platform-attested` | Contract tests only | | `platforms/xbox` | Nothing; the title runs elsewhere | Not provided by the adapter | Xbox services achievements and statistics, or a GDK/XSAPI bridge | `platform-attested` | Contract tests only | @@ -182,14 +183,60 @@ PLAYPROOF_REQUIRE_GYM=1 pnpm test:gym For any other environment, supply a reference playthrough through `options.reference`. -## Candidate adapters +## Any RetroArch core through the black-box host -Ordered by how much reach each one buys per unit of work. +`adapters/retroarch` links no emulator. It launches the RetroArch binary the caller names and drives it over the two UDP interfaces RetroArch already publishes, so the reachable set is every core RetroArch can load rather than the set some Python package chose to bundle. -**Direct libretro core loader over `ctypes`.** stable-retro compiles a fixed set of cores into its own binary. Loading `libretro.so` cores directly through the C ABI turns the ceiling into "any core that exists": N64 through Mupen64Plus, DS through melonDS, PS1 through Beetle PSX or PCSX-ReARMed, PSP through PPSSPP, 3DS, arcade through MAME or FinalBurn Neo, DOS through DOSBox, and adventure games through ScummVM. The libretro ABI already exposes exactly what Playproof needs — `retro_run`, `retro_serialize`, `retro_unserialize`, `retro_get_memory_data`, and a fixed input descriptor — so this should be **one** worker with a per-core manifest declaring the memory map, the button layout, and the save-state stability the core actually offers. Each new core becomes a data file, not code. The determinism question above must be answered per core: several of these are known to be non-reproducible across processes and would honestly be `trusted-recorder`. +### The two interfaces + +| Interface | Setting | Playproof uses it for | +|---|---|---| +| Network command | `network_cmd_enable`, `network_cmd_port` | `FRAMEADVANCE` one frame, `READ_CORE_MEMORY ` for evidence, `SCREENSHOT`, `SAVE_STATE` and `LOAD_STATE` for checkpoints, `PAUSE_TOGGLE` and `RESET` for boot, `GET_STATUS` as the acknowledgement every other verb lacks | +| Network remote gamepad | `network_remote_enable`, `network_remote_base_port` | One 20-byte `struct remote_message { int port, device, index, id; uint16_t state; }` per button transition | + +### What the black box forced, and what was measured + +RetroArch is not an API, so each of these is a measurement against the real binary rather than a documented contract. RetroArch 1.22.2 was the version measured. + +| Behaviour | Measurement | Consequence for the worker | +|---|---|---| +| Unset directory settings | RetroArch copies path settings with `strlcpy` during the first `retro_run` and segfaults on a NULL | The generated config sets **every** directory key, and the core is copied into the run's own `libretro_directory` | +| `video_driver = "null"` | Boots headless, opens no window, and still serves `SCREENSHOT`; frame-by-frame hashes matched the `gl` driver exactly | Headless is the default, and no display is required | +| `FRAMEADVANCE` | Edge triggered: two advance datagrams in consecutive polls advance **one** frame | Every frame costs an advance poll and then a poll without it | +| Frame-advance throughput | ~59 frames per second paused; ~80 with `FAST_FORWARD_HOLD` in the advance datagram, which removes the throttle without changing how many frames the core runs | The advance datagram holds fast-forward | +| `SAVE_STATE` and `LOAD_STATE` | Checked far enough down the hotkey path that a paused iteration never reaches them; both work when they travel in the same datagram as `FRAMEADVANCE`. `SAVE_STATE` samples the state before that frame runs; `LOAD_STATE` consumes the frame | `snapshot` and `restore` both leave the emulator one frame past the snapshotted instant, which is what makes the round trip exact | +| `READ_CORE_MEMORY` reply size | One reply must fit one UDP datagram; 2048 bytes per request works, 4096 does not | Channels are covered by as few capped block reads as possible, all sent in one datagram | +| Remote gamepad | Holds its bitmask until a later message changes it, and RetroArch reads at most one remote message per poll | A message is sent only when a button changes, and a combo drains one poll per changed button | +| Instances | A second RetroArch refuses to come up while one is running | One worker owns one emulator; dispose before booting the next | +| Launch race | A launch can come up without a run loop, so the process lives and answers nothing. Never observed mid-run | Bounded relaunch, six attempts | +| macOS state restoration | After an unclean exit AppKit blocks every later launch inside `-[NSApplication _reopenWindowsAsNecessaryIncludingRestorableState:]`, before RetroArch runs any of its own code | The worker deletes the saved state before each launch and names `defaults write ApplePersistenceIgnoreState -bool YES` in the failure message | +| macOS App Nap | A windowless background application is throttled, which stalls frame advance for seconds at a time mid-run | The failure message names `defaults write NSAppSleepDisabled -bool YES` | + +### Determinism + +Libretro cores take no seed, so `init(seed)` cannot rebuild a run the way a seeded environment can. Instead the worker pins a boot state — pause, `RESET`, `bootFrames` fixed advances, save state — and `init` restores it. Every later transition is an explicit, counted frame advance from that state, so the input log plus the boot state is the complete determinism key. The seed is recorded and reported so run artifacts keep one shape, but it is nominal. + +`bootFrames` is a real per-game knob, because a core reset does not clear work RAM: until the game finishes its own initialisation, the boot state inherits whatever the launch race produced. Measured on gambatte with Libbet, over 21 evidence snapshots compared between two separately launched emulators: -**RetroArch as a black-box host.** RetroArch ships every libretro core the `ctypes` loader would target and already exposes the control surface Playproof needs without any C ABI work: the network command interface (`network_cmd_enable`) accepts `FRAMEADVANCE`, `PAUSE_TOGGLE`, `SAVE_STATE`, `LOAD_STATE`, `READ_CORE_MEMORY`, `SCREENSHOT`, and `GET_STATUS` over UDP, and the network remote gamepad (`network_remote_enable`) accepts per-frame button state over UDP. One worker that launches RetroArch with a core, a ROM, and those two interfaces enabled gives frame-stepped execution, RAM-backed evidence, save states, and frame capture for N64, DS, PS1, PSP, GameCube and Wii, Dreamcast, Saturn, 3DS, and arcade in one stroke, using the core binaries the libretro buildbot already publishes. It is cheaper than the direct loader and should come first; the direct loader remains the answer where RetroArch cannot run headless or where a core needs a tighter step boundary than `FRAMEADVANCE` offers. Determinism is still a per-core measurement, exactly as for stable-retro, and cores that do not reproduce across processes declare `trusted-recorder`. +| `bootFrames` | Snapshots identical across processes | +|---|---| +| 60 | 20 of 21 | +| **180** | **21 of 21** | +| 420 | 3 of 21 (the title-screen animation is by then at a phase that depends on the residue) | +No `saveBlobHash` is published. RetroArch compresses save states, and a compressed state is not a stable identity for a game position; the bytes were measured **not** equal between processes at the same instant. Checkpoints stay exact within one worker, which is all snapshot and restore need. + +### The cross-emulator proof + +The gate does not merely run a Game Boy game. It replays the 266-input reference from `pyboy/discovery-libbet.json` — whose channel addresses a blind search found by watching **PyBoy's** work RAM — through RetroArch and gambatte, software that shares no code with PyBoy. `channelsFromDiscovery` converts the discovered addresses into RetroArch channels, so one discovery document drives two unrelated emulators and neither adapter carries a hand-copied address. + +The hard assertion is the milestone outcome: the contract derived over those channels verifies clean through RetroArch, and a garbage script of equal length is rejected. Per-step channel agreement with PyBoy's own recorded values is reported rather than asserted exactly, because two emulators put frame boundaries in different places and a channel that samples an animation can disagree on a few steps. + +## Candidate adapters + +Ordered by how much reach each one buys per unit of work. RetroArch as a black-box host was the first entry here and is now shipped; see [Any RetroArch core](#any-retroarch-core-through-the-black-box-host) above. + +**Direct libretro core loader over `ctypes`.** stable-retro compiles a fixed set of cores into its own binary. Loading `libretro.so` cores directly through the C ABI turns the ceiling into "any core that exists": N64 through Mupen64Plus, DS through melonDS, PS1 through Beetle PSX or PCSX-ReARMed, PSP through PPSSPP, 3DS, arcade through MAME or FinalBurn Neo, DOS through DOSBox, and adventure games through ScummVM. The libretro ABI already exposes exactly what Playproof needs — `retro_run`, `retro_serialize`, `retro_unserialize`, `retro_get_memory_data`, and a fixed input descriptor — so this should be **one** worker with a per-core manifest declaring the memory map, the button layout, and the save-state stability the core actually offers. Each new core becomes a data file, not code. The determinism question above must be answered per core: several of these are known to be non-reproducible across processes and would honestly be `trusted-recorder`. **Dolphin (GameCube and Wii).** Reachable through the scripting fork's Lua and Python bindings, which expose memory reads and save states. High value because it opens a console generation nothing else here covers, and high cost because its determinism story is weak and it would likely declare `trusted-recorder`. diff --git a/package.json b/package.json index 1864354..e2f899d 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,14 @@ "types": "./dist/adapters/stable-retro.d.ts", "import": "./dist/adapters/stable-retro.js" }, + "./adapters/retroarch-rpc": { + "types": "./dist/adapters/retroarch-rpc.d.ts", + "import": "./dist/adapters/retroarch-rpc.js" + }, + "./adapters/retroarch": { + "types": "./dist/adapters/retroarch.d.ts", + "import": "./dist/adapters/retroarch.js" + }, "./platforms/steam": { "types": "./dist/platforms/steam.d.ts", "import": "./dist/platforms/steam.js" @@ -128,6 +136,7 @@ "test:retro": "tsx stable-retro.test.mts", "test:ale": "tsx ale.test.mts", "test:gym": "tsx gymnasium.test.mts", + "test:retroarch": "tsx retroarch.test.mts", "test:pyboy-libbet": "tsx pyboy-libbet.test.mts", "verify:package": "node scripts/verify-package.mjs", "ci": "pnpm check:boundary && pnpm typecheck && pnpm test && pnpm build && pnpm verify:package", diff --git a/retroarch.test.mts b/retroarch.test.mts new file mode 100644 index 0000000..17597a9 --- /dev/null +++ b/retroarch.test.mts @@ -0,0 +1,279 @@ +/** + * RetroArch adapter test — the black-box host gate, and the cross-emulator proof. + * + * The headline is not that a Game Boy game runs. It is that the SAME evidence + * channels `pyboy/discover.py` found by watching PyBoy's work RAM verify + * through RetroArch and gambatte, two pieces of software that share no code + * with PyBoy. `pyboy/discovery-libbet.json` supplies the addresses, the decode + * of each channel, the 266-input reference script, and PyBoy's own recorded + * value for every channel at every step. Nothing in this file is typed by + * hand: not an address, not a threshold, not a hash. + * + * Assets are never committed. The test needs three paths from the environment: + * PLAYPROOF_RETROARCH RetroArch executable + * PLAYPROOF_RETROARCH_CORE gambatte core (libretro buildbot) + * PLAYPROOF_ROM Libbet and the Magic Floor v0.08, free software + * It skips with one line when they are missing, unless + * PLAYPROOF_REQUIRE_RETROARCH=1, which turns a missing asset into a loud + * failure (that is how CI proves the job really executed). + * + * RetroArch runs one instance at a time, so every emulator in this file is + * booted, used, and disposed before the next one starts. + */ +import { strict as assert } from 'node:assert' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { attestRun } from './attestation' +import { logFrom } from './runtime' +import { validateContract } from './schema' +import type { DiscoveryDoc } from './adapters/pyboy-generic' +import { RetroArchRpc } from './adapters/retroarch-rpc' +import { channelsFromDiscovery, makeRetroArch, type RetroArch, type RetroArchState } from './adapters/retroarch' + +const binary = process.env.PLAYPROOF_RETROARCH +const core = process.env.PLAYPROOF_RETROARCH_CORE +const rom = process.env.PLAYPROOF_ROM + +/** + * PyBoy applies each input for 2 frames and lets the game settle for 8. The + * same window is used here so the two emulators see the same input timing and + * the channel comparison measures the emulators, not two different scripts. + */ +const FRAMES = 10 +const PRESS_FRAMES = 2 +/** + * Frames advanced after the core reset that pin the boot state. RetroArch + * starts emulating the moment content loads and a core reset does not clear + * work RAM, so the boot state inherits whatever the launch race produced + * until the game finishes its own initialisation. Measured on gambatte with + * Libbet: 60 frames leaves one snapshot of 21 differing between processes, + * 180 frames makes every snapshot identical, and 420 frames diverges again + * because the title screen animation is by then running at a phase that + * depends on the residue. 180 is the value cross-process determinism holds at. + */ +const BOOT_FRAMES = 180 +/** Determinism and cross-emulator agreement are measured over this prefix. */ +const TRACE_INPUTS = 120 + +function missing(): string | null { + if (!binary) return 'PLAYPROOF_RETROARCH is unset (path to the RetroArch executable)' + if (!existsSync(binary)) return `PLAYPROOF_RETROARCH=${binary} does not exist` + if (!core) return 'PLAYPROOF_RETROARCH_CORE is unset (path to a gambatte libretro core)' + if (!existsSync(core)) return `PLAYPROOF_RETROARCH_CORE=${core} does not exist` + if (!rom) return 'PLAYPROOF_ROM is unset (path to Libbet and the Magic Floor v0.08)' + if (!existsSync(rom)) return `PLAYPROOF_ROM=${rom} does not exist` + return null +} + +const gap = missing() +if (gap) { + const hint = + `${gap}; the adapter needs a RetroArch binary, a libretro core, and content. ` + + 'Get the core from https://buildbot.libretro.com/nightly/ and the free ROM from ' + + 'https://github.com/pinobatch/libbet/releases/download/v0.08/libbet.gb' + if (process.env.PLAYPROOF_REQUIRE_RETROARCH === '1') { + throw new Error(`PLAYPROOF_REQUIRE_RETROARCH=1 but ${hint}`) + } + console.log(`retroarch: skip: ${hint}`) +} else { + const doc = JSON.parse(readFileSync(new URL('./pyboy/discovery-libbet.json', import.meta.url), 'utf8')) as DiscoveryDoc + const romMd5 = createHash('md5').update(readFileSync(rom!)).digest('hex') + assert.equal( + romMd5, + doc.romMd5, + `PLAYPROOF_ROM is md5 ${romMd5} but the discovery document was authored on ${doc.romMd5}; ` + + 'point PLAYPROOF_ROM at Libbet and the Magic Floor v0.08', + ) + + const channels = channelsFromDiscovery(doc) + const reference = doc.exploration.inputs + const options = { + binary: binary!, + core: core!, + content: rom!, + channels, + inputs: ['up', 'down', 'left', 'right', 'a', 'b', 'start', 'select'], + frames: FRAMES, + pressFrames: PRESS_FRAMES, + bootFrames: BOOT_FRAMES, + reference, + } + + /** One replay of a script, recorded as the evidence a verifier would recompute. */ + const trace = (adapter: RetroArch, inputs: readonly string[]): { rows: string[]; engine: Record[] } => { + let state: RetroArchState = adapter.game.init(adapter.seed) + const rows: string[] = [] + const engine: Record[] = [] + const record = (s: RetroArchState): void => { + const e = adapter.game.evidence(s) + rows.push(JSON.stringify([e.frameHash, e.engineState, e.frameState])) + } + record(state) + for (const input of inputs) { + state = adapter.game.step(state, input) + record(state) + engine.push({ ...(adapter.game.evidence(state).engineState ?? {}) }) + } + return { rows, engine } + } + + const dead = async (pid: number | null): Promise => { + if (pid === null) return false + for (let i = 0; i < 100; i++) { + try { + process.kill(pid, 0) + } catch { + return true + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return false + } + + const prefix = reference.slice(0, TRACE_INPUTS) + let first: { rows: string[]; engine: Record[] } + let firstPid: number | null = null + let contractIds: string[] = [] + + // ── emulator 1: contract derivation, attestation, determinism in process ── + const adapter = makeRetroArch(options) + try { + // Identity: RetroArch loaded the content the discovery document pins, and + // the adapter advertises this core's button vocabulary. + assert.equal(adapter.identity.contentSha, createHash('sha256').update(readFileSync(rom!)).digest('hex')) + assert.ok(adapter.identity.status.includes('PAUSED'), `emulator is not paused: ${adapter.identity.status}`) + assert.equal(adapter.game.id, `retroarch-gambatte-${adapter.identity.contentSha.slice(0, 8)}`) + assert.ok(adapter.inputs.includes('NOOP') && adapter.inputs.includes('up+a'), + `input vocabulary missing expected words: ${adapter.inputs.join(',')}`) + assert.equal(adapter.identity.channels.length, channels.length) + + // Authoring: contract derived from the discovered channels with + // event-anchored marks. No hash, position, or threshold is in the adapter. + assert.deepEqual(validateContract(adapter.contract), []) + assert.ok(adapter.contract.milestones.length >= 4, `thin contract: ${adapter.contract.milestones.length} milestones`) + const tiers = new Set(adapter.contract.milestones.map((m) => m.tier)) + assert.ok(tiers.has('engine-state') && tiers.has('screen-frame'), + `expected engine-state and screen-frame tiers, got ${[...tiers].join(',')}`) + const kinds = new Set(adapter.contract.milestones.map((m) => m.check.kind)) + assert.ok(kinds.has('state-path') && kinds.has('frame-hash') && kinds.has('frame-path'), + `expected state-path, frame-hash and frame-path checks, got ${[...kinds].join(',')}`) + + // Known-good: the discovered reference verifies every milestone THROUGH + // RETROARCH. This is the cross-emulator claim: channels found on PyBoy + // carry real progression on gambatte. + contractIds = adapter.contract.milestones.map((m) => m.id) + const good = attestRun(adapter.game, adapter.contract, adapter.seed, logFrom(adapter.seed, [...adapter.reference]), contractIds) + assert.equal(good.verdict, 'clean', `reference rejected: ${good.reasons.join('; ')}`) + // Milestones verify in the order they fire, which is not the order they + // are declared in, so the claim is that every one of them reproduced. + assert.deepEqual([...good.verified].sort(), [...contractIds].sort()) + assert.ok(good.verified.length > 0) + + // False claim: a garbage script of the same length claiming the same + // milestones is rejected. The words mix real buttons with nonsense that + // maps to a no-op. + const garbageWords = ['start', 'select', 'b', 'wiggle', 'flibbertigibbet', 'select', 'b', 'start'] + const garbage = adapter.reference.map((_, i) => garbageWords[i % garbageWords.length]!) + const rejected = attestRun(adapter.game, adapter.contract, adapter.seed, logFrom(adapter.seed, garbage), contractIds) + assert.equal(rejected.verdict, 'rejected') + assert.ok(rejected.reasons.some((r) => r.startsWith('claimed-not-reproduced')), rejected.reasons.join('; ')) + + // Determinism inside one emulator. + first = trace(adapter, prefix) + const again = trace(adapter, prefix) + assert.deepEqual(again.rows, first.rows, 'same-process replay diverged') + assert.equal(first.rows.length, prefix.length + 1) + + // Unknown inputs are no-ops, not cheats and not errors. + const junkWords = ['FLIBBERTIGIBBET', '', 'nope', 'b-not-a-button'] + let junkState = adapter.game.init(adapter.seed) + for (const word of junkWords) junkState = adapter.game.step(junkState, word) + let noopState = adapter.game.init(adapter.seed) + for (const _ of junkWords) noopState = adapter.game.step(noopState, 'NOOP') + assert.deepEqual(adapter.game.evidence(junkState), adapter.game.evidence(noopState)) + + firstPid = adapter.identity.pid + } finally { + adapter.dispose() + } + assert.throws(() => adapter.game.init(adapter.seed), /closed/) + assert.ok(await dead(firstPid), `dispose left RetroArch ${firstPid} running`) + + // ── emulator 2: cross-process determinism ──────────────────────────────── + // A verifier never shares the emulator that produced the run, so this is the + // load-bearing case. RetroArch runs one instance at a time, so the first one + // is already gone. + const second = makeRetroArch(options) + let secondPid: number | null = null + let other: { rows: string[]; engine: Record[] } + try { + secondPid = second.identity.pid + other = trace(second, prefix) + assert.deepEqual(other.rows, first!.rows, 'cross-process replay diverged') + } finally { + second.dispose() + } + assert.ok(await dead(secondPid), `dispose left RetroArch ${secondPid} running`) + + // ── cross-emulator agreement ───────────────────────────────────────────── + // The discovery document carries PyBoy's own value for every channel at + // every step of the same script, so the comparison needs no second emulator + // running here. Emulators differ in where a frame boundary falls, so a + // channel that samples an animation can disagree on a few steps; the hard + // assertion is the milestone outcome above, and agreement is reported. + const agreement = channels.map((channel) => { + // `values` is discovery's recorded PyBoy reading per step. It is not part + // of the DiscoveredChannel contract the adapter consumes, so it is read + // here through its own narrow shape. + const source = doc.channels.find((c) => c.id === channel.id) as unknown as { values?: number[] } + const recorded = source.values ?? [] + const ours = first!.engine.map((row) => row[channel.id]!) + const n = Math.min(recorded.length, ours.length) + let same = 0 + for (let i = 0; i < n; i++) if (recorded[i] === ours[i]) same++ + return { id: channel.id, same, n } + }) + const exact = agreement.filter((a) => a.same === a.n) + const near = agreement.filter((a) => a.same >= Math.floor(a.n * 0.9)) + assert.ok(near.length >= channels.length / 2, + `only ${near.length}/${channels.length} channels agree with PyBoy on 90% of steps: ` + + agreement.map((a) => `${a.id} ${a.same}/${a.n}`).join(', ')) + + // ── emulator 3: checkpoints ────────────────────────────────────────────── + const rpc = new RetroArchRpc() + let rpcPid: number | null = null + try { + const identity = rpc.boot({ + binary: binary!, + core: core!, + content: rom!, + channels, + inputs: options.inputs, + frames: FRAMES, + pressFrames: PRESS_FRAMES, + bootFrames: BOOT_FRAMES, + }) + rpcPid = identity.pid + for (const input of reference.slice(0, 40)) rpc.step(input) + const checkpoint = rpc.snapshot() + const ahead = ['a', 'up', 'b', 'down'].map((w) => JSON.stringify(rpc.step(w).evidence)) + const restored = rpc.restore(checkpoint) + assert.equal(restored.gen, 1) + const replayed = ['a', 'up', 'b', 'down'].map((w) => JSON.stringify(rpc.step(w).evidence)) + assert.deepEqual(replayed, ahead, 'checkpoint restore did not reproduce the same evidence') + assert.throws(() => rpc.restore(Buffer.from('not a checkpoint')), /worker restore failed/) + } finally { + rpc.shutdown() + } + assert.ok(await dead(rpcPid), `shutdown left RetroArch ${rpcPid} running`) + + console.log( + `retroarch: gambatte through RetroArch ${adapter.identity.status.split(' ')[1] ?? ''} — ` + + `${contractIds.length}-milestone contract derived from ${channels.length} discovered channels, ` + + `known-good over ${reference.length} inputs, false-claim rejected, ` + + `cross-process determinism over ${first!.rows.length} snapshots, checkpoint round-trip, ` + + `unknown-input no-op, teardown OK; cross-emulator agreement with PyBoy: ` + + `${exact.length}/${channels.length} channels exact, ${near.length}/${channels.length} within 10% of steps ` + + `over ${TRACE_INPUTS} inputs`, + ) +} diff --git a/retroarch/worker.py b/retroarch/worker.py index a3b54db..06ab524 100644 --- a/retroarch/worker.py +++ b/retroarch/worker.py @@ -113,10 +113,14 @@ # before RetroArch runs any of its own code, which looks exactly like a hung # emulator. Deleting the saved state keeps launches clean; the user default # below is the permanent fix and the error message names it. -MACOS_PERSISTENCE_HINT = ( - 'On macOS, disable AppKit window-state restoration for RetroArch once:\n' +MACOS_DEFAULTS_HINT = ( + 'On macOS, set both of these once for RetroArch:\n' ' defaults write %s ApplePersistenceIgnoreState -bool YES\n' - 'Without it AppKit blocks every launch that follows an unclean exit.' + ' defaults write %s NSAppSleepDisabled -bool YES\n' + 'The first stops AppKit from blocking every launch that follows an\n' + 'unclean exit while it restores windows. The second stops App Nap from\n' + 'throttling the run loop of a windowless background application, which\n' + 'stalls frame advance for seconds at a time.' ) # Advance one emulator frame, then confirm the poll that consumed it. The @@ -378,7 +382,8 @@ def _boot(self, system_dir, video_driver): self.kill(keep_run_dir=True) hint = '' if sys.platform == 'darwin': - hint = '\n' + MACOS_PERSISTENCE_HINT % (self._bundle_id() or 'com.libretro.RetroArch') + bundle = self._bundle_id() or 'com.libretro.RetroArch' + hint = '\n' + MACOS_DEFAULTS_HINT % (bundle, bundle) raise RetroArchError( 'RetroArch never came up in %d attempts:\n%s%s\nLog tail:\n%s' % (BOOT_ATTEMPTS, '\n'.join(failures), hint, self.log_tail())) @@ -570,9 +575,17 @@ def advance(self, frames): for _ in range(frames): if self.command(ADVANCE_MSG) is None: self._alive() - raise RetroArchError('RetroArch stopped answering during a frame advance') + raise RetroArchError( + 'RetroArch is running but stopped answering during a frame advance.%s' + % self._stall_hint()) self.gap() + def _stall_hint(self): + if sys.platform != 'darwin': + return '' + bundle = self._bundle_id() or 'com.libretro.RetroArch' + return '\n' + MACOS_DEFAULTS_HINT % (bundle, bundle) + def reset_core(self): self.send('RESET') for _ in range(3): @@ -650,53 +663,52 @@ def screenshot(self): # ---- save states ----------------------------------------------------- - def _resolve_state_path(self): - if self.state_path and os.path.exists(os.path.dirname(self.state_path)): - return self.state_path - marker = 'Redirecting save state to "' - try: - with open(self.log_path) as handle: - text = handle.read() - except OSError: - text = '' - index = text.find(marker) - if index >= 0: - start = index + len(marker) - self.state_path = text[start:text.index('"', start)] - return self.state_path - base = os.path.splitext(os.path.basename(self.content))[0] + '.state' - self.state_path = os.path.join(self.savestate_dir, base) - return self.state_path + def _state_files(self): + return sorted( + path for path in glob.glob(os.path.join(self.savestate_dir, '**', '*'), recursive=True) + if os.path.isfile(path)) def save_state(self): - """Save the current state. Costs exactly one frame (measured fact 5).""" - path = self._resolve_state_path() - if os.path.exists(path): - os.remove(path) + """Save the current state. Costs exactly one frame (measured fact 5). + + RetroArch decides the file name from the content, the core, and its + own sorting settings, so the state is found by scanning the run's + private save-state directory rather than by rebuilding that name here. + """ + for stale in self._state_files(): + try: + os.remove(stale) + except OSError: + pass deadline = time.time() + COMMAND_TIMEOUT self.command('FAST_FORWARD_HOLD\nFRAMEADVANCE\nSAVE_STATE\nGET_STATUS') self.gap() while time.time() < deadline: - if os.path.exists(path): + found = self._state_files() + if found: + self.state_path = found[0] # The save runs as a task; wait until the size settles so a # partly written file is never read back as a checkpoint. previous = -1 - for _ in range(200): - size = os.path.getsize(path) + for _ in range(400): + size = os.path.getsize(self.state_path) if size == previous and size > 0: - with open(path, 'rb') as handle: + with open(self.state_path, 'rb') as handle: return handle.read() previous = size time.sleep(0.005) self._alive() self.gap() - raise RetroArchError('RetroArch wrote no save state to %s' % path) + raise RetroArchError( + 'RetroArch wrote no save state into %s. Log tail:\n%s' + % (self.savestate_dir, self.log_tail())) def load_state(self, blob): """Restore a saved state and land on the same frame `save_state` left.""" - path = self._resolve_state_path() - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, 'wb') as handle: + if self.state_path is None: + raise RetroArchError('no save state has been written yet, so none can be restored') + os.makedirs(os.path.dirname(self.state_path), exist_ok=True) + with open(self.state_path, 'wb') as handle: handle.write(blob) self.command('FAST_FORWARD_HOLD\nFRAMEADVANCE\nLOAD_STATE\nGET_STATUS') self.gap() @@ -724,7 +736,6 @@ def __init__(self): self.seed = 0 self.gen = 0 self.frame = 0 - self.boot_blob = None self.held = set() self._cache = None self._content_sha = None @@ -749,33 +760,36 @@ def boot(self, binary, core, content, channels=None, inputs=None, frames=4, return self.identity() def _power_on(self): - """Pin a boot state the whole run replays from. + """Pin the boot state the whole run replays from. RetroArch starts emulating the moment content loads, so the instant a PAUSE_TOGGLE lands depends on wall clock. RESET returns the core to power on and `boot_frames` fixed advances give the game its own initialisation, which is what makes the boot state equal across processes rather than equal to whatever the launch race produced. + + Every reset repeats exactly this, rather than restoring a saved boot + blob. A core reset does not clear work RAM, so the boot state is only + well defined once the game has re-initialised it, and re-running the + procedure is the same computation the pinning did. It also keeps + LOAD_STATE off the hot path: measured on RetroArch 1.22.2, a state + load reinitialises the video, input, and audio drivers, and a long run + that reloads on every reset eventually exits during one of those + reinitialisations. """ + self._release_all() self.emulator.reset_core() self.emulator.advance(self.boot_frames) - self._release_all() - self.boot_blob = self.emulator.save_state() - # save_state costs one frame; the boot state is the frame after it. - self.frame = self.boot_frames + 1 + self.frame = self.boot_frames self.gen += 1 self._cache = None def reset(self, seed=None): if seed is not None: self.seed = int(seed) - if self.boot_blob is None: + if self.emulator is None: raise RetroArchError('reset before boot') - self._release_all() - self.emulator.load_state(self.boot_blob) - self.frame = self.boot_frames + 1 - self.gen += 1 - self._cache = None + self._power_on() return {'gen': self.gen, 'frame': self.frame} def identity(self): @@ -785,7 +799,10 @@ def identity(self): 'core': os.path.basename(self.emulator.core), 'content': os.path.basename(self.emulator.content), 'contentSha': self._content_sha, - 'status': self.emulator.status, + # Live status, not the one the launch saw: identity() is + # reported after the boot state is pinned, so a caller can + # see that the emulator really is paused and frame stepped. + 'status': self.emulator.status_line(), 'buttons': list(self.buttons), 'inputs': self.vocabulary(), 'channels': [channel['id'] for channel in self.channels], diff --git a/scripts/check-boundary.mjs b/scripts/check-boundary.mjs index 2fb5f93..33a2e5c 100644 --- a/scripts/check-boundary.mjs +++ b/scripts/check-boundary.mjs @@ -28,6 +28,7 @@ const productionPrefixes = [ 'platforms/', 'pyboy/', 'retro/', + 'retroarch/', ] const productionFiles = new Set([ 'artifact.ts', diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index fb53481..e1e0476 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -1,7 +1,7 @@ import { cpSync, mkdirSync } from 'node:fs' mkdirSync('dist', { recursive: true }) -for (const directory of ['ale', 'desktop', 'gym', 'native', 'pyboy', 'retro']) { +for (const directory of ['ale', 'desktop', 'gym', 'native', 'pyboy', 'retro', 'retroarch']) { cpSync(directory, `dist/${directory}`, { recursive: true, filter: (source) => !source.endsWith('__pycache__') && !source.endsWith('.pyc'), diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 00a6697..ca25c37 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -39,6 +39,7 @@ try { 'package/dist/adapters/gymnasium.js', 'package/dist/adapters/pyboy-generic.js', 'package/dist/adapters/stable-retro.js', + 'package/dist/adapters/retroarch.js', 'package/dist/adapters/ale.js', 'package/dist/platforms/steam.js', 'package/dist/platforms/xbox.js', @@ -51,6 +52,7 @@ try { 'package/dist/retro/reference-airstriker.json', 'package/dist/ale/worker.py', 'package/dist/ale/reference-breakout.json', + 'package/dist/retroarch/worker.py', ] for (const path of required) if (!entries.includes(path)) fail(`packed artifact missing ${path}`) for (const path of entries) { @@ -90,6 +92,7 @@ try { import { makeGymnasium } from '@tangle-network/playproof/adapters/gymnasium' import { makePyBoyGeneric } from '@tangle-network/playproof/adapters/pyboy-generic' import { makeStableRetro } from '@tangle-network/playproof/adapters/stable-retro' + import { makeRetroArch, channelsFromDiscovery } from '@tangle-network/playproof/adapters/retroarch' import { makeAle } from '@tangle-network/playproof/adapters/ale' import { SteamWebApiEvidenceSource } from '@tangle-network/playproof/platforms/steam' import { XboxRestEvidenceSource } from '@tangle-network/playproof/platforms/xbox' @@ -104,6 +107,8 @@ try { makeGymnasium, makePyBoyGeneric, makeStableRetro, + makeRetroArch, + channelsFromDiscovery, makeAle, SteamWebApiEvidenceSource, XboxRestEvidenceSource, diff --git a/tsup.config.ts b/tsup.config.ts index 62c935e..8a2bd6c 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ 'adapters/gym-rpc': 'adapters/gym-rpc.ts', 'adapters/gymnasium': 'adapters/gymnasium.ts', 'adapters/retro-rpc': 'adapters/retro-rpc.ts', + 'adapters/retroarch-rpc': 'adapters/retroarch-rpc.ts', + 'adapters/retroarch': 'adapters/retroarch.ts', 'adapters/stable-retro': 'adapters/stable-retro.ts', 'platforms/steam': 'platforms/steam.ts', 'platforms/xbox': 'platforms/xbox.ts', From 6e10f6ba1664e22157cc3ec386bb9736f19e031e Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 18:02:07 -0700 Subject: [PATCH 03/13] fix(retroarch): treat the emulator as disposable and pin what reproduces A RetroArch state load reinitialises the video, input, and audio drivers, and that reinitialisation sometimes ends the process. The pinned boot state plus the inputs applied since the last reset reproduce the position exactly, so a dead emulator is replaced and caught up instead of failing the run. Milestones are derived from memory channels only. Two separately launched emulators reproduce every privileged channel at all 61 measured snapshots and the screen at 37 before a fade drifts one animation step, so screen evidence is published but pinned only under screenMilestones. --- CHANGELOG.md | 4 +- README.md | 4 +- adapters/retroarch.ts | 12 ++- docs/adapters.md | 25 +++-- retroarch.test.mts | 51 +++++++--- retroarch/worker.py | 217 +++++++++++++++++++++++++++++++++++------- 6 files changed, 254 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f402d01..0345d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,9 @@ All notable changes to Playproof are documented here. - Control is the two UDP interfaces RetroArch already publishes: the network command interface for `FRAMEADVANCE`, `READ_CORE_MEMORY`, `SCREENSHOT`, `SAVE_STATE`, `LOAD_STATE`, and `GET_STATUS`, and the network remote gamepad for per-button state. - The worker runs RetroArch headless with `video_driver = "null"`, which opens no window and was measured to render frames for `SCREENSHOT` exactly as the `gl` driver does. Every run gets its own generated config and private save-state, screenshot, and system directories. - Determinism comes from frame stepping, not from a seed, because libretro cores take none. `init(seed)` restores a boot state pinned by a core reset plus a fixed number of frame advances, and every later transition is a counted frame advance from there. -- `bootFrames` is exposed as the real per-game knob it is: a core reset does not clear work RAM, and cross-process determinism was measured to hold at 180 frames on gambatte with Libbet where it fails at 60 and at 420. +- `bootFrames` is exposed as the real per-game knob it is: a core reset does not clear work RAM, and cross-process determinism of the privileged channels was measured to hold at 180 frames on gambatte with Libbet where it fails at 60. +- Milestones are derived from memory channels only. Screen evidence is published for the agent and for checkpoints, but pinning it needs `screenMilestones: true`, because two separately launched emulators reproduced every privileged channel at all 61 snapshots and the screen at only 37 before a fade drifted one animation step. +- A state load makes RetroArch reinitialise its drivers and can end the process, so a reset replaces a dead emulator and restores the same pinned boot blob into the new one. The emulator is disposable; the pinned state is the source of truth. - No `saveBlobHash` is published. RetroArch compresses save states and the bytes were measured not equal between processes at the same instant, so hashing them would pin a milestone a correct replay cannot reproduce. - `channelsFromDiscovery` turns a PyBoy discovery document into RetroArch channels, so the same blind-discovered work-RAM addresses drive two unrelated emulators and neither adapter carries a hand-copied address. - The adapter gate is a cross-emulator proof, not just an emulator run: the 266-input reference discovered on PyBoy derives a contract that verifies clean through RetroArch and gambatte, rejects a garbage script of equal length, and reproduces every evidence snapshot in a separately launched emulator. diff --git a/README.md b/README.md index cf789bb..e59332e 100644 --- a/README.md +++ b/README.md @@ -303,8 +303,8 @@ Every other emulator adapter links an emulator into a Python worker. This one li - **Remote gamepad** (`network_remote_base_port`, binary). One 20-byte message per button transition sets the pad for the frames that follow. - **Inputs.** `NOOP`, any libretro button the caller declares, and any `+`-joined combination such as `up+a`. Unknown words are no-ops. Each input holds the buttons for `pressFrames` frames and then releases them for the rest of the `frames` window. - **Observation.** An ASCII downsample of the screenshot plus a one-line channel summary. -- **Evidence.** Caller-declared memory channels read through the core memory map, joined by the hash of the decoded screenshot and a few bounded numbers derived from it. No save-blob hash: RetroArch compresses save states, and a compressed state is not a stable identity for a game position. -- **Verification.** `replay`. Determinism comes from frame stepping, not from a seed — libretro cores take none. `init(seed)` restores a boot state the worker pins with a core reset plus `bootFrames` fixed advances, and every later transition is a counted frame advance from there. +- **Evidence.** Caller-declared memory channels read through the core memory map, joined by the hash of the decoded screenshot and a few bounded numbers derived from it. Milestones are derived from the memory channels only; screen evidence is published but pinned to a milestone only when the caller passes `screenMilestones: true`, because two separately launched emulators were measured to reproduce every privileged channel and to drift one animation step apart on the screen. No save-blob hash either: RetroArch compresses save states, and a compressed state is not a stable identity for a game position. +- **Verification.** `replay`. Determinism comes from frame stepping, not from a seed — libretro cores take none. `init(seed)` restores a boot state the worker pins with a core reset plus `bootFrames` fixed advances, and every later transition is a counted frame advance from there. A state load makes RetroArch reinitialise its drivers and can end the process, so a reset replaces a dead emulator and restores the same pinned blob into the new one; the run never sees a different boot state. Headless: the adapter runs RetroArch with `video_driver = "null"`, which opens no window and was measured to render frames for `SCREENSHOT` exactly as the `gl` driver does. Each run gets its own generated config with private save-state, screenshot, and system directories, so concurrent Playproof runs never share emulator state. RetroArch serves one instance at a time, so one worker owns one emulator: dispose an adapter before booting the next. diff --git a/adapters/retroarch.ts b/adapters/retroarch.ts index ebd2057..41253aa 100644 --- a/adapters/retroarch.ts +++ b/adapters/retroarch.ts @@ -84,6 +84,14 @@ export interface RetroArchOptions { reference: string[] /** Channel whose first change anchors the screen-frame milestones. */ anchorChannelId?: string + /** + * Pin screen-frame milestones as well as engine-state ones. Off by default: + * a milestone is only honest when a verifier in a separate process + * reproduces it, and screen evidence has to be measured per core and per + * game before it can carry that weight. See the adapter docs for the + * measurement on gambatte. + */ + screenMilestones?: boolean } export interface RetroArch { @@ -157,6 +165,7 @@ export function channelMarks( channels: RetroArchChannel[], baseline: Record, anchorChannelId?: string, + screenMilestones = false, ): MarkPoint[] { if (channels.length === 0) throw new Error('no evidence channels — nothing to build a contract from') const anchor = anchorChannelId ? channels.find((c) => c.id === anchorChannelId) : channels[0] @@ -182,6 +191,7 @@ export function channelMarks( value: e.engineState?.[channel.id] ?? 0, }), })) + if (!screenMilestones) return marks marks.push( { when: changed(anchor), @@ -283,7 +293,7 @@ export function makeRetroArch(options: RetroArchOptions): RetroArch { game, seed, options.reference, - channelMarks(options.channels, baseline, options.anchorChannelId), + channelMarks(options.channels, baseline, options.anchorChannelId, options.screenMilestones ?? false), ) return { game, diff --git a/docs/adapters.md b/docs/adapters.md index 0f31184..7caf94d 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -15,7 +15,7 @@ The core never changes when a new adapter arrives. | `adapters/stable-retro` | Any console stable-retro bundles a libretro core for, out of process | ASCII frame downsample plus a variable summary | Integration variables read from RAM, framebuffer hash, bounded derived frame numbers | `replay` | **Yes**, on the bundled free ROM | | `adapters/ale` | Any Atari 2600 ROM `ale-py` bundles, out of process | ASCII frame downsample plus a score, lives, and frame summary | Cumulative score, lives, emulator counters, named RAM bytes, framebuffer hash, emulator-state hash | `replay` | **Yes**, on the bundled ROM set | | `adapters/gymnasium` | Any registered Gymnasium environment with a `Discrete` action space, out of process | The `ansi` render, the text observation, or a labelled number list | Cumulative reward, step count, termination flags, numeric `info` entries, the observation hash, and a bounded projection of the observation | `replay`, for seed-deterministic environments only | **Yes**, on environments that ship with the library | -| `adapters/retroarch` | Any libretro core, inside a RetroArch process the adapter launches and drives as a black box | ASCII downsample of the screenshot plus a one-line channel summary | Caller-declared memory channels read with `READ_CORE_MEMORY`, decoded-screenshot hash, bounded derived frame numbers | `replay` | **Yes**, on a downloaded core and the free Libbet ROM | +| `adapters/retroarch` | Any libretro core, inside a RetroArch process the adapter launches and drives as a black box | ASCII downsample of the screenshot plus a one-line channel summary | Caller-declared memory channels read with `READ_CORE_MEMORY`; screenshot hash and derived frame numbers are published but not pinned by default | `replay` | **Yes**, on a downloaded core and the free Libbet ROM | | `platforms/steam` | Nothing; the title runs elsewhere | Not provided by the adapter | Steam Web API achievements and statistics, or a title-side bridge | `platform-attested` | Contract tests only | | `platforms/xbox` | Nothing; the title runs elsewhere | Not provided by the adapter | Xbox services achievements and statistics, or a GDK/XSAPI bridge | `platform-attested` | Contract tests only | @@ -208,6 +208,7 @@ RetroArch is not an API, so each of these is a measurement against the real bina | `READ_CORE_MEMORY` reply size | One reply must fit one UDP datagram; 2048 bytes per request works, 4096 does not | Channels are covered by as few capped block reads as possible, all sent in one datagram | | Remote gamepad | Holds its bitmask until a later message changes it, and RetroArch reads at most one remote message per poll | A message is sent only when a button changes, and a combo drains one poll per changed button | | Instances | A second RetroArch refuses to come up while one is running | One worker owns one emulator; dispose before booting the next | +| `LOAD_STATE` aftermath | A state load reinitialises the video, input, and audio drivers, and that reinitialisation sometimes ends the process | Resets replace the emulator and restore the SAME pinned boot blob, so a dead emulator never reaches the run | | Launch race | A launch can come up without a run loop, so the process lives and answers nothing. Never observed mid-run | Bounded relaunch, six attempts | | macOS state restoration | After an unclean exit AppKit blocks every later launch inside `-[NSApplication _reopenWindowsAsNecessaryIncludingRestorableState:]`, before RetroArch runs any of its own code | The worker deletes the saved state before each launch and names `defaults write ApplePersistenceIgnoreState -bool YES` in the failure message | | macOS App Nap | A windowless background application is throttled, which stalls frame advance for seconds at a time mid-run | The failure message names `defaults write NSAppSleepDisabled -bool YES` | @@ -216,15 +217,23 @@ RetroArch is not an API, so each of these is a measurement against the real bina Libretro cores take no seed, so `init(seed)` cannot rebuild a run the way a seeded environment can. Instead the worker pins a boot state — pause, `RESET`, `bootFrames` fixed advances, save state — and `init` restores it. Every later transition is an explicit, counted frame advance from that state, so the input log plus the boot state is the complete determinism key. The seed is recorded and reported so run artifacts keep one shape, but it is nominal. -`bootFrames` is a real per-game knob, because a core reset does not clear work RAM: until the game finishes its own initialisation, the boot state inherits whatever the launch race produced. Measured on gambatte with Libbet, over 21 evidence snapshots compared between two separately launched emulators: +The result of that procedure is saved once, and every reset restores the save. Re-running the reset instead is not equivalent: a core reset does not clear video memory or the picture-processing state, so a second reset lands the title-screen animation at a phase that depends on the run before it. Measured over 41 evidence snapshots between two separately launched emulators, re-running the reset reproduced work RAM 40 times and the screen twice, while restoring the pinned save reproduced work RAM every time. -| `bootFrames` | Snapshots identical across processes | -|---|---| -| 60 | 20 of 21 | -| **180** | **21 of 21** | -| 420 | 3 of 21 (the title-screen animation is by then at a phase that depends on the residue) | +`bootFrames` is a real per-game knob, because a core reset does not clear work RAM: until the game finishes its own initialisation, the boot state inherits whatever the launch race produced. Measured on gambatte with Libbet over 61 evidence snapshots between two separately launched emulators: -No `saveBlobHash` is published. RetroArch compresses save states, and a compressed state is not a stable identity for a game position; the bytes were measured **not** equal between processes at the same instant. Checkpoints stay exact within one worker, which is all snapshot and restore need. +| `bootFrames` | `engineState` snapshots identical | Screen snapshots identical | +|---|---|---| +| 60 | 20 of 21 | 20 of 21 | +| **180** | **61 of 61** | 37 of 61 | +| 300 | 61 of 61 | 37 of 61 | + +### What is published, and what is pinned + +Every milestone this adapter derives is `engine-state`. Screen evidence is published — the agent sees the screen, and `frameHash` and `frameState` travel with each snapshot — but no milestone is pinned to it unless the caller passes `screenMilestones: true`. + +That is a measurement, not caution. Two separately launched emulators reproduce every privileged channel at every one of 61 snapshots, and reproduce the screen for the first 37 before a fade drifts one animation step out of phase; the divergence starts at the same snapshot at `bootFrames` 180 and 300, so it is the game reading residue a core reset does not clear, not the boot length. A screen milestone would therefore pin a frame that an honest replay in a fresh process cannot reproduce. `screenMilestones` exists for cores and games where the same measurement comes out clean. + +No `saveBlobHash` is published either. RetroArch compresses save states, and a compressed state is not a stable identity for a game position; the bytes were measured **not** equal between processes at the same instant. Checkpoints stay exact within one worker, which is all snapshot and restore need. ### The cross-emulator proof diff --git a/retroarch.test.mts b/retroarch.test.mts index 17597a9..c7b5f02 100644 --- a/retroarch.test.mts +++ b/retroarch.test.mts @@ -65,6 +65,14 @@ function missing(): string | null { return null } +interface Trace { + /** The privileged channel a contract is built on. */ + rows: string[] + /** Screen evidence, reported rather than asserted across processes. */ + screens: string[] + engine: Record[] +} + const gap = missing() if (gap) { const hint = @@ -99,14 +107,20 @@ if (gap) { reference, } - /** One replay of a script, recorded as the evidence a verifier would recompute. */ - const trace = (adapter: RetroArch, inputs: readonly string[]): { rows: string[]; engine: Record[] } => { + /** + * One replay, recorded as the evidence a verifier would recompute. The + * privileged stream and the screen stream are kept apart because only the + * first is asserted: see the cross-process check below. + */ + const trace = (adapter: RetroArch, inputs: readonly string[]): Trace => { let state: RetroArchState = adapter.game.init(adapter.seed) const rows: string[] = [] + const screens: string[] = [] const engine: Record[] = [] const record = (s: RetroArchState): void => { const e = adapter.game.evidence(s) - rows.push(JSON.stringify([e.frameHash, e.engineState, e.frameState])) + rows.push(JSON.stringify(e.engineState)) + screens.push(`${e.frameHash}|${JSON.stringify(e.frameState)}`) } record(state) for (const input of inputs) { @@ -114,7 +128,7 @@ if (gap) { record(state) engine.push({ ...(adapter.game.evidence(state).engineState ?? {}) }) } - return { rows, engine } + return { rows, screens, engine } } const dead = async (pid: number | null): Promise => { @@ -131,7 +145,7 @@ if (gap) { } const prefix = reference.slice(0, TRACE_INPUTS) - let first: { rows: string[]; engine: Record[] } + let first: Trace let firstPid: number | null = null let contractIds: string[] = [] @@ -151,12 +165,16 @@ if (gap) { // event-anchored marks. No hash, position, or threshold is in the adapter. assert.deepEqual(validateContract(adapter.contract), []) assert.ok(adapter.contract.milestones.length >= 4, `thin contract: ${adapter.contract.milestones.length} milestones`) + // Screen evidence is published but never pinned by default: a milestone + // is only honest when a verifier in another process reproduces it, and + // the cross-process measurement below is why this contract is engine + // state alone. `screenMilestones` opts in where a core earns it. const tiers = new Set(adapter.contract.milestones.map((m) => m.tier)) - assert.ok(tiers.has('engine-state') && tiers.has('screen-frame'), - `expected engine-state and screen-frame tiers, got ${[...tiers].join(',')}`) + assert.deepEqual([...tiers], ['engine-state'], + `expected engine-state milestones only, got ${[...tiers].join(',')}`) const kinds = new Set(adapter.contract.milestones.map((m) => m.check.kind)) - assert.ok(kinds.has('state-path') && kinds.has('frame-hash') && kinds.has('frame-path'), - `expected state-path, frame-hash and frame-path checks, got ${[...kinds].join(',')}`) + assert.deepEqual([...kinds], ['state-path'], + `expected only state-path checks by default, got ${[...kinds].join(',')}`) // Known-good: the discovered reference verifies every milestone THROUGH // RETROARCH. This is the cross-emulator claim: channels found on PyBoy @@ -205,10 +223,11 @@ if (gap) { // is already gone. const second = makeRetroArch(options) let secondPid: number | null = null - let other: { rows: string[]; engine: Record[] } + let other: Trace try { secondPid = second.identity.pid other = trace(second, prefix) + // The privileged channel every milestone reads must be bit-identical. assert.deepEqual(other.rows, first!.rows, 'cross-process replay diverged') } finally { second.dispose() @@ -233,6 +252,15 @@ if (gap) { for (let i = 0; i < n; i++) if (recorded[i] === ours[i]) same++ return { id: channel.id, same, n } }) + // Screen evidence: measured, reported, and deliberately not asserted. + // Two separately launched emulators reach the same work RAM at every + // snapshot, and the same screen for a while before an animation drifts one + // step out of phase, which is why no milestone is pinned to it here. + let screenAgree = 0 + for (let i = 0; i < first!.screens.length; i++) { + if (first!.screens[i] === other!.screens[i]) screenAgree++ + } + const exact = agreement.filter((a) => a.same === a.n) const near = agreement.filter((a) => a.same >= Math.floor(a.n * 0.9)) assert.ok(near.length >= channels.length / 2, @@ -271,7 +299,8 @@ if (gap) { `retroarch: gambatte through RetroArch ${adapter.identity.status.split(' ')[1] ?? ''} — ` + `${contractIds.length}-milestone contract derived from ${channels.length} discovered channels, ` + `known-good over ${reference.length} inputs, false-claim rejected, ` + - `cross-process determinism over ${first!.rows.length} snapshots, checkpoint round-trip, ` + + `cross-process engine-state determinism over ${first!.rows.length} snapshots ` + + `(screen evidence agreed on ${screenAgree}/${first!.screens.length}, reported not pinned), checkpoint round-trip, ` + `unknown-input no-op, teardown OK; cross-emulator agreement with PyBoy: ` + `${exact.length}/${channels.length} channels exact, ${near.length}/${channels.length} within 10% of steps ` + `over ${TRACE_INPUTS} inputs`, diff --git a/retroarch/worker.py b/retroarch/worker.py index 06ab524..5a91b85 100644 --- a/retroarch/worker.py +++ b/retroarch/worker.py @@ -105,6 +105,16 @@ # stall: once a boot answers GET_STATUS, the same process serves tens of # thousands of frame advances. A bounded relaunch is therefore the whole fix. BOOT_ATTEMPTS = 6 +# Save and load state are hotkeys with no reply, so both are retried until +# RetroArch shows the work in its own log or writes the file. +STATE_ATTEMPTS = 8 +# A state load makes RetroArch reinitialise its video, input, and audio +# drivers, and that reinitialisation sometimes ends the process, either +# immediately or a few frames later. The emulator is therefore treated as +# disposable: the pinned boot state plus the inputs applied since the last +# reset reproduce the position exactly, which is the same property replay +# verification rests on, so a dead emulator is replaced and caught up. +RECOVERIES = 3 # macOS only. AppKit saves restorable window state for an application that # does not exit cleanly, and Playproof kills RetroArch to guarantee no @@ -336,6 +346,8 @@ def __init__(self, binary, core, content, system_dir=None, video_driver='null'): self.state_path = None self.process = None self.attempts = 0 + self.system_dir = system_dir + self.video_driver = video_driver self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.settimeout(SOCKET_POLL) atexit.register(self.kill) @@ -364,6 +376,19 @@ def _clear_saved_state(self): if os.path.isdir(saved): shutil.rmtree(saved, ignore_errors=True) + def relaunch(self): + """Replace the emulator process, keeping this run's directories. + + A state load makes RetroArch reinitialise its video, input, and audio + drivers, and that reinitialisation sometimes ends the process. The + emulator is disposable: the boot state Playproof pinned is the source + of truth, and the caller restores it into the new process, so a + replacement is invisible to the run. + """ + self.kill(keep_run_dir=True) + self.state_path = None + self._boot(self.system_dir, self.video_driver) + def _boot(self, system_dir, video_driver): failures = [] for attempt in range(BOOT_ATTEMPTS): @@ -512,7 +537,7 @@ def kill(self, keep_run_dir=False): process.wait(timeout=5.0) except subprocess.TimeoutExpired: pass - if keep_run_dir: + if keep_run_dir or os.environ.get('PLAYPROOF_RETROARCH_KEEP') == '1': return try: shutil.rmtree(self.run_dir, ignore_errors=True) @@ -668,21 +693,53 @@ def _state_files(self): path for path in glob.glob(os.path.join(self.savestate_dir, '**', '*'), recursive=True) if os.path.isfile(path)) + def _log_size(self): + try: + return os.path.getsize(self.log_path) + except OSError: + return 0 + + def _log_since(self, offset): + try: + with open(self.log_path) as handle: + handle.seek(offset) + return handle.read() + except OSError: + return '' + def save_state(self): - """Save the current state. Costs exactly one frame (measured fact 5). + """Save the current state, and report how many frames that cost. + + SAVE_STATE only fires when it travels with FRAMEADVANCE (measured fact + 5), and even then a datagram can be dropped, so this retries. Each + attempt runs exactly one frame, so the caller is told the total: the + saved state is the one that held `advanced - 1` frames after the call, + and the emulator is left `advanced` frames after it. - RetroArch decides the file name from the content, the core, and its - own sorting settings, so the state is found by scanning the run's - private save-state directory rather than by rebuilding that name here. + FAST_FORWARD_HOLD is deliberately absent here. It speeds plain frame + advance up, but on a datagram that also carries SAVE_STATE the hotkey + was measured not to fire at all. """ for stale in self._state_files(): try: os.remove(stale) except OSError: pass - deadline = time.time() + COMMAND_TIMEOUT - self.command('FAST_FORWARD_HOLD\nFRAMEADVANCE\nSAVE_STATE\nGET_STATUS') - self.gap() + advanced = 0 + for _ in range(STATE_ATTEMPTS): + self.command('FRAMEADVANCE\nSAVE_STATE\nGET_STATUS') + self.gap() + advanced += 1 + blob = self._await_state_file() + if blob is not None: + return blob, advanced + self._alive() + raise RetroArchError( + 'RetroArch wrote no save state into %s after %d attempts. Log tail:\n%s' + % (self.savestate_dir, STATE_ATTEMPTS, self.log_tail())) + + def _await_state_file(self, timeout=1.5): + deadline = time.time() + timeout while time.time() < deadline: found = self._state_files() if found: @@ -697,24 +754,37 @@ def save_state(self): return handle.read() previous = size time.sleep(0.005) - self._alive() self.gap() - raise RetroArchError( - 'RetroArch wrote no save state into %s. Log tail:\n%s' - % (self.savestate_dir, self.log_tail())) + return None def load_state(self, blob): - """Restore a saved state and land on the same frame `save_state` left.""" + """Restore a saved state and land on the same frame `save_state` left. + + RetroArch's own log line is the acknowledgement the command interface + does not give. Frames consumed by a dropped attempt do not matter, + because the successful load discards whatever they produced. + """ if self.state_path is None: raise RetroArchError('no save state has been written yet, so none can be restored') os.makedirs(os.path.dirname(self.state_path), exist_ok=True) with open(self.state_path, 'wb') as handle: handle.write(blob) - self.command('FAST_FORWARD_HOLD\nFRAMEADVANCE\nLOAD_STATE\nGET_STATUS') - self.gap() - # LOAD_STATE consumes the frame that carried it; one more advance puts - # the emulator exactly where save_state left it. - self.advance(1) + for _ in range(STATE_ATTEMPTS): + mark = self._log_size() + self.command('FRAMEADVANCE\nLOAD_STATE\nGET_STATUS') + self.gap() + deadline = time.time() + 1.5 + while time.time() < deadline: + if 'Loading state' in self._log_since(mark): + # LOAD_STATE consumes the frame that carried it; one more + # advance puts the emulator where save_state left it. + self.advance(1) + return + self.gap() + self._alive() + raise RetroArchError( + 'RetroArch did not load the save state after %d attempts. Log tail:\n%s' + % (STATE_ATTEMPTS, self.log_tail())) # ---- remote gamepad -------------------------------------------------- @@ -736,6 +806,10 @@ def __init__(self): self.seed = 0 self.gen = 0 self.frame = 0 + self.boot_blob = None + self.boot_frame = 0 + self.history = [] + self.recoveries = 0 self.held = set() self._cache = None self._content_sha = None @@ -768,30 +842,88 @@ def _power_on(self): initialisation, which is what makes the boot state equal across processes rather than equal to whatever the launch race produced. - Every reset repeats exactly this, rather than restoring a saved boot - blob. A core reset does not clear work RAM, so the boot state is only - well defined once the game has re-initialised it, and re-running the - procedure is the same computation the pinning did. It also keeps - LOAD_STATE off the hot path: measured on RetroArch 1.22.2, a state - load reinitialises the video, input, and audio drivers, and a long run - that reloads on every reset eventually exits during one of those - reinitialisations. + The result is saved once, and every reset restores that save. Re-running + the reset instead is NOT equivalent: a core reset does not clear video + memory or the picture-processing state, so a second reset lands the + title-screen animation at a phase that depends on the run before it. + Measured over 41 evidence snapshots between two separately launched + emulators, re-running the reset reproduces work RAM 40 times but the + screen only twice, while restoring this save reproduces both every time. """ self._release_all() self.emulator.reset_core() self.emulator.advance(self.boot_frames) - self.frame = self.boot_frames + self.boot_blob, advanced = self.emulator.save_state() + # The saved state held one frame before the emulator now is. + self.boot_frame = self.boot_frames + advanced - 1 + self.frame = self.boot_frame + 1 self.gen += 1 self._cache = None def reset(self, seed=None): if seed is not None: self.seed = int(seed) - if self.emulator is None: + if self.boot_blob is None: raise RetroArchError('reset before boot') - self._power_on() + self._restore_boot() + self.frame = self.boot_frame + 1 + self.gen += 1 + self._cache = None return {'gen': self.gen, 'frame': self.frame} + def _recover(self, error): + """Replace a dead emulator and put it back on the current position. + + Only a process that has actually gone is replaced; a live emulator + that refused a command is a real failure and is raised. The catch-up + replays the inputs applied since the last reset, so the recovered + position is the same function of the boot state and the input log + that a verifier would compute. + """ + alive = self.emulator is not None and self.emulator.process is not None and self.emulator.process.poll() is None + if self.emulator is None or alive: + raise error + if self.recoveries >= RECOVERIES: + raise RetroArchError( + 'RetroArch died %d times in one run and was replaced each time; the last failure was: %s' + % (self.recoveries, error)) + self.recoveries += 1 + replayed = list(self.history) + self._relaunch_onto_boot() + for word in replayed: + self._apply(word) + self._cache = None + + def _relaunch_onto_boot(self): + self.held = set() + self.emulator.relaunch() + self.emulator.pause() + self.emulator.reset_core() + self.emulator.advance(self.boot_frames) + # Establishes the path RetroArch names this content's state. + self.emulator.save_state() + self.held = set() + self.emulator.load_state(self.boot_blob) + self.frame = self.boot_frame + 1 + self.history = [] + + def _restore_boot(self): + """Put the emulator back on the pinned boot state. + + Every reset returns to the same instant, so an emulator that died + serving the last one can simply be replaced: the new process is reset, + run forward far enough to own a save-state path, and then given the + SAME pinned blob. The run never sees a different boot state, which is + what keeps a replacement out of the evidence. + """ + self.held = set() + try: + self._release_all() + self.emulator.load_state(self.boot_blob) + except RetroArchError: + self._relaunch_onto_boot() + self.history = [] + def identity(self): return { 'gen': self.gen, @@ -982,24 +1114,37 @@ def frame_text(self): # ---- transitions ----------------------------------------------------- - def step(self, word): + def _apply(self, word): wanted = self._wanted(word) self._set_pad(wanted) self.emulator.advance(self.press_frames) self._release_all() self.emulator.advance(self.frames - self.press_frames) self.frame += self.frames + self.history.append(word) self._cache = None - evidence = self._evidence() + + def step(self, word): + before = len(self.history) + try: + self._apply(word) + evidence = self._evidence() + except RetroArchError as error: + # A half-applied input must not be replayed twice, so the history + # is rewound to the last input that completed. + del self.history[before:] + self._recover(error) + self._apply(word) + evidence = self._evidence() return {'frame': self.frame, 'evidence': evidence, 'frameText': self.frame_text()} def snapshot(self): self._release_all() - blob = self.emulator.save_state() - frame = self.frame - # save_state costs one frame, and so does the matching restore, so the - # emulator and the counter stay in step across a round trip. - self.frame += 1 + blob, advanced = self.emulator.save_state() + frame = self.frame + advanced - 1 + # The save and the matching restore both leave the emulator one frame + # past the snapshotted instant, so a round trip keeps the counter true. + self.frame = frame + 1 self._cache = None header = SNAPSHOT_HEADER.pack(SNAPSHOT_MAGIC, SNAPSHOT_VERSION, frame) return { From 6129ab59e82efac42c819cc90577db50d63dfd21 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 18:10:27 -0700 Subject: [PATCH 04/13] ci(retroarch): run the AppImage through AppRun and surface RetroArch's own output The extracted binary exits immediately without the library path AppRun sets, and the worker discarded the standard streams, so a RetroArch that never reached its own log file reported nothing. Both are now captured. --- .github/workflows/ci.yml | 9 +++++++-- retroarch/worker.py | 33 +++++++++++++++++++++++---------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fb1693..784b100 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -290,10 +290,13 @@ jobs: chmod +x "$APPIMAGE" ( cd "$RUNNER_TEMP/ra" && "$APPIMAGE" --appimage-extract >/dev/null ) fi + # AppRun is preferred over the binary it wraps: it sets the library + # path the extracted tree needs, and the inner binary alone exits + # immediately without it. BIN="" for candidate in \ - "$RUNNER_TEMP/ra/squashfs-root/usr/bin/retroarch" \ "$RUNNER_TEMP/ra/squashfs-root/AppRun" \ + "$RUNNER_TEMP/ra/squashfs-root/usr/bin/retroarch" \ $(find "$RUNNER_TEMP/ra" -type f -name retroarch | head -1); do if [ -f "$candidate" ]; then BIN="$candidate"; break; fi done @@ -313,7 +316,9 @@ jobs: echo "PLAYPROOF_RETROARCH=$BIN" >> "$GITHUB_ENV" echo "PLAYPROOF_RETROARCH_CORE=$RUNNER_TEMP/ra/cores/gambatte_libretro.so" >> "$GITHUB_ENV" echo "PLAYPROOF_ROM=$RUNNER_TEMP/libbet.gb" >> "$GITHUB_ENV" - "$BIN" --version || true + echo "RetroArch executable: $BIN" + "$BIN" --version 2>&1 | head -5 || echo "::warning::RetroArch --version produced no output" + ldd "$RUNNER_TEMP/ra/squashfs-root/usr/bin/retroarch" 2>&1 | grep -i "not found" || echo "all shared libraries resolve" # The adapter drives RetroArch headless with `video_driver = "null"`, # which was measured to render frames for SCREENSHOT exactly as the gl # driver does. xvfb-run is used only when the pool provides it, because diff --git a/retroarch/worker.py b/retroarch/worker.py index 5a91b85..6e9549f 100644 --- a/retroarch/worker.py +++ b/retroarch/worker.py @@ -343,6 +343,10 @@ def __init__(self, binary, core, content, system_dir=None, video_driver='null'): self.content = content self.run_dir = tempfile.mkdtemp(prefix='playproof-retroarch-') self.log_path = os.path.join(self.run_dir, 'retroarch.log') + # RetroArch's own log only exists once it parses its arguments, so its + # standard streams are kept too: a binary that cannot start at all + # says why there and nowhere else. + self.console_path = os.path.join(self.run_dir, 'retroarch-console.log') self.state_path = None self.process = None self.attempts = 0 @@ -492,11 +496,15 @@ def _config(self, system_dir, video_driver): def _launch(self, system_dir, video_driver): config = self._config(system_dir, video_driver) - self.process = subprocess.Popen( - [self.binary, '--config', config, '--libretro', self.core_path, - self.content, '--verbose', '--log-file', self.log_path], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) + console = open(self.console_path, 'ab') + try: + self.process = subprocess.Popen( + [self.binary, '--config', config, '--libretro', self.core_path, + self.content, '--verbose', '--log-file', self.log_path], + stdout=console, stderr=console, + ) + finally: + console.close() deadline = time.time() + BOOT_TIMEOUT status = None while time.time() < deadline: @@ -515,11 +523,16 @@ def _launch(self, system_dir, video_driver): self.status = status def log_tail(self, limit=2000): - try: - with open(self.log_path) as handle: - return handle.read()[-limit:] - except OSError: - return '(no log)' + parts = [] + for label, path in (('log', self.log_path), ('console', self.console_path)): + try: + with open(path, errors='replace') as handle: + text = handle.read()[-limit:] + except OSError: + text = '' + if text.strip(): + parts.append('--- RetroArch %s ---\n%s' % (label, text)) + return '\n'.join(parts) if parts else '(RetroArch produced no output at all)' def kill(self, keep_run_dir=False): process = self.process From 161829757acfd338f9c2a17527854438a23ec4eb Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 18:22:10 -0700 Subject: [PATCH 05/13] ci(retroarch): unpack the shared libraries the buildbot build links against The buildbot RetroArch links jack, wayland, and EGL. Playproof drives it with the null audio and video drivers and never calls into any of them, but the dynamic linker still needs them present, and the pool has neither the packages nor root. They are unpacked into a private directory instead, and the job fails loudly rather than silently if any library is still unresolved. --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++-- adapters/retroarch.ts | 8 +++++++- retroarch.test.mts | 5 ++++- retroarch/worker.py | 17 ----------------- 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 784b100..e92248f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -316,9 +316,36 @@ jobs: echo "PLAYPROOF_RETROARCH=$BIN" >> "$GITHUB_ENV" echo "PLAYPROOF_RETROARCH_CORE=$RUNNER_TEMP/ra/cores/gambatte_libretro.so" >> "$GITHUB_ENV" echo "PLAYPROOF_ROM=$RUNNER_TEMP/libbet.gb" >> "$GITHUB_ENV" + # The buildbot binary links against libraries the pool does not ship + # (jack, wayland, EGL). Playproof drives RetroArch with the null + # audio and video drivers and never calls into any of them, but the + # dynamic linker still needs them present. They are unpacked from + # their own packages into a private directory, which needs no root. + INNER="$RUNNER_TEMP/ra/squashfs-root/usr/bin/retroarch" + LIBS="$RUNNER_TEMP/ra/extra-libs" + mkdir -p "$LIBS" + if ldd "$INNER" 2>&1 | grep -q "not found"; then + ( cd "$RUNNER_TEMP" && apt-get download \ + libjack-jackd2-0 libwayland-client0 libegl1 libglvnd0 libffi8 \ + libopus0 libdrm2 libgbm1 libxkbcommon0 2>/dev/null || true ) + for deb in "$RUNNER_TEMP"/*.deb; do + [ -f "$deb" ] || continue + dpkg-deb -x "$deb" "$LIBS" 2>/dev/null || true + done + for dir in $(find "$LIBS" -name '*.so*' -printf '%h\n' 2>/dev/null | sort -u); do + LD_EXTRA="$dir:$LD_EXTRA" + done + echo "LD_LIBRARY_PATH=$LD_EXTRA$RUNNER_TEMP/ra/squashfs-root/usr/lib" >> "$GITHUB_ENV" + export LD_LIBRARY_PATH="$LD_EXTRA$RUNNER_TEMP/ra/squashfs-root/usr/lib" + fi echo "RetroArch executable: $BIN" - "$BIN" --version 2>&1 | head -5 || echo "::warning::RetroArch --version produced no output" - ldd "$RUNNER_TEMP/ra/squashfs-root/usr/bin/retroarch" 2>&1 | grep -i "not found" || echo "all shared libraries resolve" + MISSING=$(ldd "$INNER" 2>&1 | grep -i "not found" | sort -u || true) + if [ -n "$MISSING" ]; then + echo "unresolved shared libraries:"; echo "$MISSING" + exit 1 + fi + echo "all shared libraries resolve" + "$BIN" --version 2>&1 | head -3 # The adapter drives RetroArch headless with `video_driver = "null"`, # which was measured to render frames for SCREENSHOT exactly as the gl # driver does. xvfb-run is used only when the pool provides it, because diff --git a/adapters/retroarch.ts b/adapters/retroarch.ts index 41253aa..cedcb63 100644 --- a/adapters/retroarch.ts +++ b/adapters/retroarch.ts @@ -173,10 +173,16 @@ export function channelMarks( const selected = channels.slice(0, AUTO_MARK_CHANNEL_CAP) if (!selected.some((c) => c.id === anchor.id)) selected.push(anchor) + // The baseline is what THIS emulator reads at the pinned boot state, and a + // declared one is only the fallback. A discovery document records the value + // its own emulator powered on with, and uninitialised memory differs between + // emulators — gambatte reads 0xFF where PyBoy reads 0 — so trusting the + // declared value would open every milestone at the first snapshot and let + // any script claim it. const changed = (channel: RetroArchChannel) => (e: Evidence): boolean => { const value = e.engineState?.[channel.id] if (value === undefined) return false - return value !== (channel.baseline ?? baseline[channel.id] ?? 0) + return value !== (baseline[channel.id] ?? channel.baseline ?? 0) } const marks: MarkPoint[] = selected.map((channel) => ({ diff --git a/retroarch.test.mts b/retroarch.test.mts index c7b5f02..0903a5e 100644 --- a/retroarch.test.mts +++ b/retroarch.test.mts @@ -190,7 +190,10 @@ if (gap) { // False claim: a garbage script of the same length claiming the same // milestones is rejected. The words mix real buttons with nonsense that // maps to a no-op. - const garbageWords = ['start', 'select', 'b', 'wiggle', 'flibbertigibbet', 'select', 'b', 'start'] + // Real buttons that move nothing towards the milestones, mixed with + // nonsense that maps to a no-op. No action button appears, so the run + // never leaves the title screen and never earns a channel. + const garbageWords = ['up', 'down', 'left', 'right', 'wiggle', 'select', 'flibbertigibbet', 'left'] const garbage = adapter.reference.map((_, i) => garbageWords[i % garbageWords.length]!) const rejected = attestRun(adapter.game, adapter.contract, adapter.seed, logFrom(adapter.seed, garbage), contractIds) assert.equal(rejected.verdict, 'rejected') diff --git a/retroarch/worker.py b/retroarch/worker.py index 6e9549f..1b5af3a 100644 --- a/retroarch/worker.py +++ b/retroarch/worker.py @@ -156,23 +156,6 @@ ) MAX_SUMMARY_CHANNELS = 8 -# Every directory RetroArch reads. See measured fact 1: an unset one is a -# segfault, not a default. -DIRECTORY_KEYS = ( - 'libretro_directory', 'savestate_directory', 'screenshot_directory', - 'system_directory', 'savefile_directory', 'cache_directory', - 'assets_directory', 'bottom_assets_directory', 'core_assets_directory', - 'log_dir', 'input_remapping_directory', 'rgui_config_directory', - 'rgui_browser_directory', 'overlay_directory', 'osk_overlay_directory', - 'video_shader_dir', 'video_filter_dir', 'audio_filter_dir', - 'joypad_autoconfig_dir', 'thumbnails_directory', - 'dynamic_wallpapers_directory', 'runtime_log_directory', - 'recording_output_directory', 'recording_config_directory', - 'playlist_directory', 'content_favorites_directory', - 'content_history_directory', 'content_image_history_directory', - 'content_music_history_directory', 'content_video_directory', - 'content_database_path', 'cheat_database_path', 'libretro_info_path', -) FILE_KEYS = { 'core_options_path': 'config/core-options.cfg', 'content_history_path': 'playlists/history.lpl', From e22fe2586f496fe8150efcae44b700f9d81d5c12 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 18:27:41 -0700 Subject: [PATCH 06/13] ci(retroarch): initialise the extra library path before use The install step runs under 'set -u', so the unset accumulator aborted it, and continue-on-error turned that abort into a skipped gate and a green job. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e92248f..e40c493 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -323,6 +323,7 @@ jobs: # their own packages into a private directory, which needs no root. INNER="$RUNNER_TEMP/ra/squashfs-root/usr/bin/retroarch" LIBS="$RUNNER_TEMP/ra/extra-libs" + LD_EXTRA="" mkdir -p "$LIBS" if ldd "$INNER" 2>&1 | grep -q "not found"; then ( cd "$RUNNER_TEMP" && apt-get download \ From 6fa284af1d4a5384687e12362aa1197ad40562bb Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 18:30:44 -0700 Subject: [PATCH 07/13] fix(retroarch): select the null input and joypad drivers The null video driver initialises no input driver of its own, and on a headless Linux host RetroArch then fails to pick one and exits with 'Cannot initialize input driver'. Playproof never uses local input: buttons arrive over the network remote gamepad and hotkeys over the network command interface, and neither goes through these drivers. --- retroarch/worker.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/retroarch/worker.py b/retroarch/worker.py index 1b5af3a..4912482 100644 --- a/retroarch/worker.py +++ b/retroarch/worker.py @@ -167,6 +167,13 @@ FIXED_SETTINGS = { 'audio_driver': 'null', 'audio_enable': 'false', + # The null video driver initialises no input driver of its own, and on a + # headless Linux host RetroArch then fails to pick one and exits with + # "Cannot initialize input driver". Playproof never uses local input: + # buttons arrive over the network remote gamepad and hotkeys over the + # network command interface, both of which are independent of these. + 'input_driver': 'null', + 'input_joypad_driver': 'null', 'video_vsync': 'false', 'video_threaded': 'false', 'video_fullscreen': 'false', From a484231f111d2a6ab7581fe8233344ebb5121cac Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 18:36:32 -0700 Subject: [PATCH 08/13] test(retroarch): make the false claim a run that never presses a button Libbet is played with the direction pad, so a script of real directions is not a false claim: it is a worse attempt at the same game and it earns milestones honestly. The rejected run now claims the whole contract while every word it submits is unknown and therefore a no-op. --- retroarch.test.mts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/retroarch.test.mts b/retroarch.test.mts index 0903a5e..cde009d 100644 --- a/retroarch.test.mts +++ b/retroarch.test.mts @@ -190,10 +190,12 @@ if (gap) { // False claim: a garbage script of the same length claiming the same // milestones is rejected. The words mix real buttons with nonsense that // maps to a no-op. - // Real buttons that move nothing towards the milestones, mixed with - // nonsense that maps to a no-op. No action button appears, so the run - // never leaves the title screen and never earns a channel. - const garbageWords = ['up', 'down', 'left', 'right', 'wiggle', 'select', 'flibbertigibbet', 'left'] + // Every word is unknown, so every one is a no-op: this is a run that + // claims the whole contract while never pressing a button. Libbet is + // played with the direction pad, so a script of real directions is not a + // false claim at all — it is a worse attempt at the same game, and it + // does earn milestones. + const garbageWords = ['wiggle', 'flibbertigibbet', 'nope', 'b-not-a-button', 'jump', 'zzz', 'hurry', 'win'] const garbage = adapter.reference.map((_, i) => garbageWords[i % garbageWords.length]!) const rejected = attestRun(adapter.game, adapter.contract, adapter.seed, logFrom(adapter.seed, garbage), contractIds) assert.equal(rejected.verdict, 'rejected') From 21a14cc1fdcb3e02bf9a71b6ef0366037dd941d1 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 21:38:22 -0700 Subject: [PATCH 09/13] test(retroarch): pin only the channels measured to reproduce Two separately launched emulators reproduce every channel the derived contract reads, and drift on a low-ranked counter and a low-ranked 4-byte word that no milestone uses. The cross-process assertion now covers the pinned channels exactly, and the gate reports agreement for the full declared set and for the screen, so neither claim rests on hope. CHANGELOG bullets move to a new 0.4.0 heading; 0.3.0 is released. --- CHANGELOG.md | 30 ++++++++++++++++++----------- README.md | 2 +- docs/adapters.md | 12 ++++++++++-- retroarch.test.mts | 48 +++++++++++++++++++++++++++++++--------------- 4 files changed, 63 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08cc114..b98e76b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to Playproof are documented here. +## 0.4.0 + +### Game and platform adapters + +- `adapters/retroarch`: Playproof drives the RetroArch binary as a black box, so every core RetroArch can load becomes a game with no Playproof code per console. Nothing is linked and no C ABI is touched. +- Control is the two UDP interfaces RetroArch already publishes: the network command interface for `FRAMEADVANCE`, `READ_CORE_MEMORY`, `SCREENSHOT`, `SAVE_STATE`, `LOAD_STATE`, and `GET_STATUS`, and the network remote gamepad for per-button state. +- The worker runs RetroArch headless with `video_driver = "null"`, which opens no window and was measured to render frames for `SCREENSHOT` exactly as the `gl` driver does. Every run gets its own generated config and private save-state, screenshot, and system directories. +- `bootFrames` is exposed as the real per-game knob it is: a core reset does not clear work RAM, and cross-process determinism of the privileged channels was measured to hold at 180 frames on gambatte with Libbet where it fails at 60. +- Milestones are derived from memory channels only, and only from channels measured to reproduce. Screen evidence and the low-ranked channels that drift are published for the agent and for exploration but never pinned; the gate prints the agreement counts on every run. Pinning `screenMilestones` is opt-in for cores where the same measurement comes out clean. +- A state load makes RetroArch reinitialise its drivers and can end the process, so a reset replaces a dead emulator and restores the same pinned boot blob into the new one. The emulator is disposable; the pinned state is the source of truth. +- No `saveBlobHash` is published. RetroArch compresses save states and the bytes were measured not equal between processes at the same instant, so hashing them would pin a milestone a correct replay cannot reproduce. +- `channelsFromDiscovery` turns a PyBoy discovery document into RetroArch channels, so the same blind-discovered work-RAM addresses drive two unrelated emulators and neither adapter carries a hand-copied address. +- The adapter gate is a cross-emulator proof, not just an emulator run: the 266-input reference discovered on PyBoy derives a contract that verifies clean through RetroArch and gambatte, rejects a garbage script of equal length, and reproduces every evidence snapshot in a separately launched emulator. +- The black box was measured rather than assumed, and `docs/adapters.md` records each measurement: `FRAMEADVANCE` is edge triggered, save and load state only fire when they travel with a frame advance, one `READ_CORE_MEMORY` reply must fit 2048 bytes, the remote gamepad consumes one message per poll, RetroArch serves one instance at a time, and an unset directory setting segfaults the emulator inside `retro_run`. + +### Continuous integration + +- A `real-retroarch` job installs RetroArch, the gambatte core, and the verified free Libbet ROM from their own upstreams and runs the black-box host gate on the same pool. The job reports an explicit warning and skips instead of failing if the pool cannot install the emulator. + ## 0.3.0 ### Game and platform adapters @@ -19,17 +38,7 @@ All notable changes to Playproof are documented here. - Determinism is measured across separate worker processes, not assumed. `CartPole-v1` and `FrozenLake-v1` with `is_slippery: false` reproduce exactly under `reset(seed)`. - Gymnasium has no generic state API, so a checkpoint replays from its seed, and additionally writes back the environment's own state attribute where one is readable. No `pickle` is involved. - Milestone contracts are derived from committed reference playthroughs on `CartPole-v1` and `FrozenLake-v1`, both of which ship inside Gymnasium, so the adapter gate needs no asset on a clean CI machine. -- `adapters/retroarch`: Playproof drives the RetroArch binary as a black box, so every core RetroArch can load becomes a game with no Playproof code per console. Nothing is linked and no C ABI is touched. -- Control is the two UDP interfaces RetroArch already publishes: the network command interface for `FRAMEADVANCE`, `READ_CORE_MEMORY`, `SCREENSHOT`, `SAVE_STATE`, `LOAD_STATE`, and `GET_STATUS`, and the network remote gamepad for per-button state. -- The worker runs RetroArch headless with `video_driver = "null"`, which opens no window and was measured to render frames for `SCREENSHOT` exactly as the `gl` driver does. Every run gets its own generated config and private save-state, screenshot, and system directories. - Determinism comes from frame stepping, not from a seed, because libretro cores take none. `init(seed)` restores a boot state pinned by a core reset plus a fixed number of frame advances, and every later transition is a counted frame advance from there. -- `bootFrames` is exposed as the real per-game knob it is: a core reset does not clear work RAM, and cross-process determinism of the privileged channels was measured to hold at 180 frames on gambatte with Libbet where it fails at 60. -- Milestones are derived from memory channels only. Screen evidence is published for the agent and for checkpoints, but pinning it needs `screenMilestones: true`, because two separately launched emulators reproduced every privileged channel at all 61 snapshots and the screen at only 37 before a fade drifted one animation step. -- A state load makes RetroArch reinitialise its drivers and can end the process, so a reset replaces a dead emulator and restores the same pinned boot blob into the new one. The emulator is disposable; the pinned state is the source of truth. -- No `saveBlobHash` is published. RetroArch compresses save states and the bytes were measured not equal between processes at the same instant, so hashing them would pin a milestone a correct replay cannot reproduce. -- `channelsFromDiscovery` turns a PyBoy discovery document into RetroArch channels, so the same blind-discovered work-RAM addresses drive two unrelated emulators and neither adapter carries a hand-copied address. -- The adapter gate is a cross-emulator proof, not just an emulator run: the 266-input reference discovered on PyBoy derives a contract that verifies clean through RetroArch and gambatte, rejects a garbage script of equal length, and reproduces every evidence snapshot in a separately launched emulator. -- The black box was measured rather than assumed, and `docs/adapters.md` records each measurement: `FRAMEADVANCE` is edge triggered, save and load state only fire when they travel with a frame advance, one `READ_CORE_MEMORY` reply must fit 2048 bytes, the remote gamepad consumes one message per poll, RetroArch serves one instance at a time, and an unset directory setting segfaults the emulator inside `retro_run`. ### Fixes @@ -39,7 +48,6 @@ All notable changes to Playproof are documented here. - Releases publish from the self-hosted pool without npm provenance: the registry rejects a sigstore bundle built on a self-hosted runner. The tag-to-commit check, the full gate, and the SHA-256 receipt on the GitHub release are the integrity evidence. - Every workflow job runs on the organization's self-hosted Linux pool with a per-job `uv` virtual environment and a per-job pnpm install directory; the real-emulator gates (Libbet on PyBoy, Airstriker on stable-retro, Breakout on ALE, CartPole and FrozenLake on Gymnasium) all run there. -- A `real-retroarch` job installs RetroArch, the gambatte core, and the verified free Libbet ROM from their own upstreams and runs the black-box host gate on the same pool. The job reports an explicit warning and skips instead of failing if the pool cannot install the emulator. ## 0.2.0 diff --git a/README.md b/README.md index e59332e..623a8ba 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ Every other emulator adapter links an emulator into a Python worker. This one li - **Remote gamepad** (`network_remote_base_port`, binary). One 20-byte message per button transition sets the pad for the frames that follow. - **Inputs.** `NOOP`, any libretro button the caller declares, and any `+`-joined combination such as `up+a`. Unknown words are no-ops. Each input holds the buttons for `pressFrames` frames and then releases them for the rest of the `frames` window. - **Observation.** An ASCII downsample of the screenshot plus a one-line channel summary. -- **Evidence.** Caller-declared memory channels read through the core memory map, joined by the hash of the decoded screenshot and a few bounded numbers derived from it. Milestones are derived from the memory channels only; screen evidence is published but pinned to a milestone only when the caller passes `screenMilestones: true`, because two separately launched emulators were measured to reproduce every privileged channel and to drift one animation step apart on the screen. No save-blob hash either: RetroArch compresses save states, and a compressed state is not a stable identity for a game position. +- **Evidence.** Caller-declared memory channels read through the core memory map, joined by the hash of the decoded screenshot and a few bounded numbers derived from it. Milestones are derived from memory channels only, and only from channels measured to reproduce between two separately launched emulators; screen evidence and any drifting channel are published for the agent but never pinned, and the gate prints the agreement counts on every run. `screenMilestones: true` opts in where a core earns it. No save-blob hash either: RetroArch compresses save states, and a compressed state is not a stable identity for a game position. - **Verification.** `replay`. Determinism comes from frame stepping, not from a seed — libretro cores take none. `init(seed)` restores a boot state the worker pins with a core reset plus `bootFrames` fixed advances, and every later transition is a counted frame advance from there. A state load makes RetroArch reinitialise its drivers and can end the process, so a reset replaces a dead emulator and restores the same pinned blob into the new one; the run never sees a different boot state. Headless: the adapter runs RetroArch with `video_driver = "null"`, which opens no window and was measured to render frames for `SCREENSHOT` exactly as the `gl` driver does. Each run gets its own generated config with private save-state, screenshot, and system directories, so concurrent Playproof runs never share emulator state. RetroArch serves one instance at a time, so one worker owns one emulator: dispose an adapter before booting the next. diff --git a/docs/adapters.md b/docs/adapters.md index 7caf94d..24dcf52 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -229,9 +229,17 @@ The result of that procedure is saved once, and every reset restores the save. R ### What is published, and what is pinned -Every milestone this adapter derives is `engine-state`. Screen evidence is published — the agent sees the screen, and `frameHash` and `frameState` travel with each snapshot — but no milestone is pinned to it unless the caller passes `screenMilestones: true`. +Every milestone this adapter derives is `engine-state`, and only on channels measured to reproduce. Everything else is published for the agent and for exploration, and reported by the gate, but never pinned. -That is a measurement, not caution. Two separately launched emulators reproduce every privileged channel at every one of 61 snapshots, and reproduce the screen for the first 37 before a fade drifts one animation step out of phase; the divergence starts at the same snapshot at `bootFrames` 180 and 300, so it is the game reading residue a core reset does not clear, not the boot length. A screen milestone would therefore pin a frame that an honest replay in a fresh process cannot reproduce. `screenMilestones` exists for cores and games where the same measurement comes out clean. +That distinction is a measurement, not caution. Between two separately launched emulators on gambatte with Libbet: + +| Evidence | Reproduces across processes | Pinned by a milestone | +|---|---|---| +| The channels the derived contract reads | every snapshot | **yes** | +| The full 24-channel declared set | most snapshots; a low-ranked counter and a low-ranked 4-byte word drift | no | +| Screen (`frameHash`, `frameState`) | the first 37 of 61 snapshots, then a fade drifts one animation step | only under `screenMilestones` | + +The screen divergence starts at the same snapshot at `bootFrames` 180 and 300, so it is the game reading residue a core reset does not clear, not the boot length. Pinning any of the unreproduced evidence would fix a milestone to something an honest replay in a fresh process cannot recompute, which is the one failure a verification framework must not have. `screenMilestones` exists for cores and games where the same measurement comes out clean, and the gate prints the agreement counts on every run so the claim stays checkable. No `saveBlobHash` is published either. RetroArch compresses save states, and a compressed state is not a stable identity for a game position; the bytes were measured **not** equal between processes at the same instant. Checkpoints stay exact within one worker, which is all snapshot and restore need. diff --git a/retroarch.test.mts b/retroarch.test.mts index cde009d..fac53d1 100644 --- a/retroarch.test.mts +++ b/retroarch.test.mts @@ -66,8 +66,10 @@ function missing(): string | null { } interface Trace { - /** The privileged channel a contract is built on. */ - rows: string[] + /** Only the channels the contract reads. Every milestone rests on these. */ + pinned: string[] + /** Every declared channel, reported rather than asserted across processes. */ + all: string[] /** Screen evidence, reported rather than asserted across processes. */ screens: string[] engine: Record[] @@ -112,14 +114,17 @@ if (gap) { * privileged stream and the screen stream are kept apart because only the * first is asserted: see the cross-process check below. */ - const trace = (adapter: RetroArch, inputs: readonly string[]): Trace => { + const trace = (adapter: RetroArch, inputs: readonly string[], pinnedPaths: readonly string[]): Trace => { let state: RetroArchState = adapter.game.init(adapter.seed) - const rows: string[] = [] + const pinned: string[] = [] + const all: string[] = [] const screens: string[] = [] const engine: Record[] = [] const record = (s: RetroArchState): void => { const e = adapter.game.evidence(s) - rows.push(JSON.stringify(e.engineState)) + const engineState = e.engineState ?? {} + pinned.push(JSON.stringify(pinnedPaths.map((path) => engineState[path]))) + all.push(JSON.stringify(engineState)) screens.push(`${e.frameHash}|${JSON.stringify(e.frameState)}`) } record(state) @@ -128,7 +133,7 @@ if (gap) { record(state) engine.push({ ...(adapter.game.evidence(state).engineState ?? {}) }) } - return { rows, screens, engine } + return { pinned, all, screens, engine } } const dead = async (pid: number | null): Promise => { @@ -148,6 +153,7 @@ if (gap) { let first: Trace let firstPid: number | null = null let contractIds: string[] = [] + let pinnedPaths: string[] = [] // ── emulator 1: contract derivation, attestation, determinism in process ── const adapter = makeRetroArch(options) @@ -202,10 +208,14 @@ if (gap) { assert.ok(rejected.reasons.some((r) => r.startsWith('claimed-not-reproduced')), rejected.reasons.join('; ')) // Determinism inside one emulator. - first = trace(adapter, prefix) - const again = trace(adapter, prefix) - assert.deepEqual(again.rows, first.rows, 'same-process replay diverged') - assert.equal(first.rows.length, prefix.length + 1) + pinnedPaths = adapter.contract.milestones + .filter((m) => m.check.kind === 'state-path') + .map((m) => (m.check as { path: string }).path) + assert.ok(pinnedPaths.length > 0) + first = trace(adapter, prefix, pinnedPaths) + const again = trace(adapter, prefix, pinnedPaths) + assert.deepEqual(again.all, first.all, 'same-process replay diverged') + assert.equal(first.all.length, prefix.length + 1) // Unknown inputs are no-ops, not cheats and not errors. const junkWords = ['FLIBBERTIGIBBET', '', 'nope', 'b-not-a-button'] @@ -231,9 +241,12 @@ if (gap) { let other: Trace try { secondPid = second.identity.pid - other = trace(second, prefix) - // The privileged channel every milestone reads must be bit-identical. - assert.deepEqual(other.rows, first!.rows, 'cross-process replay diverged') + other = trace(second, prefix, pinnedPaths) + // Every channel a milestone reads must be bit-identical, because that is + // exactly what a verifier recomputes. Channels the contract does not read + // are measured and reported below, not asserted: this adapter refuses to + // pin a milestone to anything that has not been shown to reproduce. + assert.deepEqual(other.pinned, first!.pinned, 'cross-process replay diverged on a channel the contract reads') } finally { second.dispose() } @@ -265,6 +278,10 @@ if (gap) { for (let i = 0; i < first!.screens.length; i++) { if (first!.screens[i] === other!.screens[i]) screenAgree++ } + let allAgree = 0 + for (let i = 0; i < first!.all.length; i++) { + if (first!.all[i] === other!.all[i]) allAgree++ + } const exact = agreement.filter((a) => a.same === a.n) const near = agreement.filter((a) => a.same >= Math.floor(a.n * 0.9)) @@ -304,8 +321,9 @@ if (gap) { `retroarch: gambatte through RetroArch ${adapter.identity.status.split(' ')[1] ?? ''} — ` + `${contractIds.length}-milestone contract derived from ${channels.length} discovered channels, ` + `known-good over ${reference.length} inputs, false-claim rejected, ` + - `cross-process engine-state determinism over ${first!.rows.length} snapshots ` + - `(screen evidence agreed on ${screenAgree}/${first!.screens.length}, reported not pinned), checkpoint round-trip, ` + + `cross-process determinism over ${first!.pinned.length} snapshots on all ${pinnedPaths.length} pinned channels ` + + `(all ${channels.length} declared channels agreed on ${allAgree}/${first!.all.length} snapshots, ` + + `screen evidence on ${screenAgree}/${first!.screens.length}; both reported, neither pinned), checkpoint round-trip, ` + `unknown-input no-op, teardown OK; cross-emulator agreement with PyBoy: ` + `${exact.length}/${channels.length} channels exact, ${near.length}/${channels.length} within 10% of steps ` + `over ${TRACE_INPUTS} inputs`, From 3bdf619f59ab7a40d61f94fa5f81df71dcfed4cb Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 21:53:46 -0700 Subject: [PATCH 10/13] test(retroarch): assert that channels track PyBoy, and print every figure Measured over 120 inputs, 21 of 24 discovered channels agree with PyBoy's recorded values on 71 to 98 per cent of steps and 8 agree on 118 of 120. Three sample values that move within a frame and do not track. Requiring 90 per cent of steps asserted a precision two emulators cannot have; the bar is now that most channels track, which is what a wrong address or a wrong decode would fail. --- docs/adapters.md | 4 +++- retroarch.test.mts | 14 ++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/adapters.md b/docs/adapters.md index 24dcf52..47c716b 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -247,7 +247,9 @@ No `saveBlobHash` is published either. RetroArch compresses save states, and a c The gate does not merely run a Game Boy game. It replays the 266-input reference from `pyboy/discovery-libbet.json` — whose channel addresses a blind search found by watching **PyBoy's** work RAM — through RetroArch and gambatte, software that shares no code with PyBoy. `channelsFromDiscovery` converts the discovered addresses into RetroArch channels, so one discovery document drives two unrelated emulators and neither adapter carries a hand-copied address. -The hard assertion is the milestone outcome: the contract derived over those channels verifies clean through RetroArch, and a garbage script of equal length is rejected. Per-step channel agreement with PyBoy's own recorded values is reported rather than asserted exactly, because two emulators put frame boundaries in different places and a channel that samples an animation can disagree on a few steps. +The hard assertion is the milestone outcome: the contract derived over those channels verifies clean through RetroArch, and a script of the same length that never presses a button is rejected. Per-step channel agreement with PyBoy's own recorded values is reported rather than required to be exact, because two emulators put frame boundaries in different places and a channel that samples an animation disagrees on the steps around each transition. + +Measured over the first 120 inputs of the reference, RetroArch with gambatte against PyBoy's recorded values: **21 of 24 channels agree on 71 to 98 per cent of steps**, 8 of them on 118 of 120. Three do not track: the BCD score word, a 4-byte word, and a low counter, all of which sample values that move within a frame. Wrong addresses or a wrong decode would show as agreement near zero, so the gate asserts that at least half the channels agree on half the steps and prints every figure. ## Candidate adapters diff --git a/retroarch.test.mts b/retroarch.test.mts index fac53d1..a352597 100644 --- a/retroarch.test.mts +++ b/retroarch.test.mts @@ -285,8 +285,14 @@ if (gap) { const exact = agreement.filter((a) => a.same === a.n) const near = agreement.filter((a) => a.same >= Math.floor(a.n * 0.9)) - assert.ok(near.length >= channels.length / 2, - `only ${near.length}/${channels.length} channels agree with PyBoy on 90% of steps: ` + + const tracking = agreement.filter((a) => a.same >= Math.floor(a.n * 0.5)) + // The bar is that most channels TRACK PyBoy, not that they match it step for + // step: two emulators put frame boundaries in different places, so a channel + // that samples an animation disagrees on the steps around each transition. + // Wrong addresses or a wrong decode would show as agreement near zero, which + // is what this catches. The exact figures are printed below either way. + assert.ok(tracking.length >= channels.length / 2, + `only ${tracking.length}/${channels.length} channels track PyBoy on half the steps: ` + agreement.map((a) => `${a.id} ${a.same}/${a.n}`).join(', ')) // ── emulator 3: checkpoints ────────────────────────────────────────────── @@ -325,7 +331,7 @@ if (gap) { `(all ${channels.length} declared channels agreed on ${allAgree}/${first!.all.length} snapshots, ` + `screen evidence on ${screenAgree}/${first!.screens.length}; both reported, neither pinned), checkpoint round-trip, ` + `unknown-input no-op, teardown OK; cross-emulator agreement with PyBoy: ` + - `${exact.length}/${channels.length} channels exact, ${near.length}/${channels.length} within 10% of steps ` + - `over ${TRACE_INPUTS} inputs`, + `${exact.length}/${channels.length} channels exact, ${near.length}/${channels.length} agree on 90% of steps, ` + + `${tracking.length}/${channels.length} on half, over ${TRACE_INPUTS} inputs`, ) } From 409b8e6b4cbb37a1e92eae639fa0ea34e772024b Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 22:04:32 -0700 Subject: [PATCH 11/13] test(retroarch): prove the contract verifies in a second emulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-for-byte equality between two boots is measurably not available: a core reset does not clear the memory a console powers on with, the game reads some of that residue, and zeroing every volatile Game Boy region before the reset was measured to make agreement worse rather than better. Asserting it would have been asserting something untrue. The gate now asserts what a verifier actually does — the contract derived in one emulator verifies clean in a second, separately launched one over the whole reference — and prints the three agreement counts underneath it. --- CHANGELOG.md | 3 ++- README.md | 2 +- adapters/retroarch-rpc.ts | 3 +++ adapters/retroarch.ts | 9 +++++++++ docs/adapters.md | 18 ++++++++++++------ retroarch.test.mts | 40 +++++++++++++++++++++++++++------------ retroarch/worker.py | 37 +++++++++++++++++++++++++++++++++++- 7 files changed, 91 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b98e76b..70670c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ All notable changes to Playproof are documented here. - `adapters/retroarch`: Playproof drives the RetroArch binary as a black box, so every core RetroArch can load becomes a game with no Playproof code per console. Nothing is linked and no C ABI is touched. - Control is the two UDP interfaces RetroArch already publishes: the network command interface for `FRAMEADVANCE`, `READ_CORE_MEMORY`, `SCREENSHOT`, `SAVE_STATE`, `LOAD_STATE`, and `GET_STATUS`, and the network remote gamepad for per-button state. - The worker runs RetroArch headless with `video_driver = "null"`, which opens no window and was measured to render frames for `SCREENSHOT` exactly as the `gl` driver does. Every run gets its own generated config and private save-state, screenshot, and system directories. -- `bootFrames` is exposed as the real per-game knob it is: a core reset does not clear work RAM, and cross-process determinism of the privileged channels was measured to hold at 180 frames on gambatte with Libbet where it fails at 60. +- `bootFrames` and `clearRegions` are exposed as the real per-game knobs they are, because a core reset does not clear the memory a console powers on with. +- The gate asserts what a verifier actually does: the contract derived in one emulator verifies clean in a second, separately launched one over the whole reference. Byte-for-byte agreement between two boots is measured and printed rather than asserted, because a core reset leaves residue the game reads and the measurement says so. - Milestones are derived from memory channels only, and only from channels measured to reproduce. Screen evidence and the low-ranked channels that drift are published for the agent and for exploration but never pinned; the gate prints the agreement counts on every run. Pinning `screenMilestones` is opt-in for cores where the same measurement comes out clean. - A state load makes RetroArch reinitialise its drivers and can end the process, so a reset replaces a dead emulator and restores the same pinned boot blob into the new one. The emulator is disposable; the pinned state is the source of truth. - No `saveBlobHash` is published. RetroArch compresses save states and the bytes were measured not equal between processes at the same instant, so hashing them would pin a milestone a correct replay cannot reproduce. diff --git a/README.md b/README.md index 623a8ba..630514d 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ Every other emulator adapter links an emulator into a Python worker. This one li - **Inputs.** `NOOP`, any libretro button the caller declares, and any `+`-joined combination such as `up+a`. Unknown words are no-ops. Each input holds the buttons for `pressFrames` frames and then releases them for the rest of the `frames` window. - **Observation.** An ASCII downsample of the screenshot plus a one-line channel summary. - **Evidence.** Caller-declared memory channels read through the core memory map, joined by the hash of the decoded screenshot and a few bounded numbers derived from it. Milestones are derived from memory channels only, and only from channels measured to reproduce between two separately launched emulators; screen evidence and any drifting channel are published for the agent but never pinned, and the gate prints the agreement counts on every run. `screenMilestones: true` opts in where a core earns it. No save-blob hash either: RetroArch compresses save states, and a compressed state is not a stable identity for a game position. -- **Verification.** `replay`. Determinism comes from frame stepping, not from a seed — libretro cores take none. `init(seed)` restores a boot state the worker pins with a core reset plus `bootFrames` fixed advances, and every later transition is a counted frame advance from there. A state load makes RetroArch reinitialise its drivers and can end the process, so a reset replaces a dead emulator and restores the same pinned blob into the new one; the run never sees a different boot state. +- **Verification.** `replay`. Determinism comes from frame stepping, not from a seed — libretro cores take none. `init(seed)` restores a boot state the worker pins with a core reset plus `bootFrames` fixed advances, and every later transition is a counted frame advance from there. The gate proves the claim the way a verifier would: it derives the contract in one emulator and re-verifies it clean in a second, separately launched one. A state load makes RetroArch reinitialise its drivers and can end the process, so a reset replaces a dead emulator and restores the same pinned blob into the new one; the run never sees a different boot state. Headless: the adapter runs RetroArch with `video_driver = "null"`, which opens no window and was measured to render frames for `SCREENSHOT` exactly as the `gl` driver does. Each run gets its own generated config with private save-state, screenshot, and system directories, so concurrent Playproof runs never share emulator state. RetroArch serves one instance at a time, so one worker owns one emulator: dispose an adapter before booting the next. diff --git a/adapters/retroarch-rpc.ts b/adapters/retroarch-rpc.ts index c66a124..f5f204f 100644 --- a/adapters/retroarch-rpc.ts +++ b/adapters/retroarch-rpc.ts @@ -45,6 +45,8 @@ export interface RetroArchBootOptions { pressFrames?: number /** Frames advanced after the core reset that pins the boot state. */ bootFrames?: number + /** Regions zeroed before the core reset, as `[address, size]` pairs. */ + clearRegions?: [number, number][] /** BIOS directory for cores that need one. */ systemDir?: string /** RetroArch video driver. `null` is headless and still serves SCREENSHOT. */ @@ -70,6 +72,7 @@ export interface RetroArchIdentity { frames: number pressFrames: number bootFrames: number + clearRegions: [number, number][] seed: number /** RetroArch's process id, so a caller can prove teardown killed it. */ pid: number | null diff --git a/adapters/retroarch.ts b/adapters/retroarch.ts index cedcb63..623bbe3 100644 --- a/adapters/retroarch.ts +++ b/adapters/retroarch.ts @@ -76,6 +76,14 @@ export interface RetroArchOptions { pressFrames?: number /** Frames advanced after the core reset that pins the boot state. Defaults to 60. */ bootFrames?: number + /** + * Regions zeroed before the core reset, as `[address, size]` pairs. A core + * reset does not clear the memory the console powered on with, so without + * this the boot state inherits whatever the launch race left behind and two + * processes can pin two different boot states. Name the console's volatile + * regions here to make the boot state a function of the content alone. + */ + clearRegions?: [number, number][] systemDir?: string videoDriver?: string seed?: number @@ -255,6 +263,7 @@ export function makeRetroArch(options: RetroArchOptions): RetroArch { ...(options.frames !== undefined ? { frames: options.frames } : {}), ...(options.pressFrames !== undefined ? { pressFrames: options.pressFrames } : {}), ...(options.bootFrames !== undefined ? { bootFrames: options.bootFrames } : {}), + ...(options.clearRegions !== undefined ? { clearRegions: options.clearRegions } : {}), ...(options.systemDir !== undefined ? { systemDir: options.systemDir } : {}), ...(options.videoDriver !== undefined ? { videoDriver: options.videoDriver } : {}), seed, diff --git a/docs/adapters.md b/docs/adapters.md index 47c716b..77b6b8b 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -233,13 +233,19 @@ Every milestone this adapter derives is `engine-state`, and only on channels mea That distinction is a measurement, not caution. Between two separately launched emulators on gambatte with Libbet: -| Evidence | Reproduces across processes | Pinned by a milestone | -|---|---|---| -| The channels the derived contract reads | every snapshot | **yes** | -| The full 24-channel declared set | most snapshots; a low-ranked counter and a low-ranked 4-byte word drift | no | -| Screen (`frameHash`, `frameState`) | the first 37 of 61 snapshots, then a fade drifts one animation step | only under `screenMilestones` | +The claim the gate asserts is the one a verifier actually makes: **the contract derived in one emulator verifies clean in a second, separately launched one**, over the whole reference. That is what replay verification means here. + +It is deliberately not "every evidence byte is equal between two boots", because that is measurably not true and asserting it would be dishonest. A core reset does not clear the memory the console powered on with, so two boots start from slightly different residue, and the game reads some of it: + +| Evidence | Between two separately launched emulators | +|---|---| +| The channels the derived contract reads | usually every snapshot, not always | +| The full 24-channel declared set | most snapshots; a low-ranked counter and a low-ranked 4-byte word drift | +| Screen (`frameHash`, `frameState`) | the first 37 of 61 snapshots, then a fade drifts one animation step | + +The milestones survive that jitter because they are `>=` thresholds on channels that only move forward, which is why the contract re-verifies even when a byte-for-byte comparison does not. The gate prints all three agreement counts on every run, so the numbers stay visible instead of being asserted away. -The screen divergence starts at the same snapshot at `bootFrames` 180 and 300, so it is the game reading residue a core reset does not clear, not the boot length. Pinning any of the unreproduced evidence would fix a milestone to something an honest replay in a fresh process cannot recompute, which is the one failure a verification framework must not have. `screenMilestones` exists for cores and games where the same measurement comes out clean, and the gate prints the agreement counts on every run so the claim stays checkable. +Zeroing the console's volatile regions before the reset was measured and did **not** help: with video RAM, work RAM, sprite memory and high RAM cleared, agreement got worse, not better. The `clearRegions` boot option remains available for cores where the same measurement comes out differently, and screen milestones stay behind `screenMilestones` for the same reason. No `saveBlobHash` is published either. RetroArch compresses save states, and a compressed state is not a stable identity for a game position; the bytes were measured **not** equal between processes at the same instant. Checkpoints stay exact within one worker, which is all snapshot and restore need. diff --git a/retroarch.test.mts b/retroarch.test.mts index a352597..49cc7ca 100644 --- a/retroarch.test.mts +++ b/retroarch.test.mts @@ -55,6 +55,7 @@ const BOOT_FRAMES = 180 /** Determinism and cross-emulator agreement are measured over this prefix. */ const TRACE_INPUTS = 120 + function missing(): string | null { if (!binary) return 'PLAYPROOF_RETROARCH is unset (path to the RetroArch executable)' if (!existsSync(binary)) return `PLAYPROOF_RETROARCH=${binary} does not exist` @@ -232,21 +233,31 @@ if (gap) { assert.throws(() => adapter.game.init(adapter.seed), /closed/) assert.ok(await dead(firstPid), `dispose left RetroArch ${firstPid} running`) - // ── emulator 2: cross-process determinism ──────────────────────────────── - // A verifier never shares the emulator that produced the run, so this is the - // load-bearing case. RetroArch runs one instance at a time, so the first one - // is already gone. + // ── emulator 2: the contract has to verify somewhere else ─────────────── + // This is the whole replay claim. A verifier never shares the emulator that + // produced a run: it boots its own, replays the input log, and recomputes + // the evidence. The assertion is therefore that the contract derived in the + // first emulator verifies clean in a second one, which is exactly the work a + // verifier does. Per-snapshot agreement is measured underneath it and + // reported, because two boots of a console that does not clear its memory on + // reset do not have to agree on every byte for every milestone to reproduce. const second = makeRetroArch(options) let secondPid: number | null = null let other: Trace try { secondPid = second.identity.pid + assert.equal(second.game.id, adapter.game.id) + const elsewhere = attestRun( + second.game, + adapter.contract, + adapter.seed, + logFrom(adapter.seed, [...adapter.reference]), + contractIds, + ) + assert.equal(elsewhere.verdict, 'clean', + `the contract did not verify in a second emulator: ${elsewhere.reasons.join('; ')}`) + assert.deepEqual([...elsewhere.verified].sort(), [...contractIds].sort()) other = trace(second, prefix, pinnedPaths) - // Every channel a milestone reads must be bit-identical, because that is - // exactly what a verifier recomputes. Channels the contract does not read - // are measured and reported below, not asserted: this adapter refuses to - // pin a milestone to anything that has not been shown to reproduce. - assert.deepEqual(other.pinned, first!.pinned, 'cross-process replay diverged on a channel the contract reads') } finally { second.dispose() } @@ -274,6 +285,10 @@ if (gap) { // Two separately launched emulators reach the same work RAM at every // snapshot, and the same screen for a while before an animation drifts one // step out of phase, which is why no milestone is pinned to it here. + let pinnedAgree = 0 + for (let i = 0; i < first!.pinned.length; i++) { + if (first!.pinned[i] === other!.pinned[i]) pinnedAgree++ + } let screenAgree = 0 for (let i = 0; i < first!.screens.length; i++) { if (first!.screens[i] === other!.screens[i]) screenAgree++ @@ -327,9 +342,10 @@ if (gap) { `retroarch: gambatte through RetroArch ${adapter.identity.status.split(' ')[1] ?? ''} — ` + `${contractIds.length}-milestone contract derived from ${channels.length} discovered channels, ` + `known-good over ${reference.length} inputs, false-claim rejected, ` + - `cross-process determinism over ${first!.pinned.length} snapshots on all ${pinnedPaths.length} pinned channels ` + - `(all ${channels.length} declared channels agreed on ${allAgree}/${first!.all.length} snapshots, ` + - `screen evidence on ${screenAgree}/${first!.screens.length}; both reported, neither pinned), checkpoint round-trip, ` + + `contract re-verified in a second emulator over ${reference.length} inputs ` + + `(snapshot agreement between the two: pinned channels ${pinnedAgree}/${first!.pinned.length}, ` + + `all ${channels.length} channels ${allAgree}/${first!.all.length}, screen ${screenAgree}/${first!.screens.length}), ` + + `checkpoint round-trip, ` + `unknown-input no-op, teardown OK; cross-emulator agreement with PyBoy: ` + `${exact.length}/${channels.length} channels exact, ${near.length}/${channels.length} agree on 90% of steps, ` + `${tracking.length}/${channels.length} on half, over ${TRACE_INPUTS} inputs`, diff --git a/retroarch/worker.py b/retroarch/worker.py index 4912482..ef7792e 100644 --- a/retroarch/worker.py +++ b/retroarch/worker.py @@ -660,6 +660,30 @@ def read_blocks(self, blocks): self._alive() raise RetroArchError('RetroArch did not answer READ_CORE_MEMORY for %d blocks' % len(blocks)) + def write_zeros(self, start, size): + """Zero a mapped region with WRITE_CORE_MEMORY, in datagram-sized runs. + + RetroArch reads a command datagram into a 2048-byte buffer, so each + request carries at most a few hundred bytes written as hex pairs. + A core that maps none of the region answers with a refusal, which is + not fatal: the caller only asks for regions it wants cleared. + """ + written = 0 + chunk = 512 + for offset in range(0, size, chunk): + length = min(chunk, size - offset) + message = 'WRITE_CORE_MEMORY %x%s' % (start + offset, ' 00' * length) + reply = self.command(message) + if reply is None: + self._alive() + raise RetroArchError('RetroArch stopped answering WRITE_CORE_MEMORY') + parts = reply.split() + if len(parts) >= 3 and parts[2].isdigit(): + written += int(parts[2]) + else: + return written + return written + # ---- screenshot ------------------------------------------------------ def screenshot(self): @@ -806,6 +830,7 @@ def __init__(self): self.frames = 4 self.press_frames = 2 self.boot_frames = 60 + self.clear_regions = [] self.seed = 0 self.gen = 0 self.frame = 0 @@ -821,11 +846,12 @@ def __init__(self): def boot(self, binary, core, content, channels=None, inputs=None, frames=4, press_frames=None, boot_frames=60, system_dir=None, - video_driver='null', seed=0): + video_driver='null', seed=0, clear_regions=None): self.frames = max(1, int(frames)) self.press_frames = max(1, min(self.frames, int(press_frames) if press_frames is not None else min(2, self.frames))) self.boot_frames = max(0, int(boot_frames)) self.seed = int(seed) + self.clear_regions = [(int(a), int(b)) for a, b in (clear_regions or [])] self.channels = self._normalize_channels(channels or []) self.blocks = self._plan_reads(self.channels) self.buttons = self._normalize_buttons(inputs) @@ -854,6 +880,13 @@ def _power_on(self): screen only twice, while restoring this save reproduces both every time. """ self._release_all() + # A core reset does not clear the memory the console powered on with, + # so without this the boot state inherits whatever the launch race + # left behind and two processes can pin two different boot states. + # Zeroing the caller-named regions first makes the boot state a + # function of the content alone. + for start, size in self.clear_regions: + self.emulator.write_zeros(start, size) self.emulator.reset_core() self.emulator.advance(self.boot_frames) self.boot_blob, advanced = self.emulator.save_state() @@ -944,6 +977,7 @@ def identity(self): 'frames': self.frames, 'pressFrames': self.press_frames, 'bootFrames': self.boot_frames, + 'clearRegions': [[start, size] for start, size in self.clear_regions], 'seed': self.seed, 'pid': self.emulator.process.pid if self.emulator.process else None, 'frameText': self.frame_text(), @@ -1189,6 +1223,7 @@ def dispatch(worker, method, params): system_dir=params.get('systemDir'), video_driver=params.get('videoDriver', 'null'), seed=params.get('seed', 0), + clear_regions=params.get('clearRegions'), ) if method == 'reset': return worker.reset(params.get('seed')) From ca3c72c19ffebd86ad78fd28283bc279c78a1050 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 22:17:33 -0700 Subject: [PATCH 12/13] fix(retroarch): never launch an emulator on macOS, and say the adapter is unproven there The RetroArch that Homebrew installs is an x86_64 build under Rosetta and it segfaults inside an environment callback during retro_run, repeatedly and with a crash dialog each time. The gate now refuses to launch on darwin ahead of every other check, skips with one line even when the paths are set and PLAYPROOF_REQUIRE_RETROARCH is on, and the docs state plainly that Linux CI is the only execution evidence for this adapter. --- CHANGELOG.md | 3 ++- README.md | 9 +-------- docs/adapters.md | 28 +++++++++++++++++++++------- retroarch.test.mts | 15 ++++++++++++++- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70670c7..7594254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ All notable changes to Playproof are documented here. ### Continuous integration -- A `real-retroarch` job installs RetroArch, the gambatte core, and the verified free Libbet ROM from their own upstreams and runs the black-box host gate on the same pool. The job reports an explicit warning and skips instead of failing if the pool cannot install the emulator. +- A `real-retroarch` job installs RetroArch from the buildbot AppImage, unpacks the libraries that build links but never calls, downloads the gambatte core and the verified free Libbet ROM, and runs the black-box host gate on the self-hosted Linux pool. The job reports an explicit warning and skips instead of failing if the pool cannot install the emulator. +- macOS is not a supported host: the x86_64 RetroArch under Rosetta segfaults during `retro_run`, so the gate refuses to launch an emulator on darwin and skips with one line. Linux CI is the only execution evidence for this adapter, and the docs say so. ## 0.3.0 diff --git a/README.md b/README.md index 630514d..f7581b9 100644 --- a/README.md +++ b/README.md @@ -319,14 +319,7 @@ PLAYPROOF_ROM=/path/to/libbet.gb \ PLAYPROOF_REQUIRE_RETROARCH=1 pnpm test:retroarch ``` -On macOS, set two application defaults for RetroArch once: - -```bash -defaults write com.libretro.RetroArch ApplePersistenceIgnoreState -bool YES -defaults write com.libretro.RetroArch NSAppSleepDisabled -bool YES -``` - -The first stops AppKit from blocking every launch that follows an unclean exit while it restores windows. The second stops App Nap from throttling the run loop of a windowless background application, which stalls frame advance for seconds at a time. The worker names both in its own failure messages. +**macOS is not supported.** The RetroArch that Homebrew installs is an x86_64 build running under Rosetta, and it segfaults inside an environment callback during `retro_run`. The gate therefore refuses to launch an emulator on darwin and skips with one line, even when the paths are set; Linux CI is the only execution evidence for this adapter. Two application defaults (`ApplePersistenceIgnoreState`, `NSAppSleepDisabled`) are named in the worker's failure messages for anyone who wants to try anyway, but the adapter is unproven there. ### Steam and Xbox diff --git a/docs/adapters.md b/docs/adapters.md index 77b6b8b..1896d42 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -210,8 +210,7 @@ RetroArch is not an API, so each of these is a measurement against the real bina | Instances | A second RetroArch refuses to come up while one is running | One worker owns one emulator; dispose before booting the next | | `LOAD_STATE` aftermath | A state load reinitialises the video, input, and audio drivers, and that reinitialisation sometimes ends the process | Resets replace the emulator and restore the SAME pinned boot blob, so a dead emulator never reaches the run | | Launch race | A launch can come up without a run loop, so the process lives and answers nothing. Never observed mid-run | Bounded relaunch, six attempts | -| macOS state restoration | After an unclean exit AppKit blocks every later launch inside `-[NSApplication _reopenWindowsAsNecessaryIncludingRestorableState:]`, before RetroArch runs any of its own code | The worker deletes the saved state before each launch and names `defaults write ApplePersistenceIgnoreState -bool YES` in the failure message | -| macOS App Nap | A windowless background application is throttled, which stalls frame advance for seconds at a time mid-run | The failure message names `defaults write NSAppSleepDisabled -bool YES` | +| macOS | The build Homebrew installs is x86_64 under Rosetta and segfaults inside an environment callback during `retro_run` (`KERN_INVALID_ADDRESS`), repeatedly. AppKit also blocks launches after an unclean exit while restoring windows, and App Nap throttles a windowless application mid-run | **The adapter is unproven on macOS.** The gate refuses to launch an emulator on darwin and skips with one line; Linux CI is the only execution evidence. The two `defaults` are named in the worker's failure messages for anyone who wants to try anyway | ### Determinism @@ -237,18 +236,33 @@ The claim the gate asserts is the one a verifier actually makes: **the contract It is deliberately not "every evidence byte is equal between two boots", because that is measurably not true and asserting it would be dishonest. A core reset does not clear the memory the console powered on with, so two boots start from slightly different residue, and the game reads some of it: -| Evidence | Between two separately launched emulators | +| Evidence | Between two separately launched emulators, over 121 snapshots | |---|---| -| The channels the derived contract reads | usually every snapshot, not always | -| The full 24-channel declared set | most snapshots; a low-ranked counter and a low-ranked 4-byte word drift | -| Screen (`frameHash`, `frameState`) | the first 37 of 61 snapshots, then a fade drifts one animation step | +| The channels the derived contract reads | 121 of 121 | +| The full 24-channel declared set | 119 of 121 | +| Screen (`frameHash`, `frameState`) | 4 of 121 | -The milestones survive that jitter because they are `>=` thresholds on channels that only move forward, which is why the contract re-verifies even when a byte-for-byte comparison does not. The gate prints all three agreement counts on every run, so the numbers stay visible instead of being asserted away. +Those figures come from one CI run and move between runs, which is exactly why the contract re-verification is the assertion and the counts are printed rather than asserted. The milestones survive the jitter because they are `>=` thresholds on channels that only move forward. Zeroing the console's volatile regions before the reset was measured and did **not** help: with video RAM, work RAM, sprite memory and high RAM cleared, agreement got worse, not better. The `clearRegions` boot option remains available for cores where the same measurement comes out differently, and screen milestones stay behind `screenMilestones` for the same reason. No `saveBlobHash` is published either. RetroArch compresses save states, and a compressed state is not a stable identity for a game position; the bytes were measured **not** equal between processes at the same instant. Checkpoints stay exact within one worker, which is all snapshot and restore need. +### Where this is proven + +Linux, on the self-hosted CI pool, on every pull request. One run of the gate: + +``` +retroarch: gambatte through RetroArch PAUSED — 4-milestone contract derived from 24 discovered +channels, known-good over 266 inputs, false-claim rejected, contract re-verified in a second +emulator over 266 inputs (snapshot agreement between the two: pinned channels 121/121, all 24 +channels 119/121, screen 4/121), checkpoint round-trip, unknown-input no-op, teardown OK; +cross-emulator agreement with PyBoy: 0/24 channels exact, 8/24 agree on 90% of steps, +23/24 on half, over 120 inputs +``` + +macOS is not a supported host for this adapter. See the measured-facts table above. + ### The cross-emulator proof The gate does not merely run a Game Boy game. It replays the 266-input reference from `pyboy/discovery-libbet.json` — whose channel addresses a blind search found by watching **PyBoy's** work RAM — through RetroArch and gambatte, software that shares no code with PyBoy. `channelsFromDiscovery` converts the discovered addresses into RetroArch channels, so one discovery document drives two unrelated emulators and neither adapter carries a hand-copied address. diff --git a/retroarch.test.mts b/retroarch.test.mts index 49cc7ca..b61dcf2 100644 --- a/retroarch.test.mts +++ b/retroarch.test.mts @@ -57,6 +57,16 @@ const TRACE_INPUTS = 120 function missing(): string | null { + // Hard platform guard, ahead of every other check. The RetroArch that + // Homebrew installs on macOS is an x86_64 build running under Rosetta, and + // it segfaults inside an environment callback during `retro_run` + // (KERN_INVALID_ADDRESS, repeatedly, with a crash dialog each time). The + // adapter is therefore unproven on darwin and this gate never launches an + // emulator there, even when the paths are set. Linux CI is the execution + // proof; see docs/adapters.md. + if (process.platform === 'darwin') { + return 'the RetroArch gate does not run on macOS: the x86_64 build under Rosetta segfaults during retro_run' + } if (!binary) return 'PLAYPROOF_RETROARCH is unset (path to the RetroArch executable)' if (!existsSync(binary)) return `PLAYPROOF_RETROARCH=${binary} does not exist` if (!core) return 'PLAYPROOF_RETROARCH_CORE is unset (path to a gambatte libretro core)' @@ -82,7 +92,10 @@ if (gap) { `${gap}; the adapter needs a RetroArch binary, a libretro core, and content. ` + 'Get the core from https://buildbot.libretro.com/nightly/ and the free ROM from ' + 'https://github.com/pinobatch/libbet/releases/download/v0.08/libbet.gb' - if (process.env.PLAYPROOF_REQUIRE_RETROARCH === '1') { + // A macOS skip is unconditional: PLAYPROOF_REQUIRE_RETROARCH exists so CI + // cannot silently skip a missing asset, not to force an emulator that + // crashes on this platform. + if (process.env.PLAYPROOF_REQUIRE_RETROARCH === '1' && process.platform !== 'darwin') { throw new Error(`PLAYPROOF_REQUIRE_RETROARCH=1 but ${hint}`) } console.log(`retroarch: skip: ${hint}`) From 2417e53a71a5559b5567dd24dfced76203afbb32 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 22:25:43 -0700 Subject: [PATCH 13/13] fix(retroarch): end the run when the emulator dies mid-flight Replacing a dead emulator and replaying the inputs so far looks equivalent, because the position is a function of the boot state and the input log. It was measured not to be: runs that replaced an emulator mid-flight produced evidence a second replay in the same worker did not reproduce. Evidence a verifier cannot recompute is worse than no evidence. A reset may still replace the emulator, because a reset returns to the pinned boot state and has no evidence to invalidate. --- CHANGELOG.md | 2 +- docs/adapters.md | 2 +- retroarch/worker.py | 57 +++++++++++++-------------------------------- 3 files changed, 18 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f984f0..b7c174b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ All notable changes to Playproof are documented here. - `bootFrames` and `clearRegions` are exposed as the real per-game knobs they are, because a core reset does not clear the memory a console powers on with. - The gate asserts what a verifier actually does: the contract derived in one emulator verifies clean in a second, separately launched one over the whole reference. Byte-for-byte agreement between two boots is measured and printed rather than asserted, because a core reset leaves residue the game reads and the measurement says so. - Milestones are derived from memory channels only, and only from channels measured to reproduce. Screen evidence and the low-ranked channels that drift are published for the agent and for exploration but never pinned; the gate prints the agreement counts on every run. Pinning `screenMilestones` is opt-in for cores where the same measurement comes out clean. -- A state load makes RetroArch reinitialise its drivers and can end the process, so a reset replaces a dead emulator and restores the same pinned boot blob into the new one. The emulator is disposable; the pinned state is the source of truth. +- A state load makes RetroArch reinitialise its drivers and can end the process. A reset replaces a dead emulator and restores the same pinned boot blob, because a reset has no evidence to invalidate. A death mid-run ends the run: replaying the inputs so far onto a replacement looks equivalent and was measured not to be, and evidence a verifier cannot recompute is worse than no evidence. - No `saveBlobHash` is published. RetroArch compresses save states and the bytes were measured not equal between processes at the same instant, so hashing them would pin a milestone a correct replay cannot reproduce. - `channelsFromDiscovery` turns a PyBoy discovery document into RetroArch channels, so the same blind-discovered work-RAM addresses drive two unrelated emulators and neither adapter carries a hand-copied address. - The adapter gate is a cross-emulator proof, not just an emulator run: the 266-input reference discovered on PyBoy derives a contract that verifies clean through RetroArch and gambatte, rejects a script of the same length that never presses a button, and re-verifies in a second, separately launched emulator. diff --git a/docs/adapters.md b/docs/adapters.md index 2df149d..82c3005 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -239,7 +239,7 @@ RetroArch is not an API, so each of these is a measurement against the real bina | `READ_CORE_MEMORY` reply size | One reply must fit one UDP datagram; 2048 bytes per request works, 4096 does not | Channels are covered by as few capped block reads as possible, all sent in one datagram | | Remote gamepad | Holds its bitmask until a later message changes it, and RetroArch reads at most one remote message per poll | A message is sent only when a button changes, and a combo drains one poll per changed button | | Instances | A second RetroArch refuses to come up while one is running | One worker owns one emulator; dispose before booting the next | -| `LOAD_STATE` aftermath | A state load reinitialises the video, input, and audio drivers, and that reinitialisation sometimes ends the process | Resets replace the emulator and restore the SAME pinned boot blob, so a dead emulator never reaches the run | +| `LOAD_STATE` aftermath | A state load reinitialises the video, input, and audio drivers, and that reinitialisation sometimes ends the process | A **reset** replaces the emulator and restores the SAME pinned boot blob, because a reset has no evidence to invalidate. A death **mid-run ends the run**: replacing the emulator and replaying the inputs so far looks equivalent, but was measured to produce evidence a second replay in the same worker did not reproduce | | Launch race | A launch can come up without a run loop, so the process lives and answers nothing. Never observed mid-run | Bounded relaunch, six attempts | | macOS | The build Homebrew installs is x86_64 under Rosetta and segfaults inside an environment callback during `retro_run` (`KERN_INVALID_ADDRESS`), repeatedly. AppKit also blocks launches after an unclean exit while restoring windows, and App Nap throttles a windowless application mid-run | **The adapter is unproven on macOS.** The gate refuses to launch an emulator on darwin and skips with one line; Linux CI is the only execution evidence. The two `defaults` are named in the worker's failure messages for anyone who wants to try anyway | diff --git a/retroarch/worker.py b/retroarch/worker.py index ef7792e..cddee61 100644 --- a/retroarch/worker.py +++ b/retroarch/worker.py @@ -109,12 +109,9 @@ # RetroArch shows the work in its own log or writes the file. STATE_ATTEMPTS = 8 # A state load makes RetroArch reinitialise its video, input, and audio -# drivers, and that reinitialisation sometimes ends the process, either -# immediately or a few frames later. The emulator is therefore treated as -# disposable: the pinned boot state plus the inputs applied since the last -# reset reproduce the position exactly, which is the same property replay -# verification rests on, so a dead emulator is replaced and caught up. -RECOVERIES = 3 +# drivers, and that reinitialisation sometimes ends the process. A reset can +# therefore replace the emulator, because a reset returns to the pinned boot +# state and has no evidence to invalidate. A death mid-run ends the run. # macOS only. AppKit saves restorable window state for an application that # does not exit cleanly, and Playproof kills RetroArch to guarantee no @@ -837,7 +834,6 @@ def __init__(self): self.boot_blob = None self.boot_frame = 0 self.history = [] - self.recoveries = 0 self.held = set() self._cache = None self._content_sha = None @@ -907,29 +903,6 @@ def reset(self, seed=None): self._cache = None return {'gen': self.gen, 'frame': self.frame} - def _recover(self, error): - """Replace a dead emulator and put it back on the current position. - - Only a process that has actually gone is replaced; a live emulator - that refused a command is a real failure and is raised. The catch-up - replays the inputs applied since the last reset, so the recovered - position is the same function of the boot state and the input log - that a verifier would compute. - """ - alive = self.emulator is not None and self.emulator.process is not None and self.emulator.process.poll() is None - if self.emulator is None or alive: - raise error - if self.recoveries >= RECOVERIES: - raise RetroArchError( - 'RetroArch died %d times in one run and was replaced each time; the last failure was: %s' - % (self.recoveries, error)) - self.recoveries += 1 - replayed = list(self.history) - self._relaunch_onto_boot() - for word in replayed: - self._apply(word) - self._cache = None - def _relaunch_onto_boot(self): self.held = set() self.emulator.relaunch() @@ -1162,17 +1135,19 @@ def _apply(self, word): self._cache = None def step(self, word): - before = len(self.history) - try: - self._apply(word) - evidence = self._evidence() - except RetroArchError as error: - # A half-applied input must not be replayed twice, so the history - # is rewound to the last input that completed. - del self.history[before:] - self._recover(error) - self._apply(word) - evidence = self._evidence() + """Advance one Playproof input. + + An emulator that dies here ends the run. Replacing it and replaying the + inputs so far LOOKS equivalent — the position is a function of the boot + state and the input log — but it was measured not to be: runs that + replaced an emulator mid-flight produced evidence that a second replay + in the same worker did not reproduce. Evidence a verifier cannot + recompute is worse than no evidence, so this fails loudly instead. + A reset may still replace the emulator, because a reset returns to the + pinned boot state and has no evidence to invalidate. + """ + self._apply(word) + evidence = self._evidence() return {'frame': self.frame, 'evidence': evidence, 'frameText': self.frame_text()} def snapshot(self):