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>
534 lines
20 KiB
C#
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;
|
|
}
|
|
}
|