acdream/src/AcDream.App/Audio/OpenAlAudioEngine.cs
Erik c69b3bde04 fix(audio): Campaign A slice A1 — retail's sound probability gate (#355)
The SoundTable probability field is a Bernoulli play/skip gate applied at
the play site (SoundManager::PlayProbability @0x005500E0), not a selection
weight — and variant selection (SoundManager::GetSound @0x00550680) is a
uniform index over (n-1) that ignores probability entirely. SoundCookbook
did the opposite: a cumulative-distribution walk weighted BY probability,
short-circuiting single-entry lists before rolling at all.

A dat census says 4,183 of 4,184 entries are single-entry and 686 of those
carry probability < 1.0, so the gate was categorically absent: Speak1 idle
chatter authored at 0.05 fired every trigger (~20x too often), wound/attack/
swoosh variants never dropped, and six 0.0001 entries always played.

Split into retail's two steps (PickVariant + PlayProbability, composed by
Select) over a new ISoundRandom modelling both retail roll ranges: the
variant roll clamped below 1.0 (0x00797D48) and the gate's 1/32767 grid,
which is why 0.0001 resolves to ~1.2e-4. PickVariant reproduces retail's
(n-1) off-by-one verbatim per the port-faithfully rule — the last variant
of a multi-entry sound is unreachable, costing exactly one wave
(0x0A00051E) in the shipped dats.

Also removes invented mechanism this review disproved: the dead Core
SoundEntry/ISoundCache scaffold (PitchMin/PitchMax, Loop, Is3D — retail
never calls SetFrequency, never sets the loop flag, and creates every
gameplay buffer 2D), the engine's pitch plumbing, the int 0..7 priority
cast (the dat field is a float in [0,1]; 4,100 entries collapsed to 0),
and the clamp-at-the-field on volume (an unbounded gain retail clamps only
after the distance divide).

Tests rewritten as conformance against the disassembled values, replacing
a self-referential suite that pinned the wrong model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:28:16 +02:00

534 lines
20 KiB
C#

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 (Phase E.2) — faithful to retail's
/// 16-voice pool and inverse-square falloff behaviour (r05 §5.3).
///
/// <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 3D positional sounds. When all 16 are
/// busy, new Play3D calls evict the slot whose currently-playing
/// sound has lower effective gain than the incoming sound
/// (matches retail <c>FUN_00550ad0</c> first-free-then-evict-quieter
/// algorithm at <c>chunk_00550000.c:527</c>).
/// </description></item>
/// <item><description>
/// Separate UI source pool (4 sources) for flat 2D UI clicks /
/// wooshes — not subject to the 3D 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; mirrors retail's g_poolVols array (the
// EFFECTIVE gain at play-start time, used for eviction comparisons).
private sealed class Slot3D
{
public uint SourceId;
public uint OwnerId;
public float PlayingGain; // gain at play time (for eviction compare)
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];
// ── 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);
// ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
private readonly Dictionary<int, uint> _ambientSources = new();
private int _nextAmbientHandle = 1;
// ── Public volume knobs ──────────────────────────────────────────────────
public float MasterVolume { get; set; } = 1f;
public float SfxVolume { get; set; } = 1f;
public float MusicVolume { get; set; } = 0.7f;
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.SelectRetailDistanceModel();
_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 ─────────────────────────────────────────────────────────
public void SetListener(
float posX, float posY, float posZ,
float forwardX, float forwardY, float forwardZ,
float upX, float upY, float upZ)
{
if (!_available || _al is null) return;
_al.SetListenerProperty(ListenerVector3.Position, posX, posY, posZ);
// AL expects a 6-float orientation (fwd then up).
Span<float> ori = stackalloc float[6]
{
forwardX, forwardY, forwardZ,
upX, upY, upZ
};
fixed (float* p = ori)
_al.SetListenerProperty(ListenerFloatArray.Orientation, p);
_al.SetListenerProperty(ListenerFloat.Gain, MasterVolume);
}
/// <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;
float effectiveGain = volume * SfxVolume;
if (effectiveGain < 0.001f) return false; // silent; skip
uint buffer = EnsureBuffer(waveId, wave);
if (buffer == 0) return false;
// Pick a slot: first free, else evict quieter one, else drop.
int slotIdx = -1;
for (int i = 0; i < PoolSize3D; i++)
{
int idx = (_pool3DCursor + i) & (PoolSize3D - 1);
var s = _pool3D[idx];
if (!s.InUse || !IsStillPlaying(s.SourceId)) { slotIdx = idx; break; }
}
if (slotIdx < 0)
{
for (int i = 0; i < PoolSize3D; i++)
{
int idx = (_pool3DCursor + i) & (PoolSize3D - 1);
if (_pool3D[idx].PlayingGain < effectiveGain) { slotIdx = idx; break; }
}
}
if (slotIdx < 0) return false; // no slot quieter than us — drop
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, effectiveGain);
// No pitch: retail never calls SetFrequency on a sound buffer, so
// there is no per-play pitch variation to reproduce.
_al.SetSourceProperty(slot.SourceId, SourceVector3.Position, position.X, position.Y, position.Z);
_al.SetSourceProperty(slot.SourceId, SourceBoolean.SourceRelative, false);
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
_al.SourcePlay(slot.SourceId);
slot.PlayingGain = effectiveGain;
slot.InUse = true;
slot.OwnerId = ownerId;
slot.Priority = priority;
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
return true;
}
/// <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
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, Math.Clamp(volume, 0f, 1f) * SfxVolume);
_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 */ }
public int StartAmbient(SoundId id, float x, float y, float z)
{
// Looping ambient — needs a decoded wave + WaveId. The hook sink
// doesn't route ambient; a separate landblock-attached ambient
// system (outside R5) will drive this. For now: reserve a handle.
int handle = _nextAmbientHandle++;
return handle;
}
public void StopAmbient(int handle)
{
if (!_available || _al is null) return;
if (_ambientSources.TryGetValue(handle, out var src))
{
_al.SourceStop(src);
_ambientSources.Remove(handle);
}
}
public void PlayMusic(string resourceName, bool loop) { /* R5 §6 MIDI — not ported */ }
public void StopMusic() { /* ditto */ }
// ── 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;
}
foreach (uint sourceId in _ambientSources.Values)
{
if (IsSourceBoundTo(sourceId, bufferId)) return true;
}
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.PlayingGain = 0f;
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;
}
}