Music state machine
July 9, 2026 ยท View on GitHub
How the game decides what music plays and when: the track routing, the
PlayMusic decision, the deferred-switch queue, and the stream backend that
turns a CD/jingle request into audio. This is the layer that produced #353-#355
and the resume-silence regression.
For the retail assets, the full AIL contract, SFX/ambience/voice scripting, and diagnostic logging, see AUDIO.md. For the clocks the fades and the defer window run on, see TIMING.md.
Two eras, one stream
The 1997 engine mixed redbook CD tracks with short streamed ADPCM jingles.
The SDL port keeps the CDROM build define on (it is set in
SOURCES/CMakeLists.txt), so the CD code paths are compiled in, but there is no
CD hardware: LIB386/AIL/SDL/CD.CPP maps PlayCD(track) to MUSIC/Track%02d.wav
and hands it to PlayStream. So in practice every track, CD or jingle, is one
SDL audio stream (a WAV or its OGG transcode). There is a single stream device
at a time.
Layers:
| Layer | File | Role |
|---|---|---|
| Game state machine | SOURCES/MUSIC.CPP | routing, the PlayMusic decision, the NextMusic queue, fades, pause/resume orchestration |
| Redbook shim | LIB386/AIL/SDL/CD.CPP | PlayCD -> Track%02d.wav -> PlayStream |
| Stream backend | LIB386/AIL/SDL/STREAM.CPP | one SDL_AudioStream, WAV/OGG decode, the park/resume model |
| Park decision seam | LIB386/H/AIL/STREAM_PARK.H | the pure pause/resume/focus decisions, host-tested |
Track routing
PtrTrackCD[num] maps a logical track index to a byte: the top bit JINGLE
(0x80) marks a streamed jingle, the low 7 bits (MUSIC mask, 0x7F) are the
track/jingle number. Two layouts, selected by DistribVersion in InitTabTracks:
- EU (
EA_VERSION/UNKNOWN_VERSION) ->TrackCD: every entry isJINGLE, so everything streams.FirstCDTrack = 6. - US (default) ->
TrackCDUS: the first entries (Track01-Track06) are plain CD tracks, the rest are jingles.FirstCDTrackdiffers.
PlayMusic reads PtrTrackCD[num]: JINGLE set -> PlayJingle, else (CDROM)
PlayCD. PlayJingle(n) streams ListJingle[n - FIRST_JINGLE + 1] + ".WAV"
(FIRST_JINGLE = 2); the ListJingle table holds the JADPCM* / TADPCM*
basenames. GetNumJingle reverses it: it strips the path and extension from the
playing stream name and matches it back to a ListJingle index, so GetMusic
can report which jingle is playing.
The PlayMusic decision
PlayMusic(num, playit) is the heart of the state machine. num is the track
index; playit (a.k.a. force) chooses immediate replacement over deferral.
NextMusic = -1
cur = (playit OR StopLastMusic) ? 0 : GetMusic() // what is playing now (0 = nothing)
StopLastMusic = playit
if cur == 0: // nothing playing (or forced)
if !playit OR GetMusic() != this-track: // don't restart the exact same track
StopMusic(); PlayJingle/PlayCD(this-track) // play now
else: // something else is playing
if cur != this-track:
NextMusic = num // DEFER (queue for later)
NextMusicTimer = now + TEST_MUSIC_TEMPO // 2000 ms
The matrix that falls out of this (all covered by tests, see below):
| Situation | Result |
|---|---|
| Nothing playing, soft request | plays now |
| Same track re-requested | no-op (never restarts) |
| Different track, soft request | deferred into NextMusic |
Force (playit=TRUE), different track | replaces immediately |
| Force, same track already playing | no-op |
StopLastMusic set (call right after a force) | next soft request plays now, not deferred |
The deferred-switch queue
A soft request for a different track does not cut the current one; it parks the
request in NextMusic with a now + TEST_MUSIC_TEMPO (2 s) deadline. The purpose
is to ride out a quick pass-through of an area instead of chopping its music.
CheckNextMusic() (called every frame from PERSO.CPP) drains it:
if NextMusic != -1 AND now > NextMusicTimer AND !IsStreamPlaying():
PlayMusic(NextMusic, FALSE) // current track has ended -> start the queued one (soft)
The switch waits for two things: the 2 s debounce window and the current track
finishing. This is retail parity (#406): scene music plays the current track out
and only then moves to the next, instead of hard-cutting. IsStreamPlaying()
reports EOF (and detaches the spent stream), so by the time the drain runs there is
silence to play into.
The drain is soft (playit = FALSE), and that matters. Because the gate has
already established the current stream ended (GetMusic() is 0), the soft path plays
the queued track immediately rather than re-deferring, so it still takes over. The
point of FALSE is that it leaves StopLastMusic clear, marking the queued track as
an ordinary cube jingle. ChangeCube() does if (StopLastMusic) StopMusic(), so a
forced (TRUE) drain would get the queued jingle hard-stopped almost immediately on
the next area change, and the "play out, then switch" behaviour would only ever work
for the first hop. Soft keeps the chain going across areas.
This supersedes #355, which forced the switch the moment the window elapsed and so
cut the current track off. It forced because a soft PlayMusic(FALSE) re-deferred
forever: for the jingle layout GetMusic() reads StreamName(), which is never
cleared at natural EOF (IsCDPlaying() short-circuits on cdPlaying == 0, so
IsStreamPlaying() -- the call that tears a finished stream down -- never runs), so
a finished jingle looked like it was still playing. Gating CheckNextMusic on
IsStreamPlaying() reads the real playback state, which removes the reason to force:
the queued track still takes over (the #355 intent) without chopping the current one
(the #406 intent), and stays soft so the chain survives the next ChangeCube.
Consequence: the new track starts when the current one ends (plus up to the 2 s
window). On a cube change the old jingle plays out; the new one is queued and takes
over once the stream reaches EOF. Lever if you want a hard cut instead: make the
cube-entry call in PERSO.CPP force (PlayMusic(CubeJingle, TRUE)), which bypasses
the queue -- the same path cutscenes, FMVs, and chapter transitions already take.
GetMusic, StopMusic, fades
GetMusic()reports what is playing:IsCDPlaying()(returns the track + it is offset byFirstCDTrack), otherwiseGetNumJingle(StreamName()), otherwise0. The decision above depends on this round-trip being correct.StopMusic()=StopCD+StopStream.FadeOutVolumeMusic/FadeInVolumeMusicramp the jingle (and CD) volume overFADE_MUSIC_TIMEtimer units by spinning the virtual clock (ManageTime), driving to 0 / back to target. Volume flows through theSetVolumeJingle(v)macro =ChangeVolumeStream(RegleTrois(0, MasterVolume, 127, v)), soMasterVolumescales everything. See AUDIO.md for the fade asymmetry note.
Pause and resume: two layers
Pause/resume is a reversible pair and must never StopMusic first: a Stop is
destructive on the SDL stream (it tears the device stream down), so a resume would
have nothing to unpause. This is the resume-silence regression AUDIO.md records.
There are two independent pause layers, both driving the one stream device:
- Logical
PauseStream/ResumeStream(the game'sPauseMusic/ResumeMusic, for the Options menu and dialogue).PauseStreamparks{path, frame}so a resume can restore even if a different stream (the menu theme) played and tore this one down in between.ResumeStreamthen either fast-unpauses the still-live parked stream, or re-opens the parked file and seeks back (mirrors the MILES redbookPauseCD/ResumeCD). - Device
SuspendStreamOutput/ResumeStreamOutput(window focus loss, inAMBIANCE.CPP). A device-only pause that does not touch the parked position, andResumeStreamOutputun-pauses the device only if the stream is not logically paused. That nesting invariant keeps a focus blip during a menu/dialogue pause from un-pausing the music.
The decision logic for both (fast-unpause vs restore-from-park vs nothing; and the
focus nesting rule) is extracted, SDL-free, into LIB386/H/AIL/STREAM_PARK.H
(Stream_ShouldPark, Stream_ResumeAction, Stream_ShouldResumeDeviceOnFocus).
STREAM.CPP's PauseStream/ResumeStream/ResumeStreamOutput are thin wrappers
that call these and run the SDL side effects, so the invariants are unit-testable.
Stream backend: WAV vs OGG
The game always requests .WAV; PlayStreamInternal (STREAM.CPP) resolves it,
trying filesystem WAV, then the .ogg transcode, then a mounted disc image, then
cue audio. WAV and OGG then reach the stream very differently:
WAV (SetupWavStream) | OGG (PlayOggFile) | |
|---|---|---|
| Decode | SDL_LoadWAV decodes the whole file up front (synchronous) | stb_vorbis on a background decode thread, incrementally |
| Push | all PCM in one call, before the device resumes | chunks pushed as decoded |
| Cache | none | one-slot decoded-PCM cache (oggCache), keyed by path |
| Seek/restore | byte offset into the loaded PCM | stb_vorbis_seek_frame (a seeked decode is not cached), or offset on a cache hit |
Three consequences worth knowing:
- Cold-OGG first-audio gap.
FinishStreamresumes the device right afterPlayOggFilelaunches the decode thread, so the device can run dry for tens of ms until the first chunk is pushed. WAV and OGG-cache-hit push all their PCM first, so they have no such gap. A scene switch is always a different track (a cache miss), so it always takes the cold OGG path. - The cache and the queue do not compose. The OGG cache is a single slot for
same-track replay/restore. The
NextMusicqueue exists to switch to a different track, which is a cache miss that also evicts the previous track. So the cache buys nothing on queue-driven area changes, and toggling between two adjacent areas re-decodes each time. This is an efficiency gap, not a bug (the cache is path-keyed; partial/seeked decodes are correctly not cached). - EOF needs a flush.
SDL_OpenAudioDeviceStreamopens the device at its native format, so SDL sits a format converter in the path. The converter holds a small tail of input it will not emit until told no more is coming, soSDL_GetAudioStreamQueuedsticks a few bytes above 0 forever and the stream never reports end-of-track. Each source therefore callsSDL_FlushAudioStreamonce its whole PCM has been pushed (OGG decode thread on natural finish, OGG cache-hit, WAV), which lets the queue drain to 0 soIsStreamPlaying()can report EOF. This matters for the deferred-switch queue (#406): the queued track waits onIsStreamPlaying()going false, so without the flush it would wait forever.
Testing
The music state machine is host-tested; the stream decode/threading is not (that
is SDL and library territory). Two targets under tests/music/:
test_music_pause_resume.cppcompiles the realMUSIC.CPPagainst a spy AIL backend (records ordered calls; a small model of "what is playing" that the test controls). Covers pause/resume-never-Stop (CD and jingle layouts), faded resume, the #406/#355 deferred switch (the queue waits for the current track to end, then the queued one takes over), the full decision matrix (nothing/same/different/force/StopLastMusic),CheckNextMusictiming and empty-queue edges, theGetMusicround-trip, and fade convergence.test_music_stream_park.cppunit-tests the pureSTREAM_PARK.Hdecisions: fast-unpause vs restore-from-park vs nothing, and the focus-nesting invariant.
Why a mock rather than the real backend: the runtime control harness (--exec,
--tick) cannot reproduce music-state bugs, because its headless audio never
reports a stream as playing (IsStreamPlaying() stays 0), so PlayMusic always
takes the clean "nothing playing" path. The spy mock lets a test set "track X is
playing" and assert what the state machine does. Grow these files scenario by
scenario as behaviour is pinned down.
Debugging on a live game
audio global log 1(console) turns on the[MUSIC]/[CD]trace channel.cube <N>jumps to a scene, which runs the cube-entry music path.- Traces land in
adeline.logand on stderr (the terminal sink emits plain, tagged lines even when redirected), so grep the file or read stderr; logs never go tostdout. (These[MUSIC]/[CD]lines are INFO, so the toggle alone surfaces them; a--log-level warnrun would suppress them.)
See AUDIO.md for the full logging reference and audio global status.