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); /// /// DatCollection-backed cache of decoded waves + SoundTable lookups. /// /// /// Lazy-loads each Wave / SoundTable on first request, decodes Wave PCM /// data via , and memoizes the result. Safe to /// call from any thread. /// /// /// /// 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 _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 readonly Action? _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) { } /// /// Test seam for pinning a smaller byte budget so eviction can be /// exercised deterministically without allocating tens of megabytes. /// public DatSoundCache(IDatObjectSource dats, long maxWaveBytes) : this(dats, maxWaveBytes, afterInitialWaveMiss: null) { } /// /// Deterministic concurrency seam used to park a caller after its fast /// resident lookup but before the authoritative locked recheck. /// internal DatSoundCache( IDatObjectSource dats, long maxWaveBytes, Action? afterInitialWaveMiss) { ArgumentNullException.ThrowIfNull(dats); ArgumentOutOfRangeException.ThrowIfLessThan(maxWaveBytes, 1); _dats = dats; _maxWaveBytes = maxWaveBytes; _afterInitialWaveMiss = afterInitialWaveMiss; } /// /// 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). /// 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 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( () => 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)); } } /// /// Retrieve a SoundTable by dat id. Returns null if the table is /// missing from the dats. /// public SoundTable? GetSoundTable(uint soundTableId) { if (_tables.TryGetValue(soundTableId, out var cached)) return cached; var table = _dats.Get(soundTableId); _tables[soundTableId] = table; return table; } /// /// Number of decoded waves currently resident in the payload LRU. /// Diagnostic use only. /// 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; 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(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; Interlocked.Increment(ref _evictions); } }