feat(audio): Phase E.2 OpenAL engine + SoundTable cookbook + hook wiring

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-18 16:38:26 +02:00
parent b04d393329
commit 351723928f
9 changed files with 1072 additions and 0 deletions

View file

@ -13,6 +13,9 @@
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" /> <PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" /> <PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
<PackageReference Include="Silk.NET.Input" Version="2.23.0" /> <PackageReference Include="Silk.NET.Input" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenAL" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenAL.Extensions.Creative" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenAL.Extensions.EXT" Version="2.23.0" />
<PackageReference Include="Serilog" Version="4.0.2" /> <PackageReference Include="Serilog" Version="4.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="StbTrueTypeSharp" Version="1.26.12" /> <PackageReference Include="StbTrueTypeSharp" Version="1.26.12" />

View file

@ -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;
/// <summary>
/// <see cref="IAnimationHookSink"/> that routes sound-bearing animation
/// hooks (<see cref="SoundHook"/>, <see cref="SoundTableHook"/>,
/// <see cref="SoundTweakedHook"/>) into the
/// <see cref="OpenAlAudioEngine"/>.
///
/// <para>
/// Wiring:
/// <list type="bullet">
/// <item><description>
/// <see cref="SoundHook"/> → direct play of <c>SoundHook.Id</c> (a
/// Wave dat id) at the entity's world position. Used for custom /
/// per-animation audio like weapon swoosh or spell chant.
/// </description></item>
/// <item><description>
/// <see cref="SoundTableHook"/> → look up the entity's SoundTable +
/// the hook's <c>SoundType</c>, roll one
/// <see cref="DatReaderWriter.Types.SoundEntry"/> via
/// <see cref="SoundCookbook"/>, play its wave. Retail's "footstep
/// that varies slightly" mechanism — also how attack / damage sounds
/// pick a creature-specific variant.
/// </description></item>
/// <item><description>
/// <see cref="SoundTweakedHook"/> → same as SoundHook but with
/// pitch / volume overrides baked into the hook.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Entity → SoundTable id is resolved via an <see cref="IEntitySoundTable"/>
/// callback passed in at construction; the renderer's per-entity state
/// bag knows the PhysicsObj's <c>SoundTableId</c> (retail:
/// <c>PhysicsObj.soundtable_id</c>).
/// </para>
/// </summary>
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);
}
}
/// <summary>
/// Callback the renderer uses to resolve the sound-table id for an
/// entity. Retail stores this on <c>PhysicsObj.soundtable_id</c>; our
/// renderer keeps per-entity state that includes it.
/// </summary>
public interface IEntitySoundTable
{
/// <summary>
/// Return the SoundTable dat id (0x20xxxxxx) for <paramref name="entityId"/>,
/// or 0 if no table is known (e.g. a static prop without audio).
/// </summary>
uint GetSoundTableId(uint entityId);
}
/// <summary>
/// Simple dictionary-backed <see cref="IEntitySoundTable"/>; the renderer
/// assigns entries as it hydrates entities.
/// </summary>
public sealed class DictionaryEntitySoundTable : IEntitySoundTable
{
private readonly Dictionary<uint, uint> _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;
}

View file

@ -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;
/// <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.
/// </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>
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<uint, uint> _bufferByWaveId = new();
// ── 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;
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<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 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;
}
/// <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, 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;
}
}

View file

@ -129,6 +129,13 @@ public sealed class GameWindow : IDisposable
// per-entity tick loop can just call it unconditionally. // per-entity tick loop can just call it unconditionally.
private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new(); 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. // Phase B.2: player movement mode.
private AcDream.App.Input.PlayerMovementController? _playerController; private AcDream.App.Input.PlayerMovementController? _playerController;
private AcDream.App.Rendering.ChaseCamera? _chaseCamera; private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
@ -559,6 +566,33 @@ public sealed class GameWindow : IDisposable
_dats = new DatCollection(_datDir, DatAccessType.Read); _dats = new DatCollection(_datDir, DatAccessType.Read);
_animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats); _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; uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}"); Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
@ -1114,6 +1148,17 @@ public sealed class GameWindow : IDisposable
CurrFrame = idleCycle.LowFrame, CurrFrame = idleCycle.LowFrame,
Sequencer = sequencer, 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 // 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. // Extract camera world position from the inverse of the view matrix.
System.Numerics.Matrix4x4.Invert(camera.View, out var invView); System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43); 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); var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null; 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. // _dats; Dispose cancels the token and waits up to 2s for the thread.
_streamer?.Dispose(); _streamer?.Dispose();
_liveSession?.Dispose(); _liveSession?.Dispose();
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_staticMesh?.Dispose(); _staticMesh?.Dispose();
_textureCache?.Dispose(); _textureCache?.Dispose();
_meshShader?.Dispose(); _meshShader?.Dispose();

View file

@ -0,0 +1,78 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.Audio;
/// <summary>
/// DatCollection-backed cache of decoded waves + SoundTable lookups.
///
/// <para>
/// Lazy-loads each Wave / SoundTable on first request, decodes Wave PCM
/// data via <see cref="WaveDecoder"/>, and memoizes the result. Safe to
/// call from any thread — the inner dicts are
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>.
/// </para>
///
/// <para>
/// Returns <c>null</c> for waves whose format tag isn't PCM (MP3 / ADPCM
/// fall through silently until a compressed-decoder path is wired up).
/// </para>
/// </summary>
public sealed class DatSoundCache
{
private readonly DatCollection _dats;
private readonly ConcurrentDictionary<uint, WaveData?> _waves = new();
private readonly ConcurrentDictionary<uint, SoundTable?> _tables = new();
public DatSoundCache(DatCollection dats)
{
_dats = dats;
}
/// <summary>
/// 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).
/// </summary>
public WaveData? GetWave(uint waveId)
{
if (_waves.TryGetValue(waveId, out var cached)) return cached;
var wave = _dats.Get<Wave>(waveId);
if (wave is null)
{
_waves[waveId] = null;
return null;
}
var decoded = WaveDecoder.Decode(wave.Header, wave.Data);
_waves[waveId] = decoded;
return decoded;
}
/// <summary>
/// Retrieve a SoundTable by dat id. Returns null if the table is
/// missing from the dats.
/// </summary>
public SoundTable? GetSoundTable(uint soundTableId)
{
if (_tables.TryGetValue(soundTableId, out var cached)) return cached;
var table = _dats.Get<SoundTable>(soundTableId);
_tables[soundTableId] = table;
return table;
}
/// <summary>
/// Total number of waves that have been accessed (hit or miss).
/// Useful for diagnostic overlays.
/// </summary>
public int CachedWaveCount => _waves.Count;
/// <summary>
/// Total number of SoundTables that have been accessed.
/// </summary>
public int CachedSoundTableCount => _tables.Count;
}

View file

@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using DatReaderWriter.DBObjs;
using DRWSound = DatReaderWriter.Enums.Sound;
namespace AcDream.Core.Audio;
/// <summary>
/// Probabilistic entry picker over a retail <see cref="SoundTable"/>.
///
/// <para>
/// Each <see cref="DatReaderWriter.Enums.Sound"/> key in
/// <c>SoundTable.Sounds</c> maps to a list of
/// <see cref="DatReaderWriter.Types.SoundEntry"/> 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.
/// </para>
///
/// <para>
/// 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 &lt; 1, the
/// remaining mass means "silence" — the call returns null. If probabilities
/// sum to &gt; 1 the picker still works correctly (it clamps on the last
/// entry).
/// </para>
/// </summary>
public static class SoundCookbook
{
/// <summary>
/// Pick one entry from a sound's variant list, weighted by probability.
/// Returns <c>null</c> when the rolled sample falls into the "silence"
/// remainder of the distribution (probability sum &lt; 1).
/// </summary>
public static DatReaderWriter.Types.SoundEntry? Roll(
IReadOnlyList<DatReaderWriter.Types.SoundEntry> 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;
}
/// <summary>
/// Convenience lookup: given a SoundTable + a retail
/// <see cref="DRWSound"/> key (e.g. Swoosh1, Footstep1), roll the
/// entry list and return the winning entry. Returns null if:
/// <list type="bullet">
/// <item><description><paramref name="table"/> has no mapping for
/// <paramref name="sound"/>.</description></item>
/// <item><description>The mapping's entry list is empty.</description></item>
/// <item><description>The probability roll hits the silence
/// tail.</description></item>
/// </list>
/// </summary>
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);
}
}

View file

@ -0,0 +1,115 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Audio;
/// <summary>
/// Decodes the AC <c>Wave</c> dat (`0x0A000000..0x0A00FFFF`) into playable
/// PCM data.
///
/// <para>
/// On-disk format (r05 §2.1 + ACE.DatLoader.FileTypes.Wave + ACViewer's
/// Wave.cs:32-72):
/// <code>
/// int32 headerSize
/// int32 dataSize
/// byte[headerSize] Header // raw WAVEFORMATEX — NO RIFF wrapper
/// byte[dataSize] Data // raw sample bytes
/// </code>
/// </para>
///
/// <para>
/// Header byte 0 (low byte of <c>WAVEFORMATEX.wFormatTag</c>):
/// <list type="bullet">
/// <item><description><c>0x01</c> linear PCM — feeds straight into OpenAL.</description></item>
/// <item><description><c>0x55</c> MPEG Layer 3 — retail decoded via winmm ACM.</description></item>
/// <item><description><c>0x02</c> Microsoft ADPCM — retail decoded via winmm ACM.</description></item>
/// </list>
/// </para>
///
/// <para>
/// 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 <c>null</c> and the caller logs + skips.
/// </para>
/// </summary>
public static class WaveDecoder
{
/// <summary>
/// Format tag from the first 2 bytes of the WAVEFORMATEX header.
/// Retail and ACE map identically; we use these exact values for logs.
/// </summary>
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)
}
/// <summary>
/// Parse a <c>Wave</c> dat record into a <see cref="WaveData"/> PCM
/// container. Returns <c>null</c> when the source format is compressed
/// (MP3 / ADPCM) and we don't yet have a decoder wired up, OR when the
/// header is malformed.
/// </summary>
/// <param name="header">Raw WAVEFORMATEX bytes (at least 14 bytes).</param>
/// <param name="data">Raw payload bytes following the header.</param>
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),
};
}
/// <summary>
/// Peek only the format tag byte. Useful for logging which formats
/// failed to decode without allocating a <see cref="WaveData"/>.
/// </summary>
public static WaveFormatTag PeekFormat(byte[] header)
{
if (header is null || header.Length < 2) return WaveFormatTag.Unknown;
ushort fmtTag = BinaryPrimitives.ReadUInt16LittleEndian(header);
return (WaveFormatTag)fmtTag;
}
}

View file

@ -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<DRWSoundEntry>(), Seed(1)));
}
[Fact]
public void Roll_SingleEntry_AlwaysReturnsIt()
{
var e = new DRWSoundEntry { Probability = 0.5f, Priority = 4f, Volume = 1f };
var entries = new List<DRWSoundEntry> { 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<DRWSoundEntry> { 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<DRWSoundEntry> { 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)));
}
}

View file

@ -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!));
}
}