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:

LayerFileRole
Game state machineSOURCES/MUSIC.CPProuting, the PlayMusic decision, the NextMusic queue, fades, pause/resume orchestration
Redbook shimLIB386/AIL/SDL/CD.CPPPlayCD -> Track%02d.wav -> PlayStream
Stream backendLIB386/AIL/SDL/STREAM.CPPone SDL_AudioStream, WAV/OGG decode, the park/resume model
Park decision seamLIB386/H/AIL/STREAM_PARK.Hthe 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 is JINGLE, so everything streams. FirstCDTrack = 6.
  • US (default) -> TrackCDUS: the first entries (Track01-Track06) are plain CD tracks, the rest are jingles. FirstCDTrack differs.

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

SituationResult
Nothing playing, soft requestplays now
Same track re-requestedno-op (never restarts)
Different track, soft requestdeferred into NextMusic
Force (playit=TRUE), different trackreplaces immediately
Force, same track already playingno-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 by FirstCDTrack), otherwise GetNumJingle(StreamName()), otherwise 0. The decision above depends on this round-trip being correct.
  • StopMusic() = StopCD + StopStream.
  • FadeOutVolumeMusic / FadeInVolumeMusic ramp the jingle (and CD) volume over FADE_MUSIC_TIME timer units by spinning the virtual clock (ManageTime), driving to 0 / back to target. Volume flows through the SetVolumeJingle(v) macro = ChangeVolumeStream(RegleTrois(0, MasterVolume, 127, v)), so MasterVolume scales 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:

  1. Logical PauseStream / ResumeStream (the game's PauseMusic / ResumeMusic, for the Options menu and dialogue). PauseStream parks {path, frame} so a resume can restore even if a different stream (the menu theme) played and tore this one down in between. ResumeStream then either fast-unpauses the still-live parked stream, or re-opens the parked file and seeks back (mirrors the MILES redbook PauseCD/ResumeCD).
  2. Device SuspendStreamOutput / ResumeStreamOutput (window focus loss, in AMBIANCE.CPP). A device-only pause that does not touch the parked position, and ResumeStreamOutput un-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)
DecodeSDL_LoadWAV decodes the whole file up front (synchronous)stb_vorbis on a background decode thread, incrementally
Pushall PCM in one call, before the device resumeschunks pushed as decoded
Cachenoneone-slot decoded-PCM cache (oggCache), keyed by path
Seek/restorebyte offset into the loaded PCMstb_vorbis_seek_frame (a seeked decode is not cached), or offset on a cache hit

Three consequences worth knowing:

  • Cold-OGG first-audio gap. FinishStream resumes the device right after PlayOggFile launches 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 NextMusic queue 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_OpenAudioDeviceStream opens 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, so SDL_GetAudioStreamQueued sticks a few bytes above 0 forever and the stream never reports end-of-track. Each source therefore calls SDL_FlushAudioStream once its whole PCM has been pushed (OGG decode thread on natural finish, OGG cache-hit, WAV), which lets the queue drain to 0 so IsStreamPlaying() can report EOF. This matters for the deferred-switch queue (#406): the queued track waits on IsStreamPlaying() 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.cpp compiles the real MUSIC.CPP against 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), CheckNextMusic timing and empty-queue edges, the GetMusic round-trip, and fade convergence.
  • test_music_stream_park.cpp unit-tests the pure STREAM_PARK.H decisions: 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.log and on stderr (the terminal sink emits plain, tagged lines even when redirected), so grep the file or read stderr; logs never go to stdout. (These [MUSIC]/[CD] lines are INFO, so the toggle alone surfaces them; a --log-level warn run would suppress them.)

See AUDIO.md for the full logging reference and audio global status.