Two user findings from the Campaign A listening session. 1. The portal tunnel's in-flight sound was silent while its enter/exit cues played. The tunnel's authored SoundTweakedHook drained into the world 3-D path at its synthetic owner's origin (0,0,0) — after A2 that dies twice: the listener is usually beyond the -50 dB no-allocate radius, and the world pool is suspended for the whole transit hold. The cues the user COULD hear were on the interface bus, which has neither problem, and retail's tunnel is gmSmartBoxUI — UI-owned — so that bus is also the faithful route. UiPresentationHookSink now wraps the shared router for the tunnel: sound-bearing hooks go from-centre through the interface bus (AudioHookSink.OnUiHook); every other hook kind still reaches the particle/lighting/translucency sinks unchanged. 2. Ambience cut dead inside houses; retail keeps the outdoor soundscape in sky-lit interiors. This is TS-66, now retired: the ambient listener source resolves the per-cell CEnvCell.seen_outside bit through the physics cache (the same #107 field AdjustPosition reads) and converts the envcell-local origin through the cell's WorldTransform into landblock coordinates before the 3x3 walk centres on it — an outdoor Position's origin is already landblock-local, an envcell's is cell-local, and skipping that conversion would centre the walk wrongly by up to a landblock. A not-yet-resident cell record resolves to silence for that rebuild rather than a wrong walk. Sealed dungeons stay silent, which is retail-correct. The user also reports interiors carrying their own local sound in retail (hearth-type emitters). Statics already register their sound tables and route animation hooks, so the expectation is that the seen_outside fix plus existing emitters covers it; re-listen decides, and anything still missing becomes a precise follow-up. Full Release suite: 11,740 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
10 KiB
C#
266 lines
10 KiB
C#
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 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.
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Plays a server-addressed <c>SoundType</c> slot — retail's
|
||
/// <c>SoundManager::PlaySoundA(SoundType, CPhysicsObj*, float)</c> @
|
||
/// <c>0x00550AF0</c>, reached from <c>CPhysicsObj::play_sound</c> @
|
||
/// <c>0x0050F460</c>.
|
||
///
|
||
/// <para>
|
||
/// Two asymmetries with the animation-hook path above, both from the decode
|
||
/// and both deliberate: the sound plays at the <b>wire</b> 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.
|
||
/// </para>
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <c>gmSmartBoxUI</c>,
|
||
/// and its in-tunnel <c>SoundTweakedHook</c> 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.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// <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;
|
||
}
|