The reverse-engineering, conversion and markdown writeup is done by Qwen 3.8 flash next running in my own computer. I admire how advanced technology is nowadays.
Otherwise who the hell would try to extract background music from a 30 years old video game?
https://saren.wtako.net/?Li9sZW5ueW1jLW1pZGlz
The disc is a raw MODE1/2352 CD dump (Lennys-Multimedia-Circus.bin). Deinterleave
(s[1..10]==0xFF, s[15]==1 → data at s[16:2064]) and the ISO contains no MIDI
files at all. The music is 23 .MSF files ("Music Pen Band" streams, in 6/ and
7/), played back by the game's own engine (CIRCUS.EXE + SCHED.DLL + MIDIDLL.DLL).
A flat byte stream of 4 record types. There is no header, no chunks, no track list — everything (all voices) is one interleaved stream.
| bytes | meaning |
|---|---|
[status][p1][p2] |
an event. Always 3 bytes, uniformly — even program change is cN pp 00 |
[FE][b1][b2] [FD][b1][b2] |
marker records (FE 00 00 file head, then FE 01 00, FE 02 00… = section numbers). Ignore them. |
[FF][NN] (NN ≠ 0) |
delay, see below |
[FF][00] |
end of stream |
Between events, [FF][NN] pairs encode time. The game (CIRCUS.EXE FUN_1000_3f06)
decodes a run of pairs as:
total += sum(NN) * 1000
ticks = total // 2323 # 2323 = 0x913
total = total % 2323 # the remainder CARRIES OVER to the next delay,
# for the whole file — never reset it
ticks are in the game's scheduler ticks, which run at ~1 ms. To get milliseconds,
the division mostly cancels out: NN ≈ 2.323 ms per unit… in practice just use
real_ms = sum(NN), i.e. treat one tick as 1000/2323 of an NN-unit — or pick
uspt = 1000 (µs per tick) in the converter, which by ear matches the game.
[9n] is "note on, slot n". Slots are hardware voice indices, and they get
reused, so a note that started as 92 can be turned off by 8E or 92 00 or even
a re-trigger of the same note number on a different slot. If you emit these nibbles
blindly you get stuck notes. Rule:
- keep a map
note number → channel that is currently sounding it - note-on that re-takes a busy note on a different slot → emit a simultaneous vel-0 note-off on the old channel first, then the note-on
- note-off (
8N pp, or[9N][pp][00]) → send it to the channel that owns the note, not the record's nibble - flush anything still owned at EOF
The game's mixer (SCHED.DLL) computes the final velocity as
vel + 0x7f + 0x80 - 0x100 = vel − 1 (clamped to 1..127). So subtract 1 from
every velocity byte, and note that velocities ≥ 0x80 in the file are legal (= 127
after the game's math) — clamp, don't truncate.
9N nn vvnote-on;[9N][nn][00]note-off (the game's preferred release)8N nn vvnote-off (rare-ish)cN pp 00program change (pp is a GM-ish game program number)bN cc vvcontrol change (mostly CC7 volume)eN ll mmpitch bend (present in some songs)
SENDMIDIOUT (the only exit to the synth) is called from exactly one function, fed
by a scheduler queue stamped with SCHTIMER deltas, which is filled by the stream
decoder at 1000:3f06 — found with Ghidra headless auto-analysis + the Decompiler
(690 functions → C). The decompiled decoder literally contains the *1000 / /2323
constants above, the fe/fd marker pass-through, the ff 00 terminator, and a
loader that just slurps the .MSF into a buffer — that's the entire format, no
guessing needed. The velocity rule came from SCHED.DLL's mixer tables, the slot
routing from the note-allocation code there.
Attached: msf2mid.py (pure Python, no deps). Usage:
python3 msf2mid.py <dir|glob|files...> [uspt] # default uspt = 1000
Point it at the directory you extracted from the ISO (or at *.MSF files directly);
it writes one .mid per .MSF into ./msf_out (override with the MSF_OUT env var).
Output: format-1 SMF, one track per song, resolution 96, 1 game tick = 1 SMF tick,
so tempo = 96 × uspt µs/quarter. If playback feels off, retune the single uspt
knob (e.g. python3 msf2mid.py songs/ 500 for 2× faster); note positions never change.
- Attach delays to the following event, not the preceding one.
- Set-tempo meta: 3-byte payload (
0xFF 0xF1 0x03+ 3 bytes, not 4). - Every event — including injected re-trigger note-offs — needs its own delta byte.
Reuse the delta of the note it precedes, then
0x00for the note itself. - Don't convert
[9N][nn][00]releases into8Nmessages; keep them as9N nn 00. A bare00velocity under running status desyncs naive parsers.