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;
+ }
+ }
+}