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; /// /// Resolves retail's UI sound table from the installed dats and pins the answer. /// /// /// ClientUISystem::GetUISoundTable @ 0x00563FB0 does NOT hold a /// hard-coded DID — it calls DBObj::GetByEnum @ 0x00415490, and /// DBCache::GetDIDFromEnum @ 0x00413940 resolves the answer through /// two EnumIDMap hops (DB_TYPE_DID_MAPPER, DID range /// 0x25xxxxxx, fetched as cache type 0x26): 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 0x20xxxxxx value. /// /// /// /// Walking it against the installed dats resolves master 0x25000000 → /// slot-7 map 0x250000100x2000004B, and that table contains /// exactly the 32 UI_* slots (UI_EnterPortal 0x6A through /// UI_Thunder6 0x8A) — content that confirms the walk independently of /// the decode. /// /// /// /// Skips cleanly when the dats are absent (CI), matching every other /// installed-dat test in this suite. /// /// public sealed class UiSoundTableResolutionTests { /// /// 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. /// private const uint UiSoundTableTypeKey = 0x10000003u; /// The enum slot GetUISoundTable asks for. private const uint UiSoundTableEnumSlot = 7u; /// /// 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. /// 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(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(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(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); } }