Audio Events And The Backend Split
How AudioEventRTS resolves sound variations, ownership, and loops — and how MilesAudioManager's contract is implemented by SDLAudioManager in the remaster.
The gameplay code does not usually tell the audio backend to play a literal file. It creates an AudioEventRTS, and that object carries enough information for the backend to resolve the right variation, position, and playback policy later. The original shipping engine routed that through MilesAudioManager; the remaster replaces the device layer with SDLAudioManager while keeping the AudioEventRTS front half untouched.
AudioEventRTS in Core/GameEngine/Source/Common/Audio/AudioEventRTS.cpp is the front half of the system. It stores the event name, cached AudioEventInfo, logical-vs-local randomization mode, loop count, selected filenames, owner identity, and the handle of the currently playing sound. setEventName is intentionally small but important: if the event name changes, the cached event-info pointer is invalidated so the next resolve cannot accidentally reuse metadata from a different event.
generateFilename is where one event becomes a concrete file choice. For music and streaming entries, the file name is effectively fixed and only needs localization adjustment. For ordinary sound effects, the function chooses one variation from the event's m_sounds list. If the event is marked random, logical audio uses the game-logic RNG so every client can make the same choice deterministically, while nonlogical local audio uses the local audio RNG. If the event is not random, the function round-robins through the list instead. It also generates a fresh per-loop delay each time a filename is chosen.
generatePlayInfo fills in the rest of the playback plan: pitch shift, volume shift, loop count, attack sound, main sound, and decay sound. The attack and decay sections matter because an event can be more than one file. The backend can play a startup clip, then a sustained sound, then a tail clip without the caller having to manage three separate handles.
Positional ownership is handled the same way. isPositionalAudio first checks the event type bits and then asks whether the event is bound to a world position, object ID, or drawable ID. getCurrentPosition resolves live coordinates lazily. If the sound belongs to an object or drawable, the function looks up the current entity each time it is queried, updates the cached world position, and marks the owner as dead when that lookup fails.
SDLAudioManager in Platform/SDLAudioManager.cpp is the back half in the remaster. playAudioEvent branches on the event's sound type and ownership. Music and streaming entries become long-lived SDL streams. Positional sound effects become 3D samples. Nonpositional sound effects become ordinary 2D samples:
void SDLAudioManager::playAudioEvent(AudioEventRTS* event) { const AudioEventInfo* info = event->getEventInfoChecked(); if (!info) return; switch (info->m_soundType) { case AT_Music: playStream(event); break; case AT_Streaming: playStream(event); break; case AT_SoundEffect: if (event->isPositionalAudio()) playSample3D(event); else playSample2D(event); break; } }
The manager also supports handle replacement and lowest-priority eviction so urgent sounds can steal channels from less important ones. The legacy MilesAudioManager.cpp still exists in the tree for reference, but the Platform/SDLAudioManager is what actually serves audio.
The volume path is more nuanced than a simple master-volume multiply. getEffectiveVolume applies the event's explicit volume and random volume shift first, then multiplies by the correct category slider. For positional sound effects it also computes distance attenuation from the listener. If the event is tagged ST_GLOBAL, the min and max distances come from global audio settings. Otherwise the event's own min and max range are used. Once the source is beyond max distance, the effective volume falls to zero.
3D playback is finalized in playSample3D. That function resolves the current world position, loads the file, registers completion callbacks, sets the sample's min and max distances, writes the 3D position, and only then starts playback. The listener side is updated separately by setDeviceListenerPosition, which pushes the tactical camera's orientation and position into the audio backend.
There is also a delayed-loop path that is easy to miss. startNextLoop can decide not to play the next loop immediately if the regenerated event carries a delay larger than one logic frame. The event is moved back into the request queue instead of being destroyed, so loop delays are part of normal event semantics rather than something callers have to emulate manually.
Quirks
- Logical audio randomness is deterministic on purpose.
AudioEventRTSuses the game-logic RNG for logical events and the local audio RNG for nonlogical events. Mixing them up would either break lockstep (logical event diverges between peers) or pin local variation (same voice line every time). - UI acknowledgements are treated as 2D sounds even though they are
AT_SoundEffectentries. The 2D/3D split is about whether the event is positional, not about its declared sound type. - Streams, 2D samples, and 3D samples are separate channel pools. Replacement and priority decisions are type-specific. Starving one pool doesn't free channels in another.
- Delayed looping can push an event back into the request queue. Instead of immediately replaying the same handle. Debug tooling that "shows active handles" won't see a looping event during its delay window.
- SDL backend fills earlier gaps.
SDLAudioManagerreintroducedreset()and category-filter overrides that the initial port dropped, plusdoesViolateLimit/isPlayingAlready/isObjectPlayingVoiceoverlap gates that prevent same-sample phase-sum stacking. See sdl3_audio_backend_gaps.md and sdl3_audio_overlap_gates.md. - The engine stops audio via subsystem hooks, not explicit calls. Mission transitions, pause, and map load call into hooks that fan out stop decisions to the audio manager. A new subsystem that wants to nuke all audio must use the hooks or it will leak active streams across scene boundaries.
- Completion callbacks drive looping. The SDL backend registers per-stream completion and routes it back through the request/playing list machinery. A callback that fails to fire (rare — SDL is reliable here) would leave a sample in the playing list forever.