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

@ -46,11 +46,6 @@
<None Update="Rendering\Shaders\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Issue #11: copy spells.csv from docs/research/data/ to bin output's
data/ subdir so SpellTable.LoadFromCsv can find it at runtime. -->
<None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Phase D.2b: KSML-style panel markup assets (vitals.xml etc.) ship
next to the binary so MarkupDocument.Build can load them at runtime. -->
<None Include="UI\assets\**\*.xml">

View file

@ -803,12 +803,9 @@ public sealed class GameWindow : IDisposable
public readonly AcDream.Core.Social.SquelchState Squelch = new();
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents { get; private set; }
= System.Array.Empty<(uint Id, uint Amount)>();
// Issue #11 — load static spell metadata from data/spells.csv at startup.
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
// for the future Spellbook panel. The CSV is copied to bin/<config>/net10.0/data/
// by the csproj <None Include="...spells.csv"> entry. Loads silently to
// SpellTable.Empty if the file is missing (e.g. tooling contexts).
public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable();
// Retail resolves learned IDs through portal.dat's CSpellTable. MagicCatalog
// installs that immutable table once DatCollection opens in OnLoad.
public AcDream.Core.Spells.SpellTable SpellTable => SpellBook.Metadata;
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
public readonly AcDream.Core.Items.ClientObjectTable Objects = new();
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
@ -838,6 +835,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
private AcDream.App.Spells.MagicCatalog? _magicCatalog;
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
@ -1175,7 +1173,7 @@ public sealed class GameWindow : IDisposable
_uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<AnimatedEntity>(() => _liveEntities);
_remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() => _liveEntities);
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
SpellBook = new AcDream.Core.Spells.Spellbook();
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
// #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's
// PhysicsEngine; the two helpers it also needs but that have callers
@ -1186,33 +1184,6 @@ public sealed class GameWindow : IDisposable
_physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle);
}
/// <summary>
/// Issue #11 — load <c>data/spells.csv</c> from the bin output (copied
/// there by the csproj). Returns <c>SpellTable.Empty</c> + logs a
/// warning if the file is missing (e.g. when GameWindow is instantiated
/// from tooling contexts that don't include the data folder).
/// </summary>
private static AcDream.Core.Spells.SpellTable LoadSpellTable()
{
string path = System.IO.Path.Combine(
System.AppContext.BaseDirectory, "data", "spells.csv");
try
{
if (System.IO.File.Exists(path))
{
var t = AcDream.Core.Spells.SpellTable.LoadFromCsv(path);
Console.WriteLine($"spells: loaded {t.Count} entries from spells.csv");
return t;
}
Console.WriteLine($"spells: data/spells.csv not found at {path}; using empty table");
}
catch (Exception ex)
{
Console.WriteLine($"spells: load failed ({ex.Message}); using empty table");
}
return AcDream.Core.Spells.SpellTable.Empty;
}
public void Run()
{
// A.5 T22.5: resolve quality preset BEFORE creating the window so
@ -1506,6 +1477,9 @@ public sealed class GameWindow : IDisposable
_cameraController.ModeChanged += OnCameraModeChanged;
_dats = new DatCollection(_datDir, DatAccessType.Read);
_magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats);
SpellBook.InstallMetadata(_magicCatalog.SpellTable);
Console.WriteLine($"spells: loaded {SpellTable.Count} entries from portal.dat");
_animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(_dats);
_emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats);
@ -2092,8 +2066,7 @@ public sealed class GameWindow : IDisposable
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
AcDream.App.Spells.MagicCatalog magicCatalog =
AcDream.App.Spells.MagicCatalog.Load(_dats!);
AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!;
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects,
playerGuid: () => _playerServerGuid,
@ -14648,6 +14621,7 @@ public sealed class GameWindow : IDisposable
_itemInteractionController?.Dispose();
_itemInteractionController = null;
_magicRuntime = null;
_magicCatalog = null;
// Phase A.1: join the streamer worker thread before tearing down GL
// state. The worker may still be processing a load job that references

View file

@ -5,6 +5,7 @@ using AcDream.Core.Items;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using CoreSpellTable = AcDream.Core.Spells.SpellTable;
namespace AcDream.App.Spells;
@ -32,12 +33,14 @@ public sealed class MagicCatalog
private readonly IReadOnlyDictionary<uint, int> _spellLevels;
private MagicCatalog(
CoreSpellTable spellTable,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulaDefinitions,
IReadOnlyDictionary<uint, uint> wcidByScid,
IReadOnlyDictionary<uint, uint> magicPackWcidBySchool,
IReadOnlyDictionary<uint, int> spellLevels)
{
SpellTable = spellTable;
Components = components;
_formulaDefinitions = formulaDefinitions;
_wcidByScid = wcidByScid;
@ -45,6 +48,7 @@ public sealed class MagicCatalog
_spellLevels = spellLevels;
}
public CoreSpellTable SpellTable { get; }
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; }
public bool IsComponentPack(uint weenieClassId)
@ -89,22 +93,24 @@ public sealed class MagicCatalog
}
}
var metadata = new List<SpellMetadata>();
var formulaDefinitions = new Dictionary<uint, SpellFormulaDefinition>();
var spellLevels = new Dictionary<uint, int>();
DatReaderWriter.DBObjs.SpellTable? spellTable =
dats.Get<DatReaderWriter.DBObjs.SpellTable>(SpellTableDid);
if (spellTable is not null)
DatReaderWriter.DBObjs.SpellTable spellTable =
dats.Get<DatReaderWriter.DBObjs.SpellTable>(SpellTableDid)
?? throw new InvalidOperationException(
$"Required retail SpellTable 0x{SpellTableDid:X8} is missing from portal.dat.");
foreach (var pair in spellTable.Spells)
{
foreach (var pair in spellTable.Spells)
{
uint[] formula = pair.Value.Components.Take(8).ToArray();
formulaDefinitions[pair.Key] = new SpellFormulaDefinition(
pair.Value.FormulaVersion,
formula,
(uint)pair.Value.School);
spellLevels[pair.Key] =
RetailSpellFormula.InqSpellLevelByRoughHeuristic(formula);
}
SpellMetadata spell = RetailSpellMetadataProjector.Project(
pair.Key, pair.Value, componentTable);
metadata.Add(spell);
uint[] formula = spell.FormulaComponents.ToArray();
formulaDefinitions[pair.Key] = new SpellFormulaDefinition(
spell.FormulaVersion,
formula,
(uint)spell.SchoolId);
spellLevels[pair.Key] = spell.Generation;
}
var magicPackWcidBySchool = new Dictionary<uint, uint>();
@ -121,6 +127,7 @@ public sealed class MagicCatalog
}
return new MagicCatalog(
CoreSpellTable.Create(metadata),
components,
formulaDefinitions,
wcidByScid,

View file

@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AcDream.Core.Spells;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using CoreMagicSchool = AcDream.Core.Spells.MagicSchool;
namespace AcDream.App.Spells;
/// <summary>
/// Projects portal.dat's complete retail <see cref="SpellBase"/> record into
/// the backend-neutral Core metadata consumed by spellbook and casting state.
/// </summary>
/// <remarks>
/// Schema/order: <c>CSpellBase::UnPack @ 0x00597290</c>.
/// School names: <c>CSpellBase::SchoolEnumToName @ 0x00597400</c>.
/// Target type: <c>SpellFormula::GetTargetingType @ 0x005BC910</c>.
/// </remarks>
internal static class RetailSpellMetadataProjector
{
public static SpellMetadata Project(
uint spellId,
SpellBase spell,
SpellComponentTable? componentTable)
{
ArgumentNullException.ThrowIfNull(spell);
uint[] formula = spell.Components.Take(8).ToArray();
uint flags = (uint)spell.Bitfield;
uint formulaTargetType = RetailSpellFormula.GetTargetingType(formula);
uint targetMask = formulaTargetType;
int level = RetailSpellFormula.InqSpellLevelByRoughHeuristic(formula);
bool selfTargeted = (flags & (uint)SpellFlags.SelfTargeted) != 0u;
bool beneficial = (flags & (uint)SpellFlags.Beneficial) != 0u;
return new SpellMetadata(
spellId,
spell.Name.Value,
SchoolName(spell.School),
(uint)spell.Category,
spell.Icon,
BuildSpellWords(formula, componentTable),
checked((float)spell.Duration),
checked((int)spell.BaseMana),
(flags & (uint)SpellFlags.Reversed) != 0u,
(flags & (uint)SpellFlags.FellowshipSpell) != 0u,
spell.Description.Value,
unchecked((int)spell.DisplayOrder),
checked((int)spell.Power),
flags,
level,
(flags & (uint)SpellFlags.FastCast) != 0u,
!beneficial && !selfTargeted,
formulaTargetType == 0u,
Speed: 0f,
(uint)spell.CasterEffect,
(uint)spell.TargetEffect,
targetMask,
checked((int)spell.MetaSpellType))
{
SchoolId = ToCoreSchool(spell.School),
FormulaComponents = formula,
FormulaVersion = spell.FormulaVersion,
ComponentLoss = spell.ComponentLoss,
BaseRangeConstant = spell.BaseRangeConstant,
BaseRangeModifier = spell.BaseRangeMod,
SpellEconomyModifier = spell.SpellEconomyMod,
FizzleEffect = (uint)spell.FizzleEffect,
RecoveryInterval = spell.RecoveryInterval,
RecoveryAmount = spell.RecoveryAmount,
NonComponentTargetType = (uint)spell.NonComponentTargetType,
FormulaTargetType = formulaTargetType,
ManaModifier = spell.ManaMod,
DegradeModifier = spell.DegradeModifier,
DegradeLimit = spell.DegradeLimit,
PortalLifetime = spell.PortalLifetime,
};
}
private static string SchoolName(DatReaderWriter.Enums.MagicSchool school) => school switch
{
DatReaderWriter.Enums.MagicSchool.WarMagic => "War Magic",
DatReaderWriter.Enums.MagicSchool.LifeMagic => "Life Magic",
DatReaderWriter.Enums.MagicSchool.ItemEnchantment => "Item Enchantment",
DatReaderWriter.Enums.MagicSchool.CreatureEnchantment => "Creature Enchantment",
DatReaderWriter.Enums.MagicSchool.VoidMagic => "Void Magic",
_ => "None",
};
private static CoreMagicSchool ToCoreSchool(DatReaderWriter.Enums.MagicSchool school) => school switch
{
DatReaderWriter.Enums.MagicSchool.WarMagic => CoreMagicSchool.WarMagic,
DatReaderWriter.Enums.MagicSchool.LifeMagic => CoreMagicSchool.LifeMagic,
DatReaderWriter.Enums.MagicSchool.ItemEnchantment => CoreMagicSchool.ItemEnchantment,
DatReaderWriter.Enums.MagicSchool.CreatureEnchantment => CoreMagicSchool.CreatureEnchantment,
DatReaderWriter.Enums.MagicSchool.VoidMagic => CoreMagicSchool.VoidMagic,
_ => CoreMagicSchool.None,
};
/// <summary>
/// ACE's independently ported <c>SpellComponentsTable.GetSpellWords</c>:
/// Herb supplies word one; Powder + lower-cased Potion form word two.
/// </summary>
private static string BuildSpellWords(
IReadOnlyList<uint> formula,
SpellComponentTable? componentTable)
{
if (componentTable is null) return string.Empty;
string first = string.Empty;
string second = string.Empty;
string third = string.Empty;
foreach (uint componentId in formula)
{
if (!componentTable.Components.TryGetValue(componentId, out SpellComponentBase? component))
continue;
switch (component.Type)
{
case ComponentType.Herb:
first = component.Text.Value;
break;
case ComponentType.Powder:
second = component.Text.Value;
break;
case ComponentType.Potion:
third = component.Text.Value;
break;
}
}
string tail = second + third.ToLower(CultureInfo.InvariantCulture);
if (tail.Length != 0)
tail = char.ToUpperInvariant(tail[0]) + tail[1..];
return $"{first} {tail}".Trim();
}
}

View file

@ -419,13 +419,13 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private bool IsVisible(SpellMetadata metadata, uint filters)
{
uint school = metadata.School switch
uint school = metadata.SchoolId switch
{
"Creature Enchantment" => 0x0001u,
"Item Enchantment" => 0x0002u,
"Life Magic" => 0x0004u,
"War Magic" => 0x0008u,
"Void Magic" => 0x2000u,
MagicSchool.CreatureEnchantment => 0x0001u,
MagicSchool.ItemEnchantment => 0x0002u,
MagicSchool.LifeMagic => 0x0004u,
MagicSchool.WarMagic => 0x0008u,
MagicSchool.VoidMagic => 0x2000u,
_ => 0u,
};
int spellLevel = _spellLevel(metadata.SpellId);