diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ef8d62..e40c493 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,145 @@ 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 + # 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/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 + [ -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" + # 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" + LD_EXTRA="" + 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" + 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 + # 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 f1ecf35..b7c174b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,25 @@ All notable changes to Playproof are documented here. - Measured, and pinned by the Libbet regression in CI: a 70-turn agent campaign on the packaged blind-discovery contract earned three milestones, and pressing `a` seventy times earns the same three. `constant:start`, `round-robin`, and a seeded pseudo-random walk also earn them, while five of the eight buttons and an unknown word earn none. - Measured on the packaged 2048 target: its reference is a fixed cycle of four directions, so a pseudo-random walk of the same length reaches every milestone. That target exercises the execution and evidence paths and does not measure skill. +### 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` 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. 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. +- 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 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 ### Game and platform adapters @@ -31,6 +50,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. +- 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. ### Fixes diff --git a/README.md b/README.md index 0ff6a8b..36e795c 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,45 @@ 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. 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. 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. + +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 +``` + +**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 ```ts diff --git a/adapters/retroarch-rpc.ts b/adapters/retroarch-rpc.ts new file mode 100644 index 0000000..f5f204f --- /dev/null +++ b/adapters/retroarch-rpc.ts @@ -0,0 +1,109 @@ +/** 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 + /** 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. */ + 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 + clearRegions: [number, 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..623bbe3 --- /dev/null +++ b/adapters/retroarch.ts @@ -0,0 +1,327 @@ +/** + * 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 + /** + * 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 + python?: string + /** Reference input script the contract is derived from. */ + 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 { + 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, + 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] + 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) + + // 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 !== (baseline[channel.id] ?? channel.baseline ?? 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, + }), + })) + if (!screenMilestones) return marks + 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.clearRegions !== undefined ? { clearRegions: options.clearRegions } : {}), + ...(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, options.screenMilestones ?? false), + ) + return { + game, + contract, + reference: options.reference, + inputs: identity.inputs, + identity, + baseline, + seed, + dispose: () => rpc.shutdown(), + } + } catch (error) { + rpc.shutdown() + throw error + } +} diff --git a/docs/adapters.md b/docs/adapters.md index 065bbc2..82c3005 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`; 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 | @@ -213,14 +214,99 @@ 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 | +| `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 | + +### 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. + +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` 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: + +| `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`, 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 distinction is a measurement, not caution. Between two separately launched emulators on gambatte with Libbet: + +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, over 121 snapshots | +|---|---| +| The channels the derived contract reads | 121 of 121 | +| The full 24-channel declared set | 119 of 121 | +| Screen (`frameHash`, `frameState`) | 4 of 121 | + +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 +``` -**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`. +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. + +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 + +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 81de3d6..f692cbf 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,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" @@ -127,6 +135,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..b61dcf2 --- /dev/null +++ b/retroarch.test.mts @@ -0,0 +1,366 @@ +/** + * 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 { + // 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)' + 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 +} + +interface Trace { + /** 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[] +} + +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' + // 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}`) +} 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, 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[], pinnedPaths: readonly string[]): Trace => { + let state: RetroArchState = adapter.game.init(adapter.seed) + const pinned: string[] = [] + const all: string[] = [] + const screens: string[] = [] + const engine: Record[] = [] + const record = (s: RetroArchState): void => { + const e = adapter.game.evidence(s) + 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) + for (const input of inputs) { + state = adapter.game.step(state, input) + record(state) + engine.push({ ...(adapter.game.evidence(state).engineState ?? {}) }) + } + return { pinned, all, screens, 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: Trace + let firstPid: number | null = null + let contractIds: string[] = [] + let pinnedPaths: 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`) + // 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.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.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 + // 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. + // 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') + assert.ok(rejected.reasons.some((r) => r.startsWith('claimed-not-reproduced')), rejected.reasons.join('; ')) + + // Determinism inside one emulator. + 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'] + 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: 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) + } 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 } + }) + // 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 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++ + } + 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)) + 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 ────────────────────────────────────────────── + 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, ` + + `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 new file mode 100644 index 0000000..cddee61 --- /dev/null +++ b/retroarch/worker.py @@ -0,0 +1,1281 @@ +"""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') + # 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 + 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) + 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 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): + 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': + 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())) + + # ---- 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) + 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: + 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): + 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 + 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 or os.environ.get('PLAYPROOF_RETROARCH_KEEP') == '1': + 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 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): + 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)) + + 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): + 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 _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 _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, 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. + + 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 + 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: + 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(400): + size = os.path.getsize(self.state_path) + if size == previous and size > 0: + with open(self.state_path, 'rb') as handle: + return handle.read() + previous = size + time.sleep(0.005) + self.gap() + return None + + def load_state(self, blob): + """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) + 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 -------------------------------------------------- + + 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.clear_regions = [] + self.seed = 0 + self.gen = 0 + self.frame = 0 + self.boot_blob = None + self.boot_frame = 0 + self.history = [] + 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, 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) + 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 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. + + 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() + # 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() + # 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.boot_blob is None: + raise RetroArchError('reset before boot') + self._restore_boot() + self.frame = self.boot_frame + 1 + self.gen += 1 + self._cache = None + return {'gen': self.gen, 'frame': self.frame} + + 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, + 'frame': self.frame, + 'core': os.path.basename(self.emulator.core), + 'content': os.path.basename(self.emulator.content), + 'contentSha': self._content_sha, + # 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], + '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(), + } + + 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 _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 + + def step(self, word): + """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): + self._release_all() + 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 { + '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), + clear_regions=params.get('clearRegions'), + ) + 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() diff --git a/scripts/check-boundary.mjs b/scripts/check-boundary.mjs index eda5e89..a4995d7 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',