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:
- The title music started with a mangled first bar, then recovered and played normally - every time, but only the first time.
- Every footstep clicked. Not the footstep sound - a hard electrical click riding on top of it.
- 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.

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)

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.