Skip to content

[Feature] Add browser PICO-8 player - #4400

Open
gantoine wants to merge 11 commits into
masterfrom
pico8-browser-player
Open

[Feature] Add browser PICO-8 player#4400
gantoine wants to merge 11 commits into
masterfrom
pico8-browser-player

Conversation

@gantoine

@gantoine gantoine commented Sep 8, 2026

Copy link
Copy Markdown
Member

Description

Adds v2 browser playback for PICO-8 cartridges (.p8 and .p8.png), backed by the FAKE-08 WebAssembly runtime: canvas rendering, audio, fullscreen, and keyboard, gamepad, mouse and touch input. Platform detection, play actions, routing, localization, gallery navigation, play-session tracking and lifecycle cleanup are wired up alongside the existing browser players.

This reopens #4383 (closed) with the security review feedback applied. The player code is carried over from that PR, with three changes:

1. The runtime is fetched at build time instead of committed

#4383 committed fake08.js (69 KB) and fake08.wasm (604 KB) into frontend/public/, which would have been the first tracked binaries in the repo. They are now fetched in the emulator-stage of docker/Dockerfile, pinned by source commit and verified with sha256sum -c, exactly how EmulatorJS, Ruffle and js-dos are already provisioned:

ARG FAKE08_P3A_COMMIT=6519efd9dd1ca853e5c66f7ae9146ace0b7073dc
ARG FAKE08_JS_SHA256=fd2cd467...da463e
ARG FAKE08_WASM_SHA256=4339a77e...f4c93a

Upstream jtothebell/fake-08 publishes no web build (its latest release ships only console homebrew targets, and there is no emscripten target in-tree), so the browser artifacts come from fabkury/p3a, which compiled them. Since that tree has no tagged releases, the pin is by commit plus checksum, which makes any future re-vendor a reviewable, verifiable event. The MIT/Apache-2.0 attribution that #4383 shipped as a served NOTICE file now lives as a comment next to the fetch.

Two side effects worth noting: the 604 KB wasm no longer lands in the PWA precache manifest, and PICO-8 assets are now image-provisioned like the other emulator runtimes (so, like them, they are not present under npm run dev).

2. DISABLE_PICO8 kill switch

Every other browser player has a server-side toggle (DISABLE_EMULATOR_JS, DISABLE_RUFFLE_RS, DISABLE_JSDOS). In #4383 isPico8EmulationSupported accepted the heartbeat and discarded it, so an admin who had disabled all browser emulation still got a live PICO-8 player. Added DISABLE_PICO8 through env.template, config, the heartbeat schema and endpoint, the heartbeat store, and the frontend gate, with test coverage on both sides.

3. Fixes that would have failed CI

  • Locale keys were inserted out of order, failing check_i18n_sorted.py. Sorted.
  • Pico8.vue import order was not trunk fmt clean.
  • Added the missing JSDoc on isPico8EmulationSupported / isPico8Rom to match the sibling helpers.

Security review notes

The player code itself came back clean, and is tighter than the incumbents: it has no CDN fallback (Ruffle, js-dos and EmulatorJS all fall back to third-party CDNs), the only network call is the same-origin ROM download, locateFile is pinned to a module constant, and the cart goes into linear memory via _malloc rather than the virtual FS. No auth or ownership check was weakened.

On the runtime blob: its 28 wasm imports are all libc/WASI shims and longjmp trampolines, with no network, DOM, or arbitrary-JS import, and the glue's only sinks are Emscripten's own same-origin fetch/XHR loaders. The wasm has zero custom sections and its data section ends exactly at EOF, so there is no appended payload. The bytes were verified identical to the p3a tree as of 2026-05-03, four months before #4383 was authored.

Checklist

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

Verification

  • npm run typecheck passed
  • npx vitest run on the affected specs: 80 passed
  • npm run build passed
  • check_i18n_locales.py and check_i18n_sorted.py both pass
  • PYTEST_XDIST_WORKER=... uv run pytest tests/endpoints/test_heartbeat.py: 21 passed
  • trunk fmt && trunk check: no issues
  • The Dockerfile fetch step was run in alpine:3: both downloads succeed and both checksums verify

AI assistance disclosure

The PICO-8 player implementation was carried over from #4383, which disclosed being implemented with OpenAI Codex. The changes in this PR (build-time fetch, kill switch, CI fixes) and the security review behind them were done with Claude Code, driven and reviewed by me.

Closes #3278

🤖 Generated with Claude Code

Add v2 browser playback for .p8 and .p8.png cartridges, backed by the
FAKE-08 WebAssembly runtime, with canvas rendering, audio, fullscreen and
keyboard, gamepad, mouse and touch input. Wires up platform detection,
play actions, routing, localization, gallery navigation, play-session
tracking and lifecycle cleanup.

The runtime is fetched in the emulator stage of docker/Dockerfile, pinned
by source commit and verified with sha256, so it is provisioned the same
way as EmulatorJS, Ruffle and js-dos rather than committed to the repo.

DISABLE_PICO8 gates the player, matching the existing DISABLE_EMULATOR_JS,
DISABLE_RUFFLE_RS and DISABLE_JSDOS toggles.

Based on #4383.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 8, 2026 22:00
@greptile-apps

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds browser playback for .p8 and .p8.png cartridges using a checksum-pinned FAKE-08 WebAssembly runtime.

  • Adds the PICO-8 route, player UI, rendering, audio, and keyboard/gamepad/pointer controls.
  • Integrates cartridge eligibility, play actions, platform indicators, gallery provenance, sessions, localization, and lifecycle cleanup.
  • Adds a server-provided DISABLE_PICO8 capability flag and provisions runtime assets during full-image builds.
  • The published slim image currently advertises the feature without containing its required runtime assets.

Confidence Score: 4/5

The PR is not ready to merge because PICO-8 playback is enabled but nonfunctional in the published slim image.

The slim image omits the only available FAKE-08 runtime while continuing to expose PICO-8 as supported, so valid playback attempts reliably fail there; the canvas also has a lower-impact stuck-input edge case.

Files Needing Attention: docker/Dockerfile, frontend/src/v2/views/Player/Pico8.vue

Fix all with Greploop Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
docker/Dockerfile:319
**Slim image lacks runtime**

The published slim image does not receive the FAKE-08 assets, but its heartbeat still advertises PICO-8 support by default. Selecting a valid PICO-8 cartridge in a slim deployment requests the missing `/assets/pico8/fake08.js`, so playback fails at startup. Provision these assets in the slim image or ensure PICO-8 is unavailable there.

### Issue 2
frontend/src/v2/views/Player/Pico8.vue:174
**Pointer capture leaves input stuck**

The canvas takes pointer capture when a mouse button is pressed but does not handle `lostpointercapture`. If another element takes capture before release, the `pointerup` is delivered elsewhere and `mouseButtons` remains set, leaving the cartridge with a stuck mouse button until the window blurs or playback ends. Reset the mouse state when capture is lost, as the touch controls already do.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat: add browser PICO-8 player" | Re-trigger Greptile

Comment thread docker/Dockerfile
Comment thread frontend/src/v2/views/Player/Pico8.vue

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new PICO-8 view includes hard-coded accessibility strings and a hex color literal (v2 token policy violation), and the runtime wrapper should guard against _malloc allocation failure.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a new v2 in-browser player for PICO-8 cartridges (.p8, .p8.png) using the FAKE-08 WASM runtime, integrates it into routing and play-action selection, and introduces a DISABLE_PICO8 admin kill switch surfaced via heartbeat across backend and frontend.

Changes:

  • Add a new v2 PICO-8 player view and a small runtime wrapper (with unit tests) to boot/render frames, handle input, and play audio.
  • Wire PICO-8 into v2 routing, “can play” gating, tooltips, gallery provenance, and view transitions.
  • Provision the FAKE-08 runtime at image build-time (pinned by commit + sha256) and add DISABLE_PICO8 through config, heartbeat schema, docs, and tests.
File summaries
File Description
frontend/src/v2/views/Player/pico8Runtime.ts New FAKE-08 runtime wrapper for loading carts, stepping frames, rendering, and audio extraction.
frontend/src/v2/views/Player/pico8Runtime.test.ts Vitest coverage for runtime wrapper behavior (frame mapping + audio).
frontend/src/v2/views/Player/Pico8.vue New v2 PICO-8 player view (canvas stage, input, audio scheduling, lifecycle cleanup).
frontend/src/v2/router/routes.ts Registers pico8 as a lazily loaded v2 route component.
frontend/src/v2/composables/useViewTransition/index.ts Adds PICO-8 route to hero morph-name transitions.
frontend/src/v2/composables/usePlatformPlayable/index.ts Adds PICO-8 as a resolvable browser emulator and tooltip key.
frontend/src/v2/composables/usePlatformPlayable/index.test.ts Tests PICO-8 emulator resolution and tooltip key selection.
frontend/src/v2/composables/useGameActions/index.ts Adds PICO-8 into browser-play gating and route selection precedence.
frontend/src/v2/composables/useGameActions/index.test.ts Tests SPA navigation behavior for PICO-8 play action.
frontend/src/v2/composables/useGalleryProvenance/index.ts Adds PICO-8 route to the “chain routes” set for gallery provenance.
frontend/src/v2/composables/useGalleryProvenance/index.test.ts Updates provenance test flow to include a PICO-8 play session.
frontend/src/v2/composables/useCanPlay/index.ts Adds canPlayPico8 and gates it on platform support plus .p8/.p8.png extension.
frontend/src/v2/composables/useCanPlay/index.test.ts Adds tests for PICO-8 gating and extension filtering.
frontend/src/utils/index.ts Adds isPico8EmulationSupported + isPico8Rom and respects DISABLE_PICO8 in heartbeat.
frontend/src/utils/index.test.ts Tests PICO-8 support detection, kill switch behavior, and extension handling.
frontend/src/stores/heartbeat.ts Adds DISABLE_PICO8 to the default heartbeat shape.
frontend/src/plugins/router.ts Adds the /rom/:rom/pico8 route.
frontend/src/locales/en_US/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/en_GB/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/bg_BG/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/cs_CZ/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/de_DE/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/es_ES/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/fr_FR/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/hu_HU/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/it_IT/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/ja_JP/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/ko_KR/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/pl_PL/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/pt_BR/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/ro_RO/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/ru_RU/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/tr_TR/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/zh_CN/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/locales/zh_TW/platform.json Adds tooltip text key for PICO-8 browser playability.
frontend/src/generated/models/EmulationDict.ts OpenAPI-generated type update to include DISABLE_PICO8.
env.template Adds DISABLE_PICO8 environment variable.
docs/FRONTEND_ARCHITECTURE.md Documents the new heartbeat emulation flag.
docs/BACKEND_ARCHITECTURE.md Documents the new backend env var toggle.
docker/Dockerfile Fetches FAKE-08 artifacts at build time (pinned by commit + sha256) and copies them into web assets.
backend/tests/endpoints/test_heartbeat.py Extends heartbeat endpoint test to assert DISABLE_PICO8 exists and is boolean.
backend/endpoints/responses/heartbeat.py Extends heartbeat response schema types with DISABLE_PICO8.
backend/endpoints/heartbeat.py Adds DISABLE_PICO8 to the heartbeat payload.
backend/config/init.py Loads DISABLE_PICO8 from environment.
Review details

Files not reviewed (1)

  • frontend/src/generated/models/EmulationDict.ts: Generated file

Suppressed comments (1)

frontend/src/v2/views/Player/Pico8.vue:430

  • Several aria-label/title strings for the control surface are built from hard-coded English (e.g. "PICO-8 controls", and per-button labels like PICO-8 ${control.label}). These should be localized via t(...) so screen readers and tooltips match the active locale, with the keys added to all locale files.
  • Files reviewed: 43/44 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread frontend/src/v2/views/Player/pico8Runtime.ts Outdated
Comment thread frontend/src/v2/views/Player/Pico8.vue Outdated
Comment thread frontend/src/v2/views/Player/Pico8.vue
gantoine and others added 10 commits September 8, 2026 18:17
Render through a packed-RGBA palette lookup and a 32-bit pixel view, so a
paint is two stores per framebuffer byte instead of eight clamped byte
writes, and rebind the heap views only when wasm memory grows.

Separate `advance` from `render` so a catch-up tick emulates several frames
but paints once, rather than discarding up to two full frames of pixel work
exactly when the machine is already behind.

Load the runtime and fetch the cart in parallel, reuse one input struct
instead of allocating per step, and skip disconnected gamepads, whose stale
analog values press buttons on their own in Firefox (#3851).

Replace the hand-rolled liveness flag with useIsAlive, take the canvas
geometry and the canvas background token from their existing homes, and move
the pad, axis and mouse-button mappings next to the keyboard map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The canvas, the control group and the four d-pad buttons shipped English
aria-labels and tooltips. The parity check cannot see a key that was never
added, so nothing caught them.

The face buttons keep A and B as their own accessible name: those are the
canonical PICO-8 button names and are not translated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cut the comment that compared the pixel loop to the byte-store version it
replaced, and the two three-line notes on the runtime fetch and the script
loader, keeping only what a reader cannot recover from the code.

Rename padButtonMap, padAxisMap and mouseButtonMap to *Bits: they are pair
lists of masks, not records like keyboardMap next to them.

Give the test's fake canvas context a real ImageData return so it fails if
the runtime starts reading width or height, and drop the imports that
change orphaned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fetch EmulatorJS, Ruffle, js-dos and FAKE-08 in the dev Dockerfile, on the
same pins and checksums as the emulator stage of docker/Dockerfile, into
/opt/romm/emulators. The ./frontend bind mount hides anything the build
writes under /app/frontend, so entrypoint.sh links each runtime into the
assets tree the dev server serves, leaving names held by checked-in art
alone. The runtime directories join the dev watcher's ignore list so
chokidar does not crawl them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
isPico8Rom read fs_extension, which holds only "png" for a .p8.png cart:
the backend builds multi-part extensions from letter-only segments and "p8"
has a digit. So no cartridge stored that way offered a play action. Read the
file name instead, and assert against the values the scanner really writes.

Size the screen from the row it sits in rather than from vmin, so the square
plus the on-screen controls always fit: the stage overflowed on shorter
viewports and centred overflow clipped it at the top and the bottom at once.
Keep the controls clear of the bottom tab bar, which overlays that corner on
sm-and-down.

Move the stage chrome into a bottom-right stack of icon-only buttons, a
fullscreen toggle over exit, via a new PlayerShell `stage-actions` slot. The
other players keep the shell's own quit button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Another element taking pointer capture mid-press left the cartridge with a
stuck mouse button, and a FAKE-08 build with ABORTING_MALLOC disabled would
hand back a null pointer we wrote through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d state

Audio ran on the main thread and rebuilt the graph every frame: an AudioBuffer
and a one-shot AudioBufferSourceNode per frame, roughly 18k nodes and 80MB of
garbage per ten minutes of play. A single AudioWorkletNode now drains a ring
buffer on the audio thread, so playback survives a stalled main thread. The
route gets no COOP/COEP, so samples cross by transfer rather than through a
SharedArrayBuffer, and the worklet hands each buffer straight back for the
player to refill: 231 posts and 231 recycles over a four-second cart, so
steady-state playback allocates nothing. The runtime fills a caller-owned
buffer instead of returning a fresh slice, which drops the last copy.

Frame pacing moves to pico8Pacer, and audio scheduling to pico8Audio, so the
two pieces most likely to be wrong are unit-testable without a component. Both
had no coverage while the wasm bindings did; they now have 19 tests between
them, including the catch-up cap and the buffer recycling.

useGamepad keeps the knowledge about raw pad state, so export isUsablePad,
PAD_BUTTON and AXIS_THRESHOLD rather than restating them here. The button
index map is now keyed by name, with the event-detail lookup derived from it.

Vite inlines assets under 4KB as data: URIs, which addModule cannot be relied
on to accept, so the worklet is pinned to a real file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The player gathered keyboard, gamepad, on-screen and mouse state inline, so
the bit tables, the btnp edge detection and the pad polling all sat in the
SFC with no way to test them. pico8Input owns that state and hands back the
per-frame snapshot; the view keeps only DOM plumbing, which drops its script
block by 92 lines.

The mouse scaling takes an offset and the canvas size rather than an event,
so the clamping is exercised without a DOM. 23 tests cover the edge
detection, the dead zone, the #3851 phantom-pad guard, the pointer-button
masks and the reset a replay depends on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

[Feature] Play PICO-8 games directly in RomM

3 participants