acdream/tools/SpellDump/Program.cs
Erik 255b0aaeda
All checks were successful
CI / linux-portable (push) Successful in 3m18s
CI / windows-gate (push) Successful in 5m23s
CI / release (push) Successful in 2m3s
fix(magic): the foci map never loaded — casts demanded full components a focus should waive
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>
2026-08-20 21:28:22 +02:00

158 lines
7.1 KiB
C#

// Dump self-targeted beneficial spells from portal.dat's SpellTable so buff
// selection can be built on what the data actually says rather than on
// remembered spell names.
using AcDream.Content;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.Options;
using SysEnv = System.Environment;
string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
MagicCatalog catalog = MagicCatalog.Load(adapter);
SpellTable table = catalog.SpellTable;
Console.WriteLine($"spells loaded: {table.Count}");
if (args.Length > 0 && args[0] == "--cursors")
{
// Resolve the retail global-cursor enum table (6) to DAT surface ids, the
// same walk RetailCursorResolver does: portal master map -> table 6 -> id.
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master) || master is null)
throw new InvalidOperationException("no master enum map");
if (!master.ClientEnumToID.TryGetValue(6u, out uint cursorMapDid))
throw new InvalidOperationException("no cursor enum table 6");
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(cursorMapDid, out var cursorMap) || cursorMap is null)
throw new InvalidOperationException("cursor map missing");
foreach (var kv in cursorMap.ClientEnumToID.OrderBy(k => k.Key))
Console.WriteLine($"cursorEnum 0x{kv.Key:X2} -> surface 0x{kv.Value:X8}");
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";
foreach (uint id in table.SpellIds.OrderBy(i => i))
{
if (!table.TryGet(id, out var m)) continue;
if (!m.Name.ToLowerInvariant().Contains(want)) continue;
if (m.Generation != 1 && !m.Name.Contains(" I", StringComparison.Ordinal)) continue;
Console.WriteLine(
$"0x{m.SpellId:X4} fam{m.Family,-5} gen{m.Generation,-3} " +
$"flags=0x{m.Flags:X4} mask=0x{m.TargetMask:X4} " +
$"self={m.IsSelfTargeted,-5} unt={m.IsUntargeted,-5} ben={m.IsBeneficial,-5} " +
$"school={m.School,-20} {m.Name,-28} | {m.Description}");
}
return;
}
if (args.Length > 0 && args[0] == "--desc")
{
foreach (uint id in table.SpellIds.OrderBy(i => i))
{
if (!table.TryGet(id, out var m)) continue;
if (!m.IsSelfTargeted || !m.IsBeneficial || m.IsDebuff) continue;
// One representative per line by default; a name filter lifts that so
// the higher tiers' wording can be checked (they do not always match).
string descFilter = args.Length > 1 ? args[1].ToLowerInvariant() : "";
if (descFilter.Length == 0 && m.Generation != 1) continue;
if (descFilter.Length != 0 && !m.Name.ToLowerInvariant().Contains(descFilter)) continue;
Console.WriteLine($"fam {m.Family,-5} 0x{m.SpellId:X4} {m.Name,-40} | {m.Description}");
}
return;
}
if (args.Length > 0 && args[0] == "--skills")
{
var skillTable = dats.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u)
?? throw new InvalidOperationException("SkillTable 0x0E000004 missing");
Console.WriteLine($"skills: {skillTable.Skills.Count}");
foreach (var kv in skillTable.Skills.OrderBy(k => (uint)k.Key))
Console.WriteLine($" {(uint)kv.Key,-4} {kv.Value.Name}");
return;
}
if (args.Length > 0 && args[0] == "--enum")
{
string outPath = args.Length > 1
? args[1]
: Path.Combine("src", "AcDream.Plugins.MossTank", "SpellId.g.cs");
SpellDump.EmitEnum.Run(table, outPath, "AcDream.Plugins.MossTank", "SpellId");
return;
}
string filter = args.Length > 0 ? args[0].ToLowerInvariant() : "";
var rows = table.SpellIds
.Select(id => table.TryGet(id, out var m) ? m : null)
.Where(s => s is not null)!
.Select(s => s!)
.Where(s => s.IsSelfTargeted && s.IsBeneficial && !s.IsDebuff)
.Where(s => filter.Length == 0 || s.Name.ToLowerInvariant().Contains(filter))
.OrderBy(s => s.Family).ThenBy(s => s.Generation)
.ToList();
Console.WriteLine($"self-targeted beneficial: {rows.Count}\n");
Console.WriteLine($"{"family",-8} {"gen",-4} {"id",-8} {"school",-12} {"mana",-5} {"dur",-8} name");
foreach (var s in rows)
{
Console.WriteLine(
$"{s.Family,-8} {s.Generation,-4} 0x{s.SpellId:X4} {s.School,-12} {s.ManaCost,-5} {s.Duration,-8:F0} {s.Name}");
}