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 ISoundRandom _rng; public AudioHookSink( OpenAlAudioEngine engine, DatSoundCache cache, IEntitySoundTable entitySoundTables, ISoundRandom? 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 ?? new SoundRandom(); } public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) { if (!_engine.IsAvailable) return; switch (hook) { case SoundHook s: // A bare wave with no authored volume/priority and no // probability to gate on: retail's `PlaySoundA(DataID, obj, // prio, prob, vol)` path is handed 1.0/1.0 by this hook. Play(entityId, entityWorldPosition, (uint)s.Id, volume: 1f, priority: 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. // Volume is NOT clamped here: the dat field is an unbounded // gain (shipped values reach 10.0) and retail clamps only // after the distance divide, so clamping at the field would // cut a loud sound's audible range. A2 owns that clamp. Play(entityId, entityWorldPosition, waveId: (uint)stw.SoundId, volume: stw.Volume > 0 ? stw.Volume : 1f, priority: stw.Priority); break; // All the visual-only hooks (Scale, Luminous, Diffuse, …) // are for other sinks to handle. } } /// /// Plays a server-addressed SoundType slot — retail's /// SoundManager::PlaySoundA(SoundType, CPhysicsObj*, float) @ /// 0x00550AF0, reached from CPhysicsObj::play_sound @ /// 0x0050F460. /// /// /// Two asymmetries with the animation-hook path above, both from the decode /// and both deliberate: the sound plays at the wire volume and the /// SoundTable entry's own volume is ignored (the hook path does the /// opposite), while the entry's probability still gates it and the entry's /// priority still drives voice eviction. An object with no SoundTable plays /// nothing at all — retail early-returns before reaching the mixer. /// /// public void PlayServerSound( uint entityId, Vector3 worldPosition, uint soundType, float wireVolume) { if (!_engine.IsAvailable) return; uint tableId = _entitySoundTables.GetSoundTableId(entityId); if (tableId == 0) return; SoundTable? table = _cache.GetSoundTable(tableId); if (table is null) return; var entry = SoundCookbook.Select(table, (DRWSound)soundType, _rng); if (entry is null) return; Play( entityId, worldPosition, waveId: (uint)entry.Id, volume: wireVolume, priority: entry.Priority); } /// /// Play a sound-bearing animation hook through the INTERFACE bus — from /// centre, distance 0, unaffected by the world-audio suspension that /// covers reveal holds. This is the route for hooks authored on /// UI-owned presentations: retail's portal tunnel is gmSmartBoxUI, /// and its in-tunnel SoundTweakedHook accompanies the viewer rather /// than a world object. Routing it through the world 3-D path instead /// killed it twice over after Campaign A slice A2 — the hook's synthetic /// owner sits at the world origin, usually beyond the −50 dB no-allocate /// radius, and the world pool is suspended for the whole transit hold — /// which is exactly why the enter/exit cues (already on this bus) were /// audible while the tunnel interior was silent. /// public void OnUiHook(uint entityId, AnimationHook hook) { if (!_engine.IsAvailable) return; switch (hook) { case SoundHook s: PlayUi((uint)s.Id, volume: 1f); break; case SoundTableHook st: // A UI-owned presentation resolves through the owner's table // exactly like the world path; the tunnel's synthetic owner // carries none, so this is inert there but keeps the route // complete for any UI owner that does. uint tableId = _entitySoundTables.GetSoundTableId(entityId); if (tableId == 0) return; SoundTable? table = _cache.GetSoundTable(tableId); if (table is null) return; var entry = SoundCookbook.Select(table, st.SoundType, _rng); if (entry is null) return; PlayUi((uint)entry.Id, entry.Volume); break; case SoundTweakedHook stw: PlayUi( (uint)stw.SoundId, stw.Volume > 0 ? stw.Volume : 1f); break; } } private void PlayUi(uint waveId, float volume) { if (waveId == 0) return; WaveData? wave = _cache.GetWave(waveId); if (wave is null) return; _engine.PlayUiWave(waveId, wave, volume); } private void PlayFromSoundTable( uint entityId, Vector3 worldPos, DRWSound sound, float volumeMult = 1f) { uint tableId = _entitySoundTables.GetSoundTableId(entityId); if (tableId == 0) return; SoundTable? table = _cache.GetSoundTable(tableId); if (table is null) return; // Retail's two steps: uniform variant pick, then the entry's own // probability as a play/skip gate. A null here means retail would // have stayed silent on this trigger. var entry = SoundCookbook.Select(table, sound, _rng); if (entry is null) return; // Unlike the wire path (which uses the message's volume and ignores // the table's), the animation-hook path plays at the AUTHORED entry // volume — see the asymmetry in // docs/research/2026-08-08-audio-retail-server-sounds.md. Play( entityId, worldPos, waveId: (uint)entry.Id, volume: entry.Volume * volumeMult, priority: entry.Priority); } private void Play(uint entityId, Vector3 worldPos, uint waveId, float volume, float priority) { if (waveId == 0) return; WaveData? wave = _cache.GetWave(waveId); if (wave is null) return; _engine.Play3DWave( entityId, waveId, wave, worldPos, volume, priority); } } /// /// 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; }