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

@ -392,7 +392,7 @@ public sealed class GameWindow :
private AcDream.App.UI.ItemInteractionController? _itemInteractionController; private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle; private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle;
private AcDream.App.Spells.MagicRuntime? _magicRuntime; private AcDream.App.Spells.MagicRuntime? _magicRuntime;
private AcDream.App.Spells.MagicCatalog? _magicCatalog; private MagicCatalog? _magicCatalog;
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new(); 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 // 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). // 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"); "prepared asset source");
void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog( void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog(
AcDream.App.Spells.MagicCatalog value) => MagicCatalog value) =>
PublishCompositionOwner(ref _magicCatalog, value, "magic catalog"); PublishCompositionOwner(ref _magicCatalog, value, "magic catalog");
void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader( void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader(

View file

@ -1,163 +1,18 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using DatReaderWriter;
using AcDream.Content; using AcDream.Content;
using DatReaderWriter.DBObjs;
using CoreSpellTable = AcDream.Core.Spells.SpellTable;
namespace AcDream.App.Spells; 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> /// <summary>One resolved formula cell in retail's spell examination subview.</summary>
public readonly record struct SpellExamineComponent( public readonly record struct SpellExamineComponent(
uint SpellComponentId, uint SpellComponentId,
SpellComponentDescriptor Descriptor, SpellComponentDescriptor Descriptor,
bool Owned); 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> /// <summary>
/// App content/policy projection for Runtime's live retail magic request /// App content/policy projection for Runtime's live retail magic request
/// owner. The server owns turning, motion, fizzle, mana/component consumption, /// owner. The server owns turning, motion, fizzle, mana/component consumption,

View file

@ -1,4 +1,3 @@
using System.Collections.Immutable;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content; using AcDream.Content;
@ -102,7 +101,9 @@ public sealed class LandblockBuildFactory
} }
AcDream.Core.World.LandblockCollisionBuild collisions = AcDream.Core.World.LandblockCollisionBuild collisions =
BuildPreparedCollisionClosure(build.Landblock); LandblockPhysicsContentBuilder.BuildPreparedCollisionClosure(
_preparedCollisions,
build.Landblock);
AcDream.Core.World.PhysicsDatBundle publicationDats = AcDream.Core.World.PhysicsDatBundle publicationDats =
(build.Landblock.PhysicsDats (build.Landblock.PhysicsDats
?? AcDream.Core.World.PhysicsDatBundle.Empty) ?? AcDream.Core.World.PhysicsDatBundle.Empty)
@ -155,59 +156,11 @@ public sealed class LandblockBuildFactory
// expand into per-part MeshRefs via SetupMesh.Flatten. // expand into per-part MeshRefs via SetupMesh.Flatten.
// GPU upload happens later through the render-thread presentation // GPU upload happens later through the render-thread presentation
// pipeline, never in this worker-side build. // pipeline, never in this worker-side build.
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count); IReadOnlyList<AcDream.Core.World.WorldEntity> hydrated =
foreach (var e in baseLoaded.Entities) LandblockPhysicsContentBuilder.HydrateStaticEntities(
{ _dats,
var meshRefs = new List<AcDream.Core.World.MeshRef>(); baseLoaded,
var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); worldOffset);
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
{
// Single GfxObj stab — identity part transform.
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(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<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(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);
}
// Task 8: merge stabs + scenery + interior into one entity list. // Task 8: merge stabs + scenery + interior into one entity list.
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated); var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
@ -226,7 +179,10 @@ public sealed class LandblockBuildFactory
// Pre-read every DAT object publication consumes. Build this after // Pre-read every DAT object publication consumes. Build this after
// `merged` so all entity GfxObj/Setup ids are known. // `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(); var completedEnvCells = envCellBuild.Build();
return new AcDream.App.Streaming.LandblockBuild( return new AcDream.App.Streaming.LandblockBuild(
new AcDream.Core.World.LoadedLandblock( new AcDream.Core.World.LoadedLandblock(
@ -237,180 +193,6 @@ public sealed class LandblockBuildFactory
completedEnvCells, completedEnvCells,
request.Origin); request.Origin);
} }
/// <summary>
/// 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.
/// </summary>
private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle(
uint landblockId,
System.Collections.Generic.IReadOnlyList<AcDream.Core.World.WorldEntity> entities)
{
var envCells = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.EnvCell>();
var environments = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Environment>();
var setups = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Setup>();
var gfxObjs = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.GfxObj>();
// (1) LandBlockInfo
var lbInfo = _dats!.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
(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<DatReaderWriter.DBObjs.EnvCell>(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<DatReaderWriter.DBObjs.Environment>(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<DatReaderWriter.DBObjs.Setup>(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<DatReaderWriter.DBObjs.Setup>(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<DatReaderWriter.DBObjs.GfxObj>(gid);
if (gfx is not null) gfxObjs[gid] = gfx;
}
}
return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs);
}
/// <summary>
/// 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.
/// </summary>
private AcDream.Core.World.LandblockCollisionBuild
BuildPreparedCollisionClosure(
AcDream.Core.World.LoadedLandblock landblock)
{
AcDream.Core.World.PhysicsDatBundle dats =
landblock.PhysicsDats ??
AcDream.Core.World.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,
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<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.");
}
/// <summary> /// <summary>
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single /// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
/// landblock on the worker thread. Pure CPU — no GL calls. /// landblock on the worker thread. Pure CPU — no GL calls.

View file

@ -6,6 +6,7 @@ using AcDream.App.Spells;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Content;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;

View file

@ -1,6 +1,7 @@
using System.Numerics; using System.Numerics;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Ui; using AcDream.Core.Ui;
using AcDream.Content;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;

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

@ -1,8 +1,7 @@
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
namespace AcDream.App.UI; namespace AcDream.Content;
/// <summary> /// <summary>
/// Ports <c>DBCache::GetDIDFromEnumStatic @ 0x00413940</c>: the portal master map /// Ports <c>DBCache::GetDIDFromEnumStatic @ 0x00413940</c>: the portal master map

View file

@ -45,7 +45,7 @@ public sealed class RuntimeDatCollection : IDatReaderWriter
{ {
private readonly DatCollection _raw; private readonly DatCollection _raw;
private readonly DatCollectionAdapter _bounded; private readonly DatCollectionAdapter _bounded;
private bool _disposed; private int _disposeStage;
internal RuntimeDatCollection(DatCollection raw) internal RuntimeDatCollection(DatCollection raw)
{ {
@ -95,11 +95,22 @@ public sealed class RuntimeDatCollection : IDatReaderWriter
public void Dispose() public void Dispose()
{ {
if (_disposed) while (_disposeStage < 2)
return; {
switch (_disposeStage)
_disposed = true; {
case 0:
_bounded.Dispose(); _bounded.Dispose();
_disposeStage++;
break;
case 1:
_raw.Dispose(); _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

@ -8,7 +8,7 @@ using DatReaderWriter.Enums;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using CoreMagicSchool = AcDream.Core.Spells.MagicSchool; using CoreMagicSchool = AcDream.Core.Spells.MagicSchool;
namespace AcDream.App.Spells; namespace AcDream.Content;
/// <summary> /// <summary>
/// Projects portal.dat's complete retail <see cref="SpellBase"/> record into /// Projects portal.dat's complete retail <see cref="SpellBase"/> record into
@ -19,7 +19,7 @@ namespace AcDream.App.Spells;
/// School names: <c>CSpellBase::SchoolEnumToName @ 0x00597400</c>. /// School names: <c>CSpellBase::SchoolEnumToName @ 0x00597400</c>.
/// Target type: <c>SpellFormula::GetTargetingType @ 0x005BC910</c>. /// Target type: <c>SpellFormula::GetTargetingType @ 0x005BC910</c>.
/// </remarks> /// </remarks>
internal static class RetailSpellMetadataProjector public static class RetailSpellMetadataProjector
{ {
public static SpellMetadata Project( public static SpellMetadata Project(
uint spellId, uint spellId,

View file

@ -2,9 +2,8 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Spells; namespace AcDream.Core.Spells;
public sealed record SpellFormulaDefinition( public sealed record SpellFormulaDefinition(
uint FormulaVersion, uint FormulaVersion,

View file

@ -7,7 +7,8 @@ namespace AcDream.Core.Spells;
/// <summary> /// <summary>
/// Immutable lookup of retail spell metadata. Production builds create this /// Immutable lookup of retail spell metadata. Production builds create this
/// table from portal.dat through the App-layer <c>MagicCatalog</c>. The CSV /// table from portal.dat through Content's process-shareable
/// <c>MagicCatalog</c>. The CSV
/// parser below remains for historical tooling and small synthetic tests. /// parser below remains for historical tooling and small synthetic tests.
/// ///
/// <para> /// <para>

View file

@ -2,6 +2,7 @@ using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Content;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
@ -43,12 +44,29 @@ internal sealed class HeadlessGameplayOperations
private readonly object _gate = new(); private readonly object _gate = new();
private GameRuntime? _runtime; private GameRuntime? _runtime;
private SpellComponentRequirementService? _componentRequirements;
private WorldSession? _session; private WorldSession? _session;
private SessionRoute? _route; private SessionRoute? _route;
internal void Bind(GameRuntime runtime) => internal void Bind(
_runtime = runtime GameRuntime runtime,
?? throw new ArgumentNullException(nameof(runtime)); MagicCatalog? catalog,
Func<string> 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( internal ILiveSessionCommandRouting CreateRoute(
WorldSession session) WorldSession session)
@ -151,12 +169,16 @@ internal sealed class HeadlessGameplayOperations
public bool HasRequiredComponents(uint spellId) public bool HasRequiredComponents(uint spellId)
{ {
if (_componentRequirements is { } requirements)
return requirements.HasRequiredComponents(spellId);
GameRuntime runtime = RequireRuntime(); GameRuntime runtime = RequireRuntime();
ClientObject? player = runtime.InventoryOwner.Objects.Get( ClientObject? player = runtime.InventoryOwner.Objects.Get(
runtime.PlayerIdentity.ServerGuid); runtime.PlayerIdentity.ServerGuid);
return player is not null return player is not null
&& !player.Properties.GetBool( && !player.Properties.GetBool(
SpellComponentsRequiredProperty, SpellComponentRequirementService
.SpellComponentsRequiredProperty,
true); true);
} }
@ -264,6 +286,4 @@ internal sealed class HeadlessGameplayOperations
_route = null; _route = null;
} }
} }
private const uint SpellComponentsRequiredProperty = 68u;
} }

View file

@ -17,15 +17,20 @@ internal readonly record struct HeadlessProcessContentSnapshot(
internal interface IHeadlessProcessContentFactory internal interface IHeadlessProcessContentFactory
{ {
(IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open( HeadlessOpenedProcessContent Open(
HeadlessContentDescriptor descriptor, HeadlessContentDescriptor descriptor,
Action<string> diagnostic); Action<string> diagnostic);
} }
internal sealed record HeadlessOpenedProcessContent(
IDatReaderWriter Dats,
IPreparedAssetSource Prepared,
MagicCatalog Magic);
internal sealed class ProductionHeadlessProcessContentFactory internal sealed class ProductionHeadlessProcessContentFactory
: IHeadlessProcessContentFactory : IHeadlessProcessContentFactory
{ {
public (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open( public HeadlessOpenedProcessContent Open(
HeadlessContentDescriptor descriptor, HeadlessContentDescriptor descriptor,
Action<string> diagnostic) Action<string> diagnostic)
{ {
@ -36,18 +41,30 @@ internal sealed class ProductionHeadlessProcessContentFactory
string preparedAssetPath = string preparedAssetPath =
Path.GetFullPath(descriptor.PreparedAssetPath); Path.GetFullPath(descriptor.PreparedAssetPath);
IDatReaderWriter? dats = null; IDatReaderWriter? dats = null;
IPreparedAssetSource? prepared = null;
try try
{ {
dats = RuntimeDatCollectionFactory.OpenReadOnly(datDirectory); dats = RuntimeDatCollectionFactory.OpenReadOnly(datDirectory);
var prepared = new PakPreparedAssetSource( prepared = new PakPreparedAssetSource(
preparedAssetPath, preparedAssetPath,
dats, dats,
diagnostic); diagnostic);
return (dats, prepared); MagicCatalog magic = MagicCatalog.Load(dats);
return new HeadlessOpenedProcessContent(
dats,
prepared,
magic);
} }
catch catch
{
try
{
prepared?.Dispose();
}
finally
{ {
dats?.Dispose(); dats?.Dispose();
}
throw; throw;
} }
} }
@ -62,8 +79,10 @@ internal sealed class ProductionHeadlessProcessContentFactory
internal sealed class HeadlessProcessContentOwner : IDisposable internal sealed class HeadlessProcessContentOwner : IDisposable
{ {
private readonly object _gate = new(); private readonly object _gate = new();
private readonly MagicCatalog _magic;
private IDatReaderWriter? _dats; private IDatReaderWriter? _dats;
private IPreparedAssetSource? _prepared; private IPreparedAssetSource? _prepared;
private SharedPreparedCollisionCache? _collision;
private int _leaseCount; private int _leaseCount;
private bool _disposeRequested; private bool _disposeRequested;
private bool _disposed; private bool _disposed;
@ -75,30 +94,41 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
{ {
ArgumentNullException.ThrowIfNull(descriptor); ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(diagnostic); ArgumentNullException.ThrowIfNull(diagnostic);
(IDatReaderWriter dats, IPreparedAssetSource prepared) = HeadlessOpenedProcessContent? opened =
(factory ?? new ProductionHeadlessProcessContentFactory()) (factory ?? new ProductionHeadlessProcessContentFactory())
.Open(descriptor, diagnostic); .Open(descriptor, diagnostic);
_dats = dats if (opened is null)
?? throw new InvalidOperationException( {
"The headless content factory returned no DAT owner."); throw new InvalidOperationException(
_prepared = prepared "The headless content factory returned no content owner.");
?? throw new InvalidOperationException( }
"The headless content factory returned no prepared source."); IDatReaderWriter? dats = opened.Dats;
if (prepared is not IPreparedCollisionSource) 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 try
{ {
prepared.Dispose(); prepared?.Dispose();
} }
finally finally
{ {
dats.Dispose(); dats?.Dispose();
} }
_prepared = null; throw !incomplete
_dats = null; && prepared is not IPreparedCollisionSource
throw new NotSupportedException( ? new NotSupportedException(
"Headless production content must expose prepared collision."); "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) internal HeadlessProcessContentLease AcquireLease(string sessionId)
@ -117,7 +147,9 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
this, this,
sessionId, sessionId,
_dats!, _dats!,
_prepared!); _prepared!,
_collision!,
_magic);
} }
} }
@ -163,6 +195,11 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
private void DrainResources() private void DrainResources()
{ {
if (_collision is not null)
{
_collision.Dispose();
_collision = null;
}
if (_prepared is not null) if (_prepared is not null)
{ {
_prepared.Dispose(); _prepared.Dispose();
@ -181,17 +218,23 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
private HeadlessProcessContentOwner? _owner; private HeadlessProcessContentOwner? _owner;
private readonly IDatReaderWriter _dats; private readonly IDatReaderWriter _dats;
private readonly IPreparedAssetSource _prepared; private readonly IPreparedAssetSource _prepared;
private readonly IPreparedCollisionSource _collision;
private readonly MagicCatalog _magic;
internal HeadlessProcessContentLease( internal HeadlessProcessContentLease(
HeadlessProcessContentOwner owner, HeadlessProcessContentOwner owner,
string sessionId, string sessionId,
IDatReaderWriter dats, IDatReaderWriter dats,
IPreparedAssetSource prepared) IPreparedAssetSource prepared,
IPreparedCollisionSource collision,
MagicCatalog magic)
{ {
_owner = owner; _owner = owner;
SessionId = sessionId; SessionId = sessionId;
_dats = dats; _dats = dats;
_prepared = prepared; _prepared = prepared;
_collision = collision;
_magic = magic;
} }
internal string SessionId { get; } internal string SessionId { get; }
@ -214,8 +257,23 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
} }
} }
internal IPreparedCollisionSource PreparedCollision => internal IPreparedCollisionSource PreparedCollision
(IPreparedCollisionSource)PreparedAssets; {
get
{
ObjectDisposedException.ThrowIf(_owner is null, this);
return _collision;
}
}
internal MagicCatalog MagicCatalog
{
get
{
ObjectDisposedException.ThrowIf(_owner is null, this);
return _magic;
}
}
public void Dispose() public void Dispose()
{ {

View file

@ -123,6 +123,7 @@ internal sealed class HeadlessSessionHost : IDisposable
private long _reconnectDeadline; private long _reconnectDeadline;
private bool _reconnectPending; private bool _reconnectPending;
private ulong _stoppedGeneration; private ulong _stoppedGeneration;
private string _accountName = string.Empty;
private bool _disposed; private bool _disposed;
internal HeadlessSessionHost( internal HeadlessSessionHost(
@ -172,7 +173,15 @@ internal sealed class HeadlessSessionHost : IDisposable
CombatTime: () => CombatTime: () =>
runtimeRef?.Clock.SimulationTimeSeconds ?? 0d)); runtimeRef?.Clock.SimulationTimeSeconds ?? 0d));
runtimeRef = runtime; 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 bridge = new SessionCommandBridge();
var commands = new DirectGameRuntimeCommandAdapter( var commands = new DirectGameRuntimeCommandAdapter(
@ -448,6 +457,8 @@ internal sealed class HeadlessSessionHost : IDisposable
password, password,
MapCharacterSelector(_descriptor.Character)); MapCharacterSelector(_descriptor.Character));
LiveSessionStartResult result = _liveSession.Start(options); LiveSessionStartResult result = _liveSession.Start(options);
if (result.Selection is { } selection)
_accountName = selection.AccountName;
RuntimeSessionStartResult converted = Convert(result); RuntimeSessionStartResult converted = Convert(result);
if (converted.Error is { } error) if (converted.Error is { } error)
{ {

View file

@ -4,6 +4,7 @@ using AcDream.App.Composition;
using AcDream.App.Diagnostics; using AcDream.App.Diagnostics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Spells; using AcDream.App.Spells;
using AcDream.Content;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.App.World; using AcDream.App.World;

View file

@ -1,5 +1,6 @@
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Content;
using AcDream.Content.Vfx; using AcDream.Content.Vfx;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter; using DatReaderWriter;

View file

@ -2,6 +2,7 @@ using System.Numerics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Selection; using AcDream.App.Rendering.Selection;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Content;
using AcDream.Core.Textures; using AcDream.Core.Textures;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using DatReaderWriter; using DatReaderWriter;

View file

@ -1,5 +1,6 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Spells; using AcDream.App.Spells;
using AcDream.Content;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.Core.Combat; using AcDream.Core.Combat;

View file

@ -1,4 +1,5 @@
using AcDream.App.Spells; using AcDream.App.Spells;
using AcDream.Content;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.Core.Items; using AcDream.Core.Items;

View file

@ -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<FlatSetupCollision>(
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<FlatSetupCollision>(
cache.ReadSetupCollision(7u).Data);
FlatGfxObjCollisionAsset gfx =
Assert.IsType<FlatGfxObjCollisionAsset>(
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<ObjectDisposedException>(
() => cache.ReadSetupCollision(7u));
}
private sealed class FixtureSource : IPreparedCollisionSource
{
internal Dictionary<uint, int> SetupReads { get; } = new();
internal Dictionary<uint, int> GfxReads { get; } = new();
internal bool IsDisposed { get; private set; }
public PreparedCollisionSourceStats CollisionStats => default;
public PreparedAssetPresence ProbeCollision(
PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Available;
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Increment(GfxReads, sourceFileId);
return PreparedCollisionReadResult<
FlatGfxObjCollisionAsset>.Loaded(
new FlatGfxObjCollisionAsset(
EmptyPhysicsBsp(),
null,
null));
}
public PreparedCollisionReadResult<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Increment(SetupReads, sourceFileId);
return PreparedCollisionReadResult<FlatSetupCollision>.Loaded(
new FlatSetupCollision(
ImmutableArray<FlatCollisionCylinder>.Empty,
ImmutableArray<FlatCollisionSphere>.Empty,
0f,
0f,
0f,
0f));
}
public PreparedCollisionReadResult<
FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
PreparedCollisionReadResult<
FlatCellStructureCollisionAsset>.Missing;
public PreparedCollisionReadResult<FlatEnvCellTopology>
ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
PreparedCollisionReadResult<FlatEnvCellTopology>.Missing;
public void Dispose() => IsDisposed = true;
private static FlatPhysicsBsp EmptyPhysicsBsp() => new(
-1,
ImmutableArray<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
FlatPolygonTable.Empty);
private static void Increment(
Dictionary<uint, int> counts,
uint id) =>
counts[id] = counts.TryGetValue(id, out int count)
? count + 1
: 1;
}
}

View file

@ -1,4 +1,4 @@
using AcDream.App.Spells; using AcDream.Content;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
@ -9,7 +9,7 @@ using DatMagicSchool = DatReaderWriter.Enums.MagicSchool;
using DatSpellCategory = DatReaderWriter.Enums.SpellCategory; using DatSpellCategory = DatReaderWriter.Enums.SpellCategory;
using CoreSpellTable = AcDream.Core.Spells.SpellTable; using CoreSpellTable = AcDream.Core.Spells.SpellTable;
namespace AcDream.App.Tests.Spells; namespace AcDream.Content.Tests.Spells;
public sealed class RetailSpellMetadataProjectorTests public sealed class RetailSpellMetadataProjectorTests
{ {
@ -79,7 +79,8 @@ public sealed class RetailSpellMetadataProjectorTests
string? csvPath = ResolveRepoFile("docs", "research", "data", "spells.csv"); string? csvPath = ResolveRepoFile("docs", "research", "data", "spells.csv");
if (datDir is null || csvPath is null) return; 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); MagicCatalog catalog = MagicCatalog.Load(dats);
CoreSpellTable legacy = CoreSpellTable.LoadFromCsv(csvPath); CoreSpellTable legacy = CoreSpellTable.LoadFromCsv(csvPath);
@ -106,7 +107,8 @@ public sealed class RetailSpellMetadataProjectorTests
string? csvPath = ResolveRepoFile("docs", "research", "data", "spells.csv"); string? csvPath = ResolveRepoFile("docs", "research", "data", "spells.csv");
if (datDir is null || csvPath is null) return; 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); MagicCatalog catalog = MagicCatalog.Load(dats);
CoreSpellTable legacy = CoreSpellTable.LoadFromCsv(csvPath); CoreSpellTable legacy = CoreSpellTable.LoadFromCsv(csvPath);
var mismatches = new List<string>(); var mismatches = new List<string>();

View file

@ -1,8 +1,7 @@
using AcDream.App.Spells;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Spells; using AcDream.Core.Spells;
namespace AcDream.App.Tests.Spells; namespace AcDream.Core.Tests.Spells;
public sealed class SpellComponentRequirementServiceTests public sealed class SpellComponentRequirementServiceTests
{ {

View file

@ -111,6 +111,9 @@ public sealed class HeadlessProcessContentOwnerTests
Assert.Equal(1, factory.OpenCount); Assert.Equal(1, factory.OpenCount);
Assert.Equal(sessionCount, host.Sessions.Count); Assert.Equal(sessionCount, host.Sessions.Count);
Assert.Equal(sessionCount, host.Content?.LeaseCount); Assert.Equal(sessionCount, host.Content?.LeaseCount);
IPreparedCollisionSource sharedCollision =
Assert.IsAssignableFrom<IPreparedCollisionSource>(
host.Sessions[0].Content?.PreparedCollision);
Assert.All( Assert.All(
host.Sessions, host.Sessions,
session => session =>
@ -121,6 +124,12 @@ public sealed class HeadlessProcessContentOwnerTests
Assert.Same( Assert.Same(
factory.PreparedResource, factory.PreparedResource,
session.Content?.PreparedAssets); session.Content?.PreparedAssets);
Assert.Same(
MagicCatalog.Empty,
session.Content?.MagicCatalog);
Assert.Same(
sharedCollision,
session.Content?.PreparedCollision);
}); });
Assert.Equal( Assert.Equal(
sessionCount, sessionCount,
@ -206,12 +215,15 @@ public sealed class HeadlessProcessContentOwnerTests
internal TestResourceProxy Dats { get; } internal TestResourceProxy Dats { get; }
internal TestResourceProxy Prepared { get; } internal TestResourceProxy Prepared { get; }
public (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open( public HeadlessOpenedProcessContent Open(
HeadlessContentDescriptor descriptor, HeadlessContentDescriptor descriptor,
Action<string> diagnostic) Action<string> diagnostic)
{ {
OpenCount++; OpenCount++;
return (DatsResource, PreparedResource); return new HeadlessOpenedProcessContent(
DatsResource,
PreparedResource,
MagicCatalog.Empty);
} }
} }
} }