From 9569dadb578b299b24e30aec85eee86ec0c2b3fb Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Jul 2026 09:00:48 +0200 Subject: [PATCH] feat(headless): share immutable gameplay content --- src/AcDream.App/Rendering/GameWindow.cs | 4 +- src/AcDream.App/Spells/MagicRuntime.cs | 145 ---------- .../Streaming/LandblockBuildFactory.cs | 242 +--------------- .../UI/Layout/SpellbookWindowController.cs | 1 + .../Layout/VividTargetIndicatorController.cs | 1 + .../LandblockPhysicsContentBuilder.cs | 266 ++++++++++++++++++ src/AcDream.Content/MagicCatalog.cs | 192 +++++++++++++ .../RetailDataIdResolver.cs | 3 +- .../RuntimeDatCollectionFactory.cs | 25 +- .../SharedPreparedCollisionCache.cs | 245 ++++++++++++++++ .../Spells/RetailSpellMetadataProjector.cs | 4 +- .../SpellComponentRequirementService.cs | 3 +- src/AcDream.Core/Spells/SpellTable.cs | 3 +- .../Hosting/HeadlessGameplayOperations.cs | 32 ++- .../Hosting/HeadlessProcessContentOwner.cs | 104 +++++-- .../Hosting/HeadlessSessionHost.cs | 13 +- .../InteractionRetainedUiCompositionTests.cs | 1 + .../Rendering/PortalTunnelAssetTests.cs | 1 + .../Rendering/RetailSelectionAssetTests.cs | 1 + .../UI/Layout/AppraisalUiControllerTests.cs | 1 + .../Layout/SpellbookWindowControllerTests.cs | 1 + .../SharedPreparedCollisionCacheTests.cs | 143 ++++++++++ .../RetailSpellMetadataProjectorTests.cs | 10 +- .../SpellComponentRequirementServiceTests.cs | 3 +- .../HeadlessProcessContentOwnerTests.cs | 16 +- 25 files changed, 1031 insertions(+), 429 deletions(-) create mode 100644 src/AcDream.Content/LandblockPhysicsContentBuilder.cs create mode 100644 src/AcDream.Content/MagicCatalog.cs rename src/{AcDream.App/UI => AcDream.Content}/RetailDataIdResolver.cs (95%) create mode 100644 src/AcDream.Content/SharedPreparedCollisionCache.cs rename src/{AcDream.App => AcDream.Content}/Spells/RetailSpellMetadataProjector.cs (98%) rename src/{AcDream.App => AcDream.Core}/Spells/SpellComponentRequirementService.cs (99%) create mode 100644 tests/AcDream.Content.Tests/SharedPreparedCollisionCacheTests.cs rename tests/{AcDream.App.Tests => AcDream.Content.Tests}/Spells/RetailSpellMetadataProjectorTests.cs (94%) rename tests/{AcDream.App.Tests => AcDream.Core.Tests}/Spells/SpellComponentRequirementServiceTests.cs (99%) 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); } } }