acdream/tests/AcDream.Core.Tests/Audio/UiSoundTableResolutionTests.cs

164 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>
[Trait("Lane", "InstalledDat")]
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);
}
}