fix(magic): the foci map never loaded — casts demanded full components a focus should waive
All checks were successful
CI / linux-portable (push) Successful in 3m18s
CI / windows-gate (push) Successful in 5m23s
CI / release (push) Successful in 2m3s

User report: with the correct focus, scarab and tapers in the pack, a level 1
spell would not cast, and the spell examine window listed the full legacy
component recipe where retail shows only scarab and taper. The question asked
was whether the 2013 client data is too old for foci. It is not -- the EoR
dats carry the map, and the whole client-side mechanism (the requirement
service, the scarab-only formula port, the examine-window routing) was already
built and wired. It was fed an empty table.

MagicCatalog resolved the school-to-foci map with
Resolve(enumValue: 0x10000001, enumCategory: 0x28). Retail's
SpellComponentTable::SchoolOfMagic2WCID @ 0x005BC1F0 calls
DBObj::GetByEnum(0x10000001, 4): master map -> category 0x10000001 -> key 4
-> the school->WCID EnumIDMap. The 0x28 on that call is the EnumIDMap DBTYPE
tag, and it had been read as a lookup category. The master map has no
category 0x28, the resolver returned 0, and the foci map loaded EMPTY --
silently, so a carried focus was never detected: HasRequiredComponents
demanded the full account-customized formula (refusing the cast) and
GetExamineComponents displayed it.

Found by measurement rather than re-reading the code: a SpellDump --foci probe
proved category 0x28 absent, then brute-forced the portal enum tree for ACE's
FociWCIDs and found them at 0x27000003 under category 0x10000001 key 4:

    school 1 -> 15271 Foci of Strife       (War)
    school 2 -> 15270 Foci of Verdancy     (Life)
    school 3 -> 15269 Foci of Artifice     (Item)
    school 4 -> 15268 Foci of Enchantment  (Creature)
    school 5 -> 43173 Foci of Shadow       (Void)

The new Lane=InstalledDat test pins exactly that: the loaded catalog must map
every school to ACE's FociWCIDs -- external constants from the server-side
authority, deliberately not derived from the code under test, so an empty or
wrongly-resolved map cannot pass vacuously.

The infusion-augmentation half of the retail gate (properties 0x126-0x129,
0x148) was already correct against the decomp, as were the scarab-only ID set
{1..6, 0x6E, 0x6F, 0x70, 0xC0, 0xC1} and the taper-count table.

Complete Release suite: 14,469 tests pass on the standard hermetic lane
filter, 0 failures; the new installed-DAT test passes against the real dats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-20 21:28:22 +02:00
parent cd6eefd0ba
commit 255b0aaeda
3 changed files with 123 additions and 4 deletions

View file

@ -85,6 +85,14 @@ public sealed class MagicCatalog
public bool IsComponentPack(uint weenieClassId) =>
Components.ContainsKey(weenieClassId);
/// <summary>
/// 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 <see cref="CreateRequirementService"/>.
/// </summary>
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<uint, uint>();
// 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<EnumIDMap>(
magicPackMapDid,

View file

@ -0,0 +1,50 @@
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.Content.Tests;
/// <summary>
/// Pins the school-to-foci-WCID map against the installed retail DATs.
/// </summary>
/// <remarks>
/// <para>
/// Retail <c>SpellComponentTable::SchoolOfMagic2WCID @ 0x005BC1F0</c> resolves
/// master map → category <c>0x10000001</c> → key <c>4</c> → the school→WCID
/// <see cref="DatReaderWriter.DBObjs.EnumIDMap"/> (<c>0x27000003</c> in the
/// EoR dats). The catalog used to pass the decomp's <c>0x28</c> — 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.
/// </para>
/// <para>
/// The expected WCIDs are EXTERNAL constants — ACE's
/// <c>Player_Spells.FociWCIDs</c>, 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.
/// </para>
/// </remarks>
[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));
}
}

View file

@ -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<DatReaderWriter.DBObjs.EnumIDMap>(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<DatReaderWriter.DBObjs.EnumIDMap>(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<DatReaderWriter.DBObjs.EnumIDMap>(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";