fix(physics): activate collision generations atomically

This commit is contained in:
Erik 2026-07-31 15:19:25 +02:00
parent 3e0f3b6206
commit be94bc9b06
18 changed files with 1402 additions and 80 deletions

View file

@ -119,7 +119,11 @@ public sealed class RuntimePhysicsOwnershipTests
collisionPublisher,
StringComparison.Ordinal);
Assert.Contains(
"AdmitCollisionAssets(",
"StageCollisionAssets(",
collisionPublisher,
StringComparison.Ordinal);
Assert.Contains(
"CommitCollisionGeneration(",
collisionPublisher,
StringComparison.Ordinal);
Assert.DoesNotContain(

View file

@ -88,6 +88,11 @@ public sealed class LandblockPhysicsPublisherTests
Assert.Equal(1, diagnostics.CellSurfaceCount);
Assert.Equal(1, diagnostics.PortalPlaneCount);
Assert.Equal(1, diagnostics.BuildingCount);
Assert.Null(fixture.Cache.CellGraph.GetVisible(envCellId));
Assert.Empty(fixture.Cache.BuildingIds);
fixture.Publisher.CompletePublication(receipt);
Assert.NotNull(fixture.Cache.CellGraph.GetVisible(envCellId));
BuildingPhysics building = Assert.Single(
fixture.Cache.BuildingIds.Select(id => fixture.Cache.GetBuilding(id)!));
@ -502,6 +507,11 @@ public sealed class LandblockPhysicsPublisherTests
LandblockPhysicsPublication secondReceipt = Begin(
fixture.Publisher,
Build(FirstLandblock, [moved]));
ShadowEntry duringPreparation = Assert.Single(
fixture.Engine.ShadowObjects.AllEntriesForDebug());
Assert.Equal(
first.Position + new Vector3(0f, 0f, 0.6f),
duringPreparation.Position);
fixture.Publisher.CompletePublication(secondReceipt);
Assert.Equal(1, fixture.Engine.LandblockCount);
@ -513,6 +523,27 @@ public sealed class LandblockPhysicsPublisherTests
Assert.Equal(2, fixture.Publisher.Diagnostics.CompleteCount);
}
[Fact]
public void CancelledPublicationDisposesOnlyItsPrivateCollisionGeneration()
{
var fixture = Fixture();
Publish(fixture.Publisher, Build(FirstLandblock));
Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock));
LandblockPhysicsPublication pending = Begin(
fixture.Publisher,
Build(AdjacentLandblock));
Assert.False(pending.PreparedGeneration.IsDisposed);
pending.Dispose();
Assert.True(pending.PreparedGeneration.IsDisposed);
Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock));
Assert.False(fixture.Engine.IsLandblockTerrainResident(AdjacentLandblock));
Assert.Throws<ObjectDisposedException>(() =>
fixture.Publisher.CompletePublication(pending));
}
[Fact]
public void NearReapply_RemovesOmittedStaticAcrossSeamAndPreservesNeighborOwner()
{
@ -760,6 +791,17 @@ public sealed class LandblockPhysicsPublisherTests
Assert.DoesNotContain("ShadowObjects.RefloodLandblock", source, StringComparison.Ordinal);
Assert.DoesNotContain("_physicsEngine.DemoteLandblockToTerrain", source, StringComparison.Ordinal);
Assert.DoesNotContain("_physicsEngine.RemoveLandblock", source, StringComparison.Ordinal);
string publisherSource = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Streaming",
"LandblockPhysicsPublisher.cs"));
Assert.DoesNotContain("_physicsDataCache.RemoveCellsForLandblock", publisherSource, StringComparison.Ordinal);
Assert.DoesNotContain("_physicsDataCache.RemoveBuildingsForLandblock", publisherSource, StringComparison.Ordinal);
Assert.DoesNotContain("_physicsEngine.ShadowObjects.Reflood", publisherSource, StringComparison.Ordinal);
Assert.Contains("CommitCollisionGeneration(", publisherSource, StringComparison.Ordinal);
}
private static void Publish(

View file

@ -311,27 +311,28 @@ public sealed class RuntimePhysicsStateTests
first.Physics.BeginCollisionAdmission(0xA9B4FFFFu);
Assert.Throws<InvalidOperationException>(() =>
first.Physics.AdmitCollisionAssets(
admission,
CollisionAssets(0xA9B4FFFFu)));
first.Physics.PrepareCollisionGeneration(admission));
Assert.Throws<InvalidOperationException>(() =>
second.Physics.AdmitCollisionAssets(
newer,
CollisionAssets(0xA9B4FFFFu)));
second.Physics.PrepareCollisionGeneration(newer));
first.Physics.AdmitCollisionAssets(
using PreparedLandblockCollisionGeneration prepared =
first.Physics.PrepareCollisionGeneration(newer);
first.Physics.StageCollisionAssets(
newer,
prepared,
CollisionAssets(0xA9B4FFFFu));
RuntimeCollisionAcknowledgement completed =
first.Physics.CompleteCollisionAdmission(newer);
RuntimeCollisionGenerationCommit commit =
first.Physics.CommitCollisionGeneration(newer, prepared);
RuntimeCollisionAcknowledgement completed = commit.Acknowledgement;
Assert.True(commit.Committed);
Assert.True(completed.WasResident);
Assert.Equal(1, first.Physics.Engine.LandblockCount);
Assert.Equal(
0,
first.Physics.CaptureOwnership().CollisionAdmissionCount);
Assert.Throws<InvalidOperationException>(() =>
first.Physics.CompleteCollisionAdmission(newer));
first.Physics.CommitCollisionGeneration(newer, prepared));
RuntimeCollisionAcknowledgement withdrawn =
first.Physics.WithdrawCollision(0xA9B4FFFFu);
@ -340,6 +341,193 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(0, first.Physics.Engine.LandblockCount);
}
[Fact]
public void CollisionGenerationKeepsPreviousWorldVisibleUntilOneCommitNotification()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
RuntimeCollisionAdmission firstAdmission =
physics.BeginCollisionAdmission(0xA9B4FFFFu);
using (PreparedLandblockCollisionGeneration first =
physics.PrepareCollisionGeneration(firstAdmission))
{
physics.StageCollisionAssets(
firstAdmission,
first,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f));
Assert.True(physics.CommitCollisionGeneration(
firstAdmission,
first).Committed);
}
int notifications = 0;
physics.CollisionGenerationCommitted += _ => notifications++;
RuntimeCollisionAdmission replacementAdmission =
physics.BeginCollisionAdmission(0xA9B4FFFFu);
using PreparedLandblockCollisionGeneration replacement =
physics.PrepareCollisionGeneration(replacementAdmission);
physics.StageCollisionAssets(
replacementAdmission,
replacement,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 25f));
Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f));
Assert.Equal(0, notifications);
RuntimeCollisionGenerationCommit committed =
physics.CommitCollisionGeneration(
replacementAdmission,
replacement);
Assert.True(committed.Committed);
Assert.Equal(25f, physics.Engine.SampleTerrainZ(1f, 1f));
Assert.Equal(1, notifications);
}
[Fact]
public void CollisionGenerationRejectsStaleReplacementWithoutMutatingActiveWorld()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
RuntimeCollisionAdmission stale =
physics.BeginCollisionAdmission(0xA9B4FFFFu);
using PreparedLandblockCollisionGeneration preparedStale =
physics.PrepareCollisionGeneration(stale);
physics.StageCollisionAssets(
stale,
preparedStale,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f));
RuntimeCollisionAdmission current =
physics.BeginCollisionAdmission(0xA9B4FFFFu);
Assert.Throws<InvalidOperationException>(() =>
physics.CommitCollisionGeneration(stale, preparedStale));
Assert.False(physics.Engine.IsLandblockTerrainResident(0xA9B4FFFFu));
using PreparedLandblockCollisionGeneration preparedCurrent =
physics.PrepareCollisionGeneration(current);
physics.StageCollisionAssets(
current,
preparedCurrent,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 20f));
Assert.True(physics.CommitCollisionGeneration(
current,
preparedCurrent).Committed);
Assert.Equal(20f, physics.Engine.SampleTerrainZ(1f, 1f));
}
[Fact]
public void MovingDynamicOwnerDuringStagingMustRefreshBeforeCommit()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
RuntimeCollisionAdmission initialAdmission =
physics.BeginCollisionAdmission(0x0101FFFFu);
using (PreparedLandblockCollisionGeneration initial =
physics.PrepareCollisionGeneration(initialAdmission))
{
physics.StageCollisionAssets(
initialAdmission,
initial,
CollisionAssets(0x0101FFFFu, terrainHeight: 5f));
Assert.True(physics.CommitCollisionGeneration(
initialAdmission,
initial).Committed);
}
physics.Engine.ShadowObjects.Register(
42u,
0x01000001u,
new Vector3(10f, 10f, 0f),
Quaternion.Identity,
0.5f,
0f,
0f,
0x0101FFFFu,
seedCellId: 0x01010001u,
isStatic: false);
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(0x0101FFFFu);
using PreparedLandblockCollisionGeneration prepared =
physics.PrepareCollisionGeneration(admission);
physics.StageCollisionAssets(
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);
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.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);
}
[Fact]
public void SpawnAndDeleteDuringStagingAreBothGenerationGated()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(0x0101FFFFu);
using PreparedLandblockCollisionGeneration prepared =
physics.PrepareCollisionGeneration(admission);
physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(0x0101FFFFu, terrainHeight: 12f));
Assert.Empty(physics.CaptureCollisionDynamicOwners(admission, prepared));
physics.Engine.ShadowObjects.Register(
77u,
0x01000001u,
new Vector3(10f, 10f, 0f),
Quaternion.Identity,
0.5f,
0f,
0f,
0x0101FFFFu,
seedCellId: 0x01010001u,
isStatic: false);
RuntimeCollisionGenerationCommit spawned =
physics.CommitCollisionGeneration(admission, prepared);
Assert.False(spawned.Committed);
Assert.Equal(77u, Assert.Single(spawned.DirtyDynamicOwnerIds));
physics.RefreshCollisionDynamicOwner(admission, prepared, 77u);
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.True(physics.CommitCollisionGeneration(
admission,
prepared).Committed);
Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug());
}
[Fact]
public void TerminalDisposalClearsLandblocksShadowsAndWorksets()
{
@ -352,10 +540,13 @@ public sealed class RuntimePhysicsStateTests
RuntimeCollisionAdmission admission =
lifetime.Physics.BeginCollisionAdmission(0x0101FFFFu);
lifetime.Physics.AdmitCollisionAssets(
using PreparedLandblockCollisionGeneration prepared =
lifetime.Physics.PrepareCollisionGeneration(admission);
lifetime.Physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(0x0101FFFFu));
_ = lifetime.Physics.CompleteCollisionAdmission(admission);
_ = lifetime.Physics.CommitCollisionGeneration(admission, prepared);
lifetime.Physics.Engine.ShadowObjects.Register(
entityId: record.LocalEntityId!.Value,
gfxObjId: 0x01000001u,
@ -740,10 +931,12 @@ public sealed class RuntimePhysicsStateTests
}
private static RuntimeLandblockCollisionAssets CollisionAssets(
uint landblockId)
uint landblockId,
float terrainHeight = 0f)
{
var heights = new byte[81];
var table = new float[256];
table[0] = terrainHeight;
return new RuntimeLandblockCollisionAssets(
landblockId,
new TerrainSurface(heights, table),