fix(spells): load complete retail DAT catalog

Replace the incomplete 3,956-row production CSV with one immutable projection of all 6,266 records in portal.dat. Preserve retail formula targeting, shared Spellbook/MagicRuntime metadata ownership, and fail startup when the required SpellTable is absent.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-15 21:17:13 +02:00
parent 09612f9981
commit 0eab7497c1
19 changed files with 633 additions and 114 deletions

View file

@ -44,6 +44,37 @@ public static class RetailSpellFormula
};
}
/// <summary>
/// Retail <c>SpellFormula::GetTargetingType @ 0x005BC910</c> followed by
/// <c>SpellComponentTable::GetTargetTypeFromComponentID @ 0x005BBF50</c>.
/// The native <c>SpellFormula</c> has a vtable before its eight components.
/// Retail starts at native offset index 5 (component slot 4), scans slots
/// 5 through 7 for the end of the formula, then maps the final populated
/// component. This deliberately does not search for the first recognized
/// target component.
/// </summary>
public static uint GetTargetingType(IReadOnlyList<uint> components)
{
ArgumentNullException.ThrowIfNull(components);
if (components.Count <= 4)
return 0u;
int last = 4;
while (last < 7 && last + 1 < components.Count && components[last + 1] != 0u)
last++;
return GetTargetTypeFromComponentId(components[last]);
}
/// <summary>Retail target-component ID to ItemType mask mapping.</summary>
public static uint GetTargetTypeFromComponentId(uint componentId) => componentId switch
{
0x31u or 0x32u or 0x33u or 0x34u or 0x35u or 0x36u
or 0x37u or 0x38u or 0x3Cu or 0x3Du or 0x3Eu or 0xBEu => 0x10u,
0x39u => 0x00088B8Fu,
0x3Bu => 0x10010000u,
_ => 0u,
};
/// <summary>
/// Retail <c>CSpellBase::InqScarabOnlyFormula</c> (0x00597050).
/// Foci and the infused-magic augmentations replace the ordinary taper

View file

@ -1,35 +1,17 @@
using System.Collections.Generic;
namespace AcDream.Core.Spells;
/// <summary>
/// Per-spell static metadata loaded once at startup from
/// <c>docs/research/data/spells.csv</c> via <see cref="SpellTable"/>.
/// One record per known spell id (3,956 entries in the retail dump).
///
/// <para>
/// Used for:
/// </para>
/// <list type="bullet">
/// <item>Buff / debuff bar labels (<see cref="Name"/>,
/// <see cref="School"/>, <see cref="IconId"/>).</item>
/// <item>Stacking aggregation (<see cref="Family"/> — only one
/// enchantment per family-bucket is active; this is what
/// <c>EnchantmentMath</c> uses to filter out superseded
/// buffs in <c>LocalPlayerState.GetMaxApprox</c>).</item>
/// <item>Spell tooltips (<see cref="Description"/>,
/// <see cref="ManaCost"/>, <see cref="Duration"/>,
/// <see cref="SpellWords"/>).</item>
/// <item>Cast-bar audio + animation cues
/// (<see cref="SpellWords"/> drives the chant).</item>
/// </list>
///
/// <para>
/// Fields not exposed (yet) from the 35-column source CSV: SortKey,
/// Difficulty, Flags, Generation, IsFastWindup, IsIrresistible,
/// IsOffensive, IsUntargetted, Speed, CasterEffect, TargetEffect,
/// TargetMask, Type, plus 10 anonymous Unknown1..10. Add them on
/// demand as panels grow.
/// </para>
/// Immutable client metadata for one retail spell. Production records are
/// projected from portal.dat's SpellTable (0x0E00000E); the legacy CSV reader
/// remains available only for focused fixtures and historical tooling.
/// </summary>
/// <remarks>
/// The primary constructor preserves the former metadata surface while the
/// init-only properties retain the rest of retail's <c>CSpellBase</c> record.
/// Retail schema: <c>CSpellBase::UnPack @ 0x00597290</c>.
/// </remarks>
public sealed record SpellMetadata(
uint SpellId,
string Name,
@ -55,7 +37,24 @@ public sealed record SpellMetadata(
uint TargetMask,
int SpellType)
{
public bool IsSelfTargeted => (Flags & 0x00000008u) != 0;
public bool IsBeneficial => (Flags & 0x00000004u) != 0;
public bool IsProjectile => (Flags & 0x00000100u) != 0;
public MagicSchool SchoolId { get; init; }
public IReadOnlyList<uint> FormulaComponents { get; init; } = [];
public uint FormulaVersion { get; init; }
public float ComponentLoss { get; init; }
public float BaseRangeConstant { get; init; }
public float BaseRangeModifier { get; init; }
public float SpellEconomyModifier { get; init; }
public uint FizzleEffect { get; init; }
public double RecoveryInterval { get; init; }
public float RecoveryAmount { get; init; }
public uint NonComponentTargetType { get; init; }
public uint FormulaTargetType { get; init; }
public uint ManaModifier { get; init; }
public float DegradeModifier { get; init; }
public float DegradeLimit { get; init; }
public double PortalLifetime { get; init; }
public bool IsSelfTargeted => (Flags & (uint)SpellFlags.SelfTargeted) != 0;
public bool IsBeneficial => (Flags & (uint)SpellFlags.Beneficial) != 0;
public bool IsProjectile => (Flags & (uint)SpellFlags.Projectile) != 0;
}

View file

@ -6,10 +6,9 @@ using System.IO;
namespace AcDream.Core.Spells;
/// <summary>
/// Loads + queries <see cref="SpellMetadata"/> from a CSV at startup.
/// Source: <c>docs/research/data/spells.csv</c> (RFC 4180-ish, 35
/// columns, 3,956 rows). Loaded once, used by panels + by
/// <c>EnchantmentMath</c> for buff stacking aggregation.
/// Immutable lookup of retail spell metadata. Production builds create this
/// table from portal.dat through the App-layer <c>MagicCatalog</c>. The CSV
/// parser below remains for historical tooling and small synthetic tests.
///
/// <para>
/// Hand-rolled CSV parser — the only complication is the
@ -35,6 +34,21 @@ public sealed class SpellTable
_byId = byId;
}
/// <summary>Create a canonical immutable lookup from projected metadata.</summary>
public static SpellTable Create(IEnumerable<SpellMetadata> metadata)
{
ArgumentNullException.ThrowIfNull(metadata);
var byId = new Dictionary<uint, SpellMetadata>();
foreach (SpellMetadata spell in metadata)
{
ArgumentNullException.ThrowIfNull(spell);
if (!byId.TryAdd(spell.SpellId, spell))
throw new ArgumentException(
$"Duplicate spell id 0x{spell.SpellId:X8}.", nameof(metadata));
}
return new SpellTable(byId);
}
/// <summary>Number of spells loaded.</summary>
public int Count => _byId.Count;
@ -154,7 +168,10 @@ public sealed class SpellTable
spellId, name, school, family, iconId, words, duration,
mana, isDebuff, isFellow, description, sortKey, difficulty,
flags, generation, fastWindup, offensive, untargeted, speed,
casterEffect, targetEffect, targetMask, spellType);
casterEffect, targetEffect, targetMask, spellType)
{
SchoolId = ParseSchool(school),
};
}
return new SpellTable(byId);
@ -187,6 +204,16 @@ public sealed class SpellTable
return string.Equals(fields[i], "True", StringComparison.OrdinalIgnoreCase);
}
private static MagicSchool ParseSchool(string school) => school switch
{
"War Magic" => MagicSchool.WarMagic,
"Life Magic" => MagicSchool.LifeMagic,
"Item Enchantment" => MagicSchool.ItemEnchantment,
"Creature Enchantment" => MagicSchool.CreatureEnchantment,
"Void Magic" => MagicSchool.VoidMagic,
_ => MagicSchool.None,
};
/// <summary>
/// Hand-rolled RFC 4180-ish CSV row parser. Handles double-quoted
/// fields with embedded commas (the Description column). Embedded

View file

@ -23,7 +23,8 @@ public sealed class Spellbook
private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8)
.Select(_ => new List<uint>()).ToArray();
private readonly Dictionary<uint, uint> _desiredComponents = new();
private readonly SpellTable _table;
private SpellTable _table;
private bool _metadataInstalled;
private uint _spellbookFilters = 0x3FFFu;
/// <summary>
@ -37,6 +38,7 @@ public sealed class Spellbook
public Spellbook(SpellTable? table = null)
{
_table = table ?? SpellTable.Empty;
_metadataInstalled = table is not null;
}
/// <summary>
@ -51,6 +53,28 @@ public sealed class Spellbook
/// Returns <see cref="SpellTable.Empty"/> if none was provided.</summary>
public SpellTable Metadata => _table;
/// <summary>
/// Installs the production metadata table after DatCollection opens.
/// GameWindow constructs client state before Silk's OnLoad callback, while
/// portal.dat becomes available inside OnLoad. The table may be installed
/// once; replacing a populated catalog would invalidate active stacking and
/// presentation decisions.
/// </summary>
public void InstallMetadata(SpellTable table)
{
ArgumentNullException.ThrowIfNull(table);
if (_metadataInstalled)
{
if (ReferenceEquals(_table, table)) return;
throw new InvalidOperationException("Spell metadata is already installed.");
}
_table = table;
_metadataInstalled = true;
if (_learnedSpells.Count != 0) NotifySpellbookChanged();
if (_activeById.Count != 0) NotifyEnchantmentsChanged();
}
/// <summary>
/// Issue #6 — combined buff modifier for a vital stat. Aggregates
/// over <see cref="ActiveEnchantments"/> through