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:
parent
09612f9981
commit
0eab7497c1
19 changed files with 633 additions and 114 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
140
src/AcDream.App/Spells/RetailSpellMetadataProjector.cs
Normal file
140
src/AcDream.App/Spells/RetailSpellMetadataProjector.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue