A PICO-8 fantasy console emulator for MiSTer FPGA with native video and audio output. The FPGA handles video timing and output directly, bypassing the MiSTer scaler for zero-lag CRT support.
- Native FPGA video output — 256×224 @ 60.10Hz, byte-for-byte identical to the MiSTer NES core's default timing. Exact NES pixel clock (5.369 MHz from NTSC colorburst crystal), 47.68 µs active time, 38 lines of vertical blanking. 128×128 source is 2× horizontal + 1.75× vertical (Bresenham 4/7) so all source pixels display at 8:7 pixel aspect — same proportions as NES games on a CRT TV
- Native FPGA audio output — 48 kHz signed 16-bit stereo via DDR3 ring buffer and dual-clock DCFIFO (same audio path as NES/SNES/Genesis cores). Audio kernel: linear interpolation at engine + wrapper (matches zepto8's
src/pico8/sfx.cpp::get_audiolinear-interp PCM math; wrapper atsrc/mister_main.cpp::upsample_mono_to_stereomirrors the engine character — both stages linear). - CRT support — scanlines, shadow masks, and analog video output for CRT displays
- MiSTer OSD integration — load .p8 and .p8.png carts from the file browser
- Hot-swap carts — load a new cart from the OSD while a game is playing
- Save states — 4 slots per cart, NES-style UX, OSD-driven and F1–F4 keyboard shortcuts (see Save States below)
- Gameplay recording & replay — record a playthrough and watch it back hands-free, from the pause menu or the MiSTer OSD (
.inpfiles); deterministic playback, press any button to take over (see Recording & Replay) - Controller support — d-pad, analog stick, and button mapping through MiSTer's input system
- Auto-launch — the emulator starts automatically when the core is loaded
- Reliable database delivery via
update_all(2026-05-26 infrastructure) — two CI guardrails on the per-core repo'sbuild.ymlensure users always receive working binaries:- Diagnostic-marker gate — research-phase instrumentation patches (
TEMPORARY PROFILE,TEMPORARY DIAG,REVERT AFTER MEASURED) are auto-detected insrc/andbios.p8; if any are present, CI rebuilds the ARM binary BUT refuses to commit it back to main. The binary uploads as a workflow artifact for manual deploy testing while the public manifest continues to ship the last known-clean build. Prevents diagnostic binaries from accidentally landing indb.json. - DB rebuild auto-trigger — after every clean binary commit-back, CI fires
gh workflow run "Build Custom Database"againstMiSTer_Frontier. The database refreshes within ~30 seconds of the binary push instead of waiting for the daily cron. Closes the previous hash-mismatch window whereraw.githubusercontent.comserved a new binary whiledb.jsonstill referenced the previous hash (which would causeupdate_allto reject the download). The auto-trigger uses a per-repoMISTER_FRONTIER_DISPATCH_TOKENsecret (one-time setup).
- Diagnostic-marker gate — research-phase instrumentation patches (
The recommended path is via the MiSTer Frontier combined database, which auto-deploys the PICO-8 core (and any other Frontier core you opt into) every time you run update_all.
Add this to /media/fat/downloader.ini on your MiSTer's SD card:
[MiSTerOrganize/MiSTer_Frontier]
db_url = https://raw.githubusercontent.com/MiSTerOrganize/MiSTer_Frontier/db/db.json.zip
filter = pico-8The filter = pico-8 line tells the downloader to install only PICO-8 from the Frontier database. Drop the filter line if you want every Frontier core, or pick others — see the Frontier README for the full filter list.
After editing downloader.ini:
- Run
update_allfrom MiSTer's Scripts menu — installs the FPGA core, ARM binary, BIOS (bios.p8), handler script, and docs - Run
Scripts/Install_MiSTer_Frontier.shonce — registers the Master Daemon that auto-launches the emulator. Idempotent - Place your
.p8or.p8.pngcarts in/media/fat/games/PICO-8/Carts/ - Load PICO-8 from the MiSTer console menu — the emulator launches automatically
Inspecting the manifest: DB Inspector for MiSTer_Frontier — every file, hash, size, and tag visible in the browser. Useful for verifying which files a given filter would install before you run update_all.
Extract the release zip to the root of your MiSTer SD card (/media/fat/):
/media/fat/
├── _Other/
│ └── PICO-8_YYYYMMDD.rbf FPGA core (dated build)
├── docs/
│ └── PICO-8/
│ └── README.md Documentation
├── games/
│ └── PICO-8/
│ ├── PICO-8 ARM binary (emulator)
│ ├── _handler.sh Master_Daemon dispatcher (auto-launches the emulator)
│ ├── bios.p8 BIOS
│ └── Carts/ Place your .p8 and .p8.png carts here
├── logs/
│ └── PICO-8/ Debug logs
├── saves/
│ └── PICO-8/ Cart-data saves (created automatically when a cart calls cartdata())
├── savestates/
│ └── PICO-8/ Save state files (created automatically — <cart>_<slot>.ss)
└── Scripts/
└── Install_MiSTer_Frontier.sh Install script (shipped by MiSTer_Frontier — unified across all Frontier cores)
- Load PICO-8 from the MiSTer console menu
- The emulator starts automatically
- Press the menu button to open the MiSTer OSD
- Select Load Cart to browse and load a cart
- Load a different cart from the OSD at any time during gameplay
| Feature | PICO-8 |
|---|---|
Saves (cartdata .p8d.txt) |
✅ |
Savestates (4 slots/cart, NES-style .ss) |
✅ |
Logs (/media/fat/logs/PICO-8/pico8.log) |
✅ |
Configs (/media/fat/config/) |
✅ |
MGLs (_Other/*.mgl one-click launchers) |
✅ |
| Gameplay Recordings / TAS | ✅ .inp recordings — 8 slots per cart, reachable from the pause menu or the MiSTer OSD ("Replay Slot" + "Play Replay"), plus "Load Replay" for a file someone sent you; deterministic, hands-free, press any button to take over (see Recording & Replay) |
| Gamepad | ✅ |
| Keyboard | ✅ (cart browser; blocked when gamepad connected) |
| Mouse | ❌ no (PICO-8 spec exists but zepto8 hasn't wired stat(32,33,34)) |
| Screen Positioning (CRT) H ±3 / V ±3 | ✅ |
| Online Network Play | ❌ |
| Multiplayer | ✅ up to 4 players (FPGA hps_io joystick_0..3 slots; cart determines actual usage) |
| Light Gun | ❌ |
| Aspect Ratio (Original / Full Screen / Custom1 / Custom2) | ✅ |
| Scale Mode (Normal / V-Int / HV-Int integer scaling) | ✅ |
| Swap Joysticks (P1↔P2) | ✅ |
| Stereo Mix (None / 25 / 50 / 100% channel cross-bleed) | ❌ n/a (mono source — zepto8 outputs L=R, cross-bleed is a no-op) |
Note -- Aspect Ratio, Scale Mode, Swap Joysticks, and (on OpenBOR) Stereo Mix shipped 2026-06-08, bringing these cores in line with mainstream MiSTer console-core OSD options.
| Xbox wireless | PICO-8 action | Notes |
|---|---|---|
| D-pad / Left stick | Movement (4-way) | analog stick has ARM-side dead zone for drift |
| A button | O (confirm / primary action) | |
| X button | X (cancel / secondary action) | |
| Menu / Start | Pause / system menu | opens PICO-8's built-in pause menu |
| Xbox Guide (center) | MiSTer OSD | core's OSD overlay (Load Cart, Save State, etc.) — framework-level, not per-core |
CONF_STR: J1,O,X,Pause; / jn,B,Y,Start;. MiSTer's jn extension uses SNES naming (jn A=Xbox B, jn B=Xbox A, jn X=Xbox Y, jn Y=Xbox X), so the defaults above pair jn B (Xbox A) → PICO-8 O and jn Y (Xbox X) → PICO-8 X.
Same UX as the MiSTer NES core — 4 slots per cart, OSD pause-menu driven, with optional F1–F4 keyboard shortcuts.
Via the MiSTer OSD pause menu:
- Open the MiSTer OSD (Menu button on your controller).
- Pick Save Slot to choose slot 1, 2, 3, or 4.
- Pick Save State to save the current cart's state to that slot.
- Pick Load State to restore the saved state from the selected slot.
Via keyboard shortcuts (USB keyboard plugged into the MiSTer):
| Key | Action |
|---|---|
| F1 | Load slot 1 |
| F2 | Load slot 2 |
| F3 | Load slot 3 |
| F4 | Load slot 4 |
| Alt + F1 | Save to slot 1 |
| Alt + F2 | Save to slot 2 |
| Alt + F3 | Save to slot 3 |
| Alt + F4 | Save to slot 4 |
/media/fat/savestates/PICO-8/<cart>_<slot>.ss
For example, saving slot 1 of a cart called vracing_title.p8 produces /media/fat/savestates/PICO-8/vracing_title_0.ss (slots are zero-indexed in the filename: slot 1 → _0, slot 2 → _1, etc.).
Save state files are independent of regular cart save data (.p8d.txt files in /media/fat/saves/PICO-8/). The two systems coexist — cartdata() persistence works normally for carts that use it.
- Full cart RAM (graphics, sound, music, code, persistent / cartdata region)
- Cart's currently-active globals (
_init,_update,_draw, all variables) - Audio sequencer state (which sfx/music is playing, channel positions, fade)
- Pause menu items (including any cart-defined
menuitem()callbacks) - For multicart games: the currently-active sub-cart filename, so loads restore to the right sub-cart
Most multicart games (e.g., Pico Sonic) work transparently — save and load operate on whatever sub-cart is currently active and the state is preserved across sub-cart transitions in-game.
Known UX wart for cart-mismatch loads: if you save while on one sub-cart, then change carts (via the in-cart Reset Cart pause menu, or by hot-swapping a different cart from the OSD), then press Load State — the first Load press will reload the saved cart but show its default state, and you need to press Load State a second time for the saved state to fully restore. This is unavoidable in PICO-8's multicart model: many sub-carts only run correctly when started by their parent cart's load("subcart", breadcrumb, params) call (which passes state through params/breadcrumb). Forcing the sub-cart to start standalone crashes it. The two-load workflow is the lesser evil.
If you save+load on the same sub-cart without changing carts in between, no double-press is needed. The two-load behavior only affects the cart-mismatch case.
Save states have been verified working on:
- Single-cart games (Adventure Time World 2, A Hat on Time, A Small Dragon Kid Game, etc.)
- Multicart games on the same sub-cart (Pico Sonic, Virtua Racing, Freezing Knights)
- Multicart games across sub-cart transitions (with the two-load behavior described above)
Audio resumes from saved music position, visuals resume from saved frame, gameplay continues from saved state.
Record a playthrough and watch it back — deterministic, so a recording plays exactly what you did. There are two ways to play a recording back.
Why this is handy — and a great debugging tool. A recording replays exactly
what you did, every time, and you can replay it endlessly. Great for saving and
re-watching a favorite run — but also a powerful way to find and fix problems: if
you hit a bug, glitch, or crash, record the run that causes it and it will
reproduce the same thing on demand, hands-free — no need to remember or replay it
by hand. That makes an issue easy to pin down and easy to confirm once it's fixed
(replay the same recording and see if it's gone). The .inp file captures the whole
run, so you can keep it or share it.
Recordings are saved as .inp files in /media/fat/replays/PICO-8/
— a top-level folder alongside saves/ and savestates/, one per core.
You get 8 slots per cart, the same idea as savestates: pick a slot in the
Recording menu with left/right, and Record saves into it. The menu tells you
whether a slot is already used before you commit, and names the slot it
replaced afterwards. Each .inp is stamped with the cart it was recorded on and
the engine version. A recording only plays on its own cart — if you load a replay
while a different cart is loaded, it won't start (load the matching cart first).
And if a later core update changes the game logic, an old replay may drift (a note
is logged) — just press any button to take over.
From the pause menu (START → Recording):
- Record — restarts the cart and records everything from its first frame through your play. (It records from the start so playback can reproduce the run exactly.)
- slot N of 8 — left/right to choose which slot Record writes and Play reads. It shows used or empty so you know before you overwrite.
- Stop Recording — saves the recording into the chosen slot and drops you back into the game.
- Play Recording — restarts the cart and plays the chosen slot back hands-free, driving through menus into the game on its own.
- Take over any time — during playback, just press any button and the automated inputs stop instantly so you can play. A button you were already holding when playback started doesn't count, so a resting thumb or a drifting stick won't cut a replay short.
From the MiSTer OSD (a second way to reach the same eight slots):
-
Replay Slot — pick 1-8. This is the same slot the pause menu shows: move it in either place and the other follows, so you are never picking a slot in one place and recording to another.
One deliberate exception: while a recording or replay is running, the slot stops following. Whatever slot the take started on stays its target until it ends, so that moving the picker mid-take cannot silently retarget Stop onto a different slot and overwrite the recording sitting there. The OSD picker will still move while you turn it — it just is not the live target until the take finishes.
-
Play Replay — plays that slot. The cart restarts and the recording runs hands-free (press any button to take over). If the slot is empty it says so and leaves your game alone, rather than restarting the cart to tell you.
Record and Stop deliberately stay in the pause menu: Record restarts the cart, which is not something to put one OSD click away.
Also from the OSD, for a recording that isn't in one of your slots:
- Load Cart — pick the cart you want.
- Load Replay — pick a
.inpfile directly. This is mainly for a recording someone sent you, since your own eight are already one button-press away. You can re-load the same recording as many times as you like.
A
.inpis not only your button presses — it also carries the save data for the cart you recorded, so that anyone replaying it starts from the same progress you did. Without that, a recording made partway through a game cannot reproduce on someone else's machine.So when you share a recording, you are sharing that cart's save data with it.
And the reverse: only replay recordings from people you trust. Playing one restores the save data it carries before the cart starts. It is kept separate from your own saves — yours are never modified — but it is still someone else's data running on your machine.
Your saved progress is kept, and is never altered by recording. When you hit
Record, the cart restarts from the beginning — but your existing save data is
carried in, so you can load your progress through the cart's own menus and record
from wherever you actually are. That navigation is part of the recording, so
playback reproduces it. The save data as it stood at that moment is stored
alongside the recording and restored when you play it back, which is what keeps
the run exact. Your real saves under /media/fat/saves/PICO-8/ are only ever
read, never written, during a recording or a playback.
A few things end a recording, all deliberately: quitting, switching to a different cart from the OSD, and loading a save state (a save state jumps the game to a different point, which a recording can't represent). Resetting the cart does not end it — it restarts the take from the beginning, since that's almost always what you want after a bad run.
Playback is accurate frame for frame: recording and replay both start from a cart restart, the random-number seed is captured and restored, and the emulator advances exactly one frame per displayed frame, so the run reproduces identically regardless of load.
Carts using the standard PICO-8 API (sfx(), music(), print(), pset(), spr(), etc.) are supported, as are carts using zepto8's extension features: extended memory at 0x8000–0xFFFF (used by carts that allocate their own page buffers), raster-mode gradient palette (poke(0x5f5f, 0x30)), per-scanline raster bits, PCM streaming via serial(0x808) for carts that do their own audio mixing in pure Lua, custom drawstring blend formulas using fix32 arithmetic, and multicart load() chains.
Notable verified carts: POOM, Pico Sonic, Virtua Racing, Freezing Knights, Adventure Time World 2, and Another World (Eric Chahi's classic ported by @fsouchu — full visual + audio parity with the PC reference, exercises extended-memory page buffers, a pure-Lua 4-channel PCM mixer, and raster-mode gradient sky; pause/unpause preserves the cart's palette mapping so the sky stays correctly colored). For Another World, MiSTer save states aren't supported because the cart's state model (bytecode VM, mid-__reload bank streaming, page-buffer closures) exceeds what NES-style save states can faithfully capture — use the cart's built-in ACCESS CODE save system instead (e.g., the "LDKD" prompt) for cross-session progress.
PICO-8 ships with video timing identical to the MiSTer NES core's default output: 256×224 active, 262 lines total, 60.10 Hz refresh, exact NES pixel clock. Game pixels render at 8:7 pixel aspect (slightly wider than tall) — the same proportions an NES or SNES has on a CRT TV. The 128×128 PICO-8 source is doubled horizontally to 256 and Bresenham-scaled vertically (1.75×) to 224. No source pixels are cropped.
The result: PICO-8 games occupy the same physical area on a CRT as NES games, with identical scan timing, identical safe-area margin (38 lines of vertical blanking), and identical CRT lock behavior. Consumer NTSC CRTs that show NES games correctly will show PICO-8 games correctly. PVM/BVM users get the same image size they'd see for a real NES cart.
Because each game pixel is slightly wider than tall (8:7), a perfect circle drawn in cart coordinates displays about 12% flatter on a CRT than it does in desktop PICO-8, where pixels are square. Sprite and tile art wears this naturally -- it is the authentic NES/SNES look. But if your cart draws geometry-critical vectors (circles, wireframes, tubes), you can counter-scale: stretch your rendering vertically by 8/7 (about 1.14x), or adjust your camera/projection by the same factor, and shapes will land geometrically true on the CRT. Vector carts can make this a settings toggle; it costs nothing at the engine level and needs no support from the core.
The OSD has H Position (CRT) and V Position (CRT) options (±3 pixels) for fine-tuning image centering on individual CRTs. These adjust the timing porches so the picture shifts on the tube without changing refresh rate. No effect on HDMI (the scaler auto-centers).
If you're on HDMI, Direct Video, or VGA-via-scaler and want extra margin around the image, set vscale_border=N in MiSTer.ini (1–399 pixels). Framework-level border, only applies through the MiSTer scaler — bypassed by pure 15kHz analog out of the FPGA.
Hybrid core: FPGA handles video/audio output and controller input, ARM CPU runs the PICO-8 emulator (zepto8).
- ARM renders 128×128 RGBA frames → RGB565 → DDR3
- FPGA reads DDR3, scales 128×128 → 256×224 (2× H, 1.75× V via Bresenham), outputs native video (15,746 Hz horizontal, exact NES timing)
- Audio — ARM writes 48 kHz S16 stereo to DDR3 ring buffer, FPGA reads and outputs via I2S/SPDIF/DAC
- Controller — USB → Main_MiSTer → hps_io → FPGA → DDR3 → ARM
- Cart loading — OSD file browser → hps_io ioctl → FPGA → DDR3 → ARM
Built automatically by CI using QEMU ARM emulation with arm32v7/debian:bullseye-slim. Push to main to trigger.
Requires Intel Quartus Prime Lite 17.0. Project in fpga/, RTL in fpga/rtl/, framework in fpga/sys/ (DO NOT MODIFY sys/).
- zepto8 — PICO-8 emulator by Sam Hocevar (WTFPL license)
- 3SX MiSTer — reference architecture for ARM-to-FPGA native video
- MiSTer FPGA — open-source FPGA retro platform
GPL-3.0