diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj
index a9edc9f..d2c9ef2 100644
--- a/src/AcDream.App/AcDream.App.csproj
+++ b/src/AcDream.App/AcDream.App.csproj
@@ -13,6 +13,9 @@
+
+
+
diff --git a/src/AcDream.App/Audio/AudioHookSink.cs b/src/AcDream.App/Audio/AudioHookSink.cs
new file mode 100644
index 0000000..75cf59f
--- /dev/null
+++ b/src/AcDream.App/Audio/AudioHookSink.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Audio;
+using AcDream.Core.Physics;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+using DRWSound = DatReaderWriter.Enums.Sound;
+
+namespace AcDream.App.Audio;
+
+///
+/// that routes sound-bearing animation
+/// hooks (, ,
+/// ) into the
+/// .
+///
+///
+/// Wiring:
+///
+/// -
+/// → direct play of SoundHook.Id (a
+/// Wave dat id) at the entity's world position. Used for custom /
+/// per-animation audio like weapon swoosh or spell chant.
+///
+/// -
+/// → look up the entity's SoundTable +
+/// the hook's SoundType, roll one
+/// via
+/// , play its wave. Retail's "footstep
+/// that varies slightly" mechanism — also how attack / damage sounds
+/// pick a creature-specific variant.
+///
+/// -
+/// → same as SoundHook but with
+/// pitch / volume overrides baked into the hook.
+///
+///
+///
+///
+///
+/// Entity → SoundTable id is resolved via an
+/// callback passed in at construction; the renderer's per-entity state
+/// bag knows the PhysicsObj's SoundTableId (retail:
+/// PhysicsObj.soundtable_id).
+///
+///
+public sealed class AudioHookSink : IAnimationHookSink
+{
+ private readonly OpenAlAudioEngine _engine;
+ private readonly DatSoundCache _cache;
+ private readonly IEntitySoundTable _entitySoundTables;
+ private readonly Random _rng;
+
+ public AudioHookSink(
+ OpenAlAudioEngine engine,
+ DatSoundCache cache,
+ IEntitySoundTable entitySoundTables,
+ Random? rng = null)
+ {
+ _engine = engine ?? throw new ArgumentNullException(nameof(engine));
+ _cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ _entitySoundTables = entitySoundTables ?? throw new ArgumentNullException(nameof(entitySoundTables));
+ _rng = rng ?? Random.Shared;
+ }
+
+ public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
+ {
+ if (!_engine.IsAvailable) return;
+
+ switch (hook)
+ {
+ case SoundHook s:
+ Play(entityId, entityWorldPosition, (uint)s.Id, volume: 1f, priority: 4, pitch: 1f);
+ break;
+
+ case SoundTableHook st:
+ PlayFromSoundTable(entityId, entityWorldPosition, st.SoundType);
+ break;
+
+ case SoundTweakedHook stw:
+ // SoundTweakedHook is a direct wave play with volume +
+ // priority overrides baked into the hook itself (NOT a
+ // SoundTable lookup — that's SoundTableHook). Retail uses
+ // this for the rare "explicit wave + explicit volume" case.
+ Play(entityId, entityWorldPosition,
+ waveId: (uint)stw.SoundId,
+ volume: Math.Clamp(stw.Volume > 0 ? stw.Volume : 1f, 0f, 1f),
+ priority: stw.Priority,
+ pitch: 1f);
+ break;
+
+ // All the visual-only hooks (Scale, Luminous, Diffuse, …)
+ // are for other sinks to handle.
+ }
+ }
+
+ private void PlayFromSoundTable(
+ uint entityId, Vector3 worldPos, DRWSound sound,
+ float volumeMult = 1f, float pitchMult = 1f)
+ {
+ uint tableId = _entitySoundTables.GetSoundTableId(entityId);
+ if (tableId == 0) return;
+
+ SoundTable? table = _cache.GetSoundTable(tableId);
+ if (table is null) return;
+
+ var entry = SoundCookbook.Roll(table, sound, _rng);
+ if (entry is null) return;
+
+ Play(
+ entityId, worldPos,
+ waveId: (uint)entry.Id,
+ volume: Math.Clamp(entry.Volume * volumeMult, 0f, 1f),
+ priority: entry.Priority,
+ pitch: Math.Max(0.5f, Math.Min(2.0f, pitchMult)));
+ }
+
+ private void Play(uint entityId, Vector3 worldPos, uint waveId,
+ float volume, float priority, float pitch)
+ {
+ if (waveId == 0) return;
+ WaveData? wave = _cache.GetWave(waveId);
+ if (wave is null) return;
+ _engine.Play3DWave(waveId, wave, worldPos, volume, priority, pitch);
+ }
+}
+
+///
+/// Callback the renderer uses to resolve the sound-table id for an
+/// entity. Retail stores this on PhysicsObj.soundtable_id; our
+/// renderer keeps per-entity state that includes it.
+///
+public interface IEntitySoundTable
+{
+ ///
+ /// Return the SoundTable dat id (0x20xxxxxx) for ,
+ /// or 0 if no table is known (e.g. a static prop without audio).
+ ///
+ uint GetSoundTableId(uint entityId);
+}
+
+///
+/// Simple dictionary-backed ; the renderer
+/// assigns entries as it hydrates entities.
+///
+public sealed class DictionaryEntitySoundTable : IEntitySoundTable
+{
+ private readonly Dictionary _table = new();
+
+ public void Set(uint entityId, uint soundTableId) => _table[entityId] = soundTableId;
+ public void Remove(uint entityId) => _table.Remove(entityId);
+
+ public uint GetSoundTableId(uint entityId) =>
+ _table.TryGetValue(entityId, out var id) ? id : 0;
+}
diff --git a/src/AcDream.App/Audio/OpenAlAudioEngine.cs b/src/AcDream.App/Audio/OpenAlAudioEngine.cs
new file mode 100644
index 0000000..2b4232e
--- /dev/null
+++ b/src/AcDream.App/Audio/OpenAlAudioEngine.cs
@@ -0,0 +1,375 @@
+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 (Phase E.2) — faithful to retail's
+/// 16-voice pool and inverse-square falloff behaviour (r05 §5.3).
+///
+///
+/// Architecture:
+///
+/// -
+/// Single + bound to the
+/// system default device. Cross-platform (WASAPI / WinMM /
+/// PulseAudio / CoreAudio — whichever OpenAL-Soft picks).
+///
+/// -
+/// 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 FUN_00550ad0 first-free-then-evict-quieter
+/// algorithm at chunk_00550000.c:527).
+///
+/// -
+/// Separate UI source pool (4 sources) for flat 2D UI clicks /
+/// wooshes — not subject to the 3D 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.
+///
+///
+///
+///
+///
+/// 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.
+///
+///
+public sealed unsafe class OpenAlAudioEngine : IAudioEngine
+{
+ // ── Backends ─────────────────────────────────────────────────────────────
+ private readonly ALContext? _alc;
+ private readonly AL? _al;
+ private readonly Device* _device;
+ private readonly Context* _context;
+ private readonly bool _available;
+
+ // ── 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 float PlayingGain; // gain at play time (for eviction compare)
+ public bool InUse;
+ public uint PriorityBase; // raw priority from SoundEntry.Priority
+ }
+ private readonly Slot3D[] _pool3D = new Slot3D[PoolSize3D];
+ private int _pool3DCursor; // round-robin start
+
+ private readonly uint[] _poolUi = new uint[PoolSizeUi];
+
+ // ── Buffer cache (Wave dat id → AL buffer) ───────────────────────────────
+ private readonly Dictionary _bufferByWaveId = new();
+
+ // ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
+ private readonly Dictionary _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;
+
+ public OpenAlAudioEngine()
+ {
+ try
+ {
+ _alc = ALContext.GetApi(soft: true);
+ _al = AL.GetApi(soft: true);
+ _device = _alc.OpenDevice(string.Empty);
+ if (_device == null)
+ {
+ _available = false;
+ return;
+ }
+ _context = _alc.CreateContext(_device, null);
+ if (_context == null)
+ {
+ _alc.CloseDevice(_device);
+ _device = null;
+ _available = false;
+ return;
+ }
+ if (!_alc.MakeContextCurrent(_context))
+ {
+ _alc.DestroyContext(_context);
+ _context = null;
+ _alc.CloseDevice(_device);
+ _device = null;
+ _available = false;
+ return;
+ }
+
+ // Initialise 3D source pool.
+ for (int i = 0; i < PoolSize3D; i++)
+ {
+ uint src = _al.GenSource();
+ _al.SetSourceProperty(src, SourceFloat.Gain, 1f);
+ _al.SetSourceProperty(src, SourceFloat.MaxDistance, 1000f);
+ _al.SetSourceProperty(src, SourceFloat.RolloffFactor, 1f);
+ _al.SetSourceProperty(src, SourceFloat.ReferenceDistance, 2f);
+ _al.SetSourceProperty(src, SourceBoolean.Looping, false);
+ _pool3D[i] = new Slot3D { SourceId = src, InUse = false };
+ }
+
+ // UI sources are source-relative (attached to listener) so they
+ // ignore 3D position.
+ for (int i = 0; i < PoolSizeUi; i++)
+ {
+ uint src = _al.GenSource();
+ _al.SetSourceProperty(src, SourceBoolean.SourceRelative, true);
+ _al.SetSourceProperty(src, SourceFloat.Gain, 1f);
+ _al.SetSourceProperty(src, SourceBoolean.Looping, false);
+ _poolUi[i] = src;
+ }
+
+ // Global distance model = inverse-square clamped (classic retail feel).
+ _al.DistanceModel(DistanceModel.InverseDistanceClamped);
+
+ _available = true;
+ }
+ catch
+ {
+ // OpenAL driver unavailable (headless CI, missing libopenal).
+ _available = false;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (!_available || _al is null) return;
+
+ try
+ {
+ for (int i = 0; i < PoolSize3D; i++)
+ {
+ var slot = _pool3D[i];
+ if (slot is null) continue;
+ _al.SourceStop(slot.SourceId);
+ _al.DeleteSource(slot.SourceId);
+ }
+ for (int i = 0; i < PoolSizeUi; i++)
+ {
+ _al.SourceStop(_poolUi[i]);
+ _al.DeleteSource(_poolUi[i]);
+ }
+ foreach (var kv in _ambientSources)
+ {
+ _al.SourceStop(kv.Value);
+ _al.DeleteSource(kv.Value);
+ }
+ foreach (var buf in _bufferByWaveId.Values)
+ {
+ _al.DeleteBuffer(buf);
+ }
+ if (_context != null && _alc is not null)
+ {
+ _alc.MakeContextCurrent(null);
+ _alc.DestroyContext(_context);
+ }
+ if (_device != null && _alc is not null)
+ _alc.CloseDevice(_device);
+ }
+ catch { /* shutdown — ignore */ }
+ }
+
+ // ── 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 ori = stackalloc float[6]
+ {
+ forwardX, forwardY, forwardZ,
+ upX, upY, upZ
+ };
+ fixed (float* p = ori)
+ _al.SetListenerProperty(ListenerFloatArray.Orientation, p);
+ _al.SetListenerProperty(ListenerFloat.Gain, MasterVolume);
+ }
+
+ ///
+ /// 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 waveId,
+ WaveData wave,
+ Vector3 position,
+ float volume,
+ float priority,
+ float pitch = 1.0f)
+ {
+ if (!_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);
+ _al.SetSourceProperty(slot.SourceId, SourceFloat.Pitch, pitch);
+ _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.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7);
+ _pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
+ return true;
+ }
+
+ ///
+ /// 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, float pitch = 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.SetSourceProperty(src, SourceFloat.Pitch, pitch);
+ _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)) return existing;
+
+ uint buf = _al.GenBuffer();
+ BufferFormat fmt = PickFormat(wave);
+ if (fmt == 0)
+ {
+ _al.DeleteBuffer(buf);
+ _bufferByWaveId[waveId] = 0;
+ return 0;
+ }
+
+ fixed (byte* p = wave.PcmBytes)
+ _al.BufferData(buf, fmt, p, wave.PcmBytes.Length, wave.SampleRate);
+
+ _bufferByWaveId[waveId] = buf;
+ return buf;
+ }
+
+ 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;
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 1f75b56..b667243 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -129,6 +129,13 @@ public sealed class GameWindow : IDisposable
// per-entity tick loop can just call it unconditionally.
private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new();
+ // Phase E.2 audio. Null when ACDREAM_NO_AUDIO=1 or the OpenAL driver
+ // failed to init; all three are set together.
+ private AcDream.App.Audio.OpenAlAudioEngine? _audioEngine;
+ private AcDream.Core.Audio.DatSoundCache? _soundCache;
+ private AcDream.App.Audio.DictionaryEntitySoundTable? _entitySoundTables;
+ private AcDream.App.Audio.AudioHookSink? _audioSink;
+
// Phase B.2: player movement mode.
private AcDream.App.Input.PlayerMovementController? _playerController;
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
@@ -559,6 +566,33 @@ public sealed class GameWindow : IDisposable
_dats = new DatCollection(_datDir, DatAccessType.Read);
_animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats);
+ // Phase E.2 audio: init OpenAL + hook sink. Suppressible via
+ // ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
+ if (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1")
+ {
+ try
+ {
+ _soundCache = new AcDream.Core.Audio.DatSoundCache(_dats);
+ _audioEngine = new AcDream.App.Audio.OpenAlAudioEngine();
+ _entitySoundTables = new AcDream.App.Audio.DictionaryEntitySoundTable();
+ if (_audioEngine.IsAvailable)
+ {
+ _audioSink = new AcDream.App.Audio.AudioHookSink(
+ _audioEngine, _soundCache, _entitySoundTables);
+ _hookRouter.Register(_audioSink);
+ Console.WriteLine("audio: OpenAL engine ready (16 voices, 3D positional)");
+ }
+ else
+ {
+ Console.WriteLine("audio: OpenAL unavailable (driver missing / headless) — audio disabled");
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"audio: init failed: {ex.Message} — audio disabled");
+ }
+ }
+
uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
@@ -1114,6 +1148,17 @@ public sealed class GameWindow : IDisposable
CurrFrame = idleCycle.LowFrame,
Sequencer = sequencer,
};
+
+ // Phase E.2: register entity's SoundTable so SoundTableHook can
+ // resolve creature-specific sounds (footsteps, attack vocalizations,
+ // damage grunts, etc). Server-sent SoundTable override would take
+ // precedence here when the wire layer delivers it.
+ if (_entitySoundTables is not null)
+ {
+ uint soundTableId = (uint)setup.DefaultSoundTable;
+ if (soundTableId != 0)
+ _entitySoundTables.Set(entity.Id, soundTableId);
+ }
}
// Dump a summary periodically so we can see drop breakdowns without
@@ -2702,6 +2747,21 @@ public sealed class GameWindow : IDisposable
// Extract camera world position from the inverse of the view matrix.
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
+
+ // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
+ // correctly relative to where we're looking. Fwd = -Z of the view
+ // matrix (OpenGL convention), up = +Y. Both live in the inverse
+ // view matrix's basis vectors.
+ if (_audioEngine is not null && _audioEngine.IsAvailable)
+ {
+ var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33);
+ var up = new System.Numerics.Vector3( invView.M21, invView.M22, invView.M23);
+ _audioEngine.SetListener(
+ camPos.X, camPos.Y, camPos.Z,
+ fwd.X, fwd.Y, fwd.Z,
+ up.X, up.Y, up.Z);
+ }
+
var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null;
@@ -3166,6 +3226,7 @@ public sealed class GameWindow : IDisposable
// _dats; Dispose cancels the token and waits up to 2s for the thread.
_streamer?.Dispose();
_liveSession?.Dispose();
+ _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_staticMesh?.Dispose();
_textureCache?.Dispose();
_meshShader?.Dispose();
diff --git a/src/AcDream.Core/Audio/DatSoundCache.cs b/src/AcDream.Core/Audio/DatSoundCache.cs
new file mode 100644
index 0000000..8e25ed2
--- /dev/null
+++ b/src/AcDream.Core/Audio/DatSoundCache.cs
@@ -0,0 +1,78 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.Core.Audio;
+
+///
+/// DatCollection-backed cache of decoded waves + SoundTable lookups.
+///
+///
+/// Lazy-loads each Wave / SoundTable on first request, decodes Wave PCM
+/// data via , and memoizes the result. Safe to
+/// call from any thread — the inner dicts are
+/// .
+///
+///
+///
+/// Returns null for waves whose format tag isn't PCM (MP3 / ADPCM
+/// fall through silently until a compressed-decoder path is wired up).
+///
+///
+public sealed class DatSoundCache
+{
+ private readonly DatCollection _dats;
+ private readonly ConcurrentDictionary _waves = new();
+ private readonly ConcurrentDictionary _tables = new();
+
+ public DatSoundCache(DatCollection dats)
+ {
+ _dats = dats;
+ }
+
+ ///
+ /// Retrieve decoded PCM data for a Wave dat id. Returns null if the
+ /// wave is missing, malformed, or uses a currently-unsupported format
+ /// (MP3 / ADPCM).
+ ///
+ public WaveData? GetWave(uint waveId)
+ {
+ if (_waves.TryGetValue(waveId, out var cached)) return cached;
+
+ var wave = _dats.Get(waveId);
+ if (wave is null)
+ {
+ _waves[waveId] = null;
+ return null;
+ }
+
+ var decoded = WaveDecoder.Decode(wave.Header, wave.Data);
+ _waves[waveId] = decoded;
+ return decoded;
+ }
+
+ ///
+ /// Retrieve a SoundTable by dat id. Returns null if the table is
+ /// missing from the dats.
+ ///
+ public SoundTable? GetSoundTable(uint soundTableId)
+ {
+ if (_tables.TryGetValue(soundTableId, out var cached)) return cached;
+
+ var table = _dats.Get(soundTableId);
+ _tables[soundTableId] = table;
+ return table;
+ }
+
+ ///
+ /// Total number of waves that have been accessed (hit or miss).
+ /// Useful for diagnostic overlays.
+ ///
+ public int CachedWaveCount => _waves.Count;
+
+ ///
+ /// Total number of SoundTables that have been accessed.
+ ///
+ public int CachedSoundTableCount => _tables.Count;
+}
diff --git a/src/AcDream.Core/Audio/SoundCookbook.cs b/src/AcDream.Core/Audio/SoundCookbook.cs
new file mode 100644
index 0000000..8f9c9d8
--- /dev/null
+++ b/src/AcDream.Core/Audio/SoundCookbook.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using DatReaderWriter.DBObjs;
+using DRWSound = DatReaderWriter.Enums.Sound;
+
+namespace AcDream.Core.Audio;
+
+///
+/// Probabilistic entry picker over a retail .
+///
+///
+/// Each key in
+/// SoundTable.Sounds maps to a list of
+/// items each carrying a
+/// probability weight. Retail picks one entry per trigger by rolling the
+/// cumulative distribution — that's how footsteps sound slightly
+/// different each step, how weapon swings have 3 swoosh variants, etc.
+///
+///
+///
+/// r05 §4: the picker samples a uniform random in [0,1) and walks the
+/// entries accumulating probabilities; the first entry whose running total
+/// exceeds the sample wins. If all probabilities sum to < 1, the
+/// remaining mass means "silence" — the call returns null. If probabilities
+/// sum to > 1 the picker still works correctly (it clamps on the last
+/// entry).
+///
+///
+public static class SoundCookbook
+{
+ ///
+ /// Pick one entry from a sound's variant list, weighted by probability.
+ /// Returns null when the rolled sample falls into the "silence"
+ /// remainder of the distribution (probability sum < 1).
+ ///
+ public static DatReaderWriter.Types.SoundEntry? Roll(
+ IReadOnlyList entries,
+ Random rng)
+ {
+ ArgumentNullException.ThrowIfNull(entries);
+ ArgumentNullException.ThrowIfNull(rng);
+
+ if (entries.Count == 0) return null;
+ if (entries.Count == 1) return entries[0];
+
+ float sample = (float)rng.NextDouble();
+ float cum = 0f;
+ for (int i = 0; i < entries.Count; i++)
+ {
+ cum += Math.Max(0f, entries[i].Probability);
+ if (sample < cum) return entries[i];
+ }
+ // Fell past the last entry — either probabilities sum to >1 (return
+ // last) or < 1 and we rolled into the "silence" tail (return null).
+ float total = 0f;
+ for (int i = 0; i < entries.Count; i++)
+ total += Math.Max(0f, entries[i].Probability);
+
+ return total > 0.999f ? entries[entries.Count - 1] : null;
+ }
+
+ ///
+ /// Convenience lookup: given a SoundTable + a retail
+ /// key (e.g. Swoosh1, Footstep1), roll the
+ /// entry list and return the winning entry. Returns null if:
+ ///
+ /// - has no mapping for
+ /// .
+ /// - The mapping's entry list is empty.
+ /// - The probability roll hits the silence
+ /// tail.
+ ///
+ ///
+ public static DatReaderWriter.Types.SoundEntry? Roll(
+ SoundTable table,
+ DRWSound sound,
+ Random rng)
+ {
+ ArgumentNullException.ThrowIfNull(table);
+ if (!table.Sounds.TryGetValue(sound, out var soundData)) return null;
+ return Roll(soundData.Entries, rng);
+ }
+}
diff --git a/src/AcDream.Core/Audio/WaveDecoder.cs b/src/AcDream.Core/Audio/WaveDecoder.cs
new file mode 100644
index 0000000..838b35a
--- /dev/null
+++ b/src/AcDream.Core/Audio/WaveDecoder.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Audio;
+
+///
+/// Decodes the AC Wave dat (`0x0A000000..0x0A00FFFF`) into playable
+/// PCM data.
+///
+///
+/// On-disk format (r05 §2.1 + ACE.DatLoader.FileTypes.Wave + ACViewer's
+/// Wave.cs:32-72):
+///
+/// int32 headerSize
+/// int32 dataSize
+/// byte[headerSize] Header // raw WAVEFORMATEX — NO RIFF wrapper
+/// byte[dataSize] Data // raw sample bytes
+///
+///
+///
+///
+/// Header byte 0 (low byte of WAVEFORMATEX.wFormatTag):
+///
+/// - 0x01 linear PCM — feeds straight into OpenAL.
+/// - 0x55 MPEG Layer 3 — retail decoded via winmm ACM.
+/// - 0x02 Microsoft ADPCM — retail decoded via winmm ACM.
+///
+///
+///
+///
+/// Our decoder currently handles the PCM case (the vast majority of AC's
+/// ~3500 waves). MP3 / ADPCM decoding is best handled by a managed
+/// decoder added later (NAudio / MediaToolkit / custom). Unsupported
+/// formats return null and the caller logs + skips.
+///
+///
+public static class WaveDecoder
+{
+ ///
+ /// Format tag from the first 2 bytes of the WAVEFORMATEX header.
+ /// Retail and ACE map identically; we use these exact values for logs.
+ ///
+ public enum WaveFormatTag : ushort
+ {
+ Unknown = 0x0000,
+ Pcm = 0x0001, // Microsoft PCM
+ Adpcm = 0x0002, // Microsoft ADPCM (rare in AC)
+ Mp3 = 0x0055, // MPEGLAYER3 (common for music-ish cues)
+ }
+
+ ///
+ /// Parse a Wave dat record into a PCM
+ /// container. Returns null when the source format is compressed
+ /// (MP3 / ADPCM) and we don't yet have a decoder wired up, OR when the
+ /// header is malformed.
+ ///
+ /// Raw WAVEFORMATEX bytes (at least 14 bytes).
+ /// Raw payload bytes following the header.
+ public static WaveData? Decode(byte[] header, byte[] data)
+ {
+ ArgumentNullException.ThrowIfNull(header);
+ ArgumentNullException.ThrowIfNull(data);
+
+ if (header.Length < 14) return null;
+
+ // WAVEFORMATEX fields (little-endian):
+ // wFormatTag u16 @ 0
+ // nChannels u16 @ 2
+ // nSamplesPerSec u32 @ 4
+ // nAvgBytesPerSec u32 @ 8
+ // nBlockAlign u16 @12
+ // wBitsPerSample u16 @14 (present if headerSize >= 16)
+ // cbSize u16 @16 (optional extra-data length)
+ var h = header.AsSpan();
+ ushort fmtTag = BinaryPrimitives.ReadUInt16LittleEndian(h);
+ ushort channels = BinaryPrimitives.ReadUInt16LittleEndian(h.Slice(2));
+ uint sampleRate = BinaryPrimitives.ReadUInt32LittleEndian(h.Slice(4));
+ ushort bitsPer = header.Length >= 16
+ ? BinaryPrimitives.ReadUInt16LittleEndian(h.Slice(14))
+ : (ushort)16;
+
+ if (channels == 0 || sampleRate == 0)
+ return null;
+
+ // Only PCM is natively supported here. Compressed paths require a
+ // secondary decoder (MP3/ADPCM); caller logs + skips when null.
+ if ((WaveFormatTag)fmtTag != WaveFormatTag.Pcm)
+ return null;
+
+ // For PCM the Data array IS the sample buffer (no framing needed).
+ double duration = bitsPer > 0 && channels > 0
+ ? data.Length * 8.0 / (double)(sampleRate * channels * bitsPer)
+ : 0.0;
+
+ return new WaveData
+ {
+ ChannelCount = channels,
+ SampleRate = (int)sampleRate,
+ BitsPerSample = bitsPer == 0 ? 16 : bitsPer,
+ PcmBytes = data,
+ Duration = TimeSpan.FromSeconds(duration),
+ };
+ }
+
+ ///
+ /// Peek only the format tag byte. Useful for logging which formats
+ /// failed to decode without allocating a .
+ ///
+ public static WaveFormatTag PeekFormat(byte[] header)
+ {
+ if (header is null || header.Length < 2) return WaveFormatTag.Unknown;
+ ushort fmtTag = BinaryPrimitives.ReadUInt16LittleEndian(header);
+ return (WaveFormatTag)fmtTag;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Audio/SoundCookbookTests.cs b/tests/AcDream.Core.Tests/Audio/SoundCookbookTests.cs
new file mode 100644
index 0000000..20568ff
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Audio/SoundCookbookTests.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using AcDream.Core.Audio;
+using DatReaderWriter.DBObjs;
+using DRWSoundEntry = DatReaderWriter.Types.SoundEntry;
+using DatReaderWriter.Types;
+using DRWSound = DatReaderWriter.Enums.Sound;
+using Xunit;
+
+namespace AcDream.Core.Tests.Audio;
+
+public sealed class SoundCookbookTests
+{
+ // Deterministic Random for golden-value tests.
+ private static Random Seed(int seed) => new Random(seed);
+
+ [Fact]
+ public void Roll_EmptyList_ReturnsNull()
+ {
+ Assert.Null(SoundCookbook.Roll(new List(), Seed(1)));
+ }
+
+ [Fact]
+ public void Roll_SingleEntry_AlwaysReturnsIt()
+ {
+ var e = new DRWSoundEntry { Probability = 0.5f, Priority = 4f, Volume = 1f };
+ var entries = new List { e };
+ Assert.Same(e, SoundCookbook.Roll(entries, Seed(1)));
+ Assert.Same(e, SoundCookbook.Roll(entries, Seed(999)));
+ }
+
+ [Fact]
+ public void Roll_WeightedEntries_DistributionMatches()
+ {
+ // Three entries: 50%, 30%, 20%. Roll 10000 times and verify counts
+ // are within 5% of expected.
+ var a = new DRWSoundEntry { Probability = 0.5f };
+ var b = new DRWSoundEntry { Probability = 0.3f };
+ var c = new DRWSoundEntry { Probability = 0.2f };
+ var entries = new List { a, b, c };
+
+ var rng = new Random(42);
+ int countA = 0, countB = 0, countC = 0, countNull = 0;
+ for (int i = 0; i < 10000; i++)
+ {
+ var picked = SoundCookbook.Roll(entries, rng);
+ if (ReferenceEquals(picked, a)) countA++;
+ else if (ReferenceEquals(picked, b)) countB++;
+ else if (ReferenceEquals(picked, c)) countC++;
+ else countNull++;
+ }
+
+ Assert.InRange(countA, 4500, 5500);
+ Assert.InRange(countB, 2500, 3500);
+ Assert.InRange(countC, 1500, 2500);
+ // Probabilities sum to 1.0 → no null rolls.
+ Assert.True(countNull < 100);
+ }
+
+ [Fact]
+ public void Roll_SilenceTail_ReturnsNullOccasionally()
+ {
+ // Two entries that only cover 60% of the probability mass — the
+ // remaining 40% should roll as "silence" (null return).
+ var a = new DRWSoundEntry { Probability = 0.3f };
+ var b = new DRWSoundEntry { Probability = 0.3f };
+ var entries = new List { a, b };
+
+ var rng = new Random(42);
+ int nullCount = 0;
+ for (int i = 0; i < 10000; i++)
+ {
+ if (SoundCookbook.Roll(entries, rng) is null)
+ nullCount++;
+ }
+ Assert.InRange(nullCount, 3500, 4500); // ~40% ± margin
+ }
+
+ [Fact]
+ public void Roll_WithSoundTable_LooksUpBySound()
+ {
+ var table = new SoundTable();
+ var footstep = new DRWSoundEntry { Probability = 1f, Volume = 0.7f };
+ table.Sounds[DRWSound.Footstep1] = new SoundData();
+ table.Sounds[DRWSound.Footstep1].Entries.Add(footstep);
+
+ var picked = SoundCookbook.Roll(table, DRWSound.Footstep1, Seed(1));
+ Assert.Same(footstep, picked);
+ }
+
+ [Fact]
+ public void Roll_WithSoundTable_MissingSound_ReturnsNull()
+ {
+ var table = new SoundTable(); // no entries at all
+ Assert.Null(SoundCookbook.Roll(table, DRWSound.Attack1, Seed(1)));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Audio/WaveDecoderTests.cs b/tests/AcDream.Core.Tests/Audio/WaveDecoderTests.cs
new file mode 100644
index 0000000..e144c6a
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Audio/WaveDecoderTests.cs
@@ -0,0 +1,104 @@
+using AcDream.Core.Audio;
+using Xunit;
+
+namespace AcDream.Core.Tests.Audio;
+
+public sealed class WaveDecoderTests
+{
+ // A minimal WAVEFORMATEX header:
+ // wFormatTag u16 = 0x0001 (PCM)
+ // nChannels u16 = 1 (mono)
+ // nSamplesPerSec u32 = 22050
+ // nAvgBytesPerSec u32 = 44100
+ // nBlockAlign u16 = 2
+ // wBitsPerSample u16 = 16
+ // cbSize u16 = 0
+ private static byte[] MakePcmHeader(ushort channels = 1, uint rate = 22050, ushort bits = 16)
+ {
+ byte[] h = new byte[18];
+ h[0] = 0x01; h[1] = 0x00; // fmtTag PCM
+ h[2] = (byte)channels; h[3] = (byte)(channels >> 8);
+ h[4] = (byte)rate; h[5] = (byte)(rate >> 8);
+ h[6] = (byte)(rate >> 16); h[7] = (byte)(rate >> 24);
+ uint avg = rate * channels * (uint)(bits / 8);
+ h[8] = (byte)avg; h[9] = (byte)(avg >> 8);
+ h[10] = (byte)(avg >> 16); h[11] = (byte)(avg >> 24);
+ ushort blockAlign = (ushort)(channels * (bits / 8));
+ h[12] = (byte)blockAlign; h[13] = (byte)(blockAlign >> 8);
+ h[14] = (byte)bits; h[15] = (byte)(bits >> 8);
+ // cbSize = 0 (padding at 16..17)
+ return h;
+ }
+
+ [Fact]
+ public void Decode_PcmHeader_ReturnsWaveData()
+ {
+ byte[] header = MakePcmHeader(channels: 1, rate: 22050, bits: 16);
+ byte[] data = new byte[22050 * 2]; // 1 second of 16-bit mono
+
+ var decoded = WaveDecoder.Decode(header, data);
+
+ Assert.NotNull(decoded);
+ Assert.Equal(1, decoded!.ChannelCount);
+ Assert.Equal(22050, decoded.SampleRate);
+ Assert.Equal(16, decoded.BitsPerSample);
+ Assert.Same(data, decoded.PcmBytes);
+ // Duration should be ~1 second.
+ Assert.InRange(decoded.Duration.TotalSeconds, 0.99, 1.01);
+ }
+
+ [Fact]
+ public void Decode_StereoPcm_Works()
+ {
+ byte[] header = MakePcmHeader(channels: 2, rate: 44100, bits: 16);
+ byte[] data = new byte[44100 * 2 * 2]; // 1 second stereo
+ var decoded = WaveDecoder.Decode(header, data);
+
+ Assert.NotNull(decoded);
+ Assert.Equal(2, decoded!.ChannelCount);
+ Assert.Equal(44100, decoded.SampleRate);
+ }
+
+ [Fact]
+ public void Decode_Mp3Header_ReturnsNull()
+ {
+ // wFormatTag = 0x0055 (MPEGLAYER3) — not yet supported.
+ byte[] header = new byte[30];
+ header[0] = 0x55; header[1] = 0x00;
+ header[2] = 0x01; header[4] = 0x44; header[5] = 0xAC; // stereo 44.1kHz
+ byte[] data = new byte[1024];
+
+ var decoded = WaveDecoder.Decode(header, data);
+ Assert.Null(decoded);
+ }
+
+ [Fact]
+ public void Decode_AdpcmHeader_ReturnsNull()
+ {
+ byte[] header = new byte[20];
+ header[0] = 0x02; header[1] = 0x00; // ADPCM
+ byte[] data = new byte[1024];
+
+ var decoded = WaveDecoder.Decode(header, data);
+ Assert.Null(decoded);
+ }
+
+ [Fact]
+ public void Decode_TruncatedHeader_ReturnsNull()
+ {
+ byte[] header = new byte[5];
+ byte[] data = new byte[100];
+ Assert.Null(WaveDecoder.Decode(header, data));
+ }
+
+ [Fact]
+ public void PeekFormat_ReturnsCorrectTag()
+ {
+ Assert.Equal(WaveDecoder.WaveFormatTag.Pcm, WaveDecoder.PeekFormat(MakePcmHeader()));
+
+ byte[] mp3Header = new byte[4] { 0x55, 0x00, 0x01, 0x00 };
+ Assert.Equal(WaveDecoder.WaveFormatTag.Mp3, WaveDecoder.PeekFormat(mp3Header));
+
+ Assert.Equal(WaveDecoder.WaveFormatTag.Unknown, WaveDecoder.PeekFormat(null!));
+ }
+}