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

@ -12,6 +12,8 @@
<InternalsVisibleTo Include="AcDream.App" />
<InternalsVisibleTo Include="AcDream.App.Tests" />
<InternalsVisibleTo Include="AcDream.Core.Tests" />
<InternalsVisibleTo Include="acdream-headless" />
<InternalsVisibleTo Include="AcDream.Headless.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />

View file

@ -54,7 +54,7 @@ public sealed class RuntimeCollisionAdmission
}
internal RuntimePhysicsState Owner { get; }
internal bool AssetsAdmitted { get; set; }
internal bool AssetsPrepared { get; set; }
internal bool Completed { get; set; }
public uint LandblockId { get; }
public ulong Generation { get; }
@ -63,7 +63,109 @@ public sealed class RuntimeCollisionAdmission
public readonly record struct RuntimeCollisionAcknowledgement(
uint LandblockId,
ulong Generation,
bool WasResident);
bool WasResident,
bool Ready);
public readonly record struct RuntimeCollisionGenerationCommit(
RuntimeCollisionAcknowledgement Acknowledgement,
uint[] DirtyDynamicOwnerIds)
{
public bool Committed => Acknowledgement.Ready;
}
public readonly record struct RuntimeCollisionGenerationCommitted(
uint LandblockId,
ulong Generation,
bool Ready);
/// <summary>
/// One off-side collision generation. It owns a private cache, cell graph,
/// engine, and shadow registry cloned from the previous complete generation.
/// Hosts may populate it incrementally, but only Runtime can activate it.
/// </summary>
internal sealed class PreparedLandblockCollisionGeneration : IDisposable
{
private readonly RuntimePhysicsState _owner;
private readonly RuntimeCollisionAdmission _admission;
private readonly Dictionary<uint, ulong> _dynamicOwnerVersions = new();
private bool _disposed;
internal PreparedLandblockCollisionGeneration(
RuntimePhysicsState owner,
RuntimeCollisionAdmission admission,
PhysicsDataCache dataCache,
PhysicsEngine engine)
{
_owner = owner;
_admission = admission;
DataCache = dataCache;
Engine = engine;
}
internal PhysicsDataCache DataCache { get; }
internal PhysicsEngine Engine { get; }
internal uint[] GfxObjectIds { get; private set; } = Array.Empty<uint>();
internal uint[] SetupIds { get; private set; } = Array.Empty<uint>();
internal IReadOnlyDictionary<uint, ulong> DynamicOwnerVersions =>
_dynamicOwnerVersions;
internal bool IsDisposed => _disposed;
internal bool Matches(
RuntimePhysicsState owner,
RuntimeCollisionAdmission admission) =>
ReferenceEquals(_owner, owner)
&& ReferenceEquals(_admission, admission);
internal void SetAssetClosure(uint[] gfxObjectIds, uint[] setupIds)
{
EnsureUsable();
GfxObjectIds = gfxObjectIds ?? throw new ArgumentNullException(nameof(gfxObjectIds));
SetupIds = setupIds ?? throw new ArgumentNullException(nameof(setupIds));
}
internal void RefreshDynamicOwner(uint ownerId)
{
EnsureUsable();
bool retained = Engine.ShadowObjects.RefreshDynamicOwnerFrom(
_owner.Engine.ShadowObjects,
ownerId,
_admission.LandblockId,
out ulong version);
if (retained)
_dynamicOwnerVersions[ownerId] = version;
else
_dynamicOwnerVersions.Remove(ownerId);
}
internal uint[] FindDirtyDynamicOwners()
{
EnsureUsable();
return _owner.Engine.ShadowObjects.FindDirtyDynamicOwners(
_admission.LandblockId,
_dynamicOwnerVersions);
}
internal void MarkCommitted()
{
EnsureUsable();
_disposed = true;
}
public void Dispose()
{
if (_disposed)
return;
Engine.Clear();
_dynamicOwnerVersions.Clear();
_disposed = true;
}
private void EnsureUsable()
{
if (_disposed)
throw new ObjectDisposedException(nameof(PreparedLandblockCollisionGeneration));
}
}
/// <summary>
/// Presentation-free mutable physics world for one Runtime/session owner.
@ -83,9 +185,12 @@ public sealed class RuntimePhysicsState : IDisposable
private readonly Dictionary<uint, ulong> _collisionGenerations = new();
private readonly Dictionary<uint, RuntimeCollisionAdmission>
_collisionAdmissions = new();
private int _collisionMutationThreadId;
private bool _disposed;
public event Action<RuntimePhysicsCellCommit>? CellCommitted;
public event Action<RuntimeCollisionGenerationCommitted>?
CollisionGenerationCommitted;
internal RuntimePhysicsState(
RuntimeEntityDirectory entities,
@ -865,6 +970,7 @@ public sealed class RuntimePhysicsState : IDisposable
uint landblockId)
{
EnsureNotDisposed();
EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId);
ulong generation = _collisionGenerations.TryGetValue(
canonical,
@ -880,11 +986,29 @@ public sealed class RuntimePhysicsState : IDisposable
return admission;
}
public void AdmitCollisionAssets(
internal PreparedLandblockCollisionGeneration PrepareCollisionGeneration(
RuntimeCollisionAdmission admission)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
PhysicsDataCache stagingCache = DataCache.CreateCollisionStagingCopy();
PhysicsEngine stagingEngine =
Engine.CreateCollisionStagingCopy(stagingCache);
return new PreparedLandblockCollisionGeneration(
this,
admission,
stagingCache,
stagingEngine);
}
internal void StageCollisionAssets(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared,
RuntimeLandblockCollisionAssets assets)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
ArgumentNullException.ThrowIfNull(assets);
if (CanonicalLandblock(assets.LandblockId)
!= admission.LandblockId)
@ -898,13 +1022,13 @@ public sealed class RuntimePhysicsState : IDisposable
throw new InvalidOperationException(
"A completed collision admission cannot publish more assets.");
}
if (admission.AssetsAdmitted)
if (admission.AssetsPrepared)
{
throw new InvalidOperationException(
"Collision assets were already admitted by this receipt.");
"Collision assets were already prepared by this receipt.");
}
Engine.AddLandblock(
prepared.Engine.AddLandblock(
admission.LandblockId,
assets.Terrain,
assets.CellSurfaces,
@ -914,38 +1038,106 @@ public sealed class RuntimePhysicsState : IDisposable
if ((assets.CurrentCellId & 0xFFFF0000u)
== (admission.LandblockId & 0xFFFF0000u))
{
Engine.UpdatePlayerCurrCell(assets.CurrentCellId);
prepared.Engine.UpdatePlayerCurrCell(assets.CurrentCellId);
}
admission.AssetsAdmitted = true;
admission.AssetsPrepared = true;
}
public RuntimeCollisionAcknowledgement CompleteCollisionAdmission(
RuntimeCollisionAdmission admission)
internal uint[] CaptureCollisionDynamicOwners(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
ValidateAdmission(admission);
if (!admission.AssetsAdmitted)
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
return Engine.ShadowObjects.CaptureDynamicRefloodOwnersForLandblock(
admission.LandblockId);
}
internal void RefreshCollisionDynamicOwner(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared,
uint ownerId)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
prepared.RefreshDynamicOwner(ownerId);
}
internal RuntimeCollisionGenerationCommit CommitCollisionGeneration(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
if (!admission.AssetsPrepared)
{
throw new InvalidOperationException(
"Collision admission cannot complete before its assets publish.");
"Collision generation cannot commit before its assets are prepared.");
}
if (admission.Completed)
{
throw new InvalidOperationException(
"Collision admission has already completed.");
"Collision generation has already completed.");
}
uint[] dirtyOwners = prepared.FindDirtyDynamicOwners();
if (dirtyOwners.Length != 0)
{
return new RuntimeCollisionGenerationCommit(
new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: false),
dirtyOwners);
}
PhysicsEngine.PreparedPhysicsEngineLandblock replacement =
Engine.PrepareLandblockReplacement(
prepared.Engine,
admission.LandblockId,
prepared.GfxObjectIds,
prepared.SetupIds,
prepared.DynamicOwnerVersions);
if (!Engine.ValidateLandblockReplacement(replacement))
{
dirtyOwners = prepared.FindDirtyDynamicOwners();
return new RuntimeCollisionGenerationCommit(
new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: false),
dirtyOwners);
}
Engine.CommitLandblockReplacement(replacement);
admission.Completed = true;
_collisionAdmissions.Remove(admission.LandblockId);
return new RuntimeCollisionAcknowledgement(
prepared.MarkCommitted();
var acknowledgement = new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId));
Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: Engine.IsLandblockTerrainResident(admission.LandblockId));
PublishCollisionGenerationCommitted(
new RuntimeCollisionGenerationCommitted(
acknowledgement.LandblockId,
acknowledgement.Generation,
acknowledgement.Ready));
return new RuntimeCollisionGenerationCommit(
acknowledgement,
Array.Empty<uint>());
}
public RuntimeCollisionAcknowledgement DemoteCollisionToTerrain(
uint landblockId)
{
EnsureNotDisposed();
EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId);
bool resident = Engine.IsLandblockTerrainResident(canonical);
InvalidateCollisionAdmission(canonical);
@ -953,13 +1145,15 @@ public sealed class RuntimePhysicsState : IDisposable
return new RuntimeCollisionAcknowledgement(
canonical,
_collisionGenerations[canonical],
resident);
resident,
Ready: Engine.IsLandblockTerrainResident(canonical));
}
public RuntimeCollisionAcknowledgement WithdrawCollision(
uint landblockId)
{
EnsureNotDisposed();
EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId);
bool resident = Engine.IsLandblockTerrainResident(canonical);
InvalidateCollisionAdmission(canonical);
@ -967,7 +1161,8 @@ public sealed class RuntimePhysicsState : IDisposable
return new RuntimeCollisionAcknowledgement(
canonical,
_collisionGenerations[canonical],
resident);
resident,
Ready: false);
}
public void Dispose()
@ -981,6 +1176,7 @@ public sealed class RuntimePhysicsState : IDisposable
_collisionAdmissions.Clear();
_collisionGenerations.Clear();
CellCommitted = null;
CollisionGenerationCommitted = null;
_disposed = true;
}
@ -1053,6 +1249,20 @@ public sealed class RuntimePhysicsState : IDisposable
private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this);
private void EnsureCollisionMutationThread()
{
int current = Environment.CurrentManagedThreadId;
int owner = Interlocked.CompareExchange(
ref _collisionMutationThreadId,
current,
0);
if (owner != 0 && owner != current)
{
throw new InvalidOperationException(
"Collision generations must be staged and committed on one update thread.");
}
}
private void EnsureCurrent(RuntimeEntityRecord record)
{
if (!Entities.IsCurrent(record))
@ -1081,6 +1291,39 @@ public sealed class RuntimePhysicsState : IDisposable
}
}
private void ValidatePreparedGeneration(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
ArgumentNullException.ThrowIfNull(prepared);
ObjectDisposedException.ThrowIf(prepared.IsDisposed, prepared);
if (!prepared.Matches(this, admission))
{
throw new InvalidOperationException(
"Prepared collision generation is stale or belongs to another admission.");
}
}
private void PublishCollisionGenerationCommitted(
RuntimeCollisionGenerationCommitted committed)
{
Delegate[] observers = CollisionGenerationCommitted?
.GetInvocationList() ?? Array.Empty<Delegate>();
foreach (Delegate observer in observers)
{
try
{
((Action<RuntimeCollisionGenerationCommitted>)observer)(committed);
}
catch (Exception error)
{
System.Diagnostics.Trace.TraceError(
"Collision-generation commit observer failed after activation: {0}",
error);
}
}
}
private void InvalidateCollisionAdmission(uint landblockId)
{
ulong generation = _collisionGenerations.TryGetValue(