feat(runtime): own deferred set-position residence

This commit is contained in:
Erik 2026-07-31 22:32:49 +02:00
parent e84a388e6f
commit 4c02ac4259
18 changed files with 5557 additions and 34 deletions

View file

@ -16,6 +16,7 @@ public sealed class ParentAttachmentState
private readonly Dictionary<uint, ParentAttachmentRelation> _stagedByChild = new();
private readonly Dictionary<uint, ParentAttachmentRelation> _recoveryByChild = new();
private readonly Dictionary<uint, ParentAttachmentRelation> _lastAcceptedByChild = new();
private readonly Dictionary<ParentIncarnation, List<uint>> _committedChildrenByParent = new();
public int UnresolvedRelationCount =>
_unresolvedByChild.Values.Sum(queue => queue.Count);
@ -148,6 +149,9 @@ public sealed class ParentAttachmentState
out ParentAttachmentRelation committed)
&& committed == relation;
public bool HasCommittedParent(uint childGuid) =>
_lastAcceptedByChild.ContainsKey(childGuid);
public bool IsPending(
ParentAttachmentRelation relation,
ParentProjectionCandidateKind kind) =>
@ -184,7 +188,20 @@ public sealed class ParentAttachmentState
{
return false;
}
RemoveCommittedChild(relation.ChildGuid);
_lastAcceptedByChild[relation.ChildGuid] = relation;
var parent = new ParentIncarnation(
relation.ParentGuid,
relation.ParentInstanceSequence);
if (!_committedChildrenByParent.TryGetValue(
parent,
out List<uint>? children))
{
children = [];
_committedChildrenByParent.Add(parent, children);
}
children.Add(relation.ChildGuid);
_stagedByChild.Remove(relation.ChildGuid);
_recoveryByChild[relation.ChildGuid] = relation;
return true;
@ -233,16 +250,34 @@ public sealed class ParentAttachmentState
return result.ToArray();
}
/// <summary>
/// Returns only the exact direct children currently committed to one
/// parent incarnation. Lost-cell destruction follows retail's live
/// CHILDLIST and must not capture staged, unresolved, or future-generation
/// relations that merely reuse the same parent GUID.
/// </summary>
public IReadOnlyList<uint> ChildrenAttachedToParent(
uint parentGuid,
ushort parentInstanceSequence)
{
var parent = new ParentIncarnation(parentGuid, parentInstanceSequence);
return _committedChildrenByParent.TryGetValue(
parent,
out List<uint>? children)
? children
: Array.Empty<uint>();
}
public void RemoveObject(uint guid)
{
_stagedByChild.Remove(guid);
_recoveryByChild.Remove(guid);
_lastAcceptedByChild.Remove(guid);
RemoveCommittedChild(guid);
_unresolvedByChild.Remove(guid);
RemoveParentReferences(_stagedByChild, guid);
RemoveParentReferences(_recoveryByChild, guid);
RemoveParentReferences(_lastAcceptedByChild, guid);
RemoveCommittedParentReferences(guid);
uint[] children = _unresolvedByChild.Keys.ToArray();
for (int i = 0; i < children.Length; i++)
@ -268,10 +303,10 @@ public sealed class ParentAttachmentState
relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent);
_stagedByChild.Remove(guid);
_recoveryByChild.Remove(guid);
_lastAcceptedByChild.Remove(guid);
RemoveCommittedChild(guid);
RemoveParentReferences(_stagedByChild, guid);
RemoveParentReferences(_recoveryByChild, guid);
RemoveParentReferences(_lastAcceptedByChild, guid);
RemoveCommittedParentReferences(guid);
FilterParentCandidates(
guid,
relation => relation.ParentInstanceSequence == replacementGeneration
@ -292,10 +327,10 @@ public sealed class ParentAttachmentState
relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent);
_stagedByChild.Remove(guid);
_recoveryByChild.Remove(guid);
_lastAcceptedByChild.Remove(guid);
RemoveCommittedChild(guid);
RemoveParentReferences(_stagedByChild, guid);
RemoveParentReferences(_recoveryByChild, guid);
RemoveParentReferences(_lastAcceptedByChild, guid);
RemoveCommittedParentReferences(guid);
FilterParentCandidates(
guid,
relation => PhysicsTimestampGate.IsNewer(
@ -311,14 +346,14 @@ public sealed class ParentAttachmentState
{
_stagedByChild.Remove(childGuid);
_recoveryByChild.Remove(childGuid);
_lastAcceptedByChild.Remove(childGuid);
RemoveCommittedChild(childGuid);
}
public void RemoveChild(uint childGuid)
{
_stagedByChild.Remove(childGuid);
_recoveryByChild.Remove(childGuid);
_lastAcceptedByChild.Remove(childGuid);
RemoveCommittedChild(childGuid);
_unresolvedByChild.Remove(childGuid);
}
@ -328,6 +363,50 @@ public sealed class ParentAttachmentState
_stagedByChild.Clear();
_recoveryByChild.Clear();
_lastAcceptedByChild.Clear();
foreach (List<uint> children in _committedChildrenByParent.Values)
children.Clear();
_committedChildrenByParent.Clear();
}
private void RemoveCommittedChild(uint childGuid)
{
if (!_lastAcceptedByChild.Remove(
childGuid,
out ParentAttachmentRelation relation))
{
return;
}
var parent = new ParentIncarnation(
relation.ParentGuid,
relation.ParentInstanceSequence);
if (!_committedChildrenByParent.TryGetValue(
parent,
out List<uint>? children))
{
return;
}
int childIndex = children.IndexOf(childGuid);
if (childIndex >= 0)
{
int lastIndex = children.Count - 1;
children[childIndex] = children[lastIndex];
children.RemoveAt(lastIndex);
}
if (children.Count == 0)
_committedChildrenByParent.Remove(parent);
}
private void RemoveCommittedParentReferences(uint parentGuid)
{
uint[] children = _lastAcceptedByChild
.Where(pair => pair.Value.ParentGuid == parentGuid)
.Select(pair => pair.Key)
.ToArray();
for (int i = 0; i < children.Length; i++)
RemoveCommittedChild(children[i]);
}
private static void RemoveParentReferences(
@ -377,6 +456,10 @@ public sealed class ParentAttachmentState
else
_unresolvedByChild[childGuid] = retained;
}
private readonly record struct ParentIncarnation(
uint ServerGuid,
ushort InstanceSequence);
}
public readonly record struct ParentAttachmentRelation(

View file

@ -254,6 +254,12 @@ public sealed class RuntimeEntityDirectory
record.AdvanceMovementCommit();
}
public void AdvancePlacementCommit(RuntimeEntityRecord record)
{
EnsureKnown(record);
record.AdvancePlacementCommit();
}
public void AdvanceParentCommit(RuntimeEntityRecord record)
{
EnsureKnown(record);

View file

@ -1,4 +1,5 @@
using AcDream.Core.Items;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Entities;
@ -14,6 +15,11 @@ public interface IRuntimeEntityObjectEventSource
IDisposable Subscribe(IRuntimeEntityObjectObserver observer);
}
public interface IRuntimePlacementObserver
{
void OnPlacement(in RuntimePlacementDelta delta);
}
/// <summary>
/// One synchronous, generation-stamped commit stream for the canonical entity
/// directory and retained object table. It owns no cross-frame queue and
@ -31,6 +37,7 @@ public sealed class RuntimeEntityObjectEventStream
private readonly object _observerGate = new();
private readonly List<PendingDispatch> _pendingDispatch = [];
private IRuntimeEntityObjectObserver[] _observers = [];
private IRuntimePlacementObserver[] _placementObservers = [];
private Func<RuntimeGenerationToken> _generation = static () => default;
private Func<ulong> _frameNumber = static () => 0UL;
private bool _contextBound;
@ -53,6 +60,8 @@ public sealed class RuntimeEntityObjectEventStream
public ulong LastSequence => _sequencer.LastSequence;
public int SubscriberCount => Volatile.Read(ref _observers).Length;
public int PlacementSubscriberCount =>
Volatile.Read(ref _placementObservers).Length;
public int PendingDispatchCount => _pendingDispatch.Count;
public bool IsDispatching => _dispatching;
public long DispatchFailureCount { get; private set; }
@ -107,6 +116,26 @@ public sealed class RuntimeEntityObjectEventStream
return new ObserverSubscription(this, observer);
}
public IDisposable SubscribePlacement(IRuntimePlacementObserver observer)
{
ArgumentNullException.ThrowIfNull(observer);
lock (_observerGate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IRuntimePlacementObserver[] current = _placementObservers;
if (Array.IndexOf(current, observer) >= 0)
{
throw new InvalidOperationException(
"The Runtime placement observer is already subscribed.");
}
var replacement = new IRuntimePlacementObserver[current.Length + 1];
Array.Copy(current, replacement, current.Length);
replacement[^1] = observer;
Volatile.Write(ref _placementObservers, replacement);
}
return new PlacementObserverSubscription(this, observer);
}
/// <summary>
/// Allocates the next stamp for another domain on the same Runtime event
/// surface. J4J6 will move those remaining domain publishers into
@ -132,6 +161,15 @@ public sealed class RuntimeEntityObjectEventStream
EnqueueAndDrain(PendingDispatch.ForEntity(delta));
}
internal void PublishPlacement(
in RuntimePlacementProjectionSnapshot placement)
{
var delta = new RuntimePlacementDelta(
NextStamp(),
placement);
EnqueueAndDrain(PendingDispatch.ForPlacement(delta));
}
public void Dispose()
{
lock (_observerGate)
@ -140,6 +178,7 @@ public sealed class RuntimeEntityObjectEventStream
return;
_disposed = true;
Volatile.Write(ref _observers, []);
Volatile.Write(ref _placementObservers, []);
_objects.Cleared -= OnObjectsCleared;
_objects.ObjectRemovalClassified -= OnObjectRemoved;
_objects.ObjectMoved -= OnObjectMoved;
@ -155,6 +194,7 @@ public sealed class RuntimeEntityObjectEventStream
if (_disposed)
return;
Volatile.Write(ref _observers, []);
Volatile.Write(ref _placementObservers, []);
}
}
@ -245,7 +285,7 @@ public sealed class RuntimeEntityObjectEventStream
RuntimeEntityDelta delta = pending.Entity;
observer.OnEntity(in delta);
}
else
else if (pending.Kind is PendingDispatchKind.Inventory)
{
RuntimeInventoryDelta delta = pending.Inventory;
observer.OnInventory(in delta);
@ -256,6 +296,24 @@ public sealed class RuntimeEntityObjectEventStream
RecordDispatchFailure(error);
}
}
if (pending.Kind is PendingDispatchKind.Placement)
{
IRuntimePlacementObserver[] placementObservers =
Volatile.Read(ref _placementObservers);
foreach (IRuntimePlacementObserver observer in placementObservers)
{
try
{
RuntimePlacementDelta delta = pending.Placement;
observer.OnPlacement(in delta);
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
}
}
private void RecordDispatchFailure(Exception error)
@ -295,24 +353,54 @@ public sealed class RuntimeEntityObjectEventStream
}
}
private void UnsubscribePlacement(IRuntimePlacementObserver observer)
{
lock (_observerGate)
{
IRuntimePlacementObserver[] current = _placementObservers;
int index = Array.IndexOf(current, observer);
if (index < 0)
return;
var replacement = new IRuntimePlacementObserver[current.Length - 1];
if (index != 0)
Array.Copy(current, 0, replacement, 0, index);
if (index != current.Length - 1)
{
Array.Copy(
current,
index + 1,
replacement,
index,
current.Length - index - 1);
}
Volatile.Write(ref _placementObservers, replacement);
}
}
private enum PendingDispatchKind : byte
{
Entity,
Inventory,
Placement,
}
private readonly record struct PendingDispatch(
PendingDispatchKind Kind,
RuntimeEntityDelta Entity,
RuntimeInventoryDelta Inventory)
RuntimeInventoryDelta Inventory,
RuntimePlacementDelta Placement)
{
public static PendingDispatch ForEntity(
RuntimeEntityDelta entity) =>
new(PendingDispatchKind.Entity, entity, default);
new(PendingDispatchKind.Entity, entity, default, default);
public static PendingDispatch ForInventory(
RuntimeInventoryDelta inventory) =>
new(PendingDispatchKind.Inventory, default, inventory);
new(PendingDispatchKind.Inventory, default, inventory, default);
public static PendingDispatch ForPlacement(
RuntimePlacementDelta placement) =>
new(PendingDispatchKind.Placement, default, default, placement);
}
private sealed class ObserverSubscription(
@ -325,4 +413,17 @@ public sealed class RuntimeEntityObjectEventStream
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer);
}
private sealed class PlacementObserverSubscription(
RuntimeEntityObjectEventStream owner,
IRuntimePlacementObserver observer)
: IDisposable
{
private RuntimeEntityObjectEventStream? _owner = owner;
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?
.UnsubscribePlacement(observer);
}
}

View file

@ -28,6 +28,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
int EquipmentOwnerCount,
int PendingMoveCount,
int StreamSubscriberCount,
int PlacementStreamSubscriberCount,
long StreamDispatchFailureCount,
bool HasLastStreamDispatchFailure,
int PendingDispatchCount,
@ -51,6 +52,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
&& EquipmentOwnerCount == 0
&& PendingMoveCount == 0
&& StreamSubscriberCount == 0
&& PlacementStreamSubscriberCount == 0
&& PendingDispatchCount == 0
&& !IsDispatching
&& !IsSessionClearInProgress;
@ -95,10 +97,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
public RuntimeEntityObjectLifetime(
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
IGameRuntimeClock? gameClock = null)
{
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
Physics = new RuntimePhysicsState(Entities, timeProvider: timeProvider);
Physics = new RuntimePhysicsState(
Entities,
timeProvider: timeProvider,
gameClock: gameClock);
Objects = new ClientObjectTable();
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): the physics
// entry-restriction gate (ObjectInfo.CheckEntryRestrictions) resolves
@ -111,44 +117,51 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
EntityView = views.Entities;
InventoryView = views.Inventory;
Events = new RuntimeEntityObjectEventStream(Entities, Objects);
Physics.SetPosition.BindEventStream(Events);
}
internal RuntimeEntityObjectLifetime(
PhysicsDataCache physicsDataCache,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
IGameRuntimeClock? gameClock = null)
{
ArgumentNullException.ThrowIfNull(physicsDataCache);
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
Physics = new RuntimePhysicsState(
Entities,
physicsDataCache,
timeProvider);
timeProvider,
gameClock);
Objects = new ClientObjectTable();
Physics.Engine.Objects = Objects;
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
InventoryView = views.Inventory;
Events = new RuntimeEntityObjectEventStream(Entities, Objects);
Physics.SetPosition.BindEventStream(Events);
}
internal RuntimeEntityObjectLifetime(
PhysicsEngine physicsEngine,
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
IGameRuntimeClock? gameClock = null)
{
ArgumentNullException.ThrowIfNull(physicsEngine);
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
Physics = new RuntimePhysicsState(
Entities,
physicsEngine,
timeProvider);
timeProvider,
gameClock);
Objects = new ClientObjectTable();
Physics.Engine.Objects = Objects;
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
InventoryView = views.Inventory;
Events = new RuntimeEntityObjectEventStream(Entities, Objects);
Physics.SetPosition.BindEventStream(Events);
}
public RuntimeEntityDirectory Entities { get; }
@ -176,6 +189,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Objects.EquipmentOwnerCount,
Objects.PendingMoveCount,
Events.SubscriberCount,
Events.PlacementSubscriberCount,
Events.DispatchFailureCount,
Events.LastDispatchFailure is not null,
Events.PendingDispatchCount,
@ -421,6 +435,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(canonical);
RuntimePlacementCancellationReceipt cancellation =
Physics.SetPosition.Forget(
canonical,
releasePreparedMover: true);
Physics.RemoveSpatialProjection(canonical);
Entities.SetRemoteMotion(canonical, null);
Entities.SetRemoteMotionBindingInProgress(canonical, false);
@ -432,6 +450,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.SetPhysicsBodyAcquisitionInProgress(canonical, false);
Entities.SetHasPartArray(canonical, false);
Entities.ReleaseLocalId(canonical);
Physics.SetPosition.PublishCancellation(cancellation);
}
/// <summary>
@ -510,6 +529,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.RefreshSnapshot(canonical, accepted);
Entities.AdvancePositionAuthority(canonical);
RuntimePlacementCancellationReceipt cancellation =
Physics.SetPosition.Forget(canonical);
Entities.SuspendObjectClock(canonical);
Entities.SetFullCell(canonical, 0u, 0u);
Entities.ParentAttachments.EndChildProjection(update.Guid);
@ -520,7 +541,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
() => acknowledgeProjection?.Invoke(canonical),
RuntimeEntityChange.Withdrawn,
() => canonical.PositionAuthorityVersion == positionVersion
&& canonical.SpatialAuthorityVersion == spatialVersion);
&& canonical.SpatialAuthorityVersion == spatialVersion,
cancellation);
}
public bool TryApplyCreateParent(
@ -596,6 +618,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
return false;
}
RuntimePlacementCancellationReceipt cancellation =
Physics.SetPosition.Forget(canonical);
Entities.SuspendObjectClock(canonical);
Entities.SetFullCell(canonical, 0u, 0u);
ulong spatialVersion = canonical.SpatialAuthorityVersion;
@ -605,7 +629,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
RuntimeEntityChange.Withdrawn,
() => canonical.PositionAuthorityVersion
== positionAuthorityVersion
&& canonical.SpatialAuthorityVersion == spatialVersion);
&& canonical.SpatialAuthorityVersion == spatialVersion,
cancellation);
}
public bool TryApplyMotion(
@ -764,6 +789,9 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
};
}
RuntimePlacementCancellationReceipt cancellation = acceptedPosition
? Physics.SetPosition.Forget(canonical)
: default;
Entities.RefreshSnapshot(
canonical,
snapshot,
@ -793,7 +821,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
? RuntimeEntityChange.Rebucketed
: RuntimeEntityChange.Updated,
() => canonical.PositionAuthorityVersion == positionVersion
&& canonical.SpatialAuthorityVersion == spatialVersion);
&& canonical.SpatialAuthorityVersion == spatialVersion,
cancellation);
}
public bool CommitRebucket(
@ -915,8 +944,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
&& active.Incarnation == delete.InstanceSequence
&& Entities.RemoveActive(active))
{
RuntimePlacementCancellationReceipt cancellation =
Physics.SetPosition.Forget(
active,
releasePreparedMover: true);
retiredCanonical = active;
Entities.RetainTeardown(active);
Physics.SetPosition.PublishCancellation(cancellation);
PublishEntity(
removeRetainedObject
? RuntimeEntityChange.Deleted
@ -985,10 +1019,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
return Array.Empty<RuntimeEntityRecord>();
_sessionClearInProgress = true;
Physics.SetPosition.ResetSession();
Entities.BeginSessionClear();
RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray();
foreach (RuntimeEntityRecord canonical in active)
{
Physics.SetPosition.Forget(canonical);
if (!Entities.RemoveActive(canonical))
continue;
Entities.RetainTeardown(canonical);
@ -1119,6 +1155,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.RefreshSnapshot(canonical, accepted);
Entities.AdvancePositionAuthority(canonical);
RuntimePlacementCancellationReceipt cancellation =
Physics.SetPosition.Forget(canonical);
ulong positionVersion = canonical.PositionAuthorityVersion;
ulong spatialVersion = canonical.SpatialAuthorityVersion;
return AcknowledgeProjectionAndPublish(
@ -1126,7 +1164,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
() => acknowledgeProjection?.Invoke(canonical),
RuntimeEntityChange.Updated,
() => canonical.PositionAuthorityVersion == positionVersion
&& canonical.SpatialAuthorityVersion == spatialVersion);
&& canonical.SpatialAuthorityVersion == spatialVersion,
cancellation);
}
private void PublishEntity(
@ -1138,8 +1177,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
RuntimeEntityRecord canonical,
Action acknowledgeProjection,
RuntimeEntityChange change,
Func<bool> matchesCommittedMutation)
Func<bool> matchesCommittedMutation,
RuntimePlacementCancellationReceipt cancellation = default)
{
Physics.SetPosition.PublishCancellation(cancellation);
if (!IsExpectedCanonical(canonical, matchesCommittedMutation))
return false;
try
{
acknowledgeProjection();

View file

@ -52,6 +52,7 @@ public sealed class RuntimeEntityRecord
public uint RawPhysicsState { get; internal set; }
public PhysicsStateFlags FinalPhysicsState { get; internal set; }
public ulong SpatialAuthorityVersion { get; private set; }
public ulong PlacementCommitVersion { get; private set; }
public ulong PhysicsStateMutationVersion { get; private set; }
/// <summary>
@ -136,6 +137,8 @@ public sealed class RuntimeEntityRecord
internal void AdvanceMovementCommit() => MovementCommitVersion++;
internal void AdvancePlacementCommit() => PlacementCommitVersion++;
internal void AdvanceParentCommit() => ParentCommitVersion++;
internal void AdvanceObjDescAuthority() => ObjDescAuthorityVersion++;

View file

@ -178,7 +178,8 @@ public sealed class GameRuntime
context.EntityObjects = new RuntimeEntityObjectLifetime(
dependencies.FirstLocalEntityId,
dependencies.TimeProvider);
dependencies.TimeProvider,
clock);
construction.Own(context.EntityObjects);
Fault(
GameRuntimeConstructionPoint.EntityObjectsCreated,

View file

@ -1,4 +1,5 @@
using AcDream.Core.Combat;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime;
@ -50,6 +51,10 @@ public readonly record struct RuntimeEntityDelta(
RuntimeEntityChange Change,
RuntimeEntitySnapshot Entity);
public readonly record struct RuntimePlacementDelta(
RuntimeEventStamp Stamp,
RuntimePlacementProjectionSnapshot Placement);
public enum RuntimeInventoryChange
{
Added,

View file

@ -9,6 +9,20 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot(
int SpatialRootCount,
int SpatialRemoteCount,
int SpatialProjectileCount,
int SetPositionOperationCount,
int AwaitingSetPositionPreparationCount,
int DeferredSetPositionCount,
int PendingSetPositionHostAcknowledgementCount,
int LostCellDeadlineCount,
int LostCellDeadlineNodeCount,
int LostCellDeadlineIndexCount,
int ExpiredLostCellCount,
int ExpiredLostCellIndexCount,
int DeferredSetPositionBucketCount,
int DeferredSetPositionBucketOrderCount,
int UnboundDeferredSetPositionCellCount,
int UnboundDeferredSetPositionCellOrderCount,
int PreparedSetPositionMoverCount,
int CollisionAdmissionCount,
int CollisionGenerationCount,
bool OwnsProductionDataCache,
@ -21,6 +35,20 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot(
&& SpatialRootCount == 0
&& SpatialRemoteCount == 0
&& SpatialProjectileCount == 0
&& SetPositionOperationCount == 0
&& AwaitingSetPositionPreparationCount == 0
&& DeferredSetPositionCount == 0
&& PendingSetPositionHostAcknowledgementCount == 0
&& LostCellDeadlineCount == 0
&& LostCellDeadlineNodeCount == 0
&& LostCellDeadlineIndexCount == 0
&& ExpiredLostCellCount == 0
&& ExpiredLostCellIndexCount == 0
&& DeferredSetPositionBucketCount == 0
&& DeferredSetPositionBucketOrderCount == 0
&& UnboundDeferredSetPositionCellCount == 0
&& UnboundDeferredSetPositionCellOrderCount == 0
&& PreparedSetPositionMoverCount == 0
&& CollisionAdmissionCount == 0
&& CollisionGenerationCount == 0
&& OwnsProductionDataCache;
@ -979,6 +1007,7 @@ internal readonly record struct RuntimeCollisionSealStep(
public sealed class RuntimePhysicsState : IDisposable
{
private readonly TimeProvider _timeProvider;
private readonly IGameRuntimeClock? _gameClock;
private readonly Dictionary<RuntimeEntityKey, RuntimeEntityRecord>
_spatialRoots = new();
private readonly Dictionary<RuntimeEntityKey, IRuntimeRemoteMotion>
@ -1020,10 +1049,12 @@ public sealed class RuntimePhysicsState : IDisposable
internal RuntimePhysicsState(
RuntimeEntityDirectory entities,
PhysicsDataCache? dataCache = null,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
IGameRuntimeClock? gameClock = null)
{
Entities = entities ?? throw new ArgumentNullException(nameof(entities));
_timeProvider = timeProvider ?? TimeProvider.System;
_gameClock = gameClock;
DataCache = dataCache ?? PhysicsDataCache.CreateProduction();
Engine = new PhysicsEngine
{
@ -1032,15 +1063,18 @@ public sealed class RuntimePhysicsState : IDisposable
Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated;
Engine.ShadowObjects.OwnerPrefixMembershipChanged +=
OnCollisionOwnerPrefixMembershipChanged;
SetPosition = new RuntimeSetPositionState(this, Entities);
}
internal RuntimePhysicsState(
RuntimeEntityDirectory entities,
PhysicsEngine engine,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
IGameRuntimeClock? gameClock = null)
{
Entities = entities ?? throw new ArgumentNullException(nameof(entities));
_timeProvider = timeProvider ?? TimeProvider.System;
_gameClock = gameClock;
Engine = engine ?? throw new ArgumentNullException(nameof(engine));
DataCache = engine.DataCache
?? PhysicsDataCache.CreateProduction(engine.CollisionWorld);
@ -1048,11 +1082,13 @@ public sealed class RuntimePhysicsState : IDisposable
Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated;
Engine.ShadowObjects.OwnerPrefixMembershipChanged +=
OnCollisionOwnerPrefixMembershipChanged;
SetPosition = new RuntimeSetPositionState(this, Entities);
}
internal RuntimeEntityDirectory Entities { get; }
public PhysicsEngine Engine { get; }
public PhysicsDataCache DataCache { get; }
internal RuntimeSetPositionState SetPosition { get; }
public int SpatialRootCount => _spatialRoots.Count;
public int SpatialRemoteCount => _spatialRemotes.Count;
public int SpatialProjectileCount => _spatialProjectiles.Count;
@ -1061,18 +1097,41 @@ public sealed class RuntimePhysicsState : IDisposable
internal double UtcNowSeconds =>
(_timeProvider.GetUtcNow() - DateTimeOffset.UnixEpoch)
.TotalSeconds;
internal double MonotonicNowSeconds =>
_timeProvider.GetTimestamp()
/ (double)_timeProvider.TimestampFrequency;
internal double PlacementSimulationTime(double fallback) =>
_gameClock?.SimulationTimeSeconds ?? fallback;
public RuntimePhysicsOwnershipSnapshot CaptureOwnership() =>
new(
public RuntimePhysicsOwnershipSnapshot CaptureOwnership()
{
RuntimeSetPositionOwnershipSnapshot setPosition =
SetPosition.CaptureOwnership();
return new(
Engine.LandblockCount,
Engine.ShadowObjects.RetainedRegistrationCount,
_spatialRoots.Count,
_spatialRemotes.Count,
_spatialProjectiles.Count,
setPosition.ActiveOperationCount,
setPosition.AwaitingPreparationCount,
setPosition.DeferredCellCount,
setPosition.PendingProjectionAcknowledgementCount,
setPosition.LostDeadlineCount,
setPosition.LostDeadlineNodeCount,
setPosition.LostDeadlineIndexCount,
setPosition.ExpiredLostCellCount,
setPosition.ExpiredLostCellIndexCount,
setPosition.DeferredBucketCount,
setPosition.DeferredBucketOrderCount,
setPosition.UnboundDeferredCellCount,
setPosition.UnboundDeferredCellOrderCount,
setPosition.PreparedMoverCount,
_collisionAdmissions.Count,
_collisionGenerations.Count,
ReferenceEquals(Engine.DataCache, DataCache),
_disposed);
}
public void AcknowledgeSpatialProjection(
RuntimeEntityRecord record,
@ -1806,6 +1865,20 @@ public sealed class RuntimePhysicsState : IDisposable
EnsureNotDisposed();
EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId);
if (_collisionAdmissions.Remove(
canonical,
out RuntimeCollisionAdmission? superseded))
{
SetPosition.CancelCollisionGeneration(
canonical,
superseded.Generation);
if (_preparedCollisionGenerations.Remove(
canonical,
out PreparedLandblockCollisionGeneration? prepared))
{
prepared.Dispose();
}
}
ulong generation = _collisionGenerations.TryGetValue(
canonical,
out ulong current)
@ -1817,6 +1890,7 @@ public sealed class RuntimePhysicsState : IDisposable
canonical,
generation);
_collisionAdmissions[canonical] = admission;
SetPosition.BeginCollisionGeneration(canonical, generation);
return admission;
}
@ -1901,6 +1975,9 @@ public sealed class RuntimePhysicsState : IDisposable
&& ReferenceEquals(current, admission))
{
_collisionAdmissions.Remove(admission.LandblockId);
SetPosition.CancelCollisionGeneration(
admission.LandblockId,
admission.Generation);
_collisionGenerations[admission.LandblockId] = checked(
admission.Generation + 1UL);
}
@ -2137,6 +2214,10 @@ public sealed class RuntimePhysicsState : IDisposable
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: Engine.IsLandblockTerrainResident(admission.LandblockId));
SetPosition.CommitCollisionGeneration(
acknowledgement.LandblockId,
acknowledgement.Generation,
acknowledgement.Ready);
PublishCollisionGenerationCommitted(
new RuntimeCollisionGenerationCommitted(
acknowledgement.LandblockId,
@ -2221,6 +2302,7 @@ public sealed class RuntimePhysicsState : IDisposable
prepared.Dispose();
}
_preparedCollisionGenerations.Clear();
SetPosition.Dispose();
_collisionOwnerJournal.Clear();
_collisionOwnerSubscribers.Clear();
Engine.Clear();
@ -2380,12 +2462,22 @@ public sealed class RuntimePhysicsState : IDisposable
private void InvalidateCollisionAdmission(uint landblockId)
{
ulong generation = _collisionGenerations.TryGetValue(
ulong currentGeneration = _collisionGenerations.TryGetValue(
landblockId,
out ulong current)
? checked(current + 1UL)
: 1UL;
? current
: 0UL;
ulong invalidatedGeneration = _collisionAdmissions.TryGetValue(
landblockId,
out RuntimeCollisionAdmission? admission)
? admission.Generation
: checked(currentGeneration + 1UL);
ulong generation = checked(
Math.Max(currentGeneration, invalidatedGeneration) + 1UL);
_collisionGenerations[landblockId] = generation;
SetPosition.CancelCollisionGeneration(
landblockId,
invalidatedGeneration);
_collisionAdmissions.Remove(landblockId);
if (_preparedCollisionGenerations.Remove(
landblockId,
@ -2396,6 +2488,70 @@ public sealed class RuntimePhysicsState : IDisposable
TrimCollisionOwnerJournal();
}
internal ulong ExpectedCollisionGeneration(uint exactCellId)
{
uint landblockId = CanonicalLandblock(exactCellId);
if (landblockId == 0u)
return 0UL;
if (_collisionAdmissions.TryGetValue(
landblockId,
out RuntimeCollisionAdmission? admission))
{
return admission.Generation;
}
return _collisionGenerations.TryGetValue(
landblockId,
out ulong generation)
? checked(generation + 1UL)
: 1UL;
}
internal bool HandleSetPositionCollisions(
RuntimeEntityRecord record,
ulong positionAuthorityVersion,
ulong spatialAuthorityVersion,
ulong velocityAuthorityVersion,
bool previousContact,
bool previousOnWalkable,
in PhysicsSetPositionCollisionReport report)
{
if (!Entities.IsCurrent(record)
|| record.PositionAuthorityVersion != positionAuthorityVersion
|| record.SpatialAuthorityVersion != spatialAuthorityVersion
|| (velocityAuthorityVersion != 0UL
&& record.VelocityAuthorityVersion
!= velocityAuthorityVersion)
|| record.PhysicsBody is not { } body)
{
return false;
}
body.FramesStationaryFall = report.FramesStationaryFall;
PhysicsObjUpdate.HandleAllCollisions(
body,
report.CollisionNormalValid,
report.CollisionNormal,
previousContact,
previousOnWalkable,
body.OnWalkable);
body.TransientState &= ~(TransientStateFlags.StationaryFall
| TransientStateFlags.StationaryStop
| TransientStateFlags.StationaryStuck);
body.TransientState |= report.FramesStationaryFall switch
{
1 => TransientStateFlags.StationaryFall,
2 => TransientStateFlags.StationaryStop,
3 => TransientStateFlags.StationaryStuck,
_ => TransientStateFlags.None,
};
// Retail returns the result of collision reporting, not a collision-
// presence guess. Runtime does not yet own the per-object report/
// tracking table required to reproduce that return value, so fail
// closed. This preserves ordinary placement rejection and leaves the
// already-registered reporting seam explicit for the 4B2 cutover.
return false;
}
private void OnCollisionOwnerMutated(uint ownerId, ulong version)
{
_ = version;

File diff suppressed because it is too large Load diff