Shokunin · Products, experiments, and the work behind them.

LSDj export and import, verified in mGBA

2026-09-04

Songs now write to .lsdsng and to a 32-song cartridge, and read back. Verification boots a real ROM headlessly rather than trusting our own decoder.

Chiptunes can now write files that Little Sound DJ opens, and read them back. src/lsdj.js emits a .lsdsng, the single song LSDj musicians pass around, and a whole .sav cartridge holding up to 32 songs, which takes about 40 ms for ten. api.fromLsdsng() walks either one back the way LSDj does, sequence to chains to phrases to rows, into an ordinary document; it tells the two apart by size, since the name bytes are arbitrary text and cannot be used as a signature. The surfaces are api.toLsdsng(), api.toLsdjSav(), npx chiptunes lsdsng and npx chiptunes lsdjcart in bin/chiptunes.js, the MCP tools export_lsdsng and export_lsdj_cart in mcp/server.js, and a Download LSDj button in the tracker. It is faithful rather than converted: a bar is a phrase of 16 steps, channels map one to one onto PU1/PU2/WAV/NOI, the groove crosses intact, and an arpeggio exports as a C command instead of three hundred rows of spelled out notes, so what lands is a phrase somebody can read.

The verification was built to avoid one specific failure. scripts/verify-lsdj.js reads the output back with liblsdj itself, not with our own decompressor, because a self round trip is code and test agreeing with each other, which this repo had already done once with WebMCP. The gate header carries the two commands that build the reader, a git clone of liblsdj and one clang line over tools/lsdjcheck.c, and without that library present it runs the structural checks and says loudly that the strong one was skipped. The stronger proof came from tools/lsdjplay.c, which boots the owner's real LSDj ROM headlessly in mGBA, loads one of our saves, presses START and reads the decoded channel state. It reports two sample sets on purpose: HZ samples every frame and catches every note including idle channels, so it is the set to check for missing notes, and TRIG samples only while a channel reports playing, so it is the set to check for wrong ones. Gating on the playing flag alone found 9 of 12 notes, a fault in the observer rather than in the music, which is why both sets are kept. Two gotchas live in the harness comments: mGBA faults during reset with SIGBUS and no message if it has no video buffer or no mCoreInitConfig, and NR13/NR23/NR33 are write only, so reading them back through busRead8 returns nothing and the decoded gb->audio.chN state is the honest place to look.

Two real bugs fell out of it. The block jump in the decompressor was off by one: blocks are numbered from 1 and block N lives at (N-1)*512, but the reader went to N*512 and silently lost 512 bytes at every boundary. Nothing caught it because the only image ever round tripped through the codec was the empty song, which compresses to under one block and never jumps at all, while the export measured against it that day was 2569 bytes across five blocks. Exports were fine, since the writer emits correct jumps and LSDj has its own decompressor, but every file we produced was unreadable by us, which is the half that parity needs. The second was pitch. NOTE_ZERO_MIDI shipped as one constant, 36 for every channel, and a straight mapping off it clamped 111 notes of a busy boss cue; a clamped note is a wrong note that looks deliberate, so the export shifted whole octaves instead and seven songs in 32 had to move. The hardware settled it without a Game Boy in the room: the DMG computes pulse frequency as 131072/(2048-x) and wave at half that, so pulse spans MIDI 36 to 108 and wave reaches a full octave lower, 24 to 96. NOTE_BASE became [36, 36, 24, 36] at line 127 of src/lsdj.js, the workaround disappeared, and no song needs a shift any more. verify-lsdj now asserts the table against gb-hardware.inRange() directly and asserts the shift count is zero.