acdream/src/AcDream.App/Audio/OpenAlAudioEngine.cs
Erik 2cf94dbcd2 feat(audio): Ctrl+M instant mute + inn-chatter investigation closed as server content
Two items from listening-gate round 2.

Mute: AcdreamToggleAudioMute (default Ctrl+M; bare M is selection) flips
OpenAlAudioEngine.Muted, implemented as the AL LISTENER gain - unused
since A2 moved all mixing to the CPU, so it is a free master switch that
silences already-playing voices instantly and restores them exactly,
without touching the retail mixing math, the -50 dB allocation cutoff, or
any persisted volume setting. Rebindable like every other action; console
line confirms each flip.

Inn chatter: the user hears talk-and-laughter ambience in retail inns and
not in acdream. Three installed-dat scans (pinned as conformance tests in
EnvCellSoundEmitterInventoryTests) prove the mechanism is NOT client
data: no interior static in the town landblock carries an ambient-slot
sound table, no Setup among all 5,935 in the portal dat references one,
and yet 23 sound tables carrying ONLY Ambient1..8 slots exist - pure
soundscape banks with nothing client-side pointing at them. They are
wire-bound: the server attaches one to an emitter object via
CreateObject's sound-table field and fires the slots over 0xF750 - ACE
implements exactly this (EmoteType.Sound heartbeat emotes ->
GameMessageSound broadcast). Our 0xF750 receiver (slice A3) is live and
now instrumented (ACDREAM_PROBE_SOUND_WIRE=1, via the new
AudioDiagnostics owner per Code Structure Rule 5, with per-event drop
reasons in AudioHookSink.PlayServerSound). A probed session against the
local ACE received ZERO 0xF750 events across a town walkabout: the
silence is server world-content (no emitters configured/firing), not a
client drop. The first scan's assertion originally encoded the
emitter-object hypothesis; the data refuted it, and the test now pins the
negative so the conclusion cannot silently rot.

Full Release suite green (the one failure during development was the
hypothesis-pinning assertion, corrected to pin the finding).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:09:24 +02:00

718 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Audio;
using Silk.NET.OpenAL;
namespace AcDream.App.Audio;
/// <summary>
/// OpenAL-backed audio engine. Spatialization is NOT OpenAL's: retail creates
/// every gameplay buffer 2D (<c>m_3D = 0</c>) and computes a gain and a stereo
/// pan on the CPU per voice, so <see cref="RetailSoundMixer"/> owns that math
/// and AL is reduced to a voice bank. Every source is source-relative with
/// <c>AL_ROLLOFF_FACTOR = 0</c>.
///
/// <para>
/// Architecture:
/// <list type="bullet">
/// <item><description>
/// Single <see cref="ALContext"/> + <see cref="AL"/> bound to the
/// system default device. Cross-platform (WASAPI / WinMM /
/// PulseAudio / CoreAudio — whichever OpenAL-Soft picks).
/// </description></item>
/// <item><description>
/// Fixed 16-source pool for world sounds, allocated by
/// <see cref="RetailVoicePool"/>: a ring scan for a free or finished slot,
/// then eviction of the first slot whose DAT-authored priority is strictly
/// lower, else the sound is dropped. Retail's allocator is
/// <c>SoundManager::PlaySoundInternal</c> @ <c>0x0054FEC0</c> and it never
/// consults gain. (This comment previously cited <c>FUN_00550ad0</c> and
/// described gain-based eviction; that address is inside an
/// <c>IntrusiveHashTable</c> constructor and the behaviour was ours, not
/// retail's — both corrected in Campaign A slice A2, register row AP-28.)
/// </description></item>
/// <item><description>
/// Separate UI source pool (4 sources) for flat 2D UI clicks /
/// wooshes — not subject to the world pool's eviction game.
/// </description></item>
/// <item><description>
/// 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. 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>
/// </list>
/// </para>
///
/// <para>
/// Thread-safety: the engine is called only from the render thread
/// (the same thread that drives <c>TickAnimations</c>). No locks inside.
/// </para>
///
/// <para>
/// Fail-open: when the OpenAL driver can't be initialised (missing
/// library on a headless CI box, or explicitly disabled via
/// <c>ACDREAM_NO_AUDIO=1</c>), <see cref="IsAvailable"/> is false and all
/// Play* calls are no-ops. This lets the rest of the client run
/// unaffected.
/// </para>
/// </summary>
internal interface IWorldAudioQuiescence
{
void SuspendWorldAudio();
void ResumeWorldAudio();
}
public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescence
{
// ── Backends ─────────────────────────────────────────────────────────────
private AL? _al;
private OpenAlResourceLifetime? _resources;
private bool _available;
private bool _disposed;
// ── Pools ────────────────────────────────────────────────────────────────
private const int PoolSize3D = 16; // retail 16-slot voice pool
private const int PoolSizeUi = 4;
// Slot state per 3D source, mirroring retail's `SoundPlayingData` —
// {buffer, priority, start_time}. There is no gain field: retail's
// allocator compares priority only, and its start_time is written but never
// read, so neither a gain nor a timestamp is carried here.
private sealed class Slot3D
{
public uint SourceId;
public uint OwnerId;
public bool InUse;
// The DAT-authored priority, a float in [0,1] — NOT an 0..7 int. 4,100
// of the shipped entries carry a sub-1.0 priority that an int cast
// collapsed to 0, which flattened the eviction ordering this field
// exists for. A2 makes eviction compare it.
public float Priority;
}
private readonly Slot3D[] _pool3D = CreateWorldSlots();
private int _pool3DCursor; // round-robin start
private bool _worldAudioSuspended;
private readonly uint[] _poolUi = new uint[PoolSizeUi];
// ── Listener (retail's SmartBox::viewer: origin + compass heading) ───────
private Vector3 _listenerPosition;
private float _listenerHeadingDegrees;
/// <summary>
/// Half-width of the stereo pan arc, in degrees — OpenAL Soft's own
/// front-left/front-right speaker angle for a stereo device, so a normalised
/// stereo position of ±1 lands exactly on a speaker.
///
/// <para>
/// Positions are NOT retail's pan scaled linearly onto this arc.
/// <see cref="RetailSoundMixer.StereoPositionFromPan"/> inverts the
/// constant-power pan law first, so retail's ±15 dB inter-channel difference
/// maps to ±0.775 of the arc and both channels stay live; a linear mapping
/// would put full deflection on the speaker angle itself, giving effectively
/// infinite separation where retail gives 15 dB. The pan's shape is retail's
/// throughout (sine of the compass bearing, dead centre inside 5 m, no
/// front/back and no elevation, frozen for the voice's life); only the pan
/// LAW is approximated, since OpenAL exposes no per-channel gain for a mono
/// source. Registered as AP-173.
/// </para>
/// </summary>
private const float MaxPanAzimuthDegrees = 30f;
// ── 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 AlBufferBudgetTracker _bufferBudget = new(DefaultBufferByteBudget);
// ── Public volume knobs ──────────────────────────────────────────────────
public float MasterVolume { get; set; } = 1f;
private bool _muted;
/// <summary>
/// Instant all-voices mute. Implemented as the AL LISTENER gain — unused
/// since A2 moved all mixing to the CPU, so it is a free master switch
/// that silences already-playing voices immediately and restores them
/// exactly, without touching the retail mixing math, the 50 dB
/// allocation cutoff, or any persisted volume setting.
/// </summary>
public bool Muted
{
get => _muted;
set
{
_muted = value;
if (_available && _al is not null)
_al.SetListenerProperty(ListenerFloat.Gain, value ? 0f : 1f);
}
}
public float SfxVolume { get; set; } = 1f;
public float AmbientVolume{ get; set; } = 0.8f;
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()
: this(new SilkOpenAlResourceApiFactory())
{
}
internal OpenAlAudioEngine(IOpenAlResourceApiFactory apiFactory)
{
ArgumentNullException.ThrowIfNull(apiFactory);
IOpenAlResourceApi api;
try
{
api = apiFactory.Create();
}
catch
{
return;
}
_al = api.AudioApi;
_resources = new OpenAlResourceLifetime(api);
try
{
if (!_resources.TryOpenDevice())
{
return;
}
if (!_resources.TryCreateContext())
{
DisableAfterInitializationFailure(
new InvalidOperationException("OpenAL could not create a context."));
return;
}
if (!_resources.TryMakeCurrent())
{
DisableAfterInitializationFailure(
new InvalidOperationException("OpenAL could not activate its context."));
return;
}
// Initialise 3D source pool.
for (int i = 0; i < PoolSize3D; i++)
{
uint src = _resources.Create3DSource();
_pool3D[i].SourceId = src;
}
// UI sources are source-relative (attached to listener) so they
// ignore 3D position.
for (int i = 0; i < PoolSizeUi; i++)
{
uint src = _resources.CreateUiSource();
_poolUi[i] = src;
}
// Global distance model = inverse-square clamped (classic retail feel).
api.DisableAlDistanceAttenuation();
_available = true;
}
catch (OpenAlInitializationException)
{
throw;
}
catch (Exception failure)
{
DisableAfterInitializationFailure(failure);
}
}
public void Dispose()
{
if (_disposed)
return;
_available = false;
_resources?.RetryCleanup();
_disposed = _resources is null || _resources.IsCleanupComplete;
}
internal bool IsDisposalComplete =>
_disposed || _resources is null || _resources.IsCleanupComplete;
private void DisableAfterInitializationFailure(Exception failure)
{
_available = false;
if (_resources is null)
return;
try
{
_resources.RetryCleanup();
}
catch (AggregateException cleanupFailure)
{
throw new OpenAlInitializationException(
failure,
_resources,
cleanupFailure);
}
_al = null;
}
// ── IAudioEngine ─────────────────────────────────────────────────────────
/// <summary>
/// Records the listener pose retail's mixer reads: origin (for distance)
/// and compass heading (for pan). No AL listener orientation is published —
/// every voice is source-relative and its pan is computed on the CPU, so
/// AL's own panner must not also rotate the field.
/// </summary>
public void SetListener(float posX, float posY, float posZ, float headingDegrees)
{
_listenerPosition = new Vector3(posX, posY, posZ);
_listenerHeadingDegrees = headingDegrees;
}
/// <summary>
/// The master multiply retail's <c>GetAttenuation</c> applies — the effect
/// knob for world/UI sounds, folded with acdream's extra master slider.
///
/// <para>
/// It is folded in HERE, before the mixer, rather than published as AL's
/// listener gain, because retail's audibility decisions are made against the
/// post-master value: the 50 dB no-allocate floor, the audible radius, and
/// the whole-decibel quantisation all move with the knob. Applying it
/// downstream as a listener gain would compute the cutoff against a louder
/// signal than the user hears, and would allocate voices at master 0 where
/// retail's <c>g &lt;= 0</c> gate drops them.
/// </para>
/// </summary>
private float EffectMaster => MasterVolume * SfxVolume;
/// <summary>
/// Not exposed on IAudioEngine but used by the hook sink — play a raw
/// WaveData blob at a 3D position with full priority/volume controls.
/// Returns true on success, false if the buffer was rejected.
/// </summary>
public bool Play3DWave(
uint ownerId,
uint waveId,
WaveData wave,
Vector3 position,
float volume,
float priority)
{
if (_worldAudioSuspended || !_available || _al is null) return false;
// Retail computes gain and pan BEFORE touching the voice pool, and a
// sound that attenuates past -50 dB is never started at all — so it
// consumes no slot and evicts nothing. At volume × master == 1 that
// silence radius is about 94 metres.
RetailVoiceMix mix = RetailSoundMixer.Mix(
_listenerPosition,
_listenerHeadingDegrees,
position,
volume,
EffectMaster);
if (!mix.Play) return false;
uint buffer = EnsureBuffer(waveId, wave);
if (buffer == 0) return false;
int slotIdx = AcquireWorldSlot(priority);
if (slotIdx < 0) return false; // nothing lower-priority — drop
float gain = RetailSoundMixer.LinearGain(mix.Decibels);
var slot = _pool3D[slotIdx];
_al.SourceStop(slot.SourceId);
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0); // detach old
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
_al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, gain);
// No pitch: retail never calls SetFrequency on a sound buffer, so
// there is no per-play pitch variation to reproduce.
ApplyPan(slot.SourceId, mix.Pan);
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
_al.SourcePlay(slot.SourceId);
slot.InUse = true;
slot.OwnerId = ownerId;
slot.Priority = priority;
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
return true;
}
/// <summary>
/// Projects the live pool into <see cref="RetailVoicePool"/>'s slot view and
/// takes its answer. The allocation policy itself lives in Core so it can be
/// tested without an AL device; this method only supplies the one piece of
/// state AL owns — whether each slot's voice is still playing.
/// </summary>
private int AcquireWorldSlot(float priority)
{
Span<VoiceSlotState> slots = stackalloc VoiceSlotState[PoolSize3D];
for (int i = 0; i < PoolSize3D; i++)
{
Slot3D s = _pool3D[i];
slots[i] = new VoiceSlotState(
Occupied: s.InUse,
StillPlaying: s.InUse && IsStillPlaying(s.SourceId),
Priority: s.Priority);
}
return RetailVoicePool.Acquire(slots, _pool3DCursor, priority);
}
/// <summary>
/// Publishes retail's whole-decibel pan as a source-relative azimuth. The
/// source sits on a unit arc in front of the listener so a pan of 0 is dead
/// ahead (centred) and the deflection is purely left/right — retail
/// distinguishes neither front from back nor elevation. Distance plays no
/// part: rolloff is 0 and the CPU-computed gain is authoritative.
///
/// <para>
/// The azimuth comes from <see cref="RetailSoundMixer.StereoPositionFromPan"/>,
/// which inverts the constant-power pan law so the resulting inter-channel
/// difference is retail's ±15 dB rather than the full separation a linear
/// mapping onto the speaker angle would produce. See AP-172.
/// </para>
/// </summary>
private void ApplyPan(uint sourceId, int pan)
{
float position = RetailSoundMixer.StereoPositionFromPan(pan);
float azimuth = position * MaxPanAzimuthDegrees * (MathF.PI / 180f);
_al!.SetSourceProperty(sourceId, SourceBoolean.SourceRelative, true);
_al.SetSourceProperty(
sourceId,
SourceVector3.Position,
MathF.Sin(azimuth),
0f,
-MathF.Cos(azimuth));
}
/// <summary>
/// Stops every world-space voice while preserving the independent UI
/// source pool. Retail suppresses ambient/object audio while cell loading
/// blocks world maintenance; stopped voices are not resumed afterward.
/// </summary>
public void SuspendWorldAudio()
{
_worldAudioSuspended = true;
for (int i = 0; i < _pool3D.Length; i++)
StopWorldSlot(_pool3D[i]);
}
public void ResumeWorldAudio() => _worldAudioSuspended = false;
internal void StopAllForOwner(uint ownerId)
{
if (ownerId == 0)
return;
for (int i = 0; i < _pool3D.Length; i++)
{
Slot3D slot = _pool3D[i];
if (slot.InUse && slot.OwnerId == ownerId)
StopWorldSlot(slot);
}
}
/// <summary>
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
/// listener position).
/// </summary>
public bool PlayUiWave(uint waveId, WaveData wave, float volume = 1f)
{
if (!_available || _al is null) return false;
uint buffer = EnsureBuffer(waveId, wave);
if (buffer == 0) return false;
// UI pool: find a free source (first not-playing), else round-robin.
int slotIdx = -1;
for (int i = 0; i < PoolSizeUi; i++)
{
if (!IsStillPlaying(_poolUi[i])) { slotIdx = i; break; }
}
if (slotIdx < 0) slotIdx = 0; // always replace slot 0 as a last resort
// Retail's interface sounds go through PlaySoundFromCenter: pan 0, and
// GetAttenuation at distance 0 (so the flat branch), scaled by
// effect_sound_volume — NOT by interface_sound_volume, which retail
// registers as a preference and then never reads.
if (!RetailSoundMixer.TryGetAttenuation(0f, volume, EffectMaster, out int decibels))
return false;
uint src = _poolUi[slotIdx];
_al.SourceStop(src);
_al.SetSourceProperty(src, SourceInteger.Buffer, 0);
_al.SetSourceProperty(src, SourceInteger.Buffer, (int)buffer);
_al.SetSourceProperty(src, SourceFloat.Gain, RetailSoundMixer.LinearGain(decibels));
_al.SourcePlay(src);
return true;
}
// IAudioEngine implementations — the enum-based overloads are less
// useful than the raw-Wave overloads above, since the hook sink already
// has access to decoded WaveData. Left as no-ops for now; R5 defines
// SoundId as a sparse subset of retail enums.
public void PlayUi(SoundId id) { /* handled via AudioHookSink */ }
public void Play3D(SoundId id, float x, float y, float z) { /* handled via AudioHookSink */ }
/// <summary>
/// Play a positional ambient one-shot — retail's
/// <c>SoundManager::PlayAmbientSound</c> @ <c>0x00550820</c>. Identical to a
/// world sound except that <c>GetAttenuation</c> is told this is ambient, so
/// the AMBIENT volume knob is the master multiply rather than the effect one.
/// </summary>
public bool PlayAmbient3DWave(
uint waveId,
WaveData wave,
Vector3 position,
float volume,
float priority)
{
if (_worldAudioSuspended || !_available || _al is null) return false;
RetailVoiceMix mix = RetailSoundMixer.Mix(
_listenerPosition,
_listenerHeadingDegrees,
position,
volume,
AmbientMaster);
if (!mix.Play) return false;
uint buffer = EnsureBuffer(waveId, wave);
if (buffer == 0) return false;
int slotIdx = AcquireWorldSlot(priority);
if (slotIdx < 0) return false;
Slot3D slot = _pool3D[slotIdx];
_al.SourceStop(slot.SourceId);
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
_al.SetSourceProperty(
slot.SourceId,
SourceFloat.Gain,
RetailSoundMixer.LinearGain(mix.Decibels));
ApplyPan(slot.SourceId, mix.Pan);
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
_al.SourcePlay(slot.SourceId);
slot.InUse = true;
slot.OwnerId = 0;
slot.Priority = priority;
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
return true;
}
/// <summary>
/// Play a non-positional ambient bed — retail's
/// <c>SoundManager::PlayAmbientSoundFromCenter</c> @ <c>0x005508B0</c>. A
/// continuous ambient has no position at all (its <c>GetSoundPos</c> is a
/// folded <c>xor eax,eax</c>), so there is no pan and no distance
/// attenuation: it is a stereo bed centred on the listener. Distance 0 takes
/// the flat branch of the curve, scaled by the ambient knob.
/// </summary>
public bool PlayAmbientFromCenter(
uint waveId,
WaveData wave,
float volume,
float priority)
{
if (_worldAudioSuspended || !_available || _al is null) return false;
if (!RetailSoundMixer.TryGetAttenuation(0f, volume, AmbientMaster, out int decibels))
return false;
uint buffer = EnsureBuffer(waveId, wave);
if (buffer == 0) return false;
// The SAME 16-voice priority pool as everything else. Retail has exactly
// one (`SoundManager::playing_sounds_[0x10]`), reached by
// PlayAmbientSoundFromCenter @ 0x5508B0 -> PlaySoundInternal @ 0x54FEC0;
// the UI pool is acdream's own. Parking beds there would let a portal
// cue chop one mid-wave and would discard the authored priority.
int slotIdx = AcquireWorldSlot(priority);
if (slotIdx < 0) return false;
Slot3D slot = _pool3D[slotIdx];
_al.SourceStop(slot.SourceId);
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
_al.SetSourceProperty(
slot.SourceId,
SourceFloat.Gain,
RetailSoundMixer.LinearGain(decibels));
ApplyPan(slot.SourceId, 0); // dead centre: a bed has no bearing
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
_al.SourcePlay(slot.SourceId);
slot.InUse = true;
slot.OwnerId = 0;
slot.Priority = priority;
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
return true;
}
/// <summary>
/// The ambient master multiply, folded with acdream's extra master slider —
/// the ambient counterpart of <see cref="EffectMaster"/>. See AP-174.
/// </summary>
private float AmbientMaster => MasterVolume * AmbientVolume;
// ── Private helpers ──────────────────────────────────────────────────────
private uint EnsureBuffer(uint waveId, WaveData wave)
{
if (!_available || _al is null) return 0;
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);
BufferFormat fmt = PickFormat(wave);
if (fmt == 0)
{
_resources.ReleaseBuffer(buf);
_bufferByWaveId[waveId] = 0;
return 0;
}
fixed (byte* p = wave.PcmBytes)
_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;
}
/// <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;
}
// Ambients share the world and UI pools scanned above; there is no
// separate ambient source list any more (slice A5 deleted the
// looping-handle model retail never had).
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
{
(1, 8) => BufferFormat.Mono8,
(1, 16) => BufferFormat.Mono16,
(2, 8) => BufferFormat.Stereo8,
(2, 16) => BufferFormat.Stereo16,
_ => 0,
};
}
private bool IsStillPlaying(uint sourceId)
{
if (_al is null) return false;
_al.GetSourceProperty(sourceId, GetSourceInteger.SourceState, out int state);
return state == (int)SourceState.Playing;
}
private void StopWorldSlot(Slot3D slot)
{
if (_available && _al is not null && slot.SourceId != 0)
{
_al.SourceStop(slot.SourceId);
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
}
slot.OwnerId = 0;
slot.Priority = 0f;
slot.InUse = false;
}
private static Slot3D[] CreateWorldSlots()
{
var slots = new Slot3D[PoolSize3D];
for (int i = 0; i < slots.Length; i++)
slots[i] = new Slot3D();
return slots;
}
}