fix(audio): bound decoded-wave and AL-buffer caches (2026-07-24 audit review)
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<T> 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 <noreply@anthropic.com> (cherry picked from commit 76c880d35bcf30b50e3f7b4cb8635dc9ab9e3ec7)
This commit is contained in:
parent
60bc313917
commit
1e9031e7b7
5 changed files with 777 additions and 19 deletions
|
|
@ -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;
|
|||
/// <para>
|
||||
/// Lazy-loads each Wave / SoundTable on first request, decodes Wave PCM
|
||||
/// data via <see cref="WaveDecoder"/>, and memoizes the result. Safe to
|
||||
/// call from any thread — the inner dicts are
|
||||
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>.
|
||||
/// call from any thread.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Returns <c>null</c> 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
|
||||
/// (<see cref="DefaultMaxWaveBytes"/>, 32 MiB) enforced with an LRU. This
|
||||
/// mirrors <c>AcDream.Content.BoundedDatObjectCache</c> /
|
||||
/// <c>DecodedTextureCache</c>'s shape but is reimplemented locally: Code
|
||||
/// Structure Rule 2 forbids <c>AcDream.Core</c> from referencing
|
||||
/// <c>AcDream.Content</c>. 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).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Waves that can't be served (missing from the dats, or a format
|
||||
/// <see cref="WaveDecoder"/> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Thread-safety: <see cref="GetWave"/> may be called concurrently.
|
||||
/// Concurrent first-touch requests for the same wave id share one
|
||||
/// <see cref="WaveDecoder.Decode"/> call instead of racing duplicate
|
||||
/// decode work.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DatSoundCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Byte budget for resident decoded-wave payloads. See the class
|
||||
/// remarks for the ~100-500 KB per-wave sizing rationale.
|
||||
/// </summary>
|
||||
internal const long DefaultMaxWaveBytes = 32L * 1024 * 1024; // 32 MiB
|
||||
|
||||
private readonly IDatObjectSource _dats;
|
||||
private readonly ConcurrentDictionary<uint, WaveData?> _waves = new();
|
||||
private readonly ConcurrentDictionary<uint, SoundTable?> _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<uint, byte> _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<uint, Lazy<WaveData?>> _inflight = new();
|
||||
|
||||
// Payload LRU: resident decoded waves, bounded by _maxWaveBytes.
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<uint, LinkedListNode<WaveEntry>> _waveEntries = new();
|
||||
private readonly LinkedList<WaveEntry> _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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam for pinning a smaller byte budget so eviction can be
|
||||
/// exercised deterministically without allocating tens of megabytes.
|
||||
/// </summary>
|
||||
internal DatSoundCache(IDatObjectSource dats, long maxWaveBytes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maxWaveBytes, 1);
|
||||
_dats = dats;
|
||||
_maxWaveBytes = maxWaveBytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -39,18 +101,38 @@ public sealed class DatSoundCache
|
|||
/// </summary>
|
||||
public WaveData? GetWave(uint waveId)
|
||||
{
|
||||
if (_waves.TryGetValue(waveId, out var cached)) return cached;
|
||||
|
||||
var wave = _dats.Get<Wave>(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<WaveData?> lazy = _inflight.GetOrAdd(
|
||||
waveId,
|
||||
static (id, self) => new Lazy<WaveData?>(
|
||||
() => 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<uint, Lazy<WaveData?>>(waveId, lazy));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -67,13 +149,81 @@ public sealed class DatSoundCache
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int CachedWaveCount => _waves.Count;
|
||||
public int CachedWaveCount
|
||||
{
|
||||
get { lock (_gate) return _waveEntries.Count; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimated bytes currently resident in the payload LRU. Diagnostic
|
||||
/// use only.
|
||||
/// </summary>
|
||||
public long ResidentWaveBytes
|
||||
{
|
||||
get { lock (_gate) return _residentWaveBytes; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Total number of SoundTables that have been accessed.
|
||||
/// </summary>
|
||||
public int CachedSoundTableCount => _tables.Count;
|
||||
|
||||
private WaveData? DecodeUncached(uint waveId)
|
||||
{
|
||||
var wave = _dats.Get<Wave>(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<WaveEntry> node)
|
||||
{
|
||||
if (!ReferenceEquals(node, _waveLru.Last))
|
||||
{
|
||||
_waveLru.Remove(node);
|
||||
_waveLru.AddLast(node);
|
||||
}
|
||||
}
|
||||
|
||||
private void Evict(LinkedListNode<WaveEntry> node)
|
||||
{
|
||||
_waveLru.Remove(node);
|
||||
_waveEntries.Remove(node.Value.WaveId);
|
||||
_residentWaveBytes -= node.Value.Bytes;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue