feat: port retail magic lifecycle and retained spell UI

Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 10:55:22 +02:00
parent 7b7ffcd278
commit 07be994d97
84 changed files with 17822 additions and 1051 deletions

View file

@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Spells;
/// <summary>Immutable presentation metadata for one retail spell component WCID.</summary>
public sealed record SpellComponentDescriptor(
uint WeenieClassId,
string Name,
uint Category,
uint IconId);
/// <summary>
/// App-layer projection of retail's spell, component, and school-focus DAT tables.
/// Keeping this catalog outside <c>GameWindow</c> makes the SCID/WCID boundary and
/// rough spell-level calculation one independently testable ownership seam.
/// </summary>
public sealed class MagicCatalog
{
private const uint SpellTableDid = 0x0E00000Eu;
private const uint SpellComponentTableDid = 0x0E00000Fu;
private const uint ComponentIdMapDid = 0x27000002u;
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulaDefinitions;
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
private readonly IReadOnlyDictionary<uint, int> _spellLevels;
private MagicCatalog(
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulaDefinitions,
IReadOnlyDictionary<uint, uint> wcidByScid,
IReadOnlyDictionary<uint, uint> magicPackWcidBySchool,
IReadOnlyDictionary<uint, int> spellLevels)
{
Components = components;
_formulaDefinitions = formulaDefinitions;
_wcidByScid = wcidByScid;
_magicPackWcidBySchool = magicPackWcidBySchool;
_spellLevels = spellLevels;
}
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; }
public bool IsComponentPack(uint weenieClassId)
=> Components.ContainsKey(weenieClassId);
public int GetSpellLevel(uint spellId)
=> _spellLevels.TryGetValue(spellId, out int level) ? level : 0;
public SpellComponentRequirementService CreateRequirementService(
ClientObjectTable objects,
Func<uint> playerGuid,
Func<string> accountName)
=> new(
objects,
playerGuid,
accountName,
_formulaDefinitions,
_wcidByScid,
_magicPackWcidBySchool);
public static MagicCatalog Load(DatCollection dats)
{
ArgumentNullException.ThrowIfNull(dats);
var components = new Dictionary<uint, SpellComponentDescriptor>();
var wcidByScid = new Dictionary<uint, uint>();
SpellComponentTable? componentTable = dats.Get<SpellComponentTable>(SpellComponentTableDid);
DualEnumIDMap? componentIds = dats.Get<DualEnumIDMap>(ComponentIdMapDid);
if (componentTable is not null && componentIds is not null)
{
foreach (var pair in componentTable.Components)
{
if (!componentIds.ClientEnumToID.TryGetValue(pair.Key, out uint wcid))
continue;
wcidByScid[pair.Key] = wcid;
components[wcid] = new SpellComponentDescriptor(
wcid,
pair.Value.Name.Value,
pair.Value.Category,
pair.Value.Icon.DataId);
}
}
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)
{
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);
}
}
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.
uint magicPackMapDid = AcDream.App.UI.RetailDataIdResolver.Resolve(
dats, 0x10000001u, 0x28u);
if (magicPackMapDid != 0u
&& dats.Portal.TryGet<EnumIDMap>(magicPackMapDid, out EnumIDMap? magicPackMap)
&& magicPackMap is not null)
{
foreach (var pair in magicPackMap.ClientEnumToID)
magicPackWcidBySchool[pair.Key] = pair.Value;
}
return new MagicCatalog(
components,
formulaDefinitions,
wcidByScid,
magicPackWcidBySchool,
spellLevels);
}
}
/// <summary>
/// App-layer owner for the live retail magic request path. The server owns
/// turning, motion, fizzle, mana/component consumption, effects, and results.
/// </summary>
public sealed class MagicRuntime
{
private MagicRuntime(MagicCatalog catalog, SpellCastingController casting)
{
Catalog = catalog;
Casting = casting;
}
public MagicCatalog Catalog { get; }
public SpellCastingController Casting { get; }
public static MagicRuntime Create(
MagicCatalog catalog,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint?> selectedObject,
Func<uint> localPlayerId,
Func<string> accountName,
Action stopCompletely,
Action<uint> sendUntargeted,
Action<uint, uint> sendTargeted,
Action<string> displayMessage,
Action incrementBusy,
Func<bool> canSend)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(displayMessage);
SpellComponentRequirementService requirements = catalog.CreateRequirementService(
objects, localPlayerId, accountName);
bool TargetCompatible(uint target, SpellMetadata spell, bool showMessage)
{
if (objects.Get(target) is not { } targetObject)
return false;
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
localPlayerId(), targetObject, spell);
if (showMessage && !result.Allowed && result.Message is { } message)
displayMessage(message);
return result.Allowed;
}
var casting = new SpellCastingController(
spellbook,
selectedObject,
localPlayerId,
stopCompletely,
sendUntargeted,
sendTargeted,
displayMessage,
targetCompatible: (target, spell) => TargetCompatible(target, spell, true),
hasRequiredComponents: requirements.HasRequiredComponents,
incrementBusy: incrementBusy,
canSend: canSend,
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
return new MagicRuntime(catalog, casting);
}
public void Reset() => Casting.Reset();
}

View file

@ -0,0 +1,45 @@
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
public readonly record struct SpellTargetPolicyResult(bool Allowed, string? Message)
{
public static SpellTargetPolicyResult Accept { get; } = new(true, null);
}
/// <summary>
/// Pure projection of ClientMagicSystem::ObjectCompatibleWithSpellTargetType
/// (0x00567230). The caller owns target lookup and message presentation.
/// </summary>
public static class RetailSpellTargetPolicy
{
private const uint SpecialTargetMask = 0x00008107u;
public static SpellTargetPolicyResult Evaluate(
uint localPlayerId,
ClientObject target,
SpellMetadata spell)
{
uint mask = spell.TargetMask;
uint special = mask & SpecialTargetMask;
if (target.ObjectId == localPlayerId && special == 0u)
return new(false, "You cannot cast this spell upon yourself.");
if (target.StackSize > 1)
return new(false, "Cannot cast spell on a stack of items.");
if (((uint)target.Type & mask) == 0u && special == 0u)
return new(false, $"This spell cannot be cast on {target.Name}.");
// Retail joins the ordinary type match and the 0x8107 special-mask
// bypass before these two gates. IsPlayer is PWD bit 3; otherwise the
// object must carry BF_ATTACKABLE. Pets are never legal spell targets.
var flags = (PublicWeenieFlags)target.PublicWeenieBitfield.GetValueOrDefault();
bool playerOrAttackable = (flags
& (PublicWeenieFlags.Player | PublicWeenieFlags.Attackable)) != 0;
if (!playerOrAttackable || target.PetOwnerId != 0u)
return new(false, $"This spell cannot be cast on {target.Name}.");
return SpellTargetPolicyResult.Accept;
}
}

View file

@ -0,0 +1,157 @@
using System;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
/// <summary>
/// App-layer owner for retail cast intent. It validates the client-owned
/// selection rules, stops movement, and emits exactly one targeted or
/// untargeted request. ACE owns every gameplay result after that boundary.
/// </summary>
/// <remarks>
/// Port of <c>ClientMagicSystem::CastSpell</c> (0x00568040) and
/// <c>FreeHandsAndCastSpell</c> (0x00566EF0). This controller deliberately
/// does not consume mana/components or start local effects.
/// </remarks>
public sealed class SpellCastingController
{
private readonly Spellbook _spellbook;
private readonly Func<uint?> _selectedObject;
private readonly Func<uint> _localPlayerId;
private readonly Action _stopCompletely;
private readonly Action<uint> _sendUntargeted;
private readonly Action<uint, uint> _sendTargeted;
private readonly Action<string> _displayMessage;
private readonly Func<uint, SpellMetadata, bool> _targetCompatible;
private readonly Func<uint, SpellMetadata, bool> _targetCompatibleSilent;
private readonly Func<uint, bool> _hasRequiredComponents;
private readonly Action _incrementBusy;
private readonly Func<bool> _canSend;
public SpellCastingController(
Spellbook spellbook,
Func<uint?> selectedObject,
Func<uint> localPlayerId,
Action stopCompletely,
Action<uint> sendUntargeted,
Action<uint, uint> sendTargeted,
Action<string> displayMessage,
Func<uint, SpellMetadata, bool>? targetCompatible = null,
Func<uint, bool>? hasRequiredComponents = null,
Action? incrementBusy = null,
Func<bool>? canSend = null,
Func<uint, SpellMetadata, bool>? targetCompatibleSilent = null)
{
_spellbook = spellbook ?? throw new ArgumentNullException(nameof(spellbook));
_selectedObject = selectedObject ?? throw new ArgumentNullException(nameof(selectedObject));
_localPlayerId = localPlayerId ?? throw new ArgumentNullException(nameof(localPlayerId));
_stopCompletely = stopCompletely ?? throw new ArgumentNullException(nameof(stopCompletely));
_sendUntargeted = sendUntargeted ?? throw new ArgumentNullException(nameof(sendUntargeted));
_sendTargeted = sendTargeted ?? throw new ArgumentNullException(nameof(sendTargeted));
_displayMessage = displayMessage ?? throw new ArgumentNullException(nameof(displayMessage));
_targetCompatible = targetCompatible ?? ((_, _) => true);
_targetCompatibleSilent = targetCompatibleSilent ?? _targetCompatible;
_hasRequiredComponents = hasRequiredComponents ?? (_ => true);
_incrementBusy = incrementBusy ?? (() => { });
_canSend = canSend ?? (() => true);
}
public uint? LastRequestedSpellId { get; private set; }
public uint? LastRequestedTargetId { get; private set; }
public bool IsTargetReady(uint spellId)
{
if (!_spellbook.Knows(spellId)
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
return false;
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
return true;
return _selectedObject() is uint target and not 0u
&& _targetCompatibleSilent(target, spell);
}
public CastRequestResult Cast(uint spellId)
{
if (!_spellbook.Knows(spellId) || !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
{
_displayMessage("You do not know that spell.");
return CastRequestResult.UnknownSpell;
}
if (!_hasRequiredComponents(spellId))
{
_displayMessage("You do not have all of this spell's components.");
return CastRequestResult.MissingComponents;
}
uint? target;
bool untargeted;
if (spell.IsSelfTargeted)
{
uint playerId = _localPlayerId();
target = playerId == 0 ? null : playerId;
untargeted = target is null;
}
else if (spell.IsUntargeted || spell.TargetMask == 0)
{
target = null;
untargeted = true;
}
else
{
target = _selectedObject();
untargeted = false;
if (target is null or 0)
{
_displayMessage("You must select a suitable target.");
return CastRequestResult.NoTarget;
}
if (!_targetCompatible(target.Value, spell))
return CastRequestResult.IncompatibleTarget;
}
if (!_canSend())
{
_displayMessage("You cannot cast a spell right now.");
return CastRequestResult.Unavailable;
}
try
{
_stopCompletely();
LastRequestedSpellId = spellId;
LastRequestedTargetId = target;
if (untargeted)
_sendUntargeted(spellId);
else
_sendTargeted(target!.Value, spellId);
// FreeHandsAndCastSpell @ 0x00566EF0 increments the shared UI
// busy reference only after Event_Cast has been emitted. The
// matching server UseDone event decrements it.
_incrementBusy();
}
catch
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
throw;
}
return CastRequestResult.Sent;
}
public void Reset()
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
}
}
public enum CastRequestResult
{
Sent,
UnknownSpell,
NoTarget,
IncompatibleTarget,
MissingComponents,
Unavailable,
}

View file

@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
public sealed record SpellFormulaDefinition(
uint FormulaVersion,
IReadOnlyList<uint> ComponentIds,
uint School = 0u);
/// <summary>
/// Client-side retail component preflight for ClientMagicSystem::CastSpell
/// (0x00568040). Spell formula entries are SCIDs; carried inventory objects and
/// SetDesiredComponent use WCIDs, so all comparisons cross the DAT enum map.
/// </summary>
public sealed class SpellComponentRequirementService
{
public const uint SpellComponentsRequiredProperty = 68u;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<string> _accountName;
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulas;
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
public SpellComponentRequirementService(
ClientObjectTable objects,
Func<uint> playerGuid,
Func<string> accountName,
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulas,
IReadOnlyDictionary<uint, uint> wcidByScid,
IReadOnlyDictionary<uint, uint>? magicPackWcidBySchool = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_accountName = accountName ?? throw new ArgumentNullException(nameof(accountName));
_formulas = formulas ?? throw new ArgumentNullException(nameof(formulas));
_wcidByScid = wcidByScid ?? throw new ArgumentNullException(nameof(wcidByScid));
_magicPackWcidBySchool = magicPackWcidBySchool
?? new Dictionary<uint, uint>();
}
public bool HasRequiredComponents(uint spellId)
{
uint playerGuid = _playerGuid();
ClientObject? player = _objects.Get(playerGuid);
if (player is not null
&& !player.Properties.GetBool(SpellComponentsRequiredProperty, true))
return true;
if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
return false;
HashSet<uint> ownedWcids = _objects.Objects
.Where(item => IsOwnedByPlayer(item, playerGuid))
.Select(item => item.WeenieClassId)
.ToHashSet();
IReadOnlyList<uint> components = GetAppropriateFormula(
formula, player, playerGuid);
foreach (uint scid in components)
{
if (scid == 0u) continue;
if (!_wcidByScid.TryGetValue(scid, out uint wcid)
|| !ownedWcids.Contains(wcid))
return false;
}
return true;
}
/// <summary>
/// Retail <c>ClientMagicSystem::GetAppropriateSpellFormula</c>
/// (0x00567D50): an infused school or a directly carried school focus uses
/// the scarab-only formula; otherwise the account-customized formula wins.
/// </summary>
private IReadOnlyList<uint> GetAppropriateFormula(
SpellFormulaDefinition formula,
ClientObject? player,
uint playerGuid)
{
bool infused = SchoolInfusionProperty(formula.School) is uint propertyId
&& player?.Properties.GetInt(propertyId) > 0;
bool carriesMagicPack = _magicPackWcidBySchool.TryGetValue(
formula.School, out uint packWcid)
&& _objects.Objects.Any(item =>
item.ContainerId == playerGuid
&& item.WeenieClassId == packWcid);
return infused || carriesMagicPack
? RetailSpellFormula.InqScarabOnlyFormula(formula.ComponentIds)
: RetailSpellFormula.CustomizeForAccount(
formula.ComponentIds, formula.FormulaVersion, _accountName());
}
private static uint? SchoolInfusionProperty(uint school) => school switch
{
1u => 0x129u, // AugmentationInfusedWarMagic
2u => 0x128u, // AugmentationInfusedLifeMagic
3u => 0x127u, // AugmentationInfusedItemMagic
4u => 0x126u, // AugmentationInfusedCreatureMagic
5u => 0x148u, // AugmentationInfusedVoidMagic
_ => null,
};
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
{
if (playerGuid == 0u) return false;
ClientObject current = item;
for (int depth = 0; depth < 16; depth++)
{
if (current.WielderId == playerGuid || current.ContainerId == playerGuid)
return true;
if (current.ContainerId == 0u
|| _objects.Get(current.ContainerId) is not { } parent)
return false;
current = parent;
}
return false;
}
}