164 lines
6.6 KiB
C#
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);
|
|
}
|
|
}
|