From 1e9031e7b7fea2e17ba4c30d041a549d9616127f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 11:49:16 +0200 Subject: [PATCH] fix(audio): bound decoded-wave and AL-buffer caches (2026-07-24 audit review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two audit-verified caches grew monotonically for process lifetime, which is fatal for the 30-bot long-uptime headless-fleet goal: - DatSoundCache._waves (Core) memoized every decoded PCM WaveData forever in a bare ConcurrentDictionary — no LRU, no byte budget. - OpenAlAudioEngine._bufferByWaveId (App) retained a native OpenAL buffer copy of the same PCM per wave id until engine disposal — a second, independent unbounded cache. DatSoundCache now bounds payload residency with a 32 MiB byte-budget LRU (decoded PCM waves run ~100-500 KB each, so this holds a comfortable working set). Missing/unsupported-format waves are memoized separately in an unbounded-but-cheap negative-result set (bounded by the finite Wave dat id space) so they can never compete with or get evicted alongside payload entries. Concurrent first-touch decodes of the same wave id are deduped via a shared Lazy so racing callers don't pay for WaveDecoder.Decode twice. AcDream.Core cannot reference AcDream.Content (code-structure rule 2), so the LRU is a local reimplementation mirroring BoundedDatObjectCache/DecodedTextureCache's shape rather than a shared dependency. OpenAlAudioEngine._bufferByWaveId now bounds native buffer residency with a 48 MiB byte-budget LRU (AlBufferBudgetTracker), evicting least-recently- used buffers once oversized. alDeleteBuffers fails on a buffer still attached to a source, so eviction queries live AL per-source state (GetSourceInteger.Buffer) rather than tracking a second, easily-stale copy — several call sites (Play3DWave, PlayUiWave) set a source's buffer directly. The buffer EnsureBuffer just created is explicitly protected from its own eviction pass, since the caller hasn't attached it to a source yet at that point. Evicted waves simply replay through DatSoundCache -> EnsureBuffer on next use, identical to a first play. Verified before implementing: AudioHookSink is the only GetWave caller (single per-frame render-thread path per AnimationHookRouter's own threading doc), and PcmBytes is read only at DatSoundCache.Admit (byte accounting) and EnsureBuffer's first-upload branch — confirmed dead after AL upload on the steady-state replay path, so bounding either cache independently is correctness-safe; a cold replay after both evict simply falls back to a full re-decode + re-upload, identical to a first play. AlBufferBudgetTracker's eviction/budget decision is extracted as pure logic (no AL dependency) specifically so it's unit-testable: the existing OpenAlResourceLifetimeTests fake exposes a null AL, which short-circuits every native buffer call before it runs, so the engine's actual AL wiring isn't testable headless. Tests: 9 new DatSoundCacheTests (Core.Tests) covering eviction order, byte accounting, negative-result memoization, oversize-single-entry handling, and concurrent-access smoke tests; 10 new AlBufferBudgetTrackerTests (App.Tests) covering the pure LRU/budget/protection logic. Full suite: 3214/2 skip (Core.Tests), 3471/3 skip (App.Tests) plus one pre-existing, unrelated failure (LandblockBuildOriginTests.FarLoad_..., reproduces identically with these changes stashed out — landblock streaming, not audio). Co-Authored-By: Claude Fable 5 (cherry picked from commit 76c880d35bcf30b50e3f7b4cb8635dc9ab9e3ec7) --- .../Audio/AlBufferBudgetTracker.cs | 122 +++++++++ src/AcDream.App/Audio/OpenAlAudioEngine.cs | 101 +++++++- src/AcDream.Core/Audio/DatSoundCache.cs | 184 ++++++++++++-- .../Audio/AlBufferBudgetTrackerTests.cs | 152 +++++++++++ .../Audio/DatSoundCacheTests.cs | 237 ++++++++++++++++++ 5 files changed, 777 insertions(+), 19 deletions(-) create mode 100644 src/AcDream.App/Audio/AlBufferBudgetTracker.cs create mode 100644 tests/AcDream.App.Tests/Audio/AlBufferBudgetTrackerTests.cs create mode 100644 tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs diff --git a/src/AcDream.App/Audio/AlBufferBudgetTracker.cs b/src/AcDream.App/Audio/AlBufferBudgetTracker.cs new file mode 100644 index 00000000..88d18865 --- /dev/null +++ b/src/AcDream.App/Audio/AlBufferBudgetTracker.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.App.Audio; + +/// +/// Pure LRU/byte-budget bookkeeping for 's +/// native AL buffer cache (_bufferByWaveId). +/// +/// +/// Deliberately has no AL/Silk dependency. +/// supplies an "is this buffer id still attached to a live source" +/// predicate — native AL per-source state is the only thing that actually +/// determines whether alDeleteBuffers would fail, so the engine +/// queries it live via AL.GetSourceProperty rather than this class +/// tracking a second, easily-stale copy. This class only decides WHICH +/// resident buffer is the least-recently-used *evictable* one and keeps +/// the running byte total, which keeps that decision unit-testable without +/// a live OpenAL device. +/// +internal sealed class AlBufferBudgetTracker +{ + private sealed class Entry + { + public required uint WaveId { get; init; } + public required uint BufferId { get; init; } + public required long Bytes { get; init; } + public long LastUseTick { get; set; } + } + + private readonly Dictionary _byWaveId = new(); + private long _tick; + + public AlBufferBudgetTracker(long maxBytes) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maxBytes, 1); + MaxBytes = maxBytes; + } + + public long MaxBytes { get; } + + public long ResidentBytes { get; private set; } + + public int Count => _byWaveId.Count; + + public bool TryGetBufferId(uint waveId, out uint bufferId) + { + if (_byWaveId.TryGetValue(waveId, out var entry)) + { + bufferId = entry.BufferId; + return true; + } + bufferId = 0; + return false; + } + + /// + /// Record a freshly-allocated, freshly-uploaded buffer as resident and + /// most-recently-used. + /// + public void RecordCreated(uint waveId, uint bufferId, long bytes) + { + long charged = Math.Max(1L, bytes); + _byWaveId[waveId] = new Entry + { + WaveId = waveId, + BufferId = bufferId, + Bytes = charged, + LastUseTick = ++_tick, + }; + ResidentBytes += charged; + } + + /// + /// Bump an already-resident buffer's LRU order — call on every replay + /// (cache hit), not just on first creation. + /// + public void Touch(uint waveId) + { + if (_byWaveId.TryGetValue(waveId, out var entry)) + entry.LastUseTick = ++_tick; + } + + /// + /// Evict the single least-recently-used resident entry whose buffer id + /// is NOT reported as protected by + /// (still attached to a live AL source, or otherwise off-limits for + /// this call — e.g. a buffer just created and not yet handed to the + /// caller). Returns false when every resident entry is currently + /// protected, meaning the caller should stop evicting and accept a + /// transient budget overage rather than loop forever. + /// + public bool TryEvictOldestUnprotected( + Func isProtected, + out uint evictedWaveId, + out uint evictedBufferId) + { + ArgumentNullException.ThrowIfNull(isProtected); + + Entry? victim = null; + foreach (Entry candidate in _byWaveId.Values) + { + if (isProtected(candidate.BufferId)) + continue; + if (victim is null || candidate.LastUseTick < victim.LastUseTick) + victim = candidate; + } + + if (victim is null) + { + evictedWaveId = 0; + evictedBufferId = 0; + return false; + } + + _byWaveId.Remove(victim.WaveId); + ResidentBytes -= victim.Bytes; + evictedWaveId = victim.WaveId; + evictedBufferId = victim.BufferId; + return true; + } +} diff --git a/src/AcDream.App/Audio/OpenAlAudioEngine.cs b/src/AcDream.App/Audio/OpenAlAudioEngine.cs index a89dff4d..eaa22ac4 100644 --- a/src/AcDream.App/Audio/OpenAlAudioEngine.cs +++ b/src/AcDream.App/Audio/OpenAlAudioEngine.cs @@ -31,7 +31,12 @@ namespace AcDream.App.Audio; /// /// /// PCM buffer cache keyed by Wave dat id so the same footstep isn't -/// re-uploaded to the GL-equivalent AL buffers on every hit. +/// re-uploaded to the GL-equivalent AL buffers on every hit. Bounded +/// by a byte budget () enforced +/// with LRU eviction — see . A +/// buffer still attached to a live source is never evicted (AL +/// rejects deleting a bound buffer); eviction re-queries live AL +/// source state rather than tracking a second copy of it. /// /// /// @@ -76,7 +81,15 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine private readonly uint[] _poolUi = new uint[PoolSizeUi]; // ── Buffer cache (Wave dat id → AL buffer) ─────────────────────────────── + // Budget rationale: decoded PCM waves run ~100-500 KB each (same sizing + // as DatSoundCache's payload LRU, which this cache re-uploads from). 48 + // MiB gives comfortable headroom for the working set of a play session + // (roughly 100-480 resident buffers) while keeping the native AL-side + // copy from growing without bound over a long-uptime process (the + // 30-bot headless-fleet target). + internal const long DefaultBufferByteBudget = 48L * 1024 * 1024; // 48 MiB private readonly Dictionary _bufferByWaveId = new(); + private readonly AlBufferBudgetTracker _bufferBudget = new(DefaultBufferByteBudget); // ── Ambient handles (StartAmbient/StopAmbient) ─────────────────────────── private readonly Dictionary _ambientSources = new(); @@ -89,6 +102,12 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine public float AmbientVolume{ get; set; } = 0.8f; public bool IsAvailable => _available; + /// Estimated bytes currently resident in the AL buffer cache. Diagnostic use only. + public long ResidentBufferBytes => _bufferBudget.ResidentBytes; + + /// Number of AL buffers currently resident. Diagnostic use only. + public int ResidentBufferCount => _bufferBudget.Count; + public OpenAlAudioEngine() : this(new SilkOpenAlResourceApiFactory()) { @@ -335,7 +354,14 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine private uint EnsureBuffer(uint waveId, WaveData wave) { if (!_available || _al is null) return 0; - if (_bufferByWaveId.TryGetValue(waveId, out var existing)) return existing; + if (_bufferByWaveId.TryGetValue(waveId, out var existing)) + { + // Buffer id 0 is the "unsupported format" negative marker — no + // payload, not tracked by the budget, nothing to touch. + if (existing != 0) + _bufferBudget.Touch(waveId); + return existing; + } uint buf = _al.GenBuffer(); _resources!.OwnBuffer(buf); @@ -351,9 +377,80 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine _al.BufferData(buf, fmt, p, wave.PcmBytes.Length, wave.SampleRate); _bufferByWaveId[waveId] = buf; + _bufferBudget.RecordCreated(waveId, buf, wave.PcmBytes.Length); + // The buffer we just created is protected for this call: the + // caller hasn't attached it to a source yet, so live AL state + // would (wrongly) report it as evictable. + EvictBuffersOverBudget(protectedBufferId: buf); return buf; } + /// + /// Evict least-recently-used AL buffers until the resident-byte budget + /// is satisfied again. A buffer still bound to a live source (3D pool, + /// UI pool, or an ambient source) is protected — alDeleteBuffers + /// fails on a buffer that's still attached to a source — so eviction + /// never targets one; nor does it target , + /// the buffer just created for this call and + /// hasn't attached to a source yet. If every resident buffer is + /// protected the budget is temporarily exceeded rather than looping + /// forever; the bounded pool sizes (16 + 4 + ambient) cap how large + /// that overage can get. Evicted waves replay through + /// again on next use — re-upload from + /// , identical to a first play. + /// + private void EvictBuffersOverBudget(uint protectedBufferId) + { + while (_bufferBudget.ResidentBytes > _bufferBudget.MaxBytes) + { + bool IsProtected(uint bufferId) => + bufferId == protectedBufferId || IsBufferAttachedToAnySource(bufferId); + + if (!_bufferBudget.TryEvictOldestUnprotected( + IsProtected, out uint evictedWaveId, out uint evictedBufferId)) + { + break; + } + + _bufferByWaveId.Remove(evictedWaveId); + _resources!.ReleaseBuffer(evictedBufferId); + } + } + + /// + /// True when is currently bound to any pool + /// source. Queried live from AL (AL_BUFFER on each source) + /// rather than tracked locally: AL's per-source state is the only + /// thing that actually determines whether alDeleteBuffers would + /// fail, and several call sites (, + /// ) set a source's buffer directly, so a + /// second local copy would be one more place to keep in sync. + /// + private bool IsBufferAttachedToAnySource(uint bufferId) + { + if (_al is null) return false; + + for (int i = 0; i < PoolSize3D; i++) + { + if (IsSourceBoundTo(_pool3D[i].SourceId, bufferId)) return true; + } + for (int i = 0; i < PoolSizeUi; i++) + { + if (IsSourceBoundTo(_poolUi[i], bufferId)) return true; + } + foreach (uint sourceId in _ambientSources.Values) + { + if (IsSourceBoundTo(sourceId, bufferId)) return true; + } + return false; + } + + private bool IsSourceBoundTo(uint sourceId, uint bufferId) + { + _al!.GetSourceProperty(sourceId, GetSourceInteger.Buffer, out int attached); + return (uint)attached == bufferId; + } + private static BufferFormat PickFormat(WaveData w) { return (w.ChannelCount, w.BitsPerSample) switch diff --git a/src/AcDream.Core/Audio/DatSoundCache.cs b/src/AcDream.Core/Audio/DatSoundCache.cs index 92015f6e..188720a9 100644 --- a/src/AcDream.Core/Audio/DatSoundCache.cs +++ b/src/AcDream.Core/Audio/DatSoundCache.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Threading; using DatReaderWriter; using DatReaderWriter.DBObjs; using AcDream.Core.Content; @@ -12,24 +14,84 @@ namespace AcDream.Core.Audio; /// /// Lazy-loads each Wave / SoundTable on first request, decodes Wave PCM /// data via , and memoizes the result. Safe to -/// call from any thread — the inner dicts are -/// . +/// call from any thread. /// /// /// -/// Returns null for waves whose format tag isn't PCM (MP3 / ADPCM -/// fall through silently until a compressed-decoder path is wired up). +/// Decoded-wave residency is bounded by a byte budget +/// (, 32 MiB) enforced with an LRU. This +/// mirrors AcDream.Content.BoundedDatObjectCache / +/// DecodedTextureCache's shape but is reimplemented locally: Code +/// Structure Rule 2 forbids AcDream.Core from referencing +/// AcDream.Content. Decoded PCM waves run roughly 100-500 KB each +/// (a few seconds of 16-bit mono/stereo audio at typical AC sample +/// rates), so a 32 MiB budget comfortably holds 64-320 resident waves — +/// the working set for a play session — without growing without bound +/// over a long-uptime process (the 30-bot headless-fleet target cares +/// about exactly this). +/// +/// +/// +/// Waves that can't be served (missing from the dats, or a format +/// doesn't support yet — MP3 / ADPCM currently +/// decode to null) are remembered in a separate negative-result set that +/// is intentionally left unbounded: a bare wave id costs a handful of +/// bytes, and the id space is finite (the Wave dat range holds on the +/// order of a few thousand entries in retail), so it can never grow +/// without bound and it never competes with the payload LRU for budget +/// or gets evicted by it. +/// +/// +/// +/// Thread-safety: may be called concurrently. +/// Concurrent first-touch requests for the same wave id share one +/// call instead of racing duplicate +/// decode work. /// /// public sealed class DatSoundCache { + /// + /// Byte budget for resident decoded-wave payloads. See the class + /// remarks for the ~100-500 KB per-wave sizing rationale. + /// + internal const long DefaultMaxWaveBytes = 32L * 1024 * 1024; // 32 MiB + private readonly IDatObjectSource _dats; - private readonly ConcurrentDictionary _waves = new(); private readonly ConcurrentDictionary _tables = new(); + // Negative-result memo (missing wave / unsupported format). Unbounded + // by design — see class remarks — and never evicted by the payload LRU. + private readonly ConcurrentDictionary _negativeWaveIds = new(); + + // Dedupes concurrent first-touch decodes of the same wave id so two + // racing callers don't both pay for WaveDecoder.Decode. + private readonly ConcurrentDictionary> _inflight = new(); + + // Payload LRU: resident decoded waves, bounded by _maxWaveBytes. + private readonly object _gate = new(); + private readonly Dictionary> _waveEntries = new(); + private readonly LinkedList _waveLru = new(); + private readonly long _maxWaveBytes; + private long _residentWaveBytes; + + private sealed record WaveEntry(uint WaveId, WaveData Wave, long Bytes); + public DatSoundCache(IDatObjectSource dats) + : this(dats, DefaultMaxWaveBytes) { + } + + /// + /// Test seam for pinning a smaller byte budget so eviction can be + /// exercised deterministically without allocating tens of megabytes. + /// + internal DatSoundCache(IDatObjectSource dats, long maxWaveBytes) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentOutOfRangeException.ThrowIfLessThan(maxWaveBytes, 1); _dats = dats; + _maxWaveBytes = maxWaveBytes; } /// @@ -39,18 +101,38 @@ public sealed class DatSoundCache /// public WaveData? GetWave(uint waveId) { - if (_waves.TryGetValue(waveId, out var cached)) return cached; - - var wave = _dats.Get(waveId); - if (wave is null) + lock (_gate) { - _waves[waveId] = null; - return null; + if (_waveEntries.TryGetValue(waveId, out var node)) + { + Touch(node); + return node.Value.Wave; + } } - var decoded = WaveDecoder.Decode(wave.Header, wave.Data); - _waves[waveId] = decoded; - return decoded; + if (_negativeWaveIds.ContainsKey(waveId)) + return null; + + Lazy lazy = _inflight.GetOrAdd( + waveId, + static (id, self) => new Lazy( + () => self.DecodeUncached(id), + LazyThreadSafetyMode.ExecutionAndPublication), + this); + try + { + WaveData? decoded = lazy.Value; + if (decoded is null) + { + _negativeWaveIds.TryAdd(waveId, 0); + return null; + } + return Admit(waveId, decoded); + } + finally + { + _inflight.TryRemove(new KeyValuePair>(waveId, lazy)); + } } /// @@ -67,13 +149,81 @@ public sealed class DatSoundCache } /// - /// Total number of waves that have been accessed (hit or miss). - /// Useful for diagnostic overlays. + /// Number of decoded waves currently resident in the payload LRU. + /// Diagnostic use only. /// - public int CachedWaveCount => _waves.Count; + public int CachedWaveCount + { + get { lock (_gate) return _waveEntries.Count; } + } + + /// + /// Estimated bytes currently resident in the payload LRU. Diagnostic + /// use only. + /// + public long ResidentWaveBytes + { + get { lock (_gate) return _residentWaveBytes; } + } /// /// Total number of SoundTables that have been accessed. /// public int CachedSoundTableCount => _tables.Count; + + private WaveData? DecodeUncached(uint waveId) + { + var wave = _dats.Get(waveId); + if (wave is null) return null; + return WaveDecoder.Decode(wave.Header, wave.Data); + } + + private WaveData Admit(uint waveId, WaveData wave) + { + long bytes = Math.Max(1L, (long)wave.PcmBytes.Length); + lock (_gate) + { + // Another caller may have already admitted this id (both + // shared the same in-flight decode and both reach Admit). + if (_waveEntries.TryGetValue(waveId, out var existing)) + { + Touch(existing); + return existing.Value.Wave; + } + + if (bytes > _maxWaveBytes) + { + // Larger than the entire budget: serve it but don't retain + // it — matches BoundedDatObjectCache's oversize handling. + return wave; + } + + while (_waveLru.First is { } oldest + && _residentWaveBytes + bytes > _maxWaveBytes) + { + Evict(oldest); + } + + var node = _waveLru.AddLast(new WaveEntry(waveId, wave, bytes)); + _waveEntries[waveId] = node; + _residentWaveBytes += bytes; + return wave; + } + } + + private void Touch(LinkedListNode node) + { + if (!ReferenceEquals(node, _waveLru.Last)) + { + _waveLru.Remove(node); + _waveLru.AddLast(node); + } + } + + private void Evict(LinkedListNode node) + { + _waveLru.Remove(node); + _waveEntries.Remove(node.Value.WaveId); + _residentWaveBytes -= node.Value.Bytes; + } } diff --git a/tests/AcDream.App.Tests/Audio/AlBufferBudgetTrackerTests.cs b/tests/AcDream.App.Tests/Audio/AlBufferBudgetTrackerTests.cs new file mode 100644 index 00000000..2029b997 --- /dev/null +++ b/tests/AcDream.App.Tests/Audio/AlBufferBudgetTrackerTests.cs @@ -0,0 +1,152 @@ +using AcDream.App.Audio; + +namespace AcDream.App.Tests.Audio; + +/// +/// Pure LRU/byte-budget decision logic backing +/// 's bounded AL buffer cache (verified- +/// unbounded audit, 2026-07-24). The engine's actual AL calls +/// (alGenBuffers, alDeleteBuffers, source binding) aren't +/// unit-testable headless — OpenAlResourceLifetimeTests' fake +/// IOpenAlResourceApi deliberately exposes a null AL, which +/// short-circuits every native buffer call before it runs. This suite +/// covers the pure eviction/budget decision that was extracted specifically +/// so it stays testable without a live OpenAL device. +/// +public sealed class AlBufferBudgetTrackerTests +{ + [Fact] + public void RecordCreated_TracksResidentBytesAndCount() + { + var tracker = new AlBufferBudgetTracker(maxBytes: 1000); + + tracker.RecordCreated(waveId: 1, bufferId: 101, bytes: 200); + tracker.RecordCreated(waveId: 2, bufferId: 102, bytes: 300); + + Assert.Equal(500, tracker.ResidentBytes); + Assert.Equal(2, tracker.Count); + Assert.True(tracker.TryGetBufferId(1, out uint buf1)); + Assert.Equal(101u, buf1); + Assert.True(tracker.TryGetBufferId(2, out uint buf2)); + Assert.Equal(102u, buf2); + } + + [Fact] + public void TryGetBufferId_UnknownWaveId_ReturnsFalse() + { + var tracker = new AlBufferBudgetTracker(maxBytes: 1000); + + Assert.False(tracker.TryGetBufferId(42, out uint bufferId)); + Assert.Equal(0u, bufferId); + } + + [Fact] + public void TryEvictOldestUnprotected_PicksLeastRecentlyUsedEntry() + { + var tracker = new AlBufferBudgetTracker(maxBytes: 1000); + tracker.RecordCreated(1, 101, 100); // oldest + tracker.RecordCreated(2, 102, 100); + tracker.RecordCreated(3, 103, 100); // newest + + bool evicted = tracker.TryEvictOldestUnprotected( + static _ => false, out uint waveId, out uint bufferId); + + Assert.True(evicted); + Assert.Equal(1u, waveId); + Assert.Equal(101u, bufferId); + Assert.Equal(200, tracker.ResidentBytes); + Assert.Equal(2, tracker.Count); + Assert.False(tracker.TryGetBufferId(1, out _)); + } + + [Fact] + public void Touch_MovesEntryToMostRecentlyUsed() + { + var tracker = new AlBufferBudgetTracker(maxBytes: 1000); + tracker.RecordCreated(1, 101, 100); + tracker.RecordCreated(2, 102, 100); + tracker.RecordCreated(3, 103, 100); + + // Without a touch, wave 1 (buffer 101) is oldest and would be evicted. + tracker.Touch(1); + + bool evicted = tracker.TryEvictOldestUnprotected( + static _ => false, out uint waveId, out _); + + Assert.True(evicted); + Assert.Equal(2u, waveId); // 2 is now the least-recently-used, not 1 + } + + [Fact] + public void Touch_UnknownWaveId_IsNoOp() + { + var tracker = new AlBufferBudgetTracker(maxBytes: 1000); + tracker.RecordCreated(1, 101, 100); + + tracker.Touch(999); // never recorded — must not throw or create an entry + + Assert.Equal(1, tracker.Count); + } + + [Fact] + public void TryEvictOldestUnprotected_SkipsProtectedEntriesAndPicksNextOldest() + { + var tracker = new AlBufferBudgetTracker(maxBytes: 1000); + tracker.RecordCreated(1, 101, 100); // oldest, but protected (attached to a live source) + tracker.RecordCreated(2, 102, 100); // next-oldest, unprotected — should be picked + tracker.RecordCreated(3, 103, 100); // newest + + bool evicted = tracker.TryEvictOldestUnprotected( + bufferId => bufferId == 101, out uint waveId, out uint bufferIdOut); + + Assert.True(evicted); + Assert.Equal(2u, waveId); + Assert.Equal(102u, bufferIdOut); + Assert.True(tracker.TryGetBufferId(1, out _)); // still resident — protected + } + + [Fact] + public void TryEvictOldestUnprotected_EverythingProtected_ReturnsFalseAndChangesNothing() + { + var tracker = new AlBufferBudgetTracker(maxBytes: 1000); + tracker.RecordCreated(1, 101, 100); + tracker.RecordCreated(2, 102, 100); + + bool evicted = tracker.TryEvictOldestUnprotected( + static _ => true, out uint waveId, out uint bufferId); + + Assert.False(evicted); + Assert.Equal(0u, waveId); + Assert.Equal(0u, bufferId); + Assert.Equal(200, tracker.ResidentBytes); + Assert.Equal(2, tracker.Count); + } + + [Fact] + public void TryEvictOldestUnprotected_EmptyTracker_ReturnsFalse() + { + var tracker = new AlBufferBudgetTracker(maxBytes: 1000); + + bool evicted = tracker.TryEvictOldestUnprotected( + static _ => false, out _, out _); + + Assert.False(evicted); + } + + [Fact] + public void RecordCreated_ZeroOrNegativeBytes_IsChargedAtLeastOneByte() + { + var tracker = new AlBufferBudgetTracker(maxBytes: 1000); + + tracker.RecordCreated(1, 101, bytes: 0); + + Assert.Equal(1, tracker.ResidentBytes); + } + + [Fact] + public void Constructor_RejectsNonPositiveBudget() + { + Assert.Throws(() => new AlBufferBudgetTracker(0)); + Assert.Throws(() => new AlBufferBudgetTracker(-1)); + } +} diff --git a/tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs b/tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs new file mode 100644 index 00000000..7c7054db --- /dev/null +++ b/tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs @@ -0,0 +1,237 @@ +using System.Collections.Concurrent; +using AcDream.Core.Audio; +using AcDream.Core.Content; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Lib.IO; +using System.Diagnostics.CodeAnalysis; +using Xunit; + +namespace AcDream.Core.Tests.Audio; + +/// +/// Covers the payload-LRU eviction added to bound DatSoundCache's +/// decoded-wave residency (verified-unbounded audit, 2026-07-24): eviction +/// order, byte accounting, negative-result (missing/unsupported) handling, +/// and a concurrency smoke test. +/// +public sealed class DatSoundCacheTests +{ + [Fact] + public void GetWave_DecodesAndReturnsPcmWave() + { + var dats = new FakeDatObjectSource(); + dats.AddWave(1, MakePcmWave(1, dataBytes: 100)); + var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); + + WaveData? wave = cache.GetWave(1); + + Assert.NotNull(wave); + Assert.Equal(100, wave!.PcmBytes.Length); + Assert.Equal(1, cache.CachedWaveCount); + Assert.Equal(100, cache.ResidentWaveBytes); + } + + [Fact] + public void GetWave_MissingWave_ReturnsNullAndMemoizesNegativeWithoutRepeatedLookup() + { + var dats = new FakeDatObjectSource(); // no wave registered for id 7 + var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); + + Assert.Null(cache.GetWave(7)); + Assert.Null(cache.GetWave(7)); + Assert.Null(cache.GetWave(7)); + + // The negative-result memo short-circuits GetWave before it ever + // reaches the dat lookup a second time. + Assert.Equal(1, dats.GetCallCount(7)); + Assert.Equal(0, cache.CachedWaveCount); + Assert.Equal(0, cache.ResidentWaveBytes); + } + + [Fact] + public void GetWave_UnsupportedFormat_ReturnsNullAndDoesNotConsumeByteBudget() + { + var dats = new FakeDatObjectSource(); + dats.AddWave(2, MakeMp3Wave(2)); // WaveDecoder.Decode returns null for MP3 + var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); + + Assert.Null(cache.GetWave(2)); + Assert.Equal(0, cache.CachedWaveCount); + Assert.Equal(0, cache.ResidentWaveBytes); + + // Second call hits the negative memo, not a fresh decode of the dat entry. + Assert.Null(cache.GetWave(2)); + Assert.Equal(1, dats.GetCallCount(2)); + } + + [Fact] + public void GetWave_CacheHit_ReturnsSameInstanceWithoutRedecoding() + { + var dats = new FakeDatObjectSource(); + dats.AddWave(1, MakePcmWave(1, dataBytes: 100)); + var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); + + WaveData? first = cache.GetWave(1); + WaveData? second = cache.GetWave(1); + + Assert.Same(first, second); + Assert.Equal(1, dats.GetCallCount(1)); + } + + [Fact] + public void GetWave_EvictsLeastRecentlyUsedWhenOverBudget() + { + var dats = new FakeDatObjectSource(); + dats.AddWave(1, MakePcmWave(1, dataBytes: 150)); // A + dats.AddWave(2, MakePcmWave(2, dataBytes: 150)); // B + dats.AddWave(3, MakePcmWave(3, dataBytes: 150)); // C + var cache = new DatSoundCache(dats, maxWaveBytes: 300); + + WaveData? a1 = cache.GetWave(1); + WaveData? b1 = cache.GetWave(2); + Assert.Equal(2, cache.CachedWaveCount); + Assert.Equal(300, cache.ResidentWaveBytes); // exactly at budget, no eviction yet + + // Touch A so B becomes the least-recently-used entry. + Assert.Same(a1, cache.GetWave(1)); + + // Admitting C (150) would push residency to 450 > 300 — B must go. + WaveData? c1 = cache.GetWave(3); + Assert.Equal(2, cache.CachedWaveCount); + Assert.Equal(300, cache.ResidentWaveBytes); + + // A and C are still resident (no re-decode); B was evicted (re-decodes on next use). + Assert.Same(a1, cache.GetWave(1)); + Assert.Equal(1, dats.GetCallCount(1)); + Assert.Same(c1, cache.GetWave(3)); + Assert.Equal(1, dats.GetCallCount(3)); + + WaveData? b2 = cache.GetWave(2); + Assert.NotNull(b2); + Assert.NotSame(b1, b2); // evicted entries decode fresh on next request + Assert.Equal(2, dats.GetCallCount(2)); + } + + [Fact] + public void GetWave_OversizeSingleWave_IsServedButNotRetained() + { + var dats = new FakeDatObjectSource(); + dats.AddWave(9, MakePcmWave(9, dataBytes: 500)); + var cache = new DatSoundCache(dats, maxWaveBytes: 100); // budget smaller than the wave itself + + WaveData? first = cache.GetWave(9); + Assert.NotNull(first); + Assert.Equal(0, cache.CachedWaveCount); + Assert.Equal(0, cache.ResidentWaveBytes); + + WaveData? second = cache.GetWave(9); + Assert.NotNull(second); + Assert.NotSame(first, second); // not retained, so this re-decoded + Assert.Equal(2, dats.GetCallCount(9)); + } + + [Fact] + public void GetWave_ConcurrentSameId_PublishesOneCanonicalWaveAndDecodesOnce() + { + var dats = new FakeDatObjectSource(); + dats.AddWave(1, MakePcmWave(1, dataBytes: 1000)); + var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); + + var results = new ConcurrentBag(); + Parallel.For(0, 200, _ => results.Add(cache.GetWave(1))); + + WaveData?[] all = results.ToArray(); + Assert.All(all, Assert.NotNull); + WaveData canonical = all[0]!; + Assert.All(all, value => Assert.Same(canonical, value)); + Assert.Equal(1, dats.GetCallCount(1)); + } + + [Fact] + public void GetWave_ConcurrentMissingId_NeverThrowsAndAllReturnNull() + { + var dats = new FakeDatObjectSource(); // id 5 never registered + var cache = new DatSoundCache(dats, maxWaveBytes: 10_000); + + var results = new ConcurrentBag(); + Parallel.For(0, 200, _ => results.Add(cache.GetWave(5))); + + Assert.All(results, Assert.Null); + Assert.Equal(0, cache.CachedWaveCount); + } + + // ── Test fixtures ──────────────────────────────────────────────────── + + private static byte[] MakePcmHeader(ushort channels = 1, uint rate = 22050, ushort bits = 16) + { + byte[] h = new byte[18]; + h[0] = 0x01; h[1] = 0x00; // fmtTag PCM + h[2] = (byte)channels; h[3] = (byte)(channels >> 8); + h[4] = (byte)rate; h[5] = (byte)(rate >> 8); + h[6] = (byte)(rate >> 16); h[7] = (byte)(rate >> 24); + uint avg = rate * channels * (uint)(bits / 8); + h[8] = (byte)avg; h[9] = (byte)(avg >> 8); + h[10] = (byte)(avg >> 16); h[11] = (byte)(avg >> 24); + ushort blockAlign = (ushort)(channels * (bits / 8)); + h[12] = (byte)blockAlign; h[13] = (byte)(blockAlign >> 8); + h[14] = (byte)bits; h[15] = (byte)(bits >> 8); + return h; + } + + private static Wave MakePcmWave(uint id, int dataBytes) => new() + { + Id = id, + Header = MakePcmHeader(), + Data = new byte[dataBytes], + }; + + private static Wave MakeMp3Wave(uint id) => new() + { + Id = id, + Header = new byte[18] { 0x55, 0x00, 0x02, 0x00, 0x44, 0xAC, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + Data = new byte[1024], + }; + + private sealed class FakeDatObjectSource : IDatObjectSource + { + private readonly Dictionary _waves = new(); + private readonly Dictionary _callCounts = new(); + private readonly object _gate = new(); + + public void AddWave(uint id, Wave wave) => _waves[id] = wave; + + public int GetCallCount(uint id) + { + lock (_gate) + return _callCounts.TryGetValue(id, out int count) ? count : 0; + } + + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj + { + if (typeof(T) == typeof(Wave)) + { + lock (_gate) + { + _callCounts.TryGetValue(fileId, out int count); + _callCounts[fileId] = count + 1; + } + + return _waves.TryGetValue(fileId, out var wave) ? (T)(object)wave : default; + } + return default; + } + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj + { + T? result = Get(fileId); + if (result is null) + { + value = default!; + return false; + } + value = result; + return true; + } + } +}