feat(adapters): drive any RetroArch core as a black-box host - #16
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jsonholds work-RAM addresses that a blind search found by watching PyBoy.channelsFromDiscoveryturns 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.ts—WorkerRpcsubclass with the boot options, identity, and checkpoint shape.adapters/retroarch.ts—makeRetroArch(...)returning{ game, contract, reference, inputs, identity, baseline, seed, dispose }, pluschannelsFromDiscovery(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=1makes a missing asset a loud failure.real-retroarchon[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.test:retroarch, copy-assets, verify-package, check-boundary, README subsection,docs/adapters.mdmatrix 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.mdrecords each one. The load-bearing ones:strlcpys a NULL path during the firstretro_runand segfaults. The generated config sets every directory key and copies the core into the run's ownlibretro_directory.video_driver = "null"SCREENSHOT. Frame hashes matched thegldriver exactly.FRAMEADVANCEFAST_FORWARD_HOLDon the advance datagram, which removes the throttle without changing how many frames the core runs.SAVE_STATE/LOAD_STATEFRAMEADVANCE, andFAST_FORWARD_HOLDon the same datagram stops them firing at all. Acknowledged through RetroArch's own log and retried.READ_CORE_MEMORYstruct 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.LOAD_STATEaftermathVerification
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 andPLAYPROOF_REQUIRE_RETROARCH=1:All six CI jobs green.
real-retroarchon[self-hosted, ci-linux], 7m48s: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 ciin the worktree — green.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
clearRegionsstays an option rather than a default. Save-state bytes are not equal between processes at the same instant, so nosaveBlobHashis published; screen evidence reproduced on 3 to 4 of 121 snapshots, soscreenMilestonesis 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:melonDS exposes no core memory map —
READ_CORE_MEMORYanswers-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-N64v1.7) loads the core and initialises its controllers, then never answersGET_STATUS, withvideo_driverset to eithernullorgl.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
--configinstead of--appendconfig.--appendconfiglayers on the user's ownretroarch.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 insideretro_run.