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++;