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:

Solais under the same input tape: doubles 49.9 then 53.2 with TLSF; fix32 32.6 collapsing on first-fit, 57.0 with TLSF

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:

Corpus categories at zero fails: host lua54 11, host fix32 17, device lua54 22, device fix32 26, real PICO-8 29 of 29

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.