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
122
src/AcDream.App/Audio/AlBufferBudgetTracker.cs
Normal file
122
src/AcDream.App/Audio/AlBufferBudgetTracker.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AcDream.App.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure LRU/byte-budget bookkeeping for <see cref="OpenAlAudioEngine"/>'s
|
||||||
|
/// native AL buffer cache (<c>_bufferByWaveId</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deliberately has no AL/Silk dependency. <see cref="OpenAlAudioEngine"/>
|
||||||
|
/// 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 <c>alDeleteBuffers</c> would fail, so the engine
|
||||||
|
/// queries it live via <c>AL.GetSourceProperty</c> 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.
|
||||||
|
/// </remarks>
|
||||||
|
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<uint, Entry> _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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Record a freshly-allocated, freshly-uploaded buffer as resident and
|
||||||
|
/// most-recently-used.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bump an already-resident buffer's LRU order — call on every replay
|
||||||
|
/// (cache hit), not just on first creation.
|
||||||
|
/// </summary>
|
||||||
|
public void Touch(uint waveId)
|
||||||
|
{
|
||||||
|
if (_byWaveId.TryGetValue(waveId, out var entry))
|
||||||
|
entry.LastUseTick = ++_tick;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evict the single least-recently-used resident entry whose buffer id
|
||||||
|
/// is NOT reported as protected by <paramref name="isProtected"/>
|
||||||
|
/// (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.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryEvictOldestUnprotected(
|
||||||
|
Func<uint, bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,12 @@ namespace AcDream.App.Audio;
|
||||||
/// </description></item>
|
/// </description></item>
|
||||||
/// <item><description>
|
/// <item><description>
|
||||||
/// PCM buffer cache keyed by Wave dat id so the same footstep isn't
|
/// 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 (<see cref="DefaultBufferByteBudget"/>) enforced
|
||||||
|
/// with LRU eviction — see <see cref="EvictBuffersOverBudget"/>. 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.
|
||||||
/// </description></item>
|
/// </description></item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </para>
|
/// </para>
|
||||||
|
|
@ -76,7 +81,15 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
private readonly uint[] _poolUi = new uint[PoolSizeUi];
|
private readonly uint[] _poolUi = new uint[PoolSizeUi];
|
||||||
|
|
||||||
// ── Buffer cache (Wave dat id → AL buffer) ───────────────────────────────
|
// ── 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<uint, uint> _bufferByWaveId = new();
|
private readonly Dictionary<uint, uint> _bufferByWaveId = new();
|
||||||
|
private readonly AlBufferBudgetTracker _bufferBudget = new(DefaultBufferByteBudget);
|
||||||
|
|
||||||
// ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
|
// ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
|
||||||
private readonly Dictionary<int, uint> _ambientSources = new();
|
private readonly Dictionary<int, uint> _ambientSources = new();
|
||||||
|
|
@ -89,6 +102,12 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
public float AmbientVolume{ get; set; } = 0.8f;
|
public float AmbientVolume{ get; set; } = 0.8f;
|
||||||
public bool IsAvailable => _available;
|
public bool IsAvailable => _available;
|
||||||
|
|
||||||
|
/// <summary>Estimated bytes currently resident in the AL buffer cache. Diagnostic use only.</summary>
|
||||||
|
public long ResidentBufferBytes => _bufferBudget.ResidentBytes;
|
||||||
|
|
||||||
|
/// <summary>Number of AL buffers currently resident. Diagnostic use only.</summary>
|
||||||
|
public int ResidentBufferCount => _bufferBudget.Count;
|
||||||
|
|
||||||
public OpenAlAudioEngine()
|
public OpenAlAudioEngine()
|
||||||
: this(new SilkOpenAlResourceApiFactory())
|
: this(new SilkOpenAlResourceApiFactory())
|
||||||
{
|
{
|
||||||
|
|
@ -335,7 +354,14 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
private uint EnsureBuffer(uint waveId, WaveData wave)
|
private uint EnsureBuffer(uint waveId, WaveData wave)
|
||||||
{
|
{
|
||||||
if (!_available || _al is null) return 0;
|
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();
|
uint buf = _al.GenBuffer();
|
||||||
_resources!.OwnBuffer(buf);
|
_resources!.OwnBuffer(buf);
|
||||||
|
|
@ -351,9 +377,80 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
_al.BufferData(buf, fmt, p, wave.PcmBytes.Length, wave.SampleRate);
|
_al.BufferData(buf, fmt, p, wave.PcmBytes.Length, wave.SampleRate);
|
||||||
|
|
||||||
_bufferByWaveId[waveId] = buf;
|
_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;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 — <c>alDeleteBuffers</c>
|
||||||
|
/// fails on a buffer that's still attached to a source — so eviction
|
||||||
|
/// never targets one; nor does it target <paramref name="protectedBufferId"/>,
|
||||||
|
/// the buffer <see cref="EnsureBuffer"/> 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
|
||||||
|
/// <see cref="EnsureBuffer"/> again on next use — re-upload from
|
||||||
|
/// <see cref="DatSoundCache"/>, identical to a first play.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="bufferId"/> is currently bound to any pool
|
||||||
|
/// source. Queried live from AL (<c>AL_BUFFER</c> on each source)
|
||||||
|
/// rather than tracked locally: AL's per-source state is the only
|
||||||
|
/// thing that actually determines whether <c>alDeleteBuffers</c> would
|
||||||
|
/// fail, and several call sites (<see cref="Play3DWave"/>,
|
||||||
|
/// <see cref="PlayUiWave"/>) set a source's buffer directly, so a
|
||||||
|
/// second local copy would be one more place to keep in sync.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private static BufferFormat PickFormat(WaveData w)
|
||||||
{
|
{
|
||||||
return (w.ChannelCount, w.BitsPerSample) switch
|
return (w.ChannelCount, w.BitsPerSample) switch
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using AcDream.Core.Content;
|
using AcDream.Core.Content;
|
||||||
|
|
@ -12,24 +14,84 @@ namespace AcDream.Core.Audio;
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Lazy-loads each Wave / SoundTable on first request, decodes Wave PCM
|
/// Lazy-loads each Wave / SoundTable on first request, decodes Wave PCM
|
||||||
/// data via <see cref="WaveDecoder"/>, and memoizes the result. Safe to
|
/// data via <see cref="WaveDecoder"/>, and memoizes the result. Safe to
|
||||||
/// call from any thread — the inner dicts are
|
/// call from any thread.
|
||||||
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Returns <c>null</c> for waves whose format tag isn't PCM (MP3 / ADPCM
|
/// Decoded-wave residency is bounded by a byte budget
|
||||||
/// fall through silently until a compressed-decoder path is wired up).
|
/// (<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>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DatSoundCache
|
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 IDatObjectSource _dats;
|
||||||
private readonly ConcurrentDictionary<uint, WaveData?> _waves = new();
|
|
||||||
private readonly ConcurrentDictionary<uint, SoundTable?> _tables = 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)
|
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;
|
_dats = dats;
|
||||||
|
_maxWaveBytes = maxWaveBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -39,18 +101,38 @@ public sealed class DatSoundCache
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WaveData? GetWave(uint waveId)
|
public WaveData? GetWave(uint waveId)
|
||||||
{
|
{
|
||||||
if (_waves.TryGetValue(waveId, out var cached)) return cached;
|
lock (_gate)
|
||||||
|
|
||||||
var wave = _dats.Get<Wave>(waveId);
|
|
||||||
if (wave is null)
|
|
||||||
{
|
{
|
||||||
_waves[waveId] = null;
|
if (_waveEntries.TryGetValue(waveId, out var node))
|
||||||
return null;
|
{
|
||||||
|
Touch(node);
|
||||||
|
return node.Value.Wave;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var decoded = WaveDecoder.Decode(wave.Header, wave.Data);
|
if (_negativeWaveIds.ContainsKey(waveId))
|
||||||
_waves[waveId] = decoded;
|
return null;
|
||||||
return decoded;
|
|
||||||
|
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>
|
/// <summary>
|
||||||
|
|
@ -67,13 +149,81 @@ public sealed class DatSoundCache
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Total number of waves that have been accessed (hit or miss).
|
/// Number of decoded waves currently resident in the payload LRU.
|
||||||
/// Useful for diagnostic overlays.
|
/// Diagnostic use only.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Total number of SoundTables that have been accessed.
|
/// Total number of SoundTables that have been accessed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int CachedSoundTableCount => _tables.Count;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
152
tests/AcDream.App.Tests/Audio/AlBufferBudgetTrackerTests.cs
Normal file
152
tests/AcDream.App.Tests/Audio/AlBufferBudgetTrackerTests.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
using AcDream.App.Audio;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure LRU/byte-budget decision logic backing
|
||||||
|
/// <see cref="OpenAlAudioEngine"/>'s bounded AL buffer cache (verified-
|
||||||
|
/// unbounded audit, 2026-07-24). The engine's actual AL calls
|
||||||
|
/// (<c>alGenBuffers</c>, <c>alDeleteBuffers</c>, source binding) aren't
|
||||||
|
/// unit-testable headless — <c>OpenAlResourceLifetimeTests</c>' fake
|
||||||
|
/// <c>IOpenAlResourceApi</c> deliberately exposes a null <c>AL</c>, 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.
|
||||||
|
/// </summary>
|
||||||
|
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<ArgumentOutOfRangeException>(() => new AlBufferBudgetTracker(0));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => new AlBufferBudgetTracker(-1));
|
||||||
|
}
|
||||||
|
}
|
||||||
237
tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs
Normal file
237
tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the payload-LRU eviction added to bound <c>DatSoundCache</c>'s
|
||||||
|
/// decoded-wave residency (verified-unbounded audit, 2026-07-24): eviction
|
||||||
|
/// order, byte accounting, negative-result (missing/unsupported) handling,
|
||||||
|
/// and a concurrency smoke test.
|
||||||
|
/// </summary>
|
||||||
|
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<WaveData?>();
|
||||||
|
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<WaveData?>();
|
||||||
|
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<uint, Wave> _waves = new();
|
||||||
|
private readonly Dictionary<uint, int> _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<T>(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<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||||
|
{
|
||||||
|
T? result = Get<T>(fileId);
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
value = default!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
value = result;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue