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

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

View file

@ -25,6 +25,7 @@ internal sealed class CollisionWorldState
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, List<uint>> SuspendedShadowEntityCells { get; } = new();
internal Dictionary<uint, HashSet<uint>> WithdrawnPrefixesByOwner { get; } = new();
internal Dictionary<uint, IReadOnlyList<ShadowShape>> ShadowEntityShapes { get; } = new();
internal Dictionary<uint, ShadowObjectRegistry.RegistrationRecord>

View file

@ -33,6 +33,8 @@ public sealed class ShadowObjectRegistry
_collisionWorld.Current.ShadowEntityCells; // for deregistration
private HashSet<uint> _suspendedEntities =>
_collisionWorld.Current.SuspendedShadowEntities;
private Dictionary<uint, List<uint>> _suspendedEntityCells =>
_collisionWorld.Current.SuspendedShadowEntityCells;
// 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.
@ -691,6 +693,218 @@ public sealed class ShadowObjectRegistry
reg.State, reg.Flags, seedCellId, reg.IsStatic);
}
/// <summary>
/// Installs the shadow-list suffix of one canonical retail
/// <c>CPhysicsObj::SetPositionInternal</c> commit. The transition has
/// already selected whether retail recalculates, replaces, or preserves
/// the owner's cross-cell list; this method consumes that decision
/// without running a second placement/flood oracle.
/// </summary>
internal void CommitSetPosition(
uint entityId,
Vector3 worldPosition,
Quaternion worldRotation,
uint seedCellId,
float worldOffsetX,
float worldOffsetY,
PhysicsShadowCommitAction action,
System.Collections.Immutable.ImmutableArray<uint> crossCellIds)
{
if (!_entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration))
{
return;
}
switch (action)
{
case PhysicsShadowCommitAction.None:
RefreshPositionRows(
entityId,
registration,
worldPosition,
worldRotation,
seedCellId);
return;
case PhysicsShadowCommitAction.Recalculate:
UpdatePosition(
entityId,
worldPosition,
worldRotation,
worldOffsetX,
worldOffsetY,
landblockId: seedCellId & 0xFFFF0000u,
seedCellId);
return;
case PhysicsShadowCommitAction.Replace:
if (crossCellIds.IsDefaultOrEmpty)
{
RefreshPositionRows(
entityId,
registration,
worldPosition,
worldRotation,
seedCellId);
return;
}
ReplacePositionRows(
entityId,
registration,
worldPosition,
worldRotation,
seedCellId,
crossCellIds);
return;
case PhysicsShadowCommitAction.Preserve:
RefreshPositionRows(
entityId,
registration,
worldPosition,
worldRotation,
seedCellId);
return;
default:
throw new ArgumentOutOfRangeException(nameof(action));
}
}
private void RefreshPositionRows(
uint entityId,
RegistrationRecord registration,
Vector3 worldPosition,
Quaternion worldRotation,
uint seedCellId)
{
if ((_entityToCells.TryGetValue(
entityId,
out List<uint>? retainedCells)
|| _suspendedEntityCells.TryGetValue(
entityId,
out retainedCells))
&& retainedCells.Count != 0)
{
ReplacePositionRows(
entityId,
registration,
worldPosition,
worldRotation,
seedCellId,
retainedCells);
return;
}
_entityReg[entityId] = registration with
{
SeedCellId = seedCellId,
EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation,
};
BumpOwnerVersion(entityId);
}
private void ReplacePositionRows(
uint entityId,
RegistrationRecord registration,
Vector3 worldPosition,
Quaternion worldRotation,
uint seedCellId,
IReadOnlyList<uint> cellIds)
{
if (_entityToCells.TryGetValue(
entityId,
out List<uint>? previousCells))
{
for (int index = 0; index < previousCells.Count; index++)
{
if (_cells.TryGetValue(
previousCells[index],
out List<ShadowEntry>? entries))
{
RemoveOwnerRows(entries, entityId);
}
}
}
_suspendedEntities.Remove(entityId);
_suspendedEntityCells.Remove(entityId);
_entityReg[entityId] = registration with
{
SeedCellId = seedCellId,
EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation,
};
var exactCells = new List<uint>(cellIds.Count);
for (int index = 0; index < cellIds.Count; index++)
{
uint cellId = cellIds[index];
if (cellId == 0u || exactCells.Contains(cellId))
continue;
exactCells.Add(cellId);
}
if (registration.IsMultiPart
&& _entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes))
{
foreach (ShadowShape shape in shapes)
{
Vector3 partWorldPosition = worldPosition
+ Vector3.Transform(shape.LocalPosition, worldRotation);
Quaternion partWorldRotation = worldRotation
* shape.LocalRotation;
var entry = new ShadowEntry(
entityId,
shape.GfxObjId,
partWorldPosition,
partWorldRotation,
shape.Radius,
shape.CollisionType,
shape.CylHeight,
shape.Scale,
registration.State,
registration.Flags,
shape.LocalPosition,
shape.LocalRotation);
for (int index = 0; index < exactCells.Count; index++)
AddEntryToCell(entry, exactCells[index]);
}
}
else
{
var entry = new ShadowEntry(
entityId,
registration.GfxObjId,
worldPosition,
worldRotation,
registration.Radius,
registration.CollisionType,
registration.CylHeight,
registration.Scale,
registration.State,
registration.Flags);
for (int index = 0; index < exactCells.Count; index++)
AddEntryToCell(entry, exactCells[index]);
}
if (exactCells.Count == 0)
_entityToCells.Remove(entityId);
else
_entityToCells[entityId] = exactCells;
if (_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn))
{
for (int index = 0; index < exactCells.Count; index++)
withdrawn.Remove(exactCells[index] & 0xFFFF0000u);
if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId);
}
BumpOwnerVersion(entityId);
}
/// <summary>
/// Removes an entity from every cell collision list while retaining the
/// exact registration and shape payload needed to restore it later.
@ -706,6 +920,7 @@ public sealed class ShadowObjectRegistry
if (_entityToCells.TryGetValue(entityId, out var cellIds))
{
_suspendedEntityCells[entityId] = new List<uint>(cellIds);
foreach (uint cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
@ -924,7 +1139,8 @@ public sealed class ShadowObjectRegistry
bool existed = _entityReg.ContainsKey(entityId)
|| _entityToCells.ContainsKey(entityId)
|| _entityShapes.ContainsKey(entityId)
|| _suspendedEntities.Contains(entityId);
|| _suspendedEntities.Contains(entityId)
|| _suspendedEntityCells.ContainsKey(entityId);
if (_entityToCells.TryGetValue(entityId, out var cellIds))
{
foreach (var cellId in cellIds)
@ -937,6 +1153,7 @@ public sealed class ShadowObjectRegistry
_entityShapes.Remove(entityId);
_entityReg.Remove(entityId);
_suspendedEntities.Remove(entityId);
_suspendedEntityCells.Remove(entityId);
_withdrawnPrefixesByOwner.Remove(entityId);
if (existed && publishMutation)
{
@ -1065,6 +1282,7 @@ public sealed class ShadowObjectRegistry
_entityShapes.Remove(eid);
_entityReg.Remove(eid);
_suspendedEntities.Remove(eid);
_suspendedEntityCells.Remove(eid);
_withdrawnPrefixesByOwner.Remove(eid);
}
}
@ -1367,6 +1585,9 @@ public sealed class ShadowObjectRegistry
return false;
}
_entityToCells.TryGetValue(entityId, out List<uint>? cells);
_suspendedEntityCells.TryGetValue(
entityId,
out List<uint>? suspendedCells);
_entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes);
@ -1394,6 +1615,7 @@ public sealed class ShadowObjectRegistry
cells is null ? null : new List<uint>(cells),
rows,
_suspendedEntities.Contains(entityId),
suspendedCells is null ? null : new List<uint>(suspendedCells),
withdrawn is null ? null : new HashSet<uint>(withdrawn));
return true;
}
@ -1405,6 +1627,8 @@ public sealed class ShadowObjectRegistry
_entityShapes[state.EntityId] = state.Shapes;
if (state.Suspended)
_suspendedEntities.Add(state.EntityId);
if (state.SuspendedCellIds is not null)
_suspendedEntityCells[state.EntityId] = state.SuspendedCellIds;
if (state.WithdrawnPrefixes is not null)
{
_withdrawnPrefixesByOwner[state.EntityId] = state.WithdrawnPrefixes;
@ -1614,6 +1838,7 @@ public sealed class ShadowObjectRegistry
List<uint>? CellIds,
IReadOnlyList<PreparedShadowCellRows> Rows,
bool Suspended,
List<uint>? SuspendedCellIds,
HashSet<uint>? WithdrawnPrefixes);
internal sealed record PreparedShadowCellRows(
@ -1629,6 +1854,7 @@ public sealed class ShadowObjectRegistry
_cells.Clear();
_entityToCells.Clear();
_suspendedEntities.Clear();
_suspendedEntityCells.Clear();
_withdrawnPrefixesByOwner.Clear();
_entityShapes.Clear();
_entityReg.Clear();

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