feat(headless): share immutable gameplay content
This commit is contained in:
parent
12b500d383
commit
9569dadb57
25 changed files with 1031 additions and 429 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,163 +1,18 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using DatReaderWriter;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using CoreSpellTable = AcDream.Core.Spells.SpellTable;
|
||||
|
||||
namespace AcDream.App.Spells;
|
||||
|
||||
/// <summary>Immutable presentation metadata for one retail spell component WCID.</summary>
|
||||
public sealed record SpellComponentDescriptor(
|
||||
uint WeenieClassId,
|
||||
string Name,
|
||||
uint Category,
|
||||
uint IconId);
|
||||
|
||||
/// <summary>One resolved formula cell in retail's spell examination subview.</summary>
|
||||
public readonly record struct SpellExamineComponent(
|
||||
uint SpellComponentId,
|
||||
SpellComponentDescriptor Descriptor,
|
||||
bool Owned);
|
||||
|
||||
/// <summary>
|
||||
/// App-layer projection of retail's spell, component, and school-focus DAT tables.
|
||||
/// Keeping this catalog outside <c>GameWindow</c> makes the SCID/WCID boundary and
|
||||
/// rough spell-level calculation one independently testable ownership seam.
|
||||
/// </summary>
|
||||
public sealed class MagicCatalog
|
||||
{
|
||||
private const uint SpellTableDid = 0x0E00000Eu;
|
||||
private const uint SpellComponentTableDid = 0x0E00000Fu;
|
||||
private const uint ComponentIdMapDid = 0x27000002u;
|
||||
|
||||
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulaDefinitions;
|
||||
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
|
||||
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
|
||||
private readonly IReadOnlyDictionary<uint, int> _spellLevels;
|
||||
|
||||
private MagicCatalog(
|
||||
CoreSpellTable spellTable,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
|
||||
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulaDefinitions,
|
||||
IReadOnlyDictionary<uint, uint> wcidByScid,
|
||||
IReadOnlyDictionary<uint, uint> magicPackWcidBySchool,
|
||||
IReadOnlyDictionary<uint, int> spellLevels)
|
||||
{
|
||||
SpellTable = spellTable;
|
||||
Components = components;
|
||||
_formulaDefinitions = formulaDefinitions;
|
||||
_wcidByScid = wcidByScid;
|
||||
_magicPackWcidBySchool = magicPackWcidBySchool;
|
||||
_spellLevels = spellLevels;
|
||||
}
|
||||
|
||||
public CoreSpellTable SpellTable { get; }
|
||||
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; }
|
||||
|
||||
public bool TryGetComponentBySpellComponentId(
|
||||
uint spellComponentId,
|
||||
out SpellComponentDescriptor descriptor)
|
||||
{
|
||||
if (_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId)
|
||||
&& Components.TryGetValue(weenieClassId, out SpellComponentDescriptor? found))
|
||||
{
|
||||
descriptor = found;
|
||||
return true;
|
||||
}
|
||||
descriptor = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsComponentPack(uint weenieClassId)
|
||||
=> Components.ContainsKey(weenieClassId);
|
||||
|
||||
public int GetSpellLevel(uint spellId)
|
||||
=> _spellLevels.TryGetValue(spellId, out int level) ? level : 0;
|
||||
|
||||
public SpellComponentRequirementService CreateRequirementService(
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<string> accountName)
|
||||
=> new(
|
||||
objects,
|
||||
playerGuid,
|
||||
accountName,
|
||||
_formulaDefinitions,
|
||||
_wcidByScid,
|
||||
_magicPackWcidBySchool);
|
||||
|
||||
public static MagicCatalog Load(IDatReaderWriter dats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
|
||||
var components = new Dictionary<uint, SpellComponentDescriptor>();
|
||||
var wcidByScid = new Dictionary<uint, uint>();
|
||||
SpellComponentTable? componentTable = dats.Get<SpellComponentTable>(SpellComponentTableDid);
|
||||
DualEnumIDMap? componentIds = dats.Get<DualEnumIDMap>(ComponentIdMapDid);
|
||||
if (componentTable is not null && componentIds is not null)
|
||||
{
|
||||
foreach (var pair in componentTable.Components)
|
||||
{
|
||||
if (!componentIds.ClientEnumToID.TryGetValue(pair.Key, out uint wcid))
|
||||
continue;
|
||||
|
||||
wcidByScid[pair.Key] = wcid;
|
||||
components[wcid] = new SpellComponentDescriptor(
|
||||
wcid,
|
||||
pair.Value.Name.Value,
|
||||
pair.Value.Category,
|
||||
pair.Value.Icon.DataId);
|
||||
}
|
||||
}
|
||||
|
||||
var metadata = new List<SpellMetadata>();
|
||||
var formulaDefinitions = new Dictionary<uint, SpellFormulaDefinition>();
|
||||
var spellLevels = new Dictionary<uint, int>();
|
||||
DatReaderWriter.DBObjs.SpellTable spellTable =
|
||||
dats.Get<DatReaderWriter.DBObjs.SpellTable>(SpellTableDid)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Required retail SpellTable 0x{SpellTableDid:X8} is missing from portal.dat.");
|
||||
foreach (var pair in spellTable.Spells)
|
||||
{
|
||||
SpellMetadata spell = RetailSpellMetadataProjector.Project(
|
||||
pair.Key, pair.Value, componentTable);
|
||||
metadata.Add(spell);
|
||||
uint[] formula = spell.FormulaComponents.ToArray();
|
||||
formulaDefinitions[pair.Key] = new SpellFormulaDefinition(
|
||||
spell.FormulaVersion,
|
||||
formula,
|
||||
(uint)spell.SchoolId);
|
||||
spellLevels[pair.Key] = spell.Generation;
|
||||
}
|
||||
|
||||
var magicPackWcidBySchool = new Dictionary<uint, uint>();
|
||||
// Retail IsComponentPack resolves enum key 0x10000001 in category 0x28;
|
||||
// that key is not itself a portal.dat file id.
|
||||
uint magicPackMapDid = AcDream.App.UI.RetailDataIdResolver.Resolve(
|
||||
dats, 0x10000001u, 0x28u);
|
||||
if (magicPackMapDid != 0u
|
||||
&& dats.Portal.TryGet<EnumIDMap>(magicPackMapDid, out EnumIDMap? magicPackMap)
|
||||
&& magicPackMap is not null)
|
||||
{
|
||||
foreach (var pair in magicPackMap.ClientEnumToID)
|
||||
magicPackWcidBySchool[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return new MagicCatalog(
|
||||
CoreSpellTable.Create(metadata),
|
||||
components,
|
||||
formulaDefinitions,
|
||||
wcidByScid,
|
||||
magicPackWcidBySchool,
|
||||
spellLevels);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// App content/policy projection for Runtime's live retail magic request
|
||||
/// owner. The server owns turning, motion, fizzle, mana/component consumption,
|
||||
|
|
|
|||
|
|
@ -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<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
|
||||
foreach (var e in baseLoaded.Entities)
|
||||
{
|
||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||
var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||||
|
||||
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);
|
||||
}
|
||||
IReadOnlyList<AcDream.Core.World.WorldEntity> hydrated =
|
||||
LandblockPhysicsContentBuilder.HydrateStaticEntities(
|
||||
_dats,
|
||||
baseLoaded,
|
||||
worldOffset);
|
||||
|
||||
// Task 8: merge stabs + scenery + interior into one entity list.
|
||||
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
|
||||
// `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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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>
|
||||
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
|
||||
/// landblock on the worker thread. Pure CPU — no GL calls.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Content;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
|
|
|
|||
266
src/AcDream.Content/LandblockPhysicsContentBuilder.cs
Normal file
266
src/AcDream.Content/LandblockPhysicsContentBuilder.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
192
src/AcDream.Content/MagicCatalog.cs
Normal file
192
src/AcDream.Content/MagicCatalog.cs
Normal 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();
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
using DatReaderWriter;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
namespace AcDream.Content;
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>DBCache::GetDIDFromEnumStatic @ 0x00413940</c>: the portal master map
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
245
src/AcDream.Content/SharedPreparedCollisionCache.cs
Normal file
245
src/AcDream.Content/SharedPreparedCollisionCache.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ using DatReaderWriter.Enums;
|
|||
using DatReaderWriter.Types;
|
||||
using CoreMagicSchool = AcDream.Core.Spells.MagicSchool;
|
||||
|
||||
namespace AcDream.App.Spells;
|
||||
namespace AcDream.Content;
|
||||
|
||||
/// <summary>
|
||||
/// 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>.
|
||||
/// Target type: <c>SpellFormula::GetTargetingType @ 0x005BC910</c>.
|
||||
/// </remarks>
|
||||
internal static class RetailSpellMetadataProjector
|
||||
public static class RetailSpellMetadataProjector
|
||||
{
|
||||
public static SpellMetadata Project(
|
||||
uint spellId,
|
||||
|
|
@ -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,
|
||||
|
|
@ -7,7 +7,8 @@ namespace AcDream.Core.Spells;
|
|||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,15 +17,20 @@ internal readonly record struct HeadlessProcessContentSnapshot(
|
|||
|
||||
internal interface IHeadlessProcessContentFactory
|
||||
{
|
||||
(IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open(
|
||||
HeadlessOpenedProcessContent Open(
|
||||
HeadlessContentDescriptor descriptor,
|
||||
Action<string> 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<string> 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.App.Spells;
|
||||
using AcDream.Content;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
|
|
|
|||
143
tests/AcDream.Content.Tests/SharedPreparedCollisionCacheTests.cs
Normal file
143
tests/AcDream.Content.Tests/SharedPreparedCollisionCacheTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
@ -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<IPreparedCollisionSource>(
|
||||
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<string> diagnostic)
|
||||
{
|
||||
OpenCount++;
|
||||
return (DatsResource, PreparedResource);
|
||||
return new HeadlessOpenedProcessContent(
|
||||
DatsResource,
|
||||
PreparedResource,
|
||||
MagicCatalog.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue