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;
}