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";