From 351723928f9c60d60101dd5e21c81381874af5b4 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Apr 2026 16:38:26 +0200 Subject: [PATCH] feat(audio): Phase E.2 OpenAL engine + SoundTable cookbook + hook wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full audio pipeline from MotionHook → OpenAL 3D playback. Faithful to retail's 16-voice pool, inverse-square falloff, and SoundTable probabilistic variant selection. Core layer (AcDream.Core/Audio): - WaveDecoder parses the WAVEFORMATEX in Wave dat headers. PCM (wFormatTag=1) decodes directly; MP3 (0x55) and ADPCM (0x02) return null + log (ACM compressed decoders need Windows winmm; cross-platform path deferred). Cites r05 §2.1-2.3 + ACE Wave.cs. - SoundCookbook.Roll implements the probability-weighted entry pick that gives retail footsteps their variation. Cumulative-distribution walk; silence tail when probabilities sum to <1. - DatSoundCache: ConcurrentDictionary-backed lazy load of Wave / SoundTable dats, decoded PCM memoized. App layer (AcDream.App/Audio): - OpenAlAudioEngine (Silk.NET.OpenAL): 16-source 3D pool with round-robin first-free, then evict-quieter-slot algorithm matching retail chunk_00550000.c FUN_00550ad0 exactly. Separate 4-source UI pool (source-relative). AL buffer cache keyed by Wave id. InverseDistanceClamped distance model. Fail-open when AL driver missing or ACDREAM_NO_AUDIO=1 — client continues without audio. - AudioHookSink routes SoundHook / SoundTableHook / SoundTweakedHook from the Phase E.1 animation-hook router into OpenAL. All three hook types fire on both player AND NPCs/monsters (the sequencer dispatches per-entity and the sink uses entity worldPos for 3D pan). - DictionaryEntitySoundTable holds per-entity SoundTable mapping, populated from Setup.DefaultSoundTable at hydration time. Server- sent overrides would take precedence here when wired. GameWindow integration: - OpenAL init in OnLoad after dat collection, suppressible via ACDREAM_NO_AUDIO=1. - SetListener called each OnRender frame with camera position + view basis vectors (fwd = -Z, up = +Y of inverse view). - AudioEngine disposed in OnClosing before dats. Tests: 6 WaveDecoder (PCM / MP3-null / ADPCM-null / stereo / truncated / peek) + 6 SoundCookbook (empty / single / 50-30-20 distribution within 5%, silence tail, table lookup, missing table key). Verified against r05 §2 + ACViewer export-path. Build green, 497 tests pass (up from 485). Ref: r05 §2 (Wave format), §5.3 (16-voice pool + eviction). Ref: FUN_00550ad0 (chunk_00550000.c:527) eviction algorithm. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/AcDream.App.csproj | 3 + src/AcDream.App/Audio/AudioHookSink.cs | 156 ++++++++ src/AcDream.App/Audio/OpenAlAudioEngine.cs | 375 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 61 +++ src/AcDream.Core/Audio/DatSoundCache.cs | 78 ++++ src/AcDream.Core/Audio/SoundCookbook.cs | 83 ++++ src/AcDream.Core/Audio/WaveDecoder.cs | 115 ++++++ .../Audio/SoundCookbookTests.cs | 97 +++++ .../Audio/WaveDecoderTests.cs | 104 +++++ 9 files changed, 1072 insertions(+) create mode 100644 src/AcDream.App/Audio/AudioHookSink.cs create mode 100644 src/AcDream.App/Audio/OpenAlAudioEngine.cs create mode 100644 src/AcDream.Core/Audio/DatSoundCache.cs create mode 100644 src/AcDream.Core/Audio/SoundCookbook.cs create mode 100644 src/AcDream.Core/Audio/WaveDecoder.cs create mode 100644 tests/AcDream.Core.Tests/Audio/SoundCookbookTests.cs create mode 100644 tests/AcDream.Core.Tests/Audio/WaveDecoderTests.cs 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!)); + } +}