[Feature] Add browser PICO-8 player - #4400
Conversation
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>
Greptile SummaryThis PR adds browser playback for
Confidence Score: 4/5The 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 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 |
There was a problem hiding this comment.
🟡 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_PICO8through 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/titlestrings for the control surface are built from hard-coded English (e.g. "PICO-8 controls", and per-button labels likePICO-8 ${control.label}). These should be localized viat(...)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.
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>
Description
Adds v2 browser playback for PICO-8 cartridges (
.p8and.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) andfake08.wasm(604 KB) intofrontend/public/, which would have been the first tracked binaries in the repo. They are now fetched in theemulator-stageofdocker/Dockerfile, pinned by source commit and verified withsha256sum -c, exactly how EmulatorJS, Ruffle and js-dos are already provisioned: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
NOTICEfile 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_PICO8kill switchEvery other browser player has a server-side toggle (
DISABLE_EMULATOR_JS,DISABLE_RUFFLE_RS,DISABLE_JSDOS). In #4383isPico8EmulationSupportedaccepted the heartbeat and discarded it, so an admin who had disabled all browser emulation still got a live PICO-8 player. AddedDISABLE_PICO8throughenv.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
check_i18n_sorted.py. Sorted.Pico8.vueimport order was nottrunk fmtclean.isPico8EmulationSupported/isPico8Romto 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,
locateFileis pinned to a module constant, and the cart goes into linear memory via_mallocrather 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
Verification
npm run typecheckpassednpx vitest runon the affected specs: 80 passednpm run buildpassedcheck_i18n_locales.pyandcheck_i18n_sorted.pyboth passPYTEST_XDIST_WORKER=... uv run pytest tests/endpoints/test_heartbeat.py: 21 passedtrunk fmt && trunk check: no issuesalpine:3: both downloads succeed and both checksums verifyAI 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