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>
98 lines
3.5 KiB
C#
98 lines
3.5 KiB
C#
using System.Linq;
|
|
using AcDream.Core.Audio;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Audio;
|
|
|
|
/// <summary>
|
|
/// Conformance tests for retail's <c>AdminEnvirons</c> code → interface sound
|
|
/// table, read case-by-case out of the switch in
|
|
/// <c>CPlayerSystem::Handle_Admin__Environs</c> @ <c>0x0055DE20</c>
|
|
/// (<c>0x0055E0C6</c>..<c>0x0055E2C7</c>).
|
|
/// </summary>
|
|
public sealed class EnvironSoundCueMapTests
|
|
{
|
|
[Theory]
|
|
// Every case in retail's switch, in address order.
|
|
[InlineData(0x65u, SoundId.UI_Roar)]
|
|
[InlineData(0x66u, SoundId.UI_Bell)]
|
|
[InlineData(0x67u, SoundId.UI_Chant1)]
|
|
[InlineData(0x68u, SoundId.UI_Chant2)]
|
|
[InlineData(0x69u, SoundId.UI_DarkWhispers1)]
|
|
[InlineData(0x6Au, SoundId.UI_DarkWhispers2)]
|
|
[InlineData(0x6Bu, SoundId.UI_DarkLaugh)]
|
|
[InlineData(0x6Cu, SoundId.UI_DarkWind)]
|
|
[InlineData(0x6Du, SoundId.UI_DarkSpeech)]
|
|
[InlineData(0x6Eu, SoundId.UI_Drums)]
|
|
[InlineData(0x6Fu, SoundId.UI_GhostSpeak)]
|
|
[InlineData(0x70u, SoundId.UI_Breathing)]
|
|
[InlineData(0x71u, SoundId.UI_Howl)]
|
|
[InlineData(0x72u, SoundId.UI_LostSouls)]
|
|
[InlineData(0x75u, SoundId.UI_Squeal)]
|
|
[InlineData(0x76u, SoundId.UI_Thunder1)]
|
|
[InlineData(0x77u, SoundId.UI_Thunder2)]
|
|
[InlineData(0x78u, SoundId.UI_Thunder3)]
|
|
[InlineData(0x79u, SoundId.UI_Thunder4)]
|
|
[InlineData(0x7Au, SoundId.UI_Thunder5)]
|
|
[InlineData(0x7Bu, SoundId.UI_Thunder6)]
|
|
public void EveryRetailCase_MapsToItsSound(uint code, SoundId expected)
|
|
{
|
|
Assert.True(EnvironSoundCueMap.TryGetSound(code, out SoundId sound));
|
|
Assert.Equal(expected, sound);
|
|
}
|
|
|
|
[Theory]
|
|
// Retail's switch has NO case for these two, mid-run: they fall through and
|
|
// play nothing. This is exactly why the port is a table and not arithmetic.
|
|
[InlineData(0x73u)]
|
|
[InlineData(0x74u)]
|
|
public void GapCodes_HaveNoCase(uint code)
|
|
{
|
|
Assert.False(EnvironSoundCueMap.TryGetSound(code, out _));
|
|
}
|
|
|
|
[Theory]
|
|
// Below the run are the fog/lighting overrides (1..6, handled elsewhere);
|
|
// 0x7C is one past the last case — the switch ends at 0x7B.
|
|
[InlineData(0u)]
|
|
[InlineData(1u)]
|
|
[InlineData(6u)]
|
|
[InlineData(0x64u)]
|
|
[InlineData(0x7Cu)]
|
|
[InlineData(0xFFu)]
|
|
public void CodesOutsideTheRun_HaveNoCase(uint code)
|
|
{
|
|
Assert.False(EnvironSoundCueMap.TryGetSound(code, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheTableIsNotAConstantOffset()
|
|
{
|
|
// 0x65..0x72 sit 0x11 below their SoundType, but 0x75 -> UI_Squeal (0x84)
|
|
// is 0x0F, because the two-code gap does not shift the sound run. An
|
|
// offset port would mis-map everything from 0x75 up.
|
|
Assert.True(EnvironSoundCueMap.TryGetSound(0x72u, out SoundId lostSouls));
|
|
Assert.True(EnvironSoundCueMap.TryGetSound(0x75u, out SoundId squeal));
|
|
Assert.Equal(0x11u, (uint)lostSouls - 0x72u);
|
|
Assert.Equal(0x0Fu, (uint)squeal - 0x75u);
|
|
}
|
|
|
|
[Fact]
|
|
public void CoversExactlyTheTwentyOneInterfaceStingers()
|
|
{
|
|
// The UI_* stinger run is UI_Roar (0x76) through UI_Thunder6 (0x8A) —
|
|
// 21 sounds — and retail's switch has one case for each.
|
|
Assert.Equal(21, EnvironSoundCueMap.Codes.Count);
|
|
|
|
var sounds = EnvironSoundCueMap.Codes
|
|
.Select(code =>
|
|
{
|
|
EnvironSoundCueMap.TryGetSound(code, out SoundId sound);
|
|
return (uint)sound;
|
|
})
|
|
.OrderBy(value => value)
|
|
.ToList();
|
|
|
|
Assert.Equal(Enumerable.Range(0x76, 21).Select(v => (uint)v), sounds);
|
|
}
|
|
}
|