Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a907516
feat(pico8): add PICO-8 (femto8)
finger563 Oct 5, 2026
982271f
fix(pico8): pace the audio task on the bytes the BSP actually queued
finger563 Oct 5, 2026
5537eec
fix(pico8): clear static-analysis findings; exclude femto8 from the scan
finger563 Oct 5, 2026
be209ac
chore(pico8): suppress cppcheck findings in the vendored femto8 tree
finger563 Oct 5, 2026
88b595a
fix(pico8): gate audio on femto8's memory; any-button "press any key"…
finger563 Oct 6, 2026
10a7345
fix(pico8): PICO-8 compatibility: _ENV fallback to the API, lenient s…
finger563 Oct 6, 2026
f9b80cc
fix(pico8): clip sspr() to the visible area; load() of sibling carts;…
finger563 Oct 6, 2026
3427010
perf(pico8): PICO-8 RAM in internal SRAM; say "could not find cart" once
finger563 Oct 6, 2026
0774997
perf(pico8): pooled Lua allocator; API profiler (PICO8_PROFILE)
finger563 Oct 6, 2026
607b348
perf(pico8): faster circfill/ovalfill, hline and cls; Lua slabs from …
finger563 Oct 6, 2026
db7914f
docs(pico8): compatibility work, performance envelope and limits; pro…
finger563 Oct 6, 2026
eac44c7
feat(pico8): save states
finger563 Oct 7, 2026
af52db7
fix(pico8): no gamepad input reaches the cart while the emulator menu…
finger563 Oct 7, 2026
2cd8e60
docs(pico8): save states
finger563 Oct 7, 2026
af4745a
fix(pico8): three present buffers; well-defined fix32 rotates (review)
finger563 Oct 9, 2026
2cb7f7d
fix(pico8): synchronize the audio task with cart shutdown (review)
finger563 Oct 9, 2026
6ce4499
fix(pico8): bounds-checked cart decompression; unsigned fix32 shifts …
finger563 Oct 9, 2026
23af3a2
fix(pico8): input-bounded cart decompression; atomic frame publicatio…
finger563 Oct 9, 2026
aa50793
fix(pico8): keep the Lua VM out of IRAM
finger563 Oct 9, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/static_analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ jobs:
use_cmake: false

# (Optional) cppcheck args
cppcheck_args: -i$GITHUB_WORKSPACE/components/gbc/gnuboy -i$GITHUB_WORKSPACE/components/nes/nofrendo -i$GITHUB_WORKSPACE/components/msx/fmsx -i$GITHUB_WORKSPACE/components/doom/prboom -i$GITHUB_WORKSPACE/components/sms/smsplus -i$GITHUB_WORKSPACE/components/genesis/gwenesis -i$GITHUB_WORKSPACE/components/darkforces/tfe -i$GITHUB_WORKSPACE/components/gui/generated -i$GITHUB_WORKSPACE/components/menu/generated -i$GITHUB_WORKSPACE/components/jpegdec --check-level=exhaustive --force --enable=all --inline-suppr --inconclusive --platform=mips32 --std=c++17 --suppressions-list=$GITHUB_WORKSPACE/suppressions.txt
cppcheck_args: -i$GITHUB_WORKSPACE/components/gbc/gnuboy -i$GITHUB_WORKSPACE/components/nes/nofrendo -i$GITHUB_WORKSPACE/components/msx/fmsx -i$GITHUB_WORKSPACE/components/doom/prboom -i$GITHUB_WORKSPACE/components/sms/smsplus -i$GITHUB_WORKSPACE/components/genesis/gwenesis -i$GITHUB_WORKSPACE/components/darkforces/tfe -i$GITHUB_WORKSPACE/components/gui/generated -i$GITHUB_WORKSPACE/components/menu/generated -i$GITHUB_WORKSPACE/components/jpegdec -i$GITHUB_WORKSPACE/components/pico8/femto8 --check-level=exhaustive --force --enable=all --inline-suppr --inconclusive --platform=mips32 --std=c++17 --suppressions-list=$GITHUB_WORKSPACE/suppressions.txt
9 changes: 9 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ set(DOOM_COMPONENTS "doom")
### DARK FORCES ###
set(DARKFORCES_COMPONENTS "darkforces")

### PICO-8 ###
set(PICO8_COMPONENTS "pico8")

### SNES ###
# set(SNES_COMPONENTS "snes")

Expand Down Expand Up @@ -78,6 +81,11 @@ if(DARKFORCES_COMPONENTS)
add_compile_definitions(ENABLE_DARKFORCES)
endif()

# if PICO8_COMPONENTS is set, add compile definitions for PICO-8
if(PICO8_COMPONENTS)
add_compile_definitions(ENABLE_PICO8)
endif()

# make the components list for the emulators we want
set(EMULATOR_COMPONENTS
${NES_COMPONENTS}
Expand All @@ -88,6 +96,7 @@ set(EMULATOR_COMPONENTS
${GENESIS_COMPONENTS}
${DOOM_COMPONENTS}
${DARKFORCES_COMPONENTS}
${PICO8_COMPONENTS}
)

set(
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ that removes the frame-rate cap for maximum speed.
| MSX 1 / 2 | [fmsx](https://fms.komkon.org/fMSX/) | — | D-Pad / A / B / Start / Select |
| Doom | [prboom](https://prboom.sourceforge.net/) | — | Full speed with audio **and haptic feedback** |
| Dark Forces | [The Force Engine](https://theforceengine.github.io/) (via [BSzili's Amiga port](https://github.com/BSzili/TheForceEngine/tree/amiga)) | — | Software renderer, iMuse sound effects and OPL3 music; see [Dark Forces setup](#dark-forces-setup) |
| PICO-8 | [femto8](https://github.com/benbaker76/femto8) | — | `.p8` and `.p8.png` carts; D-Pad, A/Y = O, B/X = X, START = PICO-8 pause menu; save states; cartdata saves to `pico8/cdata/`; see [PICO-8 setup](#pico-8-setup) |

> 🧬 **Full-speed Genesis on the S3.** The Genesis core ships with a custom
> **dual-core** modification of gwenesis that spreads the workload across both of
Expand Down Expand Up @@ -252,6 +253,28 @@ darkforces/DARK.GOB, boxart/darkforces.jpg, Star Wars: Dark Forces

Settings (`settings.ini`) and agent / save files are written to the same folder.

### PICO-8 setup

PICO-8 carts are plain `.p8` text files or `.p8.png` cartridge images. Put them
in a folder on the card (for example `pico8/`) and reference them from
`metadata.csv`:

```csv
pico8/celeste.p8, boxart/celeste.jpg, Celeste Classic
```

Carts that use `cartdata()` save to `pico8/cdata/`. The 128x128 screen shows
1:1 in the "original" video mode and scaled to 240x240 in "fit". Multi-cart
games that `load("#id")` their other carts work when those carts sit in the
same folder as `id.p8.png` / `id.p8`.

What to expect: the Lua interpreter runs at roughly 1.3M instructions per
second on the S3 (its heap lives in PSRAM), enough for carts of Celeste's
weight at full speed, but CPU-heavy carts close to PICO-8's limit run at a
few frames per second. Save states work (the cart's Lua heap and PICO-8
RAM are snapshotted between frames); carts' own `cartdata()` saves are kept
separately; there is no mouse or keyboard.

### metadata.csv format

Each line maps a ROM to its boxart and display name:
Expand Down Expand Up @@ -283,7 +306,7 @@ A checklist of what's implemented and what's still in progress.

**Emulation**

- [x] Auto-select emulator by ROM extension: NES, GB/GBC, SMS/GG, MSX, Genesis, Doom, Dark Forces
- [x] Auto-select emulator by ROM extension: NES, GB/GBC, SMS/GG, MSX, Genesis, Doom, Dark Forces, PICO-8
- [x] On-demand core loading — every emulator and game loads at runtime with no reboot needed to switch games or systems
- [x] Custom **dual-core Genesis** (gwenesis): 68000 + VDP on one ESP32-S3 core, sound unit (Z80 + YM2612 + PSG) on the other, for full-speed audio and gameplay
- [x] Doom haptic feedback :rocket:
Expand Down
28 changes: 28 additions & 0 deletions components/pico8/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# PICO-8 for esp-box-emu, hosting femto8 (see VENDOR.md).
# COMPONENT_DIR, not CMAKE_CURRENT_SOURCE_DIR: IDF also evaluates this file in
# its early requirements pass, from the build directory.
set(F8 ${COMPONENT_DIR}/femto8/src)

file(GLOB SRC_FEMTO8 ${F8}/*.c ${F8}/lua/*.c ${F8}/lodepng/*.c ${F8}/lexaloffle/*.c)

idf_component_register(
INCLUDE_DIRS "include"
SRCS src/pico8.cpp src/platform/p8_lua_alloc.c src/platform/p8_state.c ${SRC_FEMTO8}
PRIV_INCLUDE_DIRS "src/platform" ${F8} ${F8}/data ${F8}/lua ${F8}/lodepng ${F8}/lexaloffle
REQUIRES box-emu statistics esp_timer heap pthread
LDFRAGMENTS "linker.lf"
)

# Where carts keep their cartdata() saves (femto8 defaults to a relative "cdata")
target_compile_definitions(${COMPONENT_LIB} PRIVATE
FEMTO8_ESPBOX=1
# PICO8_PROFILE=1: report Lua instructions/s, GC share and the cycle count of
# every API function with the fps line (see src/pico8.cpp)
# PICO8_PROFILE=1
CARTDATA_PATH="/sdcard/pico8/cdata"
DEFAULT_CARTS_PATH="/sdcard/pico8")

target_compile_options(${COMPONENT_LIB} PRIVATE
-O2
-Wno-error
-w)
75 changes: 75 additions & 0 deletions components/pico8/VENDOR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Vendored sources

`femto8/` is **femto8**, Ben Baker's PICO-8 reimplementation for embedded
systems (C, a fixed-point Lua 5.x fork, lodepng for `.p8.png` carts and the
Lexaloffle `.p8.png` code decompressor).

- Upstream: https://github.com/benbaker76/femto8 (MIT, `femto8/LICENSE`)
- Commit: `f9e5097c882d0c9203616689f3de5531e0597c43`
- Bundled: `src/lua` (Lua, MIT), `src/lodepng` (zlib, `lodepng/LICENSE`),
`src/lexaloffle` (Lexaloffle Games, zlib-style notice in the files)

Everything under `femto8/src/` except `main.c` (the desktop/SDL entry point).

## Changes made for esp-box-emu

A third platform, `FEMTO8_ESPBOX`, next to upstream's SDL and Renesas
DA1470x (`__DA1470x__`) targets. The emulator supplies video, audio and input
through `src/platform/p8_espbox.h`, implemented in `src/pico8.cpp`:

- `p8_emu.h`: `FEMTO8_ESPBOX` selects `OS_FREERTOS` (FreeRTOS clock/sleep)
and `ENABLE_AUDIO`; the DA1470x heap redirection no longer applies to every
FreeRTOS target.
- `p8_emu.c`: FreeRTOS headers from `freertos/`; the generic renderer (the one
the SDL build uses, with the screen transforms and high-colour modes) also
serves the box, writing 5-bit palette indices into a frame the emulator
provides (`p8_espbox_frame_begin/end`) instead of ARGB pixels; no on-screen
fps counter; the LCD controller and DA1470x renderer are guarded.
- `p8_input.c`: buttons come from `p8_espbox_buttons()`; `p8_pump_events()`
(femto8's Lua instruction hook) calls `p8_espbox_pump()`, where the emulator
menu pauses the cart and a quit from the menu unwinds it through `p8_quit()`.
- `linker.lf`: femto8's statics go to PSRAM and are bracketed by SURROUND
symbols so `src/pico8.cpp` can reset them between launches (femto8 was
written to run once per process).
- `CARTDATA_PATH` / `DEFAULT_CARTS_PATH` are set to `/sdcard/pico8/...` at
build time.
- PICO-8 compatibility (also fixed in the desktop build, which is the
reference used to verify them): a cart that overrides `_ENV`
(`for _ENV in all(objs) do circfill(...) end`, `function f(_ENV)`) still
reaches the API through a fallback in `lua/lvm.c` (the same approach as
fake-08's z8lua); `split()` accepts a non-string; `load("#bbs_id")`
resolves to a sibling `bbs_id.p8.png` / `.p8` so multi-cart games work when
their carts sit next to each other; `load()` resolves against the cart's
own folder (no working directory on the ESP-IDF VFS); `p8_wait_for_any_key`
accepts a gamepad button.
- Performance (`p8_lua_helper.h`): `sspr()` only walks the destination pixels
inside the clip rectangle (a cart scaling a sprite to thousands of pixels
looked hung); `circfill`/`ovalfill` draw each scanline once; `draw_hline`
writes screen nibbles directly in the common case; `cls` is a memset. All
verified pixel-identical against the previous code with a test cart.
- `p8_lua.c`: the Lua state uses the pooled allocator in
`src/platform/p8_lua_alloc.c` (small objects from internal SRAM first, then
PSRAM); `lvm`/`ltable`/`lstring` run from IRAM (`linker.lf`); a
`PICO8_PROFILE` build wraps every API function with a cycle counter.

## Status and limits

Measured on the BOX-3: the interpreter executes ~1.3M Lua instructions/s
with its heap in PSRAM (GC ~1%, the drawing API ~5-15% of a frame), which
runs Celeste at its full 30 fps but leaves CPU-heavy carts near PICO-8's
limit (e.g. Cattle Crisis, Mossmoss) at a few fps. Known: Pico Ball shows a
blank screen in upstream femto8 as well; no mouse/keyboard.

## Save states

`src/platform/p8_state.c`: PICO-8 RAM, the overlay, the frame counters, the
audio channels and the cart's Lua heap. The heap is serialized with **Eris**
(`femto8/src/lua/eris.c`, MIT, Florian Nuecke; the Lua 5.2.4 version as
carried in fake-08's z8lua, with the fixed-point number I/O and the
`populateperms` helper for this Lua). Everything the cart created or
replaced in `_G` is persisted as a graph; the API functions and library
tables recorded before the cart ran (`p8_state_init`) are permanents
referenced by name, as are the registry, `_G` and the main thread. The
menu's request is carried out by the cart task at its next frame boundary
(`p8_espbox_frame_boundary()` in `p8_main_loop`), where no cart code is on
the Lua stack; the cart's own `cartdata()` file is untouched.
7 changes: 7 additions & 0 deletions components/pico8/femto8/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Copyright 2023 Ben Baker

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
40 changes: 40 additions & 0 deletions components/pico8/femto8/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# femto8

femto8 is an open-source reimplementation of the [PICO-8](https://www.lexaloffle.com/pico-8.php) fantasy console, designed specifically for embedded systems with smaller CPUs and less memory. It provides a platform to play PICO-8 games on devices with resource constraints, making it ideal for environments with limited hardware capabilities.

## Acknowledgment

This project has been significantly influenced by the work of [Jacopo Santoni](https://github.com/Jakz) on [retro8](https://github.com/Jakz/retro8), a PICO-8 emulator designed for desktop platforms. We would like to express our gratitude to Jacopo Santoni for his contributions and efforts on retro8, which has been an invaluable resource for the development of femto8.

## Key Features

- Written in C for optimal performance on embedded systems.
- Compact and resource-efficient design, suitable for devices with smaller CPUs and limited memory.

## Screenshots

![](/images/screenshot1.png)

![](/images/screenshot2.png)

![](/images/screenshot3.png)

## Building

To build femto8 (desktop):

1. Clone the repository: `git clone https://github.com/benbaker76/femto8.git`
2. Navigate to the femto8 directory: `cd femto8`
3. Install SDL2 development files for your platform:
- Debian/Ubuntu: `sudo apt install libsdl2-dev pkg-config`
- Fedora: `sudo dnf install SDL2-devel pkgconfig`
- macOS (Homebrew): `brew install sdl2 pkg-config`
- MSYS2 (Windows): install `mingw-w64-*-SDL2` and `pkgconf` via pacman
4. Build a local binary: `make`

## Credits

- [benbaker76](https://github.com/benbaker76) - Author and maintainer of [femto8](https://github.com/benbaker76/femto8)
- [Jacopo Santoni](https://github.com/Jakz) - Author of [retro8](https://github.com/Jakz/retro8)
- [Lexaloffle](https://www.lexaloffle.com/) - Creator of PICO-8
- [atomice1](https://github.com/atomice1) - For updates and improvements
131 changes: 131 additions & 0 deletions components/pico8/femto8/src/data/lua_api.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#ifndef LUA_API_H
#define LUA_API_H

const char *lua_api_string =
"function all(t)\n"
" if t == nil then\n"
" return function() end\n"
" end\n"
" local ttype = type(t)\n"
" if ttype == 'string' then\n"
" local i = 0\n"
" local tlen = #t\n"
" return function()\n"
" i = i + 1\n"
" if i <= tlen then\n"
" return sub(t, i, i)\n"
" end\n"
" end\n"
" else\n"
" local i = 0\n"
" local r = nil\n"
" return function()\n"
" if t[i] == r then\n"
" i = i + 1\n"
" end\n"
" while i <= #t and t[i] == nil do\n"
" i = i + 1\n"
" end\n"
" r = t[i]\n"
" return r\n"
" end\n"
" end\n"
"end\n"
"\n"
"function add(t, v, i)\n"
" if type(t) == 'table' then\n"
" if i == nil then\n"
" t[#t+1] = v\n"
" else\n"
" for j = #t, i, -1 do\n"
" t[j+1] = t[j]\n"
" end\n"
" t[i] = v\n"
" end\n"
" return v\n"
" end\n"
"end\n"
"\n"
"function foreach(c, f)\n"
" if c ~= nil then\n"
" for value in all(c) do\n"
" f(value)\n"
" end\n"
" end\n"
"end\n"
"\n"
"\n"
"function mapdraw(...)\n"
" map(table.unpack(arg))\n"
"end\n"
"\n"
"function count(t, v)\n"
" if type(t) ~= 'table' then\n"
" return nil\n"
" end\n"
" if v == nil then\n"
" return #t\n"
" end\n"
" local c = 0\n"
" for i = 1, #t do\n"
" if t[i] == v then\n"
" c = c + 1\n"
" end\n"
" end\n"
" return c\n"
"end\n"
"\n"
"function del(t, v)\n"
" if type(t) == 'table' then\n"
" local found = false\n"
" for i = 1, #t do\n"
" if t[i] == v then\n"
" found = true\n"
" end\n"
" if found then\n"
" t[i] = t[i+1]\n"
" end\n"
" end\n"
" if found then\n"
" return v\n"
" end\n"
" end\n"
"end\n"
"\n"
"function deli(t, i)\n"
" if type(t) == 'table' then\n"
" if i == nil then\n"
" i = #t\n"
" end\n"
" if i < 1 or i > #t then\n"
" return nil\n"
" end\n"
" local v = t[i]\n"
" for j = i, #t do\n"
" t[j] = t[j+1]\n"
" end\n"
" return v\n"
" end\n"
"end\n"
"\n"
"function cocreate(f)\n"
" return coroutine.create(f)\n"
"end\n"
"\n"
"function yield()\n"
" coroutine.yield()\n"
"end\n"
"\n"
"function coresume(f, ...)\n"
" return coroutine.resume(f, ...)\n"
"end\n"
"\n"
"function costatus(f)\n"
" return coroutine.status(f)\n"
"end\n"
"\n"
"function pack(...)\n"
" return {n=select('#',...), ...}\n"
"end";

#endif
Loading
Loading