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

@ -0,0 +1,266 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Meshing;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
namespace AcDream.Content;
/// <summary>
/// Presentation-independent content half of one near-landblock collision
/// build. Graphical and no-window hosts consume the same parsed DAT closure,
/// immutable package collision records, and static Setup/GfxObj identities.
/// No Runtime world or backend resource is mutated here.
/// </summary>
public static class LandblockPhysicsContentBuilder
{
public static IReadOnlyList<WorldEntity> HydrateStaticEntities(
IDatReaderWriter dats,
LoadedLandblock source,
Vector3 worldOffset)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(source);
var hydrated = new List<WorldEntity>(source.Entities.Count);
foreach (WorldEntity candidate in source.Entities)
{
var meshRefs = new List<MeshRef>();
var bounds = new LocalBoundsAccumulator();
uint sourceId = candidate.SourceGfxObjOrSetupId;
if ((sourceId & 0xFF000000u) == 0x01000000u)
{
GfxObj? gfx = dats.Get<GfxObj>(sourceId);
if (gfx is not null)
{
(Vector3 Min, Vector3 Max)? partBounds =
GfxObjBounds.Get(gfx);
if (partBounds is not null)
{
bounds.Add(
Matrix4x4.Identity,
partBounds.Value);
}
meshRefs.Add(new MeshRef(
sourceId,
Matrix4x4.Identity));
}
}
else if ((sourceId & 0xFF000000u) == 0x02000000u)
{
Setup? setup = dats.Get<Setup>(sourceId);
if (setup is not null)
{
foreach (MeshRef meshRef in SetupMesh.Flatten(setup))
{
GfxObj? gfx =
dats.Get<GfxObj>(meshRef.GfxObjId);
if (gfx is null)
continue;
(Vector3 Min, Vector3 Max)? partBounds =
GfxObjBounds.Get(gfx);
if (partBounds is not null)
{
bounds.Add(
meshRef.PartTransform,
partBounds.Value);
}
meshRefs.Add(meshRef);
}
}
}
if (meshRefs.Count == 0)
continue;
var entity = new WorldEntity
{
Id = candidate.Id,
SourceGfxObjOrSetupId = sourceId,
Position = candidate.Position + worldOffset,
Rotation = candidate.Rotation,
MeshRefs = meshRefs,
EffectCellId = candidate.EffectCellId,
IsBuildingShell = candidate.IsBuildingShell,
BuildingShellAnchorCellId =
candidate.BuildingShellAnchorCellId,
};
if (bounds.TryGet(out Vector3 min, out Vector3 max))
entity.SetLocalBounds(min, max);
hydrated.Add(entity);
}
return hydrated;
}
public static PhysicsDatBundle BuildDatBundle(
IDatReaderWriter dats,
uint landblockId,
IReadOnlyList<WorldEntity> entities)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(entities);
var envCells = new Dictionary<uint, EnvCell>();
var environments = new Dictionary<uint, DatEnvironment>();
var setups = new Dictionary<uint, Setup>();
var gfxObjs = new Dictionary<uint, GfxObj>();
LandBlockInfo? info = dats.Get<LandBlockInfo>(
(landblockId & 0xFFFF0000u) | 0xFFFEu);
if (info is not null)
{
uint firstCellId =
(landblockId & 0xFFFF0000u) | 0x0100u;
for (uint offset = 0; offset < info.NumCells; offset++)
{
uint envCellId = firstCellId + offset;
EnvCell? envCell = dats.Get<EnvCell>(envCellId);
if (envCell is null)
continue;
envCells[envCellId] = envCell;
if (envCell.EnvironmentId == 0)
continue;
uint environmentId =
0x0D000000u | envCell.EnvironmentId;
if (!environments.ContainsKey(environmentId)
&& dats.Get<DatEnvironment>(
environmentId) is { } environment)
{
environments[environmentId] = environment;
}
}
foreach (var building in info.Buildings)
{
uint setupId = building.ModelId;
if ((setupId & 0xFF000000u) == 0x02000000u
&& !setups.ContainsKey(setupId)
&& dats.Get<Setup>(setupId) is { } setup)
{
setups[setupId] = setup;
}
}
}
foreach (WorldEntity entity in entities)
{
uint sourceId = entity.SourceGfxObjOrSetupId;
if ((sourceId & 0xFF000000u) == 0x02000000u
&& !setups.ContainsKey(sourceId)
&& dats.Get<Setup>(sourceId) is { } setup)
{
setups[sourceId] = setup;
}
foreach (MeshRef meshRef in entity.MeshRefs)
{
uint gfxObjId = meshRef.GfxObjId;
if ((gfxObjId & 0xFF000000u) != 0x01000000u
|| gfxObjs.ContainsKey(gfxObjId))
{
continue;
}
if (dats.Get<GfxObj>(gfxObjId) is { } gfxObj)
gfxObjs[gfxObjId] = gfxObj;
}
}
return new PhysicsDatBundle(
info,
envCells,
environments,
setups,
gfxObjs);
}
public static LandblockCollisionBuild BuildPreparedCollisionClosure(
IPreparedCollisionSource source,
LoadedLandblock landblock)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(landblock);
PhysicsDatBundle dats =
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
ImmutableArray<uint> gfxObjIds =
[.. dats.GfxObjs.Keys.Order()];
ImmutableArray<uint> setupIds =
[.. dats.Setups.Keys.Order()];
ImmutableArray<uint> envCellIds =
[.. dats.EnvCells.Keys.Order()];
var gfxObjs = ImmutableDictionary.CreateBuilder<
uint,
FlatGfxObjCollisionAsset>();
var setups = ImmutableDictionary.CreateBuilder<
uint,
FlatSetupCollision>();
var cellStructures = ImmutableDictionary.CreateBuilder<
uint,
FlatCellStructureCollisionAsset>();
var envCells = ImmutableDictionary.CreateBuilder<
uint,
FlatEnvCellTopology>();
foreach (uint id in gfxObjIds)
{
gfxObjs.Add(
id,
Require(
source.ReadGfxObjCollision(id),
"GfxObj collision",
id));
}
foreach (uint id in setupIds)
{
setups.Add(
id,
Require(
source.ReadSetupCollision(id),
"Setup collision",
id));
}
foreach (uint id in envCellIds)
{
cellStructures.Add(
id,
Require(
source.ReadCellStructureCollision(id),
"CellStruct collision",
id));
envCells.Add(
id,
Require(
source.ReadEnvCellTopology(id),
"EnvCell topology",
id));
}
return new LandblockCollisionBuild(
gfxObjs.ToImmutable(),
setups.ToImmutable(),
cellStructures.ToImmutable(),
envCells.ToImmutable(),
gfxObjIds,
setupIds,
envCellIds);
}
private static T Require<T>(
PreparedCollisionReadResult<T> result,
string kind,
uint id)
where T : class
{
if (result.Status == PreparedAssetReadStatus.Loaded
&& result.Data is not null)
{
return result.Data;
}
throw new InvalidDataException(
$"{kind} 0x{id:X8} is {result.Status}. "
+ "The complete near-tier generation cannot be published.");
}
}

View file

@ -0,0 +1,192 @@
using System.Collections.Frozen;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using CoreSpellTable = AcDream.Core.Spells.SpellTable;
namespace AcDream.Content;
/// <summary>
/// Immutable presentation and gameplay metadata for one retail spell
/// component WCID.
/// </summary>
public sealed record SpellComponentDescriptor(
uint WeenieClassId,
string Name,
uint Category,
uint IconId);
/// <summary>
/// Process-shareable projection of retail's spell, component, and
/// school-focus DAT tables. The catalog contains no player, inventory,
/// session, or presentation state.
/// </summary>
public sealed class MagicCatalog
{
private const uint SpellTableDid = 0x0E00000Eu;
private const uint SpellComponentTableDid = 0x0E00000Fu;
private const uint ComponentIdMapDid = 0x27000002u;
private readonly FrozenDictionary<uint, SpellFormulaDefinition>
_formulaDefinitions;
private readonly FrozenDictionary<uint, uint> _wcidByScid;
private readonly FrozenDictionary<uint, uint> _magicPackWcidBySchool;
private readonly FrozenDictionary<uint, int> _spellLevels;
private MagicCatalog(
CoreSpellTable spellTable,
FrozenDictionary<uint, SpellComponentDescriptor> components,
FrozenDictionary<uint, SpellFormulaDefinition> formulaDefinitions,
FrozenDictionary<uint, uint> wcidByScid,
FrozenDictionary<uint, uint> magicPackWcidBySchool,
FrozenDictionary<uint, int> spellLevels)
{
SpellTable = spellTable;
Components = components;
_formulaDefinitions = formulaDefinitions;
_wcidByScid = wcidByScid;
_magicPackWcidBySchool = magicPackWcidBySchool;
_spellLevels = spellLevels;
}
public static MagicCatalog Empty { get; } = new(
CoreSpellTable.Empty,
EmptyFrozen<SpellComponentDescriptor>(),
EmptyFrozen<SpellFormulaDefinition>(),
EmptyFrozen<uint>(),
EmptyFrozen<uint>(),
EmptyFrozen<int>());
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;
/// <summary>
/// Creates the per-session policy view over this immutable catalog. The
/// returned service borrows only the supplied session's object table and
/// identity/account delegates.
/// </summary>
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 = 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.ToFrozenDictionary(),
formulaDefinitions.ToFrozenDictionary(),
wcidByScid.ToFrozenDictionary(),
magicPackWcidBySchool.ToFrozenDictionary(),
spellLevels.ToFrozenDictionary());
}
private static FrozenDictionary<uint, TValue> EmptyFrozen<TValue>() =>
new Dictionary<uint, TValue>().ToFrozenDictionary();
}

View file

@ -0,0 +1,28 @@
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.Content;
/// <summary>
/// Ports <c>DBCache::GetDIDFromEnumStatic @ 0x00413940</c>: the portal master map
/// selects an enum category, then that category map resolves the client enum value.
/// </summary>
public static class RetailDataIdResolver
{
public static uint Resolve(IDatReaderWriter dats, uint enumValue, uint enumCategory)
{
ArgumentNullException.ThrowIfNull(dats);
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0
|| !dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)
|| master is null
|| !master.ClientEnumToID.TryGetValue(enumCategory, out uint subMapDid)
|| !dats.Portal.TryGet<EnumIDMap>(subMapDid, out var subMap)
|| subMap is null
|| !subMap.ClientEnumToID.TryGetValue(enumValue, out uint did))
return 0u;
return did;
}
}

View file

@ -45,7 +45,7 @@ public sealed class RuntimeDatCollection : IDatReaderWriter
{
private readonly DatCollection _raw;
private readonly DatCollectionAdapter _bounded;
private bool _disposed;
private int _disposeStage;
internal RuntimeDatCollection(DatCollection raw)
{
@ -95,11 +95,22 @@ public sealed class RuntimeDatCollection : IDatReaderWriter
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_bounded.Dispose();
_raw.Dispose();
while (_disposeStage < 2)
{
switch (_disposeStage)
{
case 0:
_bounded.Dispose();
_disposeStage++;
break;
case 1:
_raw.Dispose();
_disposeStage++;
break;
default:
throw new InvalidOperationException(
"Unknown DAT owner teardown stage.");
}
}
}
}

View file

@ -0,0 +1,245 @@
using AcDream.Content.Pak;
using AcDream.Core.Physics;
namespace AcDream.Content;
public readonly record struct SharedPreparedCollisionCacheSnapshot(
int Count,
int Capacity,
long Hits,
long Misses,
bool IsDisposed)
{
public bool IsBounded => Count >= 0 && Count <= Capacity;
}
/// <summary>
/// Process-scoped bounded cache over immutable prepared collision payloads.
/// The wrapped package remains owned by the caller; disposing this cache only
/// retires its decoded references. Sessions retain separate
/// <see cref="PhysicsDataCache"/> instances and share only these immutable
/// values.
/// </summary>
public sealed class SharedPreparedCollisionCache
: IPreparedCollisionSource
{
public const int DefaultCapacity = 4096;
private readonly object _gate = new();
private readonly IPreparedCollisionSource _source;
private readonly int _capacity;
private readonly Dictionary<CacheKey, LinkedListNode<CacheEntry>>
_entries = new();
private readonly LinkedList<CacheEntry> _lru = new();
private long _probes;
private long _reads;
private long _loaded;
private long _missing;
private long _corrupt;
private long _hits;
private long _misses;
private bool _disposed;
public SharedPreparedCollisionCache(
IPreparedCollisionSource source,
int capacity = DefaultCapacity)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
_capacity = capacity;
}
public PreparedCollisionSourceStats CollisionStats => new(
Interlocked.Read(ref _probes),
Interlocked.Read(ref _reads),
Interlocked.Read(ref _loaded),
Interlocked.Read(ref _missing),
Interlocked.Read(ref _corrupt));
public SharedPreparedCollisionCacheSnapshot CaptureSnapshot()
{
lock (_gate)
{
return new SharedPreparedCollisionCacheSnapshot(
_entries.Count,
_capacity,
Interlocked.Read(ref _hits),
Interlocked.Read(ref _misses),
_disposed);
}
}
public PreparedAssetPresence ProbeCollision(
PakAssetType type,
uint sourceFileId)
{
PreparedCollisionTypeContract.Validate(type);
Interlocked.Increment(ref _probes);
lock (_gate)
{
EnsureNotDisposed();
if (_entries.TryGetValue(
new CacheKey(type, sourceFileId),
out LinkedListNode<CacheEntry>? node))
{
Touch(node);
return node.Value.Status switch
{
PreparedAssetReadStatus.Loaded =>
PreparedAssetPresence.Available,
PreparedAssetReadStatus.Corrupt =>
PreparedAssetPresence.Corrupt,
_ => PreparedAssetPresence.Missing,
};
}
return _source.ProbeCollision(type, sourceFileId);
}
}
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Read(
PakAssetType.GfxObjCollision,
sourceFileId,
token => _source.ReadGfxObjCollision(sourceFileId, token),
cancellationToken);
public PreparedCollisionReadResult<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Read(
PakAssetType.SetupCollision,
sourceFileId,
token => _source.ReadSetupCollision(sourceFileId, token),
cancellationToken);
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Read(
PakAssetType.CellStructureCollision,
sourceFileId,
token => _source.ReadCellStructureCollision(
sourceFileId,
token),
cancellationToken);
public PreparedCollisionReadResult<FlatEnvCellTopology>
ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Read(
PakAssetType.EnvCellTopology,
sourceFileId,
token => _source.ReadEnvCellTopology(sourceFileId, token),
cancellationToken);
public void Dispose()
{
lock (_gate)
{
if (_disposed)
return;
_entries.Clear();
_lru.Clear();
_disposed = true;
}
}
private PreparedCollisionReadResult<T> Read<T>(
PakAssetType type,
uint sourceFileId,
Func<CancellationToken, PreparedCollisionReadResult<T>> loader,
CancellationToken cancellationToken)
where T : class
{
cancellationToken.ThrowIfCancellationRequested();
Interlocked.Increment(ref _reads);
lock (_gate)
{
EnsureNotDisposed();
var key = new CacheKey(type, sourceFileId);
if (_entries.TryGetValue(
key,
out LinkedListNode<CacheEntry>? node))
{
Touch(node);
Interlocked.Increment(ref _hits);
return CountAndConvert<T>(node.Value);
}
Interlocked.Increment(ref _misses);
PreparedCollisionReadResult<T> loaded =
loader(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
var entry = new CacheEntry(
key,
loaded.Status,
loaded.Data);
LinkedListNode<CacheEntry> added = _lru.AddFirst(entry);
_entries.Add(key, added);
Trim();
return CountAndConvert<T>(entry);
}
}
private PreparedCollisionReadResult<T> CountAndConvert<T>(
CacheEntry entry)
where T : class
{
switch (entry.Status)
{
case PreparedAssetReadStatus.Loaded
when entry.Data is T data:
Interlocked.Increment(ref _loaded);
return PreparedCollisionReadResult<T>.Loaded(data);
case PreparedAssetReadStatus.Missing:
Interlocked.Increment(ref _missing);
return PreparedCollisionReadResult<T>.Missing;
case PreparedAssetReadStatus.Corrupt:
Interlocked.Increment(ref _corrupt);
return PreparedCollisionReadResult<T>.Corrupt;
default:
throw new InvalidDataException(
$"Prepared collision cache entry {entry.Key} has an invalid payload.");
}
}
private void Touch(LinkedListNode<CacheEntry> node)
{
if (ReferenceEquals(_lru.First, node))
return;
_lru.Remove(node);
_lru.AddFirst(node);
}
private void Trim()
{
while (_entries.Count > _capacity)
{
LinkedListNode<CacheEntry> tail =
_lru.Last
?? throw new InvalidOperationException(
"Prepared collision LRU lost its terminal entry.");
_entries.Remove(tail.Value.Key);
_lru.RemoveLast();
}
}
private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this);
private readonly record struct CacheKey(
PakAssetType Type,
uint SourceFileId);
private sealed record CacheEntry(
CacheKey Key,
PreparedAssetReadStatus Status,
object? Data);
}

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.Content;
/// <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>
public 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();
}
}