diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index f6ee8a55..2b0cedc9 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -392,7 +392,7 @@ public sealed class GameWindow :
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle;
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
- private AcDream.App.Spells.MagicCatalog? _magicCatalog;
+ private 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).
@@ -750,7 +750,7 @@ public sealed class GameWindow :
"prepared asset source");
void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog(
- AcDream.App.Spells.MagicCatalog value) =>
+ MagicCatalog value) =>
PublishCompositionOwner(ref _magicCatalog, value, "magic catalog");
void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader(
diff --git a/src/AcDream.App/Spells/MagicRuntime.cs b/src/AcDream.App/Spells/MagicRuntime.cs
index 3c644203..100b8fd5 100644
--- a/src/AcDream.App/Spells/MagicRuntime.cs
+++ b/src/AcDream.App/Spells/MagicRuntime.cs
@@ -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;
-/// Immutable presentation metadata for one retail spell component WCID.
-public sealed record SpellComponentDescriptor(
- uint WeenieClassId,
- string Name,
- uint Category,
- uint IconId);
-
/// One resolved formula cell in retail's spell examination subview.
public readonly record struct SpellExamineComponent(
uint SpellComponentId,
SpellComponentDescriptor Descriptor,
bool Owned);
-///
-/// App-layer projection of retail's spell, component, and school-focus DAT tables.
-/// Keeping this catalog outside GameWindow makes the SCID/WCID boundary and
-/// rough spell-level calculation one independently testable ownership seam.
-///
-public sealed class MagicCatalog
-{
- private const uint SpellTableDid = 0x0E00000Eu;
- private const uint SpellComponentTableDid = 0x0E00000Fu;
- private const uint ComponentIdMapDid = 0x27000002u;
-
- private readonly IReadOnlyDictionary _formulaDefinitions;
- private readonly IReadOnlyDictionary _wcidByScid;
- private readonly IReadOnlyDictionary _magicPackWcidBySchool;
- private readonly IReadOnlyDictionary _spellLevels;
-
- private MagicCatalog(
- CoreSpellTable spellTable,
- IReadOnlyDictionary components,
- IReadOnlyDictionary formulaDefinitions,
- IReadOnlyDictionary wcidByScid,
- IReadOnlyDictionary magicPackWcidBySchool,
- IReadOnlyDictionary spellLevels)
- {
- SpellTable = spellTable;
- Components = components;
- _formulaDefinitions = formulaDefinitions;
- _wcidByScid = wcidByScid;
- _magicPackWcidBySchool = magicPackWcidBySchool;
- _spellLevels = spellLevels;
- }
-
- public CoreSpellTable SpellTable { get; }
- public IReadOnlyDictionary 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 playerGuid,
- Func accountName)
- => new(
- objects,
- playerGuid,
- accountName,
- _formulaDefinitions,
- _wcidByScid,
- _magicPackWcidBySchool);
-
- public static MagicCatalog Load(IDatReaderWriter dats)
- {
- ArgumentNullException.ThrowIfNull(dats);
-
- var components = new Dictionary();
- var wcidByScid = new Dictionary();
- SpellComponentTable? componentTable = dats.Get(SpellComponentTableDid);
- DualEnumIDMap? componentIds = dats.Get(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();
- var formulaDefinitions = new Dictionary();
- var spellLevels = new Dictionary();
- DatReaderWriter.DBObjs.SpellTable spellTable =
- dats.Get(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();
- // 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(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);
- }
-}
-
///
/// App content/policy projection for Runtime's live retail magic request
/// owner. The server owns turning, motion, fizzle, mana/component consumption,
diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs
index 5be22537..62573dd4 100644
--- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs
+++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs
@@ -1,4 +1,3 @@
-using System.Collections.Immutable;
using DatReaderWriter;
using AcDream.Content;
@@ -102,7 +101,9 @@ public sealed class LandblockBuildFactory
}
AcDream.Core.World.LandblockCollisionBuild collisions =
- BuildPreparedCollisionClosure(build.Landblock);
+ LandblockPhysicsContentBuilder.BuildPreparedCollisionClosure(
+ _preparedCollisions,
+ build.Landblock);
AcDream.Core.World.PhysicsDatBundle publicationDats =
(build.Landblock.PhysicsDats
?? AcDream.Core.World.PhysicsDatBundle.Empty)
@@ -155,59 +156,11 @@ public sealed class LandblockBuildFactory
// expand into per-part MeshRefs via SetupMesh.Flatten.
// GPU upload happens later through the render-thread presentation
// pipeline, never in this worker-side build.
- var hydrated = new List(baseLoaded.Entities.Count);
- foreach (var e in baseLoaded.Entities)
- {
- var meshRefs = new List();
- var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
-
- if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
- {
- // Single GfxObj stab — identity part transform.
- var gfx = _dats.Get(e.SourceGfxObjOrSetupId);
- if (gfx is not null)
- {
- var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
- if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
- meshRefs.Add(new AcDream.Core.World.MeshRef(
- e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
- }
- }
- else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
- {
- // Multi-part Setup — flatten to per-part GfxObj refs.
- var setup = _dats.Get(e.SourceGfxObjOrSetupId);
- if (setup is not null)
- {
- var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
- foreach (var mr in flat)
- {
- var gfx = _dats.Get(mr.GfxObjId);
- if (gfx is null) continue;
- var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
- if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value);
- meshRefs.Add(mr);
- }
- }
- }
-
- if (meshRefs.Count == 0) continue;
-
- var entity = new AcDream.Core.World.WorldEntity
- {
- Id = e.Id,
- SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
- Position = e.Position + worldOffset,
- Rotation = e.Rotation,
- MeshRefs = meshRefs,
- EffectCellId = e.EffectCellId,
- IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag
- BuildingShellAnchorCellId = e.BuildingShellAnchorCellId,
- };
- if (stabBounds.TryGet(out var sbMin, out var sbMax))
- entity.SetLocalBounds(sbMin, sbMax);
- hydrated.Add(entity);
- }
+ IReadOnlyList hydrated =
+ LandblockPhysicsContentBuilder.HydrateStaticEntities(
+ _dats,
+ baseLoaded,
+ worldOffset);
// Task 8: merge stabs + scenery + interior into one entity list.
var merged = new List(hydrated);
@@ -226,7 +179,10 @@ public sealed class LandblockBuildFactory
// Pre-read every DAT object publication consumes. Build this after
// `merged` so all entity GfxObj/Setup ids are known.
- var physicsDats = BuildPhysicsDatBundle(landblockId, merged);
+ var physicsDats = LandblockPhysicsContentBuilder.BuildDatBundle(
+ _dats,
+ landblockId,
+ merged);
var completedEnvCells = envCellBuild.Build();
return new AcDream.App.Streaming.LandblockBuild(
new AcDream.Core.World.LoadedLandblock(
@@ -237,180 +193,6 @@ public sealed class LandblockBuildFactory
completedEnvCells,
request.Origin);
}
-
-
-
- ///
- /// Pre-reads every DAT object that landblock publication consumes, so the
- /// update thread performs no DAT reads. Gather and publication must
- /// enumerate the same identifiers. Reads only; no runtime registration.
- ///
- private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle(
- uint landblockId,
- System.Collections.Generic.IReadOnlyList entities)
- {
- var envCells = new System.Collections.Generic.Dictionary();
- var environments = new System.Collections.Generic.Dictionary();
- var setups = new System.Collections.Generic.Dictionary();
- var gfxObjs = new System.Collections.Generic.Dictionary();
-
- // (1) LandBlockInfo
- var lbInfo = _dats!.Get(
- (landblockId & 0xFFFF0000u) | 0xFFFEu);
-
- if (lbInfo is not null)
- {
- // (2)+(3) EnvCell + Environment, per cell
- if (lbInfo.NumCells > 0)
- {
- uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
- for (uint offset = 0; offset < lbInfo.NumCells; offset++)
- {
- uint envCellId = firstCellId + offset;
- var envCell = _dats.Get(envCellId);
- if (envCell is null) continue;
- envCells[envCellId] = envCell;
- if (envCell.EnvironmentId == 0) continue;
- uint envId = 0x0D000000u | envCell.EnvironmentId;
- if (!environments.ContainsKey(envId))
- {
- var environment = _dats.Get(envId);
- if (environment is not null) environments[envId] = environment;
- }
- }
- }
-
- // (4) building shell Setup, per building
- foreach (var building in lbInfo.Buildings)
- {
- uint modelId = building.ModelId;
- if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId))
- {
- var bldSetup = _dats.Get(modelId);
- if (bldSetup is not null) setups[modelId] = bldSetup;
- }
- }
- }
-
- // (5)+(6) entity GfxObj (BSP cache) + entity Setup (ShadowObjects parts/lights)
- foreach (var entity in entities)
- {
- uint src = entity.SourceGfxObjOrSetupId;
- if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src))
- {
- var s = _dats.Get(src);
- if (s is not null) setups[src] = s;
- }
- foreach (var meshRef in entity.MeshRefs)
- {
- uint gid = meshRef.GfxObjId;
- if ((gid & 0xFF000000u) != 0x01000000u) continue;
- if (gfxObjs.ContainsKey(gid)) continue;
- var gfx = _dats.Get(gid);
- if (gfx is not null) gfxObjs[gid] = gfx;
- }
- }
-
- return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs);
- }
-
- ///
- /// Reads the exact prepared collision closure after the serialized DAT
- /// transaction completes. The immutable package is independently
- /// thread-safe, so these reads never extend the shared DAT-reader lock.
- /// Missing or corrupt prepared data fails the complete near-tier build;
- /// production must not publish a graph-only partial generation.
- ///
- private AcDream.Core.World.LandblockCollisionBuild
- BuildPreparedCollisionClosure(
- AcDream.Core.World.LoadedLandblock landblock)
- {
- AcDream.Core.World.PhysicsDatBundle dats =
- landblock.PhysicsDats ??
- AcDream.Core.World.PhysicsDatBundle.Empty;
- ImmutableArray gfxObjIds = [.. dats.GfxObjs.Keys.Order()];
- ImmutableArray setupIds = [.. dats.Setups.Keys.Order()];
- ImmutableArray envCellIds = [.. dats.EnvCells.Keys.Order()];
- var gfxObjs =
- ImmutableDictionary.CreateBuilder<
- uint,
- AcDream.Core.Physics.FlatGfxObjCollisionAsset>();
- var setups =
- ImmutableDictionary.CreateBuilder<
- uint,
- AcDream.Core.Physics.FlatSetupCollision>();
- var cellStructures =
- ImmutableDictionary.CreateBuilder<
- uint,
- AcDream.Core.Physics.FlatCellStructureCollisionAsset>();
- var envCells =
- ImmutableDictionary.CreateBuilder<
- uint,
- AcDream.Core.Physics.FlatEnvCellTopology>();
-
- foreach (uint id in gfxObjIds)
- {
- gfxObjs.Add(
- id,
- RequirePrepared(
- _preparedCollisions.ReadGfxObjCollision(id),
- "GfxObj collision",
- id));
- }
-
- foreach (uint id in setupIds)
- {
- setups.Add(
- id,
- RequirePrepared(
- _preparedCollisions.ReadSetupCollision(id),
- "Setup collision",
- id));
- }
-
- foreach (uint id in envCellIds)
- {
- cellStructures.Add(
- id,
- RequirePrepared(
- _preparedCollisions.ReadCellStructureCollision(id),
- "CellStruct collision",
- id));
- envCells.Add(
- id,
- RequirePrepared(
- _preparedCollisions.ReadEnvCellTopology(id),
- "EnvCell topology",
- id));
- }
-
- return new AcDream.Core.World.LandblockCollisionBuild(
- gfxObjs.ToImmutable(),
- setups.ToImmutable(),
- cellStructures.ToImmutable(),
- envCells.ToImmutable(),
- gfxObjIds,
- setupIds,
- envCellIds);
- }
-
- private static T RequirePrepared(
- PreparedCollisionReadResult 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.");
- }
-
///
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
/// landblock on the worker thread. Pure CPU — no GL calls.
diff --git a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
index b6e52d73..7a408d8d 100644
--- a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
+++ b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
@@ -6,6 +6,7 @@ using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
+using AcDream.Content;
namespace AcDream.App.UI.Layout;
diff --git a/src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs b/src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs
index 6afc4a20..187163e4 100644
--- a/src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs
+++ b/src/AcDream.App/UI/Layout/VividTargetIndicatorController.cs
@@ -1,6 +1,7 @@
using System.Numerics;
using AcDream.Core.Selection;
using AcDream.Core.Ui;
+using AcDream.Content;
namespace AcDream.App.UI.Layout;
diff --git a/src/AcDream.Content/LandblockPhysicsContentBuilder.cs b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs
new file mode 100644
index 00000000..e406af38
--- /dev/null
+++ b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs
@@ -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;
+
+///
+/// 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.
+///
+public static class LandblockPhysicsContentBuilder
+{
+ public static IReadOnlyList HydrateStaticEntities(
+ IDatReaderWriter dats,
+ LoadedLandblock source,
+ Vector3 worldOffset)
+ {
+ ArgumentNullException.ThrowIfNull(dats);
+ ArgumentNullException.ThrowIfNull(source);
+
+ var hydrated = new List(source.Entities.Count);
+ foreach (WorldEntity candidate in source.Entities)
+ {
+ var meshRefs = new List();
+ var bounds = new LocalBoundsAccumulator();
+ uint sourceId = candidate.SourceGfxObjOrSetupId;
+ if ((sourceId & 0xFF000000u) == 0x01000000u)
+ {
+ GfxObj? gfx = dats.Get(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(sourceId);
+ if (setup is not null)
+ {
+ foreach (MeshRef meshRef in SetupMesh.Flatten(setup))
+ {
+ GfxObj? gfx =
+ dats.Get(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 entities)
+ {
+ ArgumentNullException.ThrowIfNull(dats);
+ ArgumentNullException.ThrowIfNull(entities);
+
+ var envCells = new Dictionary();
+ var environments = new Dictionary();
+ var setups = new Dictionary();
+ var gfxObjs = new Dictionary();
+ LandBlockInfo? info = dats.Get(
+ (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(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(
+ 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(setupId) is { } setup)
+ {
+ setups[setupId] = setup;
+ }
+ }
+ }
+
+ foreach (WorldEntity entity in entities)
+ {
+ uint sourceId = entity.SourceGfxObjOrSetupId;
+ if ((sourceId & 0xFF000000u) == 0x02000000u
+ && !setups.ContainsKey(sourceId)
+ && dats.Get(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(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 gfxObjIds =
+ [.. dats.GfxObjs.Keys.Order()];
+ ImmutableArray setupIds =
+ [.. dats.Setups.Keys.Order()];
+ ImmutableArray 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(
+ PreparedCollisionReadResult 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.");
+ }
+}
diff --git a/src/AcDream.Content/MagicCatalog.cs b/src/AcDream.Content/MagicCatalog.cs
new file mode 100644
index 00000000..eb84219b
--- /dev/null
+++ b/src/AcDream.Content/MagicCatalog.cs
@@ -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;
+
+///
+/// Immutable presentation and gameplay metadata for one retail spell
+/// component WCID.
+///
+public sealed record SpellComponentDescriptor(
+ uint WeenieClassId,
+ string Name,
+ uint Category,
+ uint IconId);
+
+///
+/// Process-shareable projection of retail's spell, component, and
+/// school-focus DAT tables. The catalog contains no player, inventory,
+/// session, or presentation state.
+///
+public sealed class MagicCatalog
+{
+ private const uint SpellTableDid = 0x0E00000Eu;
+ private const uint SpellComponentTableDid = 0x0E00000Fu;
+ private const uint ComponentIdMapDid = 0x27000002u;
+
+ private readonly FrozenDictionary
+ _formulaDefinitions;
+ private readonly FrozenDictionary _wcidByScid;
+ private readonly FrozenDictionary _magicPackWcidBySchool;
+ private readonly FrozenDictionary _spellLevels;
+
+ private MagicCatalog(
+ CoreSpellTable spellTable,
+ FrozenDictionary components,
+ FrozenDictionary formulaDefinitions,
+ FrozenDictionary wcidByScid,
+ FrozenDictionary magicPackWcidBySchool,
+ FrozenDictionary spellLevels)
+ {
+ SpellTable = spellTable;
+ Components = components;
+ _formulaDefinitions = formulaDefinitions;
+ _wcidByScid = wcidByScid;
+ _magicPackWcidBySchool = magicPackWcidBySchool;
+ _spellLevels = spellLevels;
+ }
+
+ public static MagicCatalog Empty { get; } = new(
+ CoreSpellTable.Empty,
+ EmptyFrozen(),
+ EmptyFrozen(),
+ EmptyFrozen(),
+ EmptyFrozen(),
+ EmptyFrozen());
+
+ public CoreSpellTable SpellTable { get; }
+ public IReadOnlyDictionary 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;
+
+ ///
+ /// 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.
+ ///
+ public SpellComponentRequirementService CreateRequirementService(
+ ClientObjectTable objects,
+ Func playerGuid,
+ Func accountName) =>
+ new(
+ objects,
+ playerGuid,
+ accountName,
+ _formulaDefinitions,
+ _wcidByScid,
+ _magicPackWcidBySchool);
+
+ public static MagicCatalog Load(IDatReaderWriter dats)
+ {
+ ArgumentNullException.ThrowIfNull(dats);
+
+ var components =
+ new Dictionary();
+ var wcidByScid = new Dictionary();
+ SpellComponentTable? componentTable =
+ dats.Get(SpellComponentTableDid);
+ DualEnumIDMap? componentIds =
+ dats.Get(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();
+ var formulaDefinitions =
+ new Dictionary();
+ var spellLevels = new Dictionary();
+ DatReaderWriter.DBObjs.SpellTable spellTable =
+ dats.Get(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();
+ // 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(
+ 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 EmptyFrozen() =>
+ new Dictionary().ToFrozenDictionary();
+}
diff --git a/src/AcDream.App/UI/RetailDataIdResolver.cs b/src/AcDream.Content/RetailDataIdResolver.cs
similarity index 95%
rename from src/AcDream.App/UI/RetailDataIdResolver.cs
rename to src/AcDream.Content/RetailDataIdResolver.cs
index 267ae8a0..eaeb5137 100644
--- a/src/AcDream.App/UI/RetailDataIdResolver.cs
+++ b/src/AcDream.Content/RetailDataIdResolver.cs
@@ -1,8 +1,7 @@
using DatReaderWriter;
-using AcDream.Content;
using DatReaderWriter.DBObjs;
-namespace AcDream.App.UI;
+namespace AcDream.Content;
///
/// Ports DBCache::GetDIDFromEnumStatic @ 0x00413940: the portal master map
diff --git a/src/AcDream.Content/RuntimeDatCollectionFactory.cs b/src/AcDream.Content/RuntimeDatCollectionFactory.cs
index dd3a7c7b..b046acaf 100644
--- a/src/AcDream.Content/RuntimeDatCollectionFactory.cs
+++ b/src/AcDream.Content/RuntimeDatCollectionFactory.cs
@@ -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.");
+ }
+ }
}
}
diff --git a/src/AcDream.Content/SharedPreparedCollisionCache.cs b/src/AcDream.Content/SharedPreparedCollisionCache.cs
new file mode 100644
index 00000000..4f5ae3b6
--- /dev/null
+++ b/src/AcDream.Content/SharedPreparedCollisionCache.cs
@@ -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;
+}
+
+///
+/// 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
+/// instances and share only these immutable
+/// values.
+///
+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>
+ _entries = new();
+ private readonly LinkedList _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? 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
+ ReadGfxObjCollision(
+ uint sourceFileId,
+ CancellationToken cancellationToken = default) =>
+ Read(
+ PakAssetType.GfxObjCollision,
+ sourceFileId,
+ token => _source.ReadGfxObjCollision(sourceFileId, token),
+ cancellationToken);
+
+ public PreparedCollisionReadResult
+ ReadSetupCollision(
+ uint sourceFileId,
+ CancellationToken cancellationToken = default) =>
+ Read(
+ PakAssetType.SetupCollision,
+ sourceFileId,
+ token => _source.ReadSetupCollision(sourceFileId, token),
+ cancellationToken);
+
+ public PreparedCollisionReadResult
+ ReadCellStructureCollision(
+ uint sourceFileId,
+ CancellationToken cancellationToken = default) =>
+ Read(
+ PakAssetType.CellStructureCollision,
+ sourceFileId,
+ token => _source.ReadCellStructureCollision(
+ sourceFileId,
+ token),
+ cancellationToken);
+
+ public PreparedCollisionReadResult
+ 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 Read(
+ PakAssetType type,
+ uint sourceFileId,
+ Func> 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? node))
+ {
+ Touch(node);
+ Interlocked.Increment(ref _hits);
+ return CountAndConvert(node.Value);
+ }
+
+ Interlocked.Increment(ref _misses);
+ PreparedCollisionReadResult loaded =
+ loader(cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ var entry = new CacheEntry(
+ key,
+ loaded.Status,
+ loaded.Data);
+ LinkedListNode added = _lru.AddFirst(entry);
+ _entries.Add(key, added);
+ Trim();
+ return CountAndConvert(entry);
+ }
+ }
+
+ private PreparedCollisionReadResult CountAndConvert(
+ CacheEntry entry)
+ where T : class
+ {
+ switch (entry.Status)
+ {
+ case PreparedAssetReadStatus.Loaded
+ when entry.Data is T data:
+ Interlocked.Increment(ref _loaded);
+ return PreparedCollisionReadResult.Loaded(data);
+ case PreparedAssetReadStatus.Missing:
+ Interlocked.Increment(ref _missing);
+ return PreparedCollisionReadResult.Missing;
+ case PreparedAssetReadStatus.Corrupt:
+ Interlocked.Increment(ref _corrupt);
+ return PreparedCollisionReadResult.Corrupt;
+ default:
+ throw new InvalidDataException(
+ $"Prepared collision cache entry {entry.Key} has an invalid payload.");
+ }
+ }
+
+ private void Touch(LinkedListNode node)
+ {
+ if (ReferenceEquals(_lru.First, node))
+ return;
+ _lru.Remove(node);
+ _lru.AddFirst(node);
+ }
+
+ private void Trim()
+ {
+ while (_entries.Count > _capacity)
+ {
+ LinkedListNode 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);
+}
diff --git a/src/AcDream.App/Spells/RetailSpellMetadataProjector.cs b/src/AcDream.Content/Spells/RetailSpellMetadataProjector.cs
similarity index 98%
rename from src/AcDream.App/Spells/RetailSpellMetadataProjector.cs
rename to src/AcDream.Content/Spells/RetailSpellMetadataProjector.cs
index 3e342460..d71f0e2f 100644
--- a/src/AcDream.App/Spells/RetailSpellMetadataProjector.cs
+++ b/src/AcDream.Content/Spells/RetailSpellMetadataProjector.cs
@@ -8,7 +8,7 @@ using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using CoreMagicSchool = AcDream.Core.Spells.MagicSchool;
-namespace AcDream.App.Spells;
+namespace AcDream.Content;
///
/// Projects portal.dat's complete retail record into
@@ -19,7 +19,7 @@ namespace AcDream.App.Spells;
/// School names: CSpellBase::SchoolEnumToName @ 0x00597400.
/// Target type: SpellFormula::GetTargetingType @ 0x005BC910.
///
-internal static class RetailSpellMetadataProjector
+public static class RetailSpellMetadataProjector
{
public static SpellMetadata Project(
uint spellId,
diff --git a/src/AcDream.App/Spells/SpellComponentRequirementService.cs b/src/AcDream.Core/Spells/SpellComponentRequirementService.cs
similarity index 99%
rename from src/AcDream.App/Spells/SpellComponentRequirementService.cs
rename to src/AcDream.Core/Spells/SpellComponentRequirementService.cs
index 6ce6ee06..accafbc9 100644
--- a/src/AcDream.App/Spells/SpellComponentRequirementService.cs
+++ b/src/AcDream.Core/Spells/SpellComponentRequirementService.cs
@@ -2,9 +2,8 @@ using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
-using AcDream.Core.Spells;
-namespace AcDream.App.Spells;
+namespace AcDream.Core.Spells;
public sealed record SpellFormulaDefinition(
uint FormulaVersion,
diff --git a/src/AcDream.Core/Spells/SpellTable.cs b/src/AcDream.Core/Spells/SpellTable.cs
index d4a497f9..d2b987db 100644
--- a/src/AcDream.Core/Spells/SpellTable.cs
+++ b/src/AcDream.Core/Spells/SpellTable.cs
@@ -7,7 +7,8 @@ namespace AcDream.Core.Spells;
///
/// Immutable lookup of retail spell metadata. Production builds create this
-/// table from portal.dat through the App-layer MagicCatalog. The CSV
+/// table from portal.dat through Content's process-shareable
+/// MagicCatalog. The CSV
/// parser below remains for historical tooling and small synthetic tests.
///
///
diff --git a/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs b/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs
index d84a5d9a..2c1ad9c5 100644
--- a/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs
@@ -2,6 +2,7 @@ using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Spells;
+using AcDream.Content;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
@@ -43,12 +44,29 @@ internal sealed class HeadlessGameplayOperations
private readonly object _gate = new();
private GameRuntime? _runtime;
+ private SpellComponentRequirementService? _componentRequirements;
private WorldSession? _session;
private SessionRoute? _route;
- internal void Bind(GameRuntime runtime) =>
- _runtime = runtime
- ?? throw new ArgumentNullException(nameof(runtime));
+ internal void Bind(
+ GameRuntime runtime,
+ MagicCatalog? catalog,
+ Func accountName)
+ {
+ ArgumentNullException.ThrowIfNull(runtime);
+ ArgumentNullException.ThrowIfNull(accountName);
+ if (_runtime is not null)
+ {
+ throw new InvalidOperationException(
+ "Headless gameplay operations are already bound.");
+ }
+
+ _runtime = runtime;
+ _componentRequirements = catalog?.CreateRequirementService(
+ runtime.InventoryOwner.Objects,
+ () => runtime.PlayerIdentity.ServerGuid,
+ accountName);
+ }
internal ILiveSessionCommandRouting CreateRoute(
WorldSession session)
@@ -151,12 +169,16 @@ internal sealed class HeadlessGameplayOperations
public bool HasRequiredComponents(uint spellId)
{
+ if (_componentRequirements is { } requirements)
+ return requirements.HasRequiredComponents(spellId);
+
GameRuntime runtime = RequireRuntime();
ClientObject? player = runtime.InventoryOwner.Objects.Get(
runtime.PlayerIdentity.ServerGuid);
return player is not null
&& !player.Properties.GetBool(
- SpellComponentsRequiredProperty,
+ SpellComponentRequirementService
+ .SpellComponentsRequiredProperty,
true);
}
@@ -264,6 +286,4 @@ internal sealed class HeadlessGameplayOperations
_route = null;
}
}
-
- private const uint SpellComponentsRequiredProperty = 68u;
}
diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs
index 5c839845..9b9bc37d 100644
--- a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs
@@ -17,15 +17,20 @@ internal readonly record struct HeadlessProcessContentSnapshot(
internal interface IHeadlessProcessContentFactory
{
- (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open(
+ HeadlessOpenedProcessContent Open(
HeadlessContentDescriptor descriptor,
Action diagnostic);
}
+internal sealed record HeadlessOpenedProcessContent(
+ IDatReaderWriter Dats,
+ IPreparedAssetSource Prepared,
+ MagicCatalog Magic);
+
internal sealed class ProductionHeadlessProcessContentFactory
: IHeadlessProcessContentFactory
{
- public (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open(
+ public HeadlessOpenedProcessContent Open(
HeadlessContentDescriptor descriptor,
Action diagnostic)
{
@@ -36,18 +41,30 @@ internal sealed class ProductionHeadlessProcessContentFactory
string preparedAssetPath =
Path.GetFullPath(descriptor.PreparedAssetPath);
IDatReaderWriter? dats = null;
+ IPreparedAssetSource? prepared = null;
try
{
dats = RuntimeDatCollectionFactory.OpenReadOnly(datDirectory);
- var prepared = new PakPreparedAssetSource(
+ prepared = new PakPreparedAssetSource(
preparedAssetPath,
dats,
diagnostic);
- return (dats, prepared);
+ MagicCatalog magic = MagicCatalog.Load(dats);
+ return new HeadlessOpenedProcessContent(
+ dats,
+ prepared,
+ magic);
}
catch
{
- dats?.Dispose();
+ try
+ {
+ prepared?.Dispose();
+ }
+ finally
+ {
+ dats?.Dispose();
+ }
throw;
}
}
@@ -62,8 +79,10 @@ internal sealed class ProductionHeadlessProcessContentFactory
internal sealed class HeadlessProcessContentOwner : IDisposable
{
private readonly object _gate = new();
+ private readonly MagicCatalog _magic;
private IDatReaderWriter? _dats;
private IPreparedAssetSource? _prepared;
+ private SharedPreparedCollisionCache? _collision;
private int _leaseCount;
private bool _disposeRequested;
private bool _disposed;
@@ -75,30 +94,41 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(diagnostic);
- (IDatReaderWriter dats, IPreparedAssetSource prepared) =
+ HeadlessOpenedProcessContent? opened =
(factory ?? new ProductionHeadlessProcessContentFactory())
.Open(descriptor, diagnostic);
- _dats = dats
- ?? throw new InvalidOperationException(
- "The headless content factory returned no DAT owner.");
- _prepared = prepared
- ?? throw new InvalidOperationException(
- "The headless content factory returned no prepared source.");
- if (prepared is not IPreparedCollisionSource)
+ if (opened is null)
+ {
+ throw new InvalidOperationException(
+ "The headless content factory returned no content owner.");
+ }
+ IDatReaderWriter? dats = opened.Dats;
+ IPreparedAssetSource? prepared = opened.Prepared;
+ MagicCatalog? magic = opened.Magic;
+ bool incomplete =
+ dats is null || prepared is null || magic is null;
+ if (incomplete || prepared is not IPreparedCollisionSource)
{
try
{
- prepared.Dispose();
+ prepared?.Dispose();
}
finally
{
- dats.Dispose();
+ dats?.Dispose();
}
- _prepared = null;
- _dats = null;
- throw new NotSupportedException(
- "Headless production content must expose prepared collision.");
+ throw !incomplete
+ && prepared is not IPreparedCollisionSource
+ ? new NotSupportedException(
+ "Headless production content must expose prepared collision.")
+ : new InvalidOperationException(
+ "The headless content factory returned an incomplete owner.");
}
+ _dats = dats!;
+ _prepared = prepared!;
+ _magic = magic!;
+ _collision = new SharedPreparedCollisionCache(
+ (IPreparedCollisionSource)prepared!);
}
internal HeadlessProcessContentLease AcquireLease(string sessionId)
@@ -117,7 +147,9 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
this,
sessionId,
_dats!,
- _prepared!);
+ _prepared!,
+ _collision!,
+ _magic);
}
}
@@ -163,6 +195,11 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
private void DrainResources()
{
+ if (_collision is not null)
+ {
+ _collision.Dispose();
+ _collision = null;
+ }
if (_prepared is not null)
{
_prepared.Dispose();
@@ -181,17 +218,23 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
private HeadlessProcessContentOwner? _owner;
private readonly IDatReaderWriter _dats;
private readonly IPreparedAssetSource _prepared;
+ private readonly IPreparedCollisionSource _collision;
+ private readonly MagicCatalog _magic;
internal HeadlessProcessContentLease(
HeadlessProcessContentOwner owner,
string sessionId,
IDatReaderWriter dats,
- IPreparedAssetSource prepared)
+ IPreparedAssetSource prepared,
+ IPreparedCollisionSource collision,
+ MagicCatalog magic)
{
_owner = owner;
SessionId = sessionId;
_dats = dats;
_prepared = prepared;
+ _collision = collision;
+ _magic = magic;
}
internal string SessionId { get; }
@@ -214,8 +257,23 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
}
}
- internal IPreparedCollisionSource PreparedCollision =>
- (IPreparedCollisionSource)PreparedAssets;
+ internal IPreparedCollisionSource PreparedCollision
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_owner is null, this);
+ return _collision;
+ }
+ }
+
+ internal MagicCatalog MagicCatalog
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_owner is null, this);
+ return _magic;
+ }
+ }
public void Dispose()
{
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
index d6bf13bb..531d7728 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
@@ -123,6 +123,7 @@ internal sealed class HeadlessSessionHost : IDisposable
private long _reconnectDeadline;
private bool _reconnectPending;
private ulong _stoppedGeneration;
+ private string _accountName = string.Empty;
private bool _disposed;
internal HeadlessSessionHost(
@@ -172,7 +173,15 @@ internal sealed class HeadlessSessionHost : IDisposable
CombatTime: () =>
runtimeRef?.Clock.SimulationTimeSeconds ?? 0d));
runtimeRef = runtime;
- gameplay.Bind(runtime);
+ if (contentLease is { } content)
+ {
+ runtime.CharacterOwner.InstallSpellMetadata(
+ content.MagicCatalog.SpellTable);
+ }
+ gameplay.Bind(
+ runtime,
+ contentLease?.MagicCatalog,
+ () => _accountName);
var bridge = new SessionCommandBridge();
var commands = new DirectGameRuntimeCommandAdapter(
@@ -448,6 +457,8 @@ internal sealed class HeadlessSessionHost : IDisposable
password,
MapCharacterSelector(_descriptor.Character));
LiveSessionStartResult result = _liveSession.Start(options);
+ if (result.Selection is { } selection)
+ _accountName = selection.AccountName;
RuntimeSessionStartResult converted = Convert(result);
if (converted.Error is { } error)
{
diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs
index 8d3f4fcc..9ea7893e 100644
--- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs
@@ -4,6 +4,7 @@ using AcDream.App.Composition;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.Spells;
+using AcDream.Content;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.World;
diff --git a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
index 20387dda..2913cce7 100644
--- a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
@@ -1,5 +1,6 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
+using AcDream.Content;
using AcDream.Content.Vfx;
using AcDream.Core.World;
using DatReaderWriter;
diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs
index 0c30d504..8a6f3c61 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionAssetTests.cs
@@ -2,6 +2,7 @@ using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Selection;
using AcDream.App.UI;
+using AcDream.Content;
using AcDream.Core.Textures;
using AcDream.Core.Selection;
using DatReaderWriter;
diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
index 58871a0d..e4a55791 100644
--- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
@@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.App.Spells;
+using AcDream.Content;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs
index 8a09a412..0c431349 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs
@@ -1,4 +1,5 @@
using AcDream.App.Spells;
+using AcDream.Content;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
diff --git a/tests/AcDream.Content.Tests/SharedPreparedCollisionCacheTests.cs b/tests/AcDream.Content.Tests/SharedPreparedCollisionCacheTests.cs
new file mode 100644
index 00000000..bb4f7dcb
--- /dev/null
+++ b/tests/AcDream.Content.Tests/SharedPreparedCollisionCacheTests.cs
@@ -0,0 +1,143 @@
+using System.Collections.Immutable;
+using AcDream.Content.Pak;
+using AcDream.Core.Physics;
+
+namespace AcDream.Content.Tests;
+
+public sealed class SharedPreparedCollisionCacheTests
+{
+ [Fact]
+ public void LoadedPayloadIsSharedByReferenceAndLeastRecentlyUsedIsBounded()
+ {
+ var source = new FixtureSource();
+ using var cache = new SharedPreparedCollisionCache(
+ source,
+ capacity: 2);
+
+ FlatSetupCollision first =
+ Assert.IsType(
+ cache.ReadSetupCollision(1u).Data);
+ Assert.Same(
+ first,
+ cache.ReadSetupCollision(1u).Data);
+ _ = cache.ReadSetupCollision(2u);
+ Assert.Same(
+ first,
+ cache.ReadSetupCollision(1u).Data);
+ _ = cache.ReadSetupCollision(3u);
+
+ SharedPreparedCollisionCacheSnapshot snapshot =
+ cache.CaptureSnapshot();
+ Assert.Equal(2, snapshot.Count);
+ Assert.True(snapshot.IsBounded);
+ Assert.Equal(2, snapshot.Hits);
+ Assert.Equal(3, snapshot.Misses);
+ Assert.Equal(1, source.SetupReads[1u]);
+ Assert.Equal(1, source.SetupReads[2u]);
+
+ _ = cache.ReadSetupCollision(2u);
+
+ Assert.Equal(2, source.SetupReads[2u]);
+ Assert.True(cache.CaptureSnapshot().IsBounded);
+ }
+
+ [Fact]
+ public void TypeParticipatesInIdentityAndDisposeDoesNotOwnPackage()
+ {
+ var source = new FixtureSource();
+ var cache = new SharedPreparedCollisionCache(source, capacity: 4);
+
+ FlatSetupCollision setup =
+ Assert.IsType(
+ cache.ReadSetupCollision(7u).Data);
+ FlatGfxObjCollisionAsset gfx =
+ Assert.IsType(
+ cache.ReadGfxObjCollision(7u).Data);
+
+ Assert.NotSame(setup, gfx);
+ Assert.Equal(1, source.SetupReads[7u]);
+ Assert.Equal(1, source.GfxReads[7u]);
+
+ cache.Dispose();
+
+ Assert.False(source.IsDisposed);
+ Assert.True(cache.CaptureSnapshot().IsDisposed);
+ Assert.Throws(
+ () => cache.ReadSetupCollision(7u));
+ }
+
+ private sealed class FixtureSource : IPreparedCollisionSource
+ {
+ internal Dictionary SetupReads { get; } = new();
+ internal Dictionary GfxReads { get; } = new();
+ internal bool IsDisposed { get; private set; }
+
+ public PreparedCollisionSourceStats CollisionStats => default;
+
+ public PreparedAssetPresence ProbeCollision(
+ PakAssetType type,
+ uint sourceFileId) =>
+ PreparedAssetPresence.Available;
+
+ public PreparedCollisionReadResult
+ ReadGfxObjCollision(
+ uint sourceFileId,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Increment(GfxReads, sourceFileId);
+ return PreparedCollisionReadResult<
+ FlatGfxObjCollisionAsset>.Loaded(
+ new FlatGfxObjCollisionAsset(
+ EmptyPhysicsBsp(),
+ null,
+ null));
+ }
+
+ public PreparedCollisionReadResult
+ ReadSetupCollision(
+ uint sourceFileId,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Increment(SetupReads, sourceFileId);
+ return PreparedCollisionReadResult.Loaded(
+ new FlatSetupCollision(
+ ImmutableArray.Empty,
+ ImmutableArray.Empty,
+ 0f,
+ 0f,
+ 0f,
+ 0f));
+ }
+
+ public PreparedCollisionReadResult<
+ FlatCellStructureCollisionAsset>
+ ReadCellStructureCollision(
+ uint sourceFileId,
+ CancellationToken cancellationToken = default) =>
+ PreparedCollisionReadResult<
+ FlatCellStructureCollisionAsset>.Missing;
+
+ public PreparedCollisionReadResult
+ ReadEnvCellTopology(
+ uint sourceFileId,
+ CancellationToken cancellationToken = default) =>
+ PreparedCollisionReadResult.Missing;
+
+ public void Dispose() => IsDisposed = true;
+
+ private static FlatPhysicsBsp EmptyPhysicsBsp() => new(
+ -1,
+ ImmutableArray.Empty,
+ ImmutableArray.Empty,
+ FlatPolygonTable.Empty);
+
+ private static void Increment(
+ Dictionary counts,
+ uint id) =>
+ counts[id] = counts.TryGetValue(id, out int count)
+ ? count + 1
+ : 1;
+ }
+}
diff --git a/tests/AcDream.App.Tests/Spells/RetailSpellMetadataProjectorTests.cs b/tests/AcDream.Content.Tests/Spells/RetailSpellMetadataProjectorTests.cs
similarity index 94%
rename from tests/AcDream.App.Tests/Spells/RetailSpellMetadataProjectorTests.cs
rename to tests/AcDream.Content.Tests/Spells/RetailSpellMetadataProjectorTests.cs
index 25c921c9..23d1a8b0 100644
--- a/tests/AcDream.App.Tests/Spells/RetailSpellMetadataProjectorTests.cs
+++ b/tests/AcDream.Content.Tests/Spells/RetailSpellMetadataProjectorTests.cs
@@ -1,4 +1,4 @@
-using AcDream.App.Spells;
+using AcDream.Content;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
@@ -9,7 +9,7 @@ using DatMagicSchool = DatReaderWriter.Enums.MagicSchool;
using DatSpellCategory = DatReaderWriter.Enums.SpellCategory;
using CoreSpellTable = AcDream.Core.Spells.SpellTable;
-namespace AcDream.App.Tests.Spells;
+namespace AcDream.Content.Tests.Spells;
public sealed class RetailSpellMetadataProjectorTests
{
@@ -79,7 +79,8 @@ public sealed class RetailSpellMetadataProjectorTests
string? csvPath = ResolveRepoFile("docs", "research", "data", "spells.csv");
if (datDir is null || csvPath is null) return;
- using var dats = new DatCollection(datDir, DatAccessType.Read);
+ using var rawDats = new DatCollection(datDir, DatAccessType.Read);
+ using var dats = new DatCollectionAdapter(rawDats);
MagicCatalog catalog = MagicCatalog.Load(dats);
CoreSpellTable legacy = CoreSpellTable.LoadFromCsv(csvPath);
@@ -106,7 +107,8 @@ public sealed class RetailSpellMetadataProjectorTests
string? csvPath = ResolveRepoFile("docs", "research", "data", "spells.csv");
if (datDir is null || csvPath is null) return;
- using var dats = new DatCollection(datDir, DatAccessType.Read);
+ using var rawDats = new DatCollection(datDir, DatAccessType.Read);
+ using var dats = new DatCollectionAdapter(rawDats);
MagicCatalog catalog = MagicCatalog.Load(dats);
CoreSpellTable legacy = CoreSpellTable.LoadFromCsv(csvPath);
var mismatches = new List();
diff --git a/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs b/tests/AcDream.Core.Tests/Spells/SpellComponentRequirementServiceTests.cs
similarity index 99%
rename from tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs
rename to tests/AcDream.Core.Tests/Spells/SpellComponentRequirementServiceTests.cs
index 5a6e0ac1..a062aa42 100644
--- a/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs
+++ b/tests/AcDream.Core.Tests/Spells/SpellComponentRequirementServiceTests.cs
@@ -1,8 +1,7 @@
-using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;
-namespace AcDream.App.Tests.Spells;
+namespace AcDream.Core.Tests.Spells;
public sealed class SpellComponentRequirementServiceTests
{
diff --git a/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs b/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs
index d776787a..c70e57c0 100644
--- a/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs
@@ -111,6 +111,9 @@ public sealed class HeadlessProcessContentOwnerTests
Assert.Equal(1, factory.OpenCount);
Assert.Equal(sessionCount, host.Sessions.Count);
Assert.Equal(sessionCount, host.Content?.LeaseCount);
+ IPreparedCollisionSource sharedCollision =
+ Assert.IsAssignableFrom(
+ host.Sessions[0].Content?.PreparedCollision);
Assert.All(
host.Sessions,
session =>
@@ -121,6 +124,12 @@ public sealed class HeadlessProcessContentOwnerTests
Assert.Same(
factory.PreparedResource,
session.Content?.PreparedAssets);
+ Assert.Same(
+ MagicCatalog.Empty,
+ session.Content?.MagicCatalog);
+ Assert.Same(
+ sharedCollision,
+ session.Content?.PreparedCollision);
});
Assert.Equal(
sessionCount,
@@ -206,12 +215,15 @@ public sealed class HeadlessProcessContentOwnerTests
internal TestResourceProxy Dats { get; }
internal TestResourceProxy Prepared { get; }
- public (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open(
+ public HeadlessOpenedProcessContent Open(
HeadlessContentDescriptor descriptor,
Action diagnostic)
{
OpenCount++;
- return (DatsResource, PreparedResource);
+ return new HeadlessOpenedProcessContent(
+ DatsResource,
+ PreparedResource,
+ MagicCatalog.Empty);
}
}
}