acdream/src/AcDream.Core/Audio/EnvironSoundCueMap.cs
Erik 6eaa490bb3 feat(audio): Campaign A slice A4 — the interface sound bus
Retail's UI sound bank was absent, so three families of cue were silent:
the portal enter/exit stingers, the AdminEnvirons dungeon atmosphere
(chanting, drums, whispers, thunder — what players remember as dungeon
'music'), and every other interface slot.

The bank's DID is not a literal anywhere in retail: GetUISoundTable
@0x00563FB0 asks GetByEnum for enum slot 7, and DBCache::GetDIDFromEnum
@0x00413940 resolves it through two EnumIDMap hops off the portal dat
header's master map. UiSoundTableResolver walks that chain the way
RetailCursorResolver already walks it for cursors. Against the shipped
dats it resolves to 0x2000004B, and that table holds exactly the 32 UI_*
slots (UI_EnterPortal 0x6A .. UI_Thunder6 0x8A) — content that confirms
the walk independently of the decode. UiSoundTableResolutionTests pins
the walk, the DID, and the content, and skips when dats are absent.

Two corrections to the research along the way. The lane-5 note recorded
GetByEnum's arguments transposed: the 0x22 it called a fileType is the
CACHE type (CLOCache(cache, CSoundTable::Allocator, 0x22)) and the real
second-hop key is 0x10000003; walking it the other way finds nothing. And
its claim that the interface volume pref applies is wrong — GetAttenuation
with ambient=0 multiplies by the EFFECT knob, so retail's
interface_sound_volume stays the dead knob lane 1 byte-decoded it to be.

EnvironSoundCueMap is an explicit 21-case table read straight out of
Handle_Admin__Environs @0x0055DE20, not arithmetic: codes 0x65..0x72 sit
0x11 below their SoundType, but 0x73/0x74 have no case, so 0x75 lands on
UI_Squeal (0x84) where an offset gives 0x86, and the switch ends at 0x7B
with no 0x7C case. Verified case-by-case against the decomp rather than
from the lane note, whose tail table was ambiguous.

Cues are attached where retail plays them: the teleport-animation
boundary for the portal pair, and the AdminEnvirons handler for the
stingers. PlaySoundFromCenter's pan-0 / distance-0 shape is what
PlayUiWave already implements after A2.

Retires TS-54. Narrows AP-115 to its notice-presentation residual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:22:09 +02:00

67 lines
2.9 KiB
C#

using System.Collections.Frozen;
using System.Collections.Generic;
namespace AcDream.Core.Audio;
/// <summary>
/// Retail's <c>AdminEnvirons</c> code → interface <see cref="SoundId"/> table,
/// from the switch in <c>CPlayerSystem::Handle_Admin__Environs</c> @
/// <c>0x0055DE20</c> (<c>0x0055E07F..0x0055E2C7</c>). Each case plays
/// <c>SoundManager::PlaySoundFromCenter(Sound_UI_*, GetUISoundTable())</c> after
/// checking that the local player physics object and the UI table both exist.
///
/// <para>
/// This is what players remember as dungeon "music": the server drives the
/// chanting, drums, whispers and thunder through these codes. It is not a music
/// system — retail has none — but it is the atmosphere channel.
/// </para>
///
/// <para>
/// <b>An explicit table, deliberately.</b> The environ code and the SoundType
/// differ by 0x11 for most of the run, but NOT uniformly: codes <c>0x73</c> and
/// <c>0x74</c> have no case at all (retail falls through and plays nothing), so
/// <c>0x75</c> lands on <c>UI_Squeal</c> (0x84) rather than the 0x86 arithmetic
/// would give. Retail's switch is explicit; porting it as an offset would
/// mis-map every code from <c>0x75</c> up.
/// </para>
/// </summary>
public static class EnvironSoundCueMap
{
private static readonly FrozenDictionary<uint, SoundId> Map =
new Dictionary<uint, SoundId>
{
[0x65u] = SoundId.UI_Roar,
[0x66u] = SoundId.UI_Bell,
[0x67u] = SoundId.UI_Chant1,
[0x68u] = SoundId.UI_Chant2,
[0x69u] = SoundId.UI_DarkWhispers1,
[0x6Au] = SoundId.UI_DarkWhispers2,
[0x6Bu] = SoundId.UI_DarkLaugh,
[0x6Cu] = SoundId.UI_DarkWind,
[0x6Du] = SoundId.UI_DarkSpeech,
[0x6Eu] = SoundId.UI_Drums,
[0x6Fu] = SoundId.UI_GhostSpeak,
[0x70u] = SoundId.UI_Breathing,
[0x71u] = SoundId.UI_Howl,
[0x72u] = SoundId.UI_LostSouls,
// 0x73 and 0x74: no case in retail's switch — nothing plays.
[0x75u] = SoundId.UI_Squeal,
[0x76u] = SoundId.UI_Thunder1,
[0x77u] = SoundId.UI_Thunder2,
[0x78u] = SoundId.UI_Thunder3,
[0x79u] = SoundId.UI_Thunder4,
[0x7Au] = SoundId.UI_Thunder5,
[0x7Bu] = SoundId.UI_Thunder6,
}.ToFrozenDictionary();
/// <summary>
/// Map an <c>AdminEnvirons</c> change type to its interface sound, or false
/// when retail's switch has no case for it (including <c>0x73</c>/<c>0x74</c>
/// inside the run, and every code outside it).
/// </summary>
public static bool TryGetSound(uint changeType, out SoundId sound) =>
Map.TryGetValue(changeType, out sound);
/// <summary>Every code retail has a case for. Diagnostic and test use.</summary>
public static IReadOnlyCollection<uint> Codes => Map.Keys;
}