The Swap, Measured
This morning's post ended with "the measuring is done; now the surgery". Same day, same desk: the surgery happened. The console now builds with either Lua core - the old 5.4 doubles runtime, or z8lua, the Lua 5.2 fork with PICO-8's 16.16 fixed-point numbers baked into the VM - selected by one feature flag, with the old core still the default until the new one earns it.
What follows is not a story about clever code. It is a story about how every single bug in the swap was found by an instrument - the benchmark harness, a compatibility corpus, and eventually my own licensed copy of PICO-8 running as a ground-truth oracle - rather than by staring at a debugger.
A bug class, not bugs
z8lua upstream is C++: its fix32 number type converts to and from integers
through operator overloads, invisibly, at hundreds of call sites. The C99
conversion I vendored keeps the semantics but loses the invisible
conversions - fix32_t is just typedef int32_t, and a plain C cast copies
RAW Q16.16 bits instead of converting the value.
The corpus found the first one within minutes of the core booting:
lang_strings FAIL 5/12 first 1
Check 1 was sub("hello",2,3)=="el". Under the hood, string.sub's
arguments went through luaL_checkinteger, which under the raw-bits bug
turned positions 2 and 3 into 131072 and 196608. Every fix after that was
the same class wearing a different hat: the shift operators applied a
16.16-scaled shift COUNT (1<<15 shifted by 983040 and returned 0 while
shl(1,15) was correct), ord("a") returned 97/65536, the number-to-string
path, the peek operator's address decode. One class, maybe a dozen sites,
each one caught by a failing corpus row and killed with a one-line
conversion.
The game that felt slow
With the semantics green, I put the new core on the console and played Solais. It felt slow. Not subtly - visibly, in the lit rooms.
So I attached the profiler to the live board without resetting it and
watched while playing: 29 fps at the title, 14 in the caves, 7 in the worst
room, with _update at 13 milliseconds against the old core's 3.5. And here
is where it gets interesting, because the obvious suspect - fixed-point
maths on a chip with a hardware double FPU - is innocent, and the proof took
four short benchmark runs:
- a division-dense microbench on the DEVICE: fix32 2829 us, doubles 2764 us. Equal. The 64-bit soft-divide everyone worries about on Cortex-M is noise.
- the same game, same input tape, on the HOST: fix32 1.5x FASTER.
Fast everywhere except the device, equal on pure arithmetic, and the one cart that collapsed was the one whose heap working set spills out of the fast 256K RAM bank into the overflow bank. The allocator - a first-fit free-list walk that gets slower the fuller and more fragmented the heap is - was the entire story. The fix was swapping the heap backend to TLSF (two-level segregated fit, constant-time alloc and free), which the architecture doc had already pencilled in on yocto-8's evidence:

The new core does not just recover - at 57 fps it beats everything the old core ever did on this game, and the 789-millisecond worst frame from the collapse era becomes 27. The "fix32 is slow" narrative died in an afternoon because the instrument would not let it live.
The oracle
The compatibility corpus is a set of small self-checking carts - each one computes its own pass/fail in Lua, so the expectation is what PICO-8 actually does, not what my runtime happens to produce. That claim had always carried an asterisk: verified against the manual, not the machine.
It turns out the machine takes the stand willingly. PICO-8 has a headless
execute mode, printh writes to stdout, and extcmd("shutdown") exits
cleanly - so a small wrapper runs every cart through my licensed copy and
records the verdict. The very first pass found that three of my carts used
pcall, which real PICO-8 does not expose at all. Those carts had never
run on real hardware. The corpus had been quietly wrong about its own
ground truth, and now it cannot be: the oracle file is regenerated from the
real machine, and a cart that fails there is a wrong cart, not a wrong
runtime.
The oracle also handed over PICO-8's actual random number generator. Seed it, print the sequence in hex, and match the add-rotate pair bit for bit:
srand(1) -> 0x0000.14b7 0x0000.fd18 0x0006.97e2
srand(0) -> 0x0000.8474 0x0000.5724 (zero seed becomes 0xdeadbeef)
rnd(-8) -> 0xb800.79e2 (negative range: modulo the raw bits)
That exact generator now runs on every one of my runtimes, pinned by a corpus cart that real PICO-8 and the new core both pass. Seeded carts reproduce their sequences on the console, bit for bit.
One flash, five bugs
The last piece was running the whole corpus ON the console - every cart embedded in a single firmware image, driven through the real cart machinery, results streamed back over the debug link. Its first day caught five real bugs, and my favourite is this one:
int32_t a = (((x & 0x4000) ? ~x : x) & 0x3fff) + 2;
fix32_t ret = (a >> 2 << 4) + sintable[a >> 2];
For sin(0.25) exactly, a reaches 0x4001 and sintable[a >> 2] indexes
one element past the end of a 4096-entry table. Undefined behaviour - which
upstream C++ builds got away with for years because the linker happened to
place the NEXT table right after it, and that table's first entry is the
mathematically correct continuation. The ARM compiler laid memory out
differently, and my console quietly computed sin(0.25) = -1.00372. The
4097th entry exists now. The other four: a boolean argument that a flag API
rejected and then clobbered the whole flag byte; music data that never made
it into RAM for text-format carts; dialect-lowered code poisoning the new
core (\ integer division lowers to //, which z8lua reads as a comment -
the old core needed the lowering, the new core is poisoned by it); and the
peek operators reading from a memory map nobody had wired up.
The matrix
By the end of the day the corpus gates five runtime instances, and the scoreboard looks like this:

Two things in that picture are worth saying out loud. The device columns beat the host columns because the console's bindings were always more complete than the host harness - now measured per cart instead of suspected. And the new core's lead over the old one on identical silicon is exactly the number-system categories: fixed-point arithmetic, bit operations, seeded randomness, the things the entire swap was for.
The new core stays behind its flag - that switch is a decision with gates, not a build option that drifts into production. But the case now rests on a table of measurements, a corpus that answers to real hardware, and one afternoon where the player, the tape, and the profiler all agreed about everything.