Skip to content

feat: add PICO-8 (femto8) - #126

Open
finger563 wants to merge 18 commits into
mainfrom
feat/pico8
Open

finger563 wants to merge 18 commits into
mainfrom
feat/pico8

Conversation

@finger563

Copy link
Copy Markdown
Contributor

Summary

Adds PICO-8 as a new core (components/pico8), hosting femto8 (Ben Baker's C reimplementation for embedded systems: fixed-point Lua 5.2, lodepng for .p8.png). This replaces the cursor/add-pico-8-emulator-support-d887 branch (#107), which was a skeleton against an imagined femto8 API with no emulator source; #107 can be closed in favour of this.

See components/pico8/VENDOR.md for the vendored tree, every change made and the measured limits.

What's in it

  • components/pico8/ — femto8 at f9e5097 with a third platform FEMTO8_ESPBOX beside its SDL and DA1470x targets, plus the box glue:
    • The cart's p8_run() loop runs in its own task; frames are 128×128 palette indices scaled by the emulator (1:1 "original", 240×240 "fit"); audio rendered on core 1 at 44.1 kHz.
    • Pause/quit happen in femto8's Lua instruction hook (the VM is at a safe point, no locks held): quit unwinds through femto8's own p8_quit() longjmp, no reboot; statics reset via linker SURROUND symbols for relaunch.
    • Save states: PICO-8 RAM + overlay + audio channels + the cart's Lua heap serialized with Eris (as fake-08 does), carried out by the cart task at a frame boundary. Host-tested round trip (closures/upvalues, metatables, captured library functions, coroutines, replaced globals).
    • PICO-8 compatibility fixes verified against femto8's desktop build: _ENV-override API fallback (for _ENV in all(objs) do circfill(...)), lenient split(), load("#bbs_id") of sibling carts (multi-cart games), load() relative to the cart's folder, gamepad "press any key".
    • Performance: sspr() clipped to the visible area (an O(dw·dh) loop looked like a hang), span-based circfill/ovalfill/hline, memset cls (all pixel-identical to the old code on a test cart); pooled Lua allocator (internal SRAM first); VM core in IRAM; PICO8_PROFILE build option reports Lua instructions/s, GC share and per-API cycles.
  • Integration: main/pico8_cart.hpp, carts.hpp, rom_info (.p8 / .p8.png), top-level CMake (PICO8_COMPONENTS / ENABLE_PICO8), README table + setup section, static-analysis exclusion/suppression for the vendored tree.

Controls

D-pad; A/Y = O, B/X = X; START = PICO-8's pause menu; carts' cartdata() saves go to pico8/cdata/.

Status (BOX-3)

  • Celeste at a locked 30 fps; emulator pause/resume, quit → relaunch, save/load state verified; memory returns to baseline across relaunches.
  • Measured envelope: the interpreter runs ~1.3 M Lua instructions/s with its heap in PSRAM (GC ~1 %, drawing API ~5–15 % of a frame) — carts near PICO-8's CPU limit (e.g. Cattle Crisis in play, Mossmoss) run at a few fps. Known: Pico Ball shows a blank screen in upstream femto8 too; no mouse/keyboard.

Test plan

  • Build (IDF 6.1), flash BOX-3
  • Celeste: play, pause/resume, quit, relaunch; colors and input correct
  • Save state → play on → load; quit → relaunch → load
  • Multi-cart load(), _ENV carts (Mossmoss, Pico Ball load and run), lenient split
  • cppcheck clean on the glue with the CI flags

🤖 Generated with Claude Code

https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1

finger563 and others added 14 commits October 4, 2026 21:38
Vendors femto8 (benbaker76/femto8 @ f9e5097) as components/pico8 with an
esp-box-emu platform: the cart runs in its own task, frames are palette
indices scaled by the emulator, audio is rendered on core 1, and the Lua
instruction hook is the pause/quit point so the core stops cleanly and
relaunches without a reboot (statics reset through linker SURROUND symbols).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…; backtrace on a stuck cart

The audio task rendered before p8_init() had allocated PICO-8 RAM (a
NULL-base LoadProhibited at 0x5f2f). femto8's keyboard-only "press any
key" loop now takes a button and honours the emulator's stop request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…plit()

Carts that override _ENV (`for _ENV in all(objs) do circfill(...) end`,
`function f(_ENV)`) could not reach the API: a string-keyed lookup that
comes back nil now falls back to a snapshot of the API globals taken
before the cart ran (the same approach as fake-08's z8lua). split() no
longer raises on a non-string argument, as PICO-8 does not.

Reproduced and verified with femto8's desktop build: all 7 test carts
run (Mossmoss and Pico Ball failed before).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
… audio gate on the kill path

- draw_scaled_sprite walked every destination pixel of a scaled sprite
  before clipping: a cart scaling a sprite to thousands of pixels spent
  seconds per frame on the S3 and looked hung (Cattle Crisis). The loops
  now cover only the clip rectangle.
- load(): no working-directory check on the box, the cart's own folder
  is the base, and "#bbs_id" (multi-cart games) resolves to a sibling
  bbs_id.p8.png / .p8.
- The _ENV fallback caches its table pointers instead of interning the
  registry key on every nil lookup.
- A cart killed while wedged left the audio task armed; the next launch
  rendered before p8_init() and crashed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
The 64KB PICO-8 RAM (screen, sprites, map, draw state) is read a dozen
times per pixel drawn; it came from the PSRAM heap. A multi-cart menu
retrying load() every frame no longer floods the console.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
The interpreter, not the drawing API, dominates the slow carts (Cattle
Crisis in play: >90% Lua; Mossmoss: ~86%), and a string creation cost
~12k cycles through the locked system heap. Lua's small objects now come
from size-class free lists over 64KB PSRAM slabs (src/platform/
p8_lua_alloc.c, host-tested under ASan); larger blocks still use the
heap. PICO8_PROFILE wraps every API function with a cycle counter and
reports the top entries with the fps line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…internal SRAM first; VM core in IRAM

circfill cost ~0.5ms per call (a third of some carts' frame): the octant
walk drew every scanline several times over, each pixel through the full
pixel_set. The fill now records one extent per row and draws it once;
draw_hline writes screen nibbles directly when no fill pattern, secondary
palette, mask or per-call attributes are in play; cls is a memset.
Verified pixel-identical against the previous code with a test cart on
the desktop build (circles of every radius, camera/clip, ovals, fillp).

The interpreter itself is the limit for heavy carts (~1.3M Lua
instructions/s from PSRAM, GC 1%): its small objects now come from
internal SRAM first up to a budget, lvm/ltable/lstring run from IRAM, and
PICO-8 RAM goes back to the default heap (measured ~10% of a frame).
PICO8_PROFILE also reports instructions/s and GC share.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…filer off by default

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
A state is PICO-8's RAM, the overlay, the frame counters, the audio
channels and the cart's Lua heap, serialized with Eris (Lua 5.2's
persistence library, as fake-08 does; vendored from its z8lua tree with
the fixed-point number I/O and the populateperms helper). Everything the
cart created or replaced in _G is persisted as a graph; the API functions
and library tables recorded before the cart ran are permanents referenced
by name. The menu's save/load request is carried out by the cart task at
its next frame boundary, where no cart code is on the Lua stack.

Host-tested: a round trip through a fresh Lua state keeps closures with
upvalues, metatables, captured library functions, a suspended coroutine,
replaced originals, and clears globals created after the save.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
… is open

A save/load runs the cart for one frame with the menu up, and the button
that closed the menu was still held on the first frame after resume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
Copilot AI balanced review requested due to automatic review settings October 7, 2026 14:21

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.

Copilot review overview

🟡 Changes recommended

Unresolved buffer-ownership races, unsafe cartridge decompression, and platform-specific audio defects can cause corruption or incorrect runtime behavior.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds PICO-8 support through the vendored femto8 emulator, including runtime integration, save states, audio, controls, and cartridge discovery.

Changes:

  • Adds the femto8 core and ESP-BOX platform adapter.
  • Integrates .p8/.p8.png carts, save states, video, audio, and input.
  • Updates build configuration, documentation, and static-analysis exclusions.
File Description
CMakeLists.txt Enables the PICO-8 component.
main/​carts.hpp Registers the new cart type.
main/​pico8_cart.hpp Implements the cart lifecycle and menu integration.
components/​rom_info/​** Detects and formats PICO-8 cartridges.
components/​pico8/​CMakeLists.txt Builds femto8 and platform glue.
components/​pico8/​include/​pico8.hpp Defines the public emulator API.
components/​pico8/​src/​** Implements tasks, frames, audio, allocation, and save states.
components/​pico8/​linker.lf Places and resets femto8 static state.
components/​pico8/​femto8/​** Vendors and adapts the femto8 runtime and dependencies.
components/​pico8/​VENDOR.md Documents provenance, modifications, and limitations.
README.md Documents PICO-8 setup and controls.
.github/​workflows/​static_analysis.yml Excludes vendored sources from cppcheck.
suppressions.txt Adds matching vendored-code suppressions.

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

Comment thread components/pico8/src/pico8.cpp
Comment thread components/pico8/femto8/src/lua/fix32.h
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

ESP-IDF Size Report for 'Esp Box Emu'

Metric Base PR Delta
FLASH 3,755,576 bytes (59.69%) 4,021,660 bytes (63.92%) 🔺 +266,084 bytes (+4.23%)
DRAM 145,612 bytes (42.61%) 156,856 bytes (45.90%) 🔺 +11,244 bytes (+3.29%)
IRAM 0 bytes 0 bytes 0 bytes
RAM (DRAM+IRAM) 145,612 bytes 156,856 bytes 🔺 +11,244 bytes (+3.29%)

FLASH uses app .bin size or json2 flash sum. RAM sums DRAM+IRAM via idf_size. Percentages shown when totals are available.
DRAM/IRAM usage does not include memory used by the heap allocator at runtime.
This report was generated by esp-idf-size-delta.

The video task keeps the frame pointer it dequeued for the whole LCD
write and nothing signals completion, so with two buffers the cart could
render into one still being scanned out; three buffers put the reuse two
full frames out. fix32_rotl/rotr shifted a signed value, and by 32 for a
rotate count of 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1

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

Unresolved memory-safety, path-validation, state-restoration, and lifecycle issues can cause crashes, unsafe file writes, or leaks.

1 open finding
2 resolved since last review

🧠 Review effort: Balanced

Comment thread components/pico8/src/pico8.cpp Outdated
The audio task could pass its s_audioReady check and be preempted before
render_sounds(); clearing the flag and sleeping did not cover that. It now
re-checks the flag and renders under a mutex, and the cart task takes
that mutex once after clearing the flag, before p8_shutdown() frees
femto8's memory.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1

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

Save-state restoration and malformed-cartridge bounds handling contain correctness and memory-safety defects.

2 open findings
1 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Medium severity Perform wrapping shifts on unsigned bit patterns

components/​pico8/​femto8/​src/​lua/​fix32.h:25

Left-shifting a negative int, or a positive value whose result is not representable, is undefined behavior. PICO-8 conversion relies on 32-bit wrapping, so perform the shift on the unsigned bit pattern before converting back.

This issue also appears on line 129 of the same file.

🧠 Review effort: Balanced

Comment thread components/pico8/femto8/src/lexaloffle/p8_compress.c Outdated
Comment thread components/pico8/femto8/src/lexaloffle/pxa_compress_snippets.c
…(review)

Both .p8.png code decompressors took the back-reference offset and
length straight from the cart stream: the legacy one memcpy'd overlapping
ranges, neither checked that the source lies inside the data produced so
far or that the copy fits the output. A malformed cart now fails to load
instead of reading before the buffer or writing past it. fix32's
from_int / shl / abs shift the unsigned bit pattern (PICO-8 numbers wrap).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1

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

Unresolved memory-safety, concurrency, resource-leak, audio-validation, and save-state correctness issues remain.

2 open findings
2 resolved since last review

🧠 Review effort: Balanced

Comment on lines +77 to +79
// compressed length (to do: use to check)
READ_VAL(val);
READ_VAL(val);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 23af3a2: both decompressors take the code section's length (PNG payload minus cart memory); every read past it fails the decode, the PXA path also rejects a comp_len larger than the input, and the legacy raw-text path is bounded by the section and the output.

Comment thread components/pico8/src/pico8.cpp Outdated
Comment on lines +111 to +112
const uint8_t* s_lastFrame = nullptr;
unsigned s_framesPresented = 0;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 23af3a2: s_lastFrame and s_framesPresented are std::atomic; the readers take a local copy of the pointer before using it.

…n (review)

Both .p8.png code decompressors read the cart stream with no end check,
so a cart declaring more output than its payload encodes read on past the
decoded image. They now take the code section's length, every read past
it fails the decode, and the legacy raw-text path is bounded as well. The
last-frame pointer and the frame counter are written by the cart task and
read by the emulator task; they are atomics now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
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.

2 participants