The shell, the stack and the secret palette

Rusty Nail is my fantasy-console OS on a Nucleo-H753ZI: PICO-8 carts and Game Boy ROMs at 60fps out of an RP2040 HDMI bridge, with a WiFi cart library served from the Mac. Until this week the "UI" was a flat list of 384 cart names. These two days it became a console shell: home screen, per-system shelves, search, favourites, recents - and, in the way of these things, the work detoured through the nastiest crash of the project and ended inside PICO-8's undocumented palette.

A library with a soul

The redesign brief I set myself was SPLORE - PICO-8's own cart browser - not as a copy but as a posture: the box art is the hero, the list is a guest. The selected cart's 128x128 label fills the whole screen and a compact floating panel carries six rows over it. Tabs flip between the shelves (pico-8, game boy, game boy color), search, favourites and recently played, and the panel wears each tab's colour.

The v2 home screen (pixel-exact re-render of the shell fixture)

A shelf: the label is the screen, the panel floats (re-render)

The scroll behaviour took three attempts to get right, because SPLORE's feel is specific: the list holds still until the hover reaches the third row, then rolls to keep two carts above it, and stops rolling at the tail. On the search tab the [search] opener is a doorway, not a cart - the first step down kicks it out of view. The whole rule fits in one function:

fn follow(sel: u16, total: u16, opener: bool) -> u16 {
    let window: u16 = nav::LIST_WINDOW; // six rows, every list
    let mut scroll = sel.saturating_sub(2);
    if opener && sel >= 1 {
        scroll = scroll.max(1);
    }
    scroll.min(total.saturating_sub(window))
}

Search itself became an in-place keyboard: a card drops in from the top edge, the panel's own rows re-rank on every keystroke, and a little demoscene square slides into the corner while a query is live. No separate screen, no second results list.

Typing on the search tab: the keyboard card above, the live rows below (re-render)

The crash that was never where it landed

Mid-afternoon on the second day, launching any .p8.png cart started killing the console. Not politely: the crash system reported

Fault: DoubleFault
PC: 0x00000000
CFSR: 0x00000000

A double fault with all-zero capture means the fault handler could not even push its own stack frame. The probe's unwinds pointed at absurd places - the USB OTG driver's register code, the vector table - which I eventually recognised as the signature of a corrupted return address: the crash is never where it lands.

I worked it with instruments instead of theories. A heap canary (allocate 64K, pattern it, verify, free) ran clean right before the dying code, so the allocator was innocent. Then I painted the whole stack region with a sentinel at boot and logged the low-water mark every seventeen seconds:

stack low-water: 0x24077be0 (7136 bytes clear of the floor)

Seven kilobytes. At idle. The stack gap on this build was 40K, and the comment history in the heap-levy constant says the design figure was ~66K - years of small unfunded static additions had quietly eaten the margin, and the constant's own history even warns about exactly this ("unfunded .bss growth is the silent stack-gap erosion..."). The final straw was hiding in the PNG loader: miniz_oxide's DecompressorOxide is an ~11KB struct, and I had it on the stack, where a debug build is happy to keep several temporary copies in one frame. Boxed:

let mut state = alloc::boxed::Box::new(DecompressorOxide::new());
let (status, _consumed, written) = decompress(&mut state, &idat, &mut buf, 0, flags);

The negative results are worth recording. I restored 26K to the stack first, which promptly OOMed the heavy carts - the trajectory logger showed them peaking at 485K of what was now a 565K heap. The boxed decompressor turned out to be the real fix; the measured worst case after it was ~33K deep, so the stack keeps a measured-margin 8K and the heap keeps the rest. Measure, do not guess: every number in that paragraph came off the defmt stream.

Loading you can see

With launches fixed, a LAN library exposed a comedy problem: a cart fetch takes ~30ms and the conversion 25ms, so any loading indicator was gone before the eye landed on it. Worse, the Ethernet fetch used to run inline on the loop that pumps frames to the panel - nothing I drew could even reach the screen, and cancel presses queued up behind the transfer.

The fetch now lives in its own courier task and the load became a small ceremony: the demoscene square slides into the corner, the swap deliberately waits about a third of a second for it, the shell dims through the house checkerboard for the boot beat, and the game takes over. Fast wire or slow, the load reads as a moment instead of a blink.

The secret palette, and the wire that lies about colour

PICO-8 has 16 hidden colours - the extended palette, addressable as negative indices - and each one lines up hue-for-hue as a darker twin of a default colour. The NerdyTeachers colours guide has the lineup. That gave the shell a design system for free: every tab's selection bar is its notch colour's twin.

pub const fn dark_twin(c: u8) -> u8 {
    16 + (c & 15)
}

All sixteen twin pairs: notch in the default, bar in its hidden twin (re-render)

Getting them onto the panel was its own story. The H7 sends the RP2040 bridge one byte per pixel, nominally RGB332 - and my first fixes treated that byte as a colour, requantising it, which made everything worse in creative ways (a light tan rendered as dark pink; the whole home screen shifted shade when I "improved" the rounding). The byte is not a colour. The bridge keeps an exact RGB888 palette keyed by the byte, so the byte is a lookup key, and rewriting the quantiser just broke the keys.

The real defects were two documented byte collisions: twin -15 truncates onto base black (which is why a favourites bar once rendered pure black), and twin -11 onto twin -14 (which is why 5 and -11 looked identical). The fix was two bytes, remapped in lockstep on both sides of the wire:

const fn secret_byte(i: usize) -> u8 {
    match i {
        1 => 0x01, // twin -15: off base black's byte
        5 => 0x45, // twin -11: off twin -14's byte
        _ => rgb332(fcgfx::palette::PICO8_SECRET[i]),
    }
}

with the matching pal24[0x01] / pal24[0x45] entries in the bridge's palette and a fresh UF2 onto the Feather. I cross-checked the result against the reference sheet by sampling the PNG's actual pixels - the last "wrong" colour turned out to be byte-exact in the firmware and wrong only on the not-yet-reflashed bridge.

The colour lab

By this point I was sick of judging colour by build-flash-squint, so the last piece of the shell is a screen that ends that loop: an on-device colour lab. The full 32-colour lineup, live sample rows recomposing as you edit, and a recipe readout in the guide's negative notation. The d-pad walks all 32 colours, up/down flips a colour to its twin.

The colour lab: the lineup, live sample rows, and the recipe readout (re-render)

I tuned the final recipes sitting in front of the TV: pico-8 9/-7, game boy 11/-5, game boy color 12/-4, favourites 13/-3, recently played 4/-12, search 8/-8. White text throughout. Ten approved combos are recorded in the design doc for whatever needs a pair next.

Two days, three subsystems deep, and the takeaway is the same one this project keeps teaching: the bug is never where it lands, the fix is never where you guessed, and an instrument on the real hardware beats any amount of cleverness about it.