Skip to content

feat(adapters): drive any RetroArch core as a black-box host - #16

Merged
drewstone merged 16 commits into
mainfrom
feat/retroarch-adapter
Aug 21, 2026
Merged

feat(adapters): drive any RetroArch core as a black-box host#16
drewstone merged 16 commits into
mainfrom
feat/retroarch-adapter

Conversation

@drewstone

@drewstone drewstone commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Purpose

Every emulator adapter Playproof has links an emulator into a Python worker, which caps the reachable set at whatever some Python package chose to bundle. This adapter links nothing. It 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.

The headline is not that a Game Boy game runs. It is that the same discovered evidence channels verify through a second, unrelated emulator. pyboy/discovery-libbet.json holds work-RAM addresses that a blind search found by watching PyBoy. channelsFromDiscovery turns them into RetroArch channels, and the 266-input reference from that same document derives a contract that verifies clean through RetroArch and gambatte. One discovery document, two emulators, no hand-copied address in either adapter.

What changes

  • retroarch/worker.py — the black-box host. Generates a private per-run config, launches RetroArch, pauses it, pins a boot state, and serves the shared line-JSON protocol: boot, reset, step, evidence, frame, inputs, snapshot, restore, shutdown. Standard library only; no Python package is needed to drive an emulator.
  • adapters/retroarch-rpc.tsWorkerRpc subclass with the boot options, identity, and checkpoint shape.
  • adapters/retroarch.tsmakeRetroArch(...) returning { game, contract, reference, inputs, identity, baseline, seed, dispose }, plus channelsFromDiscovery(doc), the join that lets one discovery document drive two emulators.
  • retroarch.test.mts — the gate. Skips with one line without its three env paths; PLAYPROOF_REQUIRE_RETROARCH=1 makes a missing asset a loud failure.
  • CI job real-retroarch on [self-hosted, ci-linux]: installs RetroArch from the buildbot archive, the gambatte core, and the free Libbet ROM verified by SHA-256 and MD5. If the pool cannot run the emulator the job emits ::warning:: and skips rather than failing.
  • Wiring: tsup entries, two package exports, test:retroarch, copy-assets, verify-package, check-boundary, README subsection, docs/adapters.md matrix row plus a full section (RetroArch moves out of "Candidate adapters"), CHANGELOG under ## 0.3.0.

RetroArch is not an API, so every behaviour is measured

docs/adapters.md records each one. The load-bearing ones:

Behaviour Measurement
Unset directory settings RetroArch strlcpys a NULL path during the first retro_run and segfaults. The generated config sets every directory key and copies the core into the run's own libretro_directory.
video_driver = "null" Runs headless, opens no window, and still serves SCREENSHOT. Frame hashes matched the gl driver exactly.
FRAMEADVANCE Edge triggered. Two advance datagrams in consecutive polls advance one frame, so each frame costs an advance poll and then a poll without it.
Throughput ~59 frames/s paused; ~80 with FAST_FORWARD_HOLD on the advance datagram, which removes the throttle without changing how many frames the core runs.
SAVE_STATE / LOAD_STATE A paused iteration never reaches those hotkeys; both fire only when they travel with FRAMEADVANCE, and FAST_FORWARD_HOLD on the same datagram stops them firing at all. Acknowledged through RetroArch's own log and retried.
READ_CORE_MEMORY One reply must fit one UDP datagram: 2048 bytes works, 4096 does not. Channels are covered by as few capped block reads as possible, all in one datagram.
Remote gamepad struct remote_message { int port, device, index, id; uint16_t state; }, 20 bytes, one message consumed per poll, state held until changed. Proven by observing the game react, not by reading the header.
Instances A second RetroArch will not start while one is running. One worker owns one emulator.
LOAD_STATE aftermath A state load reinitialises the video, input, and audio drivers, and that sometimes ends the process. A reset therefore replaces the emulator and restores the same pinned boot blob into the new one.
macOS AppKit blocks every launch after an unclean exit while restoring windows, and App Nap throttles a windowless application mid-run. The worker clears the saved state and names both defaults in its failure messages.

Verification

Where this is proven: Linux CI only. 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). The adapter is unproven on macOS. The gate refuses to launch an emulator on darwin ahead of every other check and skips with one line, even with the paths set and PLAYPROOF_REQUIRE_RETROARCH=1:

retroarch: skip: the RetroArch gate does not run on macOS: the x86_64 build under
Rosetta segfaults during retro_run; ...

All six CI jobs green. real-retroarch on [self-hosted, ci-linux], 7m48s:

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 64/121, all 24
channels 64/121, screen 3/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,
21/24 on half, over 120 inputs

The snapshot-agreement counts move between runs — 121/121 on one run, 64/121 on another — which is exactly why the assertion is the contract re-verification and the counts are printed. The milestones reproduce in both cases because they are >= thresholds on channels that only move forward.

pnpm run ci in the worktree — green.

playproof-boundary: 101 files checked; framework is research-harness- and provider-neutral
$ tsc -p tsconfig.json --noEmit
playproof: 22 calibration gates green + review-fix regressions green
packed Playproof imports verified
playproof-package: 94 packed entries verified

The cross-emulator result. A 4-milestone contract derived over channelsFromDiscovery(pyboy/discovery-libbet.json) verifies clean through RetroArch and gambatte against the 266-input reference discovered on PyBoy, and a script of the same length that never presses a button is rejected. Direction inputs are not a false claim here — Libbet is played with the direction pad, so a script of real directions is a worse attempt at the same game and earns milestones honestly.

Channel agreement with PyBoy's own recorded values, over 120 inputs: 23 of 24 channels track PyBoy on at least half the steps, 8 of them on 90 per cent. The one that does not samples a value that moves within a frame. A wrong address or a wrong decode would show as agreement near zero, which is the bar the gate asserts.

Determinism. The gate asserts what a verifier actually does: the contract derived in one emulator re-verifies clean in a second, separately launched one over the whole reference. Byte-for-byte equality between two boots is measured and printed, not asserted — a core reset does not clear the memory a console powers on with, and the game reads some of that residue. Zeroing every volatile Game Boy region before the reset was measured and made agreement worse, so clearRegions stays an option rather than a default. Save-state bytes are not equal between processes at the same instant, so no saveBlobHash is published; screen evidence reproduced on 3 to 4 of 121 snapshots, so screenMilestones is opt-in.

Reach proof

Nintendo DS booted, paused, and frame-stepped through the same worker — no adapter change, melonDS with TWiLightMenu's GPL BOOT.NDS:

BOOT ok  status=GET_STATUS PAUSED nds,twilight,crc32=31fbde77
  core=melonds_libretro.dylib content=twilight.nds
  frame=301 frames/step=30
  step 0 (a) ok -> frame=331 ... step 4 (a) ok -> frame=451

melonDS exposes no core memory mapREAD_CORE_MEMORY answers -1 no memory map defined — so a DS game is screen-evidence only until a core that maps memory is used. That is the per-core measurement this adapter is built to surface rather than assume.

Nintendo 64 did not boot. Mupen64Plus-Next with a BSD-licensed homebrew ROM (meeq/FlappyBird-N64 v1.7) loads the core and initialises its controllers, then never answers GET_STATUS, with video_driver set to either null or gl.

Both reach attempts were made before macOS was ruled out as a host, and neither has been repeated since; no emulator is launched on this Mac any more. Retrying N64 belongs on the Linux pool.

Deviations, with reasons

  • macOS is unsupported and the gate refuses to run there. An honest "unproven here" beats a crash loop; Linux CI is the execution evidence.
  • --config instead of --appendconfig. --appendconfig layers on the user's own retroarch.cfg, which makes a run depend on machine state. A generated full config is what makes two runs on two machines the same run, and it has to be complete: an unset directory key segfaults RetroArch inside retro_run.
  • Byte-equal cross-process evidence is reported, not asserted. Measured, with the numbers above; the contract re-verification is the assertion.
  • One worker owns one RetroArch process. A second instance will not start while one is running; dispose before booting the next.
  • An emulator that dies mid-run ends the run. Replacing it and replaying the inputs so far looks equivalent and was measured not to be, so it fails loudly. A reset may still replace a dead emulator, because a reset returns to the pinned boot state and has no evidence to invalidate.

Playproof links no emulator here. It launches the RetroArch binary the
caller names and drives it over the two UDP interfaces RetroArch already
publishes, so every core RetroArch can load becomes a Playproof game with
no Playproof code per console.

The network command interface gives FRAMEADVANCE, READ_CORE_MEMORY,
SCREENSHOT, SAVE_STATE, LOAD_STATE, and GET_STATUS. The network remote
gamepad gives per-button state. RetroArch is not an API, so each behaviour
the worker depends on is a measurement against the real binary and is
recorded in docs/adapters.md.

The gate is a cross-emulator proof, not just an emulator run: the 266-input
reference whose channel addresses a blind search found by watching PyBoy's
work RAM derives a contract that verifies clean through RetroArch and
gambatte, rejects a garbage script of equal length, and reproduces every
evidence snapshot in a separately launched emulator.
A RetroArch state load reinitialises the video, input, and audio drivers,
and that reinitialisation sometimes ends the process. The pinned boot state
plus the inputs applied since the last reset reproduce the position exactly,
so a dead emulator is replaced and caught up instead of failing the run.

Milestones are derived from memory channels only. Two separately launched
emulators reproduce every privileged channel at all 61 measured snapshots
and the screen at 37 before a fade drifts one animation step, so screen
evidence is published but pinned only under screenMilestones.
…s own output

The extracted binary exits immediately without the library path AppRun sets,
and the worker discarded the standard streams, so a RetroArch that never
reached its own log file reported nothing. Both are now captured.
…gainst

The buildbot RetroArch links jack, wayland, and EGL. Playproof drives it with
the null audio and video drivers and never calls into any of them, but the
dynamic linker still needs them present, and the pool has neither the packages
nor root. They are unpacked into a private directory instead, and the job
fails loudly rather than silently if any library is still unresolved.
The install step runs under 'set -u', so the unset accumulator aborted it,
and continue-on-error turned that abort into a skipped gate and a green job.
The null video driver initialises no input driver of its own, and on a
headless Linux host RetroArch then fails to pick one and exits with
'Cannot initialize input driver'. Playproof never uses local input: buttons
arrive over the network remote gamepad and hotkeys over the network command
interface, and neither goes through these drivers.
Libbet is played with the direction pad, so a script of real directions is
not a false claim: it is a worse attempt at the same game and it earns
milestones honestly. The rejected run now claims the whole contract while
every word it submits is unknown and therefore a no-op.
Two separately launched emulators reproduce every channel the derived
contract reads, and drift on a low-ranked counter and a low-ranked 4-byte
word that no milestone uses. The cross-process assertion now covers the
pinned channels exactly, and the gate reports agreement for the full
declared set and for the screen, so neither claim rests on hope.

CHANGELOG bullets move to a new 0.4.0 heading; 0.3.0 is released.
…gure

Measured over 120 inputs, 21 of 24 discovered channels agree with PyBoy's
recorded values on 71 to 98 per cent of steps and 8 agree on 118 of 120.
Three sample values that move within a frame and do not track. Requiring
90 per cent of steps asserted a precision two emulators cannot have; the
bar is now that most channels track, which is what a wrong address or a
wrong decode would fail.
Byte-for-byte equality between two boots is measurably not available: a core
reset does not clear the memory a console powers on with, the game reads some
of that residue, and zeroing every volatile Game Boy region before the reset
was measured to make agreement worse rather than better. Asserting it would
have been asserting something untrue.

The gate now asserts what a verifier actually does — the contract derived in
one emulator verifies clean in a second, separately launched one over the
whole reference — and prints the three agreement counts underneath it.
…r is unproven there

The RetroArch that Homebrew installs is an x86_64 build under Rosetta and it
segfaults inside an environment callback during retro_run, repeatedly and with
a crash dialog each time. The gate now refuses to launch on darwin ahead of
every other check, skips with one line even when the paths are set and
PLAYPROOF_REQUIRE_RETROARCH is on, and the docs state plainly that Linux CI is
the only execution evidence for this adapter.
Replacing a dead emulator and replaying the inputs so far looks equivalent,
because the position is a function of the boot state and the input log. It was
measured not to be: runs that replaced an emulator mid-flight produced evidence
a second replay in the same worker did not reproduce. Evidence a verifier
cannot recompute is worse than no evidence.

A reset may still replace the emulator, because a reset returns to the pinned
boot state and has no evidence to invalidate.
@drewstone
drewstone merged commit 3376761 into main Aug 21, 2026
6 checks passed
@drewstone
drewstone deleted the feat/retroarch-adapter branch August 21, 2026 06:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant