fix(physics): seal collision generations before activation

This commit is contained in:
Erik 2026-07-31 15:53:05 +02:00
parent be94bc9b06
commit d94145e6b8
15 changed files with 1556 additions and 410 deletions

View file

@ -976,6 +976,43 @@ public sealed class LandblockPhysicsPublisherTests
new Dictionary<uint, GfxObj>());
}
[Fact]
public void ReloadedSeamAtomicallyRestoresAdjacentStaticAndClearsRepairMarker()
{
var fixture = Fixture();
CacheCylinderSetup(fixture.Cache);
WorldEntity neighbor = CylinderEntity(
0x80AAB401u,
new Vector3(192.25f, 12f, 0f));
Publish(fixture.Publisher, Build(FirstLandblock));
Publish(fixture.Publisher, Build(AdjacentLandblock, [neighbor]));
Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock(
neighbor.Id,
FirstLandblock));
fixture.Publisher.RemoveLandblock(FirstLandblock);
Assert.Equal(1, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount);
Assert.False(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock(
neighbor.Id,
FirstLandblock));
LandblockPhysicsPublication pending = Begin(
fixture.Publisher,
Build(FirstLandblock));
Assert.Equal(1, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount);
fixture.Publisher.CompletePublication(pending);
Assert.Equal(0, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount);
Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock(
neighbor.Id,
FirstLandblock));
Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock(
neighbor.Id,
AdjacentLandblock));
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
}
private static LandblockCollisionBuild FlatCellClosure(
PhysicsDatBundle bundle,
uint envCellId)

View file

@ -12,6 +12,7 @@ using AcDream.Headless.Platform;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
@ -373,6 +374,72 @@ public sealed class HeadlessSessionHostTests
Assert.Equal(2, collision.CenterCount);
}
[Fact]
public void CollisionTransactionCancelsPostAdmissionFaultWithoutWithdrawingActiveWorld()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
const uint landblockId = 0xA9B4FFFFu;
_ = HeadlessCollisionGenerationTransaction.Execute(
physics,
landblockId,
afterAdmission: null,
(admission, prepared) =>
physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(landblockId, 10f)));
Assert.Throws<FixtureCollisionPublicationException>(() =>
HeadlessCollisionGenerationTransaction.Execute(
physics,
landblockId,
_ => throw new FixtureCollisionPublicationException(),
(_, _) => throw new InvalidOperationException(
"Staging must not run after the injected admission fault.")));
Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f));
RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership();
Assert.Equal(1, ownership.LandblockCount);
Assert.Equal(0, ownership.CollisionAdmissionCount);
}
[Fact]
public void CollisionTransactionCancelsStagingFaultWithoutWithdrawingActiveWorld()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
const uint landblockId = 0xA9B4FFFFu;
_ = HeadlessCollisionGenerationTransaction.Execute(
physics,
landblockId,
afterAdmission: null,
(admission, prepared) =>
physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(landblockId, 10f)));
Assert.Throws<FixtureCollisionPublicationException>(() =>
HeadlessCollisionGenerationTransaction.Execute(
physics,
landblockId,
afterAdmission: null,
(admission, prepared) =>
{
physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(landblockId, 25f));
throw new FixtureCollisionPublicationException();
}));
Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f));
RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership();
Assert.Equal(1, ownership.LandblockCount);
Assert.Equal(0, ownership.CollisionAdmissionCount);
}
private static HeadlessSessionDescriptor Descriptor(
HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment,
@ -422,6 +489,25 @@ public sealed class HeadlessSessionHostTests
runtime.MovementOwner.Controller = controller;
}
private static RuntimeLandblockCollisionAssets CollisionAssets(
uint landblockId,
float terrainHeight)
{
var heights = new byte[81];
var table = new float[256];
table[0] = terrainHeight;
return new RuntimeLandblockCollisionAssets(
landblockId,
new TerrainSurface(heights, table),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f,
0u);
}
private sealed class FixtureCollisionPublicationException : Exception;
private static void AddFlatLandblock(PhysicsEngine engine)
{
var heights = new byte[81];

View file

@ -322,7 +322,7 @@ public sealed class RuntimePhysicsStateTests
prepared,
CollisionAssets(0xA9B4FFFFu));
RuntimeCollisionGenerationCommit commit =
first.Physics.CommitCollisionGeneration(newer, prepared);
CommitPrepared(first.Physics, newer, prepared);
RuntimeCollisionAcknowledgement completed = commit.Acknowledgement;
Assert.True(commit.Committed);
@ -332,7 +332,7 @@ public sealed class RuntimePhysicsStateTests
0,
first.Physics.CaptureOwnership().CollisionAdmissionCount);
Assert.Throws<InvalidOperationException>(() =>
first.Physics.CommitCollisionGeneration(newer, prepared));
CommitPrepared(first.Physics, newer, prepared));
RuntimeCollisionAcknowledgement withdrawn =
first.Physics.WithdrawCollision(0xA9B4FFFFu);
@ -355,7 +355,7 @@ public sealed class RuntimePhysicsStateTests
firstAdmission,
first,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f));
Assert.True(physics.CommitCollisionGeneration(
Assert.True(CommitPrepared(physics,
firstAdmission,
first).Committed);
}
@ -375,7 +375,7 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(0, notifications);
RuntimeCollisionGenerationCommit committed =
physics.CommitCollisionGeneration(
CommitPrepared(physics,
replacementAdmission,
replacement);
@ -401,7 +401,11 @@ public sealed class RuntimePhysicsStateTests
RuntimeCollisionAdmission current =
physics.BeginCollisionAdmission(0xA9B4FFFFu);
Assert.Throws<InvalidOperationException>(() =>
physics.CommitCollisionGeneration(stale, preparedStale));
CommitPrepared(physics, stale, preparedStale));
physics.CancelCollisionGeneration(stale, preparedStale);
Assert.Equal(
1,
physics.CaptureOwnership().CollisionAdmissionCount);
Assert.False(physics.Engine.IsLandblockTerrainResident(0xA9B4FFFFu));
using PreparedLandblockCollisionGeneration preparedCurrent =
@ -410,14 +414,14 @@ public sealed class RuntimePhysicsStateTests
current,
preparedCurrent,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 20f));
Assert.True(physics.CommitCollisionGeneration(
Assert.True(CommitPrepared(physics,
current,
preparedCurrent).Committed);
Assert.Equal(20f, physics.Engine.SampleTerrainZ(1f, 1f));
}
[Fact]
public void MovingDynamicOwnerDuringStagingMustRefreshBeforeCommit()
public void WithdrawnOwnerStateChangeRejectsSealUntilRefreshed()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
@ -430,7 +434,7 @@ public sealed class RuntimePhysicsStateTests
initialAdmission,
initial,
CollisionAssets(0x0101FFFFu, terrainHeight: 5f));
Assert.True(physics.CommitCollisionGeneration(
Assert.True(CommitPrepared(physics,
initialAdmission,
initial).Committed);
}
@ -445,6 +449,9 @@ public sealed class RuntimePhysicsStateTests
0x0101FFFFu,
seedCellId: 0x01010001u,
isStatic: false);
physics.Engine.ShadowObjects.RemoveLandblock(0x0101FFFFu);
Assert.Equal(0, physics.Engine.ShadowObjects.TotalRegistered);
Assert.Equal(1, physics.Engine.ShadowObjects.RetainedRegistrationCount);
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(0x0101FFFFu);
@ -454,33 +461,125 @@ public sealed class RuntimePhysicsStateTests
admission,
prepared,
CollisionAssets(0x0101FFFFu, terrainHeight: 15f));
uint owner = Assert.Single(physics.CaptureCollisionDynamicOwners(
admission,
prepared));
physics.RefreshCollisionDynamicOwner(admission, prepared, owner);
physics.Engine.ShadowObjects.UpdatePosition(
owner,
new Vector3(30f, 10f, 0f),
Quaternion.Identity,
0f,
0f,
0x0101FFFFu,
seedCellId: 0x01010009u);
uint owner = Assert.Single(SealPrepared(physics, admission, prepared));
physics.Engine.ShadowObjects.UpdatePhysicsState(owner, 0x14u);
RuntimeCollisionGenerationCommit rejected =
physics.CommitCollisionGeneration(admission, prepared);
Assert.False(rejected.Committed);
Assert.Equal(owner, Assert.Single(rejected.DirtyDynamicOwnerIds));
Assert.Equal(5f, physics.Engine.SampleTerrainZ(1f, 1f));
physics.RefreshCollisionDynamicOwner(admission, prepared, owner);
Assert.Equal(owner, Assert.Single(SealPrepared(
physics,
admission,
prepared)));
Assert.True(physics.CommitCollisionGeneration(
admission,
prepared).Committed);
Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f));
Assert.Contains(
physics.Engine.ShadowObjects.GetObjectsInCell(0x01010009u),
entry => entry.EntityId == owner);
ShadowEntry restored = Assert.Single(
physics.Engine.ShadowObjects.AllEntriesForDebug());
Assert.Equal(owner, restored.EntityId);
Assert.Equal(0x14u, restored.State);
}
[Fact]
public void DenseReplacementSealsOneWorkUnitPerStepAndActivatesWithoutAllocation()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
const uint landblockId = 0x0101FFFFu;
RuntimeCollisionAdmission initialAdmission =
physics.BeginCollisionAdmission(landblockId);
using (PreparedLandblockCollisionGeneration initial =
physics.PrepareCollisionGeneration(initialAdmission))
{
physics.StageCollisionAssets(
initialAdmission,
initial,
CollisionAssets(landblockId, terrainHeight: 5f));
Assert.True(CommitPrepared(
physics,
initialAdmission,
initial).Committed);
}
const int ownerCount = 256;
for (uint index = 0; index < ownerCount; index++)
{
physics.Engine.ShadowObjects.Register(
entityId: 1000u + index,
gfxObjId: 0x01000001u,
worldPos: new Vector3(
8f + (index % 16u) * 0.25f,
8f + (index / 16u) * 0.25f,
0f),
rotation: Quaternion.Identity,
radius: 0.5f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId,
seedCellId: 0x01010001u,
isStatic: false);
}
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(landblockId);
using PreparedLandblockCollisionGeneration prepared =
physics.PrepareCollisionGeneration(admission);
physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(landblockId, terrainHeight: 15f));
int captureSteps = 0;
RuntimeCollisionOwnerCaptureStep capture;
do
{
capture = physics.AdvanceCollisionRetainedOwnerCapture(
admission,
prepared);
captureSteps++;
}
while (!capture.Completed);
Assert.True(captureSteps >= ownerCount);
Assert.Equal(ownerCount, prepared.RetainedOwnerIds.Count);
foreach (uint ownerId in prepared.RetainedOwnerIds)
{
physics.RefreshCollisionRetainedOwner(
admission,
prepared,
ownerId);
}
int sealSteps = 0;
int workUnits = 0;
RuntimeCollisionSealStep seal;
do
{
seal = physics.AdvanceCollisionGenerationSeal(
admission,
prepared);
sealSteps++;
Assert.InRange(seal.WorkUnits, 0, 1);
workUnits += seal.WorkUnits;
}
while (!seal.Completed);
Assert.False(seal.Restarted);
Assert.True(sealSteps > ownerCount);
Assert.True(workUnits > ownerCount);
_ = GC.GetAllocatedBytesForCurrentThread();
long before = GC.GetAllocatedBytesForCurrentThread();
RuntimeCollisionGenerationCommit commit =
physics.CommitCollisionGeneration(admission, prepared);
long allocated =
GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(commit.Committed);
Assert.Equal(0L, allocated);
Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f));
Assert.Equal(ownerCount, physics.Engine.ShadowObjects.TotalRegistered);
}
[Fact]
@ -496,7 +595,7 @@ public sealed class RuntimePhysicsStateTests
admission,
prepared,
CollisionAssets(0x0101FFFFu, terrainHeight: 12f));
Assert.Empty(physics.CaptureCollisionDynamicOwners(admission, prepared));
Assert.Empty(SealPrepared(physics, admission, prepared));
physics.Engine.ShadowObjects.Register(
77u,
@ -512,16 +611,17 @@ public sealed class RuntimePhysicsStateTests
RuntimeCollisionGenerationCommit spawned =
physics.CommitCollisionGeneration(admission, prepared);
Assert.False(spawned.Committed);
Assert.Equal(77u, Assert.Single(spawned.DirtyDynamicOwnerIds));
physics.RefreshCollisionDynamicOwner(admission, prepared, 77u);
Assert.Equal(77u, Assert.Single(SealPrepared(
physics,
admission,
prepared)));
physics.Engine.ShadowObjects.Deregister(77u);
RuntimeCollisionGenerationCommit deleted =
physics.CommitCollisionGeneration(admission, prepared);
Assert.False(deleted.Committed);
Assert.Equal(77u, Assert.Single(deleted.DirtyDynamicOwnerIds));
physics.RefreshCollisionDynamicOwner(admission, prepared, 77u);
Assert.Empty(SealPrepared(physics, admission, prepared));
Assert.True(physics.CommitCollisionGeneration(
admission,
prepared).Committed);
@ -546,7 +646,7 @@ public sealed class RuntimePhysicsStateTests
admission,
prepared,
CollisionAssets(0x0101FFFFu));
_ = lifetime.Physics.CommitCollisionGeneration(admission, prepared);
_ = CommitPrepared(lifetime.Physics, admission, prepared);
lifetime.Physics.Engine.ShadowObjects.Register(
entityId: record.LocalEntityId!.Value,
gfxObjId: 0x01000001u,
@ -930,6 +1030,49 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(record.SpatialAuthorityVersion, observed.SpatialAuthorityVersion);
}
private static RuntimeCollisionGenerationCommit CommitPrepared(
RuntimePhysicsState physics,
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
_ = SealPrepared(physics, admission, prepared);
return physics.CommitCollisionGeneration(admission, prepared);
}
private static uint[] SealPrepared(
RuntimePhysicsState physics,
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
while (true)
{
while (!physics.AdvanceCollisionRetainedOwnerCapture(
admission,
prepared).Completed)
{
}
foreach (uint ownerId in prepared.RetainedOwnerIds)
{
physics.RefreshCollisionRetainedOwner(
admission,
prepared,
ownerId);
}
RuntimeCollisionSealStep seal;
do
{
seal = physics.AdvanceCollisionGenerationSeal(
admission,
prepared);
Assert.InRange(seal.WorkUnits, 0, 1);
}
while (!seal.Completed && !seal.Restarted);
if (seal.Completed)
break;
}
return [.. prepared.RetainedOwnerIds];
}
private static RuntimeLandblockCollisionAssets CollisionAssets(
uint landblockId,
float terrainHeight = 0f)