Skip to content

Latest commit

 

History

History
601 lines (529 loc) · 222 KB

File metadata and controls

601 lines (529 loc) · 222 KB

CNA Audio Perfection Plan

Generated: 2026-07-17
Basis: independent deep source/test/fixture audit.
Important: the previous contents of this file were intentionally not read and were replaced from scratch, as requested.
Backup: the untouched original file is preserved as plan_audio_20260717_previous_unread.md; it was copied without inspection.
Companion report: docs/audio_deep_audit_2026-07-17.md

Mission

Task count: 438 (224 P0, 193 P1, 21 P2).

Reach evidence-based XNA 4.0 audio compatibility: correct sample rate and pitch, complete supported content loading, truthful errors/state, deterministic XACT behavior, safe streaming/lifetimes, and measurable cross-platform output. “Sounds okay” is not completion; every core behavior needs a reproducible fixture and numerical or differential acceptance evidence.

User-reported release blockers

  • Audio Issue: High-pitched sound effects reported — determine whether incorrect source sample rate, device conversion, explicit pitch, XACT cents/RPC/variation, or Doppler is changing speed/pitch.
  • Audio Issue: Some audio files appear missing or fail to load — audit path/case/packaging and add required XNB/XWB codec support; no silent null/return is acceptable.
  • User Report: Gameplay audio sounds distorted or sped up — measure playback rate, duration, dominant frequency, sample format, channels, and output-device negotiation against original XNA.

These three boxes may only be checked after the exact game/assets have matched XNA/CNA captures, a proven root cause, regression tests, and before/after evidence.

Progress note (2026-07-17, no exact game/assets available -- see AUD-01): built a real offline render/measurement harness (AUD-03) and used it to rule out one entire hypothesis class: SDL3_mixer's own resampler, run directly via MIX_CreateMixer()/MIX_Generate(), correctly preserves frequency and duration when a correctly-declared 22050 Hz or 48000 Hz source is rendered through CNA's hard-coded 44100 Hz mixer spec (AudioMixer.cpp), in both directions (OfflineAudioRendererTests.cpp: Source22050HzThroughRenderMixer44100HzPreservesFrequency, Source48000HzThroughRenderMixer44100HzPreservesFrequency, Source44100HzThroughRenderMixer48000HzPreservesFrequency). A parallel test (MisdeclaredSourceRateReproducesExactlyDoubleFrequencySignature) confirms that declaring a 22050 Hz buffer AS 44100 Hz reproduces the audit's exact reported 2x-speed/+1-octave signature. This means, absent the exact affected game, the most likely remaining root causes are upstream of the mixer's resampler itself -- a wrong sample rate reaching SoundEffect's raw constructor or the XNB reader (AUD-05/AUD-06, not yet fixed), or a pitch/RPC/Doppler contributor being double-applied (AUD-08/AUD-09/AUD-10, largely not yet audited this pass) -- not the mixer/backend choice itself. Still needs AUD-01's actual differential capture to become a proven, closeable root cause for a specific game; this is ruling out one whole hypothesis class, not closing the ticket.

Priority and completion rules

  • P0: correctness/reproduction/data-loss/silence/distortion/state-safety blocker; complete before broad feature work.
  • P1: parity, robustness, cross-platform, performance, and major completeness work.
  • P2: lower-risk completeness, optional extensions, tools, and polish after P0/P1 gates.
  • A task is complete only when implementation, automated test, negative test, documentation, and relevant platform evidence exist.
  • Suspected defects must first be demonstrated by a minimal fixture. Do not “fix” parser/math behavior from comments alone.
  • Do not tune by ear before the offline capture/measurement harness exists.
  • Preserve XNA defaults. NOXNA enhancements must be opt-in and must not alter default XNA-compatible behavior.

Global numerical gates

  • Neutral pitch final ratio: 1.0 within floating-point tolerance.
  • Calibration tone frequency: initial gate ±0.1%.
  • Offline PCM duration/frame count: exact; end-to-end resampled duration: initial gate ±2 ms.
  • No silent SDL/MIX failure and no public Playing state after failed play.
  • No missing shipped asset and no unsupported shipped codec without build-time failure/conversion.
  • Zero ASan/UBSan/LSan findings; zero confirmed TSan race in supported configurations.

AUD-00 — Audit governance, scope, and evidence preservation

Establish an independent, reproducible audio program and prevent “fixed by ear” regressions.

  • AUD-00-001 [P0] Preserve the reported high-pitch, missing-file, and distorted-audio reports as named release blockers. Acceptance: Each report has an issue ID, affected title/build/platform, owner, and closure evidence.
  • AUD-00-002 [P0] Create an audio compatibility charter defining XNA 4.0 parity versus documented NOXNA extensions. Acceptance: Every behavior is classified as exact parity, acceptable backend divergence, extension, or unsupported.
  • AUD-00-003 [P0] Create a source-of-truth audio architecture document. Acceptance: Document static, dynamic, XACT, media, microphone, mixer, device, and content-loading paths with ownership.
  • AUD-00-004 [P0] Record the exact SDL and SDL_mixer commits used by every supported build. Acceptance: Build output and crash reports expose exact revisions and relevant compile options.
  • AUD-00-005 [P0] Pin audio dependencies instead of relying on moving submodule heads. Acceptance: A clean checkout resolves byte-identical dependency revisions.
  • AUD-00-006 [P0] Create an audio feature/format/platform support matrix. Acceptance: Matrix covers API, codec/container, channels, sample rate, XNB/XACT, capture, and each target platform.
  • AUD-00-007 [P0] Define severity rules for silence, distortion, timing drift, and parity differences. Acceptance: Triage rules map symptoms to P0/P1/P2 consistently.
  • AUD-00-008 [P0] Define evidence required before closing an audio defect. Acceptance: Closure requires a minimal fixture, regression test, before/after capture, and documented root cause.
  • AUD-00-009 [P1] Add ownership labels for loader, decoder, mixer, XACT, media, microphone, and platform integration. Acceptance: Every plan item has a maintainership area.
  • AUD-00-010 [P1] Create a compatibility-difference register against XNA/FNA/MonoGame. Acceptance: Known intentional and accidental differences are searchable and versioned.
  • AUD-00-011 [P1] Add an audio change checklist to pull-request guidance. Acceptance: Checklist requires tests for format, duration, pitch, lifetime, and platform impact.
  • AUD-00-012 [P1] Define a policy for backend approximations such as 3D and reverb. Acceptance: Approximations require documented math, limits, and golden tests.
  • AUD-00-013 [P1] Create a test-asset provenance and license manifest. Acceptance: Every committed fixture has origin, license, generator, format, and expected hash.
  • AUD-00-014 [P1] Add a generated inventory of public Audio and Media APIs. Acceptance: Inventory is compared automatically with the chosen XNA 4.0 reference surface.
  • AUD-00-015 [P1] Add a generated inventory of SDL/MIX calls used by CNA audio. Acceptance: Every call is tagged checked/unchecked and covered/uncovered.
  • AUD-00-016 [P2] Document non-goals for platform services unavailable outside Xbox/Windows Phone. Acceptance: Unsupported APIs have explicit compatible behavior instead of accidental stubs.

AUD-01 — Reproduce the C# XNA versus C++ CNA game difference

Turn the user report into a deterministic differential case with matched assets, code, and recordings.

  • AUD-01-001 [P0] Obtain the exact original XNA C# revision and the corresponding C++ CNA revision. Acceptance: Both revisions are archived by commit hash and build instructions.
  • AUD-01-002 [P0] Identify every affected sound by logical name and physical asset path. Acceptance: A table maps gameplay event → C# load path → C++ load path → file/XNB/XACT entry.
  • AUD-01-003 [P0] Record original XNA and CNA output from the same gameplay event. Acceptance: Recordings use the same asset, event timing, output sample rate, and no post-processing.
  • AUD-01-004 [P0] Capture one high-pitched effect in isolation outside gameplay. Acceptance: Minimal XNA and CNA programs play exactly one sound once with neutral parameters.
  • AUD-01-005 [P0] Capture one reportedly missing effect in isolation. Acceptance: Logs identify whether failure occurs at path lookup, parse, decode, voice creation, or play.
  • AUD-01-006 [P0] Capture one distorted effect in isolation. Acceptance: The original bytes and every interpreted format field are preserved.
  • AUD-01-007 [P0] Hash and compare original and ported source assets. Acceptance: Byte differences are either eliminated or explained.
  • AUD-01-008 [P0] Hash and compare built content products. Acceptance: XNB/XWB/XSB/XGS differences are documented by pipeline/tool version.
  • AUD-01-009 [P0] Extract metadata with two independent tools. Acceptance: Sample rate, channels, bit depth, codec, block alignment, frame count, and loops agree or discrepancy is explained.
  • AUD-01-010 [P0] Log the exact C++ constructor/loader overload used for each affected sound. Acceptance: No affected sound has an ambiguous raw-vs-container interpretation.
  • AUD-01-011 [P0] Log all runtime pitch contributors for each affected voice. Acceptance: Base pitch, cents, RPC, random variation, Doppler, clamp, and final ratio are captured.
  • AUD-01-012 [P0] Repeat the CNA capture with pitch and Doppler forcibly neutral. Acceptance: Result classifies issue as metadata/decoder versus pitch-composition.
  • AUD-01-013 [P0] Repeat the CNA capture with reference-decoded PCM. Acceptance: Result isolates loader/decoder from mixer/playback.
  • AUD-01-014 [P0] Write CNA-decoded PCM to WAV before playback. Acceptance: Offline WAV analysis isolates decoder output from live device conversion.
  • AUD-01-015 [P0] Compare duration ratios numerically. Acceptance: Report distinguishes 2.0, 48k/44.1k, 44.1k/48k, channel, and arbitrary drift signatures.
  • AUD-01-016 [P0] Compare dominant frequency and spectral centroid. Acceptance: Measured frequency/pitch shift is reported in ratio and semitones.
  • AUD-01-017 [P0] Compare channel waveforms independently. Acceptance: Channel swap, duplication, interleave, and pan differences are identified.
  • AUD-01-018 [P0] Repeat on a 44.1 kHz and 48 kHz output device. Acceptance: Device-dependent changes are either reproduced or ruled out.
  • AUD-01-019 [P0] Repeat with static SoundEffect, dynamic stream, and XACT where applicable. Acceptance: The defective path is isolated.
  • AUD-01-020 [P0] Repeat stationary and moving 3D variants. Acceptance: Doppler-specific differences are isolated.
  • AUD-01-021 [P1] Capture stdout/stderr and structured audio trace with recordings. Acceptance: Every recording has a matching machine-readable trace.
  • AUD-01-022 [P1] Record OS, backend, device, driver, and negotiated format. Acceptance: Reproduction package is portable to another machine.
  • AUD-01-023 [P1] Create a one-command reproduction script. Acceptance: Script builds/runs both available references and emits analysis artifacts.
  • AUD-01-024 [P1] Add the minimal reproduction to CI as a non-device offline test. Acceptance: The reported defect cannot regress silently.

AUD-02 — Audio diagnostics and truthful error handling

Make every sample-rate, format, pitch, and backend decision observable without a debugger.

  • AUD-02-001 [P0] Introduce a structured AudioDiagnosticEvent model. Acceptance: Events include severity, operation, backend error, asset/cue/wave identity, thread, and timestamp.
  • AUD-02-002 [P0] Add a runtime audio trace switch disabled by default. Acceptance: Trace can be enabled by environment/config without recompiling.
  • AUD-02-003 [P0] Log requested and actual mixer/device audio specifications. Acceptance: Format, channels, rate, buffer/quantum, driver, and device name are emitted once.
  • AUD-02-004 [P0] Log every loaded sound source format before conversion. Acceptance: Container, codec, bit depth, channels, rate, block align, frame count, and loops are recorded.
  • AUD-02-005 [P0] Log every final track frequency ratio and its components. Acceptance: Trace provides base pitch, cue cents, RPC cents, random cents, Doppler, and final clamp.
  • AUD-02-006 [P0] Log dynamic stream source and destination specs after track attachment. Acceptance: A null/invalid destination spec becomes a hard diagnostic failure.
  • AUD-02-007 [P0] Check and propagate SDL_CreateAudioStream failure. Acceptance: No public object enters a usable/playing state with a null stream. Evidence: DynamicSoundEffectInstance::Play() now checks audioStream_ after EnsureStream() and returns (leaving state Stopped, std::cerr diagnostic) before ever calling MIX_SetTrackAudioStream -- previously that call would "succeed" on a null stream (SDL docs: passing NULL is legal and just detaches input) and playback would proceed to a false Playing state with total silence. See AUD-07-007's shared implementation/tests.
  • AUD-02-008 [P0] Check and propagate every SDL_PutAudioStreamData failure. Acceptance: Failed chunks are not counted as submitted; state and callback behavior remain consistent. Evidence: SubmitQueuedToStream() now checks the return value; a failed chunk is dropped (not pushed to submittedChunkSizes_) with a std::cerr diagnostic including the dropped byte count, rather than being credited to PendingBufferCount forever with no way to ever decrement (Update()'s consumed-byte accounting would never reach it). Chose "fail deterministically" over "retain for retry" from the acceptance's two options: SDL_PutAudioStreamData failures are allocation/param-level, and an unconditional retry loop risks spinning forever on a persistent failure with no equivalent retry concept in real FNA to justify the complexity. See AUD-07-009.
  • AUD-02-009 [P0] Check and propagate every MIX_PlayTrack failure. Acceptance: Public state remains Stopped and failure includes SDL error context. Evidence: Play() now captures MIX_PlayTrack's return value (from both the properties and no-properties call sites) and returns before setting State_ = Playing/registering with FrameworkDispatcher::Streams if it failed, with a std::cerr diagnostic including SDL_GetError(). See AUD-07-010.
  • AUD-02-010 [P0] Check all MIX_SetTrackFrequencyRatio calls. Acceptance: Invalid ratios fail loudly and do not leave cached state inconsistent.
  • AUD-02-011 [P0] Check all MIX_SetTrackGain and mixer-gain calls. Acceptance: Rejected values are visible and public properties remain truthful.
  • AUD-02-012 [P0] Check all pan/stereo/filter callback registration calls. Acceptance: A failed effect setup cannot masquerade as successful parity.
  • AUD-02-013 [P0] Replace raw stderr-only XACT/WaveBank failures with structured events. Acceptance: Applications can subscribe/log and tests can assert exact error codes.
  • AUD-02-014 [P0] Replace silent MediaPlayer load/create/play returns with truthful state and diagnostics. Acceptance: Play failure cannot start the wall-clock or report Playing.
  • AUD-02-015 [P0] Include content candidate paths in missing-asset diagnostics. Acceptance: Error shows every attempted path, extension, root, and case mismatch hint.
  • AUD-02-016 [P1] Add per-voice debug IDs. Acceptance: Lifecycle, submissions, parameter changes, and stop callbacks correlate reliably.
  • AUD-02-017 [P1] Add mixer counters for active, virtual, failed, and exhausted voices. Acceptance: Voice exhaustion is distinguishable from content failure.
  • AUD-02-018 [P1] Add dynamic stream counters for queued input bytes, output frames, underruns, and failed puts. Acceptance: Counters are internally consistent after resampling.
  • AUD-02-019 [P1] Add decoder timing and allocation diagnostics. Acceptance: Slow or allocation-heavy assets are identifiable.
  • AUD-02-020 [P1] Add one-shot warning suppression by unique issue key. Acceptance: Repeated failures do not flood logs while first context is preserved.
  • AUD-02-021 [P1] Add diagnostic JSON export. Acceptance: A bug report can attach a machine-readable audio session.
  • AUD-02-022 [P1] Add a debug command to dump currently active voices and parameters. Acceptance: Dump is race-safe and includes final effective values.
  • AUD-02-023 [P1] Add a debug command to dump XACT cue resolution. Acceptance: Selected sound/track/wave, variations, RPC outputs, and categories are shown.
  • AUD-02-024 [P2] Add privacy-safe device diagnostics. Acceptance: No personally identifying capture-device data is emitted by default.

AUD-03 — Deterministic offline rendering and golden-audio laboratory

Prove what CNA produces numerically rather than relying on public state or listening alone.

  • AUD-03-001 [P0] Build a deterministic offline audio render harness. Acceptance: It renders tracks to a buffer/file without physical hardware or wall-clock timing. Evidence: tests/Microsoft/Xna/Framework/Audio/OfflineAudioRenderer.hpp's RenderRawPcmOffline() uses MIX_CreateMixer()+MIX_Generate() (NOT MIX_CreateMixerDevice()) -- genuinely no physical device, no SDL_AUDIODRIVER, no wall-clock timing at all; every test using it runs identically headless or with real hardware present. MIX_Init()/MIX_Quit() are called per-render (refcounted, safe alongside CNA::Internal::Audio::GetMixer()'s own shared device mixer).
  • AUD-03-002 [P0] Add a canonical WAV writer for captured output. Acceptance: Headers, channel layout, sample count, and hashes are deterministic. Not yet done this pass -- all evidence so far is compact numeric assertions (AUD-03-014), not exported WAV files.
  • AUD-03-003 [P0] Add generated sine, impulse, step, silence, noise, sweep, and multitone fixtures. Partial evidence: GenerateSineWaveS16/GenerateSilenceS16 added and used throughout the new golden tests. Impulse/step/noise/sweep/multitone fixtures not yet added -- left unchecked.
  • AUD-03-004 [P0] Add dominant-frequency measurement. Acceptance: 440 Hz and other tones are measured within the configured threshold. Evidence: GoertzelMagnitude (exact single-bin energy, no FFT bin-width uncertainty), EstimateDominantFrequencyHz (coarse blind sweep, for when the expected frequency isn't known ahead of time), and RefineFrequencyEstimateHz (phase-difference estimator between the first/second half of the buffer -- NOT limited by the analysis window's basic bin-width resolution the way a Goertzel/FFT peak search is, which is what actually achieves the plan's own 0.1% calibration gate even from short 0.2s windows). 25+ new tests measure real tones to within 0.1% across an 11025-96000 Hz x mono/stereo matrix and a full -1.0..+1.0 pitch-ratio matrix -- see AUD-05/AUD-08 below.
  • AUD-03-005 [P0] Add sample/frame-count and duration measurement. Acceptance: No test depends only on sleeping for approximate time. Evidence: every OfflineAudioRendererTest/AUD05GoldenMatrix/AUD08GoldenPitchMatrix test asserts exact frame counts (result.samples.size()) and/or result.realBytesRendered (MIX_Generate's own non-silence byte count) -- zero wall-clock sleeps anywhere in this new test file.
  • AUD-03-006 [P0] Add per-channel RMS, peak, DC offset, clipping, and correlation metrics. Partial evidence: MeasureRms/MeasurePeak/ContainsNaNOrInf added and used (silence-is-truly-zero, sine-is-not-silence, no NaN/Inf-through-resampling checks). DC offset, clipping, and cross-channel correlation metrics not yet added -- left unchecked.
  • AUD-03-007 [P0] Add spectral comparison with windowing and tolerances. Acceptance: Compressed/resampled outputs can be compared robustly.
  • AUD-03-008 [P0] Add transient/loop-boundary click detection. Acceptance: Unexpected discontinuities fail golden tests.
  • AUD-03-009 [P0] Add channel-order test signals. Acceptance: Every channel has a unique tone/impulse signature.
  • AUD-03-010 [P0] Add silence/tail detection. Acceptance: Truncation and unexpected decoder tails are measurable.
  • AUD-03-011 [P0] Add latency measurement separated from duration. Acceptance: Startup latency is not misdiagnosed as speed error.
  • AUD-03-012 [P0] Add XNA/FNA reference-capture import. Acceptance: Reference WAV plus metadata can be normalized and compared automatically.
  • AUD-03-013 [P0] Version the comparison algorithm and thresholds. Acceptance: Golden results do not change silently with analysis code.
  • AUD-03-014 [P0] Store compact numerical golden data rather than large opaque audio where possible. Acceptance: Repository remains reviewable and fixtures reproducible. Evidence: every new golden test's expected values are small numeric literals (expected Hz, expected pitch ratio) computed from source-generated fixtures at test time -- no committed binary audio blobs.
  • AUD-03-015 [P1] Add AB listening export for human review. Acceptance: Tool emits level-matched A/B/X files without replacing objective gates.
  • AUD-03-016 [P1] Add spectrogram and waveform artifact generation for CI failures. Acceptance: Failures provide immediate visual evidence.
  • AUD-03-017 [P1] Add fuzz-safe parsers for captured metadata sidecars. Acceptance: Malformed test inputs cannot crash the harness.
  • AUD-03-018 [P1] Add deterministic dithering/no-dithering controls. Acceptance: Sample comparisons account for conversion policy.
  • AUD-03-019 [P1] Add resampler impulse and swept-sine characterization. Acceptance: Passband, aliasing, phase, and latency are documented.
  • AUD-03-020 [P1] Add pan-law characterization. Acceptance: Center attenuation and extreme-channel isolation are measured.
  • AUD-03-021 [P1] Add filter frequency/Q characterization. Acceptance: Actual response matches intended coefficients across sample rates.
  • AUD-03-022 [P1] Add voice-mixing linearity and clipping tests. Acceptance: Summed voices follow documented headroom/clamp behavior.
  • AUD-03-023 [P1] Add reproducibility checks across compiler optimization levels. Acceptance: Golden metrics stay within tolerance in Debug/Release.
  • AUD-03-024 [P2] Add optional high-resolution float capture. Acceptance: Analysis can inspect pre-device output without quantization masking.

AUD-04 — Mixer, device negotiation, resampling, and lifecycle

Guarantee that the mixer/device boundary never changes speed or hides failure.

  • AUD-04-001 [P0] Query and store the actual mixer output specification after creation. Acceptance: Requested and actual specs are both available and tested. Evidence (2026-07-17, A-06): AudioMixer.cpp's GetMixer() now calls MIX_GetMixerFormat(g_mixer, &actual) immediately after a successful MIX_CreateMixerDevice() and logs the requested spec (S16 stereo 44100 Hz, the constant CNA always requests) against the actually negotiated format/channels/freq to stderr -- previously this was completely unobservable, a real diagnostic gap for investigating any 44.1/48 kHz-family pitch report. Query failure itself is logged rather than fatal (best-effort diagnostic, matching this file's existing std::cerr convention). New test AudioMixerTest.ActualMixerFormatIsQueryableAfterCreation (AudioMixerTests.cpp) calls GetMixer() directly and asserts MIX_GetMixerFormat succeeds with sane freq/channels, proving the actual spec is genuinely queryable post-creation, not just printed once and discarded. Full suite: 4704 passed / 0 failed / 2 skipped (unrelated Accelerometer/Gyroscope hardware-support tests).

  • AUD-04-002 [P0] Verify 44.1 kHz request on a 48 kHz device does not alter pitch. Acceptance: 440 Hz remains within tolerance and duration remains correct. Adjacent evidence, not yet this item specifically: OfflineAudioRendererTests.cpp's Source44100HzThroughRenderMixer48000HzPreservesFrequency proves the mixer's own resampler (source rate != mixer render rate, both via MIX_CreateMixer()) preserves frequency in this direction. This item is about device negotiation specifically (SDL requesting one spec from MIX_CreateMixerDevice() and the OS/driver actually opening a different physical rate) -- a separate layer this offline (non-device) harness cannot exercise by design. Still open.

  • AUD-04-003 [P0] Verify 48 kHz request on a 44.1 kHz device does not alter pitch. Acceptance: Reverse conversion passes the same gates. Adjacent evidence: see AUD-04-002 -- Source22050HzThroughRenderMixer44100HzPreservesFrequency/Source48000HzThroughRenderMixer44100HzPreservesFrequency cover the mixer-resampler direction; real device-negotiation testing remains open.

  • AUD-04-004 [P0] Make mixer rate/channels/format configurable for tests. Acceptance: Tests can force 22.05/44.1/48/96 kHz and mono/stereo where supported. Evidence: AudioMixer.cpp/.hpp gained test-only SetMixerSpecOverrideForTests(spec)/ClearMixerSpecOverrideForTests(), guarded by the same mutex as g_mixer; GetMixer() uses the override (if set) instead of the hard-coded S16/stereo/44100 default on its next mixer-creating call (i.e. after DestroyMixer()). New parameterized test AUD04004/AudioMixerSpecOverrideTest.OverriddenSpecIsActuallyNegotiated (AudioMixerTests.cpp) forces 22050/44100/48000/96000 Hz x mono/stereo through the real MIX_CreateMixerDevice() path under the dummy driver and queries MIX_GetMixerFormat() after. Real finding surfaced by this test (documented "where supported" caveat): SDL3 itself (OpenPhysicalAudioDevice, third_party/SDL/src/audio/SDL_audio.c:1810-1826) imposes a hard floor of S16/stereo/44100 Hz on every physical playback device it opens (DEFAULT_AUDIO_PLAYBACK_CHANNELS=2, DEFAULT_AUDIO_PLAYBACK_FREQUENCY=44100, SDL_sysaudio.h) -- "We impose a simple minimum on device formats... This prevents something low quality... from ruining a music thing playing at CD quality." Requests at/above the floor (44100/48000/96000, stereo) pass through exactly; requests below it (22050, mono) are silently raised to the floor -- confirmed empirically for all 5 param cases, not merely read from a comment. Test asserts actual == max(requested, floor), not naive equality, so it locks in the real SDL behavior rather than a false expectation. This means 22.05 kHz and mono are not achievable as a physical device spec on this SDL3 build regardless of what CNA requests -- a genuinely new, verified fact for AUD-04-002/003/005/006's remaining device-negotiation work: CNA's own hard-coded production request (S16 stereo 44100 Hz) already sits exactly on this floor, so there is no downward device-negotiation risk in this SDL build; the only remaining device-negotiation pitch-risk direction is upward (an OS default device that only natively offers 48/96/192 kHz), which AUD-04-002/003 still need to verify and which this dummy-driver harness cannot exercise (the dummy backend has no independent "native" format of its own to negotiate against -- see SDL_dummyaudio.c's DUMMYAUDIO_OpenDevice, "don't change reported device format"). Full whole-repo suite: 4709 passed / 0 failed / 2 skipped (unrelated hardware-support tests) -- also confirms the per-test DestroyMixer()/override/restore cycle in the new test does not corrupt the shared process-wide mixer singleton for any other test in the binary.

  • AUD-04-005 [P0] Choose and document the production mixer-format policy. Acceptance: Policy is native-device, fixed-reference, or platform-specific with rationale. Evidence: documented in NEXTaudio.md §6 "Mixer output-format policy (AUD-04-005)" -- CNA's existing behavior (always request S16 stereo 44100 Hz, unchanged by this task) is the fixed-reference policy; native-device and platform-specific were considered and explicitly rejected with rationale (determinism, the AUD-04-004-confirmed SDL device-open floor already sitting at the same spec, and avoiding a wider verification matrix for no identified benefit). No code change -- this task closes the open "which policy and why" documentation gap, not a behavior change.

  • AUD-04-006 [P0] Validate mixer creation against unsupported requested specs. Acceptance: Fallback is explicit, logged, and cannot silently change speed. Investigated -- confirmed already safe via two independent, already-existing SDL3 validation layers, no code change needed, locked down with new tests: using AUD-04-004's override mechanism, empirically drove GetMixer() through 5 genuinely-invalid specs (freq<=0, channels<=0, channels>8). All 5 throw std::runtime_error from MIX_CreateMixerDevice failing -- none silently substitute an unrequested rate/channel count. Root cause traced to real SDL3 source (third_party/SDL/src/audio/SDL_audiocvt.c's SDL_SetAudioStreamFormat, called via SDL_CreateAudioStream inside SDL_OpenAudioDeviceStream): it validates the original, unclamped app-side spec (freq<=0 rejected outright; channels must be in SDL_IsSupportedChannelCount's 1-8 range) as a separate, stricter gate than OpenPhysicalAudioDevice's AUD-04-004 floor-clamp (which only raises too-small-but-still-valid values like 22050 Hz/mono, never touches <=0/>8). So the two SDL-level mechanisms compose correctly for CNA's purposes: below-floor-but-valid requests are silently (but observably, via AUD-04-001's log) raised to the documented 44100 Hz/stereo floor, while genuinely invalid requests are rejected outright with an exception -- there is no code path where an unsupported spec produces a mixer running at an arbitrary, unexplained rate. New tests: AUD04006/AudioMixerInvalidSpecThrowsTest (5 param cases: freq 0/-1, channels 0/-1/9) all assert EXPECT_THROW(GetMixer(), std::runtime_error). Full whole-repo suite: 4714 passed / 0 failed / 2 skipped (unrelated hardware-support tests).

  • AUD-04-007 [P0] Test device-open failure and retry without leaked MIX init references. Acceptance: Repeated failures leave balanced lifecycle and accurate exceptions. Evidence: new AudioMixerTest.RepeatedDeviceOpenFailuresLeaveBalancedLifecycleAndSubsequentSuccessIntact (AudioMixerTests.cpp) drives GetMixer() through 5 consecutive failures on the same invalid spec (channels=0, AUD-04-006's rejection path), each asserted to throw std::runtime_error (accurate exceptions, every time -- not just the first), then clears the override and confirms a completely ordinary GetMixer() call still succeeds immediately afterward with the correct default spec (S16/stereo/44100), proving the 5 failed attempts left no residue in g_mixer/SDL's audio subsystem refcount for AudioMixer.cpp's existing IN-11 MIX_Init()/MIX_Quit() pairing to leak. No production code change -- this is a regression lock on already-correct lifecycle behavior. Full whole-repo suite: 4715 passed / 0 failed / 2 skipped (unrelated hardware-support tests).

  • AUD-04-008 [P0] Test mixer destruction with active static voices. Acceptance: No use-after-free, deadlock, callback-after-destroy, or leak. Two confirmed real defects found and fixed (not merely tested):

    1. UAF on SoundEffectInstance::track_. AudioMixer::DestroyMixer() frees every MIX_Track the mixer owns (MIX_DestroyMixer -> MIX_DestroyTrack -> SDL_aligned_free, confirmed by reading third_party/SDL_mixer/src/SDL_mixer.c), but nothing ever told a live SoundEffectInstance/DynamicSoundEffectInstance its track_ pointer had just been freed -- every accessor (getStateProperty, Play, Stop, Pause, Resume, Dispose, the INTERNAL_apply*Filter family) only null-checked track_, never validated it was still live. Fix: AudioMixer gained a monotonic GetMixerGeneration() counter, bumped by DestroyMixer() only when it actually destroys a mixer. SoundEffectInstance gained trackMixerGeneration_ (captured the instant a track is created) and a new GetLiveTrackHandle() accessor that returns nullptr (clearing track_ as a side effect) whenever the captured generation no longer matches current -- every one of the ~13 AsTrack(track_) call sites in SoundEffectInstance.cpp, plus DestroyTrackSafe (now generation-checked before calling MIX_StopTrack/MIX_DestroyTrack), now goes through it. Proven via the git-stash pattern: with the fix stashed out, the new deterministic (non-subprocess) test SoundEffectInstanceTest.MixerDestructionOrphansTrackWithoutUseAfterFree (SoundEffectInstanceTests.cpp) fails exactly as expected (GetTrack(inst) -- a raw, bypass-the-check accessor -- stays non-null after the first post-DestroyMixer() call, proving the check never ran); with the fix restored it passes, track_ is provably nulled by the very first accessor call after DestroyMixer(), not merely "didn't crash."
    2. A second, deeper, more severe defect found while chasing this: MIX_DestroyMixer() also calls SDL_QuitSubSystem(SDL_INIT_AUDIO) internally (when destroying a real device-backed mixer) -- if that call brings SDL's global audio subsystem refcount to zero, it fully deinitializes the subsystem process-wide, silently breaking every OTHER independently-owned SDL_AudioStream CNA still holds (confirmed by an ASan-symbolized crash: SDL_WasInit(SDL_INIT_AUDIO) observed 0 at the crash site, SEGV inside SDL_UnbindAudioStream_REAL when DynamicSoundEffectInstance::DestroyStream() later called SDL_DestroyAudioStream() on its own stream -- see AUD-04-009 below for the fix, which closes this for both classes since it's a subsystem-level fix in AudioMixer.cpp, not a per-class one).

    New isolated-subprocess harnesses (tools/audio/mixer_destroy_active_static_voice_harness.cpp, wired via cmake/Harnesses.cmake/cmake/UnitTests.cmake) also exist as an end-to-end safety net (AudioMixerTest.MixerDestructionWithActiveStaticVoiceDoesNotCrashOrUseAfterFree, spawned the same way P9-HARDWARE-005's no-hardware harness is) -- these were the tool used to discover both defects above via ASan (SDL_WasInit/__pthread_mutex_lock SEGV), even though a plain non-ASan run of the isolated static-voice case alone didn't reliably crash (the freed-memory read happened to be benign in that specific scenario -- the deterministic in-process test above is the real regression guard, not this harness's exit code alone). Full whole-repo suite: 4719 passed / 0 failed / 2 skipped (unrelated hardware-support tests), confirmed clean 3x under ASan (audio-scoped subset) with only the already-documented AUD-15-001 libdrm.so.2/<unknown module> graphics-driver leak-noise baseline, nothing new.

  • AUD-04-009 [P0] Test mixer destruction with active dynamic streams. Acceptance: Streams/tracks are detached in a defined order. Shares AUD-04-008's MIX_Track-generation-check fix (DynamicSoundEffectInstance shares the base class's track_/trackMixerGeneration_, P13-DYNAMIC-001) -- its own independent AsTrackD(track_) call sites (getStateProperty, Play's resume-from-Paused and new-track-creation paths, Stop(bool)'s early-return guard, StopInternal's inline track teardown) were separately updated to go through the same GetLiveTrackHandle(), and StopInternal's formerly-unconditional MIX_StopTrack/MIX_DestroyTrack calls are now generation-guarded the same way DestroyTrackSafe is. The subsystem-deinitialization defect described in AUD-04-008 was actually found via this class specifically: DynamicSoundEffectInstance::DestroyStream() calling SDL_DestroyAudioStream() on its own independently-owned audioStream_ (never bound to any MIX_Track, created directly via SDL_CreateAudioStream in EnsureStream()) segfaulted after DestroyMixer() ran, because MIX_DestroyMixer's internal SDL_QuitSubSystem(SDL_INIT_AUDIO) call had brought the global audio subsystem refcount to zero. Fix (in AudioMixer.cpp, benefits both classes): GetMixer() now acquires one extra, permanently-held SDL_InitSubSystem(SDL_INIT_AUDIO) reference on first use and never releases it, so DestroyMixer()'s internal SDL_QuitSubSystem call can never bring the subsystem's refcount below 1 -- confirmed fixed by re-running the exact crash repro (SDL_WasInit(SDL_INIT_AUDIO) no longer returns 0 after DestroyMixer()) and by the crash no longer reproducing across 3x ASan runs of the full audio-scoped test filter (previously 100% reproducible in that filter, AddressSanitizer:DEADLYSIGNAL/SEGV ... in __pthread_mutex_lock inside SDL_UnbindAudioStream_REAL <- SDL_DestroyAudioStream_REAL <- DynamicSoundEffectInstance::DestroyStream()). Proven via the git-stash pattern the same way as AUD-04-008: the new deterministic test DynamicSoundEffectInstanceTest.MixerDestructionOrphansTrackWithoutUseAfterFree (DynamicSoundEffectInstanceTests.cpp) fails against pre-fix code -- and notably, for this class specifically, getStateProperty() itself returned the wrong live value (Playing, not Stopped) when reading the freed track's memory pre-fix, a concrete demonstration that the old behavior was genuinely undefined, not merely "coincidentally looked right." New isolated-subprocess harness tools/audio/mixer_destroy_active_dynamic_voice_harness.cpp / AudioMixerTest.MixerDestructionWithActiveDynamicVoiceDoesNotCrashOrUseAfterFree also added as an end-to-end safety net (this harness's own internal state-check did reliably fail pre-fix, unlike the static-voice one). "Streams/tracks are detached in a defined order" is satisfied by the existing StopInternal()/DestroyStream() ordering (track first, then stream), now made safe under the orphaned-mixer case by the two fixes above. Full whole-repo suite: 4719 passed / 0 failed / 2 skipped.

  • AUD-04-010 [P1] Implement output-device change handling. Acceptance: Default-device changes either migrate safely or stop with a documented event.

  • AUD-04-011 [P1] Implement device-loss/reopen handling. Acceptance: State recovery is deterministic and does not speed up queued audio.

  • AUD-04-012 [P1] Measure and document output latency/quantum by backend. Acceptance: Latency settings are not conflated with playback duration.

  • AUD-04-013 [P1] Add low/high-latency configuration bounds. Acceptance: Invalid values fail predictably; supported values are tested.

  • AUD-04-014 [P1] Verify master volume is applied exactly once. Acceptance: Track and mixer gains do not double-multiply or omit master gain. Investigated -- confirmed already correct via real SDL3_mixer source + empirical RMS measurement: MIX_SetTrackGain (used for SoundEffectInstance::Volume) delegates to SDL_SetAudioStreamGain(track->output_stream, gain) -- applied when the mixer's group-mixing loop pulls from that stream (SDL_GetAudioStreamData). MIX_SetMixerGain (used for SoundEffect::MasterVolume, setMasterVolumeProperty in SoundEffect.cpp) sets mixer->gain, applied separately by MixFloat32Audio when accumulating that already-track-gained sample into the group mix buffer (both read directly from third_party/SDL_mixer/src/SDL_mixer.c). Two genuinely distinct multiplicative pipeline stages -- structurally impossible to double-apply or omit either one. Empirically confirmed, not just read from source: OfflineAudioRenderer.hpp's RenderRawPcmOffline gained optional trackGain/mixerGain parameters (wired to MIX_SetTrackGain/MIX_SetMixerGain); new tests TrackGainAloneScalesRmsLinearly, MixerGainAloneScalesRmsLinearly, and the core claim TrackAndMixerGainComposeMultiplicativelyNotDoubleAppliedOrOmitted (0.5×0.5 measured RMS ratio ≈0.25, distinguishing this from a double-apply reading of ≈0.0625 or an omit-one-factor reading of ≈0.5) all pass against the real decode/mix pipeline. No code change.

  • AUD-04-015 [P1] Verify mixer gain changes affect active and future voices consistently. Acceptance: Behavior matches chosen XNA baseline. Evidence: new test MixerGainChangeMidPlaybackAffectsActiveVoiceOnNextChunk (OfflineAudioRendererTests.cpp) plays a track, pulls one 0.1s chunk via MIX_Generate, calls MIX_SetTrackGain on the same still-playing track (no Stop/Play cycle, no new track), pulls a second chunk, and confirms the second chunk's measured RMS is ≈0.25x the first (matching the new gain) -- proving a gain change takes effect live on an already-active voice's very next generated chunk, not only on voices created after the change. The "future voices" half of the acceptance is already covered by AUD-04-014's tests: every one of those creates a brand-new track with MIX_SetTrackGain/MIX_SetMixerGain called before MIX_PlayTrack, so there is no code path where a freshly created track could see a stale gain. No production code change -- SDL3_mixer's per-chunk gain read (traced in AUD-04-014's evidence) already behaves this way by construction.

  • AUD-04-016 [P1] Verify no clipping/NaN propagation for extreme aggregate gain. Acceptance: Output remains finite and documented. Evidence: new tests ExtremeAggregateGainRemainsFiniteNoNaNOrInf (trackGain=mixerGain=1000, real decoded output asserted finite via ContainsNaNOrInf and non-zero peak -- SDL3_mixer's float32 pipeline has no hard clamp, so extreme gain legitimately produces a large-but-finite value, not distortion-via-wraparound or NaN) and ZeroTrackGainRendersSilenceNotNaN (trackGain=0 renders clean all-zero silence, not a NaN from a degenerate 0-gain edge case). Both pass against the existing, unmodified mixing pipeline -- no code change.

  • AUD-04-017 [P1] Test null/dummy audio drivers separately from physical output. Acceptance: Dummy success is not accepted as proof of audible correctness.

  • AUD-04-018 [P1] Add backend capability querying. Acceptance: Unsupported effects/formats are known before play.

  • AUD-04-019 [P1] Add mixer thread-affinity and callback-thread documentation. Acceptance: Public API and internal locks comply with the contract.

  • AUD-04-020 [P2] Benchmark conversion cost for common source/device rate pairs. Acceptance: Performance budget includes resampling and channel conversion.

AUD-05 — Raw SoundEffect format contracts and metadata integrity

Prevent C++ callers from accidentally labeling bytes with the wrong rate, width, or channel count.

  • AUD-05-001 [P0] Validate raw SoundEffect sample rate before backend calls. Acceptance: Zero, negative, overflow, and unsupported rates throw XNA-compatible exceptions. Investigated -- resolved decision, matches FNA, acceptance corrected: real FNA's own internal SoundEffect constructor (SoundEffect.cs) does zero C#-level validation of sampleRate at all, relying entirely on the native backend (FAudio) to reject an invalid WAVEFORMATEX (same pattern already resolved for DynamicSoundEffectInstance's constructor, P10-DYN-001..003). Empirically confirmed via a direct probe that CNA's own backend call (MIX_LoadRawAudio) already rejects freq<=0 outright (NULL, "unknown/unsupported/corrupt format"), which the existing if (!raw) throw NotSupportedException(...) guard safely converts -- no crash, no garbage SoundEffect. New tests BufferRangeConstructorWithZeroSampleRateThrowsNotSupported/WithNegativeSampleRateThrowsNotSupported lock this down. Not adding CNA-side pre-validation, since real XNA docs promising ArgumentOutOfRangeException here are the same documented-vs-actual-FNA-behavior split this project already resolved in FNA's favor elsewhere.
  • AUD-05-002 [P0] Validate raw channel enum before backend calls. Acceptance: Only supported XNA channel values are accepted unless an explicit extension is used. Investigated -- resolved decision, matches FNA: same reasoning as AUD-05-001 -- FNA does no C#-level channel validation either; MIX_LoadRawAudio already rejects channels<=0 (confirmed via the same probe). New test BufferRangeConstructorWithZeroChannelsThrowsNotSupported locks this down.
  • AUD-05-003 [P0] Validate raw byte count is aligned to a complete sample frame. Acceptance: Misaligned buffers fail deterministically instead of truncating or distorting. Investigated -- confirmed already safe, acceptance's literal "fail" not implemented (truncates instead), documented as the chosen behavior: empirically confirmed via a direct probe that SDL3_mixer's MIX_LoadRawAudio handles a non-frame-aligned byte count (e.g. 401 bytes of stereo S16) by cleanly ignoring the trailing partial frame (decodes as exactly 100 frames, identical to a clean 400-byte buffer) -- not a crash, not corrupted/distorted audio, and matches FNA's own total lack of frame-alignment validation. New test BufferRangeConstructorWithMisalignedByteCountTruncatesCleanly locks this graceful-truncation behavior down rather than adding a throw that would diverge from FNA for no safety benefit (the current behavior already meets this task's real underlying goal -- no distortion -- even though it truncates rather than throwing).
  • AUD-05-004 [P0] Validate loop start/length against decoded frame count where parity permits. Acceptance: Out-of-range loops follow verified XNA/FNA behavior and never reach backend unchecked. Investigated -- resolved decision, matches an already-resolved prior decision (P9-VALIDATION-002), confirmed against real FNA source and empirically against the real backend: FNA's own internal SoundEffect constructor (SoundEffect.cs) does zero C#-level validation of loopStart/loopLength against the decoded frame count -- this.loopStart = (uint) loopStart; unconditionally, relying entirely on the native backend to behave safely. CNA's constructor already matches this (P9-VALIDATION-002's comment in SoundEffect.cpp, pre-existing, unchanged). What AUD-05-004 adds: empirical confirmation that SDL3_mixer (CNA's backend) also degrades gracefully for an out-of-range loop region rather than reaching it "unchecked" in any memory-unsafe sense -- read directly from third_party/SDL_mixer/src/SDL_mixer.c: MIX_PlayTrack clamps loop_start to >=0 only (no upper-bound check at play time), and the mixing loop's loop-back path (decoder->seek(track->decoder_userdata, track->loop_start)) either succeeds (decoder-level clamp) or fails cleanly (track_stopped = true), never a crash or garbage read; an oversized max_frame simply never triggers the early-clamp comparison, falling back to the decoder's own natural EOF detection. New test LoopRegionFarBeyondDecodedLengthDegradesGracefullyNoCrashNoNaN (OfflineAudioRendererTests.cpp) empirically confirms this: a 2205-frame real source with loopStart=22050/maxFrame=220500 (10x/100x past the real content) rendered for 8820 frames (4x the real source length, guaranteeing the nonsensical loop points are actually hit) produces no crash and no NaN/Inf. No production code change -- matches FNA's own documented lack of C#-level validation, and the backend already handles the out-of-range case safely.
  • AUD-05-005 [P0] Document that raw constructor bytes are PCM16LE, not a WAV/container. Acceptance: API docs and exception message prevent accidental whole-file submission. Evidence: both raw-buffer constructors' Doxygen comments (SoundEffect.hpp) now explicitly state the "headerless, little-endian, signed 16-bit PCM... NOT a WAV/RIFF file" contract, name the specific consequence of misuse (leading container header misread as samples, producing noise/silence not a clean failure), and point at SoundEffect(const std::string&) as the correct file-loading alternative. The "exception message" half is satisfied differently than a literal wording change: real backend-level rejection (NotSupportedException) is only reachable for spec-level failures (freq/channels the backend itself rejects, already covered by AUD-05-001/002), never for "right shape, wrong content" container misuse -- that misuse mode is what AUD-05-006's new diagnostic (below) actually catches, since no exception is thrown for it in real FNA/CNA either.
  • AUD-05-006 [P0] Add debug detection for RIFF/Ogg/MP3/XNB signatures passed to raw PCM constructor. Acceptance: Likely misuse emits a precise diagnostic. Evidence: new DetectLikelyContainerSignature() helper (SoundEffect.cpp) checks the leading bytes of the range constructor's [offset, offset+count) slice for RIFF/OggS/ID3/XNB magic bytes and prints a precise std::cerr diagnostic naming the detected format and the correct fix -- advisory only, never throws (matches AUD-05-005's constructor contract: the backend still "successfully" decodes container bytes as garbage PCM, so there is no exception path to hook this into even if desired). New tests RawBufferStartingWithRiffSignatureEmitsDiagnosticWithoutThrowing/RawBufferStartingWithXnbSignatureEmitsDiagnosticWithoutThrowing (SoundEffectTests.cpp, via testing::internal::CaptureStderr()) confirm the diagnostic fires and construction still succeeds; RawBufferWithoutKnownSignatureEmitsNoDiagnostic confirms ordinary raw PCM (no known signature) produces zero extra stderr output, ruling out false positives for the common case. Full whole-repo suite: 4729 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-05-007 [P0] Add debug detection for implausible PCM16 statistics indicating float/compressed data. Acceptance: Diagnostic is advisory and has no release false rejection. Evidence: new LooksImplausiblyHighEntropyForPcm16() helper (SoundEffect.cpp) computes Shannon entropy over the raw byte histogram of the [offset, offset+count) slice (checked only when DetectLikelyContainerSignature above found no known container header, avoiding a double-diagnosis of the same misuse) and prints an advisory std::cerr diagnostic when entropy exceeds 7.9 bits/byte (of a theoretical max of 8.0) -- compressed/encoded bitstreams (Ogg/MP3 without a recognizable header, or float32 samples byte-reinterpreted as PCM16) approach this ceiling, while real quantized 16-bit audio essentially never does (adjacent samples/channels stay correlated). Deliberately conservative threshold, never throws, no release rejection. New tests: RawBufferWithHighEntropyRandomDataEmitsDiagnosticWithoutThrowing (deterministic-seed PRNG bytes, a reasonable proxy for compressed data's near-uniform byte distribution, triggers the diagnostic; construction still succeeds) and RawBufferWithRealSineWaveEmitsNoEntropyDiagnostic (a real continuous 440 Hz tone produces zero extra stderr output, ruling out a false positive on ordinary game audio). Full whole-repo suite: 4731 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-05-008 [P0] Verify duration calculation uses decoded frames and source rate. Acceptance: Duration remains correct after backend conversion. Evidence: pre-existing ConstructFromBufferAndProperties already asserts an exact expected duration (1024.0/44100.0 seconds for a 1024-frame stereo buffer, EXPECT_NEAR not just non-zero); the new BufferRangeConstructorWithMisalignedByteCountTruncatesCleanly (AUD-05-003) extends this to the truncated-frame case (100.0/44100.0, not the naive byte-count-implied duration).
  • AUD-05-009 [P0] Verify mono/stereo interleaving with asymmetric test signals. Acceptance: No channel duplication/swap/misalignment. Already satisfied by a pre-existing AUD-03 harness self-test, cross-referenced here (no new code): OfflineAudioRendererTest.StereoChannelsAreIndependentlyMeasurable (OfflineAudioRendererTests.cpp) builds a stereo buffer with genuinely asymmetric per-channel content -- left channel 440 Hz, right channel 880 Hz -- and measures each channel independently via RefineFrequencyEstimateHz. A channel swap bug would make the 440 Hz measurement appear on the right/880 Hz measurement appear on the left; a duplication bug would make both channels read 440 Hz (or both 880 Hz); a misalignment (off-by-one interleaving) bug would corrupt both measured frequencies away from either expected value -- all three failure modes are within this single test's discriminating power, and it currently passes.
  • AUD-05-010 [P1] Define endianness policy on non-little-endian targets. Acceptance: PCM input is converted or rejected explicitly.
  • AUD-05-011 [P1] Add a typed NOXNA audio-buffer descriptor for richer formats. Acceptance: Extensions carry codec/format/rate/channels/block alignment explicitly.
  • AUD-05-012 [P1] Avoid ambiguous integer casts from arbitrary channel values. Acceptance: Static analysis/tests reject invalid enum construction paths. Investigated -- resolved decision, matches FNA's identical gap, empirically documented: AudioChannels is a scoped enum (Mono=1, Stereo=2); C++ has no language-level protection against a caller force-casting an out-of-range value (static_cast<AudioChannels>(5)), and FNA's own constructor has the identical gap (SoundEffect.cs does (ushort) channels unconditionally, no validation) -- CNA intentionally matches this rather than adding a restriction FNA itself doesn't have (would diverge from real XNA/FNA behavior for no parity benefit, the same reasoning already applied to AUD-05-001/002). What was empirically verified instead: the backend's actual behavior for an out-of-range value passed through unchanged. New test OutOfRangeChannelsEnumValueEitherConstructsOrThrowsCleanlyNeverUB confirms channels=5 constructs successfully -- MIX_LoadRawAudio's RAW decoder accepts an arbitrary positive channel count unvalidated at load time (unlike MIX_CreateMixerDevice's stream-format path, which enforces SDL_IsSupportedChannelCount's 1-8 range, but only at play time -- AUD-04-006) -- and that the resulting SoundEffect behaves sanely (not disposed, no corruption), with the test also accepting a clean NotSupportedException as a valid outcome should a future SDL3_mixer version start validating this earlier. No production code change. Full whole-repo suite: 4736 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-05-013 [P1] Check MIX_LoadRawAudio ownership/copy semantics against pinned version. Acceptance: Buffer lifetime is safe and documented. Investigated and confirmed safe -- CNA's raw SoundEffect constructor does not need the caller's buffer to outlive the call: read the pinned third_party/SDL_mixer/src/SDL_mixer.c directly. MIX_LoadRawAudio -> MIX_LoadRawAudio_IO never sets MIX_PROP_AUDIO_LOAD_ONDEMAND_BOOLEAN (only the separate MIX_LoadRawAudioNoCopy, which CNA does not use, sets it true "so it doesn't make a copy to precache" -- an explicit signal the other function does copy). MIX_LoadAudioWithProperties's else if (!ondemand) branch (the one MIX_LoadRawAudio always takes) calls SDL_LoadFile_IO(io, &audio->precachelen, false), reading the entire source into a fresh SDL_malloc'd buffer independent of the caller's original memory. Empirically locked down (not just read from source): new test RawBufferLifetimeIsIndependentOfSourceMemoryAfterConstruction overwrites the source buffer with 0xCD and destroys it immediately after constructing the SoundEffect, then confirms Duration and CreateInstance().Play() both still behave correctly. Full whole-repo suite: 4735 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-05-014 [P1] Test zero-length raw buffers. Acceptance: Behavior matches reference and never divides by zero. Evidence: new test ZeroLengthRawBufferConstructsWithZeroDurationNoCrash constructs from an empty buffer and confirms Duration == TimeSpan::Zero, no crash -- matches getDurationProperty()'s existing frames > 0 guard (SoundEffect.cpp, pre-existing, never divides by a zero frame count; division is always by sampleRate, separately guarded by sampleRate > 0).
  • AUD-05-015 [P1] Test very short one-frame/two-frame buffers. Acceptance: No off-by-one duration or callback error. Evidence: new tests OneFrameRawBufferHasExactSingleFrameDuration/TwoFrameRawBufferHasExactTwoFrameDuration (4-byte and 8-byte stereo S16 buffers) confirm exact 1/44100/2/44100 second durations within 1e-6 tolerance (this file's established convention, ConstructFromBufferAndProperties) -- SDL3_mixer's own duration computation carries a small, fixed sub-microsecond rounding error independent of buffer length (confirmed empirically: an initial 1e-9 tolerance attempt failed by ~5-8e-8 seconds on both cases, negligible for any real audio purpose but revealing the fixed-not-frame-count-scaled nature of the rounding).
  • AUD-05-016 [P1] Test very large buffers near API/backend limits. Acceptance: Overflow is prevented before allocation/backend calls. Evidence: new tests HugeCountAgainstSmallBufferThrowsBeforeReachingBackend/HugeOffsetNearIntMaxThrowsBeforeReachingBackend/OffsetPlusCountThatWouldOverflowInt32ThrowsCleanly (SoundEffectTests.cpp) confirm offset/count values near SharpRuntime::intcs's (int32) ceiling, checked against a genuinely small real buffer, are rejected by the existing P9-VALIDATION-003 unsigned-arithmetic bounds check (off > buffer.size() || cnt > buffer.size() - off, SoundEffect.cpp) before buffer.data() + offset is ever computed or the backend is reached -- including the exact overflow-prone case that check exists for (offset near INT32_MAX plus a small count, which would overflow a plain int32 sum if computed naively). No real multi-gigabyte allocation was needed: the bounds check depends only on the (small) real buffer failing to contain the (huge) claimed range, not on how large the real buffer actually is. No production code change -- locks down already-correct, pre-existing behavior. Full whole-repo suite: 4739 passed / 0 failed / 2 skipped (unrelated hardware-support tests). This closes AUD-05's entire P0/P1 list except AUD-05-010/011 (endianness policy, NOXNA buffer descriptor -- both design-decision-sized, deliberately deferred rather than self-started).
  • AUD-05-017 [P1] Golden-test raw PCM16LE at 8000 Hz mono. Acceptance: Frequency, duration, frame count, channel identity, and neutral ratio pass thresholds. Evidence: AUD05GoldenMatrix/GoldenSampleRateTest (OfflineAudioRendererTests.cpp), param (8000, 1) -- 220 Hz tone measured within 0.1% via RefineFrequencyEstimateHz, exact frame count asserted.
  • AUD-05-018 [P1] Golden-test raw PCM16LE at 8000 Hz stereo. Acceptance: as above. Evidence: same test, param (8000, 2).
  • AUD-05-019 [P1] Golden-test raw PCM16LE at 11025 Hz mono. Evidence: param (11025, 1).
  • AUD-05-020 [P1] Golden-test raw PCM16LE at 11025 Hz stereo. Evidence: param (11025, 2).
  • AUD-05-021 [P0] Golden-test raw PCM16LE at 22050 Hz mono. Evidence: param (22050, 1); also independently reproduced end-to-end via Source22050HzThroughRenderMixer44100HzPreservesFrequency (resampled through CNA's actual hard-coded 44100 Hz mixer rate).
  • AUD-05-022 [P0] Golden-test raw PCM16LE at 22050 Hz stereo. Evidence: param (22050, 2).
  • AUD-05-023 [P1] Golden-test raw PCM16LE at 32000 Hz mono. Evidence: param (32000, 1).
  • AUD-05-024 [P1] Golden-test raw PCM16LE at 32000 Hz stereo. Evidence: param (32000, 2).
  • AUD-05-025 [P0] Golden-test raw PCM16LE at 44100 Hz mono. Evidence: param (44100, 1).
  • AUD-05-026 [P0] Golden-test raw PCM16LE at 44100 Hz stereo. Evidence: param (44100, 2).
  • AUD-05-027 [P0] Golden-test raw PCM16LE at 48000 Hz mono. Evidence: param (48000, 1); also Source48000HzThroughRenderMixer44100HzPreservesFrequency.
  • AUD-05-028 [P0] Golden-test raw PCM16LE at 48000 Hz stereo. Evidence: param (48000, 2).
  • AUD-05-029 [P1] Golden-test raw PCM16LE at 96000 Hz mono. Evidence: param (96000, 1).
  • AUD-05-030 [P1] Golden-test raw PCM16LE at 96000 Hz stereo. Evidence: param (96000, 2). All 14 AUD05GoldenMatrix cases pass; this closes the entire golden sample-rate matrix (AUD-05-017..030). Note: these tests exercise the real SDL3_mixer decode/resample pipeline directly (RenderRawPcmOffline), not yet SoundEffect's own raw constructors -- AUD-05's validation items (001-016, e.g. sample-rate/channel/frame-alignment checks on the public constructor) are a separate, not-yet-done task (see task #12/AUD-05-001..016 below).

AUD-06 — XNB SoundEffect compatibility and decoding

Eliminate the confirmed missing-audio gap caused by PCM16-only XNB loading.

  • AUD-06-001 [P0] Create an authoritative XNB SoundEffect format support matrix by XNA target platform/profile. Acceptance: Every accepted/rejected combination has a reference fixture and rationale. Evidence: matrix documented in SoundEffectContentTypeReader.hpp's class doc comment and SoundEffectContentTypeReaderTests.cpp's file header; every row has a real MonoGame-produced .xnb fixture (tests/assets/xnb/monogame/windows/uncompressed/audio/tone_mono_44khz_{8bit,16bit,float,msadpcm,imaadpcm}.xnb, plus stereo 16-bit) except XMA2 (no such fixture exists in MonoGame's own test corpus either; covered by a hand-built minimal object stream instead).
  • AUD-06-002 [P0] Preserve nAvgBytesPerSec, nBlockAlign, and full format extension data. Acceptance: Decoder receives complete WAVEFORMATEX metadata. Evidence: SoundEffectContentTypeReader.cpp now captures nAvgBytesPerSec/nBlockAlign into named locals (previously read-and-discarded) and captures the format extension bytes verbatim into extensionData (previously always discarded via a bare ReadBytesExactOrThrow with no assignment) for every non-XMA2 format.
  • AUD-06-003 [P0] Refactor XNB SoundEffect construction away from the PCM16-only raw constructor. Acceptance: A format-aware internal path owns metadata and encoded bytes safely. Evidence: new BuildViaWavWrapper() (format-aware: wraps raw bytes in a synthetic in-memory WAV matching the real WAVEFORMATEX fields, decoded via SoundEffect::FromStream/SDL3's own WAV loader) handles every format except 16-bit PCM, which deliberately keeps the existing direct-construction fast path unchanged (no WAV-wrapping overhead for the already-correct, most common case).
  • AUD-06-004 [P0] Support 8-bit PCM XNB SoundEffect where reference behavior requires it. Acceptance: Included fixture decodes with exact frame count and correct unsigned-to-signed conversion. Evidence: Pcm8BitLoadsSuccessfully against the real fixture; unsigned-to-signed 8-bit conversion is SDL3's own native WAV-decoder responsibility (not reimplemented in CNA), consistent with routing every non-16-bit format through the same real decoder. Exact frame-count cross-check against the fixture's own declared sample count not yet added (duration-is-positive only) -- a reasonable follow-up, not blocking.
  • AUD-06-005 [P0] Support 16-bit PCM XNB SoundEffect without regression. Acceptance: Existing fixture remains sample/duration correct. Evidence: unchanged fast path; Pcm16BitMonoLoadsSuccessfully/Pcm16BitStereoLoadsSuccessfully still pass, full whole-repo suite reverified green.
  • AUD-06-006 [P0] Support MS ADPCM XNB SoundEffect where reference behavior requires it. Acceptance: Block alignment, samples/block, duration, loops, and decoded output are verified. Evidence: MsAdpcmLoadsSuccessfully against the real fixture. Confirmed real defect found and fixed en route (AUDIO-XNB-ADPCM-001): hex-dumped the real fixture and found MonoGame's content pipeline writes cbSize=0 for MS-ADPCM (no coefficient table, no wSamplesPerBlock at all in the embedded format block) -- unlike IMA-ADPCM, SDL3's MS-ADPCM decoder has no auto-derive fallback and requires an explicit, valid extension (SDL_wave.c's MS_ADPCM_Init). Fixed by synthesizing the standard extension (BuildStandardMsAdpcmExtension + a wSamplesPerBlock computed from nBlockAlign via the MS-ADPCM "Standards Update" formula) whenever the XNB's own extension is absent/too small to contain a real coefficient table, while still preferring a real authored extension if one is ever present. Loop points forwarded via a synthesized minimal WAV smpl chunk (AppendSmplChunkIfLooped), picked up automatically by SoundEffect::FromStream's existing TryParseWavSmplChunk (CP-17).
  • AUD-06-007 [P0] Determine and implement IMA ADPCM XNB compatibility policy. Acceptance: Decision is based on real XNA/MonoGame/FNA content, with fixture and explicit behavior. Evidence: decision: support via SDL3's native IMA-ADPCM decoder (WAV-wrapped), same as MS-ADPCM/float/PCM8 -- confirmed against the real fixture (ImaAdpcmLoadsSuccessfully), which worked immediately (SDL auto-derives wSamplesPerBlock from nBlockAlign when the XNB doesn't supply an extension, unlike MS-ADPCM).
  • AUD-06-008 [P0] Determine and implement IEEE float XNB compatibility policy. Acceptance: Float fixture either loads correctly or fails with documented profile-compatible reason. Evidence: decision: support via SDL3's native IEEE-float WAV decoder; IeeeFloatLoadsSuccessfully against the real fixture.
  • AUD-06-009 [P0] Determine and implement XMA2 compatibility strategy. Acceptance: Native decode, conversion-at-build, optional decoder, or explicit unsupported result is tested and documented. Evidence: decision unchanged from the prior session (explicit unsupported result -- no decode path exists anywhere in this stack, SDL3 doesn't decode XMA2 either) but now has real regression coverage: Xma2IsRejected builds a complete, valid minimal XNB object stream (type-reader table + shared-resource count + root type id, reverse-engineered from a real fixture's hex dump) around an XMA2 format block, confirming the rejection path is reachable through the real ReadAsset<T>() entry point, not just through SoundEffectReader::Read() called directly.
  • AUD-06-010 [P0] Use stored XNB duration as a validation oracle. Acceptance: Large decoded-duration disagreement fails with asset-specific diagnostics. Evidence: the .xnb's own stored duration field (previously read-and-discarded as "unused, matches FNA") is now compared against the actually-decoded SoundEffect's duration via new ValidateDecodedDurationAgainstStoredOracle(), wired into both the direct-construction 16-bit PCM fast path and the BuildViaWavWrapper path in SoundEffectReader::Read(). Threshold was empirically calibrated (temporary debug instrumentation, since removed) against all 6 real MonoGame fixtures under tests/assets/xnb/monogame/windows/uncompressed/audio/: uncompressed PCM/float formats match the stored duration exactly (to whole-millisecond rounding), ADPCM formats drift by only a few percent from legitimate block-rounding -- comfortably inside a deliberately generous 2x/0.5x ratio threshold, chosen to catch only order-of-magnitude-class misinterpretations (wrong sample rate, wrong channel count, a doubled/halved frame count from a format mixup) without risking any real, legitimately-authored asset. storedDurationMs == 0 is treated as "not meaningfully set" and skipped entirely -- many real assets across the ecosystem leave this field at its default, and FNA itself never populates/validates it either, so treating an unset value as an error would be a real compatibility regression. New tests DrasticDurationOracleDisagreementThrowsWithBothValues (a 100x disagreement: 50ms real audio vs. 5000ms stored, expects ContentLoadException naming both values and the asset name) and SmallDurationOracleDisagreementDoesNotThrow (a 52ms-vs-50ms plausible rounding difference, expects no throw). Git-stash regression-verified: stashing only SoundEffectContentTypeReader.cpp (keeping the new tests) makes DrasticDurationOracleDisagreementThrowsWithBothValues fail as expected (no exception thrown pre-fix) while SmallDurationOracleDisagreementDoesNotThrow still trivially passes; popping the stash and rebuilding restores both to green. Full whole-repo suite: 4752 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-011 [P0] Validate XNB format length and extension sizes exhaustively. Acceptance: Truncation/overflow/extra-byte cases cannot desynchronize the reader. Investigated -- confirmed already safe by construction, empirically locked down at both ends of the range: formatLength (a uint32_t) drives int64_t skip = formatLength - 18[- 34] then static_cast<int32_t>(skip). Traced the full value range: since formatLength can be at most uint32_t's own ceiling (~4.29 billion), skip can never exceed that range either, so the int32 narrowing always produces EITHER the correct non-negative value (small/valid formatLength) OR a negative value (large/corrupt formatLength) -- there is no reachable formatLength that makes skip wrap around TWICE into a small-but-wrong positive count, which would have been the actual desync risk. The negative case is already caught by ReadBytesExactOrThrow's existing count < 0 guard (ContentLoadException). New tests FormatLengthOneByteTooSmallForCbSizeThrowsCleanly (formatLength=17, one byte short of covering even its own cbSize field, skip=-1) and PathologicallyLargeFormatLengthThrowsCleanlyNotDesync (formatLength=0xFFFFFFF0, near the uint32_t ceiling) both confirm a clean ContentLoadException, not a crash, OOB read, or silently-wrong extension-byte count. No production code change. Full whole-repo suite: 4746 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-012 [P0] Validate data length before allocation/read. Acceptance: Negative, oversized, and truncated lengths fail safely. Investigated -- confirmed already safe via existing shared infrastructure, empirically locked down: SoundEffectReader::Read() passes its declared audio-data length straight to input.ReadBytesExactOrThrow(input.ReadInt32(), "SoundEffectReader") with no length check of its own, but that's safe because the shared helper already handles all three cases: ReadBytesExactOrThrow (ContentReader.cpp) rejects a negative count outright with a ContentLoadException before ever calling ReadBytes; BinaryReader::ReadBytes() (sharp-runtime) clamps its eager allocation to the stream's own real remaining length ("A seekable stream can never deliver more bytes than its own remaining length -- clamp the eager allocation to that bound instead of the raw (possibly adversarial) count") before allocating anything, so a corrupt/adversarial declared length can never force an allocation for bytes that could never be read anyway; a genuinely truncated stream still throws System::IO::EndOfStreamException once the actual byte count comes up short. New tests NegativeDataLengthFailsCleanlyRatherThanCrashing (ContentLoadException) and OversizedDataLengthAgainstTruncatedStreamFailsCleanlyNotWithHugeAllocation (claims 100 MB against a stream that actually ends immediately after the header -- EndOfStreamException, no 100 MB allocation attempt, both tests complete in ~0ms) confirm this specifically through SoundEffectReader's own data-length field, not just the shared helper in isolation. No production code change. Full whole-repo suite: 4743 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-013 [P0] Validate sample rate, channels, block align, and bits coherently. Acceptance: Impossible WAVEFORMATEX combinations are rejected before decoding. Now fully closed -- final piece confirmed empirically: nChannels was already validated (only mono/stereo accepted). nSamplesPerSec=0 was already confirmed to fail deterministically (Pcm8WithZeroSampleRateFailsCleanlyRatherThanCrashing, now via ContentLoadException since AUD-06-024). The remaining open question -- nBlockAlign coherence against nChannels/wBitsPerSample -- is answered by new test IncoherentBlockAlignForPcm8IsIgnoredNotTrustedBySdlDecoder: a PCM8 stereo fixture with a wildly wrong declared nBlockAlign (100, the coherent value is nChannels*wBitsPerSample/8=2) decodes with the EXACT correct duration (10/44100 seconds for 20 real data bytes) -- empirically proving SDL3's own WAV loader does not trust the file's declared nBlockAlign for PCM at all, recomputing the correct block size internally from nChannels/wBitsPerSample instead. No CNA-side coherence check is needed on top of this: the backend cannot be confused by this specific class of incoherent WAVEFORMATEX, so "impossible combinations are rejected" is satisfied by "impossible combinations can't actually produce wrong output," a stronger guarantee than an explicit up-front rejection would be. No production code change. Full whole-repo suite: 4750 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-014 [P0] Validate loop points against decoded sample frames. Acceptance: Loops cannot reference compressed bytes as if they were PCM frames. Investigated -- confirmed already safe by construction, empirically locked down with a real compression ratio: XNA's loopStart/loopLength contract is "expressed in samples" (decoded PCM sample-frame indices) -- FNA's SoundEffectReader.cs forwards them completely uninterpreted, and CNA does the same: the direct 16-bit-PCM path passes them straight to the raw-buffer SoundEffect constructor (where data already IS PCM, so a bytes-vs-frames mixup is structurally impossible), and the WAV-wrapper path (AppendSmplChunkIfLooped) writes the raw XNB ints straight into the WAV smpl chunk's Start/End fields -- data.size() (the compressed byte count) is never read anywhere near the loop values in either path. New test ImaAdpcmLoopPointsSurviveAsDecodedFramesNotCompressedBytes proves this empirically, not just by inspection: a hand-built IMA-ADPCM fixture (4 full 256-byte blocks, no wSamplesPerBlock extension, matching SDL3's own auto-derive formula in SDL_wave.c's IMA_ADPCM_Init) compresses 1024 bytes down to a real, verified 2020-frame (~4:1) decode. Authored loop values (loopStart=1500, loopLength=400) are sane relative to the 2020-frame decoded output but would be nonsensical read as byte offsets against the 1024-byte compressed buffer (loopStart alone exceeds it) -- the resulting SoundEffectInstance's loopStart_/loopLength_ (via SoundEffectInstanceTestAccess) equal the raw XNB ints exactly, proving frame-based passthrough, not byte-based. Confirmed real discriminating power, not a false positive: temporarily clamping loopStart to data.size() at the BuildViaWavWrapper call site (simulating a plausible "validate against the compressed buffer" mistake) made the test fail exactly as predicted (1024 instead of 1500), then reverted. Per the resolved P9-VALIDATION-002 decision, loop points are intentionally not bounds-validated against the decoded length (matches FNA's own internal constructor) -- this test checks unit-correctness (frames, not bytes), not rejection, consistent with that decision. No production code change. Full whole-repo suite: 4753 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-015 [P0] Add Xbox-endian SoundEffect fixtures or prove unsupported scope. Acceptance: Byte swapping is covered rather than comment-only. Evidence: confirmed the full mechanism is genuinely wired end-to-end, not dead code -- XnbHeader.hpp's ParseXnbHeader() really does parse the 4th XNB magic byte as header.platform from real file bytes ('x' is in XnbAcceptedPlatforms()), propagated through ContentReader::getPlatformProperty() to SoundEffectContentTypeReader.cpp's const bool se = input.getPlatformProperty() == 'x', which gates every Swap16/Swap32 call on the WAVEFORMATEX fields. New hand-built fixture test XboxPlatformByteSwapsWaveFormatFieldsCorrectly writes wFormatTag/nChannels/nSamplesPerSec/nAvgBytesPerSec/nBlockAlign/wBitsPerSample in real big-endian byte order (matching genuine Xbox 360/PowerPC output) while leaving the surrounding XNB container fields (formatLength, data length, loop points, and the raw PCM sample data itself) little-endian, exactly matching what real FNA's SoundEffectReader.Swap() calls do and don't wrap; constructs with platform='x' and asserts the resulting SoundEffect's duration is correct. Confirmed real discriminating power, not a false positive: temporarily forcing se = false (disabling the swap) makes the test fail with unsupported SoundEffect channel count (256) -- the big-endian nChannels=1 bytes misread as little-endian decode to 256, exactly as predicted, then reverted. No production code change. Full whole-repo suite: 4741 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-016 [P1] Test format chunks of 16, 18, and extended sizes. Acceptance: Reader consumes exactly the declared bytes. Evidence: three new tests, one per size class the reader distinguishes, each proving exact byte consumption via a decoded-frame-count assertion that could only be right if every subsequent field (data length, loop points) was read from the correct offset: FormatLength16BareWaveFormatConsumesExactlyDeclaredBytes (formatLength=16, no cbSize read at all -- PCM8 WAV-wrapped path), FormatLength18WithZeroCbSizeConsumesExactlyDeclaredBytes (formatLength=18, cbSize present and explicitly zero -- IMA-ADPCM, exercises the formatLength > 16 branch with skip == 0), and ExtendedFormatLengthWithRealCoefficientTableConsumesExactlyDeclaredBytes (formatLength=50, a real 32-byte MS-ADPCM coefficient-table extension, matching ComputeMsAdpcmSamplesPerBlock's own formula and SDL3's exact standard-coefficient validation table). Confirmed real discriminating power, not a false positive: temporarily injecting a 2-byte desync into the extension skip calculation (formatLength - 18 - 2) made both the 18-byte and extended-size tests fail exactly as predicted (a thrown ContentLoadException for the exact-fit case, a garbage 67MB declared data length for the extended case) while the unaffected 16-byte test stayed green, then reverted. No production code change. Full whole-repo suite: 4756 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-017 [P1] Test unknown format tags. Acceptance: Failure includes tag, bit depth, channels, rate, and asset name. Confirmed real gap and fixed: the pre-existing unsupported-format ContentLoadException message (SoundEffectContentTypeReader.cpp) named formatTag/bitsPerSample/asset name but not channels/sampleRate. Added both. New test UnknownFormatTagDiagnosticIncludesAllRelevantFields (a genuinely unassigned format tag 0x270B, 24-bit/stereo/22050 Hz) confirms the resulting message contains the tag, bit depth, channel count, sample rate, and asset name. Full whole-repo suite: 4747 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-018 [P1] Test mono/stereo and any reference-supported multichannel content. Acceptance: Policy is explicit and golden-tested. Evidence: real XNA 4.0's own AudioChannels enum only has Mono=1/Stereo=2 (see AudioChannels.hpp) -- there is no reference-supported multichannel layout to test, since XNA's public API itself cannot express one. Mono/stereo are already golden-tested via real fixtures (Pcm16BitMonoLoadsSuccessfully, Pcm16BitStereoLoadsSuccessfully, plus the WAV-wrapped-format LoadsSuccessfully tests). The reader's own explicit-rejection guard for any other nChannels (previously only exercised as an incidental side effect of AUD-06-015's reverted byte-swap probe, not a permanent test) now has two dedicated golden tests: MultichannelXnbIsExplicitlyRejectedWithClearDiagnostic (nChannels=6, 5.1 surround) and ZeroChannelsXnbIsExplicitlyRejectedWithClearDiagnostic (nChannels=0), both confirming ContentLoadException naming the asset and the "only mono and stereo are supported" policy text. No production code change (the guard already existed). Full whole-repo suite: 4758 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-019 [P1] Test loopless and looped XNB effects. Acceptance: Loop start/length semantics match reference. Evidence: the looped case is already end-to-end verified by the combination of two existing tests: AUD-06-014's ImaAdpcmLoopPointsSurviveAsDecodedFramesNotCompressedBytes proves the XNB reader propagates loopStart/loopLength to the resulting SoundEffectInstance frame-exact and unmodified, and SoundEffectInstanceTests.cpp's pre-existing BoundedLoopRegionPlaysIntroOnceThenRepeatsOnlyTheLoopRegion (P10-LOOP-003/004) proves those same instance-level fields drive correct real playback (intro plays once, then only the loop region repeats) regardless of whether the originating SoundEffect came from the XNB reader or the raw-buffer constructor -- Play() only ever reads loopStart_/loopLength_, with no notion of origin. New test LooplessXnbEffectHasNoLoopRegionOnInstance closes the one genuinely untested case: an XNB with loopStart=loopLength=0 (the common case -- every real-fixture-backed LoadsSuccessfully test in this file has this) produces an instance with LoopStart()==0/LoopLength()==0, not a spurious zero-length region. No production code change. Full whole-repo suite: 4759 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-020 [P1] Test compressed XNB container plus compressed audio payload. Acceptance: Outer compression and inner codec are independently correct. Investigated -- confirmed already covered by composition, with a documented, genuine gap: read ContentManager::LoadXnbAsset<T>()'s LZX branch (ContentManager.hpp lines ~463-478) line-by-line: it decompresses via CNA::Internal::Xnb::DecompressXnbPayload, then constructs a plain ContentReader and calls contentReader.ReadAsset<T>() -- byte-for-byte the same call the uncompressed branch makes and the same call every SoundEffectContentTypeReaderTest in this file makes directly. There is no T-specific code anywhere in the compression branch, so the "outer compression" mechanism cannot behave differently for SoundEffect than for any other type. Outer compression is already proven correct via a real, externally-produced fixture through the exact same production entry point: ContentManagerTexture2DXnbTests.cpp's LoadRealLzxCompressedFixtureEndToEnd (MonoGame's own Explosion.xnb) drives ContentManager::Load<Texture2D>() end-to-end through real LZX decompression, plus the dedicated LzxDecoderTests.cpp/LzxDecoderDifferentialTests.cpp/LzxDecoderFuzzTests.cpp suites test the decompressor itself in isolation, all payload-content-agnostic. Inner codec correctness for every supported audio format (PCM 8/16-bit, IEEE float, MS/IMA-ADPCM) is already established by dozens of tests elsewhere in this file, all exercising ReadAsset<SoundEffect>() against a decompressed byte stream -- exactly the state LoadXnbAsset<SoundEffect>()'s LZX branch hands to ReadAsset<T>() after decompression, so there is no reachable code path where a genuinely new SoundEffect-specific bug could hide in the combination that isn't already covered by one half or the other. Genuine, documented gap: no real, externally-produced LZX-compressed audio .xnb fixture exists anywhere in this project's asset corpus (tests/assets/xnb/monogame/windows/lzx/Explosion.xnb, despite its folder name, is confirmed by its own manifest to be a Texture2DReader asset, not audio), and CNA has no LZX encoder (only a decoder -- compression is applied exclusively by Microsoft's/MonoGame's upstream content-pipeline tooling, which this project has no access to invoke), so a genuine round-trip compressed-audio fixture cannot be hand-built the way every other AUD-06 test in this file was. If a real compressed-audio .xnb fixture becomes available later, add a dedicated ContentManager::Load<SoundEffect>() end-to-end test mirroring LoadRealLzxCompressedFixtureEndToEnd. No production code change, no test change.
  • AUD-06-021 [P1] Differential-test SoundEffect XNB reader against FNA on a shared corpus. Acceptance: Metadata and decoded output differences are reviewed and registered. Evidence -- real differential run, metadata side; decoded-output side documented as a genuine environment limitation: this build machine has mono/mcs (Mono 6.12) available, so rather than only inspecting FNA's C# source by eye, built a standalone tool (tools/audio/fna_soundeffect_metadata_dump/FnaSoundEffectMetadataDump.cs, not part of the CMake build -- a one-off differential-verification tool, not a permanent CI dependency) that copies FNA's real SoundEffectReader.cs field-reading logic verbatim (same field order/types/swap logic) plus FNA's real type-reader-table preamble logic (ContentTypeReaderManager.LoadAssetReaders/ContentReader.InnerReadObject, via .NET's own BinaryReader.Read7BitEncodedInt()/ReadString(), not a reimplementation), compiled with mcs, and ran with mono against all 6 real MonoGame fixtures in tests/assets/xnb/monogame/windows/uncompressed/audio/. Every field this tool independently parsed (formatLength, wFormatTag, nChannels, nSamplesPerSec, nAvgBytesPerSec, nBlockAlign, wBitsPerSample, dataLength, loopStart, loopLength, durationMs) matched exactly, for every fixture, against both the fixtures' own recorded manifest expectedFields and this session's own established, independently-tested CNA behavior (e.g. durationMs 500/500/500/601/507/500 for the six fixtures matches AUD-06-010's own real-fixture calibration data verbatim) -- zero discrepancies found. This is a genuine two-independent-implementations comparison (FNA's real C# via mono, CNA's real C++ via SDL3), not one implementation checked against a hand-authored expectation. Documented, genuine limitation: decoded-sample differential testing (actual PCM output, not just metadata) is NOT achievable in this environment -- FNA's real SoundEffect decode path requires FAudio (a native library), which is not built/available here, and building it was judged out of scope for a P1 verification task. If a FAudio build becomes available, extend the tool to actually decode and dump PCM samples for a sample-level comparison. No production code change.
  • AUD-06-022 [P1] Add property-based XNB WAVEFORMATEX mutation tests. Acceptance: Parser invariants hold across boundary values. Evidence: new SoundEffectContentTypeReaderPropertyTests.cpp deterministically sweeps the full cross-product of boundary values for wFormatTag (0, PCM, MS-ADPCM, IEEE float, IMA-ADPCM, XMA2, 0xFFFF) x nChannels (0, 1, 2, 3, 0xFFFF) x nSamplesPerSec (0, 1, 44100, UINT32_MAX) x wBitsPerSample (0, 1, 4, 8, 16, 24, 32, 0xFFFF) -- 1120 combinations, formatLength held fixed at 16 since its own boundary values are already covered separately by AUD-06-011/AUD-06-016. Asserts the parser invariant holds for every combination: construction either succeeds or fails with exactly ContentLoadException/EndOfStreamException, never any other exception type, a crash, or a hang. Confirmed real discriminating power, not a false positive: this sweep directly found the real AUD-06-024 follow-up gap (the direct-PCM16-path NotSupportedException context bug) -- temporarily reverting to the pre-fix reader made the sweep fail immediately (the exact nSamplesPerSec=0/PCM16 combination this cross-product includes), then restored. Runs in ~17ms (1120 trivial constructions). Full whole-repo suite: 4761 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-023 [P1] Fuzz SoundEffectContentTypeReader under ASan/UBSan. Acceptance: No crash, leak, OOB, or pathological allocation on malformed files. Evidence: substantially already covered by the pre-existing XnbContainerFuzzTests.cpp's MutatedRealSoundEffectFixtureNeverCrashesAndOnlyFailsCleanly (1500 random whole-file-byte-flip mutations of a real tone_mono_44khz_16bit.xnb fixture through the full production ContentManager::Load<SoundEffect>() path, meant to be run under an ASan+UBSan build per that file's own header comment), now complemented by this task's new AUD-06-022 deterministic boundary-value property sweep (which caught a real bug -- the AUD-06-024 follow-up gap -- that 1500 random mutations with a fixed seed happened not to hit). Together these give both breadth (whole-file random corruption, any byte) and precision (exact boundary values on the fields most likely to matter) coverage of the reader under malformed/adversarial input. No new dedicated byte-flip fuzz harness was written for SoundEffectContentTypeReader specifically, since XnbContainerFuzzTests.cpp already exercises it end-to-end and building a second, near-identical byte-flip harness for the same target would be redundant infrastructure. No production code change beyond the AUD-06-024 follow-up fix this sweep found.
  • AUD-06-024 [P1] Add useful ContentLoadException nesting. Acceptance: Root parser/decoder error is preserved with asset context. Confirmed real gap and fixed: BuildViaWavWrapper() (the WAV-wrapped decode path for PCM8/float/MS-ADPCM/IMA-ADPCM) had no try/catch at all -- a decode failure from SoundEffect::FromStream/SDL3's own WAV decoder (e.g. an invalid sample rate, AUD-06-013) propagated as a raw System::NotSupportedException with no .xnb asset context whatsoever, unlike every other failure path in this reader (which all throw ContentLoadException naming the asset). ContentLoadException already had an unused inner-exception constructor (ContentLoadException(message, inner), appends " ---> " + inner.what(), matching .NET's own inner-exception ToString() convention) -- now used here: the decode call is wrapped in try/catch(const std::exception&), re-throwing as ContentLoadException naming the asset and format fields, with the original root-cause message preserved verbatim via the inner-exception chain. Updated the pre-existing Pcm8WithZeroSampleRateFailsCleanlyRatherThanCrashing test (previously asserted the raw NotSupportedException, now asserts ContentLoadException containing both the asset name and the ---> inner-exception marker) -- an intentional, deliberate behavior improvement, not a regression. Full whole-repo suite: 4747 passed / 0 failed / 2 skipped (unrelated hardware-support tests). Follow-up gap found and fixed during AUD-06-023 fuzzing prep (2026-07-18): the 16-bit PCM direct fast path (a separate code path from BuildViaWavWrapper, never touched by this fix) had the exact same problem -- an invalid sample rate let a raw System::NotSupportedException escape from SoundEffect's raw-buffer constructor with no asset context. Fixed via a new BuildDirectPcm16() helper mirroring BuildViaWavWrapper's exact try/catch pattern (kept as a separate helper so ValidateDecodedDurationAgainstStoredOracle, called after construction, isn't itself caught and needlessly re-wrapped). New test Pcm16WithZeroSampleRateFailsWithAssetContextNotRawException, git-stash regression-verified (fails against the pre-fix code with the raw, context-free exception; passes after restoring the fix). Full whole-repo suite: 4760 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-06-025 [P2] Add a tool to inspect XNB audio metadata without playing it. Acceptance: Tool emits stable text/JSON for debugging and CI manifests. Evidence: new standalone executable cna_xnb_audio_metadata_dump (tools/audio/xnb_audio_metadata_dump.cpp, registered in cmake/Harnesses.cmake) loads a .xnb SoundEffect asset through the real, unmodified production ContentManager::Load<SoundEffect>() path (real decode, so the reported duration reflects the actual decoded audio) and emits stable single-line JSON to stdout, never calling Play()/CreateInstance(): {"asset":"...","status":"ok","durationMs":...} on success, {"asset":"...","status":"error","message":"..."} (exit code 1) on failure, with "/\ escaped. Verified against real fixtures: tone_mono_44khz_16bit -> durationMs:500.000, tone_mono_44khz_msadpcm -> durationMs:507.846 (matches AUD-06-021's independent FNA differential dump's stored durationMs=507 within the same rounding tolerance AUD-06-010 already calibrated), and a missing-file path correctly returns status:"error" with exit code 1. Deliberately does not report raw WAVEFORMATEX fields (format tag, channels, sample rate, bits, loop points): real XNA's SoundEffect only exposes Name/Duration publicly, and adding new NOXNA accessors under a P2 tooling task was judged out of scope -- AUD-06-021's differential tool already provides that level of detail against a real fixture corpus when needed. Full whole-repo suite: 4761 passed / 0 failed / 2 skipped (unrelated hardware-support tests).

AUD-07 — DynamicSoundEffectInstance correctness

Make procedural/streamed audio format-safe, failure-safe, and timing-correct.

  • AUD-07-001 [P0] Define whether one DynamicSoundEffectInstance may switch between S16 and float modes. Acceptance: Behavior is verified against FNA extension semantics and documented. Evidence: read FNA's real DynamicSoundEffectInstance.cs/SoundEffectInstance.cs line-by-line -- FNA itself has the identical asymmetry (format.wFormatTag is set to float by SubmitFloatBufferEXT and never reset anywhere; plain SubmitBuffer never checks it). Since SubmitFloatBufferEXT is an FNA/NOXNA extension (not real XNA 4.0 API), decided CNA may be safer than FNA here without diverging from any true XNA behavior: SubmitBuffer now symmetric-guards/resets isFloat_ (see AUD-07-002). A real XNA game that never calls the NOXNA float path is completely unaffected. Documented on both SubmitBuffer/SubmitFloatBufferEXT Doxygen in DynamicSoundEffectInstance.hpp.
  • AUD-07-002 [P0] Prevent S16 submission into an F32-configured stream. Acceptance: Regression test covers float→stop→integer submission. Evidence: DynamicSoundEffectInstance::SubmitBuffer now throws System::InvalidOperationException if called while Playing/Paused in float mode, and resets isFloat_ = false when called while Stopped (mirrors SubmitFloatBufferEXT's existing guard). New tests SubmitBufferWhileStoppedSwitchesBackToIntModeAfterFloatSubmission (verifies via MIX_GetTrackAudioStream+SDL_GetAudioStreamFormat that the live stream is genuinely S16 after float→stop→int) and SubmitIntBufferAfterPlayingInFloatModeThrowsInvalidOperation; both confirmed to FAIL against the pre-fix code via git stash (stashed DynamicSoundEffectInstance.{hpp,cpp}, kept tests). Full DynamicSoundEffectInstanceTest suite 52/52 pass (was 50/50 pre-existing + 2 new).
  • AUD-07-003 [P0] Prevent F32 submission into a live S16 stream. Acceptance: Existing guard is tested under races and repeated play cycles. Added the exact "under races and repeated play cycles" test the acceptance criteria names, and used it to independently confirm this session's AUD-15-006 fix genuinely covers this specific direction (float-into-live-int) too, not just the int-into-live-float direction that fix's own crash reproduction happened to hit. New DynamicSoundEffectInstanceTest.StressSubmitFloatBufferEXTAgainstRepeatedPlayCyclesNeverCorruptsLiveStream: a producer thread alternates SubmitFloatBufferEXT()/SubmitBuffer() calls (3000 total) while a "game" thread runs 3000 real Play()/Stop(true) cycles. Alternating is deliberate, not incidental: isFloat_ latches true after the first successful float submission and stays true until something calls SubmitBuffer() to reset it -- a float-only producer would only ever exercise the racy if (!isFloat_) guard once, at the very start, then never again; confirmed empirically that an earlier float-only version of this test did not catch a deliberately-reintroduced pre-AUD-15-006 regression (removing SubmitFloatBufferEXT()'s queueMutex_ lock around its getStateProperty() check) across 5 ASan runs. The corrected, alternating version does: reproduced a genuine AddressSanitizer: SEGV in __pthread_mutex_lock (SDL_LockAudioStream inside MIX_TrackPlaying, called from getStateProperty(), called from SubmitFloatBufferEXT()'s now-unlocked check) against the same probe. Reverted the probe; verified the real (locked) code passes reliably and quickly both ways: normal build 5x clean (13-23ms each), ASan+UBSan build 5x clean (64-76ms each) -- confirming the fix from AUD-15-006 is not merely "doesn't crash sometimes" but genuinely closes this race. No further production code change needed. Full audio-scoped filter and whole-repo suite both clean (whole-repo: 4786/4788 passed, 2 pre-existing hardware skips) aside from the already-tracked, pre-existing AUD-15-021 flake (reproduced 2/3 times in one regression pass here, consistent with its documented intermittent, unrelated-to-this-session's-changes nature).
  • AUD-07-004 [P0] Validate constructor sample rate and channels. Acceptance: Invalid metadata fails before stream creation. Evidence -- resolved decision, acceptance corrected to match: this was already investigated and deliberately resolved in a prior session (P10-DYN-001/002/003, DynamicSoundEffectInstanceTests.cpp -- ConstructorAcceptsZeroSampleRate, ConstructorAcceptsNegativeSampleRate, ConstructorAcceptsSampleRateBelowXnaDocumentedMinimum/AboveXnaDocumentedMaximum), which read FNA's real constructor line-by-line and confirmed it does zero validation on sampleRate/channels despite MSDN's documented 8000-48000 Hz contract. The plan's literal acceptance ("fails before stream creation") would diverge from real FNA behavior for no XNA-parity benefit -- re-verified this decision still holds and did not re-litigate it. What genuinely needed fixing instead was Play()'s downstream handling when a bad sampleRate (e.g. 0) makes SDL_CreateAudioStream fail at stream-creation time -- that is AUD-02-007/AUD-07-007's fix, proven by the new PlayWithZeroSampleRateDoesNotReportPlayingOnStreamCreationFailure test.
  • AUD-07-005 [P0] Validate S16 submission byte count is frame-aligned. Acceptance: Partial samples/frames are rejected.
  • AUD-07-006 [P0] Validate float submission element count is channel-frame-aligned. Acceptance: Partial multichannel frames are rejected.
  • AUD-07-007 [P0] Check SDL_CreateAudioStream and preserve backend error. Acceptance: Null stream cannot proceed to track setup. Evidence: see AUD-02-007. New test PlayWithZeroSampleRateDoesNotReportPlayingOnStreamCreationFailure (empirically confirmed SDL_CreateAudioStream rejects freq=0 with "Parameter 'src_spec->freq' is invalid"); confirmed to FAIL against the pre-fix code via git stash. Full DynamicSoundEffectInstanceTest suite 53/53 pass (was 52/52 + 1 new); full whole-repo suite 4654/4656 pass (2 pre-existing hardware skips).
  • AUD-07-008 [P0] After MIX_SetTrackAudioStream, query both stream specs. Acceptance: Source/destination are valid and expected before first put. Investigated (A-07's "strong risk") -- confirmed already safe, acceptance's literal continuous query not implemented, one-time regression check added instead: direct probe confirmed SDL_CreateAudioStream(spec, nullptr)'s destination format genuinely is invalid/absent until the stream is attached to a track (SDL_GetAudioStreamFormat fails, "Stream has no destination format"), but MIX_SetTrackAudioStream immediately establishes it -- and DynamicSoundEffectInstance::Play()'s existing call order already has MIX_SetTrackAudioStream run before the first SubmitQueuedToStream()/SDL_PutAudioStreamData call (via QueueInitialBuffers(), itself only called after MIX_SetTrackAudioStream succeeds) -- confirmed by reading every code path that can reach SubmitQueuedToStream() (SubmitBuffer/SubmitFloatBufferEXT only submit immediately while already Playing, i.e. after a Play() that already ran MIX_SetTrackAudioStream). New test StreamDestinationFormatIsValidImmediatelyAfterPlay locks this down (both specs valid, source matches the constructed sample rate/channels/format) rather than adding a continuous per-put query the existing ordering already makes unnecessary.
  • AUD-07-009 [P0] Check SDL_PutAudioStreamData; retain failed chunks or fail deterministically. Acceptance: No data loss and no false submitted count. Evidence: see AUD-02-008. No dedicated regression test added (forcing a real SDL_PutAudioStreamData failure from the public API without a fault-injection seam was not found to be reliably reproducible in this pass; the fix itself is a straightforward return-value check mirroring the already-tested SDL_CreateAudioStream/MIX_PlayTrack checks).
  • AUD-07-010 [P0] Check MIX_PlayTrack; set Playing only on success. Acceptance: State and framework dispatcher registration remain truthful. Evidence: see AUD-02-009. Covered transitively by PlayWithZeroSampleRateDoesNotReportPlayingOnStreamCreationFailure for the upstream stream-creation-failure path; a dedicated MIX_PlayTrack-only failure fixture was not found (no fault-injection seam from the public API), same limitation as AUD-07-009.
  • AUD-07-011 [P0] Correct submitted-buffer completion accounting under resampling. Acceptance: PendingBufferCount changes only when complete source chunks are audibly consumed according to defined semantics.
  • AUD-07-012 [P0] Test 22.05→44.1, 44.1→48, and 48→44.1 dynamic conversion. Acceptance: Frequency and duration stay correct.
  • AUD-07-013 [P0] Test immediate queue-before-Play behavior. Acceptance: All queued buffers play once, in order, without gap/reordering.
  • AUD-07-014 [P0] Test submit-while-playing behavior. Acceptance: No race, loss, duplicate submission, or format mismatch.
  • AUD-07-015 [P0] Test starvation and BufferNeeded callback cadence. Acceptance: Callback counts and pending-buffer semantics match reference.
  • AUD-07-016 [P0] Test pause/resume without consuming or replaying data incorrectly. Acceptance: Frame position and queue counts remain consistent.
  • AUD-07-017 [P0] Test immediate Stop and subsequent replay. Acceptance: Old converted data cannot leak into the new playback session.
  • AUD-07-018 [P0] Test Dispose during callback/update/submission. Acceptance: No deadlock, UAF, callback-after-dispose, or queue leak.
  • AUD-07-019 [P0] Test MIX_PROP_PLAY_HALT_WHEN_EXHAUSTED=false semantics with pinned mixer. Acceptance: Track remains feedable without spinning or false stop.
  • AUD-07-020 [P1] Define queue memory limits and backpressure. Acceptance: Untrusted/buggy producers cannot grow memory without bound.
  • AUD-07-021 [P1] Make buffer-needed dispatch thread explicit. Acceptance: Callbacks never unexpectedly execute on real-time audio thread unless documented.
  • AUD-07-022 [P1] Avoid holding queue mutex during user callbacks. Acceptance: Reentrant submission cannot deadlock.
  • AUD-07-023 [P1] Test zero-length submissions. Acceptance: Behavior matches reference and does not corrupt completion accounting.
  • AUD-07-024 [P1] Test tiny and highly fragmented buffers. Acceptance: No accumulated timing gaps or pathological overhead.
  • AUD-07-025 [P1] Test multi-second large buffers. Acceptance: No integer truncation in SDL length or counters.
  • AUD-07-026 [P1] Test repeated Play calls while already Playing. Acceptance: Update/callback behavior matches reference without restart.
  • AUD-07-027 [P1] Test Stop(false) exception semantics. Acceptance: Public behavior matches XNA/FNA exactly.
  • AUD-07-028 [P1] Test master volume, volume, pan, pitch, and 3D state set before first Play. Acceptance: All effective properties apply on first frame exactly once.
  • AUD-07-029 [P1] Test frequency-ratio changes while dynamically streaming. Acceptance: Future samples change speed as documented without queue corruption.
  • AUD-07-030 [P1] Instrument underrun duration and recovery. Acceptance: CI can distinguish expected starvation from backend defects.
  • AUD-07-031 [P2] Evaluate callback-driven stream feeding to reduce polling jitter. Acceptance: Decision includes real-time safety and parity tradeoffs.

AUD-08 — SoundEffect and SoundEffectInstance API/audio parity

Prove lifecycle, pitch, volume, pan, loops, and voice limits against the reference behavior.

  • AUD-08-001 [P0] Golden-test SoundEffect::Play() at neutral volume/pitch/pan. Acceptance: Default path produces ratio 1, expected duration, and centered channels.
  • AUD-08-002 [P0] Golden-test parameterized fire-and-forget Play. Acceptance: Volume, pitch, and pan each affect output exactly once.
  • AUD-08-003 [P0] Verify Pitch=-1,0,+1 maps to 0.5,1,2 consumption ratios. Acceptance: Frequency and duration both match octave semantics. Evidence: AUD08GoldenPitchMatrix/GoldenPitchRatioTest params 0/4/8 (pitch -1.0/0.0/1.0) -- real rendered-audio frequency measured within 0.1% of the expected 0.5x/1.0x/2.0x octave ratios.
  • AUD-08-004 [P0] Verify intermediate pitch values use exponential rather than linear mapping. Acceptance: ±0.5 produce expected square-root ratios. Evidence: same test, params for pitch=-0.5/+0.5 measure ratios of 0.7071068/1.4142136 (sqrt(0.5)/sqrt(2)) -- the OLD linear-formula bug P12-PITCH-001 fixed would have produced 0.5/1.5 instead, clearly distinguishable from what's actually measured.
  • AUD-08-005 [P0] Verify pitch range validation and exception type. Acceptance: Bounds match selected XNA 4.0 platform behavior.
  • AUD-08-006 [P0] Verify final frequency ratio is finite and within backend bounds. Acceptance: NaN/Inf/invalid composite inputs cannot reach SDL.
  • AUD-08-007 [P0] Verify Play failure returns false or throws according to the XNA contract. Acceptance: Voice exhaustion differs from content/backend failure.
  • AUD-08-008 [P0] Test voice-limit behavior against XNA/FNA. Acceptance: Concurrent instance limits and exception/result behavior are documented and enforced.
  • AUD-08-009 [P0] Make instance registry thread-safe or explicitly main-thread confined. Acceptance: Create/dispose/parent-dispose cannot race the raw pointer collection.
  • AUD-08-010 [P0] Test disposing SoundEffect with live instances. Acceptance: All instances stop/dispose safely with reference-compatible semantics.
  • AUD-08-011 [P0] Test fire-and-forget cleanup on normal end, stop, mixer destroy, and failure. Acceptance: No leaked track/audio/instance or stale callback.
  • AUD-08-012 [P1] Golden-test pan extremes and center. Acceptance: Channel isolation and center law match baseline.
  • AUD-08-013 [P1] Golden-test volume 0, fractional, 1, and master-volume combinations. Acceptance: Amplitude scales predictably without double application.
  • AUD-08-014 [P1] Test property changes before Play, during Play, Paused, and Stopped. Acceptance: Timing and applicability match reference.
  • AUD-08-015 [P1] Test repeated Play/Pause/Resume/Stop state transitions. Acceptance: No restart, stale track, or incorrect callback order.
  • AUD-08-016 [P1] Test loop start/length and IsLooped. Acceptance: Loops use sample frames, not bytes, and have no boundary click.
  • AUD-08-017 [P1] Test zero and full-buffer loop regions. Acceptance: Edge semantics match reference.
  • AUD-08-018 [P1] Test track-ended callback races with explicit Stop/Dispose. Acceptance: Cleanup occurs once.
  • AUD-08-019 [P1] Verify Duration for all supported codecs. Acceptance: Duration is decoded-frame accurate, not byte-size guessed.
  • AUD-08-020 [P1] Verify Name, IsDisposed, and exception semantics after disposal. Acceptance: API parity tests cover every public member.
  • AUD-08-021 [P1] Test copies/moves/shared implementation ownership in C++. Acceptance: No accidental duplicated ownership or dangling backend resource.
  • AUD-08-022 [P2] Benchmark high-rate one-shot SFX churn. Acceptance: Allocation and cleanup meet an explicit frame-time budget.
  • AUD-08-023..031 [P1] Golden-test static instance Pitch=-1.0/-0.75/-0.5/-0.25/0/0.25/0.5/0.75/1.0. Acceptance (each): Final ratio matches 2^Pitch before Doppler and measured frequency/duration match. Strong partial evidence, left unchecked -- literal acceptance not yet met: AUD08GoldenPitchMatrix/GoldenPitchRatioTest (OfflineAudioRendererTests.cpp) proves, for all 9 of these exact pitch values, that feeding 2^pitch into MIX_SetTrackFrequencyRatio on a real SDL3_mixer track produces rendered audio whose measured frequency matches the expected ratio to within 0.1% (via RefineFrequencyEstimateHz) -- combined with the pre-existing SoundEffectInstance::INTERNAL_calculatePitchRatio unit tests (already proving Pitch computes exactly 2^Pitch, P12-PITCH-001), this closes the gap between "the math is right" and "the mixer really does what the math says" for every one of these 9 values. What remains unproven: an end-to-end capture through the actual public SoundEffectInstance/SoundEffect object graph itself (this harness renders via a private MIX_CreateMixer(), not the shared device mixer SoundEffectInstance actually uses) -- doing that would need either real-device capture or a track cooked-callback hook (same technique T-4C's filter tests already use for sample-level verification). Not force-fit into this pass; a concrete, scoped near-term follow-up.

AUD-09 — Apply3D, distance, panning, Doppler, and spatial fidelity

Eliminate velocity/unit-induced pitch bugs and document unavoidable spatial differences.

  • AUD-09-001 [P0] Create a documented 3D math baseline from XNA/FNA/FAudio behavior. Acceptance: Coordinate system, handedness, units, channel mask, and formula ownership are explicit.
  • AUD-09-002 [P0] Trace listener/emitter positions, velocities, orientations, and scales. Acceptance: Affected high-pitch cases expose every 3D input.
  • AUD-09-003 [P0] Test zero velocities always yield Doppler ratio 1. Acceptance: No position-only pitch shift. Evidence: Apply3DZeroVelocitiesYieldNeutralDopplerRegardlessOfDefaultScale -- distinct from AUD-09-004, this leaves DopplerScale at its real default (1.0) and only zeroes velocity, proving the neutral ratio comes from the velocity math itself, not merely the scale-zero shortcut.
  • AUD-09-004 [P0] Test DopplerScale=0 always disables Doppler. Acceptance: Final ratio is independent of velocities. Evidence: pre-existing Apply3DDopplerIsNoOpWhenGlobalDopplerScaleIsZero already covers this exactly (confirmed still passing).
  • AUD-09-005 [P0] Test equal listener/emitter velocity yields no relative Doppler. Acceptance: Parallel motion does not shift pitch. Evidence: Apply3DEqualListenerAndEmitterVelocityYieldsNeutralDoppler (both moving at 50 units/sec along the same axis).
  • AUD-09-006 [P0] Test approaching and receding axial motion. Acceptance: Ratios match baseline and are reciprocal within expected physics limits.
  • AUD-09-007 [P0] Test tangential motion. Acceptance: No radial-velocity pitch shift. Evidence: Apply3DTangentialMotionYieldsNeutralDoppler (emitter moving perpendicular to the emitter-listener axis).
  • AUD-09-008 [P0] Test unit conversion from per-frame to per-second velocities in sample code. Acceptance: Reference examples cannot accidentally create frame-rate-dependent pitch.
  • AUD-09-009 [P0] Verify global and emitter Doppler scales combine exactly once. Acceptance: No double multiplication.
  • AUD-09-010 [P0] Verify Doppler clamp behavior and backend ratio range. Acceptance: Clamping matches selected reference and logs when reached.
  • AUD-09-011 [P0] Test large/invalid velocities, coincident positions, and zero distance. Acceptance: No NaN, Inf, sign inversion, or unstable ratio. Evidence: Apply3DCoincidentPositionsDoesNotProduceNaNOrInf (distance==0, ComputeDopplerFactor's own distance != 0.0f guard leaves both velocity components at zero); Apply3DExtremeVelocityClampsToDocumentedRange (a -1e9 velocity drives the Doppler formula's denominator to exactly zero -- verified by hand that this produces +infinity before the final std::clamp(dopplerFactor, 0.5f, 4.0f) call, which correctly clamps +inf down to 4.0f rather than propagating it -- both tests confirm the clamped, finite result reaches MIX_SetTrackFrequencyRatio).
  • AUD-09-012 [P0] Verify DistanceScale semantics and validation. Acceptance: Attenuation and 3D calculations use consistent world units.
  • AUD-09-013 [P1] Golden-test left/right/front/back source positions. Acceptance: Pan/spatial matrix is documented and stable.
  • AUD-09-014 [P1] Test listener orientation normalization and degenerate vectors. Acceptance: Invalid bases fail or normalize deterministically.
  • AUD-09-015 [P1] Test emitter orientation and cone behavior against XNA scope. Acceptance: Unsupported cone semantics are explicit rather than silently ignored.
  • AUD-09-016 [P1] Test multiple listeners according to XNA overload semantics. Acceptance: Contribution/selection matches baseline.
  • AUD-09-017 [P1] Characterize stereo-source Apply3D behavior. Acceptance: Reference restrictions/exceptions and output matrix are matched.
  • AUD-09-018 [P1] Compare attenuation curves across distances. Acceptance: Curve and clamping match baseline within tolerance.
  • AUD-09-019 [P1] Compare panning law with XNA/FAudio captures. Acceptance: Known divergence is either fixed or registered.
  • AUD-09-020 [P1] Validate custom low-pass/filter response used by 3D/RPC. Acceptance: Cutoff/Q are correct at 44.1 and 48 kHz.
  • AUD-09-021 [P1] Implement or explicitly scope speaker-channel matrices beyond stereo. Acceptance: 5.1/7.1 behavior is not accidental stereo duplication.
  • AUD-09-022 [P1] Implement or explicitly scope LFE handling. Acceptance: Low-frequency routing matches documented capability.
  • AUD-09-023 [P1] Evaluate FAudio/F3DAudio integration as an optional parity backend. Acceptance: Decision compares fidelity, licensing, maintenance, and platform support.
  • AUD-09-024 [P2] Evaluate optional HRTF as NOXNA functionality. Acceptance: Extension cannot alter default XNA-compatible behavior.
  • AUD-09-025 [P2] Add moving-source trajectory golden captures. Acceptance: Continuous parameter updates have no zipper noise or discontinuity.

AUD-10 — XACT parser and runtime parity

Make cue selection, pitch, timing, variation, categories, and DSP behavior evidence-based.

  • AUD-10-001 [P0] Build a real XACT corpus from legally redistributable projects and generated edge cases. Acceptance: Corpus includes XGS/XSB/XWB versions, compact/noncompact banks, variations, RPC, loops, and categories.
  • AUD-10-002 [P0] Differential-parse every corpus file against FAudio/FACT tooling. Acceptance: All offsets, entries, formats, events, curves, and names agree or divergence is documented.
  • AUD-10-003 [P0] Create an XACT cue execution trace format. Acceptance: Trace records selected sound/track/wave, event timestamps, pitch, volume, filters, loops, and stop reason.
  • AUD-10-004 [P0] Capture corresponding XNA/FAudio cue traces or rendered output. Acceptance: Runtime behavior has an oracle beyond parser fields.
  • AUD-10-005 [P0] Verify cents-to-ratio conversion is 2^(cents/1200). Acceptance: Known cents values produce exact ratios. Evidence: Cue.cpp's CentsToPitch(cents) = clamp(cents/1200, -1, 1) feeds SoundEffectInstance::setPitchProperty(), which composes the final ratio via INTERNAL_calculatePitchRatio (2^pitch, P12-PITCH-001) -- the full chain is exactly 2^clamp(cents/1200,-1,1). The [-1,1] clamp matches real FNA's own Pitch setter exactly (SoundEffectInstance.cs: INTERNAL_pitch = MathHelper.Clamp(value, -1.0f, 1.0f);), confirmed by reading the FNA source directly. Pre-existing PlayShiftsPitchByRpcCurveEvaluatedAtCurrentVariableValue already proves exact known values (-600/0/+600 cents -> -0.5/0.0/+0.5 pitch).
  • AUD-10-006 [P0] Verify sound, track, wave, variation, RPC, and category pitch contributions combine exactly once. Acceptance: Trace and golden tone prove composition order. Evidence: every pitch contributor (base basePitchCents_, RPC rpcPitchCents, effect-variation effectPitchCentsDelta) is summed in cents BEFORE a single CentsToPitch() conversion call (see Cue.cpp lines ~482-483, ~550-673) -- matches the code's own established comment (P9-XACT-007: "result can be summed in cents before converting once, matching FAudio's..."). No call site converts a partial sum to pitch/ratio and then adds more cents afterward. RepeatedReconcileStateTicksWithConstantVariableDoNotDriftPitch (new, AUD-10-013) additionally proves the composed result doesn't drift across repeated re-evaluation ticks.
  • AUD-10-007 [P0] Verify random pitch/volume variation bounds and distribution. Acceptance: Seeded tests prove endpoints, inclusivity, and no bias from wrong integer math.
  • AUD-10-008 [P0] Provide deterministic RNG injection for tests. Acceptance: Cue selection is reproducible without changing production randomness.
  • AUD-10-009 [P0] Verify RPC curve interpolation and boundary handling. Acceptance: Linear/fast/slow/sin/cos or supported curve types match reference samples.
  • AUD-10-010 [P0] Verify RPC variable units and update cadence. Acceptance: Global/cue variables drive parameters at correct times.
  • AUD-10-011 [P0] Verify cue event timestamps, offsets, and relative/absolute timing. Acceptance: No frame-rate-dependent event drift.
  • AUD-10-012 [P0] Verify wave event loop counts and infinite-loop semantics. Acceptance: Counts match XACT and stop cleanly.
  • AUD-10-013 [P0] Verify pitch does not get reapplied on repeated Update calls. Acceptance: Stable parameter values cannot accumulate ratio exponentially. Evidence: confirmed by reading Cue.cpp that basePitchCents_ is assigned exactly once per Play() (plain =, never +=) and every ReconcileState() tick recomputes the full cents sum fresh from that constant base + a freshly re-evaluated RPC curve value -- no code path applies an incremental delta on top of the previous tick's already-converted result. New test RepeatedReconcileStateTicksWithConstantVariableDoNotDriftPitch (50 ReconcileState() ticks via repeated getIsPlayingProperty() calls with the bound variable held constant) empirically confirms bit-for-bit-identical pitch across every tick.
  • AUD-10-014 [P0] Verify pause/resume freezes event time and playback consistently. Acceptance: No skipped/replayed delayed event.
  • AUD-10-015 [P0] Verify immediate versus release stop behavior. Acceptance: Fade/release semantics match baseline where supported.
  • AUD-10-016 [P0] Verify category volume and hierarchy composition. Acceptance: Parent/child gain is applied exactly once.
  • AUD-10-017 [P0] Verify category pause/resume/stop affects current and future cues correctly. Acceptance: State matches XACT reference.
  • AUD-10-018 [P0] Verify instance limiting: fail, queue, replace-oldest, replace-quietest. Acceptance: Each policy has a real behavioral test, not a shared placeholder.
  • AUD-10-019 [P0] Resolve unfinished REPLACE_QUIETEST behavior. Acceptance: Quietest selection is measured from effective audible gain and matches baseline.
  • AUD-10-020 [P0] Resolve QUEUE versus REPLACE_OLDEST semantics. Acceptance: Queued cues start at the correct time/order.
  • AUD-10-021 [P0] Verify wave variation selection modes. Acceptance: Ordered, ordered-from-random, random, and no-repeat modes match reference.
  • AUD-10-022 [P0] Verify variation weights and malformed ranges. Acceptance: Selection probabilities and validation are correct.
  • AUD-10-023 [P0] Verify track event ordering for equal timestamps. Acceptance: Stable order matches file/reference semantics.
  • AUD-10-024 [P0] Verify marker/callback events if present in supported scope. Acceptance: Callbacks occur once on documented thread.
  • AUD-10-025 [P1] Verify XGS category/variable/name table parsing across versions. Acceptance: No version-specific offset assumptions.
  • AUD-10-026 [P1] Verify XSB simple and complex cues across versions. Acceptance: Cue resolution matches reference corpus.
  • AUD-10-027 [P1] Verify transition tables if present in XNA 4.0 content. Acceptance: Implemented behavior or explicit unsupported error has a fixture.
  • AUD-10-028 [P1] Verify filter type, cutoff, and Q parameter mapping. Acceptance: Measured frequency response matches intended XACT values.
  • AUD-10-029 [P1] Implement or explicitly scope reverb-send events. Acceptance: No silent no-op without capability diagnostic.
  • AUD-10-030 [P1] Implement or explicitly scope DSP preset events. Acceptance: Behavior is compatibility-registered.
  • AUD-10-031 [P1] Test cue disposal while waves are active. Acceptance: No dangling callbacks or wave-bank references.
  • AUD-10-032 [P1] Test wave-bank disposal while cues reference it. Acceptance: Reference-compatible stop/error with no UAF.
  • AUD-10-033 [P1] Test sound-bank/audio-engine disposal ordering. Acceptance: All registrations and callbacks are cleaned once.
  • AUD-10-034 [P1] Test hot global-variable updates under active cues. Acceptance: No lock inversion or parameter discontinuity.
  • AUD-10-035 [P1] Fuzz XGS/XSB parsers with corpus-guided mutation. Acceptance: No OOB, leak, hang, or unbounded allocation.
  • AUD-10-036 [P1] Add parser size/count/offset limits. Acceptance: Adversarial files cannot cause integer overflow or huge allocation.
  • AUD-10-037 [P1] Add exact asset/cue/wave context to parser errors. Acceptance: Failure identifies file segment and offset.
  • AUD-10-038 [P1] Benchmark hundreds of simultaneous cues and frequent Update. Acceptance: CPU/allocation budgets are explicit.
  • AUD-10-039 [P2] Document unsupported Xbox-specific XACT codecs/features. Acceptance: Applications receive actionable compatibility diagnostics.

AUD-11 — WaveBank/XWB formats, streaming, and extraction

Ensure every parsed wave has correct boundaries, codec metadata, and explicit playback support.

  • AUD-11-001 [P0] Confirm compact XWB final-entry length-deviation semantics against FAudio/FACT. Acceptance: Authoritative fixture proves whether final deviation must be subtracted. Confirmed NOT a defect -- and surfaced a more interesting finding en route: read real FAudio source directly (FACT_internal.c's compact-entry parsing loop, ~line 3106-3124, from the locally available FAudio checkout, a currently actively-maintained repo with commits through 2026-05) -- the last entry's PlayRegion.dwLength is computed as ENTRYWAVEDATA segment length - offset, with no deviation subtraction, exactly matching CNA's existing else branch. The audit's suspicion (from CNA's own comment text, which claimed the last entry should also subtract deviation) pointed at a real inconsistency, but the wrong side of it: the comment was wrong/aspirational, not the code. Along the way, found that FAudio's own non-last-entry computation in that same function reads as a genuine, long-standing bug (git-blamed to at least a 2018-12-18 commit, unchanged since): it computes thisEntry'sOwnOffset - thisEntry'sOwnOffset, always zero, which would make every non-last compact-bank entry silent if actually hit -- CNA's own non-last-entry computation (subtracting deviation from the gap to the next entry's offset) is a deliberate, sensible divergence from that apparent FAudio bug, not an oversight (same precedent as P11-XACT-004's lottery-bias fix: don't blindly replicate a confirmed reference-implementation defect). Fixed the misleading comments in XactParser.cpp to state this precisely instead of the stale, incorrect claim.
  • AUD-11-002 [P0] Fix compact final-entry length if confirmed. Acceptance: Nonzero final deviation test decodes exact payload without padding. Not confirmed as a defect (see AUD-11-001) -- no code fix needed or made. New regression test CompactWaveBankLastEntryLengthIgnoresItsOwnDeviation (new fixture BuildCompactXwbFixtureWithNonzeroLastEntryDeviation, a single/last entry with a nonzero deviation field that would change the computed length if it were incorrectly subtracted) locks down the already-correct behavior -- no prior test exercised a nonzero deviation specifically on the last entry (the pre-existing CompactWaveBankComputesLengthsFromConsecutiveOffsets fixture's last entry happened to have deviation=0, unable to distinguish the two interpretations). CompactWaveBankComputesLengthsFromConsecutiveOffsets (pre-existing) already proves non-last entries get correct, non-zero lengths, confirming CNA does not replicate FAudio's own apparent non-last-entry bug either. Full whole-repo suite 4692/4694 pass (2 pre-existing hardware skips).
  • AUD-11-003 [P0] Validate compact alignment is nonzero and multiplication cannot overflow. Acceptance: Corrupt files fail before offset arithmetic. Confirmed real defect and fixed: alignment (an unvalidated uint32_t read straight from the file) was used in a raw 32-bit rawOffsetUnits[i] * alignment multiplication -- zero alignment would silently collapse every compact entry's offset to 0 (all entries claiming the same start, not a crash but genuinely wrong data), and a large-enough alignment could overflow 32-bit arithmetic and silently wrap to a wrong-but-plausible offset. Both are real "distorted/missing audio" symptom classes, not just theoretical hardening. Fixed: alignment == 0 now throws immediately; the offset multiplication is now computed in 64-bit and validated to fit in 32 bits before narrowing (matching this function's existing D7 "throw rather than silently produce wrong data" policy for the neighboring deviation/offset checks). New tests CompactWaveBankThrowsOnZeroAlignment/CompactWaveBankThrowsWhenOffsetMultiplicationOverflows32Bits (the overflow fixture deliberately chosen so the wrapped 32-bit result is exactly 0 -- a small, plausible-looking offset that would NOT trip any pre-existing bounds check on its own, unlike a wrap that happens to still land on an out-of-range value); both confirmed to fail against the pre-fix code via git stash. Full whole-repo suite 4702/4704 pass (2 pre-existing hardware skips).
  • AUD-11-004 [P0] Validate every entry range lies fully inside wave-data segment/file. Acceptance: No OOB pointer or oversized decoder input. Investigated -- confirmed already safe end-to-end, gap in test coverage closed: the parse-time stage (XactParser.cpp) validates COMPACT entries extensively (AUD-11-003's overflow/deviation/segment checks) but the NORMAL (non-compact) branch takes playOffset/playLength from the file with no range check against segLength[4] at parse time -- however, the actual EXTRACTION stage (WaveBank.cpp::GetSoundEffect()) already bounds-checks entry.dataOffset + entry.dataLength against the REAL resident data before any byte is ever read, for both the streaming path (against the real on-disk file size, pre-existing IN-9 fix) and the non-streaming path (against the fully-resident fileData buffer's real size, same IN-9 fix) -- both checks widen to 64-bit first so a corrupt/adversarial entry can't wrap the check via uint32_t overflow. This is arguably more robust than a parse-time check alone, since it validates against the TRUE resident buffer size rather than another self-reported length field. The streaming path already had a regression test (StreamingGetSoundEffectRejectsEntryLengthExceedingRealFileSize); the non-streaming path's equivalent check had none -- new test NonStreamingGetSoundEffectRejectsEntryLengthExceedingRealFileSize closes that gap, reusing the exact same corrupt fixture (entry claims 1,000,000 bytes against a ~184-byte real file) via the non-streaming constructor instead, confirming GetSoundEffect() returns nullptr rather than reading out of bounds. No production code change. Full whole-repo suite: 4746 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-005 [P0] Validate compact/noncompact metadata sizes and segment overlap. Acceptance: Malformed tables cannot desynchronize parsing. Confirmed a real gap and hardened, plus one theoretical defense-in-depth fix: the non-compact entryMetaDataSize path (XactParser.cpp's ctx.skip(entryMetaDataSize - static_cast<uint32_t>(ctx.cur - entryPtr))) is already safe by construction -- its graduated if (entryMetaDataSize >= N) ... ctx.u32() reads structurally guarantee bytes-consumed never exceeds entryMetaDataSize, for any value including one smaller than 4. The compact path had no equivalent guard: it unconditionally reads a 4-byte ce = ctx.u32() per entry, then calls ctx.skip(entryMetaDataSize - 4) (both uint32_t) with zero validation that entryMetaDataSize >= 4 first -- a corrupt/adversarial value smaller than 4 underflows that subtraction to a huge value. Empirically investigated whether this is exploitable, not assumed: built a standalone repro (compiled directly against XactParser.cpp, bypassing the full test-suite build) under Clang's ASan+UBSan with -fno-sanitize-recover=undefined (abort on any UB) -- neither sanitizer flagged anything; the pre-existing Ctx::skip() bounds check already throws a generic "skip past end" cleanly before any out-of-bounds byte is ever read, and the huge-but-still-address-space-resident pointer value formed en route is never dereferenced, only compared, so ASan has nothing to catch and Clang's -fsanitize=pointer-overflow only fires on genuine machine-representation wraparound (crossing address 0 or the pointer's max value), not this "in-bounds-looking-but-technically-not" class of UB. Confirmed real (not exploitable, but real) UB per the standard regardless (forming a pointer more than one-past-the-end of an array has undefined behavior independent of whether the resulting bit pattern happens to look valid) -- fixed defensively, matching seek()'s own established AUDIO-PARSER-001 precedent for the identical concern: Ctx::skip() now validates n > remaining in the integer domain before ever forming cur + n. Also added an explicit if (entryMetaDataSize < 4) throw at the compact-branch's own point of use, giving a specific, named diagnostic ("entryMetaDataSize too small") instead of relying on skip()'s generic catch-all -- this is the more directly valuable fix for "malformed tables cannot desynchronize parsing," verified via git-stash (new test CompactWaveBankThrowsWhenEntryMetaDataSizeTooSmall checks for the specific message text, not just any exception, so it genuinely fails against the pre-fix code, which already threw a generic message). Full whole-repo suite: 4762 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-006 [P0] Golden-test PCM8 wave-bank entries. Acceptance: Unsigned conversion, duration, and loops are correct. Evidence: new GetSoundEffectForPcm8EntryHasExactFrameCount verifies the exact decoded frame count (200 bytes, 8-bit mono -> 200 frames @ 44100 Hz) via WaveBankTestAccess::GetSoundEffect + getDurationProperty(), matching AUDIO-ADPCM-001/AUD-11-013's established pattern -- this is what would catch a wrong dataOffset/dataLength/sampleRate extracted from the compact XWB entry format, the actual CNA-specific risk. Unsigned-to-signed conversion itself is out of CNA's own scope to re-verify here: WaveBank::GetSoundEffect()'s 8-bit-PCM branch wraps the raw bytes in a real WAV file and decodes via SoundEffect::FromStream -> SDL3's own real WAV decoder (not custom CNA conversion logic) -- the identical WAV-wrapping technique already covered by AUD-06's Pcm8BitLoadsSuccessfully et al. Loop application is a separate, deliberately out-of-scope gap for this task: investigated while working on this and confirmed compact/non-compact WaveBank entries' parsed loopStartSample/loopTotalSamples fields (XactTypes.hpp) are never actually applied anywhere downstream (not wired into the constructed SoundEffect's loop points) -- this is AUD-11-014's exact scope ("Verify loop regions use sample frames and codec-aware mapping"), not re-solved here to avoid scope creep. Full whole-repo suite: 4764 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-007 [P0] Golden-test PCM16 wave-bank entries. Acceptance: Sample output and metadata are exact. Evidence: new GetSoundEffectForPcm16EntryHasExactFrameCount verifies the exact decoded frame count (200 bytes, 16-bit mono -> 100 frames @ 44100 Hz) via the same direct-access pattern as AUD-11-006. Sample output correctness (byte-for-byte the raw 16-bit PCM samples, not a decoded reinterpretation) is inherent to this path by construction: WaveBank::GetSoundEffect()'s 16-bit branch constructs a SoundEffect directly from the raw uint8_t bytes via the raw-buffer constructor (std::vector<uint8_t> samples(audioData, audioData + audioLen); cached.emplace(samples, sampleRate, channels)) -- no WAV-wrapping, no decode step, no reinterpretation of any kind, so the samples reaching SDL3_mixer are byte-identical to the source .xwb file's own bytes; the frame-count check above is what proves the correct byte RANGE was selected. Loop-region application is AUD-11-014's scope, same note as AUD-11-006. Full whole-repo suite: 4764 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-008 [P0] Golden-test MS ADPCM wave-bank entries. Acceptance: Decode, block alignment, tail samples, duration, and loops pass. Confirmed real P0 defect found and fixed (AUDIO-ADPCM-001): WaveBank.cpp's BuildAdpcmWav() wrapped raw MS-ADPCM bytes in a synthetic WAV with a cbSize=2 fmt-chunk extension -- just wSamplesPerBlock, no coefficient table at all. Empirically confirmed via a standalone probe (SDL_LoadWAV_IO against the exact byte layout the old code produced) that SDL3's real MS-ADPCM decoder rejects this outright: "Could not read MS ADPCM format header"/"Missing required coefficients in MS ADPCM format header". This means every MS-ADPCM-compressed XACT WaveBank entry silently failed to load (WaveBank::GetSoundEffect caught the resulting exception and returned nullptr) -- a direct, high-value match for the audit's reported "missing audio" symptom class, since MS-ADPCM is XACT's standard compression codec for size-conscious games. Fixed by adding the standard 7-pair MS-ADPCM coefficient table (CNA::Internal::Audio::BuildStandardMsAdpcmExtension, new shared WavWrapper.hpp/.cpp) -- these are the fixed, industry-standard coefficients every MS-ADPCM encoder (including XACT's) uses and SDL3 validates against exactly, not something derived per-file. Independently verified XactParser.cpp's own samplesPerBlock/blockAlign derivation formula for compact XACT entries already satisfies SDL's block-size/samples-per-block consistency constraint (traced the exact arithmetic through MS_ADPCM_CalculateSampleFrames's validation in SDL_wave.c) -- the coefficient table was the only missing piece. New test WaveBankTest.GetSoundEffectForAdpcmEntrySucceeds asserts GetSoundEffect() is non-null directly (not just inferred via IsInUseProperty after Play(), which conflates "no audio device" with "decode failed"); confirmed to FAIL against the pre-fix code via git stash with the exact same SDL error message reproduced. Full WaveBankTest suite 24/24 pass (was 23/23 + 1 new).
  • AUD-11-009 [P0] Determine IMA ADPCM XWB support requirements. Acceptance: Real fixtures establish required decode path. Determined: not applicable -- IMA-ADPCM is not a distinct WaveBank mini-format at all. Confirmed against real FACT source (FACT.h's FACT_WAVEBANKMINIFORMAT_TAG_* constants): the WaveBank entry format field is a 2-bit tag with exactly 4 values (PCM/XMA/ADPCM/WMA) -- ADPCM always means MS-ADPCM at this level. IMA-ADPCM only matters for the separate XNB SoundEffectReader container format (already supported, AUD-06-007). Documented in XactTypes.hpp's XwbFormat doc comment and CHECKLIST.md.
  • AUD-11-010 [P0] Implement or explicitly handle XMA/XMA2 wave-bank entries. Acceptance: No parsed entry disappears as unexplained null. Decision: explicitly rejected, diagnostic quality improved -- XMA/XMA2 is a proprietary Xbox-oriented codec with no decode path anywhere in this stack (SDL3 doesn't decode it either); implementing real decode is out of scope (would need an external XMA decoder library CNA doesn't have). WaveBank::GetSoundEffect's rejection diagnostic now names the bank and a human-readable format instead of a raw enum integer, so a "missing sound" symptom is traceable to this exact cause from the log alone. New test GetSoundEffectForXmaEntryReturnsNullCleanly (runs unconditionally, no audio device needed -- rejected before any backend call). CHECKLIST.md row added.
  • AUD-11-011 [P0] Implement or explicitly handle WMA entries. Acceptance: Behavior is platform/profile documented and diagnostic. Decision: same as AUD-11-010 -- WMA is also a proprietary codec with no decode path anywhere in this stack. Same improved diagnostic covers both formats (formatName branches on XwbFormat::XMA/XwbFormat::WMA); same CHECKLIST.md row.
  • AUD-11-012 [P0] Validate mini-wave-format bit extraction for every format. Acceptance: Channels/rate/block/bits match reference parser. Confirmed correct against real FAudio source and empirically locked down: FACTWaveBankMiniWaveFormat's real bitfield layout (FAudio/include/FACT.h: wFormatTag:2, nChannels:3, nSamplesPerSec:18, wBlockAlign:8, wBitsPerSample:1, packed LSB-first) matches CNA's manual bit-shift extraction (XactParser.cpp) field-for-field and bit-for-bit. A pre-existing test only checked channels in isolation (values common enough that a field-swap bug could still coincidentally pass); new test NonCompactWaveBankMiniWaveFormatBitExtractionMatchesEveryFieldExactly uses distinctive, mutually-non-collidable values for every field (channels=5, sampleRate=12345, wBlockAlign_raw=77, bits=16) via ADPCM specifically (so the raw wBlockAlign field's own extraction is verifiable through the derived blockAlign/samplesPerBlock formulas -- for PCM that raw field is read but overridden by a channels-only formula, so it wouldn't exercise this field at all) and asserts all four fields plus the two ADPCM-derived values simultaneously. No production code change. Full whole-repo suite: 4748 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-013 [P0] Verify samples-per-block formulas for ADPCM compact and normal entries. Acceptance: Decoded frame count matches reference. Evidence (compact entries): new test GetSoundEffectForAdpcmEntryDecodesExactFrameCountFromSamplesPerBlockFormula (WaveBankTests.cpp) computes the expected frame count from the samplesPerBlock formula ((wBlockAlign_raw + 16) * 2, AUD-11-012) against the real BuildAdpcmXwbFixtureBytes fixture (blockAlign=30, blockCount=4, wBlockAlign_raw=8 -> samplesPerBlock=48, expected 192 total decoded frames, no partial trailing block) and asserts the REAL SoundEffect produced by decoding through SDL3's own MS-ADPCM decoder (via the shared WavWrapper, AUDIO-ADPCM-001) reports the exact matching duration (192/22050 seconds, 1e-6 tolerance) -- the pre-existing GetSoundEffectForAdpcmEntrySucceeds test only asserted a non-zero duration, not that the formula's predicted frame count matches what SDL3 actually decodes. Normal (non-compact) ADPCM entries share the identical formula (same (wBlockAlign+16)*2/(wBlockAlign+22)*channels computation, XactParser.cpp's non-compact branch) and the same downstream WavWrapper/SDL3 decode path as the compact case just verified -- no separate normal-entry fixture needed to prove the same arithmetic decodes correctly a second time. No production code change. Full whole-repo suite: 4749 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-014 [P0] Verify loop regions use sample frames and codec-aware mapping. Acceptance: Loop playback matches XACT reference. Confirmed a real, significant defect and fixed: WaveBank::GetSoundEffect() parsed each entry's loopStartSample/loopTotalSamples (FACTWaveBankEntry.LoopRegion) but never applied them to the constructed SoundEffect at all -- every WaveBank-sourced looping cue looped the entire track from start to end, regardless of an authored intro-then-loop region. Verified against real FAudio source (FACT.c, ~line 1880-1891): FAudio applies entry->LoopRegion.dwStartSample/dwTotalSamples to the playback buffer's LoopBegin/LoopLength whenever the cue's PlayWaveEvent requests looping (nLoopCount != 0) -- for streaming banks and non-PCM/non-MS-ADPCM formats XACT doesn't support loop subregions at all (FAudio_assert(entry->LoopRegion.dwStartSample == 0)), so this fix is correctly scoped to the non-streaming PCM/ADPCM entries CNA already routes through a cached SoundEffect. Fixed by baking the entry's loop region into the cached SoundEffect at construction time (16-bit PCM: switched from the 3-arg raw-buffer constructor to the 7-arg loopStart/loopLength-taking overload; 8-bit PCM/MS-ADPCM: promoted the XNB reader's private AppendSmplChunkIfLooped() helper into the shared CNA::Internal::Audio::WavWrapper and called it before SoundEffect::FromStream -- same technique, now used by both callers instead of duplicated) -- this is safe regardless of whether any particular play actually loops, since SoundEffectInstance::Play() (CP-17/P10-LOOP-003/004) only ever applies loopStart_/loopLength_ while IsLooped_ is true, which Cue.cpp already sets per-instance from the PlayWaveEvent's own loopCount, independent of this change. Only non-compact XWB entries can carry a loop region at all (compact format's per-entry metadata is packed into a single 4-byte u32 -- offsetUnits:21/deviation:11 -- with no room for loop fields; confirmed by reading XactParser.cpp's compact-entry parsing, which never sets loopStartSample/loopTotalSamples, matching the format's own structural limit, not a CNA omission). New test GetSoundEffectForNonCompactEntryHonorsAuthoredLoopRegion (non-compact PCM16 fixture, 100 real frames, authored loop region [20, 70) -- neither the whole track nor starting at 0, so "loops the entire track" is distinguishable from correct behavior) verifies the exact authored values survive to the SoundEffectInstance via SoundEffectInstanceTestAccess, git-stash regression-verified (fails against the pre-fix code with LoopStart=0/LoopLength=0 instead of 20/50, passes restored). Full whole-repo suite: 4765 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-015 [P0] Verify streaming WaveBank offset/alignment reads. Acceptance: Partial reads and file seeking cannot shift audio data. Investigated -- confirmed already adequately covered by existing code + tests, no gap found: WaveBank::GetSoundEffect()'s streaming path (1) bounds-checks entry.dataOffset + entry.dataLength against the real on-disk file size (via tellg()) before allocating or seeking, rejecting a corrupt/adversarial length cleanly rather than attempting an oversized read (StreamingGetSoundEffectRejectsEntryLengthExceedingRealFileSize), and (2) checks sf.gcount() != audioLen after every read, catching any short/truncated read regardless of cause. Neither check has any alignment-dependent logic at all (plain seekg/read calls, no bitwise rounding or block-alignment assumptions anywhere in this path) -- an offset that happens to be byte-unaligned is handled identically to an aligned one by construction, so a dedicated unaligned-offset test would not exercise any code path the existing aligned-offset test (StreamingGetSoundEffectReadsCorrectPerEntryOffsetAndLength, which already proves entry 1's 300 bytes at offset 100 read correctly, not shifted into entry 0's region) doesn't already cover identically. No production code change, no new test needed.
  • AUD-11-016 [P0] Check all streaming file I/O failures. Acceptance: Cue reports the affected bank/wave and remains in truthful state. Confirmed a real gap and fixed: Cue::Play() silently continued on both a missing wave bank (AudioEngine::FindWaveBank() returns null) and a failed SoundEffect load (WaveBank::GetSoundEffect() returns null), with zero cue-level diagnostic identifying which cue was affected -- WaveBank::GetSoundEffect()'s own std::cerr messages only name the wave index (not even the bank name, in most of them), leaving no way to trace a missing/failed sound back to the cue that requested it. Both failure paths are genuinely reachable in practice, not theoretical: an authored .xsb referencing a wave bank that was never loaded/registered (a real content-authoring mistake), and a wave bank that loaded but a specific entry failed to decode (AUDIO-ADPCM-001's exact original symptom, before that fix). Added a diagnostic at each continue site naming the cue (getNameProperty()), the wave bank name, and the wave index. New test PlayWithUnresolvableWaveBankLogsCueNameAndDoesNotCrash (a minimal .xsb referencing a guaranteed-unique, never-registered wave bank name, so the test is immune to other tests in the same binary having already registered a same-named WaveBank as a persistent function-local static) verifies the logged message names both the cue and the wave bank, git-stash regression-verified (fails against the pre-fix code, which logs nothing at all). Full whole-repo suite: 4766 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-017 [P1] Test wave banks with and without entry names. Acceptance: Name lookup and index lookup agree. Investigated -- confirmed real XNA has no name-based lookup for this acceptance criterion to be about, but found and fixed a real, ASan-confirmed defect along the way: real FNA's WaveBank public API (WaveBank.cs) has no name-based lookup method at all -- every wave access is by numeric waveIndex, driven by the SoundBank/Cue's own XSB-authored fields; XwbData::entryNames is parsed but genuinely never consumed anywhere in CNA either (confirmed by grep), matching FNA's own behavior exactly (names are XACT-authoring-tool metadata, not a runtime API surface). "Name lookup and index lookup agree" therefore reduces to: does the presence/absence of names (hasNames flag, entryNames parsing) ever desync index-based entry parsing? Investigating this surfaced a real defect (see AUD-11-018). No production code change beyond that fix.
  • AUD-11-018 [P1] Test duplicate, empty, and unterminated names. Acceptance: Parser follows reference and remains safe. Confirmed a real, empirically-verified heap-buffer-overflow and fixed: three call sites in XactParser.cpp (WaveBank's bankName, WaveBank's per-entry entryNames[i], SoundBank's wavebankNames[i]) called strnlen(p, N) with either a fixed constant (64) or a fully unvalidated file-supplied entryNameElemSize, with no check that N real bytes actually remained in the buffer from p -- for a file truncated to end exactly at one of these fixed-width name fields, strnlen read past the end of fileData's real heap allocation before the bounds-checked ctx.skip() immediately after each site ever got a chance to catch the truncation. This is the exact "unterminated names" case the acceptance criterion names, and it was genuinely unsafe, not just theoretically: a standalone repro (compiled directly against XactParser.cpp with Clang's ASan+UBSan, -fno-sanitize-recover=undefined) against a .xwb truncated to end immediately after wbFlags/entryCount reproduced a real, unambiguous AddressSanitizer: heap-buffer-overflow report (READ of size 9 ... 0 bytes after 64-byte region) at XactParser.cpp:550 -- a materially stronger finding than AUD-11-005's theoretical-but-sanitizer-invisible pointer-arithmetic UB, since this is a genuine out-of-bounds dereference. Fixed at all three sites by capping the strnlen scan to min(intendedWidth, realRemainingBytes) first, mirroring cstr()'s own already-correct AUDIO-PARSER-001 pattern -- the ctx.skip()/wc.skip() call immediately after each site still throws cleanly on genuine truncation, just without the OOB read on the way there. Rebuilt the same standalone repro against the fixed code: no ASan/UBSan finding, clean "XACT parse: skip past end" exception instead. New test ParseXwbTruncatedExactlyAtBankNameFieldThrowsNotOob locks in the same fixture shape as a permanent regression check; documented explicitly that this specific gtest-level assertion does not discriminate pre/post-fix via git-stash (the pre-fix code already threw some exception too, just after the OOB read already happened) -- the real proof is the ASan repro, matching this session's precedent for AUD-11-004's identical caveat. Duplicate names are not independently a safety concern (each name is parsed and stored independently by index; nothing in the parser deduplicates or cross-references names), and empty names (entryNameElemSize>0 but the name's first byte is \0) already produce a correctly-empty std::string via the same strnlen-based logic, no special-casing needed. Full whole-repo suite: 4767 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-019 [P1] Test zero-length and tiny entries. Acceptance: No decoder crash or accidental remainder consumption. Evidence: new GetSoundEffectForZeroLengthEntryDoesNotCrash (a real, reachable case -- an authored-but-never-recorded placeholder wave, or a truncated bank, not just a synthetic edge case) confirms GetSoundEffect() handles a genuinely zero-byte (dwLength=0) non-compact entry cleanly -- either a zero-duration SoundEffect or nullptr, never a crash. No production code change; the "no accidental remainder consumption" half of this acceptance criterion is already covered by the 6000-mutation XactParserFuzzTests.cpp sweep (AUD-11-026), which incidentally exercises many zero/tiny-length combinations across its random mutations with zero findings.
  • AUD-11-020 [P1] Test padding between entries and at segment end. Acceptance: Padding never becomes audible payload. Evidence: new GetSoundEffectEntriesSeparatedByPaddingHaveExactLengthsNotLeakingTheGap -- two entries (50 and 70 bytes, deliberately different lengths to prove offset correctness, not just length correctness) separated by a real 30-byte padding gap in the wave-data segment. Confirms each entry's decoded duration is exactly what its own authored dwLength implies (25/35 frames), never inflated by the intervening gap and never accidentally starting from the gap instead of its own authored dwOffset. This is structurally guaranteed by construction (each entry's dataOffset/dataLength are explicit, authored per-entry fields -- nothing in WaveBank::GetSoundEffect() or XactParser.cpp ever infers a length from the gap to a neighboring entry, unlike the compact format's own length-from-next-offset computation, which is a different, already-covered code path with its own dedicated AUD-11-003 tests), and this test proves it empirically rather than just by inspection. "Padding at segment end" is the same guarantee from the other direction (the last entry's own dwLength is also explicit, not inferred from the segment's own end) -- already covered structurally by every existing non-compact-format test in this file. No production code change. Full audio-scoped suite: 660 passed / 0 failed.
  • AUD-11-021 [P1] Test old XWB versions and short metadata entries. Acceptance: Zero-init/partial-read semantics match FAudio. Found a real coverage gap and closed it -- short metadata entries were already covered, header-version handling was not. "Short metadata entries" (entryMetaDataSize < 24, zero-init for fields beyond the declared width, matching FAudio's own zero-init-then-partial-read FACTWaveBankEntry semantics) is already covered by the pre-existing NonCompactWaveBankWithNarrowEntryMetaDataDoesNotReadForeignBytes. "Old XWB versions" was genuinely uncovered: every single fixture in this test file (checked via grep across all of XactParserTests.cpp/WaveBankTests.cpp) uses version=1 (<=43, the older header layout with no headerVersion field) -- the newer header format (version>43, with an extra 4-byte headerVersion field between version and the segment table, per ReadXwbSegmentTable's own explicit branch) had zero test coverage anywhere, despite every segOffset in the file being authored relative to this header's own true size -- a real desync risk if the branch condition were ever wrong. New test NewerHeaderVersionWithExtraFieldParsesSegmentsAtCorrectOffset (version=44) confirms the segment table still lands at the correct offset. Confirmed real discriminating power, not a false positive: temporarily breaking the branch condition (version > 43 to version > 4300) made the test fail (tripping the AUD-11-026 entryCount guard from the resulting desync, a nice incidental cross-check that fix also catches corruption from this angle), then reverted. No production code change. Full whole-repo suite (audio-scoped subset, 659 tests): 659 passed / 0 failed -- the same run's whole-repo pass hit a real, pre-existing, out-of-scope crash in an unrelated ENetBackendTest (malloc(): unsorted double linked list corrupted), consistent with the already-documented Net Dispose() double-free (see the project memory file); confirmed via the isolated audio-scoped rerun that this is unrelated to any commit on this branch.
  • AUD-11-022 [P1] Test seek tables where applicable. Acceptance: Streaming/compressed seek and duration are correct. Investigated -- confirmed genuinely not applicable, not a gap: the task's own wording ("where applicable") anticipates this. Read real FAudio's FACT.c line-by-line for every use of pWaveBank->seekTables[...]: they are used in exactly two places, both gated on a specific compressed codec -- XMA2 decode (computing dwSamplesEncoded/wBlockCount from seek entries, FACT.c ~1751-1786) and WMA streaming (bufferWMA.pDecodedPacketCumulativeBytes/PacketCount, a byte-index seek table for streaming compressed WMA packets, ~1893-1898) -- confirmed via the source comment "The XMA2 seek table uses sample indices as opposed to WMA's byte index seek table." CNA has zero decode path for either XMA2 or WMA (AUD-11-010/011's already-documented CHECKLIST.md accepted deviation) -- since seek tables are structurally meaningless for every format CNA actually decodes (PCM/MS-ADPCM/IMA-ADPCM never use them in real FAudio either), there is no reachable "applicable" case on this branch to test. XactParser.cpp correctly never parses segment 2 (the seek-tables segment) at all. No production code change, no test needed.
  • AUD-11-023 [P1] Cache decoded/static waves with bounded memory policy. Acceptance: Repeated cues avoid unnecessary decode without unbounded cache. Investigated -- confirmed already satisfied by construction, no gap found: XactWaveBankImpl::cache (std::vector<std::optional<SoundEffect>>) is sized exactly once, at construction, to entries.size() -- it can never grow, and entries.size() is itself now bounded by AUD-11-026's entryCount guard (at most the real file's own byte size), so the cache's memory footprint is bounded by the same real-world constraint as the source file, not unbounded. "Repeated cues avoid unnecessary decode" is the pre-existing if (cached.has_value()) return &cached.value(); early return -- decode happens exactly once per entry, ever. No production code change beyond what AUD-11-024/026 already provide.
  • AUD-11-024 [P1] Make wave-cache concurrency safe. Acceptance: Simultaneous first use decodes once or safely duplicates. Confirmed a real gap and fixed: GetSoundEffect()'s cache lookup-then-populate sequence (if (cached.has_value()) return ...; ... cached.emplace(...)) had no synchronization at all -- two threads racing to first-use the same entry could both observe an empty cache slot and both attempt to decode+emplace() into the same std::optional<SoundEffect> concurrently, a genuine data race (UB), not just a performance/duplication concern. Fixed with a new XactWaveBankImpl::cacheMutex (std::mutex) guarding the whole lookup-then-populate sequence via std::lock_guard -- the simplest, safe interpretation of "decodes once or safely duplicates": every decode is now fully serialized, so it always decodes exactly once, never races or duplicates. New test GetSoundEffectFromManyThreadsSimultaneouslyDecodesOnceNotPerThread spawns 16 threads all requesting the same entry simultaneously and asserts every thread receives the identical cached pointer back, repeated 20x with zero flakes under the normal build. Verification note, stated honestly: a full ThreadSanitizer run (this project's own established stronger standard for concurrency fixes, matching the ConcurrentFilterUpdatesDoNotRaceWithRealMixingThread precedent) was attempted but blocked mid-session by an unrelated, transient build failure in a sibling dependency (meta-gl, a separate repo shared across this machine's cna* project family) that was being actively edited by another concurrent process on this shared machine at the time (git status in that sibling repo showed a growing, in-progress uncommitted diff renaming/restructuring metagl::ClearBuffer) -- not caused by, or related to, anything on this branch. The fix itself is a textbook mutex-guarded critical section (not a subtle lock-free scheme needing exotic verification), and the full whole-repo suite (4771/4771) and 20x-repeated new test both passed cleanly before the external breakage occurred. If TSAN verification is wanted later, retry once the sibling meta-gl checkout is stable.
  • AUD-11-025 [P1] Test disposal while cache/decode is in progress. Acceptance: No race or leaked decoder data. Confirmed a real, empirically-reproduced use-after-free and fixed, superseding the initial investigation. WaveBank::Dispose() called xactImpl_.reset() unconditionally and unsynchronized with GetSoundEffect() -- reproduced as a genuine crash via a Clang ASan build (not just reasoned about): a test hammering GetSoundEffect() from 8 threads while concurrently calling Dispose() on the main thread produced a real AddressSanitizer: heap-use-after-free (std::__shared_ptr<SoundEffect::Impl>::get() reading from a SoundEffect inside the already-destroyed XactWaveBankImpl/cache). On reflection, the fix needed is simpler than initially assessed (an earlier note in this same entry proposed an AUD-04-008/009-style generation-counter, since that pattern solves "a foreign consumer holds a raw pointer that might outlive a singleton's internal state" -- a genuinely harder problem than this one): here, Dispose() and GetSoundEffect() are both methods on the same WaveBank instance, so a single mutex serializing both against each other is sufficient and correct. Fixed by moving the lock from AUD-11-024's XactWaveBankImpl::cacheMutex (which only serialized GetSoundEffect() calls against each other, not against the containing object's own destruction) up to a new WaveBank::xactImplMutex_, taken by GetSoundEffect() before its very first xactImpl_ access and by Dispose() before xactImpl_.reset() -- the old cacheMutex is now redundant and removed. New test GetSoundEffectConcurrentWithDisposeNeverCrashes (20 iterations x 8 racing threads) reproduced the real ASan UAF against the pre-fix code, and passed cleanly (the only remaining ASan/LeakSanitizer noise traced to SDL3's own internal per-thread error-buffer allocations, SDL_SetErrorV/SDL_GetErrBuf, not CNA's own code) against the fix. Full whole-repo suite (normal build): 4773 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-026 [P1] Fuzz XWB parser and WAV-wrapper builders. Acceptance: No malformed header, overflow, OOB, leak, or hang. Confirmed a real, empirically-verified allocation-bomb gap and fixed, then swept for anything further: while preparing the fuzz harness, found entryCount (the WaveBank per-file entry count) is a full uint32_t fed straight into result.entries.resize(entryCount) with zero validation -- unlike every other count field this parser reads (categoryCount/variableCount/rpcCount/pointCount/wavebankCount/soundCount/totalCues), all naturally bounded by their own 8/16-bit field width. Confirmed empirically (not just by inspection) via git-stash: a fixture with entryCount=5,000,000 against a real ~144-byte file made the pre-fix parser actually attempt the resize (completing in ~140ms since 5M is a moderate, safe-to-actually-run magnitude, then failing later for an unrelated reason once it ran out of real entry bytes) -- proving entryCount truly reached the resize unguarded. A genuinely adversarial value (near UINT32_MAX) could reserve hundreds of gigabytes via Linux's virtual-memory overcommit and then hang for a long time (or trigger the OOM killer) default-constructing billions of entries -- a real DoS-class defect, not just a clean quick std::bad_alloc. Fixed by validating entryCount against the file's own total size immediately after reading it (the cheapest possible sound bound: a legitimate entry needs at least 1 real byte to exist), matching this plan's established D7 policy and XnbContainerFuzzTests.cpp's own explicit standard that an unguarded allocation attempt is "a guard gap, not an acceptable outcome." New test ParseXwbRejectsImplausiblyLargeEntryCountWithoutAllocationAttempt checks both the specific diagnostic message and that rejection is fast (<500ms), git-stash regression-verified with the same safe 5M magnitude (confirmed to fail pre-fix, both on message content and by actually observing the ~140ms unguarded resize attempt). Then swept the whole parser with a proper deterministic fuzz harness (new XactParserFuzzTests.cpp, mirroring LzxDecoderFuzzTests.cpp's established LCG-mutation pattern -- 3 hand-built well-formed seeds covering compact XWB, non-compact XWB with real loop/name fields, and a minimal XSB, since valid XACT bytes can be hand-authored unlike LZX streams; 2000 mutations per seed, 6000 total, asserting every input either completes or fails with exactly std::runtime_error -- the only exception type this parser is designed to ever throw -- within a 2-second-per-iteration ceiling). Ran under both a normal build (6000/6000 resolved cleanly) and a fresh Clang ASan+UBSan build (6000/6000, zero sanitizer findings) -- confirming this session's other fixes (AUD-11-005's skip() hardening, AUD-11-017/018's strnlen fix, this task's entryCount guard) hold up under a broad mutation sweep, and no further defects were found. Also ran the full audio-scoped test subset (SoundEffect*/Dynamic*/.../XactParser*, per NEXTaudio.md §7's established filter) under the same ASan+UBSan build: 649/649 pass, zero sanitizer findings in any CNA-owned code (the only LeakSanitizer noise traced to pre-existing libbz2/driver-internal frames, matching this session's own established precedent from AUD-15-001's prior sweep). WAV-wrapper builders (BuildWavFromWaveFormatEx/BuildStandardMsAdpcmExtension/AppendSmplChunkIfLooped) are exercised indirectly by every WaveBank/XNB-reader test in this session (their output is always immediately handed to SDL3's own real WAV decoder, which would reject a malformed chunk outright) -- see AUD-11-027 for a more direct, field-by-field validation of their own output, a distinct, narrower task not re-done here. A genuine, real, out-of-scope finding surfaced during the full whole-repo (not audio-scoped) ASan run: a use-after-free in Microsoft::Xna::Framework::Net::NetworkSession::Dispose() (double-dispose calling std::vector::clear() on an already-destroyed LocalNetworkGamer a second time) -- confirmed via full ASan stack trace, entirely within the Net module, not touched by any commit on this branch; matches this session's own prior precedent of flagging (not fixing) out-of-scope Net defects (NetworkSession::BeginCreate's known leak). Full whole-repo suite (non-sanitizer): 4768 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-027 [P1] Validate generated WAV wrapper fields independently. Acceptance: RIFF sizes, fmt chunks, fact chunks, block align, and data sizes are correct. Evidence: new tests/CNA/Internal/Audio/WavWrapperTests.cpp -- the first tests in this codebase to check BuildWavFromWaveFormatEx/BuildStandardMsAdpcmExtension/AppendSmplChunkIfLooped's own output bytes directly (every other test exercising this shared wrapper only checks it indirectly, via "did SDL3 successfully decode it," which proves decodability, not that every individual field is exactly correct). 6 tests: exact 44-byte header for a plain-PCM/no-extension/no-fact file with every fmt field checked byte-for-byte; extension data copied verbatim with the correct cbSize; the fact chunk present only when factSampleFrames != 0 (absent entirely, not zero-filled, otherwise) with correct dwSampleLength; RIFF size correctly accounts for every chunk including extension and fact bytes, cross-checked against the real total file size; AppendSmplChunkIfLooped's loop Start/End encoding (End = Start + Length, PlayCount = 0 for XNA's infinite-loop semantics); and its no-op behavior for zero/negative length. All 6 pass on the first attempt. Full whole-repo suite: 4780 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-11-028 [P2] Add an XWB inspection/extraction tool. Acceptance: Tool lists metadata and can export decoded waves for diagnosis. Evidence: new standalone executable cna_xwb_inspect (tools/audio/xwb_inspect.cpp, registered in cmake/Harnesses.cmake) parses a .xwb via the real, unmodified CNA::Internal::Audio::ParseXwb() and emits every entry's metadata (name, format, channels, sample rate, bits, block align, samples/block, data offset/length, loop start/total) as stable JSON, optionally also exporting each PCM/MS-ADPCM entry's real audio payload as a playable .wav file (via the same shared WavWrapper technique WaveBank.cpp itself uses to reach SDL3's native decoder) for offline diagnosis -- never plays anything, needs no AudioEngine/WaveBank/Cue instance at all. Verified end-to-end against a real, hand-built compact .xwb fixture: JSON output matched every field exactly (dataOffset=148 correctly computed from header+bankdata+entrymeta sizes), and the exported entry0.wav was independently verified (via a Python script parsing the WAV byte-for-byte) to be a genuinely valid 64-byte WAV file (44-byte header + the exact original 20-byte payload, unmodified). XMA/WMA entries are correctly skipped from export (no decode path anywhere in this stack, CHECKLIST.md accepted deviation) while still being listed in the metadata JSON. This closes the entire AUD-11 WaveBank/XACT-parser section (all 28 tasks now [x]). Full audio-scoped suite: 660 passed / 0 failed.

AUD-12 — Song, MediaPlayer, codecs, position, and Media API

Make music playback truthful, rate-correct, and compatible while closing explicit Media stubs.

  • AUD-12-001 [P0] Make MediaPlayer load failure observable and state-truthful. Acceptance: State stays Stopped and error identifies song/path/decoder.
  • AUD-12-002 [P0] Check track creation, input assignment, and play results. Acceptance: No failed operation starts timer or emits Playing state.
  • AUD-12-003 [P0] Drive song completion from actual track/decoder state. Acceptance: MediaStateChanged and ActiveSong changes cannot race wall-clock guesses.
  • AUD-12-004 [P0] Validate Song duration against decoder metadata. Acceptance: Duration is known before/after Play according to reference semantics.
  • AUD-12-005 [P0] Test playback rate/pitch remains neutral for 44.1 and 48 kHz music. Acceptance: Dominant frequency and duration pass conversion gates.
  • AUD-12-006 [P0] Test Play/Pause/Resume/Stop transitions with real decoder output. Acceptance: Position and state match audible playback.
  • AUD-12-007 [P0] Test repeated Play with same and different Song. Acceptance: Old track/timer/callback cannot leak into new song.
  • AUD-12-008 [P0] Test IsRepeating loop boundary. Acceptance: No gap, duplicate callback, or position drift beyond tolerance.
  • AUD-12-009 [P0] Test IsShuffled and queue semantics. Acceptance: Selection and ActiveSong match reference.
  • AUD-12-010 [P0] Test MoveNext/MovePrevious edge cases. Acceptance: Queue index and events are correct.
  • AUD-12-011 [P1] Replace wall-clock-only position with backend/decoded-frame position where possible. Acceptance: Position remains correct under pause, device stall, seek, and resampling.
  • AUD-12-012 [P1] Test volume, mute, and game-has-control semantics. Acceptance: Gain is applied once and state follows reference platform behavior.
  • AUD-12-013 [P1] Test supported media containers/codecs by platform. Acceptance: Support matrix has golden decode/duration tests.
  • AUD-12-014 [P1] Test Unicode, spaces, long paths, and case sensitivity. Acceptance: Song resolution is portable and diagnostic.
  • AUD-12-015 [P1] Test malformed/truncated media. Acceptance: Failure is bounded and leaves no active track.
  • AUD-12-016 [P1] Implement or document GetVisualizationData. Acceptance: No silent no-op if XNA-compatible data is required.
  • AUD-12-017 [P1] Define decoder thread and callback ownership. Acceptance: Stop/dispose cannot race decode callbacks.
  • AUD-12-018 [P1] Test application suspend/resume and device loss. Acceptance: Music state recovers or stops predictably.
  • AUD-12-019 [P2] Implement missing MediaLibrary catalog classes where platform-feasible. Acceptance: Album/Artist/Genre/Playlist/Picture APIs no longer throw generic not-implemented errors.
  • AUD-12-020 [P2] Provide platform-compatible fallback behavior for unavailable media catalogs. Acceptance: Exceptions/results match selected XNA platform contract.
  • AUD-12-021 [P2] Add gapless-playback capability assessment. Acceptance: Supported/unsupported status and measurable gap are documented.

AUD-13 — Microphone and capture correctness

Make microphone state, format, buffering, hotplug, and permission behavior reliable.

  • AUD-13-001 [P0] Set Microphone state to Started only after capture stream opens successfully. Acceptance: Backend failure leaves Stopped and raises/returns reference-compatible failure.
  • AUD-13-002 [P0] Distinguish no captured data from capture backend error. Acceptance: Diagnostics and API behavior are unambiguous.
  • AUD-13-003 [P0] Validate capture format, rate, channels, and sample width. Acceptance: Returned bytes match documented XNA microphone format.
  • AUD-13-004 [P0] Validate GetData buffer ranges and frame alignment. Acceptance: No partial sample/frame or out-of-bounds write.
  • AUD-13-005 [P0] Test Start/Stop/Start cycles. Acceptance: No stale data, leaked device, or duplicate callback.
  • AUD-13-006 [P0] Test device-open failure, unplug, and permission denial. Acceptance: State/events/errors remain truthful.
  • AUD-13-007 [P1] Implement device-list refresh/hotplug policy. Acceptance: Enumeration cache cannot remain permanently stale.
  • AUD-13-008 [P1] Test default microphone selection changes. Acceptance: Default property tracks platform behavior safely.
  • AUD-13-009 [P1] Verify BufferDuration validation and callback cadence. Acceptance: Intervals match reference bounds and measured captured frames.
  • AUD-13-010 [P1] Ensure BufferReady is dispatched on the documented thread. Acceptance: User callback cannot block real-time capture unexpectedly.
  • AUD-13-011 [P1] Test concurrent GetData and Stop/Dispose. Acceptance: No deadlock, UAF, or data race.
  • AUD-13-012 [P1] Bound capture buffering and define overflow policy. Acceptance: Slow consumers do not cause unbounded memory.
  • AUD-13-013 [P1] Test mono calibration recording for frequency, level, and duration. Acceptance: Capture path does not alter sample rate.
  • AUD-13-014 [P1] Test privacy/permission flows on desktop/mobile/web. Acceptance: Denied access is explicit and recoverable.
  • AUD-13-015 [P2] Add optional NOXNA capture-device diagnostics. Acceptance: Device capability details are available without changing XNA API.

AUD-14 — Content lookup, asset deployment, and build-pipeline integrity

Eliminate “missing audio” caused by path, case, packaging, or unsupported content products.

  • AUD-14-001 [P0] Generate an audio asset manifest at build time. Acceptance: Manifest lists logical name, deployed path, hash, format, rate, channels, and content kind.
  • AUD-14-002 [P0] Fail CI on case-only asset/reference mismatches. Acceptance: Linux and Windows resolve the same logical names.
  • AUD-14-003 [P0] Detect duplicate logical assets differing only by case. Acceptance: Ambiguous deployment cannot pass.
  • AUD-14-004 [P0] Verify extension candidate ordering cannot select the wrong file. Acceptance: Tests cover .xnb, .cnb, .wav, compressed media, and duplicate stems.
  • AUD-14-005 [P0] Verify ContentRoot normalization and traversal protection. Acceptance: Paths remain inside allowed roots and normalize portably.
  • AUD-14-006 [P0] Verify all game-referenced sounds are packaged. Acceptance: Static scan/runtime manifest reports zero missing production assets.
  • AUD-14-007 [P0] Verify XACT banks and loose sounds are copied by install/package rules. Acceptance: Clean packaged build contains every manifest entry.
  • AUD-14-008 [P0] Add a startup or tool-mode audio asset validation pass. Acceptance: Missing/unsupported assets are reported before gameplay.
  • AUD-14-009 [P1] Add content lookup trace with attempted candidates. Acceptance: A missing sound can be diagnosed from one log.
  • AUD-14-010 [P1] Test Unicode and non-ASCII asset names. Acceptance: Lookup and decoder opening are portable.
  • AUD-14-011 [P1] Test paths with spaces and long components. Acceptance: No truncation or shell/build-script issue.
  • AUD-14-012 [P1] Test read-only packaged assets and virtual filesystems. Acceptance: Loaders do not require writable filesystem paths.
  • AUD-14-013 [P1] Test archives/bundles on Android/Web where applicable. Acceptance: Content access path does not silently bypass audio assets.
  • AUD-14-014 [P1] Add format inspection to content build. Acceptance: Unsupported codecs are rejected or converted before shipping.
  • AUD-14-015 [P1] Record content-pipeline/tool versions in asset metadata. Acceptance: Differences between XNA and ported builds are attributable.
  • AUD-14-016 [P2] Add optional content conversion recipes for unsupported XMA/WMA. Acceptance: Conversion is deterministic, licensed, and preserves loops/duration.

AUD-15 — Thread safety, lifetime, memory, and performance

Make audio robust under stress and safe for long-running games.

  • AUD-15-001 [P0] Run all audio tests under ASan, UBSan, and LSan. Acceptance: Zero sanitizer findings in parsers, queues, callbacks, and teardown. Evidence (Phase 15 session-wide sweep, 2026-07-17): fresh one-off ASan+UBSan build, audio-scoped subset (NEXTaudio.md §7's filter list) 579/579 pass, zero ERROR: AddressSanitizer findings, zero UBSan runtime error: findings. LeakSanitizer flags ~15KB across 20 allocations in the FULL audio-scoped run, all traced to <unknown module>/libdrm.so.2/libubsan.so.1 frames (pre-existing graphics-driver/sanitizer-runtime init noise, not audio code) -- confirmed by re-running only this session's own new/changed tests (152 tests: OfflineAudioRendererTest/AUD05GoldenMatrix/AUD08GoldenPitchMatrix/the new WaveBankTest/SoundEffectContentTypeReaderTest/DynamicSoundEffectInstanceTest/Apply3D-Doppler/SoundEffectTest cases) in isolation, which reports zero leaks. Not yet run: a full whole-repo (non-audio-scoped) ASan sweep this pass, or a fresh TSan pass beyond the DynamicSoundEffectInstance-focused one already done for AUD-07-001/002 (this session's other changes -- WavWrapper, OfflineAudioRenderer, the Cue.cpp pitch-composition read-only investigation -- don't introduce new concurrent code paths).
  • AUD-15-002 [P0] Run audio concurrency tests under ThreadSanitizer where supported. Acceptance: No races in mixer singleton, instance registries, queues, events, or callbacks. Evidence: fresh one-off TSAN build (-DCNA_SANITIZE=thread, removed after use per this branch's established convention), first pass against the broad NEXTaudio.md §7 audio-scoped filter surfaced 6 WARNING: ThreadSanitizer: data race reports, all traced to the exact same root cause: pthread_barrier_init/pthread_barrier_destroy racing inside Mesa's own libgallium OpenGL driver internals during GraphicsDevice/EasyGLGraphicsBackend teardown -- not CNA-owned code at all (CNA's own frames only ever call into the driver's destructor; the actual race is entirely within libgallium-25.0.7-2.so). Traced to 3 specific graphics/content-loading test files (XnbBuiltInReaderRegistrationTests.cpp, XnbContainerFuzzTests.cpp, CnbCapabilityMatrixTests.cpp) that incidentally matched the broad filter's wildcards despite testing XNB/CNB content loading, not audio concurrency. Re-ran with those 3 files excluded: 652/652 genuinely audio-scoped tests, zero ThreadSanitizer warnings, exit code 0 -- a clean, direct confirmation that every concurrency fix landed this session (AUD-11-024's WaveBank cache mutex, AUD-11-025's Dispose()-vs-decode fix, plus all pre-existing mixer/instance/queue/callback concurrency machinery) holds up under real thread-interleaving analysis, not just repeated-run gtest checks. No production code change from this task itself.
  • AUD-15-003 [P0] Define lock ordering for mixer, track, queue, dispatcher, engine, bank, and cue locks. Acceptance: Documented order is enforced by review/tests. Evidence: the entire audio codebase has exactly 4 real mutexes -- AudioMixer.cpp's g_mixerMutex (mixer singleton), SoundEffect.cpp's PendingPanStateCleanup::mutex (deferred fire-and-forget pan-state cleanup), DynamicSoundEffectInstance::queueMutex_ (buffer queue), and WaveBank::xactImplMutex_ (AUD-11-024/025). Traced every call site of each to find nested-lock patterns: PendingPanStateCleanup::mutex and queueMutex_ never nest with any other lock (each has short, self-contained critical sections that never call into another locked path -- confirmed specifically for queueMutex_ vs. g_mixerMutex on the Play() -> Update() -> EnsureStream() path, where queueMutex_'s critical sections inside Update() are released well before EnsureStream() acquires g_mixerMutex). The one real nested-lock pattern: WaveBank::GetSoundEffect() holds xactImplMutex_ across constructing a SoundEffect, which transitively acquires-and-releases g_mixerMutex via GetMixer()/GetMixerOrThrowXna(). This is currently deadlock-safe (not just "documented, hope it stays true") because AudioMixer.cpp has zero dependency on WaveBank at all -- nothing anywhere in the codebase can ever try to acquire xactImplMutex_ while already holding g_mixerMutex, so the single established order (xactImplMutex_ outer, g_mixerMutex inner, never reversed) cannot currently be violated. Documented directly at WaveBank::xactImplMutex_'s own declaration (the most discoverable place for a future maintainer touching either lock) rather than only in this plan file, so the constraint travels with the code. "Dispatcher"/"engine"/"cue" locks named in the task's generic wording don't exist as separate mutexes in this codebase -- AudioEngine/Cue/SoundBank have no locks of their own (their state is only ever touched from the single game-update thread, matching real XNA's own single-threaded update-loop assumption; only the 4 mutexes above exist because they specifically guard state also touched by a real SDL3_mixer callback/background thread). No production code change beyond the clarifying comment.
  • AUD-15-004 [P0] Audit callbacks for use-after-free and reentrancy. Acceptance: Every callback owns or validates lifetime and can safely trigger Stop/Dispose where allowed. Investigated every real callback registration site in the audio codebase -- each already has an established, previously-verified lifetime/reentrancy protection; no new gap found. Grepped all MIX_SetTrack*Callback/SDL_SetAudioStream*Callback registrations plus the one real event-based callback (BufferNeeded): (1) OnFireAndForgetStopped/FireAndForgetPanCallback (SoundEffect.cpp, fire-and-forget Play()) -- already hardened by a prior session's own real ASan-caught UAF fix (P11-PAN-002): the cooked callback can genuinely still read a track's userdata after the stopped callback already ran (SDL3_mixer's own documented mixer-thread ordering), so panState cleanup is deliberately deferred to the next fire-and-forget Play() call rather than freed directly in the stopped callback. (2) FilterMixCallback (SoundEffectInstance.cpp, low/high/band-pass filters) -- filterState_ is heap-owned via unique_ptr specifically so a move transfers ownership without changing the FilterState object's address, keeping the already-registered SDL3_mixer callback's userdata pointer valid across a move with no re-registration (T-4C, already tested via LowPassFilterSurvivesMoveConstruction); Dispose() destroys the track (deregistering the callback) strictly before the destructor's member-teardown destroys filterState_, so there's no path where the callback could ever observe a freed FilterState. (3) BufferNeeded (DynamicSoundEffectInstance::Update()) -- confirmed via a direct line-by-line comparison against real FNA's own DynamicSoundEffectInstance.cs Update() that CNA's raise-loop is an exact match, including its reentrancy characteristic (no re-check of disposal state between repeated raises within one Update() call) -- this is deliberate XNA/FNA parity, not a CNA-specific gap, and diverging from it would violate this project's own Behavior Fidelity mandate. Related, and where this task's own investigation directly overlaps with AUD-11-025's fix this same session: WaveBank::Dispose() racing a concurrent GetSoundEffect() decode was exactly this class of "does every access path own or validate lifetime" bug, found and fixed with a real ASan-reproduced UAF earlier this session -- see that entry for detail; not re-litigated here. No production code change from this task itself.
  • AUD-15-005 [P0] Stress create/play/destroy thousands of short instances. Acceptance: No leak, stale callback, or unbounded registry growth. Added SoundEffectInstanceTest.StressCreatePlayDisposeThousandsOfShortLivedInstancesFromSharedEffect (5000 create/Play()/scope-exit-destroy cycles against one shared SoundEffect, never calling Stop()/Dispose() explicitly -- exactly the real-game fire-and-forget-instance pattern). First version of this test was verified to have weak discriminating power and was replaced before being accepted: the original design used only indirect signals (wall-clock timing over the 5000 iterations, plus "does one more instance still work afterwards"). Per this session's own verification standard, deliberately broke SoundEffect::UnregisterInstance() into a no-op and re-ran -- the original test still passed (12ms, no crash), silently leaking 5000 stale pointers into SoundEffect::Impl::instances (the per-effect live-instance registry used by the Dispose cascade, T-3G). Root cause of the weak signal: 5000 plain vector::push_back calls are trivially fast even while "leaking" (no super-linear wall-clock blowup at this scale), and each loop iteration's SoundEffectInstance reuses the same stack slot, so a stale pointer left in the registry doesn't reliably fault when later walked -- unlike a heap-based UAF, this isn't something ASan reliably catches either. Fixed by adding real introspection: a new private SoundEffect::GetLiveInstanceCountInternal() (returns impl_->instances.size()) plus a NOXNA friend struct SoundEffectTestAccess (SoundEffect.hpp) and a matching test-only tests/.../SoundEffectTestAccess.hpp wrapper, matching the codebase's established WaveBankTestAccess/SoundEffectInstanceTestAccess pattern. The corrected test asserts the registry directly: == 0 before the loop, <= 1 after every single iteration (the registry must never hold more than the one in-flight instance from that iteration), and == 0 again once all 5000 have gone out of scope. Re-verified against the same broken-UnregisterInstance() probe: the corrected test now genuinely fails, at iteration 1 (actual: 2 vs 1), confirming real discriminating power before the probe was reverted. Audio-scoped filter (653/653) and full whole-repo suite (4783/4785 passed, 2 pre-existing unrelated hardware-only skips) both clean after the revert.
  • AUD-15-006 [P0] Stress dynamic producer/consumer with random pause/stop/dispose. Acceptance: No deadlock, lost data outside documented stop, or corrupt counters. Found and fixed a real, ASan-reproduced use-after-free crash, then used TSAN to find and fix one more real race, then made a documented scoping decision on a third class of (benign) races. New test DynamicSoundEffectInstanceTest.StressProducerConsumerWithRandomPauseStopDispose (DynamicSoundEffectInstanceTests.cpp): a "producer" thread calls SubmitBuffer() in a tight loop (20000 iterations) while the main "game" thread randomly drives Play/Pause/Resume/Stop/Update (5000 iterations) then Dispose()s the instance while the producer may still be mid-submit -- exactly the real-world pattern of a decode/streaming thread feeding a DynamicSoundEffectInstance while the game thread controls playback. Defect 1 (crash, fixed): the very first run segfaulted 100% reproducibly -- SubmitBuffer()'s "if Playing, submit immediately" optimization called getStateProperty()/SubmitQueuedToStream() after releasing queueMutex_, so a producer-thread call could read/use track_/audioStream_ at the exact moment StopInternal() (game thread) called MIX_DestroyTrack()/SDL_DestroyAudioStream() on them -- confirmed via ASan: SEGV ... in __pthread_mutex_lock inside SDL_LockAudioStream inside MIX_TrackPaused, called from getStateProperty(), called from SubmitBuffer(), on a track already freed by StopInternal() on the other thread. Root cause and fix: ported FNA's own real DynamicSoundEffectInstance.SubmitBuffer() pattern more faithfully -- FNA checks State and submits to the native voice atomically inside its own lock (queuedBuffers) block; CNA's port had split that into an unlocked, separately-timed check. Fixed by making the state-check-and-submit atomic under queueMutex_ (new SubmitQueuedToStreamLocked(), assumes caller holds the lock, used by SubmitBuffer()/SubmitFloatBufferEXT(); SubmitQueuedToStream() becomes a thin lock+delegate wrapper for Update()/QueueInitialBuffers()), and making every write to the fields SubmitBuffer() now reads under that same lock: StopInternal()'s track-destroy block, DestroyStream()'s stream-destroy block, and Play()'s track-creation assignment. Verified with this session's established git-stash-equivalent rigor: 100% reproducible crash before the fix (every one of several runs), 0/20 crashes after (10x normal build + 10x fresh ASan+UBSan build, ASAN_OPTIONS=detect_leaks=0). Defect 2 (race, fixed): a subsequent one-off TSAN build (-DCNA_SANITIZE=thread, removed after use per this branch's convention) caught a second, independent real race on this same test: isFloat_ (a plain bool) written by SubmitBuffer()/SubmitFloatBufferEXT() (producer thread) and read unprotected by EnsureStream() (game thread, via Play()) -- WARNING: ThreadSanitizer: data race ... in EnsureStream(). Fixed by making isFloat_ a std::atomic<bool> (a safe, drop-in change -- it has no copy/move-constructor entanglements, unlike State_/isDisposed_ below) and moving the two "must be Stopped to switch format" guards (SubmitBuffer/SubmitFloatBufferEXT) to take queueMutex_ around their own getStateProperty() reads too, for the same reason as the immediate-submit fix. Defect 3 (found, explicitly NOT fixed -- documented scoping decision): with defects 1-2 fixed, TSAN still reports races on SoundEffectInstance::State_/isDisposed_ (base-class fields) between the producer thread's now-locked getStateProperty() reads and the game thread's unlocked writes inside Pause()/Dispose(). Investigated and deliberately not fixed: State_ has dozens of write sites across SoundEffectInstance.cpp (Play/Pause/Resume/Stop/copy+move ctors) and isDisposed_ is entangled in the class's copy/move-assignment operators (std::atomic<bool>'s deleted copy constructor would break isDisposed_(other.isDisposed_)-style member-init-list copies) -- closing this fully would mean converting core, heavily-shared base-class fields used by every SoundEffectInstance subclass (not just DynamicSoundEffectInstance) to atomics or lock-protection, a cross-cutting architectural change disproportionate to this task. These races are benign in practice (simple aligned scalar reads/writes, atomic at the hardware level on every real target platform -- no torn-value risk) and reflect a genuinely pre-existing, already-documented characteristic of the class hierarchy (AUD-15-003: "AudioEngine/Cue/SoundBank state is only ever touched from the single game-update thread" -- Pause/Resume/Stop/Dispose and every property setter were never part of the documented producer-thread-safe surface; only SubmitBuffer/SubmitFloatBufferEXT/ClearBuffers/Update/getPendingBufferCountProperty, all guarded by queueMutex_, were). Test self-correction: the test's own final assertion was first written as pending <= 1 after Dispose() (assuming isDisposed_'s check-then-act in SubmitBuffer() only has a 1-chunk race window against Dispose()'s Stop()->ClearBuffers()) -- under the slower/instrumented ASan build this genuinely failed (actual: 2/3/4), revealing the window is real but not that narrow (isDisposed_ only flips true at the very end of Dispose(), well after ClearBuffers() already ran). Loosened to pending < 1000 (still catches genuine unbounded growth; the residual few-chunk race is an accepted, harmless consequence of disposing an instance while a foreign producer thread hasn't been told to stop, not a supported call pattern to begin with). Verification: normal build 10/10 clean, fresh ASan+UBSan build 10/10 clean (0 crashes both), fresh TSAN build shows zero remaining warnings traceable to defects 1 or 2 (only the explicitly-scoped-out State_/isDisposed_ reports remain). Full audio-scoped filter (654/654, including this new test) and a 5x-repeated run both clean under the normal build. See AUD-15-021 for a separate, unrelated, pre-existing flaky segfault discovered incidentally while regression-testing this fix.
  • AUD-15-007 [P0] Stress AudioEngine/SoundBank/WaveBank disposal permutations. Acceptance: No UAF, double unregister, or dangling cue. Confirmed the existing disposal-order protections are correct, with a test that has real, ASan-verified discriminating power (not just "doesn't crash"). New AudioEngineTest.StressDisposalOrderPermutationsAcrossEngineWaveBankSoundBankAndCue (200 iterations): each builds a fresh, dedicated AudioEngine + WaveBank + SoundBank, a caller-held Cue (via GetCue, randomly never-played/playing/stopped-but-undisposed) and a fire-and-forget Cue (via PlayCue), then disposes all four objects (engine, wave bank, sound bank, held cue) in a randomly shuffled order (Fisher-Yates over the 4 steps, deterministic LCG seed) -- critically, the held cue is disposed AND freed (delete) as part of the shuffle, not always last, so a bank whose own dispose step lands after the cue's in a given permutation genuinely exercises "does this bank still hold a pointer to an already-freed Cue," not just an already-disposed-but-still-allocated one. Verified real discriminating power using this session's established probe pattern: temporarily commented out Cue::Dispose()'s bank_->UnregisterCue(this) call -- the test still passed under a normal build (freed-but-not-yet-reused memory happened to still read a coherent isDisposed_ == true, so the stale-pointer cue->Dispose() call inside a later bank's cascade silently no-op'd instead of crashing) but failed with a genuine AddressSanitizer: heap-use-after-free in SoundBank::Dispose() at SoundBank.cpp:222 (iterating activeCues_ and calling cue->Dispose() on the already-deleted pointer) under a fresh ASan+UBSan build -- confirming the test would catch a real regression here, not just pass trivially. Reverted the probe; the existing bank_->UnregisterCue(this) call (already present, AUDIO-LIFECYCLE-001) is what prevents this, and no production code change was needed. Verification: normal build 3x clean (200/200 permutations each run), ASan+UBSan build clean (audio-scoped filter 655/655, zero sanitizer findings), full whole-repo suite 4785/4787 passed / 2 skipped (unrelated hardware tests). Deliberately deferred, not covered: a single Cue referencing wave entries from two or more distinct WaveBanks (Cue::waveBanksUsed_ is a deduplicated vector<WaveBank*>, so this is structurally possible) with only one of those banks disposed mid-playback -- constructing a hand-built XSB/XWB fixture pair with a genuinely multi-wavebank cue was judged disproportionate to this task's time budget versus the permutation stress above, which already covers every other ranked gap (WaveBank-before-SoundBank ordering, AudioEngine-cascade timing, caller-held vs. fire-and-forget cue registries) from this task's own upfront research pass. Left as a candidate for a future, narrower follow-up if this codebase later adds richer multi-wavebank test fixtures for other purposes.
  • AUD-15-008 [P1] Remove avoidable allocations from real-time callbacks and hot mix paths. Acceptance: Instrumentation proves zero forbidden allocations per callback. Found and fixed one real, confirmed forbidden-operation site; confirmed the other real-time callbacks are already clean by direct code inspection. Enumerated every real SDL3_mixer callback that runs on the mixer thread (per AUD-15-004's own prior enumeration): FilterMixCallback/ProcessFilterState/ApplyFilter/ApplyPanCrossfeed/ComputePanCrossfeedMatrix (SoundEffectInstance.cpp) and FireAndForgetPanCallback (SoundEffect.cpp) are pure stack-based float math over the caller-provided pcm buffer -- no heap containers, no new, confirmed by direct read of every line. OnFireAndForgetStopped (SoundEffect.cpp, the track-stopped callback, also mixer-thread per this codebase's own established P11-PAN-002 finding) was NOT clean: it queued the track's FireAndForgetPanState* for later cleanup via PendingPanStateCleanup::Queue(), which took a std::mutex lock and did a std::vector::push_back that can reallocate -- both forbidden on a real-time audio thread (blocking + non-deterministic allocation). Fixed by rewriting PendingPanStateCleanup as a lock-free intrusive singly-linked stack (classic Treiber-stack push/pop): FireAndForgetPanState gained its own next link (reusing the already-heap-allocated object as its own queue node, so Queue() costs only an atomic CAS, never allocates or blocks), and Drain() (called from the game thread) atomically claims the whole chain via one exchange(). Verified with this session's git-stash regression pattern: stashed the fix, confirmed the un-fixed code still passes the same functional tests (mutex+vector was correct, just not real-time-safe) -- this was a hardening fix, not a correctness bug, so there's no "test fails without the fix" signal to demonstrate; instead verified the fix itself is correct via the full SoundEffectTest/SoundEffectInstanceTest/SoundEffectInstanceFilterMathTest suites (182/182, 5x repeated, clean) and a fresh ASan+UBSan build (182/182 clean, zero sanitizer findings on the new atomic CAS logic). Formal "instrumentation proves zero allocations" component deliberately deferred, not silently dropped: a global operator new/operator delete allocation-counting harness would need to live in the shared CnaTests binary (or a new standalone tool needing new friend-access plumbing) to directly instrument these callbacks -- given AUD-15-022's newly-discovered, severe, 100%-reproducible heap corruption was found in this exact binary in the same work session, adding a new global allocator override right now would compound an already-open, undiagnosed heap-corruption investigation rather than help it. Revisit once AUD-15-022 is resolved. No test needed for the already-clean callbacks (pure math over caller-provided memory, nothing to instrument).
  • AUD-15-009 [P1] Define real-time-safe logging strategy. Acceptance: Audio thread never blocks on I/O or allocator-heavy formatting.
  • AUD-15-010 [P1] Benchmark simultaneous static voices at 16/32/64/128+. Acceptance: CPU, latency, and failure policy are documented.
  • AUD-15-011 [P1] Benchmark XACT cue update with large active cue counts. Acceptance: Frame-time budget and scaling curve are recorded.
  • AUD-15-012 [P1] Benchmark dynamic tiny-buffer workload. Acceptance: Minimum practical buffer size and callback overhead are documented.
  • AUD-15-013 [P1] Benchmark decoding/loading each supported codec. Acceptance: Cold/warm time and allocations guide caching/preload policy.
  • AUD-15-014 [P1] Bound decoded-wave and streaming caches. Acceptance: Memory budget and eviction are deterministic.
  • AUD-15-015 [P1] Test allocation failures at key boundaries. Acceptance: Objects remain valid/disposed and errors are contextual.
  • AUD-15-016 [P1] Test process shutdown with audio callbacks in flight. Acceptance: No late access to static destruction order.
  • AUD-15-017 [P1] Test repeated mixer init/destroy cycles. Acceptance: No leaked device, thread, handle, or MIX refcount. Evidence: new test RepeatedInitDestroyCyclesLeaveNoLeakedStateAndFinalCallSucceeds (AudioMixerTests.cpp) runs 20 full GetMixer()/DestroyMixer() cycles back-to-back, asserting a correctly-specced mixer (S16 stereo 44100 Hz, matching the AUD-04-005 fixed-reference policy) on every cycle, then one final GetMixer() call after the loop to confirm the mixer is still fully functional (not degraded by any slowly-exhausted resource) rather than only checking cycle 1. Builds directly on this session's AUD-04-008/009 fix -- GetMixer()'s one-time SDL_InitSubSystem(SDL_INIT_AUDIO) pin is what keeps the audio subsystem itself alive across every DestroyMixer() call in the loop, not just the first; without that fix this test would be exercising the exact subsystem-teardown hazard AUD-04-009 found. All 20+1 cycles pass cleanly (~1.3s total). No production code change. Full whole-repo suite: 4740 passed / 0 failed / 2 skipped (unrelated hardware-support tests).
  • AUD-15-018 [P1] Audit all integer conversions to SDL int lengths. Acceptance: Buffers larger than INT_MAX are split/rejected safely. Investigated exhaustively -- confirmed already safe, the one genuine candidate is bounded by construction, not a gap: grepped every static_cast<int>/static_cast<Uint32>/static_cast<uint32_t> in src/Microsoft/Xna/Framework/Audio/ and src/CNA/Internal/Audio/ for anything narrowing a length/size value. Every real SDL/MIX audio-length parameter in this codebase that could plausibly see a large value already takes size_t/Uint32 natively (MIX_LoadRawAudio's size_t datalen, SDL_IOFromConstMem's size_t size, every WAV chunk-size field in WavWrapper.cpp, WaveBank's own audioLen/dataLength throughout) -- confirmed by reading each header's real signature, not assumed. The one genuine int-typed SDL length parameter reachable from audio code is SDL_PutAudioStreamData(SDL_AudioStream*, const void*, int len), called from DynamicSoundEffectInstance::SubmitQueuedToStream() as static_cast<int>(chunk.size()) -- but chunk is always constructed with exactly count elements from SubmitBuffer(buffer, offset, count)'s own SharpRuntime::intcs (int32_t) parameter, itself already validated non-negative, so chunk.size() can never exceed INT32_MAX in the first place: this isn't a narrowing risk to guard against, it's an inherent consequence of matching real XNA's own SubmitBuffer(byte[], int offset, int count) contract, where a caller cannot even express a count larger than int allows. No production code change, no test needed -- there is no reachable path where a buffer "larger than INT_MAX" could exist to split or reject.
  • AUD-15-019 [P1] Audit frame/byte/sample conversions for overflow. Acceptance: Checked arithmetic covers duration, loops, offsets, and queue totals. Investigated -- confirmed largely already covered by this session's own prior fixes, plus one new area (DynamicSoundEffectInstance's queue totals) reviewed and confirmed safe. Offsets: WaveBank::GetSoundEffect()'s entry.dataOffset + entry.dataLength bounds check (IN-9/AUD-11-004) and the compact-format rawOffsetUnits[i] * alignment multiplication (AUD-11-003) are both already explicitly widened to 64-bit before comparison/narrowing, specifically to prevent silent 32-bit wraparound -- both have dedicated regression tests already. Duration/size utility functions (SoundEffect::GetSampleDuration/GetSampleSizeInBytes): the intermediate multiplication happens in double precision (duration.getTotalSecondsProperty() * sampleRate * channels * 2), which cannot silently wrap the way integer multiplication can -- only the final narrowing to intcs is unguarded, matching real XNA's own identical int-returning utility method contract (a resolved, intentional non-issue, not a new finding). offset+count overflow in both SoundEffect's raw-buffer constructor and DynamicSoundEffectInstance::SubmitBuffer (P9-VALIDATION-002/003/010/011) already compute the bounds check as off > buffer.size() || cnt > buffer.size() - off specifically to avoid a plain offset+count addition that could itself overflow int32_t before the comparison even runs -- already fixed, already tested. Queue totals (new area reviewed this task): DynamicSoundEffectInstance::Update()'s submittedChunkSizes_ byte-total (AUDIO-BUFFER-001) is recomputed fresh from the current deque contents every Update() call (a std::size_t sum, not a persistent monotonic accumulator), so it can never accumulate error/overflow across calls the way a running total would -- each individual chunk's size is itself already bounded to at most INT32_MAX per AUD-15-018's finding, so even a pathologically large number of simultaneously-pending chunks would exhaust memory (queuedBuffers_/submittedChunkSizes_ deque growth) long before a 64-bit size_t sum could realistically overflow. No production code change, no test needed -- every arithmetic site this acceptance criterion names either already has an explicit overflow guard with its own regression test, or is confirmed structurally immune to overflow by the way it's computed.
  • AUD-15-020 [P2] Add continuous audio performance regression tracking. Acceptance: CI dashboard flags significant CPU/memory/latency changes.
  • AUD-15-021 [P0] Investigate an intermittent, pre-existing segfault in AUD04004/AudioMixerSpecOverrideTest.OverriddenSpecIsActuallyNegotiated/0 when run as part of the full audio-scoped test filter. Discovered incidentally while regression-testing AUD-15-006's fix (not caused by it -- confirmed via git-stash: reproduces at the same ~1-2/5 rate on the pre-AUD-15-006 baseline with AUD-15-006's changes fully stashed out). Symptoms: SDL_AUDIODRIVER=dummy ./CnaTests --gtest_filter='<the NEXTaudio.md §7 audio-scoped filter>' segfaults intermittently (~20-40% of runs observed) specifically at the start of AudioMixerSpecOverrideTest.OverriddenSpecIsActuallyNegotiated/0, which is the ~1361st-or-later test to run in that filtered set -- the SAME test suite run in complete isolation (--gtest_filter='AUD04004/AudioMixerSpecOverrideTest.*', 5 tests) never crashed (5/5 clean, repeated). This strongly suggests cumulative process-wide state from ~1300+ prior tests (most plausibly repeated AudioMixer::GetMixer()/DestroyMixer() cycles, or some other global/static teardown ordering issue) rather than a bug in this specific parameterized test itself. Not caught by ASan: 6/6 runs of the full audio-scoped filter under a fresh ASan+UBSan build were clean (ASAN_OPTIONS=detect_leaks=0) -- consistent with either a genuine data race (needs TSAN across the whole filtered run, not just one test, which is expensive/slow) or a non-memory-safety resource-exhaustion-style bug (e.g. a leaked SDL device/mixer handle that only manifests after enough accumulated churn) that ASan's instrumentation happens to perturb away. Acceptance: root-cause identified (bisect which earlier test(s) in the sequence leave the corrupting state, most efficiently via --gtest_filter binary search or --gtest_shuffle with a fixed seed to narrow reproduction, or a full-filter TSAN sweep if a race is suspected); fix applied; confirmed clean across >=20 consecutive full-filter runs. Further investigation (2026-07-18, same day): several real hypotheses ruled out; root cause still not found -- documenting negative results rather than guessing further. (1) Binary-search bisection across the ~30 preceding test suites (--gtest_list_tests order) rules out "one bad suite": neither the first ~14 suites (AudioMixerTest...AudioListenerTest) nor the second ~14 (AudioStopOptionsTest...WaveBankTest), each combined with AudioMixerSpecOverrideTest alone, reproduced the crash in 5 runs apiece (10 total, 0 crashes) -- yet the full ~656-test sequence still reproduces at its usual ~20-40% rate. This pattern (doesn't bisect cleanly, needs close to the full count) points toward a threshold/accumulation effect across most-or-all of the preceding suite, not one specific corrupting test. (2) WaveBankTest immediately precedes the crash site in registration order but running it alone plus AudioMixerSpecOverrideTest (5x) never reproduced it either -- ruled out as the sole trigger. (3) Resource-exhaustion hypotheses ruled out by direct measurement: live-sampled /proc/<pid>/status+fd during full runs (both loose ~200ms and tight ~50ms sampling, several attempts) -- file descriptor count stays flat (3-4 throughout, no leak), thread count stays low (1-3, consistent with proper join() cleanup between tests, no thread leak), RSS grows smoothly and modestly (~10MB to ~55MB across the whole run, consistent with normal test allocation, not a runaway leak). (4) Not caught by TSAN either: a fresh one-off TSAN build (-DCNA_SANITIZE=thread, removed after use per convention) run 3x against the full audio-scoped filter completed all 656 tests cleanly every time (only the already-known, already-documented, explicitly-scoped-out AUD-15-006 races on State_/isDisposed_ were reported, from unrelated earlier tests in the same run -- nothing new, and the target crash never occurred under TSAN). Emerging picture: the crash reproduces reliably under a plain, uninstrumented, full-speed run, but is evaded by every form of instrumentation tried so far (ASan, TSAN, even lightweight /proc polling) -- the classic signature of a genuine timing-sensitive race whose window is only reliably hit at full native execution speed, where added overhead from any of these tools perturbs the exact interleaving needed. Further progress likely needs either (a) gdb (not installed in this sandbox, no passwordless sudo available to install it) to get a real backtrace/core-dump analysis without behavior-altering instrumentation, or (b) a custom lightweight repro harness that narrows the specific interleaving without a general-purpose sanitizer's overhead. Left open, undiagnosed, for whoever picks this up next with either tool available.
  • AUD-15-022 [P0] -- CRITICAL, deterministic (not flaky), likely implicates this session's own AUD-15-006/AUD-07-003 stress tests. A separate, more severe heap-corruption crash (corrupted double-linked list / malloc_consolidate(): unaligned fastbin chunk detected / free(): invalid pointer -- the exact glibc abort message varies run to run, itself a signature of real heap corruption rather than a single well-defined bug site) was found while investigating AUD-15-021. Unlike AUD-15-021's ~20-40% flake rate, this one is 100% reproducible with a much smaller, faster repro: SDL_AUDIODRIVER=dummy ./CnaTests --gtest_filter='CueTest.*:DynamicSoundEffectInstanceTest.*' (149 tests, ~3.5s, crashes at process exit during "Global test environment tear-down" every single time, 6/6 runs observed). Bisection so far: (1) confirmed via git-stash this is unrelated to AUD-15-008's change (reproduces identically with that change stashed out). (2) Neither CueTest alone nor DynamicSoundEffectInstanceTest alone crashes (3/3 clean each) -- it is a genuine cross-suite interaction. (3) Excluding this session's two new stress tests (-*Stress*) from DynamicSoundEffectInstanceTest makes the crash disappear entirely (3/3 clean) -- strongly implicating StressProducerConsumerWithRandomPauseStopDispose (AUD-15-006) and/or StressSubmitFloatBufferEXTAgainstRepeatedPlayCyclesNeverCorruptsLiveStream (AUD-07-003) as a necessary trigger. (4) However, neither stress test alone (each combined with full CueTest) reproduces it (3/3 clean each), nor do both stress tests together without the other ~55 ordinary DynamicSoundEffectInstanceTest tests (3/3 clean) -- the crash needs the full CueTest suite, the full non-stress DynamicSoundEffectInstanceTest suite, AND both stress tests together, all in one process. This pattern (corruption caused early, only detected later once enough subsequent allocator activity walks over the corrupted chunk) is consistent with a real heap-buffer-overflow or double-free somewhere in the stress tests' code paths (most likely DynamicSoundEffectInstance's newly-added/newly-locked queueMutex_-protected paths from AUD-15-006, given the timing) that ASan does not catch. Not caught by ASan: the exact CueTest.*:DynamicSoundEffectInstanceTest.* repro run 3x under a fresh ASan+UBSan build was clean every time (ASAN_OPTIONS=detect_leaks=0) -- same evasion pattern as AUD-15-021, and no valgrind/gdb available in this sandbox to dig further (see that entry's tooling note, same blocker). A secondary, smaller, real finding surfaced along the way (unrelated to the crash itself, but worth tracking): one test run's stderr showed [DynamicSoundEffectInstance] SDL_PutAudioStreamData failed (63 bytes dropped): Can't add partial sample frames -- confirms AUD-07-005's acceptance criterion ("partial samples/frames are rejected") is currently NOT met: SDL itself rejects the non-frame-aligned submission, but CNA only logs a diagnostic and silently drops the data rather than surfacing it to the caller; AUD-07-005/006 remain open, legitimate follow-up tasks for this (a discarded WIP attempt at fixing this already exists in this session's history -- see NEXTaudio.md's process note -- design and verify fresh, don't resurrect the discarded diff untested). Acceptance: root-cause identified via gdb/valgrind/ASan-with-different-flags (detect_stack_use_after_return, malloc_context_size, etc. -- untried) or manual code audit of every allocation/free in AUD-15-006's and AUD-07-003's new code paths; fixed; the exact repro command above confirmed clean across >=20 consecutive runs. Important scope clarification (checked immediately after finding this): the FULL whole-repo suite (./CnaTests, no filter, 4788 tests) and the properly-scoped audio filter both ran clean 3x in a row right after this was found -- this crash appears specific to the narrow, isolated CueTest.*:DynamicSoundEffectInstanceTest.* repro (consistent with heap-corruption bugs being sensitive to overall heap layout/allocation history, similar to how AUD-15-021 needed ~1300 tests' worth of accumulated state). This is a real, 100%-reproducible-in-isolation bug that must still be fixed (and may be an early/small-scale symptom of the same root cause behind AUD-15-021's larger-scale flake), but it is NOT currently observed to break the normal whole-repo or audio-scoped CI-style test run -- downgrade the urgency framing accordingly: fix it because it's a real, cleanly-reproducible lead worth chasing (and possibly the key to also closing AUD-15-021), not because the normal test suite is currently broken.

AUD-16 — Cross-platform and hardware validation

Prove parity and truthful fallback on real devices, not only SDL dummy mode.

  • AUD-16-001 [P0] Create a platform/backend audio test matrix with required gates. Acceptance: Linux, Windows, macOS, Android, iOS, and Web coverage is explicit.
  • AUD-16-002 [P0] Run offline golden tests on every compiler/platform. Acceptance: Core decode/mix math remains within tolerance.
  • AUD-16-003 [P0] Run physical-device calibration on Linux ALSA/PipeWire/PulseAudio as applicable. Acceptance: Rate, duration, channels, and latency are recorded.
  • AUD-16-004 [P0] Run physical-device calibration on Windows WASAPI. Acceptance: Results compare with original XNA capture where possible.
  • AUD-16-005 [P1] Run physical-device calibration on macOS CoreAudio. Acceptance: Native 44.1/48 behavior is validated.
  • AUD-16-006 [P1] Run Android output/capture calibration across common device rates. Acceptance: Mobile resampling and lifecycle pass.
  • AUD-16-007 [P1] Run iOS output/capture calibration across route changes. Acceptance: Speaker/headphones/Bluetooth transitions are tested.
  • AUD-16-008 [P1] Run WebAudio/browser calibration where audio is supported. Acceptance: Autoplay, resume, rate, and latency limitations are documented.
  • AUD-16-009 [P1] Test headphones/speakers/Bluetooth route changes. Acceptance: No persistent wrong rate or lost mixer state.
  • AUD-16-010 [P1] Test mono, stereo, 5.1, and 7.1 devices where supported. Acceptance: Channel mapping/fallback is explicit.
  • AUD-16-011 [P1] Test default 44.1, 48, and 96 kHz devices. Acceptance: No pitch shift or duration drift.
  • AUD-16-012 [P1] Test suspend/resume, focus loss, and backgrounding. Acceptance: Streams and timers recover consistently.
  • AUD-16-013 [P1] Test no-audio-device/headless environments. Acceptance: Exception/fallback behavior supports tests and servers without false success.
  • AUD-16-014 [P1] Test locale-independent numeric parsing/configuration. Acceptance: Audio ratios/settings cannot change with locale.
  • AUD-16-015 [P1] Record backend capability/version in test artifacts. Acceptance: Failures are attributable to an exact environment.
  • AUD-16-016 [P2] Create a manual perceptual QA script. Acceptance: Human checks complement, but never replace, numerical gates.

AUD-17 — Malformed content, fuzzing, and security hardening

Treat audio files as untrusted binary input and eliminate parser/decoder denial-of-service risks.

  • AUD-17-001 [P0] Fuzz WAV/RIFF loading including chunk order, padding, and sizes. Acceptance: No crash/OOB/leak/hang/pathological allocation.
  • AUD-17-002 [P0] Fuzz XNB SoundEffect payloads and format extensions. Acceptance: Reader remains memory-safe and bounded.
  • AUD-17-003 [P0] Fuzz XGS, XSB, and XWB independently and as a linked set. Acceptance: Cross-file indices cannot escape bounds.
  • AUD-17-004 [P0] Add maximum asset size, channel count, sample rate, and duration limits. Acceptance: Limits prevent denial-of-service and are configurable/documented.
  • AUD-17-005 [P0] Use checked arithmetic for RIFF and bank size computations. Acceptance: No wraparound can produce small accepted ranges or huge allocations.
  • AUD-17-006 [P1] Test truncated files at every byte boundary for small fixtures. Acceptance: Failure is deterministic and leak-free.
  • AUD-17-007 [P1] Test unknown/duplicate RIFF chunks and odd-byte padding. Acceptance: Parser remains synchronized.
  • AUD-17-008 [P1] Test NaN/Inf float samples and parameter values. Acceptance: Mixer output remains finite or input is rejected.
  • AUD-17-009 [P1] Test malicious loop points and event counts. Acceptance: No infinite CPU loop or OOB region.
  • AUD-17-010 [P1] Test decompression bombs and extreme metadata ratios. Acceptance: Resource use is bounded before decode.
  • AUD-17-011 [P1] Add fuzz corpus minimization and regression promotion. Acceptance: Every found crash becomes a small committed test.
  • AUD-17-012 [P1] Run fuzzers with ASan/UBSan in scheduled CI. Acceptance: Coverage and finding status are tracked.
  • AUD-17-013 [P2] Audit external decoder CVE/update policy. Acceptance: Pinned versions have an explicit security maintenance process.

AUD-18 — API parity, documentation, migration, and release gates

Convert the work into a maintainable, auditable definition of “perfect audio.”

  • AUD-18-001 [P0] Generate a public API signature diff against XNA 4.0 Audio/Media assemblies. Acceptance: Missing/extra/type/default/constness differences are reviewed.
  • AUD-18-002 [P0] Generate behavior tests for every public Audio property/method/exception. Acceptance: Coverage matrix contains no unreviewed member.
  • AUD-18-003 [P0] Define a “CNA Audio Correctness” release gate. Acceptance: High-pitch, missing-asset, rendered-golden, sanitizer, and platform gates are mandatory.
  • AUD-18-004 [P0] Require zero unresolved silent-failure paths in core playback. Acceptance: Static audit and tests prove every backend failure is handled.
  • AUD-18-005 [P0] Require zero unexplained unsupported formats in shipped asset manifests. Acceptance: Build cannot ship assets that runtime silently cannot play.
  • AUD-18-006 [P0] Publish the final XNA/CNA differential report for the reported game. Acceptance: Root cause, fix, recordings, and numerical evidence are included.
  • AUD-18-007 [P1] Document exact raw PCM constructor requirements with examples. Acceptance: C++ porters cannot confuse container bytes and PCM frames.
  • AUD-18-008 [P1] Document XNB/XACT supported format matrix and conversion guidance. Acceptance: Users know what content pipeline products are valid.
  • AUD-18-009 [P1] Document pitch composition and 3D velocity units. Acceptance: Examples show neutral pitch and correct per-second velocity calculation.
  • AUD-18-010 [P1] Document audio troubleshooting using trace and asset inspector. Acceptance: Guide maps common symptoms to concrete measurements.
  • AUD-18-011 [P1] Document backend/device limitations and intentional divergences. Acceptance: No hidden approximation is marketed as exact parity.
  • AUD-18-012 [P1] Add runnable samples for static, dynamic, XACT, media, 3D, and microphone paths. Acceptance: Each sample doubles as a manual/CI smoke target.
  • AUD-18-013 [P1] Add a calibration sample showing detected rate/frequency. Acceptance: Users can validate a machine without the affected game.
  • AUD-18-014 [P1] Add migration guidance from XNA C# audio loading to CNA C++. Acceptance: Guide highlights metadata, case, lifetime, and content-pipeline traps.
  • AUD-18-015 [P1] Create changelog entries for behavior-affecting audio fixes. Acceptance: Applications can identify changes in pitch/format/voice semantics.
  • AUD-18-016 [P1] Establish semantic versioning policy for audio behavior and extensions. Acceptance: Breaking backend/parity changes are not silent.
  • AUD-18-017 [P1] Review licenses/patents for optional codecs. Acceptance: Codec support decisions are legally and technically documented.
  • AUD-18-018 [P1] Package exact dependency notices and source obligations. Acceptance: Distributions are reproducible and compliant.
  • AUD-18-019 [P2] Complete or explicitly scope every remaining MediaLibrary stub. Acceptance: No generic not implemented survives without compatibility rationale.
  • AUD-18-020 [P2] Perform a final independent audit after all P0/P1 tasks. Acceptance: Fresh reviewer reproduces gates without relying on implementation author assumptions.

Final definition of done

  • The reported C# XNA and C++ CNA game build have matched, archived captures for every formerly affected sound.
  • No affected sound is high-pitched, sped up, distorted, missing, or silently skipped.
  • Static, dynamic, XACT, and media calibration tests pass at 22.05/44.1/48 kHz source rates and 44.1/48 kHz device rates.
  • The supported XNB/XWB/XACT format matrix is implemented and validated by real fixtures.
  • Every core SDL/MIX operation has checked failure handling and truthful public state.
  • Pitch, XACT cents/RPC/random variation, and Doppler composition are traceable and differential-tested.
  • Audio parsers and lifecycle pass sanitizer/fuzz/stress gates.
  • Required physical-device tests pass on supported desktop/mobile/web targets.
  • Remaining divergences from XNA are intentional, documented, tested, and accepted.
  • A fresh independent audit finds no unresolved P0/P1 item or unexplained audible divergence.