XACT at run time: engine, banks, cues and their limits
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Checked by reading AudioEngine, SoundBank, WaveBank, Cue, AudioCategory and the XACT parser at 009d40f5, with the FAudio behaviours they cite taken from CNA's own source comments. Nothing was built or executed, and no output was compared with real XACT or FAudio.
CNA reads XACT content with its own parser and plays it through the selected mixer; it does not use Microsoft's XACT engine or FNA's FAudio. This page states the run-time contract of AudioEngine, WaveBank, SoundBank, Cue and AudioCategory precisely: how files fail to load, who owns a cue and for how long, what AudioEngine::Update() does, how variations, runtime parameter curves, categories and instance limits behave, and exactly where fidelity stops. It is for anyone porting an XNA title that ships XACT banks. The task-level guide, with the setup order, the two ways of playing a cue and the three headline caveats, is Tutorial 120: XACT; the mixer underneath is described on Audio engine internals.
Loading and failure
The three files are loaded by path through the selected platform's title-content reader, never through ContentManager. The parser in XactParser.cpp bounds-checks every read and throws on data that runs past the end; names are bounded by the real end of the buffer, a hardening that followed an over-read found by AddressSanitizer on corrupt input. XACT content version 46 is expected: an .xgs or .xsb with another version, or an .xwb newer than 46, is still parsed after a warning on standard error. The constructors fail as FNA's do:
| Object | Missing file | Existing but corrupt file |
|---|---|---|
AudioEngine(settingsFile) | FileNotFoundException | InvalidOperationException ("Engine initialization failed!") |
WaveBank(engine, file) (in memory) | FileNotFoundException | No exception: a diagnostic on standard error and an unprepared bank, as FNA never checks the native result |
WaveBank(engine, file, offset, packetSize) (streaming) | No exception: FNA's streaming path opens the file natively, not through the title container | A diagnostic on standard error |
An empty file name throws ArgumentNullException in each case. The AudioEngine constructor's look-ahead time and renderer id are accepted and ignored: there is one mixer and nothing to schedule ahead. getRendererDetailsProperty() preserves XNA's enumeration shape with exactly one entry, "CNA mixer (ALSA)" under the ALSA selection and "SDL3_mixer" otherwise (including under SDL2 and NULL, where nothing is audible); it does not enumerate physical audio devices.
Each format is recognized by a four-byte magic at the start of the file: XGSF for the .xgs settings file, SDBK for the .xsb sound bank and WBND for the .xwb wave bank; any other magic is rejected. The .xgs and .xsb checks also accept the byte-swapped spelling (FSGX, KBDS) used by big-endian console builds, but the parser reads every later field in host byte order, so a genuinely big-endian file passes the magic check and is then misparsed instead of being rejected (the source comment calls the acceptance cosmetic); the wave bank check accepts only the little-endian form. Treat files from the Windows toolchain as the supported input. Read from XactParser.cpp at 009d40f5; not executed.
Who owns what, and for how long
- Engine and banks. The engine registers its wave and sound banks and cascades disposal: disposing the engine disposes every bank and cue it created, so no bank outlives its engine.
- Fire-and-forget cues.
SoundBank::PlayCue(name)and its 3D overload create a cue owned by the bank. The bank sweeps finished ones on eachAudioEngine::Update(); a paused one is kept. A fire-and-forget cue that is still playing five minutes after it started is also swept: this "safety net" inSoundBank.cpphas no XNA counterpart and cuts off a long looping cue started this way, so start music and long ambiences withGetCue, which the sweep never touches. - Held cues.
GetCue(name)returns anew Cue*that the caller owns and must dispose and delete. It throwsArgumentNullExceptionfor an empty name,ObjectDisposedExceptionfor a disposed bank andInvalidOperationException("Invalid cue name!") for a name the bank does not contain;AudioEngine::GetCategoryfollows the same three rules. - Cue registration. A cue registers with its bank in its constructor and unregisters only when disposed. Earlier code registered it only on
Play(), so a cue obtained but never played could outlive a destroyed bank with a dangling bank pointer; the current rule covers the whole interval fromGetCueto disposal. Disposing aSoundBankdisposes every cue still associated with it, fire-and-forget or held, asFACTSoundBank_Destroydoes; the memory of a held cue is still the caller's to free. IsInUse. A bank is in use while any associated cue is playing or paused: a paused cue still holds its resources, as in FACT.
What AudioEngine::Update() does
AudioEngine::Update() is FNA's FACTAudioEngine_DoWork equivalent and must run every frame. It reconciles each active cue's state, which also re-evaluates its runtime parameter curves and advances authored fades, then sweeps finished fire-and-forget cues. A cue's state is also reconciled whenever it is queried, so a read is never stale, but a fade-out that nobody queries is only audible as a ramp if Update() ticks it; without the call it jumps from full volume to silence.
Cue state and playback
A new cue is Prepared. Cue exposes XACT's states as separate queries (IsCreated, IsPreparing, IsPrepared, IsPlaying, IsPaused, IsStopping, IsStopped, IsDisposed) and keeps pause as an independent flag on top of Playing, so IsPlaying and IsPaused can both be true (Cue.cpp). Stop(AudioStopOptions::AsAuthored) enters Stopping for the authored fade-out, or for the release time of a volume curve bound to the ReleaseTime variable when no fade-out is authored, and only then reaches Stopped; Immediate cuts at once. Pause and Resume are idempotent.
When a cue plays, CNA spawns one sound instance per track. Parsing walks each track's event list only up to its first play-wave event, so a track authored to fire several waves in sequence plays only the first. Variation tables are a separate mechanism and are supported in all five authored modes (Ordered, OrderedFromRandom, Random, RandomNoRepeats, Shuffle), with FAudio's one-time initial value (an ordered table starts before its first entry) and weighted selection; effect variations apply their per-play pitch, volume and filter offsets. Loop regions authored on a wave-bank entry are applied when the play-wave event asks to loop.
Cue::Apply3D(listener, emitter) takes one listener, as XNA's signature does, forwards the pan, attenuation and Doppler of the sound-effect formulas to every wave the cue is playing, and writes the cue's built-in variables Distance, DopplerPitchScalar and OrientationAngle (the angle between the emitter's forward vector and the listener direction), which runtime parameter curves can read.
Variables and runtime parameter control
Global variables live on the engine (SetGlobalVariable, GetGlobalVariable) and cue variables on each cue; a variable belongs to one scope, as the settings file declares, and a read-only variable ignores writes silently as in FACT. Runtime parameter control (RPC) curves are evaluated continuously, not once at Play(): on every reconciliation each curve bound to the cue is evaluated at its variable's current value, following FAudio's FACT_INTERNAL_UpdateRPCs:
- volume curves are authored in centibels and summed across every bound curve before one conversion to an amplitude,
10sum/2000; - pitch curves are authored in cents and summed with the sound's base pitch before one conversion to a ratio;
- filter-frequency and filter-Q curves overwrite rather than accumulate: the last curve evaluated wins;
- the variables
AttackTimeandReleaseTimeare live values (milliseconds since play, and since the start of an RPC-only release), visible only to curve evaluation and never stored back into the cue's variables; - curves targeting the reverb send or a DSP preset parameter are ignored: there is no DSP preset system and no reverb bus.
Categories and instance limits
AudioCategory::SetVolume(v) changes cues that are already playing, not only future ones, and v is a multiplier on the category's authored base volume, not a replacement. The change cascades to child categories, each receiving its own pre-cascade value as the new multiplier; a repeated SetVolume on a parent therefore compounds a child's authored volume against itself, which is FACT's real formula and is reproduced rather than "fixed" (AudioEngine.cpp). Pause, Resume and Stop act on every cue of the category.
Instance limits are enforced when a cue starts, first the cue definition's own limit and then its category's, as in FACT's play_sound. A cue still fading out counts toward a limit but is never chosen as the victim.
| Authored behaviour | What CNA does at the limit |
|---|---|
FAIL | Rejects the new cue: it stays Stopped without an exception. |
QUEUE, REPLACE_OLDEST, REPLACE_QUIETEST | All three evict the oldest cue that is still playing, fading it out with the authored fade-out, and start the new one with the authored fade-in. FAudio treats QUEUE like REPLACE_OLDEST and its REPLACE_QUIETEST branch is unfinished, so CNA reproduces that shipped behaviour instead of implementing a real quietest search. |
REPLACE_LOWEST_PRIORITY | Evicts the playing cue with the lowest priority; the one behaviour FAudio implements fully. |
The victim search differs by level. For a category limit it considers only cues in the same category. For a cue-level limit it considers every playing cue in the same sound bank, whatever its definition or category, because FAudio calls its eviction routine with no category there; a cue-level limit can therefore evict an unrelated cue. When both levels trigger, the category's fade-in overwrites the cue's. InstancePlayLimitException exists as a public type, but no production path throws it, for XACT or for SoundEffect.
Per-track filters and reverb
Complex sounds can carry a per-track filter. CNA decodes the filter type from the track data with FAudio's own bit arithmetic, which can only yield low-pass or high-pass; a band-pass filter is structurally unreachable from parsed content, although the sound instance implements one. The authored Q byte becomes OneOverQ = min(3 / q, 1) as in FACT, and filter RPC curves retarget frequency and Q live. The reverb send is accepted and does nothing, since neither mixer has an auxiliary bus.
Wave banks
A wave-bank entry is PCM (8-bit or 16-bit), MS-ADPCM, XMA/XMA2 or WMA (WaveBank.cpp). 16-bit PCM is used directly; 8-bit PCM and MS-ADPCM entries are wrapped as WAV data (MS-ADPCM with its standard coefficient table) and decoded; the authored loop region is written into a smpl chunk so an intro-then-loop wave keeps its loop points. XMA/XMA2 and WMA have no decoder anywhere in the stack: the entry logs a diagnostic naming the bank, the wave and the format, and the sound is simply missing, with no exception. An .xnb sound in XMA2, by contrast, is rejected by the content reader with an exception.
Decoded entries are cached per bank on first use behind one mutex, so disposal cannot race decoding. The streaming constructor exists for API compatibility but does not stream packets: offset and packetSize are ignored, as FNA's own streaming constructor never forwards them, and each entry's bytes are read from the file on first use and then cached like an in-memory entry. Memory use is therefore bounded by the entries actually played, not by a streaming buffer.
Evidence
The parser is covered by XactParserTests.cpp and a fuzz suite; engine, bank, cue and category behaviour by AudioEngineTests.cpp, CueTests.cpp, SoundBankTests.cpp, WaveBankTests.cpp and AudioCategoryTests.cpp, most of which run only under the SDL3 audio selection (see the audio test table). The repository's cna_demo_xact example generates its own banks. None of that compares CNA's output with real XACT or FAudio, and nothing on this page was executed.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- Tutorial 120: XACT · Audio System guide: XACT
- Architecture
- Audio and input architecture: audio layers
- Internals
- Audio engine internals
- Tests and validation
- Test architecture and change recipes
- Deep dives
- Sound effects, streaming and capture