The Cartridge That Killed Its Own Soundtrack

Rusty Nail is a homebrew fantasy console: an STM32 microcontroller that plays PICO-8 cartridges over HDMI, with its own runtime, synth, and cart slot. Yesterday it lost a third of its code on purpose. This post is about what the speakers said the morning after - and about a cartridge that turned out to be assassinating its own soundtrack, with my help.

The morning after the deletion

The runtime migration ended last night the way these things should: the old Lua core - doubles for numbers, plus the preprocessor that lowered PICO-8's dialect for it - was deleted in one commit. Not feature-flagged off. Deleted.

89 files changed, 219 insertions(+), 32436 deletions(-)

The fixed-point core is no longer "the default". It is the only thing there is. A tagged commit is the recovery point, and a stale build flag now errors on purpose, so no command in my shell history can quietly summon the dead core back.

The deletion also flushed out two kinds of rot worth recording. Six of the host harness's own tests had quietly broken months ago - that crate's test suite was never wired into CI, so nothing noticed until the deletion made everything recompile. The suite is in CI now. And my first attempt to strip the dead code automatically used a brace-matching script that counted braces inside a string literal and cut it mid-string; every risky deletion after that was done with line-verified boundaries that refuse to write if the file does not look exactly as expected. Cheap paranoia, instantly repaid.

Flashed the deletion build: 303 carts in the library, the physical cart slot, saves, hot-swap, 59fps on the wire. Everything played. I went dungeon-crawling to celebrate.

Three sounds that should not exist

Dank Tomb is my heaviest cartridge - a torchlit dungeon that computes its lighting per pixel in Lua, with a soundtrack built almost entirely from PICO-8's custom instruments. It has been this project's best bug detector for a week, and it delivered again. Three sounds, none of them in the game:

  1. The title music started with a mangled first bar, then recovered and played normally - every time, but only the first time.
  2. Every footstep clicked. Not the footstep sound - a hard electrical click riding on top of it.
  3. Once I started listening for clicks, another cart's music had a faint crackle too. It had been there so long I had stopped hearing it.

Two wrong fixes

Theory one: channel contention. When a cart calls sfx(n) without naming a channel, the synth picks one - and my picker's fallback, when every channel was busy, was channel 0. During music, channel 0 is usually the melody. An ambient sound landing there would cut the tune mid-bar, and the music would only recover when the next pattern re-claimed its channels. That matched the "mangled bar, then it recovers" shape exactly.

I made the picker music-aware - it now refuses to land an auto-picked sound on a channel the music is playing - wrote the regression tests, flashed it. The semantics are genuinely more correct, and they stay. The clicks did not care.

Theory two: hard cuts. When a new sound replaces one still mid-waveform, the output steps instantly from one amplitude to another. I added a declick that bleeds the last sample out exponentially over 9ms instead of slamming to the new value. Flashed it. Same clicks.

Two plausible fixes, both tested, both flashed, both wrong about the clicks. Time to stop theorising and instrument.

The trace

I added a defmt log line for every command the audio task processes - every music(), every sfx(), every cart reload. Rare events, cheap to keep forever. Then I loaded Dank Tomb and read what actually happened:

75.360965 audio: reload-cart (stop + resnapshot)
76.875462 audio: sfx n=-1 ch=-1
76.875470 audio: music n=0 fade_ms=300 mask=0
77.740056 audio: sfx n=-1 ch=-1
78.929606 audio: sfx n=39 ch=0
79.237148 audio: sfx n=39 ch=0

Two convictions in six lines.

Timeline reconstructed from the real trace: reload-cart at 75.4s, music(0) with a 300ms fade at 76.9s, the sfx(-1,-1) cleanup killing the music channels at 77.7s, and the pattern boundary re-claiming them - the moment the music "continues"

First: music(0, fade_ms=300). Dank Tomb starts its music with a 300ms fade-in - and my music() binding had been discarding every argument after the pattern number. The fade never happened. Neither did the channel-reservation mask, which is the third argument for a reason I was about to rediscover.

Second, the assassination: 0.86 seconds after starting its music, the cart calls sfx(-1, -1) - "stop all sound effects", a routine cleanup. My synth's stop-all did exactly what it said: it stopped ALL channels, including the ones the music was playing on. The cart was killing its own soundtrack with a housekeeping call, and my synth was the accomplice. On real PICO-8, sfx(-1) stops sound effects; the music plays on. The mangled first bar was the music being executed mid-phrase and then resurrected at the next pattern boundary - which is precisely why it "recovered and played normally" every time.

That is a semantics bug, and the fix is one guard: stop-all now skips channels the music owns. But the trace also showed the footsteps landing on their own channel, cleanly - so the clicks were never about channel management at all.

Why a step clicks

A speaker cone follows the waveform. If the waveform jumps instantaneously from one value to another, that edge is not "a bit of the old note and a bit of the new" - a discontinuity carries energy across the whole spectrum at once, which the ear reads as a click. It does not matter how musical the two notes on either side are. Any instant step does it:

  • a percussive note starting at full volume (every footstep),
  • a note boundary where the volume changes sharply,
  • a custom instrument switching its sub-voice pitch mid-note.

Dank Tomb's soundtrack is dense with custom instruments, so it stepped more often and harder than anything else I own - but the faint crackle in the other cart's music was the same defect at lower amplitude. One note lasts 183 * speed samples at PICO-8's 22050Hz mixing rate; a track at speed 8 crosses a potential step every 66 milliseconds, all track long.

My 9ms exponential bleed failed because it only triggered on channel takeovers - it never saw the note boundaries INSIDE a playing sound. And it holds a decaying constant, which softens a step but does not remove it.

The fix every reimplementation learns

This is where being late to a genre helps: every PICO-8 reimplementation has fought this exact bug. fake-08, whose synth descends from the same zepto8 lineage mine does, shipped it as a one-line release note: "Add a crossfade between notes to more closely match PICO-8 behavior and fix some audio clipping."

The mechanism, ported into my synth: watch each channel's resolved voice per sample, and call a change "harsh" when the volume steps by more than 0.1, the pitch by more than 1%, or the waveform switches. On a harsh change, snapshot the outgoing voice - and keep rendering it. The old voice continues oscillating at its old pitch while the output blends:

out = (1 - f) * new_voice + f * old_voice
f: 1 -> 0, stepping 130/sample_rate per sample  (a 7.7ms blend)

Top: a hard mid-cycle cut from a 440Hz square to a 660Hz square, with the step marked - the click. Bottom: the same transition through the 7.7ms crossfade window where both voices render and the old one fades out. Synthetic illustration.

The detection, as it landed in the synth (the snapshot only refreshes once the previous fade has finished - re-snapshotting on every harsh sample during a custom-instrument run would chase its own tail and never converge):

let lp = self.channels[c].last_params;
let freq_thresh = p.freq.min(lp.freq) * 0.01;
let harsh = (p.volume - lp.volume).abs() > 0.1
    || (p.freq - lp.freq).abs() > freq_thresh
    || p.wave != lp.wave;
if harsh {
    if self.channels[c].fade <= 0.0 {
        self.channels[c].fade_params = lp;   // the outgoing voice, whole
        self.channels[c].fade_phi = self.channels[c].phi;
    }
    self.channels[c].fade = 1.0;
}

Because a channel takeover is just another harsh change, this one mechanism subsumed the failed declick too: footstep replacing footstep, note boundary, instrument swap, stop - all the same 7.7ms blend.

What the speakers say now

Dank Tomb's title music starts clean, with its 300ms fade-in, un-stabbed by its own cleanup call. The footsteps are footsteps. And the other cart's faint crackle - the one I had stopped hearing, the one I would have sworn was just how the hardware sounded - is gone. It was never the hardware. It was a waveform step at every note boundary, quietly, for weeks.

The synth's regression suite now pins all of it: the stop-all that spares the music, the music-aware channel picker, the pattern boundary that no longer kills ambient loops, and a test that hard-cuts a square wave mid-cycle and asserts the output bleeds out smoothly instead of stepping.

The deletion removed 32,000 lines and broke nothing. The bug of the day was three lines of channel semantics and a crossfade - found not by reading code, but by making the audio task confess what it was told, one timestamped command at a time. The instruments keep being smarter than the theories. That is why they are the instruments.