feat(headless): share immutable gameplay content

This commit is contained in:
Erik 2026-07-27 09:00:48 +02:00
parent 12b500d383
commit 9569dadb57
25 changed files with 1031 additions and 429 deletions

View file

@ -1,163 +1,18 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using CoreSpellTable = AcDream.Core.Spells.SpellTable;
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>One resolved formula cell in retail's spell examination subview.</summary>
public readonly record struct SpellExamineComponent(
uint SpellComponentId,
SpellComponentDescriptor Descriptor,
bool Owned);
/// <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(
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;
_magicPackWcidBySchool = magicPackWcidBySchool;
_spellLevels = spellLevels;
}
public CoreSpellTable SpellTable { get; }
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; }
public bool TryGetComponentBySpellComponentId(
uint spellComponentId,
out SpellComponentDescriptor descriptor)
{
if (_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId)
&& Components.TryGetValue(weenieClassId, out SpellComponentDescriptor? found))
{
descriptor = found;
return true;
}
descriptor = null!;
return false;
}
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(IDatReaderWriter 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 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)
?? throw new InvalidOperationException(
$"Required retail SpellTable 0x{SpellTableDid:X8} is missing from portal.dat.");
foreach (var pair in spellTable.Spells)
{
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>();
// 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(
CoreSpellTable.Create(metadata),
components,
formulaDefinitions,
wcidByScid,
magicPackWcidBySchool,
spellLevels);
}
}
/// <summary>
/// App content/policy projection for Runtime's live retail magic request
/// owner. The server owns turning, motion, fizzle, mana/component consumption,

View file

@ -1,140 +0,0 @@
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

@ -1,151 +0,0 @@
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.
/// ACE characters with component enforcement disabled use the same modern
/// scarab/taper presentation instead of exposing an unusable legacy recipe.
/// </summary>
public IReadOnlyList<uint> GetAppropriateFormula(uint spellId)
{
if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
return [];
uint playerGuid = _playerGuid();
return GetAppropriateFormula(
formula,
_objects.Get(playerGuid),
playerGuid);
}
/// <summary>
/// Whether the component tracker would leave a formula icon unghosted.
/// The input is a retail spell-component id (SCID), not a weenie class id.
/// </summary>
public bool IsComponentOwned(uint spellComponentId)
{
if (!_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId))
return false;
uint playerGuid = _playerGuid();
return _objects.Objects.Any(item =>
item.WeenieClassId == weenieClassId
&& IsOwnedByPlayer(item, playerGuid));
}
private IReadOnlyList<uint> GetAppropriateFormula(
SpellFormulaDefinition formula,
ClientObject? player,
uint playerGuid)
{
bool componentsRequired =
player?.Properties.GetBool(SpellComponentsRequiredProperty, true)
?? true;
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 !componentsRequired || 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;
}
}