fix(runtime): preserve hidden setposition collision ownership

This commit is contained in:
Erik 2026-08-01 18:22:45 +02:00
parent 9b0f59bd1b
commit 0fbc7a1fb7
8 changed files with 343 additions and 43 deletions

View file

@ -883,23 +883,17 @@ public sealed class ShadowObjectRegistry
DataCache = DataCache,
};
staging.InstallOwnerState(source);
if (suspendOwner)
{
if (!staging.Suspend(entityId))
return false;
}
else
{
staging.CommitSetPosition(
entityId,
worldPosition,
worldRotation,
seedCellId,
worldOffsetX,
worldOffsetY,
action,
crossCellIds);
}
staging.CommitSetPosition(
entityId,
worldPosition,
worldRotation,
seedCellId,
worldOffsetX,
worldOffsetY,
action,
crossCellIds);
if (suspendOwner && !staging.Suspend(entityId))
return false;
if (!staging.TryCaptureOwnerState(
entityId,
out PreparedShadowOwnerState? replacement)

View file

@ -752,6 +752,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
transition = Entities.ApplyRawPhysicsState(
canonical,
update.PhysicsState);
if (canonical.Key is { } key)
{
Physics.Engine.ShadowObjects.UpdatePhysicsState(
key.LocalEntityId,
(uint)canonical.FinalPhysicsState);
}
ulong stateVersion = canonical.StateAuthorityVersion;
ulong physicsMutationVersion =
canonical.PhysicsStateMutationVersion;

View file

@ -438,6 +438,12 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
bool reported = false;
for (int index = 0; index < receipt.Actions.Length; index++)
{
if (IsExactSetPositionBatchOwnerHidden(receipt))
{
return new(
SetPositionCollisionBatchDispatchStatus.Completed,
reported);
}
if (receipt.Owner.PositionAuthorityVersion
!= receipt.OwnerPositionAuthorityVersion
|| _owners.TryGetValue(
@ -463,6 +469,12 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
action, receipt.PhysicsTime, receipt.BatchId);
}
if (IsExactSetPositionBatchOwnerHidden(receipt))
{
return new(
SetPositionCollisionBatchDispatchStatus.Completed,
reported);
}
if (receipt.Owner.PositionAuthorityVersion
!= receipt.OwnerPositionAuthorityVersion
|| _owners.TryGetValue(
@ -498,6 +510,14 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
reported);
}
private bool IsExactSetPositionBatchOwnerHidden(
in SetPositionCollisionBatchReceipt receipt) =>
_entities.IsCurrent(receipt.Owner)
&& receipt.Owner.PositionAuthorityVersion
== receipt.OwnerPositionAuthorityVersion
&& ReferenceEquals(receipt.Owner.PhysicsBody, receipt.OwnerBody)
&& (receipt.OwnerBody.State & PhysicsStateFlags.Hidden) != 0;
internal bool DiscardSetPositionBatch(
in SetPositionCollisionBatchReceipt receipt) =>
receipt.IsValid

View file

@ -3199,8 +3199,7 @@ public sealed class RuntimePhysicsState : IDisposable
&& record.PositionAuthorityVersion == positionAuthorityVersion
&& record.SpatialAuthorityVersion == spatialAuthorityVersion
&& ReferenceEquals(record.PhysicsBody, body)
&& body.InWorld
&& (body.State & PhysicsStateFlags.Hidden) == 0;
&& body.InWorld;
}
private void OnCollisionOwnerMutated(uint ownerId, ulong version)

View file

@ -392,8 +392,7 @@ internal sealed class RuntimeSetPositionState : IDisposable
body,
placementCommitVersion,
fullCellId,
requireSpatialRoot: false)
&& owner.IsCollisionReportingEligible(record, body);
requireSpatialRoot: false);
}
private readonly RuntimePhysicsState _physics;
@ -3389,21 +3388,21 @@ internal sealed class RuntimeSetPositionState : IDisposable
body,
canonicalCommitVersion,
committedCellId,
requireSpatialRoot: false)
|| !IsCollisionReportingEligible(record, body))
requireSpatialRoot: false))
{
return false;
}
bool reportingCurrent = _physics.HandleSetPositionCollisionReports(
record,
operation.PositionAuthorityVersion,
operation.SpatialAuthorityVersion,
operation.Command.GameTime,
operation.PreviousContact,
operation.PreviousOnWalkable,
collidedWithEnvironment,
collidedObjectIds,
out _);
bool reportingCurrent = !IsCollisionReportingEligible(record, body)
|| _physics.HandleSetPositionCollisionReports(
record,
operation.PositionAuthorityVersion,
operation.SpatialAuthorityVersion,
operation.Command.GameTime,
operation.PreviousContact,
operation.PreviousOnWalkable,
collidedWithEnvironment,
collidedObjectIds,
out _);
if (!reportingCurrent
|| !IsCanonicalPlacementCommitCurrent(
operation,
@ -3411,8 +3410,7 @@ internal sealed class RuntimeSetPositionState : IDisposable
body,
canonicalCommitVersion,
committedCellId,
requireSpatialRoot: false)
|| !IsCollisionReportingEligible(record, body))
requireSpatialRoot: false))
return false;
body.FramesStationaryFall = result.FramesStationaryFall;
if (IsVelocityCurrent(operation))

View file

@ -38,6 +38,33 @@ public sealed class ShadowSetPositionCommitTests
Assert.Single(registry.GetObjectsInCell(Cell9)).Position);
}
[Fact]
public void PreparedSuspendedMoveRetainsNewCanonicalPosition()
{
var registry = RegisteredSingle();
var moved = new Vector3(36f, 12f, 50f);
Assert.True(registry.TryPrepareSetPosition(
1u,
moved,
Quaternion.Identity,
Cell9,
0f,
0f,
PhysicsShadowCommitAction.Replace,
[Cell9],
provenShapeless: false,
suspendOwner: true,
out var prepared));
Assert.Single(registry.GetObjectsInCell(Cell1));
Assert.True(registry.TryApplySetPosition(prepared!, out _));
Assert.Empty(registry.GetObjectsInCell(Cell1));
Assert.Empty(registry.GetObjectsInCell(Cell9));
Assert.Equal(1, registry.SuspendedRegistrationCount);
Assert.Equal(moved, prepared!.OwnerState!.Registration.EntityWorldPos);
Assert.Equal(Cell9, prepared.OwnerState.Registration.SeedCellId);
}
[Fact]
public void PreparedCrossPrefixDispatchesMembershipThenMutationOnce()
{

View file

@ -1883,7 +1883,7 @@ public sealed class RuntimeCollisionReportingStateTests
}
[Fact]
public void HitGroundHiddenTransitionCannotResumeCollisionTracking()
public void HitGroundHiddenTransitionCommitsPlacementWithoutResumingCollisionTracking()
{
using var lifetime = Lifetime();
RuntimeEntityRecord owner = Entity(
@ -1937,11 +1937,20 @@ public sealed class RuntimeCollisionReportingStateTests
PlacementCommand(owner, new Vector3(16f, 12f, 7f)));
Assert.True(hidden);
Assert.Equal(RuntimeSetPositionStatus.Cancelled, outcome.Status);
Assert.Equal(
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
outcome.Status);
Assert.True(lifetime.Physics.SetPosition.TryPeekProjection(
out RuntimePlacementProjectionSnapshot projection));
Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden));
Assert.Equal(Cell, owner.FullCellId);
Assert.Equal(projection.WorldPosition, owner.PhysicsBody!.Position);
Assert.True(owner.PhysicsBody.InWorld);
Assert.Empty(observer.Reports);
Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership()
.TrackedObjectCount);
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
outcome.Projection));
}
[Fact]
@ -2010,7 +2019,7 @@ public sealed class RuntimeCollisionReportingStateTests
}
[Fact]
public void CollisionReportHiddenTransitionStopsResponseAndShadowReflood()
public void CollisionReportHiddenTransitionPreservesCommittedPlacement()
{
using var lifetime = Lifetime();
RuntimeEntityRecord owner = Entity(
@ -2023,7 +2032,6 @@ public sealed class RuntimeCollisionReportingStateTests
RegisterDynamicShadow(lifetime, owner);
RegisterDynamicShadow(lifetime, target);
uint targetId = target.Key!.Value.LocalEntityId;
Vector3 initial = owner.PhysicsBody!.Position;
lifetime.Physics.Engine.TransitionCellCollisionTestHook =
(transition, phase, _, _) =>
{
@ -2062,14 +2070,217 @@ public sealed class RuntimeCollisionReportingStateTests
PlacementCommand(owner, new Vector3(18f, 12f, 7f)));
Assert.True(hidden);
Assert.Equal(RuntimeSetPositionStatus.Cancelled, outcome.Status);
Assert.Equal(
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
outcome.Status);
Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden));
Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership()
.TrackedObjectCount);
ShadowEntry shadow = Assert.Single(
lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(),
Assert.Equal(Cell, owner.FullCellId);
Assert.Equal(new Vector3(18f, 12f, 7f), owner.PhysicsBody!.Position);
ShadowObjectRegistry shadows = lifetime.Physics.Engine.ShadowObjects;
uint ownerId = owner.Key!.Value.LocalEntityId;
ShadowEntry hiddenShadow = Assert.Single(
shadows.AllEntriesForDebug(),
entry => entry.EntityId == ownerId);
Assert.Equal(owner.PhysicsBody.Position, hiddenShadow.Position);
Assert.Equal(0, shadows.SuspendedRegistrationCount);
Assert.True(shadows.TryGetCollisionOwner(
ownerId,
out uint hiddenShadowState,
out _));
Assert.Equal((uint)owner.FinalPhysicsState, hiddenShadowState);
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
outcome.Projection));
Assert.True(lifetime.TryApplyState(
new SetState.Parsed(
owner.ServerGuid,
(uint)PhysicsStateFlags.ReportCollisions,
owner.Incarnation,
StateSequence: 3),
acknowledgeProjection: null,
out _,
out _));
Assert.Equal(0, shadows.SuspendedRegistrationCount);
ShadowEntry restored = Assert.Single(
shadows.AllEntriesForDebug(),
entry => entry.EntityId == ownerId);
Assert.Equal(owner.PhysicsBody.Position, restored.Position);
Assert.Equal((uint)owner.FinalPhysicsState, restored.State);
}
[Fact]
public void PreparedCollisionBatchStopsAfterCallbackHidesOwner()
{
using var lifetime = Lifetime();
RuntimeEntityRecord owner = Entity(
lifetime,
0x7000207Eu,
1,
PhysicsStateFlags.ReportCollisions);
RuntimeEntityRecord first = Entity(
lifetime, 0x7000207Fu, 1, PhysicsStateFlags.None);
RuntimeEntityRecord second = Entity(
lifetime, 0x70002080u, 1, PhysicsStateFlags.None);
RegisterDynamicShadow(lifetime, owner);
RegisterDynamicShadow(lifetime, first);
RegisterDynamicShadow(lifetime, second);
bool hidden = false;
var observer = new CollisionObserver(report =>
{
if (hidden
|| report.Kind is not RuntimeCollisionReportKind.ObjectCollision
|| report.Recipient != owner.Key)
{
return;
}
hidden = lifetime.TryApplyState(
new SetState.Parsed(
owner.ServerGuid,
(uint)(PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Hidden),
owner.Incarnation,
StateSequence: 2),
acknowledgeProjection: null,
out _,
out _);
});
using IDisposable subscription = lifetime.Physics.CollisionReports
.Subscribe(observer);
Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
owner,
owner.PhysicsBody!,
physicsTime: 12d,
previousContact: false,
previousOnWalkable: false,
finalOnWalkable: false,
collidedWithEnvironment: false,
[
first.Key!.Value.LocalEntityId,
second.Key!.Value.LocalEntityId,
],
out var prepared));
Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
prepared!, out var receipt));
SetPositionCollisionBatchDispatchResult result = lifetime.Physics
.CollisionReports.DispatchSetPositionBatchResult(receipt);
Assert.True(hidden);
Assert.Equal(SetPositionCollisionBatchDispatchStatus.Completed,
result.Status);
RuntimeCollisionReport report = Assert.Single(
observer.Reports,
candidate => candidate.Kind
is RuntimeCollisionReportKind.ObjectCollision);
Assert.Equal(first.Key, report.Other);
RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics
.CollisionReports.CaptureOwnership();
Assert.Equal(0, ownership.OwnerCount);
Assert.Equal(0, ownership.TrackedObjectCount);
Assert.Equal(0, ownership.ReversePeerCount);
Assert.Equal(0, ownership.PendingSetPositionDispatchCount);
ShadowObjectRegistry shadows = lifetime.Physics.Engine.ShadowObjects;
Assert.Equal(0, shadows.SuspendedRegistrationCount);
Assert.Contains(
shadows.AllEntriesForDebug(),
entry => entry.EntityId == owner.Key!.Value.LocalEntityId);
Assert.Equal(initial, shadow.Position);
Assert.True(shadows.TryGetCollisionOwner(
owner.Key!.Value.LocalEntityId,
out uint shadowState,
out _));
Assert.Equal((uint)owner.FinalPhysicsState, shadowState);
}
[Fact]
public void NoDrawOwnerStillPublishesItsCollisionReport()
{
using var lifetime = Lifetime();
RuntimeEntityRecord owner = Entity(
lifetime,
0x7000207Au,
1,
PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.NoDraw);
RuntimeEntityRecord target = Entity(
lifetime, 0x7000207Bu, 1, PhysicsStateFlags.None);
RegisterDynamicShadow(lifetime, owner);
RegisterDynamicShadow(lifetime, target);
uint targetId = target.Key!.Value.LocalEntityId;
lifetime.Physics.Engine.TransitionCellCollisionTestHook =
(transition, phase, _, _) =>
{
if (phase is TransitionCellCollisionPhase.Environment)
{
transition.CollisionInfo.CollideObjectGuids.Add(targetId);
transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX);
}
return TransitionState.OK;
};
var observer = new CollisionObserver();
using IDisposable subscription = lifetime.Physics.CollisionReports
.Subscribe(observer);
RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply(
owner,
owner.PositionAuthorityVersion,
PlacementCommand(owner, new Vector3(19f, 12f, 7f)));
Assert.Equal(
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
outcome.Status);
RuntimeCollisionReport report = Assert.Single(
observer.Reports,
report => report.Kind is RuntimeCollisionReportKind.ObjectCollision);
Assert.Equal(owner.Key, report.Recipient);
Assert.Equal(target.Key, report.Other);
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
outcome.Projection));
}
[Fact]
public void NoDrawOwnerStillAllowsReciprocalPeerCollisionReport()
{
using var lifetime = Lifetime();
RuntimeEntityRecord owner = Entity(
lifetime, 0x7000207Cu, 1, PhysicsStateFlags.NoDraw);
RuntimeEntityRecord target = Entity(
lifetime,
0x7000207Du,
1,
PhysicsStateFlags.ReportCollisions);
RegisterDynamicShadow(lifetime, owner);
RegisterDynamicShadow(lifetime, target);
uint targetId = target.Key!.Value.LocalEntityId;
lifetime.Physics.Engine.TransitionCellCollisionTestHook =
(transition, phase, _, _) =>
{
if (phase is TransitionCellCollisionPhase.Environment)
{
transition.CollisionInfo.CollideObjectGuids.Add(targetId);
transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX);
}
return TransitionState.OK;
};
var observer = new CollisionObserver();
using IDisposable subscription = lifetime.Physics.CollisionReports
.Subscribe(observer);
RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply(
owner,
owner.PositionAuthorityVersion,
PlacementCommand(owner, new Vector3(20f, 12f, 7f)));
Assert.Equal(
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
outcome.Status);
RuntimeCollisionReport report = Assert.Single(
observer.Reports,
report => report.Kind is RuntimeCollisionReportKind.ObjectCollision);
Assert.Equal(target.Key, report.Recipient);
Assert.Equal(owner.Key, report.Other);
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
outcome.Projection));
}
[Fact]

View file

@ -113,6 +113,42 @@ public sealed class RuntimeSetPositionStateTests
Assert.Equal(1, ownership.PreparedMoverCount);
}
[Theory]
[InlineData(PhysicsStateFlags.Hidden)]
[InlineData(PhysicsStateFlags.Hidden | PhysicsStateFlags.NoDraw)]
public void HiddenObjectCommitsResidenceWhileSuppressingCollisionReports(
PhysicsStateFlags suppressedState)
{
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
using var lifetime = new RuntimeEntityObjectLifetime(engine);
RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001022u, 1);
PhysicsStateFlags state = PhysicsStateFlags.Gravity | suppressedState;
PhysicsBody body = AttachBody(lifetime, record, SourceCell, state);
var collisionObserver = new CollisionReportObserver();
using IDisposable collisionSubscription = lifetime.Physics
.CollisionReports.Subscribe(collisionObserver);
RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply(
record,
record.PositionAuthorityVersion,
Command(Request(SourceCell, new Vector3(12f, 18f, 7f))));
Assert.Equal(
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
outcome.Status);
Assert.True(body.InWorld);
Assert.Equal(SourceCell, record.FullCellId);
Assert.Equal(new Vector3(12f, 18f, 7f), body.Position);
Assert.True(lifetime.Physics.IsSpatialRoot(record));
Assert.Empty(collisionObserver.Reports);
RuntimeCollisionReportingOwnershipSnapshot reporting = lifetime
.Physics.CollisionReports.CaptureOwnership();
Assert.Equal(0, reporting.OwnerCount);
Assert.Equal(0, reporting.PendingSetPositionDispatchCount);
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
outcome.Projection));
}
[Fact]
public void PublicPlacementChannelObservesRetriesAndAcknowledgesExactToken()
{
@ -2582,6 +2618,15 @@ public sealed class RuntimeSetPositionStateTests
}
}
private sealed class CollisionReportObserver
: IRuntimeCollisionReportObserver
{
internal List<RuntimeCollisionReport> Reports { get; } = [];
public void OnCollisionReport(in RuntimeCollisionReport report) =>
Reports.Add(report);
}
private sealed class EntityObserver : IRuntimeEntityObjectObserver
{
internal List<RuntimeEntityDelta> Deltas { get; } = [];