Same bytes, different song

Rusty Nail plays PICO-8 carts on an STM32-based console, through my own synth. The cart data is the real thing; every sample the speaker makes is my reimplementation's opinion of what PICO-8 would have done. Mostly that opinion is calibrated - I have audited it against the licensed binary before. But Celeste's soundtrack kept nagging at me. Not broken, exactly. Wrong. The bones of the song were there and the feel was not.

My first hypothesis was embarrassing and cheap to test: maybe my copy of the cart is stale. Celeste has had revisions, hacks, B-sides; maybe the library cart drifted from the BBS original. So I pulled the cart fresh and hashed everything:

b8c78f53bc18cd9c...  celeste-bbs-15133.p8.png     (fresh download)
b8c78f53bc18cd9c...  carts/library/pico8/15133.p8.png
b8c78f53bc18cd9c...  demos/pico-8/[BBS]/Celeste.p8.png

Byte-identical, all three. I overwrote them with the download anyway, watched git report a clean tree, and accepted the uncomfortable version: the same bytes sound different on my synth. The cart is innocent. The synth is lying somewhere.

Ruling out the voices

The per-sfx audit path already existed: a probe cart that carries Celeste's entire sfx bank plus one line of Lua, export("cel_%d.wav"), run through the licensed binary. That exports each of the 64 sfx as a WAV, ground truth by construction. Render the same bank through my synth, score every pair on duration, RMS, zero-crossing rate and peak.

Result: the individual voices were mostly fine. A handful of noise-heavy percussion sfx sat 10-20% off on brightness, worth a note, but nothing that turns a song into a different song. Whatever was wrong lived above the voices - in the music layer that sequences them.

Recording the real thing

For the music layer I needed a full-song reference, and the binary obliges: extcmd("audio_rec") records the live mixer output to a WAV.

f=0
function _init()
 extcmd("audio_rec")
 music(0)
end
function _update60()
 f+=1
 if f==60*30 then extcmd("audio_end") end
end

Thirty seconds of the real soundtrack, against thirty seconds of mine. The loudness envelopes correlated at 0.49. Half the song, statistically. Bar boundaries roughly lined up early on and then everything smeared.

That smell - right content, wrong schedule - pointed at the music pattern clock: the rule that decides how long a pattern plays before the song advances. My synth used the obvious rule: a pattern lasts as long as its fastest channel's 32 rows. Nothing in the manual says otherwise. Celeste disagreed.

Marker beeps and the actual rule

So, measure it. I built probe carts where the question answers itself: a pattern with a carefully chosen channel mix, followed by a pattern containing a loud 2093 Hz marker beep. Record, find the marker, read the pattern length off the timeline. Three probes nailed the whole rule:

Probe 3's measured timeline: pattern 0 holds two non-looping sfx (short one leftmost) and ends at 2.1s; pattern 2 holds two looping sfx (short one leftmost) and ends after the longer channel; the end-loop flag then replays the opening exactly on schedule

The rules, as measured:

  • The pattern clock is the leftmost channel with a non-looping sfx: its full 32 rows times speed, and trailing silent rows count.
  • A looping sfx never sets the clock, no matter how short or fast.
  • When every channel loops, the longest channel sets the clock.
  • An sfx with a loop start but no loop end is truncated to loop_start rows - the standard PICO-8 short-bar idiom - and that shortened length is what clocks the pattern.

The probes came with a free self-check. Pattern 3 carried an end-loop flag, so after it finished the song jumped back to pattern 0 and replayed the opening - and the second and third marker beeps landed at 10.6s and 17.0s, exactly where the rule above predicts them. When your probe validates its own prediction on the replay, you can stop recording.

The fix in the synth is small once you know the rule:

let looping = le > ls;
let rows = if !looping && ls > 0 { ls.min(32) as f32 } else { 32.0 };
// ...
let len = rows * 183.0 * speed * (self.sample_rate / PICO8_RATE);
if looping {
    longest_loop = longest_loop.max(len);
} else if clock.is_none() {
    clock = Some(len);   // leftmost non-looping channel wins
}

Why did this wreck Celeste in particular? The soundtrack mixes speed-32 melody lines with speed-8 and speed-16 looping percussion. Under my old shortest-channel rule, a bar that should run 8.5 seconds - the big pattern 4 fill - advanced after 2.12. A quarter of the bar, everywhere, for the whole song. The notes were all correct. The song was not.

0.94 and still wrong

Envelope correlation after the clock fix: 0.49 to 0.94. I rendered the song, put it next to the real capture, felt good about the numbers, and listened.

Still different.

This is the part worth writing down. The correlation measured loudness over time, and loudness over time was now right. Ears do not listen to envelopes. I went back and diffed the two renders spectrally instead - half-second windows, energy per band - and sorted by disagreement. Every one of the worst windows had the same thing in common: the wave-6 noise percussion was playing. Hats and snares. Above 4 kHz my render had about 45% of the real energy. Seven decibels of missing shimmer.

The noise generator was not unmeasured, which is what stung. I had fit it against binary exports weeks ago - on zero-crossing rate and RMS. Those two numbers came out matched, and the spectrum came out wrong anyway, because a zero-crossing count is a terrible proxy for a spectral shape. The old fit put a one-pole low-pass over white noise with the cutoff on a power law of pitch. Matched the counts. Sat 3-4x too low in actual cutoff.

Octave-band PSDs of an 8-pitch noise sweep told the real story: PICO-8's noise falls at roughly 3.5 dB per octave across the audio band, and the whole shape translates linearly with note pitch:

Power spectral density of the noise waveform at pitch 30: the real binary and the refit synth track each other within a couple of dB; the old zcr-fit curve sags to 8 dB below them by the top of the band

The refit is almost insultingly simple - same one-pole, cutoff linear in the note increment, gain refit against the sweep's RMS:

let a = (5.5 * inc).min(0.95);
*noise += a * (next_rand(rng) - *noise);
let f = 1.0 - key / 63.0;
*noise * (1.6813 - 1.4892 * f + 3.137 * f * f)

That lands within about 2 dB in every octave band at every probe pitch, RMS within 3%, and Celeste's two drum kits went from band ratios of 0.35-0.55 to 0.83-1.04 against the binary. The lesson got written into the audit doc in capital letters, more or less: zcr under-constrains a spectrum. Fit band energies.

Where it ended up

Two panels of loudness envelopes over the first 30 seconds: before the fix the synth's green trace desynchronises from the real blue trace within two bars; after the fix the magenta trace sits on top of it

Full song, thirty seconds, against the binary: envelope correlation 0.94, RMS 0.235 against 0.228, band energies matched from the bass to the top of the hats. The remaining spectral disagreement in percussion windows is the one kind I am happy to keep: two independent random noise streams never match bin-for-bin, and they are not supposed to.

Is the synth now 100% correct? No, and I want to be precise about what it is instead: every dimension I have pointed a measurement at is within a few percent. The pattern clock is probe-verified. The noise spectrum is band-verified. The other seven waveforms, the effect curves, the detune factors - those match zepto8's formulas and the old count-based audit, and this week proved twice that "audit-green" and "right" are different claims. The audit corpus gets band-energy columns before I trust it again.

Two forensic sessions on this synth now (the clicking cartridge was the first) and the same lesson both times: the licensed binary answers any question you can phrase as a cart. This time the cart had one Lua line and a beep in it.