fix(physics): activate collision generations atomically

This commit is contained in:
Erik 2026-07-31 15:19:25 +02:00
parent 3e0f3b6206
commit be94bc9b06
18 changed files with 1402 additions and 80 deletions

View file

@ -12,7 +12,7 @@ namespace AcDream.App.Streaming;
/// publication. The render publisher commits buildings and EnvCells between
/// these stages without recomputing the captured origin.
/// </summary>
public sealed class LandblockPhysicsPublication
public sealed class LandblockPhysicsPublication : IDisposable
{
internal LandblockPhysicsPublication(
object owner,
@ -21,7 +21,8 @@ public sealed class LandblockPhysicsPublication
uint currentCellId,
BuildingInfo[] buildings,
uint[] priorStaticOwnerIds,
RuntimeCollisionAdmission collisionAdmission)
RuntimeCollisionAdmission collisionAdmission,
PreparedLandblockCollisionGeneration preparedGeneration)
{
Owner = owner;
Build = build;
@ -30,6 +31,7 @@ public sealed class LandblockPhysicsPublication
Buildings = buildings;
PriorStaticOwnerIds = priorStaticOwnerIds;
CollisionAdmission = collisionAdmission;
PreparedGeneration = preparedGeneration;
}
internal object Owner { get; }
@ -38,6 +40,9 @@ public sealed class LandblockPhysicsPublication
internal BuildingInfo[] Buildings { get; }
internal uint[] PriorStaticOwnerIds { get; }
internal RuntimeCollisionAdmission CollisionAdmission { get; }
internal PreparedLandblockCollisionGeneration PreparedGeneration { get; }
internal PhysicsDataCache StagingCache => PreparedGeneration.DataCache;
internal PhysicsEngine StagingEngine => PreparedGeneration.Engine;
internal SortedSet<uint> GfxObjectIdSet { get; } = new();
internal uint[] GfxObjectIds { get; set; } = Array.Empty<uint>();
internal int PreparationCursor { get; set; }
@ -64,6 +69,12 @@ public sealed class LandblockPhysicsPublication
internal bool BeginCommitted { get; set; }
internal bool CompletionCommitted { get; set; }
public void Dispose()
{
if (!CompletionCommitted)
PreparedGeneration.Dispose();
}
public uint LandblockId => Build.Landblock.LandblockId;
public Vector3 Origin { get; }
}
@ -204,6 +215,8 @@ public sealed class LandblockPhysicsPublisher
build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray()
?? Array.Empty<BuildingInfo>();
RuntimeCollisionAdmission collisionAdmission =
_physics.BeginCollisionAdmission(build.Landblock.LandblockId);
var publication = new LandblockPhysicsPublication(
_receiptOwner,
build,
@ -212,11 +225,14 @@ public sealed class LandblockPhysicsPublisher
buildings,
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
build.Landblock.LandblockId),
_physics.BeginCollisionAdmission(
build.Landblock.LandblockId));
collisionAdmission,
_physics.PrepareCollisionGeneration(collisionAdmission));
publication.SetupObjectIds = build.Collisions is { } collisions
? [.. collisions.SetupIds]
: datBundle.Setups.Keys.Order().ToArray();
publication.PreparedGeneration.SetAssetClosure(
publication.GfxObjectIds,
publication.SetupObjectIds);
return publication;
}
@ -237,6 +253,9 @@ public sealed class LandblockPhysicsPublisher
if (publication.Build.Collisions is { } collisions)
{
publication.GfxObjectIds = [.. collisions.GfxObjIds];
publication.PreparedGeneration.SetAssetClosure(
publication.GfxObjectIds,
publication.SetupObjectIds);
publication.PreparationCursor = entities.Count;
publication.PreparationCommitted = true;
return true;
@ -256,6 +275,9 @@ public sealed class LandblockPhysicsPublisher
}
publication.GfxObjectIds = publication.GfxObjectIdSet.ToArray();
publication.PreparedGeneration.SetAssetClosure(
publication.GfxObjectIds,
publication.SetupObjectIds);
publication.PreparationCommitted = true;
return true;
}
@ -299,9 +321,9 @@ public sealed class LandblockPhysicsPublisher
// CacheCellStruct/CacheBuilding use first-wins semantics within one
// publication, so the replacement pass starts with one exact
// landblock-scoped withdrawal.
_physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId);
_physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId);
_physicsDataCache.CellGraph.RemoveEnvCellsForLandblock(
publication.StagingCache.RemoveCellsForLandblock(landblock.LandblockId);
publication.StagingCache.RemoveBuildingsForLandblock(landblock.LandblockId);
publication.StagingCache.CellGraph.RemoveEnvCellsForLandblock(
landblock.LandblockId);
publication.PriorCacheRemoved = true;
}
@ -330,6 +352,7 @@ public sealed class LandblockPhysicsPublisher
PublishBuilding(
landblock,
datBundle,
publication.StagingCache,
publication.TerrainSurface,
origin,
publication.Buildings[publication.BuildingCursor]);
@ -337,8 +360,9 @@ public sealed class LandblockPhysicsPublisher
}
else if (!publication.BaseCommitted)
{
_physics.AdmitCollisionAssets(
_physics.StageCollisionAssets(
publication.CollisionAdmission,
publication.PreparedGeneration,
new RuntimeLandblockCollisionAssets(
landblock.LandblockId,
publication.TerrainSurface,
@ -409,7 +433,7 @@ public sealed class LandblockPhysicsPublisher
gfxObjectId,
out FlatGfxObjCollisionAsset? prepared) == true)
{
_physicsDataCache.CacheGfxObj(gfxObjectId, prepared);
publication.StagingCache.CacheGfxObj(gfxObjectId, prepared);
}
else if (datBundle.GfxObjs.TryGetValue(
gfxObjectId,
@ -417,7 +441,7 @@ public sealed class LandblockPhysicsPublisher
{
// Graph-oracle fixture seam. Production near builds always
// carry the strict prepared closure.
_physicsDataCache.CacheGfxObj(gfxObjectId, source);
publication.StagingCache.CacheGfxObj(gfxObjectId, source);
}
publication.GfxCursor++;
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
@ -430,19 +454,19 @@ public sealed class LandblockPhysicsPublisher
setupId,
out FlatSetupCollision? prepared) == true)
{
_physicsDataCache.CacheSetup(setupId, prepared);
publication.StagingCache.CacheSetup(setupId, prepared);
}
else if (datBundle.Setups.TryGetValue(setupId, out var source))
{
// Graph-oracle fixture seam only.
_physicsDataCache.CacheSetup(setupId, source);
publication.StagingCache.CacheSetup(setupId, source);
}
publication.SetupCursor++;
}
else if (publication.PriorStaticCursor
< publication.PriorStaticOwnerIds.Length)
{
_physicsEngine.ShadowObjects.DeregisterStaticOwnerForLandblock(
publication.StagingEngine.ShadowObjects.DeregisterStaticOwnerForLandblock(
publication.PriorStaticOwnerIds[
publication.PriorStaticCursor],
landblock.LandblockId);
@ -458,15 +482,17 @@ public sealed class LandblockPhysicsPublisher
else if (publication.RefloodOwnerIds is null)
{
publication.RefloodOwnerIds =
_physicsEngine.ShadowObjects
.CaptureRefloodOwnersForLandblock(landblock.LandblockId);
_physics.CaptureCollisionDynamicOwners(
publication.CollisionAdmission,
publication.PreparedGeneration);
}
else if (publication.RefloodCursor
< publication.RefloodOwnerIds.Length)
{
_physicsEngine.ShadowObjects.RefloodOwnerForLandblock(
publication.RefloodOwnerIds[publication.RefloodCursor],
landblock.LandblockId);
_physics.RefreshCollisionDynamicOwner(
publication.CollisionAdmission,
publication.PreparedGeneration,
publication.RefloodOwnerIds[publication.RefloodCursor]);
publication.RefloodCursor++;
}
else if (!publication.RefloodCommitted)
@ -478,14 +504,24 @@ public sealed class LandblockPhysicsPublisher
$"lb 0x{landblock.LandblockId:X8}: scenery tried={publication.SceneryTried} " +
$"(outdoorNone={publication.NoCollisionCount})");
}
LogMissingSceneryBounds(landblock);
_refloodCount++;
LogMissingSceneryBounds(landblock, publication.StagingCache);
publication.RefloodCommitted = true;
}
else
{
_physics.CompleteCollisionAdmission(
publication.CollisionAdmission);
RuntimeCollisionGenerationCommit commit =
_physics.CommitCollisionGeneration(
publication.CollisionAdmission,
publication.PreparedGeneration);
if (!commit.Committed)
{
publication.RefloodOwnerIds = commit.DirtyDynamicOwnerIds;
publication.RefloodCursor = 0;
publication.RefloodCommitted = false;
_completePublishTicks += Stopwatch.GetTimestamp() - started;
return false;
}
_refloodCount++;
_staticBspOwnerCount += publication.BspOwnerCount;
_staticCylinderOwnerCount += publication.CylinderOwnerCount;
publication.CompletionCommitted = true;
@ -545,7 +581,7 @@ public sealed class LandblockPhysicsPublisher
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld);
_physicsDataCache.CacheCellStruct(
publication.StagingCache.CacheCellStruct(
envCellId,
envCell,
physicsCellTransform,
@ -623,7 +659,7 @@ public sealed class LandblockPhysicsPublisher
Matrix4x4 physicsCellTransform =
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld);
_physicsDataCache.CacheCellStruct(
publication.StagingCache.CacheCellStruct(
envCellId,
envCell,
cellStruct,
@ -687,6 +723,7 @@ public sealed class LandblockPhysicsPublisher
private void PublishBuilding(
LoadedLandblock landblock,
PhysicsDatBundle datBundle,
PhysicsDataCache cache,
TerrainSurface terrainSurface,
Vector3 origin,
BuildingInfo building)
@ -720,7 +757,7 @@ public sealed class LandblockPhysicsPublisher
? setup.Parts[0]
: 0u;
}
_physicsDataCache.CacheBuilding(
cache.CacheBuilding(
landcellId,
portals,
buildingTransform,
@ -752,11 +789,11 @@ public sealed class LandblockPhysicsPublisher
ShadowShapeBuilder.FromLandblockBspParts(
entity.MeshRefs,
entity.IsBuildingShell,
_physicsDataCache.GetGfxObj);
publication.StagingCache.GetGfxObj);
entityBspCount = bspShapes.Count;
if (entityBspCount > 0)
{
_physicsEngine.ShadowObjects.RegisterMultiPart(
publication.StagingEngine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
@ -772,9 +809,9 @@ public sealed class LandblockPhysicsPublisher
}
FlatSetupCollision? setup =
_physicsDataCache.GetFlatSetup(entity.SourceGfxObjOrSetupId);
publication.StagingCache.GetFlatSetup(entity.SourceGfxObjOrSetupId);
if (setup is null
&& _physicsDataCache.GetSetup(
&& publication.StagingCache.GetSetup(
entity.SourceGfxObjOrSetupId) is { } graphSetup)
{
// Graph-oracle fixture seam only. Production Setup publication is
@ -858,7 +895,7 @@ public sealed class LandblockPhysicsPublisher
if (setupShapes.Count > 0)
{
_physicsEngine.ShadowObjects.RegisterMultiPart(
publication.StagingEngine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
@ -917,7 +954,9 @@ public sealed class LandblockPhysicsPublisher
}
}
private void LogMissingSceneryBounds(LoadedLandblock landblock)
private static void LogMissingSceneryBounds(
LoadedLandblock landblock,
PhysicsDataCache cache)
{
if (!PhysicsDiagnostics.ProbeBuildingEnabled)
return;
@ -933,7 +972,7 @@ public sealed class LandblockPhysicsPublisher
foreach (MeshRef meshRef in entity.MeshRefs)
{
GfxObjVisualBounds? bounds =
_physicsDataCache.GetVisualBounds(meshRef.GfxObjId);
cache.GetVisualBounds(meshRef.GfxObjId);
if (bounds is not null && bounds.Radius > 0f)
{
hasBounds = true;

View file

@ -215,6 +215,18 @@ public sealed class LandblockPresentationPipeline
public IReadOnlyList<LandblockStreamResult> GetPendingPublicationResults() =>
_publications.Keys.ToArray();
/// <summary>
/// Cancels retained publication receipts during a generation reset. A
/// collision receipt owns only its private staging world until activation,
/// so cancellation cannot withdraw or partially replace the active world.
/// </summary>
internal void CancelPendingPublications()
{
foreach (PublicationTransaction transaction in _publications.Values)
transaction.PhysicsPublication?.Dispose();
_publications.Clear();
}
public void ResumePublication(LandblockStreamResult result)
{
ArgumentNullException.ThrowIfNull(result);

View file

@ -32,6 +32,7 @@ public sealed class StreamingController
public bool GenerationAdvanced;
public bool PendingLoadsCleared;
public bool CompletionQueueCleared;
public bool PendingPublicationsCleared;
public bool RegionCleared;
public bool SpatialGenerationDetached;
public bool PreparationCommitted;
@ -45,6 +46,7 @@ public sealed class StreamingController
public bool GenerationAdvanced;
public bool PendingLoadsCleared;
public bool CompletionQueueCleared;
public bool PendingPublicationsCleared;
public bool RegionCleared;
public List<uint>? ResidentIds;
public IEnumerator<uint>? ResidentEnumerator;
@ -1358,6 +1360,22 @@ public sealed class StreamingController
}
if (!transaction.RegionCleared)
{
if (!transaction.PendingPublicationsCleared)
{
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),
"recenter-cancel-publications",
() =>
{
_presentation.CancelPendingPublications();
transaction.PendingPublicationsCleared = true;
return true;
}))
{
return false;
}
}
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),
@ -1499,6 +1517,22 @@ public sealed class StreamingController
if (!transaction.RegionCleared)
{
if (!transaction.PendingPublicationsCleared)
{
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),
"reload-cancel-publications",
() =>
{
_presentation.CancelPendingPublications();
transaction.PendingPublicationsCleared = true;
return true;
}))
{
return false;
}
}
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),

View file

@ -20,6 +20,9 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AcDream.Core.Tests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AcDream.Runtime</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" />

View file

@ -6,6 +6,7 @@ using DatReaderWriter.Types;
using Plane = System.Numerics.Plane;
using UcgEnvCell = AcDream.Core.World.Cells.EnvCell;
using UcgCellGraph = AcDream.Core.World.Cells.CellGraph;
using PreparedCellGraphLandblock = AcDream.Core.World.Cells.PreparedCellGraphLandblock;
namespace AcDream.Core.Physics;
@ -21,6 +22,7 @@ namespace AcDream.Core.Physics;
public sealed class PhysicsDataCache
{
private readonly bool _requirePreparedCollision;
private PhysicsDataCache? _readFallback;
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
@ -92,7 +94,112 @@ public sealed class PhysicsDataCache
/// (<c>TryGetTerrainOrigin</c>, read by <c>CellTransit</c>'s pick + transit
/// paths). No longer inert.
/// </summary>
public UcgCellGraph CellGraph { get; } = new();
public UcgCellGraph CellGraph { get; private set; } = new();
/// <summary>
/// Copies the currently committed immutable collision records into an
/// off-side cache. Streaming may replace one landblock in this copy over
/// many frames without exposing a partially withdrawn cell graph to live
/// physics queries.
/// </summary>
internal PhysicsDataCache CreateCollisionStagingCopy()
{
var copy = new PhysicsDataCache(_requirePreparedCollision)
{
CollisionTraversalMode = CollisionTraversalMode,
CellGraph = CellGraph.CreateCollisionStagingCopy(),
_readFallback = this,
};
// Global immutable GfxObj/Setup records are not copied wholesale.
// The accepted build's exact closure is staged cursor-by-cursor below;
// copying the process-retained asset catalog here would turn every
// landblock publication into an unbounded frame spike.
CopyDictionary(_cellStruct, copy._cellStruct);
CopyDictionary(_flatCellStruct, copy._flatCellStruct);
CopyDictionary(_flatEnvCell, copy._flatEnvCell);
CopyDictionary(_buildings, copy._buildings);
return copy;
}
internal PreparedPhysicsDataCacheLandblock PrepareLandblockReplacement(
uint landblockId,
ReadOnlySpan<uint> gfxObjectIds,
ReadOnlySpan<uint> setupIds)
{
uint prefix = landblockId & 0xFFFF0000u;
return new PreparedPhysicsDataCacheLandblock(
prefix,
CaptureRequested(_gfxObj, gfxObjectIds),
CaptureRequested(_visualBounds, gfxObjectIds),
CaptureRequested(_flatGfxObj, gfxObjectIds),
CaptureRequested(_setup, setupIds),
CaptureRequested(_flatSetup, setupIds),
CapturePrefix(_cellStruct, prefix),
CapturePrefix(_flatCellStruct, prefix),
CapturePrefix(_flatEnvCell, prefix),
CapturePrefix(_buildings, prefix),
CellGraph.PrepareLandblockReplacement(prefix));
}
internal void CommitLandblockReplacement(
PreparedPhysicsDataCacheLandblock replacement)
{
RemoveCellsForLandblock(replacement.LandblockPrefix);
RemoveBuildingsForLandblock(replacement.LandblockPrefix);
CommitEntries(_gfxObj, replacement.GfxObjects, replace: false);
CommitEntries(_visualBounds, replacement.VisualBounds, replace: false);
CommitEntries(_flatGfxObj, replacement.FlatGfxObjects, replace: false);
CommitEntries(_setup, replacement.Setups, replace: false);
CommitEntries(_flatSetup, replacement.FlatSetups, replace: false);
CommitEntries(_cellStruct, replacement.Cells, replace: true);
CommitEntries(_flatCellStruct, replacement.FlatCells, replace: true);
CommitEntries(_flatEnvCell, replacement.FlatEnvCells, replace: true);
CommitEntries(_buildings, replacement.Buildings, replace: true);
CellGraph.CommitLandblockReplacement(replacement.CellGraph);
}
private static void CopyDictionary<T>(
ConcurrentDictionary<uint, T> source,
ConcurrentDictionary<uint, T> destination)
{
foreach ((uint id, T value) in source)
destination.TryAdd(id, value);
}
private static KeyValuePair<uint, T>[] CaptureRequested<T>(
ConcurrentDictionary<uint, T> source,
ReadOnlySpan<uint> ids)
{
var result = new List<KeyValuePair<uint, T>>(ids.Length);
for (int index = 0; index < ids.Length; index++)
{
uint id = ids[index];
if (source.TryGetValue(id, out T? value))
result.Add(new KeyValuePair<uint, T>(id, value));
}
return result.ToArray();
}
private static KeyValuePair<uint, T>[] CapturePrefix<T>(
ConcurrentDictionary<uint, T> source,
uint prefix) => source
.Where(pair => (pair.Key & 0xFFFF0000u) == prefix)
.OrderBy(static pair => pair.Key)
.ToArray();
private static void CommitEntries<T>(
ConcurrentDictionary<uint, T> destination,
KeyValuePair<uint, T>[] entries,
bool replace)
{
foreach ((uint id, T value) in entries)
{
if (replace)
destination[id] = value;
else
destination.TryAdd(id, value);
}
}
/// <summary>
/// Extract and cache the physics BSP + polygon data from a GfxObj,
@ -237,7 +344,9 @@ public sealed class PhysicsDataCache
/// Get the cached visual AABB for a GfxObj, or null if not cached.
/// </summary>
public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) =>
_visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null;
_visualBounds.TryGetValue(gfxObjId, out var vb)
? vb
: _readFallback?.GetVisualBounds(gfxObjId);
/// <summary>
/// Compute a tight axis-aligned bounding box over all vertices in the mesh.
@ -756,14 +865,24 @@ public sealed class PhysicsDataCache
$"Production {kind} 0x{sourceId:X8} has no prepared collision asset. " +
"Gameplay must not extract or fall back to a parsed DAT graph.");
public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null;
public GfxObjPhysics? GetGfxObj(uint id) =>
_gfxObj.TryGetValue(id, out var p)
? p
: _readFallback?.GetGfxObj(id);
public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null;
public SetupPhysics? GetSetup(uint id) =>
_setup.TryGetValue(id, out var p)
? p
: _readFallback?.GetSetup(id);
public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null;
public FlatGfxObjCollisionAsset? GetFlatGfxObj(uint id) =>
_flatGfxObj.TryGetValue(id, out var value) ? value : null;
_flatGfxObj.TryGetValue(id, out var value)
? value
: _readFallback?.GetFlatGfxObj(id);
public FlatSetupCollision? GetFlatSetup(uint id) =>
_flatSetup.TryGetValue(id, out var value) ? value : null;
_flatSetup.TryGetValue(id, out var value)
? value
: _readFallback?.GetFlatSetup(id);
public FlatCellStructureCollisionAsset? GetFlatCellStruct(uint id) =>
_flatCellStruct.TryGetValue(id, out var value) ? value : null;
public FlatEnvCellTopology? GetFlatEnvCell(uint id) =>
@ -926,6 +1045,19 @@ public sealed class PhysicsDataCache
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b;
}
internal sealed record PreparedPhysicsDataCacheLandblock(
uint LandblockPrefix,
KeyValuePair<uint, GfxObjPhysics>[] GfxObjects,
KeyValuePair<uint, GfxObjVisualBounds>[] VisualBounds,
KeyValuePair<uint, FlatGfxObjCollisionAsset>[] FlatGfxObjects,
KeyValuePair<uint, SetupPhysics>[] Setups,
KeyValuePair<uint, FlatSetupCollision>[] FlatSetups,
KeyValuePair<uint, CellPhysics>[] Cells,
KeyValuePair<uint, FlatCellStructureCollisionAsset>[] FlatCells,
KeyValuePair<uint, FlatEnvCellTopology>[] FlatEnvCells,
KeyValuePair<uint, BuildingPhysics>[] Buildings,
PreparedCellGraphLandblock CellGraph);
/// <summary>
/// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless
/// of whether it has physics data. Used as a collision fallback shape for

View file

@ -153,13 +153,106 @@ public sealed class PhysicsEngine
/// </summary>
public ClientObjectTable? Objects { get; set; }
private sealed record LandblockPhysics(
internal sealed record LandblockPhysics(
TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells,
IReadOnlyList<PortalPlane> Portals,
float WorldOffsetX,
float WorldOffsetY);
/// <summary>
/// Creates an off-side collision world from the last complete generation.
/// Streaming modifies this copy only; the active engine and its borrowed
/// cache/registry identities remain stable until Runtime commits.
/// </summary>
internal PhysicsEngine CreateCollisionStagingCopy(
PhysicsDataCache stagingCache)
{
ArgumentNullException.ThrowIfNull(stagingCache);
var staging = new PhysicsEngine
{
DataCache = stagingCache,
Objects = Objects,
};
foreach ((uint id, LandblockPhysics landblock) in _landblocks)
staging._landblocks[id] = landblock;
staging.ShadowObjects.CopyCollisionStateFrom(
ShadowObjects,
stagingCache);
return staging;
}
internal PreparedPhysicsEngineLandblock PrepareLandblockReplacement(
PhysicsEngine staging,
uint landblockId,
ReadOnlySpan<uint> gfxObjectIds,
ReadOnlySpan<uint> setupIds,
IReadOnlyDictionary<uint, ulong> expectedDynamicVersions)
{
ArgumentNullException.ThrowIfNull(staging);
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!staging._landblocks.TryGetValue(
canonical,
out LandblockPhysics? landblock))
{
throw new InvalidOperationException(
$"Staging collision generation has no landblock 0x{canonical:X8}.");
}
PhysicsDataCache stagingCache = staging.DataCache
?? throw new InvalidOperationException(
"Staging collision engine has no data cache.");
return new PreparedPhysicsEngineLandblock(
canonical,
landblock,
stagingCache.PrepareLandblockReplacement(
canonical,
gfxObjectIds,
setupIds),
ShadowObjects.PrepareLandblockReplacement(
staging.ShadowObjects,
canonical,
expectedDynamicVersions));
}
internal bool ValidateLandblockReplacement(
PreparedPhysicsEngineLandblock replacement) =>
ShadowObjects.ValidateLandblockReplacement(replacement.Shadows);
internal void CommitLandblockReplacement(
PreparedPhysicsEngineLandblock replacement)
{
if (!ValidateLandblockReplacement(replacement))
{
throw new InvalidOperationException(
"Collision generation changed after it was sealed.");
}
(DataCache ?? throw new InvalidOperationException(
"Active collision engine has no data cache."))
.CommitLandblockReplacement(replacement.DataCache);
_landblocks[replacement.LandblockId] = replacement.Landblock;
ShadowObjects.CommitLandblockReplacement(replacement.Shadows);
}
internal sealed class PreparedPhysicsEngineLandblock
{
internal PreparedPhysicsEngineLandblock(
uint landblockId,
LandblockPhysics landblock,
PreparedPhysicsDataCacheLandblock dataCache,
ShadowObjectRegistry.PreparedLandblockShadowReplacement shadows)
{
LandblockId = landblockId;
Landblock = landblock;
DataCache = dataCache;
Shadows = shadows;
}
internal uint LandblockId { get; }
internal LandblockPhysics Landblock { get; }
internal PreparedPhysicsDataCacheLandblock DataCache { get; }
internal ShadowObjectRegistry.PreparedLandblockShadowReplacement Shadows { get; }
}
/// <summary>
/// Register a landblock with its terrain surface, indoor cells, portal
/// planes, and world-space origin offset.

View file

@ -51,8 +51,9 @@ public sealed class ShadowObjectRegistry
/// is the streaming-side trigger.
/// </summary>
private readonly Dictionary<uint, RegistrationRecord> _entityReg = new();
private readonly Dictionary<uint, ulong> _ownerVersions = new();
private sealed record RegistrationRecord(
internal sealed record RegistrationRecord(
uint SeedCellId,
Vector3 EntityWorldPos,
Quaternion EntityWorldRot,
@ -67,6 +68,16 @@ public sealed class ShadowObjectRegistry
float CylHeight,
float Scale);
internal ulong GetOwnerVersion(uint entityId) =>
_ownerVersions.TryGetValue(entityId, out ulong version)
? version
: 0UL;
private void BumpOwnerVersion(uint entityId)
{
_ownerVersions[entityId] = checked(GetOwnerVersion(entityId) + 1UL);
}
/// <summary>
/// The flood's data source (cells, buildings, terrain origins). Wired by
/// <see cref="PhysicsEngine"/> when its own <c>DataCache</c> is set.
@ -135,6 +146,7 @@ public sealed class ShadowObjectRegistry
_entityReg[entityId] = new RegistrationRecord(
seed, worldPos, rotation, state, flags, isStatic,
IsMultiPart: false, gfxObjId, radius, collisionType, cylHeight, scale);
BumpOwnerVersion(entityId);
}
/// <summary>
@ -214,6 +226,7 @@ public sealed class ShadowObjectRegistry
seed, entityWorldPos, entityWorldRot, state, flags, isStatic,
IsMultiPart: true, GfxObjId: 0u, Radius: 0f,
CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f);
BumpOwnerVersion(entityId);
}
/// <summary>
@ -271,7 +284,10 @@ public sealed class ShadowObjectRegistry
};
if (suspended || !_entityToCells.TryGetValue(entityId, out List<uint>? cells))
{
BumpOwnerVersion(entityId);
return;
}
foreach (uint cellId in cells)
{
@ -300,6 +316,7 @@ public sealed class ShadowObjectRegistry
foreach (uint cellId in cells)
AddEntryToCell(entry, cellId);
}
BumpOwnerVersion(entityId);
}
/// <summary>
@ -441,6 +458,7 @@ public sealed class ShadowObjectRegistry
}
_suspendedEntities.Add(entityId);
BumpOwnerVersion(entityId);
return true;
}
@ -624,11 +642,16 @@ public sealed class ShadowObjectRegistry
if (_entityReg.TryGetValue(entityId, out var reg))
_entityReg[entityId] = reg with { State = newState };
BumpOwnerVersion(entityId);
}
/// <summary>Remove an entity from all cells it was registered in.</summary>
public void Deregister(uint entityId)
{
bool existed = _entityReg.ContainsKey(entityId)
|| _entityToCells.ContainsKey(entityId)
|| _entityShapes.ContainsKey(entityId)
|| _suspendedEntities.Contains(entityId);
if (_entityToCells.TryGetValue(entityId, out var cellIds))
{
foreach (var cellId in cellIds)
@ -642,6 +665,8 @@ public sealed class ShadowObjectRegistry
_entityReg.Remove(entityId);
_suspendedEntities.Remove(entityId);
_withdrawnPrefixesByOwner.Remove(entityId);
if (existed)
BumpOwnerVersion(entityId);
}
/// <summary>
@ -708,11 +733,13 @@ public sealed class ShadowObjectRegistry
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toRemove = new List<uint>();
var touchedOwners = new HashSet<uint>();
foreach (var (entityId, cells) in _entityToCells)
{
if (!cells.Exists(cell => (cell & 0xFFFF0000u) == lbPrefix))
continue;
touchedOwners.Add(entityId);
if (!_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn))
{
withdrawn = new HashSet<uint>();
@ -753,6 +780,8 @@ public sealed class ShadowObjectRegistry
_withdrawnPrefixesByOwner.Remove(eid);
}
}
foreach (uint entityId in touchedOwners)
BumpOwnerVersion(entityId);
}
/// <summary>
@ -795,6 +824,315 @@ public sealed class ShadowObjectRegistry
/// <summary>Suspended logical registrations awaiting spatial re-entry.</summary>
public int SuspendedRegistrationCount => _suspendedEntities.Count;
/// <summary>
/// Copies the committed registry into an off-side collision generation.
/// All mutable lists and sets are cloned; immutable registration and shape
/// payloads may be shared.
/// </summary>
internal void CopyCollisionStateFrom(
ShadowObjectRegistry source,
PhysicsDataCache stagingCache)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(stagingCache);
Clear();
DataCache = stagingCache;
foreach ((uint cellId, List<ShadowEntry> entries) in source._cells)
_cells[cellId] = new List<ShadowEntry>(entries);
foreach ((uint ownerId, List<uint> cells) in source._entityToCells)
_entityToCells[ownerId] = new List<uint>(cells);
foreach (uint ownerId in source._suspendedEntities)
_suspendedEntities.Add(ownerId);
foreach ((uint ownerId, HashSet<uint> prefixes) in
source._withdrawnPrefixesByOwner)
{
_withdrawnPrefixesByOwner[ownerId] = new HashSet<uint>(prefixes);
}
foreach ((uint ownerId, IReadOnlyList<ShadowShape> shapes) in
source._entityShapes)
{
_entityShapes[ownerId] = shapes;
}
foreach ((uint ownerId, RegistrationRecord registration) in
source._entityReg)
{
_entityReg[ownerId] = registration;
}
foreach ((uint ownerId, ulong version) in source._ownerVersions)
_ownerVersions[ownerId] = version;
}
internal uint[] CaptureDynamicRefloodOwnersForLandblock(
uint landblockId)
{
uint[] owners = CaptureRefloodOwnersForLandblock(landblockId);
return owners.Where(ownerId =>
_entityReg.TryGetValue(ownerId, out RegistrationRecord? record)
&& !record.IsStatic)
.ToArray();
}
/// <summary>
/// Refreshes one staging owner from the exact active payload, then floods
/// it against the staging generation's complete cell graph. The returned
/// source version is the commit-time freshness token.
/// </summary>
internal bool RefreshDynamicOwnerFrom(
ShadowObjectRegistry source,
uint entityId,
uint landblockId,
out ulong sourceVersion)
{
ArgumentNullException.ThrowIfNull(source);
Deregister(entityId);
sourceVersion = source.GetOwnerVersion(entityId);
if (!source._entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration)
|| registration.IsStatic
|| source._suspendedEntities.Contains(entityId)
|| !source.OwnerTouchesLandblock(entityId, landblockId))
{
return false;
}
if (registration.IsMultiPart
&& source._entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes))
{
RegisterMultiPart(
entityId,
registration.EntityWorldPos,
registration.EntityWorldRot,
shapes,
registration.State,
registration.Flags,
0f,
0f,
landblockId,
registration.SeedCellId,
isStatic: false);
}
else
{
Register(
entityId,
registration.GfxObjId,
registration.EntityWorldPos,
registration.EntityWorldRot,
registration.Radius,
0f,
0f,
landblockId,
registration.CollisionType,
registration.CylHeight,
registration.Scale,
registration.State,
registration.Flags,
registration.SeedCellId,
isStatic: false);
}
return true;
}
internal uint[] FindDirtyDynamicOwners(
uint landblockId,
IReadOnlyDictionary<uint, ulong> expectedVersions)
{
var dirty = new HashSet<uint>(
CaptureDynamicRefloodOwnersForLandblock(landblockId));
dirty.UnionWith(expectedVersions.Keys);
dirty.RemoveWhere(ownerId =>
expectedVersions.TryGetValue(ownerId, out ulong expected)
&& OwnerTouchesLandblock(ownerId, landblockId)
&& GetOwnerVersion(ownerId) == expected);
uint[] result = dirty.ToArray();
Array.Sort(result);
return result;
}
internal PreparedLandblockShadowReplacement PrepareLandblockReplacement(
ShadowObjectRegistry staging,
uint landblockId,
IReadOnlyDictionary<uint, ulong> expectedDynamicVersions)
{
ArgumentNullException.ThrowIfNull(staging);
uint[] dirty = FindDirtyDynamicOwners(
landblockId,
expectedDynamicVersions);
if (dirty.Length != 0)
{
throw new InvalidOperationException(
"Dynamic shadow owners changed before collision generation sealing.");
}
var owners = new HashSet<uint>(CaptureStaticOwnersForLandblock(landblockId));
owners.UnionWith(staging.CaptureStaticOwnersForLandblock(landblockId));
owners.UnionWith(expectedDynamicVersions.Keys);
uint[] ownerIds = owners.ToArray();
Array.Sort(ownerIds);
var states = new List<PreparedShadowOwnerState>(ownerIds.Length);
foreach (uint ownerId in ownerIds)
{
if (staging.TryCaptureOwnerState(ownerId, out PreparedShadowOwnerState? state)
&& state is not null)
states.Add(state);
}
return new PreparedLandblockShadowReplacement(
landblockId & 0xFFFF0000u,
ownerIds,
states.ToArray(),
expectedDynamicVersions.ToDictionary(
static pair => pair.Key,
static pair => pair.Value));
}
internal bool ValidateLandblockReplacement(
PreparedLandblockShadowReplacement replacement)
{
foreach ((uint ownerId, ulong version) in replacement.DynamicVersions)
{
if (GetOwnerVersion(ownerId) != version
|| !OwnerTouchesLandblock(ownerId, replacement.LandblockPrefix))
{
return false;
}
}
return CaptureDynamicRefloodOwnersForLandblock(
replacement.LandblockPrefix)
.SequenceEqual(replacement.DynamicVersions.Keys.Order());
}
internal void CommitLandblockReplacement(
PreparedLandblockShadowReplacement replacement)
{
if (!ValidateLandblockReplacement(replacement))
{
throw new InvalidOperationException(
"Dynamic shadow owners changed before collision generation commit.");
}
foreach (uint ownerId in replacement.OwnerIds)
Deregister(ownerId);
foreach (PreparedShadowOwnerState state in replacement.OwnerStates)
InstallOwnerState(state);
}
private bool OwnerTouchesLandblock(uint entityId, uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? record))
return false;
if ((record.SeedCellId & 0xFFFF0000u) == prefix)
return true;
if (_entityToCells.TryGetValue(entityId, out List<uint>? cells)
&& cells.Exists(cell => (cell & 0xFFFF0000u) == prefix))
{
return true;
}
return _withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn)
&& withdrawn.Contains(prefix);
}
private bool TryCaptureOwnerState(
uint entityId,
out PreparedShadowOwnerState? state)
{
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? registration))
{
state = null;
return false;
}
_entityToCells.TryGetValue(entityId, out List<uint>? cells);
_entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes);
_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn);
var rows = new List<PreparedShadowCellRows>();
if (cells is not null)
{
foreach (uint cellId in cells)
{
if (_cells.TryGetValue(cellId, out List<ShadowEntry>? entries))
{
rows.Add(new PreparedShadowCellRows(
cellId,
entries.Where(entry => entry.EntityId == entityId)
.ToArray()));
}
}
}
state = new PreparedShadowOwnerState(
entityId,
registration,
shapes,
cells?.ToArray() ?? Array.Empty<uint>(),
rows.ToArray(),
_suspendedEntities.Contains(entityId),
withdrawn?.ToArray() ?? Array.Empty<uint>());
return true;
}
private void InstallOwnerState(PreparedShadowOwnerState state)
{
_entityReg[state.EntityId] = state.Registration;
if (state.Shapes is not null)
_entityShapes[state.EntityId] = state.Shapes;
if (state.Suspended)
_suspendedEntities.Add(state.EntityId);
if (state.WithdrawnPrefixes.Length != 0)
{
_withdrawnPrefixesByOwner[state.EntityId] =
new HashSet<uint>(state.WithdrawnPrefixes);
}
if (state.CellIds.Length != 0)
_entityToCells[state.EntityId] = new List<uint>(state.CellIds);
foreach (PreparedShadowCellRows row in state.Rows)
{
foreach (ShadowEntry entry in row.Entries)
AddEntryToCell(entry, row.CellId);
}
BumpOwnerVersion(state.EntityId);
}
internal sealed class PreparedLandblockShadowReplacement
{
internal PreparedLandblockShadowReplacement(
uint landblockPrefix,
uint[] ownerIds,
PreparedShadowOwnerState[] ownerStates,
Dictionary<uint, ulong> dynamicVersions)
{
LandblockPrefix = landblockPrefix;
OwnerIds = ownerIds;
OwnerStates = ownerStates;
DynamicVersions = dynamicVersions;
}
internal uint LandblockPrefix { get; }
internal uint[] OwnerIds { get; }
internal PreparedShadowOwnerState[] OwnerStates { get; }
internal Dictionary<uint, ulong> DynamicVersions { get; }
}
internal sealed record PreparedShadowOwnerState(
uint EntityId,
RegistrationRecord Registration,
IReadOnlyList<ShadowShape>? Shapes,
uint[] CellIds,
PreparedShadowCellRows[] Rows,
bool Suspended,
uint[] WithdrawnPrefixes);
internal sealed record PreparedShadowCellRows(
uint CellId,
ShadowEntry[] Entries);
/// <summary>
/// Retires the complete logical registry at terminal physics-engine
/// disposal, including suspended live registrations that own no cell row.
@ -807,6 +1145,7 @@ public sealed class ShadowObjectRegistry
_withdrawnPrefixesByOwner.Clear();
_entityShapes.Clear();
_entityReg.Clear();
_ownerVersions.Clear();
_fallback = null;
}

View file

@ -125,4 +125,76 @@ public sealed class CellGraph
return stab;
return null;
}
/// <summary>
/// Creates an immutable-reference snapshot for collision-generation
/// preparation. EnvCell and TerrainSurface records are immutable after
/// publication, so copying the registries is sufficient; the active graph
/// remains untouched while the staging graph is rebuilt.
/// </summary>
internal CellGraph CreateCollisionStagingCopy()
{
var copy = new CellGraph { CurrCell = CurrCell };
foreach ((uint id, EnvCell cell) in _envCells)
copy._envCells.TryAdd(id, cell);
foreach ((uint id, (TerrainSurface Terrain, Vector3 Origin) terrain) in
_terrain)
{
copy._terrain.TryAdd(id, terrain);
}
return copy;
}
internal PreparedCellGraphLandblock PrepareLandblockReplacement(
uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
KeyValuePair<uint, EnvCell>[] envCells = _envCells
.Where(static pair => (pair.Key & 0xFFFFu) >= 0x0100u)
.Where(pair => (pair.Key & 0xFFFF0000u) == prefix)
.OrderBy(static pair => pair.Key)
.ToArray();
bool hasTerrain = _terrain.TryGetValue(prefix, out var terrain);
return new PreparedCellGraphLandblock(
prefix,
envCells,
hasTerrain,
terrain.Terrain,
terrain.Origin,
CurrCell?.Id ?? 0u);
}
internal void CommitLandblockReplacement(
PreparedCellGraphLandblock replacement)
{
uint currentCellId = CurrCell?.Id ?? 0u;
RemoveLandblock(replacement.LandblockPrefix);
if (replacement.HasTerrain)
{
_terrain[replacement.LandblockPrefix] = (
replacement.Terrain!,
replacement.Origin);
}
foreach ((uint id, EnvCell cell) in replacement.EnvCells)
_envCells[id] = cell;
uint desiredCurrentCellId =
(currentCellId & 0xFFFF0000u) == replacement.LandblockPrefix
? currentCellId
: currentCellId == 0u
&& (replacement.CurrentCellId & 0xFFFF0000u)
== replacement.LandblockPrefix
? replacement.CurrentCellId
: 0u;
if (desiredCurrentCellId != 0u)
CurrCell = GetVisible(desiredCurrentCellId);
}
}
internal sealed record PreparedCellGraphLandblock(
uint LandblockPrefix,
KeyValuePair<uint, EnvCell>[] EnvCells,
bool HasTerrain,
TerrainSurface? Terrain,
Vector3 Origin,
uint CurrentCellId);

View file

@ -174,7 +174,14 @@ internal sealed class HeadlessCollisionNeighborhood
RuntimePhysicsState physics =
_runtime.EntityObjects.Physics;
PhysicsDataCache cache = physics.DataCache;
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(landblockId);
using PreparedLandblockCollisionGeneration prepared =
physics.PrepareCollisionGeneration(admission);
prepared.SetAssetClosure(
[.. collisions.GfxObjIds],
[.. collisions.SetupIds]);
PhysicsDataCache cache = prepared.DataCache;
TerrainSurface terrain =
LandblockPhysicsContentBuilder.BuildTerrainSurface(
landblock,
@ -197,12 +204,11 @@ internal sealed class HeadlessCollisionNeighborhood
cache,
collisions);
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(landblockId);
try
{
physics.AdmitCollisionAssets(
physics.StageCollisionAssets(
admission,
prepared,
new RuntimeLandblockCollisionAssets(
landblockId,
terrain,
@ -213,12 +219,27 @@ internal sealed class HeadlessCollisionNeighborhood
currentCellId));
_ = LandblockPhysicsContentBuilder
.PublishStaticCollision(
physics.Engine,
prepared.Engine,
cache,
landblock,
collisions,
origin);
_ = physics.CompleteCollisionAdmission(admission);
foreach (uint ownerId in physics.CaptureCollisionDynamicOwners(
admission,
prepared))
{
physics.RefreshCollisionDynamicOwner(
admission,
prepared,
ownerId);
}
RuntimeCollisionGenerationCommit commit =
physics.CommitCollisionGeneration(admission, prepared);
if (!commit.Committed)
{
throw new InvalidOperationException(
"Headless collision generation changed during synchronous publication.");
}
_resident.Add(CanonicalLandblock(landblockId));
}
catch

View file

@ -12,6 +12,8 @@
<InternalsVisibleTo Include="AcDream.App" />
<InternalsVisibleTo Include="AcDream.App.Tests" />
<InternalsVisibleTo Include="AcDream.Core.Tests" />
<InternalsVisibleTo Include="acdream-headless" />
<InternalsVisibleTo Include="AcDream.Headless.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />

View file

@ -54,7 +54,7 @@ public sealed class RuntimeCollisionAdmission
}
internal RuntimePhysicsState Owner { get; }
internal bool AssetsAdmitted { get; set; }
internal bool AssetsPrepared { get; set; }
internal bool Completed { get; set; }
public uint LandblockId { get; }
public ulong Generation { get; }
@ -63,7 +63,109 @@ public sealed class RuntimeCollisionAdmission
public readonly record struct RuntimeCollisionAcknowledgement(
uint LandblockId,
ulong Generation,
bool WasResident);
bool WasResident,
bool Ready);
public readonly record struct RuntimeCollisionGenerationCommit(
RuntimeCollisionAcknowledgement Acknowledgement,
uint[] DirtyDynamicOwnerIds)
{
public bool Committed => Acknowledgement.Ready;
}
public readonly record struct RuntimeCollisionGenerationCommitted(
uint LandblockId,
ulong Generation,
bool Ready);
/// <summary>
/// One off-side collision generation. It owns a private cache, cell graph,
/// engine, and shadow registry cloned from the previous complete generation.
/// Hosts may populate it incrementally, but only Runtime can activate it.
/// </summary>
internal sealed class PreparedLandblockCollisionGeneration : IDisposable
{
private readonly RuntimePhysicsState _owner;
private readonly RuntimeCollisionAdmission _admission;
private readonly Dictionary<uint, ulong> _dynamicOwnerVersions = new();
private bool _disposed;
internal PreparedLandblockCollisionGeneration(
RuntimePhysicsState owner,
RuntimeCollisionAdmission admission,
PhysicsDataCache dataCache,
PhysicsEngine engine)
{
_owner = owner;
_admission = admission;
DataCache = dataCache;
Engine = engine;
}
internal PhysicsDataCache DataCache { get; }
internal PhysicsEngine Engine { get; }
internal uint[] GfxObjectIds { get; private set; } = Array.Empty<uint>();
internal uint[] SetupIds { get; private set; } = Array.Empty<uint>();
internal IReadOnlyDictionary<uint, ulong> DynamicOwnerVersions =>
_dynamicOwnerVersions;
internal bool IsDisposed => _disposed;
internal bool Matches(
RuntimePhysicsState owner,
RuntimeCollisionAdmission admission) =>
ReferenceEquals(_owner, owner)
&& ReferenceEquals(_admission, admission);
internal void SetAssetClosure(uint[] gfxObjectIds, uint[] setupIds)
{
EnsureUsable();
GfxObjectIds = gfxObjectIds ?? throw new ArgumentNullException(nameof(gfxObjectIds));
SetupIds = setupIds ?? throw new ArgumentNullException(nameof(setupIds));
}
internal void RefreshDynamicOwner(uint ownerId)
{
EnsureUsable();
bool retained = Engine.ShadowObjects.RefreshDynamicOwnerFrom(
_owner.Engine.ShadowObjects,
ownerId,
_admission.LandblockId,
out ulong version);
if (retained)
_dynamicOwnerVersions[ownerId] = version;
else
_dynamicOwnerVersions.Remove(ownerId);
}
internal uint[] FindDirtyDynamicOwners()
{
EnsureUsable();
return _owner.Engine.ShadowObjects.FindDirtyDynamicOwners(
_admission.LandblockId,
_dynamicOwnerVersions);
}
internal void MarkCommitted()
{
EnsureUsable();
_disposed = true;
}
public void Dispose()
{
if (_disposed)
return;
Engine.Clear();
_dynamicOwnerVersions.Clear();
_disposed = true;
}
private void EnsureUsable()
{
if (_disposed)
throw new ObjectDisposedException(nameof(PreparedLandblockCollisionGeneration));
}
}
/// <summary>
/// Presentation-free mutable physics world for one Runtime/session owner.
@ -83,9 +185,12 @@ public sealed class RuntimePhysicsState : IDisposable
private readonly Dictionary<uint, ulong> _collisionGenerations = new();
private readonly Dictionary<uint, RuntimeCollisionAdmission>
_collisionAdmissions = new();
private int _collisionMutationThreadId;
private bool _disposed;
public event Action<RuntimePhysicsCellCommit>? CellCommitted;
public event Action<RuntimeCollisionGenerationCommitted>?
CollisionGenerationCommitted;
internal RuntimePhysicsState(
RuntimeEntityDirectory entities,
@ -865,6 +970,7 @@ public sealed class RuntimePhysicsState : IDisposable
uint landblockId)
{
EnsureNotDisposed();
EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId);
ulong generation = _collisionGenerations.TryGetValue(
canonical,
@ -880,11 +986,29 @@ public sealed class RuntimePhysicsState : IDisposable
return admission;
}
public void AdmitCollisionAssets(
internal PreparedLandblockCollisionGeneration PrepareCollisionGeneration(
RuntimeCollisionAdmission admission)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
PhysicsDataCache stagingCache = DataCache.CreateCollisionStagingCopy();
PhysicsEngine stagingEngine =
Engine.CreateCollisionStagingCopy(stagingCache);
return new PreparedLandblockCollisionGeneration(
this,
admission,
stagingCache,
stagingEngine);
}
internal void StageCollisionAssets(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared,
RuntimeLandblockCollisionAssets assets)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
ArgumentNullException.ThrowIfNull(assets);
if (CanonicalLandblock(assets.LandblockId)
!= admission.LandblockId)
@ -898,13 +1022,13 @@ public sealed class RuntimePhysicsState : IDisposable
throw new InvalidOperationException(
"A completed collision admission cannot publish more assets.");
}
if (admission.AssetsAdmitted)
if (admission.AssetsPrepared)
{
throw new InvalidOperationException(
"Collision assets were already admitted by this receipt.");
"Collision assets were already prepared by this receipt.");
}
Engine.AddLandblock(
prepared.Engine.AddLandblock(
admission.LandblockId,
assets.Terrain,
assets.CellSurfaces,
@ -914,38 +1038,106 @@ public sealed class RuntimePhysicsState : IDisposable
if ((assets.CurrentCellId & 0xFFFF0000u)
== (admission.LandblockId & 0xFFFF0000u))
{
Engine.UpdatePlayerCurrCell(assets.CurrentCellId);
prepared.Engine.UpdatePlayerCurrCell(assets.CurrentCellId);
}
admission.AssetsAdmitted = true;
admission.AssetsPrepared = true;
}
public RuntimeCollisionAcknowledgement CompleteCollisionAdmission(
RuntimeCollisionAdmission admission)
internal uint[] CaptureCollisionDynamicOwners(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
ValidateAdmission(admission);
if (!admission.AssetsAdmitted)
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
return Engine.ShadowObjects.CaptureDynamicRefloodOwnersForLandblock(
admission.LandblockId);
}
internal void RefreshCollisionDynamicOwner(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared,
uint ownerId)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
prepared.RefreshDynamicOwner(ownerId);
}
internal RuntimeCollisionGenerationCommit CommitCollisionGeneration(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
if (!admission.AssetsPrepared)
{
throw new InvalidOperationException(
"Collision admission cannot complete before its assets publish.");
"Collision generation cannot commit before its assets are prepared.");
}
if (admission.Completed)
{
throw new InvalidOperationException(
"Collision admission has already completed.");
"Collision generation has already completed.");
}
uint[] dirtyOwners = prepared.FindDirtyDynamicOwners();
if (dirtyOwners.Length != 0)
{
return new RuntimeCollisionGenerationCommit(
new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: false),
dirtyOwners);
}
PhysicsEngine.PreparedPhysicsEngineLandblock replacement =
Engine.PrepareLandblockReplacement(
prepared.Engine,
admission.LandblockId,
prepared.GfxObjectIds,
prepared.SetupIds,
prepared.DynamicOwnerVersions);
if (!Engine.ValidateLandblockReplacement(replacement))
{
dirtyOwners = prepared.FindDirtyDynamicOwners();
return new RuntimeCollisionGenerationCommit(
new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: false),
dirtyOwners);
}
Engine.CommitLandblockReplacement(replacement);
admission.Completed = true;
_collisionAdmissions.Remove(admission.LandblockId);
return new RuntimeCollisionAcknowledgement(
prepared.MarkCommitted();
var acknowledgement = new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId));
Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: Engine.IsLandblockTerrainResident(admission.LandblockId));
PublishCollisionGenerationCommitted(
new RuntimeCollisionGenerationCommitted(
acknowledgement.LandblockId,
acknowledgement.Generation,
acknowledgement.Ready));
return new RuntimeCollisionGenerationCommit(
acknowledgement,
Array.Empty<uint>());
}
public RuntimeCollisionAcknowledgement DemoteCollisionToTerrain(
uint landblockId)
{
EnsureNotDisposed();
EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId);
bool resident = Engine.IsLandblockTerrainResident(canonical);
InvalidateCollisionAdmission(canonical);
@ -953,13 +1145,15 @@ public sealed class RuntimePhysicsState : IDisposable
return new RuntimeCollisionAcknowledgement(
canonical,
_collisionGenerations[canonical],
resident);
resident,
Ready: Engine.IsLandblockTerrainResident(canonical));
}
public RuntimeCollisionAcknowledgement WithdrawCollision(
uint landblockId)
{
EnsureNotDisposed();
EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId);
bool resident = Engine.IsLandblockTerrainResident(canonical);
InvalidateCollisionAdmission(canonical);
@ -967,7 +1161,8 @@ public sealed class RuntimePhysicsState : IDisposable
return new RuntimeCollisionAcknowledgement(
canonical,
_collisionGenerations[canonical],
resident);
resident,
Ready: false);
}
public void Dispose()
@ -981,6 +1176,7 @@ public sealed class RuntimePhysicsState : IDisposable
_collisionAdmissions.Clear();
_collisionGenerations.Clear();
CellCommitted = null;
CollisionGenerationCommitted = null;
_disposed = true;
}
@ -1053,6 +1249,20 @@ public sealed class RuntimePhysicsState : IDisposable
private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this);
private void EnsureCollisionMutationThread()
{
int current = Environment.CurrentManagedThreadId;
int owner = Interlocked.CompareExchange(
ref _collisionMutationThreadId,
current,
0);
if (owner != 0 && owner != current)
{
throw new InvalidOperationException(
"Collision generations must be staged and committed on one update thread.");
}
}
private void EnsureCurrent(RuntimeEntityRecord record)
{
if (!Entities.IsCurrent(record))
@ -1081,6 +1291,39 @@ public sealed class RuntimePhysicsState : IDisposable
}
}
private void ValidatePreparedGeneration(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
ArgumentNullException.ThrowIfNull(prepared);
ObjectDisposedException.ThrowIf(prepared.IsDisposed, prepared);
if (!prepared.Matches(this, admission))
{
throw new InvalidOperationException(
"Prepared collision generation is stale or belongs to another admission.");
}
}
private void PublishCollisionGenerationCommitted(
RuntimeCollisionGenerationCommitted committed)
{
Delegate[] observers = CollisionGenerationCommitted?
.GetInvocationList() ?? Array.Empty<Delegate>();
foreach (Delegate observer in observers)
{
try
{
((Action<RuntimeCollisionGenerationCommitted>)observer)(committed);
}
catch (Exception error)
{
System.Diagnostics.Trace.TraceError(
"Collision-generation commit observer failed after activation: {0}",
error);
}
}
}
private void InvalidateCollisionAdmission(uint landblockId)
{
ulong generation = _collisionGenerations.TryGetValue(