using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Audio;
using Silk.NET.OpenAL;
namespace AcDream.App.Audio;
///
/// OpenAL-backed audio engine. Spatialization is NOT OpenAL's: retail creates
/// every gameplay buffer 2D (m_3D = 0) and computes a gain and a stereo
/// pan on the CPU per voice, so owns that math
/// and AL is reduced to a voice bank. Every source is source-relative with
/// AL_ROLLOFF_FACTOR = 0.
///
///
/// Architecture:
///
/// -
/// Single + bound to the
/// system default device. Cross-platform (WASAPI / WinMM /
/// PulseAudio / CoreAudio — whichever OpenAL-Soft picks).
///
/// -
/// Fixed 16-source pool for world sounds, allocated by
/// : 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
/// SoundManager::PlaySoundInternal @ 0x0054FEC0 and it never
/// consults gain. (This comment previously cited FUN_00550ad0 and
/// described gain-based eviction; that address is inside an
/// IntrusiveHashTable constructor and the behaviour was ours, not
/// retail's — both corrected in Campaign A slice A2, register row AP-28.)
///
/// -
/// Separate UI source pool (4 sources) for flat 2D UI clicks /
/// wooshes — not subject to the world pool's eviction game.
///
/// -
/// 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 () enforced
/// with LRU eviction — see . 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.
///
///
///
///
///
/// Thread-safety: the engine is called only from the render thread
/// (the same thread that drives TickAnimations). No locks inside.
///
///
///
/// Fail-open: when the OpenAL driver can't be initialised (missing
/// library on a headless CI box, or explicitly disabled via
/// ACDREAM_NO_AUDIO=1), is false and all
/// Play* calls are no-ops. This lets the rest of the client run
/// unaffected.
///
///
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;
///
/// 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.
///
///
/// Positions are NOT retail's pan scaled linearly onto this arc.
/// 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.
///
///
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 _bufferByWaveId = new();
private readonly AlBufferBudgetTracker _bufferBudget = new(DefaultBufferByteBudget);
// ── Public volume knobs ──────────────────────────────────────────────────
public float MasterVolume { get; set; } = 1f;
private bool _muted;
///
/// 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.
///
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;
/// Estimated bytes currently resident in the AL buffer cache. Diagnostic use only.
public long ResidentBufferBytes => _bufferBudget.ResidentBytes;
/// Number of AL buffers currently resident. Diagnostic use only.
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 ─────────────────────────────────────────────────────────
///
/// 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.
///
public void SetListener(float posX, float posY, float posZ, float headingDegrees)
{
_listenerPosition = new Vector3(posX, posY, posZ);
_listenerHeadingDegrees = headingDegrees;
}
///
/// The master multiply retail's GetAttenuation applies — the effect
/// knob for world/UI sounds, folded with acdream's extra master slider.
///
///
/// 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 g <= 0 gate drops them.
///
///
private float EffectMaster => MasterVolume * SfxVolume;
///
/// 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.
///
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;
}
///
/// Projects the live pool into '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.
///
private int AcquireWorldSlot(float priority)
{
Span 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);
}
///
/// 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.
///
///
/// The azimuth comes from ,
/// 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.
///
///
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));
}
///
/// 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.
///
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);
}
}
///
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
/// listener position).
///
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 */ }
///
/// Play a positional ambient one-shot — retail's
/// SoundManager::PlayAmbientSound @ 0x00550820. Identical to a
/// world sound except that GetAttenuation is told this is ambient, so
/// the AMBIENT volume knob is the master multiply rather than the effect one.
///
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;
}
///
/// Play a non-positional ambient bed — retail's
/// SoundManager::PlayAmbientSoundFromCenter @ 0x005508B0. A
/// continuous ambient has no position at all (its GetSoundPos is a
/// folded xor eax,eax), 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.
///
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;
}
///
/// The ambient master multiply, folded with acdream's extra master slider —
/// the ambient counterpart of . See AP-174.
///
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;
}
///
/// 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 — alDeleteBuffers
/// fails on a buffer that's still attached to a source — so eviction
/// never targets one; nor does it target ,
/// the buffer 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
/// again on next use — re-upload from
/// , identical to a first play.
///
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);
}
}
///
/// True when is currently bound to any pool
/// source. Queried live from AL (AL_BUFFER on each source)
/// rather than tracked locally: AL's per-source state is the only
/// thing that actually determines whether alDeleteBuffers would
/// fail, and several call sites (,
/// ) set a source's buffer directly, so a
/// second local copy would be one more place to keep in sync.
///
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;
}
}