Skip to content

Instantly share code, notes, and snippets.

@Saren-Arterius
Last active September 14, 2026 22:22
Show Gist options
  • Select an option

  • Save Saren-Arterius/1485d5528fe26f7b942a48531f21c329 to your computer and use it in GitHub Desktop.

Select an option

Save Saren-Arterius/1485d5528fe26f7b942a48531f21c329 to your computer and use it in GitHub Desktop.

Lenny's Multimedia Circus (1995) background music extraction — .MSF → .MID

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?

Results

https://saren.wtako.net/?Li9sZW5ueW1jLW1pZGlz

Where the music is

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).

The .MSF file format

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

Timing

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.

The status nibble is a voice slot, not a channel

[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

Velocity is off by one

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.

Event kinds seen

  • 9N nn vv note-on; [9N][nn][00] note-off (the game's preferred release)
  • 8N nn vv note-off (rare-ish)
  • cN pp 00 program change (pp is a GM-ish game program number)
  • bN cc vv control change (mostly CC7 volume)
  • eN ll mm pitch bend (present in some songs)

How this was decoded

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.

Converting

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.

Gotchas (each one cost real hours)

  • 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 0x00 for the note itself.
  • Don't convert [9N][nn][00] releases into 8N messages; keep them as 9N nn 00. A bare 00 velocity under running status desyncs naive parsers.
#!/usr/bin/env python3
# Lenny's Multimedia Circus .MSF (Music Pen "Band") -> Standard MIDI File
# Format from Ghidra decompile of CIRCUS.EXE (FUN_1000_3f06 / FUN_1000_406a):
# [status][p1][p2] : 3-byte event record (uniform; cN has p2=0)
# [FE][b1][b2]/[FD][b1][b2] : marker records (section ids; ignored)
# [FF][NN] NN!=0 : delay; run accumulates sum(NN)*1000, ticks = total//2323
# remainder carries across the whole stream
# [FF][00] : end of stream
import glob, os, struct, sys
USPT = 1000 # microseconds per scheduler tick (retune here)
RES = 96 # one SMF tick per scheduler tick
def parse_msf(d):
i = 0; rem = 0; pend = 0; evs = []
while i < len(d):
b = d[i]
if b in (0xFD, 0xFE):
i += 3
elif b == 0xFF:
nn = d[i+1]
if nn == 0: break
t = rem + nn*1000; rem = t % 2323
pend += t // 2323
i += 2
else:
evs.append([pend, b, d[i+1], d[i+2]]); pend = 0; i += 3
return evs
def vlq(n):
out = bytes([n & 0x7F]); n >>= 7
while n:
out = bytes([(n & 0x7F) | 0x80]) + out; n >>= 7
return out
def to_mid(path):
evs = parse_msf(open(path, 'rb').read())
assert evs, 'empty stream ' + path
note_ch = {}
tr = bytearray()
for dlt, st, p1, p2 in evs:
hi = st & 0xF0; n = st & 0x0F
if hi == 0x90 and p2 == 0:
tr += vlq(dlt) + bytes([0x90 | note_ch.pop(p1, n), p1, 0]) # release via vel-0 note-on
continue
extra = b''
if hi == 0x90:
p2 = min(127, max(1, p2 - 1)) # MSF velocity is +1 vs MIDI (SCHED vel-1 rule)
prev = note_ch.get(p1)
if prev is not None and prev != n:
extra = bytes([0x90 | prev, p1, 0]) # close stale voice before re-trigger
note_ch[p1] = n
elif hi == 0x80:
n = note_ch.pop(p1, n)
p2 &= 0x7F
elif hi in (0xA0, 0xB0, 0xE0):
p2 &= 0x7F
if extra:
tr += vlq(dlt) + extra + vlq(0) # extra carries the delta, main event is simultaneous
else:
tr += vlq(dlt)
if hi == 0xC0:
tr += bytes([0xC0 | n, p1])
elif hi == 0xE0:
tr += bytes([0xE0 | n, p1 & 0x7F, p2 & 0x7F])
else:
tr += bytes([hi | n, p1, p2])
for note, ch in note_ch.items():
tr += vlq(0) + bytes([0x80 | ch, note, 0])
tr += vlq(0) + b'\xFF\x2F\x00'
head = vlq(0) + b'\xFF\x51\x03' + struct.pack('>I', USPT * RES)[1:]
head += vlq(0) + b'\xFF\x06\x02\x01\x00'
head += tr
return b'MThd' + struct.pack('>IHHH', 6, 1, 1, RES) + b'MTrk' + struct.pack('>I', len(head)) + head
if __name__ == '__main__':
args = sys.argv[1:]
if len(args) > 1 and args[-1].isdigit():
USPT = int(args.pop())
if not args:
args = ['/tmp/lenny']
files = []
for a in args:
if os.path.isdir(a):
files += sorted(glob.glob(os.path.join(a, '**', '*.MSF'), recursive=True))
else:
files += sorted(glob.glob(a))
assert files, 'no .MSF files found'
outs = os.environ.get('MSF_OUT', os.path.join('.', 'msf_out'))
os.makedirs(outs, exist_ok=True)
for f in files:
open(os.path.join(outs, os.path.basename(f)[:-4] + '.mid'), 'wb').write(to_mid(f))
d = to_mid(files[0])
assert d[:4] == b'MThd' and d.count(b'MTrk') == 1
print(f'wrote {len(files)} midis to {outs} (uspt={USPT})')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment