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; } }