301 lines
9.9 KiB
C#
301 lines
9.9 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using AcDream.Core.Content;
|
|
|
|
namespace AcDream.Core.Audio;
|
|
|
|
public readonly record struct DatSoundCacheDiagnostics(
|
|
int CachedWaveCount,
|
|
long ResidentWaveBytes,
|
|
long BudgetBytes,
|
|
long Hits,
|
|
long Misses,
|
|
long Evictions);
|
|
|
|
/// <summary>
|
|
/// DatCollection-backed cache of decoded waves + SoundTable lookups.
|
|
///
|
|
/// <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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// 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, 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 readonly Action<uint>? _afterInitialWaveMiss;
|
|
private long _residentWaveBytes;
|
|
private long _hits;
|
|
private long _misses;
|
|
private long _evictions;
|
|
|
|
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>
|
|
public DatSoundCache(IDatObjectSource dats, long maxWaveBytes)
|
|
: this(dats, maxWaveBytes, afterInitialWaveMiss: null)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deterministic concurrency seam used to park a caller after its fast
|
|
/// resident lookup but before the authoritative locked recheck.
|
|
/// </summary>
|
|
internal DatSoundCache(
|
|
IDatObjectSource dats,
|
|
long maxWaveBytes,
|
|
Action<uint>? afterInitialWaveMiss)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(dats);
|
|
ArgumentOutOfRangeException.ThrowIfLessThan(maxWaveBytes, 1);
|
|
_dats = dats;
|
|
_maxWaveBytes = maxWaveBytes;
|
|
_afterInitialWaveMiss = afterInitialWaveMiss;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieve decoded PCM data for a Wave dat id. Returns null if the
|
|
/// wave is missing, malformed, or uses a currently-unsupported format
|
|
/// (MP3 / ADPCM).
|
|
/// </summary>
|
|
public WaveData? GetWave(uint waveId)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_waveEntries.TryGetValue(waveId, out var node))
|
|
{
|
|
Interlocked.Increment(ref _hits);
|
|
Touch(node);
|
|
return node.Value.Wave;
|
|
}
|
|
}
|
|
|
|
if (_negativeWaveIds.ContainsKey(waveId))
|
|
{
|
|
Interlocked.Increment(ref _hits);
|
|
return null;
|
|
}
|
|
|
|
_afterInitialWaveMiss?.Invoke(waveId);
|
|
|
|
Lazy<WaveData?> lazy;
|
|
lock (_gate)
|
|
{
|
|
// A caller can pause after the fast miss while another caller
|
|
// decodes, admits, and removes the in-flight Lazy. Recheck the
|
|
// authoritative caches and acquire/create the Lazy under the same
|
|
// gate used by admission so that stale caller cannot start a
|
|
// second decode after publication.
|
|
if (_waveEntries.TryGetValue(waveId, out var node))
|
|
{
|
|
Interlocked.Increment(ref _hits);
|
|
Touch(node);
|
|
return node.Value.Wave;
|
|
}
|
|
|
|
if (_negativeWaveIds.ContainsKey(waveId))
|
|
{
|
|
Interlocked.Increment(ref _hits);
|
|
return null;
|
|
}
|
|
|
|
Interlocked.Increment(ref _misses);
|
|
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>
|
|
/// Retrieve a SoundTable by dat id. Returns null if the table is
|
|
/// missing from the dats.
|
|
/// </summary>
|
|
public SoundTable? GetSoundTable(uint soundTableId)
|
|
{
|
|
if (_tables.TryGetValue(soundTableId, out var cached)) return cached;
|
|
|
|
var table = _dats.Get<SoundTable>(soundTableId);
|
|
_tables[soundTableId] = table;
|
|
return table;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Number of decoded waves currently resident in the payload LRU.
|
|
/// Diagnostic use only.
|
|
/// </summary>
|
|
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;
|
|
|
|
public DatSoundCacheDiagnostics Diagnostics
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return new DatSoundCacheDiagnostics(
|
|
_waveEntries.Count,
|
|
_residentWaveBytes,
|
|
_maxWaveBytes,
|
|
Interlocked.Read(ref _hits),
|
|
Interlocked.Read(ref _misses),
|
|
Interlocked.Read(ref _evictions));
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
Interlocked.Increment(ref _evictions);
|
|
}
|
|
}
|