fix(physics): make collision activation starvation-free

This commit is contained in:
Erik 2026-07-31 18:34:46 +02:00
parent d94145e6b8
commit 6b28ff999c
14 changed files with 4637 additions and 474 deletions

View file

@ -265,6 +265,13 @@ public sealed class LandblockPhysicsPublisher
if (publication.PreparationCommitted)
return true;
if (!_physics.AdvanceCollisionGenerationPreparation(
publication.CollisionAdmission,
publication.PreparedGeneration).Completed)
{
return false;
}
IReadOnlyList<WorldEntity> entities =
publication.Build.Landblock.Entities;
if (publication.Build.Collisions is { } collisions)
@ -548,7 +555,13 @@ public sealed class LandblockPhysicsPublisher
publication.RefloodCommitted = false;
}
}
else
// Seal reconciliation and the zero-work root transfer are one host
// update-thread transaction. Deferring this commit to the next frame
// would let continuously moving, unrelated live owners dirty their
// already-visited journal slots forever even though each metered replay
// had just caught up exactly.
if (publication.SealCommitted && !publication.CompletionCommitted)
{
RuntimeCollisionGenerationCommit commit =
_physics.CommitCollisionGeneration(
@ -556,12 +569,9 @@ public sealed class LandblockPhysicsPublisher
publication.PreparedGeneration);
if (!commit.Committed)
{
_physics.RestartCollisionRetainedOwnerCapture(
publication.CollisionAdmission,
publication.PreparedGeneration);
publication.RefloodOwnerIds = null;
publication.RefloodCursor = 0;
publication.RefloodCommitted = false;
// Runtime coalesces post-seal arrivals in its owner journal.
// Resume that seal tail rather than restarting the
// completed generation-wide capture/reflood pass.
publication.SealCommitted = false;
_completePublishTicks += Stopwatch.GetTimestamp() - started;
return false;

View file

@ -0,0 +1,86 @@
using System.Collections.Concurrent;
using AcDream.Core.World.Cells;
namespace AcDream.Core.Physics;
/// <summary>
/// One exclusive-by-ownership collision-world root. A preparation mutates only
/// its private root; activation transfers the complete root through one volatile
/// reference publication shared by every collision facade.
/// </summary>
internal sealed class CollisionWorldState
{
internal Dictionary<uint, PhysicsEngine.LandblockPhysics> Landblocks { get; } = new();
internal List<uint> LandblockSlots { get; } = new();
internal Dictionary<uint, int> LandblockIndices { get; } = new();
internal Stack<int> LandblockFreeSlots { get; } = new();
internal ConcurrentDictionary<uint, CellPhysics> CellStruct { get; } = new();
internal ConcurrentDictionary<uint, FlatCellStructureCollisionAsset>
FlatCellStruct { get; } = new();
internal ConcurrentDictionary<uint, FlatEnvCellTopology> FlatEnvCell { get; } = new();
internal ConcurrentDictionary<uint, BuildingPhysics> Buildings { get; } = new();
internal ConcurrentDictionary<uint, EnvCell> EnvCells { get; } = new();
internal ConcurrentDictionary<uint, CellGraphTerrain> Terrain { get; } = new();
internal ConcurrentDictionary<uint, ObjCell> OutdoorCells { get; } = new();
internal Dictionary<uint, List<ShadowEntry>> ShadowCells { get; } = new();
internal Dictionary<uint, List<uint>> ShadowEntityCells { get; } = new();
internal HashSet<uint> SuspendedShadowEntities { get; } = new();
internal Dictionary<uint, HashSet<uint>> WithdrawnPrefixesByOwner { get; } = new();
internal Dictionary<uint, IReadOnlyList<ShadowShape>> ShadowEntityShapes { get; } = new();
internal Dictionary<uint, ShadowObjectRegistry.RegistrationRecord>
ShadowEntityRegistrations { get; } = new();
internal Dictionary<uint, ulong> ShadowOwnerVersions { get; } = new();
internal Dictionary<uint, HashSet<uint>> ShadowOwnerPrefixes { get; } = new();
internal Dictionary<uint, List<uint>> ShadowPrefixOwnerSlots { get; } = new();
internal Dictionary<uint, Dictionary<uint, int>> ShadowPrefixOwnerIndices { get; } = new();
internal Dictionary<uint, Stack<int>> ShadowPrefixFreeSlots { get; } = new();
internal List<uint> ShadowOwnerSlots { get; } = new();
internal Dictionary<uint, int> ShadowOwnerIndices { get; } = new();
internal Stack<int> ShadowOwnerFreeSlots { get; } = new();
}
/// <summary>
/// Stable indirection shared by PhysicsEngine, PhysicsDataCache, CellGraph,
/// and ShadowObjectRegistry. Readers observe either complete root, never a
/// mixture assembled by several facade assignments.
/// </summary>
internal sealed class CollisionWorldStateSlot
{
private CollisionWorldState? _current = new();
private bool _revoked;
internal CollisionWorldStateSlot()
{
}
internal CollisionWorldStateSlot(CollisionWorldState current)
{
_current = current ?? throw new ArgumentNullException(nameof(current));
}
internal CollisionWorldState Current
{
get
{
if (_revoked)
throw new ObjectDisposedException("Transferred collision generation");
return Volatile.Read(ref _current)
?? throw new ObjectDisposedException("Transferred collision generation");
}
}
internal CollisionWorldState TransferTo(CollisionWorldStateSlot destination)
{
ArgumentNullException.ThrowIfNull(destination);
if (_revoked)
throw new ObjectDisposedException("Transferred collision generation");
CollisionWorldState transferred = _current
?? throw new ObjectDisposedException("Transferred collision generation");
_revoked = true;
Volatile.Write(ref destination._current, transferred);
_current = null;
return transferred;
}
internal CollisionWorldState Capture() => Current;
}

View file

@ -23,18 +23,20 @@ public sealed class PhysicsDataCache
{
private readonly bool _requirePreparedCollision;
private PhysicsDataCache? _readFallback;
private readonly CollisionWorldStateSlot _collisionWorld;
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
private ConcurrentDictionary<uint, CellPhysics> _cellStruct =>
_collisionWorld.Current.CellStruct;
private readonly ConcurrentDictionary<uint, FlatGfxObjCollisionAsset>
_flatGfxObj = new();
private readonly ConcurrentDictionary<uint, FlatSetupCollision>
_flatSetup = new();
private readonly ConcurrentDictionary<uint, FlatCellStructureCollisionAsset>
_flatCellStruct = new();
private readonly ConcurrentDictionary<uint, FlatEnvCellTopology>
_flatEnvCell = new();
private ConcurrentDictionary<uint, FlatCellStructureCollisionAsset>
_flatCellStruct => _collisionWorld.Current.FlatCellStruct;
private ConcurrentDictionary<uint, FlatEnvCellTopology>
_flatEnvCell => _collisionWorld.Current.FlatEnvCell;
public PhysicsDataCache()
: this(requirePreparedCollision: false)
@ -42,8 +44,18 @@ public sealed class PhysicsDataCache
}
private PhysicsDataCache(bool requirePreparedCollision)
: this(requirePreparedCollision, new CollisionWorldStateSlot())
{
}
private PhysicsDataCache(
bool requirePreparedCollision,
CollisionWorldStateSlot collisionWorld)
{
_requirePreparedCollision = requirePreparedCollision;
_collisionWorld = collisionWorld
?? throw new ArgumentNullException(nameof(collisionWorld));
CellGraph = new UcgCellGraph(_collisionWorld);
if (!requirePreparedCollision
&& PhysicsDiagnostics.CollisionShadowSampleEvery > 0)
{
@ -67,6 +79,18 @@ public sealed class PhysicsDataCache
return cache;
}
internal static PhysicsDataCache CreateProduction(
CollisionWorldStateSlot collisionWorld)
{
var cache = new PhysicsDataCache(
requirePreparedCollision: true,
collisionWorld)
{
CollisionTraversalMode = CollisionTraversalMode.Flat,
};
return cache;
}
internal CollisionShadowVerifier? CollisionShadow { get; set; }
public CollisionShadowStats CollisionShadowStats =>
@ -80,7 +104,8 @@ public sealed class PhysicsDataCache
CollisionTraversalMode.Graph;
// ── Phase 2: building portal cache for outdoor→indoor entry ───────────
private readonly ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();
private ConcurrentDictionary<uint, BuildingPhysics> _buildings =>
_collisionWorld.Current.Buildings;
/// <summary>
/// The unified cell graph (UCG): the active id-&gt;cell resolver and registry.
@ -94,31 +119,23 @@ public sealed class PhysicsDataCache
/// (<c>TryGetTerrainOrigin</c>, read by <c>CellTransit</c>'s pick + transit
/// paths). No longer inert.
/// </summary>
public UcgCellGraph CellGraph { get; private set; } = new();
public UcgCellGraph CellGraph { get; }
internal CollisionWorldStateSlot CollisionWorld => _collisionWorld;
/// <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.
/// Creates the empty off-side facade used by Runtime's metered root
/// materializer. Global immutable catalogs fall through to this cache;
/// mutable world topology is installed one leaf per host step.
/// </summary>
internal PhysicsDataCache CreateCollisionStagingCopy()
internal PhysicsDataCache CreateEmptyCollisionStaging(
CollisionWorldStateSlot collisionWorld)
{
var copy = new PhysicsDataCache(_requirePreparedCollision)
return new PhysicsDataCache(_requirePreparedCollision, collisionWorld)
{
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 LandblockReplacementBuilder CreateLandblockReplacementBuilder(
@ -132,56 +149,6 @@ public sealed class PhysicsDataCache
gfxObjectIds,
setupIds);
internal void CommitLandblockReplacement(
PreparedPhysicsDataCacheLandblock replacement)
{
RemoveEntries(_cellStruct, replacement.CellIdsToRemove);
RemoveEntries(_flatCellStruct, replacement.FlatCellIdsToRemove);
RemoveEntries(_flatEnvCell, replacement.FlatEnvCellIdsToRemove);
RemoveEntries(_buildings, replacement.BuildingIdsToRemove);
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 void CommitEntries<T>(
ConcurrentDictionary<uint, T> destination,
IReadOnlyList<KeyValuePair<uint, T>> entries,
bool replace)
{
for (int index = 0; index < entries.Count; index++)
{
(uint id, T value) = entries[index];
if (replace)
destination[id] = value;
else
destination.TryAdd(id, value);
}
}
private static void RemoveEntries<T>(
ConcurrentDictionary<uint, T> destination,
IReadOnlyList<uint> ids)
{
for (int index = 0; index < ids.Count; index++)
destination.TryRemove(ids[index], out _);
}
/// <summary>
/// Extract and cache the physics BSP + polygon data from a GfxObj,
/// PLUS always cache a visual AABB from the vertex data regardless of
@ -1085,6 +1052,9 @@ public sealed class PhysicsDataCache
if (_cursor < _gfxIds.Length)
{
uint id = _gfxIds[_cursor++];
Preinstall(_staging._gfxObj, _active._gfxObj, id);
Preinstall(_staging._visualBounds, _active._visualBounds, id);
Preinstall(_staging._flatGfxObj, _active._flatGfxObj, id);
Capture(_staging._gfxObj, id, _gfx);
Capture(_staging._visualBounds, id, _bounds);
Capture(_staging._flatGfxObj, id, _flatGfx);
@ -1098,6 +1068,8 @@ public sealed class PhysicsDataCache
if (_cursor < _setupIds.Length)
{
uint id = _setupIds[_cursor++];
Preinstall(_staging._setup, _active._setup, id);
Preinstall(_staging._flatSetup, _active._flatSetup, id);
Capture(_staging._setup, id, _setups);
Capture(_staging._flatSetup, id, _flatSetups);
WorkUnits++;
@ -1229,6 +1201,15 @@ public sealed class PhysicsDataCache
destination.Add(new KeyValuePair<uint, T>(id, value));
}
private static void Preinstall<T>(
ConcurrentDictionary<uint, T> source,
ConcurrentDictionary<uint, T> destination,
uint id)
{
if (source.TryGetValue(id, out T? value))
destination.TryAdd(id, value);
}
private static bool CapturePrefixOne<T>(
IEnumerator<KeyValuePair<uint, T>> enumerator,
uint prefix,

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Items;
using AcDream.Core.World.Cells;
namespace AcDream.Core.Physics;
@ -28,7 +29,15 @@ internal readonly record struct TerrainWalkableSample(
/// </summary>
public sealed class PhysicsEngine
{
private readonly Dictionary<uint, LandblockPhysics> _landblocks = new();
private CollisionWorldStateSlot _collisionWorld;
private Dictionary<uint, LandblockPhysics> _landblocks =>
_collisionWorld.Current.Landblocks;
private List<uint> _landblockSlots =>
_collisionWorld.Current.LandblockSlots;
private Dictionary<uint, int> _landblockIndices =>
_collisionWorld.Current.LandblockIndices;
private Stack<int> _landblockFreeSlots =>
_collisionWorld.Current.LandblockFreeSlots;
private readonly TransitionScratchArena? _transitionScratch;
public PhysicsEngine()
@ -43,6 +52,8 @@ public sealed class PhysicsEngine
/// </summary>
internal PhysicsEngine(bool reuseTransitionScratch)
{
_collisionWorld = new CollisionWorldStateSlot();
ShadowObjects = new ShadowObjectRegistry(_collisionWorld);
_transitionScratch = reuseTransitionScratch
? new TransitionScratchArena()
: null;
@ -57,6 +68,8 @@ public sealed class PhysicsEngine
/// <summary>Number of registered landblocks (diagnostic).</summary>
public int LandblockCount => _landblocks.Count;
internal CollisionWorldStateSlot CollisionWorld => _collisionWorld;
/// <summary>
/// Optional high-volume collision trace sink. Production leaves this
/// unset; focused diagnostic gates may opt in explicitly.
@ -85,7 +98,7 @@ public sealed class PhysicsEngine
public bool IsLandblockTerrainResident(uint cellOrLandblockId)
{
uint prefix = cellOrLandblockId & 0xFFFF0000u;
foreach (var key in _landblocks.Keys)
foreach ((uint key, _) in _landblocks)
if ((key & 0xFFFF0000u) == prefix) return true;
return false;
}
@ -103,7 +116,8 @@ public sealed class PhysicsEngine
public bool IsNeighborhoodTerrainResident(uint cellOrLandblockId, int radius)
{
var resident = new HashSet<uint>();
foreach (var key in _landblocks.Keys) resident.Add(key & 0xFFFF0000u);
foreach ((uint key, _) in _landblocks)
resident.Add(key & 0xFFFF0000u);
int cx = (int)((cellOrLandblockId >> 24) & 0xFFu);
int cy = (int)((cellOrLandblockId >> 16) & 0xFFu);
@ -122,7 +136,7 @@ public sealed class PhysicsEngine
/// Cell-based spatial index for static object collision.
/// Populated during landblock streaming; queried by the Transition system.
/// </summary>
public ShadowObjectRegistry ShadowObjects { get; } = new();
public ShadowObjectRegistry ShadowObjects { get; }
/// <summary>
/// Physics BSP cache shared with the streaming loader. Set once by the
@ -135,10 +149,61 @@ public sealed class PhysicsEngine
public PhysicsDataCache? DataCache
{
get => _dataCache;
set { _dataCache = value; ShadowObjects.DataCache = value; }
set
{
if (value is not null
&& !ReferenceEquals(_collisionWorld, value.CollisionWorld))
{
if (ShadowObjects.TotalRegistered != 0)
{
throw new InvalidOperationException(
"A populated physics engine cannot change collision roots.");
}
// Legacy/test construction commonly installs terrain before
// attaching its first cache. Preserve that one-time ordering
// without permitting a live populated root swap: move only
// the engine-owned landblock index into the still-private
// cache root, then bind the stable facades.
if (_dataCache is null && _landblocks.Count != 0)
CopyDetachedLandblocksTo(value.CollisionWorld);
else if (_landblocks.Count != 0)
throw new InvalidOperationException(
"A populated physics engine cannot change collision roots.");
_collisionWorld = value.CollisionWorld;
ShadowObjects.AttachCollisionWorld(_collisionWorld);
}
_dataCache = value;
ShadowObjects.DataCache = value;
}
}
private PhysicsDataCache? _dataCache;
private void CopyDetachedLandblocksTo(
CollisionWorldStateSlot destinationSlot)
{
CollisionWorldState source = _collisionWorld.Current;
CollisionWorldState destination = destinationSlot.Current;
if (destination.Landblocks.Count != 0
|| destination.LandblockSlots.Count != 0
|| destination.LandblockIndices.Count != 0
|| destination.LandblockFreeSlots.Count != 0)
{
throw new InvalidOperationException(
"A cache collision root already owns engine landblocks.");
}
foreach ((uint landblockId, LandblockPhysics landblock) in
source.Landblocks)
{
destination.Landblocks.Add(landblockId, landblock);
}
destination.LandblockSlots.AddRange(source.LandblockSlots);
foreach ((uint landblockId, int slot) in source.LandblockIndices)
destination.LandblockIndices.Add(landblockId, slot);
int[] freeSlots = source.LandblockFreeSlots.ToArray();
for (int index = freeSlots.Length - 1; index >= 0; index--)
destination.LandblockFreeSlots.Push(freeSlots[index]);
}
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): optional live
/// weenie-object table, consulted ONLY by
@ -165,21 +230,16 @@ public sealed class PhysicsEngine
/// 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)
internal CollisionStagingBuilder CreateCollisionStagingBuilder(
uint targetLandblockId)
{
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;
PhysicsDataCache activeCache = DataCache
?? throw new InvalidOperationException(
"Active collision engine has no data cache.");
return new CollisionStagingBuilder(
this,
activeCache,
targetLandblockId & 0xFFFF0000u);
}
internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
@ -187,7 +247,7 @@ public sealed class PhysicsEngine
uint landblockId,
uint[] gfxObjectIds,
uint[] setupIds,
IReadOnlyDictionary<uint, ulong> expectedRetainedVersions)
IReadOnlyList<uint> expectedRetainedOwners)
{
ArgumentNullException.ThrowIfNull(staging);
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
@ -204,6 +264,7 @@ public sealed class PhysicsEngine
return new LandblockReplacementBuilder(
canonical,
landblock,
staging,
(DataCache ?? throw new InvalidOperationException(
"Active collision engine has no data cache."))
.CreateLandblockReplacementBuilder(
@ -214,17 +275,721 @@ public sealed class PhysicsEngine
ShadowObjects.CreateLandblockReplacementBuilder(
staging.ShadowObjects,
canonical,
expectedRetainedVersions));
expectedRetainedOwners));
}
internal void CommitLandblockReplacement(
PreparedPhysicsEngineLandblock replacement)
{
(DataCache ?? throw new InvalidOperationException(
"Active collision engine has no data cache."))
.CommitLandblockReplacement(replacement.DataCache);
_landblocks[replacement.LandblockId] = replacement.Landblock;
ShadowObjects.CommitLandblockReplacement(replacement.Shadows);
PhysicsDataCache activeCache = DataCache
?? throw new InvalidOperationException(
"Active collision engine has no data cache.");
PhysicsDataCache stagingCache = replacement.Staging.DataCache
?? throw new InvalidOperationException(
"Staging collision engine has no data cache.");
uint activeCurrentCellId = activeCache.CellGraph.CurrCell?.Id ?? 0u;
stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld);
if ((activeCurrentCellId & 0xFFFF0000u)
== (replacement.LandblockId & 0xFFFF0000u))
{
activeCache.CellGraph.CurrCell =
activeCache.CellGraph.GetVisible(activeCurrentCellId);
}
}
internal LandblockReplacementApplyCursor
CreateLandblockReplacementApplyCursor(
PreparedPhysicsEngineLandblock replacement) =>
new(this, replacement);
internal readonly record struct LandblockReplacementApplyStep(
bool Completed,
bool Worked,
bool HasOwner,
uint OwnerId);
internal LandblockRetirementCursor CreateLandblockRetirementCursor(
PhysicsEngine authoritative,
uint landblockId,
bool withdraw) => new(
this,
authoritative,
landblockId,
withdraw);
/// <summary>
/// Applies one demotion/withdrawal to an off-side root without a whole-
/// world synchronous scan. Every advance inspects or mutates at most one
/// stable owner slot, dictionary leaf, or authored outdoor cell.
/// </summary>
internal sealed class LandblockRetirementCursor : IDisposable
{
private readonly PhysicsEngine _destinationEngine;
private readonly PhysicsDataCache _destinationCache;
private readonly CollisionWorldState _destination;
private readonly CollisionWorldState _authoritative;
private readonly uint _canonical;
private readonly uint _prefix;
private readonly bool _withdraw;
private readonly List<uint> _ownerSlots;
private readonly int _ownerSlotLimit;
private readonly LandblockPhysics? _demotedLandblock;
private readonly CellGraphTerrain? _demotedTerrain;
private IEnumerator<KeyValuePair<uint, CellPhysics>>? _cells;
private IEnumerator<KeyValuePair<uint, FlatCellStructureCollisionAsset>>?
_flatCells;
private IEnumerator<KeyValuePair<uint, FlatEnvCellTopology>>? _flatEnvCells;
private IEnumerator<KeyValuePair<uint, BuildingPhysics>>? _buildings;
private IEnumerator<KeyValuePair<uint, EnvCell>>? _envCells;
private int _ownerIndex;
private int _outdoorIndex;
private int _phase;
internal LandblockRetirementCursor(
PhysicsEngine destination,
PhysicsEngine authoritative,
uint landblockId,
bool withdraw)
{
_destinationEngine = destination;
_destinationCache = destination.DataCache
?? throw new InvalidOperationException(
"Collision engine has no data cache.");
_destination = destination._collisionWorld.Capture();
_authoritative = authoritative._collisionWorld.Capture();
_canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
_prefix = landblockId & 0xFFFF0000u;
_withdraw = withdraw;
_ownerSlots = _destination.ShadowOwnerSlots;
_ownerSlotLimit = _ownerSlots.Count;
if (!withdraw)
{
_authoritative.Landblocks.TryGetValue(
_canonical,
out _demotedLandblock);
_authoritative.Terrain.TryGetValue(
_prefix,
out _demotedTerrain);
}
}
internal uint LandblockId => _canonical;
internal LandblockRetirementStep Advance()
{
while (true)
{
switch (_phase)
{
case 0:
if (_ownerIndex < _ownerSlotLimit)
{
uint ownerId = _ownerSlots[_ownerIndex++];
if (ownerId != 0u)
{
_destinationEngine.ShadowObjects
.RetireOwnerFromLandblock(
ownerId,
_canonical);
}
return Worked();
}
_phase++;
continue;
case 1:
_cells ??= _destination.CellStruct.GetEnumerator();
if (RemoveOneInPrefix(_cells, _destination.CellStruct))
return Worked();
DisposeEnumerator(ref _cells);
_phase++;
continue;
case 2:
_flatCells ??= _destination.FlatCellStruct.GetEnumerator();
if (RemoveOneInPrefix(
_flatCells,
_destination.FlatCellStruct))
return Worked();
DisposeEnumerator(ref _flatCells);
_phase++;
continue;
case 3:
_flatEnvCells ??= _destination.FlatEnvCell.GetEnumerator();
if (RemoveOneInPrefix(
_flatEnvCells,
_destination.FlatEnvCell))
return Worked();
DisposeEnumerator(ref _flatEnvCells);
_phase++;
continue;
case 4:
_buildings ??= _destination.Buildings.GetEnumerator();
if (RemoveOneInPrefix(
_buildings,
_destination.Buildings))
return Worked();
DisposeEnumerator(ref _buildings);
_phase++;
continue;
case 5:
_envCells ??= _destination.EnvCells.GetEnumerator();
if (RemoveOneInPrefix(_envCells, _destination.EnvCells))
return Worked();
DisposeEnumerator(ref _envCells);
_phase++;
continue;
case 6:
if (_outdoorIndex < 0x40)
{
uint id = _prefix | (uint)++_outdoorIndex;
_destination.ShadowCells.Remove(id);
if (_withdraw)
{
_destination.OutdoorCells.TryRemove(id, out _);
}
else if (_authoritative.OutdoorCells.TryGetValue(
id,
out ObjCell? outdoor))
{
_destination.OutdoorCells[id] = outdoor;
}
return Worked();
}
_phase++;
continue;
case 7:
if (_withdraw)
{
_destinationEngine._landblocks.Remove(_canonical);
_destinationEngine.RemoveLandblockSlot(_canonical);
_destination.Terrain.TryRemove(_prefix, out _);
}
else
{
if (_demotedLandblock is not null)
{
_destinationEngine._landblocks[_canonical] =
_demotedLandblock;
_destinationEngine.EnsureLandblockSlot(_canonical);
}
if (_demotedTerrain is not null)
_destination.Terrain[_prefix] = _demotedTerrain;
}
_phase++;
return Worked();
case 8:
uint currentCellId =
_destinationCache.CellGraph.CurrCell?.Id ?? 0u;
if ((currentCellId & 0xFFFF0000u) == _prefix
&& (_withdraw
|| (currentCellId & 0xFFFFu) >= 0x0100u))
{
_destinationCache.CellGraph.CurrCell = null;
}
_phase++;
return new LandblockRetirementStep(
Completed: true,
Worked: false);
default:
return new LandblockRetirementStep(
Completed: true,
Worked: false);
}
}
}
private LandblockRetirementStep Worked() => new(
Completed: false,
Worked: true);
private bool RemoveOneInPrefix<T>(
IEnumerator<KeyValuePair<uint, T>> source,
IDictionary<uint, T> destination)
{
if (!source.MoveNext())
return false;
uint id = source.Current.Key;
if ((id & 0xFFFF0000u) == _prefix)
{
destination.Remove(id);
_destination.ShadowCells.Remove(id);
}
return true;
}
private static void DisposeEnumerator<T>(
ref IEnumerator<KeyValuePair<uint, T>>? enumerator)
{
enumerator?.Dispose();
enumerator = null;
}
public void Dispose()
{
DisposeEnumerator(ref _cells);
DisposeEnumerator(ref _flatCells);
DisposeEnumerator(ref _flatEnvCells);
DisposeEnumerator(ref _buildings);
DisposeEnumerator(ref _envCells);
}
}
internal readonly record struct LandblockRetirementStep(
bool Completed,
bool Worked);
/// <summary>
/// Applies one already-committed landblock delta to a later off-side root.
/// Each advance mutates at most one dictionary leaf, one synthesized
/// outdoor cell, or one logical shadow owner.
/// </summary>
internal sealed class LandblockReplacementApplyCursor : IDisposable
{
private readonly PhysicsEngine _destinationEngine;
private readonly PhysicsDataCache _destinationCache;
private readonly CollisionWorldState _destination;
private readonly PreparedPhysicsEngineLandblock _replacement;
private int _phase;
private int _index;
internal LandblockReplacementApplyCursor(
PhysicsEngine destination,
PreparedPhysicsEngineLandblock replacement)
{
_destinationEngine = destination;
_destinationCache = destination.DataCache
?? throw new InvalidOperationException(
"Collision engine has no data cache.");
_destination = _destinationCache.CollisionWorld.Capture();
_replacement = replacement;
}
internal uint LandblockId => _replacement.LandblockId;
internal LandblockReplacementApplyStep Advance()
{
PreparedPhysicsDataCacheLandblock data = _replacement.DataCache;
PreparedCellGraphLandblock graph = data.CellGraph;
while (true)
{
switch (_phase)
{
case 0:
if (RemoveOne(_destination.CellStruct, data.CellIdsToRemove))
return Worked();
NextPhase();
continue;
case 1:
if (InstallOne(_destination.CellStruct, data.Cells))
return Worked();
NextPhase();
continue;
case 2:
if (RemoveOne(_destination.FlatCellStruct, data.FlatCellIdsToRemove))
return Worked();
NextPhase();
continue;
case 3:
if (InstallOne(_destination.FlatCellStruct, data.FlatCells))
return Worked();
NextPhase();
continue;
case 4:
if (RemoveOne(_destination.FlatEnvCell, data.FlatEnvCellIdsToRemove))
return Worked();
NextPhase();
continue;
case 5:
if (InstallOne(_destination.FlatEnvCell, data.FlatEnvCells))
return Worked();
NextPhase();
continue;
case 6:
if (RemoveOne(_destination.Buildings, data.BuildingIdsToRemove))
return Worked();
NextPhase();
continue;
case 7:
if (InstallOne(_destination.Buildings, data.Buildings))
return Worked();
NextPhase();
continue;
case 8:
if (RemoveOne(_destination.EnvCells, graph.EnvCellIdsToRemove))
return Worked();
NextPhase();
continue;
case 9:
if (graph.HasTerrain)
{
if (_index == 0)
{
_destination.Terrain[graph.LandblockPrefix] =
graph.Terrain!;
_index++;
return Worked();
}
if (_index <= 0x40)
{
uint low = (uint)_index++;
CellGraphTerrain terrain = graph.Terrain!;
int cellIndex = (int)(low - 1u);
uint id = graph.LandblockPrefix | low;
_destination.OutdoorCells[id] =
LandCell.Synthesize(
id,
terrain.Terrain,
terrain.Origin,
cellIndex / 8,
cellIndex % 8);
return Worked();
}
}
else
{
if (_index == 0)
{
_destination.Terrain.TryRemove(
graph.LandblockPrefix,
out _);
_index++;
return Worked();
}
if (_index <= 0x40)
{
uint low = (uint)_index++;
_destination.OutdoorCells.TryRemove(
graph.LandblockPrefix | low,
out _);
return Worked();
}
}
NextPhase();
continue;
case 10:
if (InstallOne(_destination.EnvCells, graph.EnvCells))
return Worked();
NextPhase();
continue;
case 11:
_destinationEngine.InstallLandblockClone(
_replacement.LandblockId,
_replacement.Landblock);
NextPhase();
return Worked();
case 12:
if (_index < _replacement.Shadows.OwnerIds.Count)
{
uint ownerId =
_replacement.Shadows.OwnerIds[_index++];
return new LandblockReplacementApplyStep(
Completed: false,
Worked: true,
HasOwner: true,
ownerId);
}
NextPhase();
continue;
case 13:
uint currentCellId =
_destinationCache.CellGraph.CurrCell?.Id ?? 0u;
if ((currentCellId & 0xFFFF0000u)
== graph.LandblockPrefix)
{
_destinationCache.CellGraph.CurrCell =
_destinationCache.CellGraph.GetVisible(
currentCellId);
}
_phase++;
return new LandblockReplacementApplyStep(
Completed: true,
Worked: false,
HasOwner: false,
OwnerId: 0u);
default:
return new LandblockReplacementApplyStep(
Completed: true,
Worked: false,
HasOwner: false,
OwnerId: 0u);
}
}
}
private LandblockReplacementApplyStep Worked() => new(
Completed: false,
Worked: true,
HasOwner: false,
OwnerId: 0u);
private void NextPhase()
{
_phase++;
_index = 0;
}
private bool RemoveOne<T>(
IDictionary<uint, T> destination,
IReadOnlyList<uint> ids)
{
if (_index >= ids.Count)
return false;
destination.Remove(ids[_index++]);
return true;
}
private bool InstallOne<T>(
IDictionary<uint, T> destination,
IReadOnlyList<KeyValuePair<uint, T>> entries)
{
if (_index >= entries.Count)
return false;
KeyValuePair<uint, T> pair = entries[_index++];
destination[pair.Key] = pair.Value;
return true;
}
public void Dispose()
{
}
}
/// <summary>
/// Retained one-leaf-at-a-time materializer for an off-side collision
/// generation. Construction captures the current root reference only; no
/// resident dictionary is copied until <see cref="Advance"/>. Runtime's
/// owner journal reconciles mutations that occur while this cursor walks.
/// </summary>
internal sealed class CollisionStagingBuilder : IDisposable
{
private readonly PhysicsEngine _active;
private readonly CollisionWorldState _source;
private readonly CollisionWorldState _destination;
private readonly ShadowObjectRegistry _sourceShadows;
private readonly uint _targetPrefix;
private readonly HashSet<uint> _suppressedPrefixes = new();
private readonly int _landblockSlotLimit;
private readonly int _ownerSlotLimit;
private IEnumerator<KeyValuePair<uint, CellPhysics>>? _cells;
private IEnumerator<KeyValuePair<uint, FlatCellStructureCollisionAsset>>? _flatCells;
private IEnumerator<KeyValuePair<uint, FlatEnvCellTopology>>? _flatEnvCells;
private IEnumerator<KeyValuePair<uint, BuildingPhysics>>? _buildings;
private IEnumerator<KeyValuePair<uint, EnvCell>>? _envCells;
private IEnumerator<KeyValuePair<uint, CellGraphTerrain>>? _terrain;
private IEnumerator<KeyValuePair<uint, ObjCell>>? _outdoorCells;
private int _landblockIndex;
private int _ownerIndex;
private int _phase;
internal CollisionStagingBuilder(
PhysicsEngine active,
PhysicsDataCache activeCache,
uint targetPrefix)
{
_active = active;
_targetPrefix = targetPrefix;
_source = active._collisionWorld.Capture();
var stagingSlot = new CollisionWorldStateSlot();
StagingCache = activeCache.CreateEmptyCollisionStaging(stagingSlot);
StagingEngine = new PhysicsEngine
{
DataCache = StagingCache,
Objects = active.Objects,
};
_destination = stagingSlot.Capture();
_sourceShadows = new ShadowObjectRegistry(
new CollisionWorldStateSlot(_source));
_landblockSlotLimit = _source.LandblockSlots.Count;
_ownerSlotLimit = _source.ShadowOwnerSlots.Count;
}
internal PhysicsDataCache StagingCache { get; }
internal PhysicsEngine StagingEngine { get; }
internal int WorkUnits { get; private set; }
internal bool Completed => _phase == 9;
/// <summary>
/// Prevent a landblock retired after this cursor captured its source
/// root from being copied back into the draft by a later phase.
/// Already-copied leaves are retired by the caller before cloning
/// resumes; this tombstone covers every leaf not visited yet.
/// </summary>
internal void SuppressLandblock(uint landblockId) =>
_suppressedPrefixes.Add(landblockId & 0xFFFF0000u);
internal bool Advance()
{
switch (_phase)
{
case 0:
if (_landblockIndex < _landblockSlotLimit)
{
uint id = _source.LandblockSlots[_landblockIndex++];
if (id != 0u
&& (id & 0xFFFF0000u) != _targetPrefix
&& !_suppressedPrefixes.Contains(
id & 0xFFFF0000u)
&& _source.Landblocks.TryGetValue(
id,
out LandblockPhysics? landblock))
{
StagingEngine.InstallLandblockClone(id, landblock);
}
WorkUnits++;
return false;
}
_phase++;
return false;
case 1:
_cells ??= _source.CellStruct.GetEnumerator();
if (CopyOneOutsideTarget(_cells, _destination.CellStruct))
return CountOne();
DisposeEnumerator(ref _cells);
_phase++;
return false;
case 2:
_flatCells ??= _source.FlatCellStruct.GetEnumerator();
if (CopyOneOutsideTarget(_flatCells, _destination.FlatCellStruct))
return CountOne();
DisposeEnumerator(ref _flatCells);
_phase++;
return false;
case 3:
_flatEnvCells ??= _source.FlatEnvCell.GetEnumerator();
if (CopyOneOutsideTarget(_flatEnvCells, _destination.FlatEnvCell))
return CountOne();
DisposeEnumerator(ref _flatEnvCells);
_phase++;
return false;
case 4:
_buildings ??= _source.Buildings.GetEnumerator();
if (CopyOneOutsideTarget(_buildings, _destination.Buildings))
return CountOne();
DisposeEnumerator(ref _buildings);
_phase++;
return false;
case 5:
_envCells ??= _source.EnvCells.GetEnumerator();
if (CopyOneOutsideTarget(_envCells, _destination.EnvCells))
return CountOne();
DisposeEnumerator(ref _envCells);
_phase++;
return false;
case 6:
_terrain ??= _source.Terrain.GetEnumerator();
if (CopyOneOutsideTarget(_terrain, _destination.Terrain))
return CountOne();
DisposeEnumerator(ref _terrain);
_phase++;
return false;
case 7:
_outdoorCells ??= _source.OutdoorCells.GetEnumerator();
if (CopyOneOutsideTarget(_outdoorCells, _destination.OutdoorCells))
return CountOne();
DisposeEnumerator(ref _outdoorCells);
_phase++;
return false;
case 8:
if (_ownerIndex < _ownerSlotLimit)
{
uint ownerId = _source.ShadowOwnerSlots[_ownerIndex++];
if (ownerId != 0u
&& !_sourceShadows.IsStaticOwnerRootedIn(
ownerId,
_targetPrefix)
&& !IsSuppressedStaticOwner(ownerId)
&& !StagingEngine.ShadowObjects.HasLogicalOwner(
ownerId))
{
StagingEngine.ShadowObjects.MirrorOwnerFrom(
_sourceShadows,
ownerId);
}
WorkUnits++;
return false;
}
uint currentCellId = _active.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
StagingCache.CellGraph.CurrCell =
StagingCache.CellGraph.GetVisible(currentCellId);
_phase++;
return true;
default:
return true;
}
}
private bool CountOne()
{
WorkUnits++;
return false;
}
private bool CopyOneOutsideTarget<T>(
IEnumerator<KeyValuePair<uint, T>> source,
IDictionary<uint, T> destination)
{
if (!source.MoveNext())
return false;
KeyValuePair<uint, T> pair = source.Current;
uint prefix = pair.Key & 0xFFFF0000u;
if (prefix != _targetPrefix
&& !_suppressedPrefixes.Contains(prefix))
destination[pair.Key] = pair.Value;
return true;
}
private bool IsSuppressedStaticOwner(uint ownerId)
=> _sourceShadows.TryGetStaticOwnerRootPrefix(
ownerId,
out uint prefix)
&& _suppressedPrefixes.Contains(prefix);
private static void DisposeEnumerator<T>(
ref IEnumerator<KeyValuePair<uint, T>>? enumerator)
{
enumerator?.Dispose();
enumerator = null;
}
public void Dispose()
{
DisposeEnumerator(ref _cells);
DisposeEnumerator(ref _flatCells);
DisposeEnumerator(ref _flatEnvCells);
DisposeEnumerator(ref _buildings);
DisposeEnumerator(ref _envCells);
DisposeEnumerator(ref _terrain);
DisposeEnumerator(ref _outdoorCells);
}
}
private void InstallLandblockClone(
uint landblockId,
LandblockPhysics landblock)
{
_landblocks[landblockId] = landblock;
EnsureLandblockSlot(landblockId);
}
private void EnsureLandblockSlot(uint landblockId)
{
if (_landblockIndices.ContainsKey(landblockId))
return;
if (_landblockFreeSlots.TryPop(out int freeIndex))
{
_landblockSlots[freeIndex] = landblockId;
_landblockIndices[landblockId] = freeIndex;
return;
}
_landblockIndices[landblockId] = _landblockSlots.Count;
_landblockSlots.Add(landblockId);
}
private void RemoveLandblockSlot(uint landblockId)
{
if (!_landblockIndices.Remove(landblockId, out int slotIndex))
return;
_landblockSlots[slotIndex] = 0u;
_landblockFreeSlots.Push(slotIndex);
}
internal sealed class PreparedPhysicsEngineLandblock
@ -232,17 +997,20 @@ public sealed class PhysicsEngine
internal PreparedPhysicsEngineLandblock(
uint landblockId,
LandblockPhysics landblock,
PhysicsEngine staging,
PreparedPhysicsDataCacheLandblock dataCache,
ShadowObjectRegistry.PreparedLandblockShadowReplacement shadows)
{
LandblockId = landblockId;
Landblock = landblock;
Staging = staging;
DataCache = dataCache;
Shadows = shadows;
}
internal uint LandblockId { get; }
internal LandblockPhysics Landblock { get; }
internal PhysicsEngine Staging { get; }
internal PreparedPhysicsDataCacheLandblock DataCache { get; }
internal ShadowObjectRegistry.PreparedLandblockShadowReplacement Shadows { get; }
}
@ -251,6 +1019,7 @@ public sealed class PhysicsEngine
{
private readonly uint _landblockId;
private readonly LandblockPhysics _landblock;
private readonly PhysicsEngine _staging;
private readonly PhysicsDataCache.LandblockReplacementBuilder _data;
private readonly ShadowObjectRegistry.LandblockReplacementBuilder _shadows;
private int _phase;
@ -258,19 +1027,23 @@ public sealed class PhysicsEngine
internal LandblockReplacementBuilder(
uint landblockId,
LandblockPhysics landblock,
PhysicsEngine staging,
PhysicsDataCache.LandblockReplacementBuilder data,
ShadowObjectRegistry.LandblockReplacementBuilder shadows)
{
_landblockId = landblockId;
_landblock = landblock;
_staging = staging;
_data = data;
_shadows = shadows;
}
internal int WorkUnits => _data.WorkUnits + _shadows.WorkUnits;
internal bool IsStable => _shadows.IsStable;
internal PreparedPhysicsEngineLandblock? Prepared { get; private set; }
internal void RefreshRetainedOwner(uint ownerId) =>
_shadows.RefreshOwner(ownerId);
internal bool Advance()
{
if (_phase == 0)
@ -284,12 +1057,13 @@ public sealed class PhysicsEngine
{
if (!_shadows.Advance())
return false;
if (IsStable && _data.Prepared is not null
if (_data.Prepared is not null
&& _shadows.Prepared is not null)
{
Prepared = new PreparedPhysicsEngineLandblock(
_landblockId,
_landblock,
Prepared = new PreparedPhysicsEngineLandblock(
_landblockId,
_landblock,
_staging,
_data.Prepared,
_shadows.Prepared);
}
@ -314,6 +1088,7 @@ public sealed class PhysicsEngine
float worldOffsetX, float worldOffsetY)
{
_landblocks[landblockId] = new LandblockPhysics(terrain, cells, portals, worldOffsetX, worldOffsetY);
EnsureLandblockSlot(landblockId);
// UCG Stage 1: mirror terrain into the unified graph (inert this stage).
DataCache?.CellGraph.RegisterTerrain(landblockId, terrain, new Vector3(worldOffsetX, worldOffsetY, 0f));
@ -325,6 +1100,7 @@ public sealed class PhysicsEngine
public void RemoveLandblock(uint landblockId)
{
_landblocks.Remove(landblockId);
RemoveLandblockSlot(landblockId);
ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId);
ShadowObjects.RemoveLandblock(landblockId);
DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply

View file

@ -26,20 +26,26 @@ namespace AcDream.Core.Physics;
/// </summary>
public sealed class ShadowObjectRegistry
{
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
private readonly HashSet<uint> _suspendedEntities = new();
private CollisionWorldStateSlot _collisionWorld;
private Dictionary<uint, List<ShadowEntry>> _cells =>
_collisionWorld.Current.ShadowCells;
private Dictionary<uint, List<uint>> _entityToCells =>
_collisionWorld.Current.ShadowEntityCells; // for deregistration
private HashSet<uint> _suspendedEntities =>
_collisionWorld.Current.SuspendedShadowEntities;
// Rows withdrawn because a touched landblock streamed out. The owner may
// be seeded in an adjacent still-resident landblock, so its remaining rows
// cannot by themselves tell RefloodLandblock that this prefix needs repair.
private readonly Dictionary<uint, HashSet<uint>> _withdrawnPrefixesByOwner = new();
private Dictionary<uint, HashSet<uint>> _withdrawnPrefixesByOwner =>
_collisionWorld.Current.WithdrawnPrefixesByOwner;
/// <summary>
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
/// <see cref="UpdatePosition"/> to recompose part world-transforms when
/// the entity moves. Cleared by <see cref="Deregister"/>.
/// </summary>
private readonly Dictionary<uint, System.Collections.Generic.IReadOnlyList<ShadowShape>> _entityShapes = new();
private Dictionary<uint, System.Collections.Generic.IReadOnlyList<ShadowShape>> _entityShapes =>
_collisionWorld.Current.ShadowEntityShapes;
/// <summary>
/// BR-7: per-entity registration arguments, kept so a registration can be
@ -50,9 +56,50 @@ public sealed class ShadowObjectRegistry
/// gets its cell set recomputed afterwards. <see cref="RefloodLandblock"/>
/// is the streaming-side trigger.
/// </summary>
private readonly Dictionary<uint, RegistrationRecord> _entityReg = new();
private readonly Dictionary<uint, ulong> _ownerVersions = new();
private ulong _mutationVersion;
private Dictionary<uint, RegistrationRecord> _entityReg =>
_collisionWorld.Current.ShadowEntityRegistrations;
private Dictionary<uint, ulong> _ownerVersions =>
_collisionWorld.Current.ShadowOwnerVersions;
private Dictionary<uint, HashSet<uint>> _ownerPrefixes =>
_collisionWorld.Current.ShadowOwnerPrefixes;
private Dictionary<uint, List<uint>> _prefixOwnerSlots =>
_collisionWorld.Current.ShadowPrefixOwnerSlots;
private Dictionary<uint, Dictionary<uint, int>> _prefixOwnerIndices =>
_collisionWorld.Current.ShadowPrefixOwnerIndices;
private Dictionary<uint, Stack<int>> _prefixFreeSlots =>
_collisionWorld.Current.ShadowPrefixFreeSlots;
private List<uint> _ownerSlots =>
_collisionWorld.Current.ShadowOwnerSlots;
private Dictionary<uint, int> _ownerIndices =>
_collisionWorld.Current.ShadowOwnerIndices;
private Stack<int> _ownerFreeSlots =>
_collisionWorld.Current.ShadowOwnerFreeSlots;
private readonly HashSet<uint> _prefixScratch = new();
private readonly List<uint> _removedPrefixScratch = new();
internal event Action<uint, ulong>? OwnerMutated;
internal event Action<uint, uint>? OwnerPrefixMembershipChanged;
public ShadowObjectRegistry()
: this(new CollisionWorldStateSlot())
{
}
internal ShadowObjectRegistry(CollisionWorldStateSlot collisionWorld)
{
_collisionWorld = collisionWorld
?? throw new ArgumentNullException(nameof(collisionWorld));
}
internal void AttachCollisionWorld(CollisionWorldStateSlot collisionWorld)
{
ArgumentNullException.ThrowIfNull(collisionWorld);
if (_cells.Count != 0 || _entityReg.Count != 0)
{
throw new InvalidOperationException(
"A populated shadow registry cannot change collision roots.");
}
_collisionWorld = collisionWorld;
}
internal sealed record RegistrationRecord(
uint SeedCellId,
@ -76,31 +123,38 @@ public sealed class ShadowObjectRegistry
private void BumpOwnerVersion(uint entityId)
{
_ownerVersions[entityId] = checked(GetOwnerVersion(entityId) + 1UL);
_mutationVersion = checked(_mutationVersion + 1UL);
ulong version = checked(GetOwnerVersion(entityId) + 1UL);
_ownerVersions[entityId] = version;
RefreshOwnerPrefixIndex(entityId);
OwnerMutated?.Invoke(entityId, version);
}
internal ulong MutationVersion => _mutationVersion;
internal RetainedRefloodOwnerScan CreateRetainedRefloodOwnerScan(
uint landblockId) => new(this, landblockId & 0xFFFF0000u);
uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
_prefixOwnerSlots.TryGetValue(prefix, out List<uint>? slots);
return new RetainedRefloodOwnerScan(this, prefix, slots);
}
internal sealed class RetainedRefloodOwnerScan : IDisposable
{
private readonly ShadowObjectRegistry _owner;
private readonly uint _prefix;
private readonly ulong _sourceMutationVersion;
private Dictionary<uint, RegistrationRecord>.Enumerator _enumerator;
private readonly List<uint>? _slots;
private readonly int _limit;
private int _index;
private bool _completed;
internal RetainedRefloodOwnerScan(
ShadowObjectRegistry owner,
uint prefix)
uint prefix,
List<uint>? slots)
{
_owner = owner;
_prefix = prefix;
_sourceMutationVersion = owner.MutationVersion;
_enumerator = owner._entityReg.GetEnumerator();
_slots = slots;
_limit = slots?.Count ?? 0;
}
internal RetainedRefloodOwnerScanStep Advance()
@ -109,55 +163,169 @@ public sealed class ShadowObjectRegistry
{
return new RetainedRefloodOwnerScanStep(
Completed: true,
Stable: _owner.MutationVersion == _sourceMutationVersion,
HasOwner: false,
OwnerId: 0u,
SourceMutationVersion: _sourceMutationVersion);
OwnerId: 0u);
}
if (_owner.MutationVersion != _sourceMutationVersion)
if (_slots is null || _index >= _limit)
{
_completed = true;
return new RetainedRefloodOwnerScanStep(
Completed: true,
Stable: false,
HasOwner: false,
OwnerId: 0u,
SourceMutationVersion: _sourceMutationVersion);
}
if (!_enumerator.MoveNext())
{
_completed = true;
return new RetainedRefloodOwnerScanStep(
Completed: true,
Stable: true,
HasOwner: false,
OwnerId: 0u,
SourceMutationVersion: _sourceMutationVersion);
OwnerId: 0u);
}
(uint ownerId, RegistrationRecord registration) =
_enumerator.Current;
bool retained = !_owner._suspendedEntities.Contains(ownerId)
&& (!registration.IsStatic
|| (registration.SeedCellId & 0xFFFF0000u) != _prefix)
&& _owner.OwnerTouchesLandblock(ownerId, _prefix);
uint ownerId = _slots[_index++];
bool retained = _owner.IsRetainedRefloodOwner(ownerId, _prefix);
return new RetainedRefloodOwnerScanStep(
Completed: false,
Stable: true,
HasOwner: retained,
OwnerId: retained ? ownerId : 0u,
SourceMutationVersion: _sourceMutationVersion);
OwnerId: retained ? ownerId : 0u);
}
public void Dispose() => _enumerator.Dispose();
public void Dispose() { }
}
private void RefreshOwnerPrefixIndex(uint entityId)
{
if (!_entityReg.ContainsKey(entityId))
{
RemoveOwnerPrefixMembership(entityId);
return;
}
EnsureOwnerSlot(entityId);
_prefixScratch.Clear();
if (_entityReg.TryGetValue(entityId, out RegistrationRecord? registration))
_prefixScratch.Add(registration.SeedCellId & 0xFFFF0000u);
if (_entityToCells.TryGetValue(entityId, out List<uint>? cells))
{
for (int index = 0; index < cells.Count; index++)
_prefixScratch.Add(cells[index] & 0xFFFF0000u);
}
if (_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn))
{
foreach (uint prefix in withdrawn)
_prefixScratch.Add(prefix & 0xFFFF0000u);
}
if (!_ownerPrefixes.TryGetValue(entityId, out HashSet<uint>? current))
{
current = new HashSet<uint>();
_ownerPrefixes[entityId] = current;
}
_removedPrefixScratch.Clear();
foreach (uint prefix in current)
{
if (!_prefixScratch.Contains(prefix))
_removedPrefixScratch.Add(prefix);
}
for (int index = 0; index < _removedPrefixScratch.Count; index++)
{
uint prefix = _removedPrefixScratch[index];
current.Remove(prefix);
if (_prefixOwnerIndices.TryGetValue(
prefix,
out Dictionary<uint, int>? indices)
&& indices.Remove(entityId, out int slotIndex))
{
_prefixOwnerSlots[prefix][slotIndex] = 0u;
_prefixFreeSlots[prefix].Push(slotIndex);
ReleaseEmptyPrefixContainer(prefix, indices);
}
OwnerPrefixMembershipChanged?.Invoke(entityId, prefix);
}
foreach (uint prefix in _prefixScratch)
{
if (!current.Add(prefix))
continue;
if (!_prefixOwnerSlots.TryGetValue(prefix, out List<uint>? slots))
{
slots = new List<uint>();
_prefixOwnerSlots[prefix] = slots;
_prefixOwnerIndices[prefix] = new Dictionary<uint, int>();
_prefixFreeSlots[prefix] = new Stack<int>();
}
Dictionary<uint, int> indices = _prefixOwnerIndices[prefix];
if (indices.ContainsKey(entityId))
continue;
Stack<int> free = _prefixFreeSlots[prefix];
if (free.TryPop(out int freeIndex))
{
slots[freeIndex] = entityId;
indices[entityId] = freeIndex;
}
else
{
indices[entityId] = slots.Count;
slots.Add(entityId);
}
OwnerPrefixMembershipChanged?.Invoke(entityId, prefix);
}
}
private void RemoveOwnerPrefixMembership(uint entityId)
{
if (_ownerPrefixes.Remove(entityId, out HashSet<uint>? prefixes))
{
foreach (uint prefix in prefixes)
{
if (!_prefixOwnerIndices.TryGetValue(
prefix,
out Dictionary<uint, int>? indices)
|| !indices.Remove(entityId, out int slotIndex))
{
continue;
}
_prefixOwnerSlots[prefix][slotIndex] = 0u;
_prefixFreeSlots[prefix].Push(slotIndex);
ReleaseEmptyPrefixContainer(prefix, indices);
OwnerPrefixMembershipChanged?.Invoke(entityId, prefix);
}
}
if (_ownerIndices.Remove(entityId, out int ownerSlot))
{
_ownerSlots[ownerSlot] = 0u;
_ownerFreeSlots.Push(ownerSlot);
}
}
private void EnsureOwnerSlot(uint entityId)
{
if (_ownerIndices.ContainsKey(entityId))
return;
if (_ownerFreeSlots.TryPop(out int freeIndex))
{
_ownerSlots[freeIndex] = entityId;
_ownerIndices[entityId] = freeIndex;
return;
}
_ownerIndices[entityId] = _ownerSlots.Count;
_ownerSlots.Add(entityId);
}
private void ReleaseEmptyPrefixContainer(
uint prefix,
Dictionary<uint, int> indices)
{
if (indices.Count != 0)
return;
// An in-flight scan retains its captured List reference and observes
// only tombstones. A future owner gets a fresh compact container.
_prefixOwnerSlots.Remove(prefix);
_prefixOwnerIndices.Remove(prefix);
_prefixFreeSlots.Remove(prefix);
}
internal readonly record struct RetainedRefloodOwnerScanStep(
bool Completed,
bool Stable,
bool HasOwner,
uint OwnerId,
ulong SourceMutationVersion);
uint OwnerId);
/// <summary>
/// The flood's data source (cells, buildings, terrain origins). Wired by
@ -193,7 +361,8 @@ public sealed class ShadowObjectRegistry
uint state = 0u,
EntityCollisionFlags flags = EntityCollisionFlags.None,
uint seedCellId = 0u,
bool isStatic = true)
bool isStatic = true,
bool publishMutation = true)
{
// Flood FIRST: retail keeps the previous shadows when the new cell
// array would be empty (SetPositionInternal num_cells gate,
@ -211,7 +380,7 @@ public sealed class ShadowObjectRegistry
FloodCache, seed, spheres, spheres.Length, isStatic);
if (cellSet.Count == 0) return;
Deregister(entityId);
DeregisterCore(entityId, publishMutation: false);
var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius,
collisionType, cylHeight, scale, state, flags);
@ -227,7 +396,10 @@ public sealed class ShadowObjectRegistry
_entityReg[entityId] = new RegistrationRecord(
seed, worldPos, rotation, state, flags, isStatic,
IsMultiPart: false, gfxObjId, radius, collisionType, cylHeight, scale);
BumpOwnerVersion(entityId);
if (publishMutation)
BumpOwnerVersion(entityId);
else
RefreshOwnerPrefixIndex(entityId);
}
/// <summary>
@ -256,7 +428,8 @@ public sealed class ShadowObjectRegistry
EntityCollisionFlags flags,
float worldOffsetX, float worldOffsetY, uint landblockId,
uint seedCellId = 0u,
bool isStatic = false)
bool isStatic = false,
bool publishMutation = true)
{
if (shapes.Count == 0) { Deregister(entityId); return; }
@ -271,7 +444,7 @@ public sealed class ShadowObjectRegistry
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
if (cellSet.Count == 0) return;
Deregister(entityId);
DeregisterCore(entityId, publishMutation: false);
_entityShapes[entityId] = shapes;
var allCells = new List<uint>(cellSet.Count);
@ -307,7 +480,10 @@ 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);
if (publishMutation)
BumpOwnerVersion(entityId);
else
RefreshOwnerPrefixIndex(entityId);
}
/// <summary>
@ -635,7 +811,8 @@ public sealed class ShadowObjectRegistry
0f,
lbPrefix,
reg.SeedCellId,
reg.IsStatic);
reg.IsStatic,
publishMutation: false);
}
else
{
@ -654,7 +831,8 @@ public sealed class ShadowObjectRegistry
reg.State,
reg.Flags,
reg.SeedCellId,
reg.IsStatic);
reg.IsStatic,
publishMutation: false);
}
// Register is also the authoritative movement/replacement API and
@ -674,6 +852,7 @@ public sealed class ShadowObjectRegistry
if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId);
}
BumpOwnerVersion(entityId);
}
/// <summary>
@ -711,12 +890,16 @@ public sealed class ShadowObjectRegistry
if (retained)
{
_entityReg[entityId] = retainedRegistration! with { State = newState };
BumpOwnerVersion(entityId);
}
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
{
if (retained)
BumpOwnerVersion(entityId);
return; // not registered — no-op
}
foreach (var cellId in cellIds)
{
if (!_cells.TryGetValue(cellId, out var list)) continue;
@ -727,10 +910,16 @@ public sealed class ShadowObjectRegistry
}
}
if (retained)
BumpOwnerVersion(entityId);
}
/// <summary>Remove an entity from all cells it was registered in.</summary>
public void Deregister(uint entityId)
=> DeregisterCore(entityId, publishMutation: true);
private void DeregisterCore(uint entityId, bool publishMutation)
{
bool existed = _entityReg.ContainsKey(entityId)
|| _entityToCells.ContainsKey(entityId)
@ -749,8 +938,12 @@ public sealed class ShadowObjectRegistry
_entityReg.Remove(entityId);
_suspendedEntities.Remove(entityId);
_withdrawnPrefixesByOwner.Remove(entityId);
if (existed)
if (existed && publishMutation)
{
BumpOwnerVersion(entityId);
RemoveOwnerPrefixMembership(entityId);
_ownerVersions.Remove(entityId);
}
}
private static void RemoveOwnerRows(
@ -879,6 +1072,59 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId);
}
/// <summary>
/// Retires one logical owner's rows from a streamed-out prefix. This is
/// the owner-granular form used by the collision-generation retirement
/// cursor; it preserves the same static/dynamic lifetime rules as
/// <see cref="RemoveLandblock"/> without scanning the complete registry.
/// </summary>
internal void RetireOwnerFromLandblock(uint entityId, uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
if (_entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration)
&& registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u) == prefix)
{
DeregisterCore(entityId, publishMutation: false);
RemoveOwnerPrefixMembership(entityId);
_ownerVersions.Remove(entityId);
return;
}
if (!_entityToCells.TryGetValue(entityId, out List<uint>? cells))
return;
bool touched = false;
for (int index = cells.Count - 1; index >= 0; index--)
{
uint cellId = cells[index];
if ((cellId & 0xFFFF0000u) != prefix)
continue;
touched = true;
cells.RemoveAt(index);
if (_cells.TryGetValue(cellId, out List<ShadowEntry>? entries))
{
RemoveOwnerRows(entries, entityId);
if (entries.Count == 0)
_cells.Remove(cellId);
}
}
if (!touched)
return;
if (!_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn))
{
withdrawn = new HashSet<uint>();
_withdrawnPrefixesByOwner[entityId] = withdrawn;
}
withdrawn.Add(prefix);
if (cells.Count == 0)
_entityToCells.Remove(entityId);
BumpOwnerVersion(entityId);
}
/// <summary>
/// All objects registered in a specific cell — retail
/// <c>CObjCell::find_obj_collisions</c> iterating only
@ -925,44 +1171,35 @@ public sealed class ShadowObjectRegistry
(cell & 0xFFFF0000u) == (landblockId & 0xFFFF0000u));
/// <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.
/// Mirrors one ordinary active-world mutation into an off-side generation.
/// Target-prefix owners may subsequently be reflooded against the staged
/// topology; unrelated owners retain these exact active rows.
/// </summary>
internal void CopyCollisionStateFrom(
internal void MirrorOwnerFrom(
ShadowObjectRegistry source,
PhysicsDataCache stagingCache)
uint entityId)
{
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)
DeregisterCore(entityId, publishMutation: false);
if (source.TryCaptureOwnerState(
entityId,
out PreparedShadowOwnerState? state)
&& state is not null)
{
_withdrawnPrefixesByOwner[ownerId] = new HashSet<uint>(prefixes);
InstallOwnerState(state);
_ownerVersions[entityId] = source.GetOwnerVersion(entityId);
}
foreach ((uint ownerId, IReadOnlyList<ShadowShape> shapes) in
source._entityShapes)
else
{
_entityShapes[ownerId] = shapes;
RemoveOwnerPrefixMembership(entityId);
_ownerVersions.Remove(entityId);
}
foreach ((uint ownerId, RegistrationRecord registration) in
source._entityReg)
{
_entityReg[ownerId] = registration;
}
foreach ((uint ownerId, ulong version) in source._ownerVersions)
_ownerVersions[ownerId] = version;
_mutationVersion = source._mutationVersion;
}
internal int CaptureOwnerSlotLimit() => _ownerSlots.Count;
internal uint GetOwnerSlot(int index) => _ownerSlots[index];
/// <summary>
/// Refreshes one staging owner from the exact active payload, then floods
/// it against the staging generation's complete cell graph. The returned
@ -975,19 +1212,27 @@ public sealed class ShadowObjectRegistry
out ulong sourceVersion)
{
ArgumentNullException.ThrowIfNull(source);
Deregister(entityId);
sourceVersion = source.GetOwnerVersion(entityId);
if (!source._entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration)
|| source._suspendedEntities.Contains(entityId)
|| (registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u)
== (landblockId & 0xFFFF0000u))
|| !source.OwnerTouchesLandblock(entityId, landblockId))
{
// A target-local refresh is not a global owner deletion. Preserve
// the exact active rows when the live owner has moved elsewhere.
MirrorOwnerFrom(source, entityId);
return false;
}
if (registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u)
== (landblockId & 0xFFFF0000u))
{
// Target statics come from the staged landblock itself.
return false;
}
DeregisterCore(entityId, publishMutation: false);
if (registration.IsMultiPart
&& source._entityShapes.TryGetValue(
@ -1005,7 +1250,8 @@ public sealed class ShadowObjectRegistry
0f,
landblockId,
registration.SeedCellId,
isStatic: registration.IsStatic);
isStatic: registration.IsStatic,
publishMutation: false);
}
else
{
@ -1024,7 +1270,8 @@ public sealed class ShadowObjectRegistry
registration.State,
registration.Flags,
registration.SeedCellId,
isStatic: registration.IsStatic);
isStatic: registration.IsStatic,
publishMutation: false);
}
if (source._withdrawnPrefixesByOwner.TryGetValue(
@ -1041,28 +1288,21 @@ public sealed class ShadowObjectRegistry
if (retainedWithdrawn.Count != 0)
_withdrawnPrefixesByOwner[entityId] = retainedWithdrawn;
}
RefreshOwnerPrefixIndex(entityId);
_ownerVersions[entityId] = sourceVersion;
return true;
}
internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
ShadowObjectRegistry staging,
uint landblockId,
IReadOnlyDictionary<uint, ulong> expectedRetainedVersions) => new(
IReadOnlyList<uint> expectedRetainedOwners) => new(
this,
staging,
landblockId,
expectedRetainedVersions);
expectedRetainedOwners);
internal void CommitLandblockReplacement(
PreparedLandblockShadowReplacement replacement)
{
for (int index = 0; index < replacement.OwnerIds.Count; index++)
Deregister(replacement.OwnerIds[index]);
for (int index = 0; index < replacement.OwnerStates.Count; index++)
InstallOwnerState(replacement.OwnerStates[index]);
}
private bool OwnerTouchesLandblock(uint entityId, uint landblockId)
internal bool OwnerTouchesLandblock(uint entityId, uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? record))
@ -1080,6 +1320,43 @@ public sealed class ShadowObjectRegistry
&& withdrawn.Contains(prefix);
}
internal bool IsStaticOwnerRootedIn(uint entityId, uint landblockId) =>
_entityReg.TryGetValue(entityId, out RegistrationRecord? registration)
&& registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u)
== (landblockId & 0xFFFF0000u);
internal bool TryGetStaticOwnerRootPrefix(
uint entityId,
out uint landblockPrefix)
{
if (_entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration)
&& registration.IsStatic)
{
landblockPrefix = registration.SeedCellId & 0xFFFF0000u;
return true;
}
landblockPrefix = 0u;
return false;
}
internal bool HasLogicalOwner(uint entityId) =>
_entityReg.ContainsKey(entityId);
public int PrefixOwnerSlotCapacityForDiagnostics(uint landblockId) =>
_prefixOwnerSlots.TryGetValue(
landblockId & 0xFFFF0000u,
out List<uint>? slots)
? slots.Count
: 0;
public int OwnerVersionCountForDiagnostics => _ownerVersions.Count;
public int PrefixOwnerContainerCountForDiagnostics =>
_prefixOwnerSlots.Count;
private bool TryCaptureOwnerState(
uint entityId,
out PreparedShadowOwnerState? state)
@ -1148,63 +1425,65 @@ public sealed class ShadowObjectRegistry
private readonly ShadowObjectRegistry _active;
private readonly ShadowObjectRegistry _staging;
private readonly uint _prefix;
private readonly ulong _sourceMutationVersion;
private readonly IReadOnlyList<uint> _expected;
private readonly List<uint>? _activeSlots;
private readonly List<uint>? _stagingSlots;
private readonly int _activeSlotLimit;
private readonly int _stagingSlotLimit;
private readonly HashSet<uint> _owners = new();
private readonly List<uint> _ownerIds = new();
private readonly List<PreparedShadowOwnerState> _states = new();
private IEnumerator<KeyValuePair<uint, ulong>>? _expectedEnumerator;
private Dictionary<uint, RegistrationRecord>.Enumerator _registrationEnumerator;
private HashSet<uint>.Enumerator _ownerEnumerator;
private readonly List<PreparedShadowOwnerSlot> _states = new();
private readonly Dictionary<uint, int> _stateIndex = new();
private int _expectedIndex;
private int _activeSlotIndex;
private int _stagingSlotIndex;
private int _ownerIndex;
private int _phase;
internal LandblockReplacementBuilder(
ShadowObjectRegistry active,
ShadowObjectRegistry staging,
uint landblockId,
IReadOnlyDictionary<uint, ulong> expected)
IReadOnlyList<uint> expected)
{
_active = active;
_staging = staging;
_prefix = landblockId & 0xFFFF0000u;
_sourceMutationVersion = active.MutationVersion;
_expectedEnumerator = expected.GetEnumerator();
_expected = expected;
active._prefixOwnerSlots.TryGetValue(
_prefix,
out _activeSlots);
staging._prefixOwnerSlots.TryGetValue(
_prefix,
out _stagingSlots);
_activeSlotLimit = _activeSlots?.Count ?? 0;
_stagingSlotLimit = _stagingSlots?.Count ?? 0;
}
internal int WorkUnits { get; private set; }
internal bool IsStable =>
_active.MutationVersion == _sourceMutationVersion;
internal PreparedLandblockShadowReplacement? Prepared { get; private set; }
internal bool Advance()
{
if (!IsStable)
return true;
switch (_phase)
{
case 0:
if (_expectedEnumerator!.MoveNext())
if (_expectedIndex < _expected.Count)
{
(uint ownerId, ulong version) = _expectedEnumerator.Current;
if (_active.GetOwnerVersion(ownerId) != version
|| !_active.IsRetainedRefloodOwner(ownerId, _prefix))
{
return true;
}
AddOwner(ownerId);
AddOwner(_expected[_expectedIndex++]);
WorkUnits++;
return false;
}
_expectedEnumerator.Dispose();
_expectedEnumerator = null;
_registrationEnumerator = _active._entityReg.GetEnumerator();
_phase++;
return false;
case 1:
if (_registrationEnumerator.MoveNext())
if (_activeSlotIndex < _activeSlotLimit)
{
(uint ownerId, RegistrationRecord registration) =
_registrationEnumerator.Current;
if (registration.IsStatic
uint ownerId = _activeSlots![_activeSlotIndex++];
if (_active._entityReg.TryGetValue(
ownerId,
out RegistrationRecord? registration)
&& registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u) == _prefix)
{
AddOwner(ownerId);
@ -1212,16 +1491,16 @@ public sealed class ShadowObjectRegistry
WorkUnits++;
return false;
}
_registrationEnumerator.Dispose();
_registrationEnumerator = _staging._entityReg.GetEnumerator();
_phase++;
return false;
case 2:
if (_registrationEnumerator.MoveNext())
if (_stagingSlotIndex < _stagingSlotLimit)
{
(uint ownerId, RegistrationRecord registration) =
_registrationEnumerator.Current;
if (registration.IsStatic
uint ownerId = _stagingSlots![_stagingSlotIndex++];
if (_staging._entityReg.TryGetValue(
ownerId,
out RegistrationRecord? registration)
&& registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u) == _prefix)
{
AddOwner(ownerId);
@ -1229,32 +1508,24 @@ public sealed class ShadowObjectRegistry
WorkUnits++;
return false;
}
_registrationEnumerator.Dispose();
_ownerEnumerator = _owners.GetEnumerator();
_phase++;
return false;
case 3:
if (_ownerEnumerator.MoveNext())
if (_ownerIndex < _ownerIds.Count)
{
uint ownerId = _ownerEnumerator.Current;
if (_staging.TryCaptureOwnerState(
ownerId,
out PreparedShadowOwnerState? state)
&& state is not null)
{
_states.Add(state);
}
uint ownerId = _ownerIds[_ownerIndex++];
_staging.TryCaptureOwnerState(
ownerId,
out PreparedShadowOwnerState? state);
_stateIndex[ownerId] = _states.Count;
_states.Add(new PreparedShadowOwnerSlot(ownerId, state));
WorkUnits++;
return false;
}
_ownerEnumerator.Dispose();
if (IsStable)
{
Prepared = new PreparedLandblockShadowReplacement(
_prefix,
_ownerIds,
_states);
}
Prepared = new PreparedLandblockShadowReplacement(
_prefix,
_ownerIds,
_states);
_phase++;
return true;
default:
@ -1262,20 +1533,34 @@ public sealed class ShadowObjectRegistry
}
}
private void AddOwner(uint ownerId)
internal void AddOwner(uint ownerId)
{
if (_owners.Add(ownerId))
_ownerIds.Add(ownerId);
}
public void Dispose()
internal void RefreshOwner(uint ownerId)
{
_expectedEnumerator?.Dispose();
if (_phase is 1 or 2)
_registrationEnumerator.Dispose();
if (_phase == 3)
_ownerEnumerator.Dispose();
AddOwner(ownerId);
if (_stateIndex.TryGetValue(ownerId, out int index))
{
_staging.TryCaptureOwnerState(
ownerId,
out PreparedShadowOwnerState? state);
_states[index].State = state;
return;
}
if (_phase > 3)
{
_staging.TryCaptureOwnerState(
ownerId,
out PreparedShadowOwnerState? state);
_stateIndex[ownerId] = _states.Count;
_states.Add(new PreparedShadowOwnerSlot(ownerId, state));
}
}
public void Dispose() { }
}
private bool IsRetainedRefloodOwner(uint ownerId, uint landblockId)
@ -1296,7 +1581,7 @@ public sealed class ShadowObjectRegistry
internal PreparedLandblockShadowReplacement(
uint landblockPrefix,
IReadOnlyList<uint> ownerIds,
IReadOnlyList<PreparedShadowOwnerState> ownerStates)
IReadOnlyList<PreparedShadowOwnerSlot> ownerStates)
{
LandblockPrefix = landblockPrefix;
OwnerIds = ownerIds;
@ -1305,7 +1590,21 @@ public sealed class ShadowObjectRegistry
internal uint LandblockPrefix { get; }
internal IReadOnlyList<uint> OwnerIds { get; }
internal IReadOnlyList<PreparedShadowOwnerState> OwnerStates { get; }
internal IReadOnlyList<PreparedShadowOwnerSlot> OwnerStates { get; }
}
internal sealed class PreparedShadowOwnerSlot
{
internal PreparedShadowOwnerSlot(
uint entityId,
PreparedShadowOwnerState? state)
{
EntityId = entityId;
State = state;
}
internal uint EntityId { get; }
internal PreparedShadowOwnerState? State { get; set; }
}
internal sealed record PreparedShadowOwnerState(
@ -1334,7 +1633,15 @@ public sealed class ShadowObjectRegistry
_entityShapes.Clear();
_entityReg.Clear();
_ownerVersions.Clear();
_mutationVersion = 0UL;
_ownerPrefixes.Clear();
_prefixOwnerSlots.Clear();
_prefixOwnerIndices.Clear();
_prefixFreeSlots.Clear();
_ownerSlots.Clear();
_ownerIndices.Clear();
_ownerFreeSlots.Clear();
_prefixScratch.Clear();
_removedPrefixScratch.Clear();
_fallback = null;
}

View file

@ -18,8 +18,24 @@ namespace AcDream.Core.World.Cells;
/// </summary>
public sealed class CellGraph
{
private readonly ConcurrentDictionary<uint, EnvCell> _envCells = new();
private readonly ConcurrentDictionary<uint, CellGraphTerrain> _terrain = new();
private readonly CollisionWorldStateSlot _collisionWorld;
private ConcurrentDictionary<uint, EnvCell> _envCells =>
_collisionWorld.Current.EnvCells;
private ConcurrentDictionary<uint, CellGraphTerrain> _terrain =>
_collisionWorld.Current.Terrain;
private ConcurrentDictionary<uint, ObjCell> _outdoorCells =>
_collisionWorld.Current.OutdoorCells;
public CellGraph()
: this(new CollisionWorldStateSlot())
{
}
internal CellGraph(CollisionWorldStateSlot collisionWorld)
{
_collisionWorld = collisionWorld
?? throw new ArgumentNullException(nameof(collisionWorld));
}
/// <summary>The player's current cell — the render/lighting root. Written ONLY at the
/// player chokepoint <see cref="AcDream.Core.Physics.PhysicsEngine.UpdatePlayerCurrCell"/>
@ -34,8 +50,21 @@ public sealed class CellGraph
/// <param name="landblockPrefix">Any id in the cell's landblock; masked to (id &amp; 0xFFFF0000).</param>
public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin)
=> _terrain[landblockPrefix & 0xFFFF0000u] =
new CellGraphTerrain(terrain, worldOrigin);
{
uint prefix = landblockPrefix & 0xFFFF0000u;
_terrain[prefix] = new CellGraphTerrain(terrain, worldOrigin);
for (uint low = 1u; low <= 0x40u; low++)
{
uint id = prefix | low;
int index = (int)(low - 1u);
_outdoorCells[id] = LandCell.Synthesize(
id,
terrain,
worldOrigin,
index / 8,
index % 8);
}
}
/// <summary>
/// World origin (SW corner) of the landblock containing <paramref name="id"/>,
@ -65,6 +94,8 @@ public sealed class CellGraph
CurrCell = null;
}
_terrain.TryRemove(lb, out _);
for (uint low = 1u; low <= 0x40u; low++)
_outdoorCells.TryRemove(lb | low, out _);
foreach (var id in new List<uint>(_envCells.Keys))
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
}
@ -95,9 +126,9 @@ public sealed class CellGraph
uint low = id & 0xFFFFu;
if (low < 1u || low > 0x40u) return null;
if (!_terrain.TryGetValue(id & 0xFFFF0000u, out var t)) return null;
int idx = (int)(low - 1u);
return LandCell.Synthesize(id, t.Terrain, t.Origin, idx / 8, idx % 8);
return _outdoorCells.TryGetValue(id, out ObjCell? cell)
? cell
: null;
}
/// <summary>
@ -127,63 +158,10 @@ public sealed class CellGraph
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, CellGraphTerrain terrain) in _terrain)
{
copy._terrain.TryAdd(id, terrain);
}
return copy;
}
internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
CellGraph staging,
uint landblockId) => new(this, staging, landblockId);
internal void CommitLandblockReplacement(
PreparedCellGraphLandblock replacement)
{
uint currentCellId = CurrCell?.Id ?? 0u;
for (int index = 0; index < replacement.EnvCellIdsToRemove.Count; index++)
_envCells.TryRemove(replacement.EnvCellIdsToRemove[index], out _);
if (replacement.HasTerrain)
{
_terrain[replacement.LandblockPrefix] = replacement.Terrain!;
}
else
{
_terrain.TryRemove(replacement.LandblockPrefix, out _);
}
for (int index = 0; index < replacement.EnvCells.Count; index++)
{
(uint id, EnvCell cell) = replacement.EnvCells[index];
_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);
else if ((currentCellId & 0xFFFF0000u)
== replacement.LandblockPrefix)
CurrCell = null;
}
internal sealed class LandblockReplacementBuilder : IDisposable
{
private readonly CellGraph _active;
@ -249,8 +227,7 @@ public sealed class CellGraph
_removeIds,
_envCells,
hasTerrain,
terrain,
_staging.CurrCell?.Id ?? 0u);
terrain);
_phase = 2;
}
return true;
@ -271,8 +248,7 @@ internal sealed record PreparedCellGraphLandblock(
IReadOnlyList<uint> EnvCellIdsToRemove,
IReadOnlyList<KeyValuePair<uint, EnvCell>> EnvCells,
bool HasTerrain,
CellGraphTerrain? Terrain,
uint CurrentCellId);
CellGraphTerrain? Terrain);
internal sealed record CellGraphTerrain(
TerrainSurface Terrain,

File diff suppressed because it is too large Load diff