Why I Didn't Replace the PICO-8 Runtime

Rusty Nail runs real PICO-8 cartridges on a bare-metal STM32H753, with an RP2040 doing the HDMI. The Lua interpreter already runs on the board, real games play, and the load rate over a big cart sweep sits in the mid-nineties. So naturally the question I put to myself this week was: should I throw the runtime away?

Not out of masochism. There are mature open-source PICO-8 reimplementations out there - fake-08, zepto8, tac08, open8 - and a nagging worry that I was polishing a homegrown thing while a better one existed. The honest way to settle that is not vibes, it is an audit, a test corpus, and some cross-compiles. This post is the paper trail.

What I actually have

First step: read my own project like a stranger. The runtime is three layers - a vendored Lua 5.4.7 built for the Cortex-M7, a Rust PICO-8 API (drawing, memory map, input, audio commands) registered into it, and a cart pipeline that decodes .p8.png steganography and lowers PICO-8's Lua dialect to standard Lua before the parser ever sees it. The runtime itself is Rust; Lua is only in the building because the cartridges are written in it - compatibility, not implementation choice.

For orientation, the whole path from cartridge to screen:

cartridge (.p8.png)
        |
        v
cart pipeline (decode, lower the dialect)
        |
        v
Lua 5.4 VM + PICO-8 API (Rust)
        |
        v
renderer -> 128x128 framebuffer
        |
        |   ...everything above runs on the STM32H753
        v
SPI link
        |
        v
RP2040 running PicoDVI
        |
        v
HDMI

The numbers going in: 845 of a curated 884-cart sweep load and run (95.6%), 92.5% across a ~19,000-cart archive dump, and the parser path is at 98.9% clean. Three different rulers there, worth keeping apart: the 884-cart sweep is the curated does-it-run set, the 92.5% is a raw load rate across an unfiltered archive dump, and the 98.9% is the parser alone - carts that survive source-lowering before the runtime executes a single instruction. Celeste Classic, Solais and Dank Tomb all play on the actual board. That is not a runtime you replace casually.

But the audit also confirmed the one structural wart I already knew about: PICO-8 numbers are 16.16 fixed point, and mine are IEEE doubles with shims bolted around the edges. Bitwise ops convert through the fixed-point representation, a metatable catches fractional operands of & and |, and the dialect preprocessor rewrites operators line by line. It mostly works. "Mostly" is doing real work in that sentence.

Making the gap measurable

Opinions about compatibility are cheap, so I wrote a corpus: twenty-one small .p8 cartridges that each pin a cluster of PICO-8 behaviour and check THEMSELVES in Lua. A cart computes its own pass/fail with pget readbacks and arithmetic assertions, so the expected result is what real PICO-8 does, not what my runtime happens to produce. A headless runner steps each cart eight frames, reads the counters back, and prints one row per cart with a framebuffer hash for regression.

The fixed-point cart is the brutal one:

x=32767
x+=1
chk(x==-32768)             -- integer overflow wraps
chk((1/3)*3<1)             -- 1/3 is 0.33332, *3 = 0.99998
y=0.1
chk(y*65536==flr(y*65536)) -- every value sits on the 1/65536 grid
z=1/0
chk(z==0x7fff.ffff)        -- div by zero clamps to max fix32
chk(0xffff==-1)            -- hex literals map the 16-bit int part
chk(-(-32768)==-32768)     -- negation overflow wraps

Every one of those passes on real PICO-8. On my doubles-based runtime:

The fixed-point corpus cart failing all six checks on the current runtime - host runner output, scaled 4x

Six out of six, by design, and no amount of shimming around a double will fix them - 0xffff == -1 is a statement about the number TYPE. Carts really do depend on this: bit-packed level decompressors mask with 0xffff meaning "minus one", physics code leans on overflow wrap.

The corpus paid for itself the same afternoon it existed. It caught a y-inversion bug in my host build's atan2 (the manual is explicit: atan2(0,-1) is 0.25), and it exposed that my host test harness had quietly fallen behind the on-device bindings - sprite flips, pack, cartdata, even color() were host stubs:

The sprite corpus cart on the host runner: flips fail while transparency and palettes pass - host runner output, scaled 4x

That second finding rewrote a piece of the plan on its own: one API implementation, shared by host and device, so the harness can never drift from the hardware again.

What everyone else did

Then I went and read the other runtimes properly - not the READMEs, the source and the issue trackers.

The pattern is unanimous. Every implementation with a credible compatibility record changed the Lua VM's number type to 16.16 fixed point instead of translating around it: zepto8 and fake-08 and tac08 via z8lua (Sam Hocevar's Lua 5.2 fork with the PICO-8 dialect built into the lexer), pemsa and yocto-8 via libfixmath, open8 via a C99 conversion of z8lua. Nobody serious ships doubles. The one project that documents trying floats first (tac08) switched.

The microcontroller attempts were the most instructive reading:

  • PicoPico ran Celeste at about 9ms a frame on a 240MHz ESP32 - with audio - after moving to fixed point. The author measured a sin-heavy Lua loop at 735ms on floats and 135ms on fix32 on the same chip. His RP2040 port died, and not from lack of clock: "a lot of games simply need more memory than there's available."
  • yocto-8 tried to fake a big Lua heap on the RP2040 by trapping hardfaults and emulating loads from SPI PSRAM by hand. Best case, all cache hits, it managed an effective read rate of about 1.1MHz on a 250MHz core. The post-mortem is admirably blunt, and the project now states the law plainly: PICO-8 allows carts a 2MB Lua heap, and no magic runs that on a chip with a tenth of it.
  • tac08's author ported his runtime to a Teensy 4.0 - a 600MHz Cortex-M7 with 1MB of RAM, almost exactly my chip class - and reported that porting was simple, small carts ran, and "most games did not fit in memory... due to the LUA interpreter."

So the field says: fixed point is not optional, CPU is not the problem on an M7, and RAM is the wall that decides which carts you can honestly promise.

Kicking the tyres on z8lua

Reading is not evidence, so I cloned z8lua and put it through two probes.

First, would it even build for my target? With the same Arm GNU toolchain that builds my vendored Lua today, the whole interpreter core compiled for the Cortex-M7 with exactly two tiny patches (newlib defines int32_t as long, which upsets the fix32 constructor overloads). 97KB of Thumb-2 text at -O2. My current Lua 5.4 build is about 170KB. It fits before I have deleted anything.

Second, the semantics. I fed it a chunk written in the raw PICO-8 dialect - compound assignment, shorthand if, hex fraction literals, no preprocessing whatsoever - containing every fixed-point check from the corpus plus the trig and shift quirks:

check 1 pass
check 2 pass
...
check 15 pass
z8lua semantics probe: 0/15 checks failed

Fifteen for fifteen, including all six my runtime fails. The dialect never touched a preprocessor because z8lua's lexer speaks it natively.

My favourite moment of the whole exercise: the probe initially reported one failure, and the bug was in MY test. I had written the division-by-zero check as z > 32767 and z < 32768 - but in a 16.16 world the literal 32768 itself wraps to -32768, so the comparison is false on real PICO-8 too. The candidate VM corrected my understanding of the thing I was testing for. That is what a ground-truth oracle is for. The corner cases are simply already in there, and they read like this:

// z8lua fix32.h (WTFPL, Sam Hocevar) - division
// Return 0x8000.0001 (not 0x8000.0000) for -Inf, just like PICO-8
return frombits((m_bits ^ x.m_bits) >= 0 ? 0x7fff'ffffu : 0x8000'0001u);

Somebody already fought every one of these battles against the real console and encoded the scars. Reimplementing that on top of Lua 5.4 would be weeks of VM surgery to arrive where a maintained fork already stands.

A bonus from reading open8: its authors converted z8lua from C++ to plain C99, which means the fixed-point VM can join my firmware build without dragging a C++ toolchain in at all. And poking a tracking allocator into that VM gave me the heap numbers I had been missing: about 13KB for the VM plus stdlib, and Celeste Classic at roughly 90KB steady state. My standalone firmware build already gives Lua 608KB.

The 45fps red herring

One number kept coming up in my notes as an argument for drastic action: heavy carts render at ~45fps on the HDMI output. Surely the runtime is too slow?

No. The frame going to the RP2040 is 16,524 bytes (128 lines of 128 RGB332 pixels plus a checksum byte each, plus preamble and magic). Over the SPI link at its stable jumper-wire clock of 6.4MHz, that is 800KB/s, which is a 48fps ceiling before the runtime has done anything at all. At 8MHz the same link shifts a full 60fps - and the RP2040 receives glitchy rows, because the link is a handful of flying jumper leads on a bench, not a board. The 45fps is the wire. The four-layer board being designed for this console exists precisely to fix the wire. Optimising the interpreter to fix a transport bottleneck would have been the classic wrong-layer fix.

For what the runtime does cost: the compute side of a heavy cart frame profiles at about 15ms on the board today. The interpreter is not innocent - a 64x64 grid of sin/cos/pset per frame like this corpus probe is exactly the workload fixed point sped up 5x on the ESP32 - but it is not the thing capping the display either.

The interpreter-throughput probe cart: a 64x64 sin/cos plasma - host runner output, scaled 4x

The decision

Keep the runtime. Swap the Lua core. Everything else I audited - the compositor, the cart slot and its burn/verify engine, the synth (which is feature-complete down to the sfx filter bits, something none of the candidate runtimes can claim), the input chain, the memory-map emulation - survives untouched, because none of it depends on what a Lua number is.

What the swap deletes is satisfying: the entire dialect preprocessor (a line-based rewriter with a long tail of known-unfixable edge cases around multi-line strings and the % peek operator), the fixed-point bitwise shims, the number-formatting patches. The most fragile code I own, replaced by a lexer that speaks the language natively.

And it is staged, not heroic. To be clear about what exists today: none of the swap has landed yet - what follows is the migration plan, not the changelog. The old core will stay behind a feature flag while the corpus and the 884-cart sweep gate every step: baseline profiling first (frame-time split, heap high-water, transfer time, so the swap lands as measured deltas rather than impressions), then the z8lua prototype, then renderer and audio validation, then cartridge streaming off the physical flash carts, then the board benchmarks, and only then the link clock work on the real PCB. If fixed point somehow loses to doubles on this chip's FPU, the data will say so and the flag flips back.

The compatibility promise changes shape too. Instead of "100%", which nobody honest can offer (PICO-8's own spec allows carts 2MB of Lua heap; this chip has 1MB of SRAM total), it becomes tiers: virtually all normal carts in Tier A with the ~600KB heap; known, named limitations in Tier B; and the giant-heap monsters in Tier C, impossible without a future board revision, failing with a polite notice instead of a hang. Every emulator project that lasted - DOSBox, MAME - ended up saying it this way, with a compatibility list instead of an adjective.

The best outcome of the week is not the z8lua verdict. It is that the next months of runtime work now start from twenty-one cartridges that know the right answers, a baseline table with real milliseconds in it, and a paper trail for why the architecture is what it is. The assumption went in. The roadmap came out. The next milestone is concrete: prove the transport at a clean 60fps before the custom PCB arrives.