Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 143 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading