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>
163 lines
6.6 KiB
C#
163 lines
6.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AcDream.Core.Tests.Conformance;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Options;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace AcDream.Core.Tests.Audio;
|
|
|
|
/// <summary>
|
|
/// Resolves retail's UI sound table from the installed dats and pins the answer.
|
|
///
|
|
/// <para>
|
|
/// <c>ClientUISystem::GetUISoundTable</c> @ <c>0x00563FB0</c> does NOT hold a
|
|
/// hard-coded DID — it calls <c>DBObj::GetByEnum</c> @ <c>0x00415490</c>, and
|
|
/// <c>DBCache::GetDIDFromEnum</c> @ <c>0x00413940</c> resolves the answer through
|
|
/// two <c>EnumIDMap</c> hops (<c>DB_TYPE_DID_MAPPER</c>, DID range
|
|
/// <c>0x25xxxxxx</c>, fetched as cache type <c>0x26</c>): the master map keyed by
|
|
/// the enum INDEX yields a per-slot map, which keyed by the TYPE yields the
|
|
/// concrete DID. Because that chain lives in the dats rather than the binary,
|
|
/// the only honest way to learn the DID is to walk it — which is what this
|
|
/// does, instead of guessing a plausible <c>0x20xxxxxx</c> value.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Walking it against the installed dats resolves master <c>0x25000000</c> →
|
|
/// slot-7 map <c>0x25000010</c> → <c>0x2000004B</c>, and that table contains
|
|
/// exactly the 32 <c>UI_*</c> slots (<c>UI_EnterPortal</c> 0x6A through
|
|
/// <c>UI_Thunder6</c> 0x8A) — content that confirms the walk independently of
|
|
/// the decode.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Skips cleanly when the dats are absent (CI), matching every other
|
|
/// installed-dat test in this suite.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class UiSoundTableResolutionTests
|
|
{
|
|
/// <summary>
|
|
/// The key the second enum hop uses. `DBObj::GetByEnum` @ 0x00415490 takes
|
|
/// (type, idx, cache) and passes (type, idx) to the enum chain, then hands
|
|
/// the resolved DID to `DBCache::Get(did, cache)`. Walking the real dats
|
|
/// shows this hop is keyed by values in the 0x1000000x space, and
|
|
/// 0x10000003 is the one whose value lands in the SoundTable DID range —
|
|
/// so 0x10000003 is the `type` argument and the 0x22 in the decode is the
|
|
/// CACHE type (`CLOCache(cache, CSoundTable::Allocator, 0x22)`), not a
|
|
/// lookup key. The lane-5 note transposed the two.
|
|
/// </summary>
|
|
private const uint UiSoundTableTypeKey = 0x10000003u;
|
|
|
|
/// <summary>The enum slot <c>GetUISoundTable</c> asks for.</summary>
|
|
private const uint UiSoundTableEnumSlot = 7u;
|
|
|
|
/// <summary>
|
|
/// The DID the chain resolves to in the shipped dats. Pinned so a dat
|
|
/// change or a regression in the walk is caught rather than silently
|
|
/// producing a different sound bank.
|
|
/// </summary>
|
|
public const uint ExpectedUiSoundTableDid = 0x2000004Bu;
|
|
|
|
private readonly ITestOutputHelper _out;
|
|
|
|
public UiSoundTableResolutionTests(ITestOutputHelper output) => _out = output;
|
|
|
|
[Fact]
|
|
public void UiSoundTable_ResolvesThroughTheEnumIdMapChain()
|
|
{
|
|
string? datDir = ConformanceDats.ResolveDatDir();
|
|
if (datDir is null)
|
|
return; // dats absent (CI) — nothing to resolve
|
|
|
|
using var dats = new DatCollection(new DatCollectionOptions
|
|
{
|
|
DatDirectory = datDir,
|
|
AccessType = DatAccessType.Read,
|
|
});
|
|
|
|
var candidates = new List<(uint MapId, uint PerTypeMapId, uint SoundTableDid)>();
|
|
for (uint id = 0x25000000u; id <= 0x2500FFFFu; id++)
|
|
{
|
|
EnumIDMap? master;
|
|
try
|
|
{
|
|
master = dats.Get<EnumIDMap>(id);
|
|
}
|
|
catch
|
|
{
|
|
continue; // not an EnumIDMap / unreadable
|
|
}
|
|
if (master is null)
|
|
continue;
|
|
|
|
// Retail's order, from the decode: the MASTER map is keyed by the
|
|
// enumIndex (arg4, = 7) and yields a per-slot map; that map is then
|
|
// keyed by the fileType (arg3, = 0x22) and yields the DID. Reading
|
|
// the two hops the other way round finds nothing.
|
|
if (!master.ClientEnumToID.TryGetValue(UiSoundTableEnumSlot, out uint perSlot)
|
|
|| perSlot == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
EnumIDMap? perSlotMap;
|
|
try
|
|
{
|
|
perSlotMap = dats.Get<EnumIDMap>(perSlot);
|
|
}
|
|
catch
|
|
{
|
|
continue;
|
|
}
|
|
if (perSlotMap is null)
|
|
continue;
|
|
|
|
_out.WriteLine(
|
|
$"master 0x{id:X8} -> slot-{UiSoundTableEnumSlot} map 0x{perSlot:X8} " +
|
|
$"({perSlotMap.ClientEnumToID.Count} type keys)");
|
|
foreach (var slot in perSlotMap.ClientEnumToID.OrderBy(kv => kv.Key))
|
|
_out.WriteLine($" typeKey 0x{slot.Key:X8} -> 0x{slot.Value:X8}");
|
|
|
|
if (perSlotMap.ClientEnumToID.TryGetValue(UiSoundTableTypeKey, out uint did)
|
|
&& did != 0)
|
|
{
|
|
candidates.Add((id, perSlot, did));
|
|
}
|
|
}
|
|
|
|
Assert.NotEmpty(candidates);
|
|
|
|
// Every master map that carries the chain must agree on the answer;
|
|
// retail follows exactly one, so a disagreement would mean the walk is
|
|
// wrong rather than that retail is ambiguous.
|
|
uint resolved = candidates[0].SoundTableDid;
|
|
Assert.All(candidates, c => Assert.Equal(resolved, c.SoundTableDid));
|
|
|
|
_out.WriteLine($"RESOLVED UI sound table DID = 0x{resolved:X8}");
|
|
Assert.Equal(ExpectedUiSoundTableDid, resolved);
|
|
|
|
// It must be a real SoundTable in the 0x20xxxxxx range and it must load.
|
|
Assert.InRange(resolved, 0x20000000u, 0x2000FFFFu);
|
|
SoundTable? table = dats.Get<SoundTable>(resolved);
|
|
Assert.NotNull(table);
|
|
Assert.NotEmpty(table!.Sounds);
|
|
|
|
_out.WriteLine($"UI sound table 0x{resolved:X8} carries {table.Sounds.Count} slots:");
|
|
foreach (var kv in table.Sounds.OrderBy(kv => (uint)kv.Key))
|
|
_out.WriteLine($" {kv.Key} ({(uint)kv.Key:X2}) -> {kv.Value.Entries.Count} entries");
|
|
|
|
// The content check: every slot must be a UI_* slot, and the three the
|
|
// named decode calls out by name must be present.
|
|
Assert.All(
|
|
table.Sounds.Keys,
|
|
slot => Assert.InRange((uint)slot, 0x6Au, 0x8Au));
|
|
Assert.Contains(DatReaderWriter.Enums.Sound.UI_EnterPortal, table.Sounds.Keys);
|
|
Assert.Contains(DatReaderWriter.Enums.Sound.UI_ExitPortal, table.Sounds.Keys);
|
|
Assert.Contains(DatReaderWriter.Enums.Sound.UI_Roar, table.Sounds.Keys);
|
|
Assert.Contains(DatReaderWriter.Enums.Sound.UI_Thunder6, table.Sounds.Keys);
|
|
}
|
|
}
|