diff --git a/src/AcDream.Content/MagicCatalog.cs b/src/AcDream.Content/MagicCatalog.cs index eb84219b..f6a8f52a 100644 --- a/src/AcDream.Content/MagicCatalog.cs +++ b/src/AcDream.Content/MagicCatalog.cs @@ -85,6 +85,14 @@ public sealed class MagicCatalog public bool IsComponentPack(uint weenieClassId) => Components.ContainsKey(weenieClassId); + /// + /// The foci (magic pack) WCID for a retail school id (1..5), or 0 when + /// unmapped. Exposed for the installed-DAT conformance test; production + /// reads go through . + /// + public uint MagicPackWcidForSchool(uint school) => + _magicPackWcidBySchool.TryGetValue(school, out uint wcid) ? wcid : 0u; + public int GetSpellLevel(uint spellId) => _spellLevels.TryGetValue(spellId, out int level) ? level @@ -162,12 +170,19 @@ public sealed class MagicCatalog } var magicPackWcidBySchool = new Dictionary(); - // Retail IsComponentPack resolves enum key 0x10000001 in category - // 0x28; that key is not itself a portal.dat file id. + // Retail SpellComponentTable::SchoolOfMagic2WCID @ 0x005BC1F0: + // DBObj::GetByEnum(0x10000001, 4) resolves master map -> category + // 0x10000001 -> key 4 -> the school->foci-WCID EnumIDMap + // (0x27000003 in the EoR dats: 1->15271 Strife, 2->15270 Verdancy, + // 3->15269 Artifice, 4->15268 Enchantment, 5->43173 Shadow — + // matching ACE's FociWCIDs). The decomp's 0x28 on that call is the + // EnumIDMap DBTYPE tag, not a lookup key; passing it as the category + // resolved nothing, so this map loaded EMPTY and a carried focus was + // never detected — every cast demanded the full component list. uint magicPackMapDid = RetailDataIdResolver.Resolve( dats, - 0x10000001u, - 0x28u); + enumValue: 0x4u, + enumCategory: 0x10000001u); if (magicPackMapDid != 0u && dats.Portal.TryGet( magicPackMapDid, diff --git a/tests/AcDream.Content.Tests/InstalledMagicCatalogFociTests.cs b/tests/AcDream.Content.Tests/InstalledMagicCatalogFociTests.cs new file mode 100644 index 00000000..689941c9 --- /dev/null +++ b/tests/AcDream.Content.Tests/InstalledMagicCatalogFociTests.cs @@ -0,0 +1,50 @@ +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.Content.Tests; + +/// +/// Pins the school-to-foci-WCID map against the installed retail DATs. +/// +/// +/// +/// Retail SpellComponentTable::SchoolOfMagic2WCID @ 0x005BC1F0 resolves +/// master map → category 0x10000001 → key 4 → the school→WCID +/// (0x27000003 in the +/// EoR dats). The catalog used to pass the decomp's 0x28 — the +/// EnumIDMap DBTYPE tag on that call — as the lookup category, which resolved +/// nothing: the map loaded EMPTY, a carried focus was never detected, and +/// every cast demanded the full component list while retail (and the spell +/// examine window there) asked only for scarabs and tapers. +/// +/// +/// The expected WCIDs are EXTERNAL constants — ACE's +/// Player_Spells.FociWCIDs, the server-side authority acdream must +/// agree with — deliberately not derived from the code under test, so an +/// empty or wrongly-resolved map cannot satisfy this vacuously. +/// +/// +[Trait("Lane", "InstalledDat")] +public sealed class InstalledMagicCatalogFociTests +{ + [Fact] + public void FociMap_ResolvesEveryRetailSchoolToAcesFociWcid() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + MagicCatalog catalog = MagicCatalog.Load(adapter); + + // ACE Player_Spells.FociWCIDs: War=Strife, Life=Verdancy, + // Item=Artifice, Creature=Enchantment, Void=Shadow. + Assert.Equal(15271u, catalog.MagicPackWcidForSchool(1u)); + Assert.Equal(15270u, catalog.MagicPackWcidForSchool(2u)); + Assert.Equal(15269u, catalog.MagicPackWcidForSchool(3u)); + Assert.Equal(15268u, catalog.MagicPackWcidForSchool(4u)); + Assert.Equal(43173u, catalog.MagicPackWcidForSchool(5u)); + } +} diff --git a/tools/SpellDump/Program.cs b/tools/SpellDump/Program.cs index 355b57c6..064d3b51 100644 --- a/tools/SpellDump/Program.cs +++ b/tools/SpellDump/Program.cs @@ -32,6 +32,60 @@ if (args.Length > 0 && args[0] == "--cursors") return; } +if (args.Length > 0 && args[0] == "--foci") +{ + // Retail SpellComponentTable::SchoolOfMagic2WCID @ 0x005BC1F0: master + // enum map 0x10000001, category 0x28 -> per-school foci WCID. Dump it so + // the client's foci detection can be checked against ACE's FociWCIDs + // (15268..15271, 43173) instead of assumed. + uint masterDid2 = (uint)dats.Portal.Header.MasterMapId; + if (!dats.Portal.TryGet(masterDid2, out var master2) || master2 is null) + throw new InvalidOperationException("no master enum map"); + Console.WriteLine($"master did=0x{masterDid2:X8}, categories: " + + string.Join(", ", master2.ClientEnumToID.Keys.OrderBy(k => k).Select(k => $"0x{k:X}"))); + // ACE's FociWCIDs are the ground truth for what the map must contain: + uint[] fociWcids = [15268, 15269, 15270, 15271, 43173]; + foreach (var cat in master2.ClientEnumToID.OrderBy(k => k.Key)) + { + DatReaderWriter.DBObjs.EnumIDMap? sub = null; + try + { + dats.Portal.TryGet(cat.Value, out sub); + } + catch { } + if (sub is null) continue; + // one level: does this sub-map itself map schools to foci wcids? + if (sub.ClientEnumToID.Values.Any(v => fociWcids.Contains(v))) + { + Console.WriteLine($"HIT level1 cat=0x{cat.Key:X} did=0x{cat.Value:X8}"); + foreach (var kv in sub.ClientEnumToID.OrderBy(k => k.Key)) + Console.WriteLine($" {kv.Key} -> {kv.Value}"); + } + // two levels: category -> sub-map -> did of another EnumIDMap + foreach (var kv2 in sub.ClientEnumToID) + { + DatReaderWriter.DBObjs.EnumIDMap? sub2 = null; + try + { + dats.Portal.TryGet(kv2.Value, out sub2); + } + catch { } // not every mapped DID is an EnumIDMap + if (sub2 is null) continue; + if (sub2.ClientEnumToID.Values.Any(v => fociWcids.Contains(v))) + { + Console.WriteLine($"HIT level2 cat=0x{cat.Key:X} key=0x{kv2.Key:X} did=0x{kv2.Value:X8}"); + foreach (var kv in sub2.ClientEnumToID.OrderBy(k => k.Key)) + Console.WriteLine($" {kv.Key} -> {kv.Value}"); + } + } + } + // and the school ids the spell table itself uses, for the same check + foreach (uint id in new uint[] { 0x0006, 0x001C, 0x022D, 0x0545, 0x14C7 }) + if (table.TryGet(id, out var sm)) + Console.WriteLine($" spell 0x{id:X4} {sm.Name,-36} schoolId={sm.SchoolId}"); + return; +} + if (args.Length > 0 && args[0] == "--flags") { string want = args.Length > 1 ? args[1].ToLowerInvariant() : "bane";