feat(app): stage live entities before runtime placement

This commit is contained in:
Erik 2026-08-01 18:43:23 +02:00
parent 3f800a4aec
commit 74103f75b5
2 changed files with 187 additions and 3 deletions

View file

@ -30,6 +30,18 @@ public enum LiveEntityProjectionKind
Attached,
}
/// <summary>
/// Selects whether App may publish a newly constructed sidecar immediately or
/// must keep it non-spatial until the canonical Runtime SetPosition receipt is
/// projected. The latter is the production CreateObject path; the former is a
/// temporary compatibility seam for callers not yet cut over in Slice 4B2.
/// </summary>
internal enum LiveEntityMaterializationResidence
{
LegacyImmediate,
AwaitRuntimePlacement,
}
/// <summary>
/// Logical-resource seam coordinated by <see cref="LiveEntityRuntime"/>.
/// Spatial bucketing is deliberately absent: registering or removing meshes,
@ -249,6 +261,8 @@ public sealed class LiveEntityRecord
public IRuntimeProjectile? ProjectileRuntime => Canonical.Projectile;
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
public bool ResourcesRegistered { get; internal set; }
internal LiveEntityMaterializationResidence? MaterializationResidence
{ get; set; }
public bool IsSpatiallyProjected { get; internal set; }
public bool IsSpatiallyVisible { get; internal set; }
internal ulong ProjectionMutationVersion { get; set; }
@ -578,10 +592,20 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
Func<uint, WorldEntity> factory,
LiveEntityProjectionKind projectionKind,
Action<LiveEntityRecord>? initializeProjection,
out LiveEntityRecord? record)
out LiveEntityRecord? record,
LiveEntityMaterializationResidence residence =
LiveEntityMaterializationResidence.LegacyImmediate)
{
ArgumentNullException.ThrowIfNull(expectedCanonical);
ArgumentNullException.ThrowIfNull(factory);
if (residence is LiveEntityMaterializationResidence
.AwaitRuntimePlacement
&& projectionKind is not LiveEntityProjectionKind.World)
{
throw new ArgumentException(
"Only a top-level world projection can await canonical Runtime placement.",
nameof(projectionKind));
}
record = null;
if (_isClearing
|| _sessionClearPendingFinalization
@ -605,6 +629,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{
record = _projections.AddMaterializing(expectedCanonical);
createdSidecar = true;
record.MaterializationResidence = residence;
initializeProjection?.Invoke(record);
}
catch
@ -618,6 +643,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
throw;
}
}
else if (record.MaterializationResidence != residence)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8}/{expectedCanonical.Incarnation} "
+ $"cannot change its materialization residence from "
+ $"{record.MaterializationResidence} to {residence}.");
}
if (record.WorldEntity is null)
{
@ -653,6 +685,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
}
record.WorldEntity = entity;
// Runtime-placement residence is sticky for this incarnation and
// must suppress draw before arbitrary resource callbacks observe
// or re-enter the sidecar.
RefreshPresentation(record);
_isRegisteringResources = true;
try
{
@ -726,6 +762,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
}
RefreshPresentation(record);
WorldEntity materialized = record.WorldEntity!;
if (residence is LiveEntityMaterializationResidence
.AwaitRuntimePlacement)
{
// Resource construction is a logical App-side ownership edge, not
// world residence. Runtime's immutable Place receipt is the only
// path which may install the accepted frame and spatial bucket.
record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false;
RefreshSpatialPresentationIndexes(record);
RefreshPresentation(record);
return materialized;
}
if (!RebucketLiveEntity(serverGuid, fullCellId)
|| !_projections.TryGet(expectedCanonical, out LiveEntityRecord? current)
|| !ReferenceEquals(current, record)
@ -746,6 +794,14 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is not { } entity)
return false;
if (record.MaterializationResidence is
LiveEntityMaterializationResidence.AwaitRuntimePlacement)
{
// The private Runtime Place path below performs a presentation-
// only bucket update. This legacy API also commits canonical
// Runtime residence and cannot touch a cut-over incarnation.
return false;
}
RuntimeEntityKey key = RequireProjectionKey(record);
bool wasProjected = record.IsSpatiallyProjected;
@ -3031,8 +3087,11 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return;
PhysicsStateFlags state = record.FinalPhysicsState;
entity.IsDrawVisible =
(state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0;
bool residenceVisible = record.MaterializationResidence is not
LiveEntityMaterializationResidence.AwaitRuntimePlacement
|| record.IsSpatiallyProjected;
entity.IsDrawVisible = residenceVisible
&& (state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0;
bool interactionVisible = record.IsSpatiallyVisible
&& record.ProjectionKind is LiveEntityProjectionKind.World

View file

@ -202,6 +202,131 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(new[] { false, true }, visibilityEdges);
}
[Fact]
public void RuntimePlacementPendingMaterialization_OwnsResourcesWithoutPublishingResidence()
{
const uint guid = 0x70000081u;
const uint cell = 0x01010001u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new CallbackResources();
LiveEntityRuntime runtime = LiveEntityRuntimeFixture.Create(
spatial,
resources);
LiveEntityRegistrationResult registration = runtime.RegisterLiveEntity(
Spawn(guid, instance: 1, positionSequence: 1, cell));
RuntimeEntityRecord canonical = Assert.IsType<RuntimeEntityRecord>(
registration.Canonical);
uint acceptedCell = canonical.FullCellId;
ulong spatialAuthority = canonical.SpatialAuthorityVersion;
ulong placementCommit = canonical.PlacementCommitVersion;
ulong clockEpoch = canonical.ObjectClockEpoch;
bool clockActive = canonical.ObjectClock.IsActive;
RuntimePhysicsOwnershipSnapshot physicsOwnership =
runtime.Physics.CaptureOwnership();
var visibilityEdges = new List<bool>();
runtime.ProjectionVisibilityChanged += (_, visible) =>
visibilityEdges.Add(visible);
resources.OnRegister = entity =>
{
Assert.False(entity.IsDrawVisible);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.False(current.IsSpatiallyProjected);
Assert.False(current.IsSpatiallyVisible);
Assert.False(runtime.RebucketLiveEntity(guid, cell));
};
WorldEntity entity = Assert.IsType<WorldEntity>(
runtime.MaterializeLiveEntity(
canonical,
cell,
id => Entity(id, guid),
LiveEntityProjectionKind.World,
initializeProjection: null,
out LiveEntityRecord? record,
LiveEntityMaterializationResidence.AwaitRuntimePlacement));
Assert.NotNull(record);
Assert.Same(entity, record!.WorldEntity);
Assert.True(record.ResourcesRegistered);
Assert.False(record.IsSpatiallyProjected);
Assert.False(record.IsSpatiallyVisible);
Assert.False(entity.IsDrawVisible);
Assert.Equal(acceptedCell, canonical.FullCellId);
Assert.Equal(spatialAuthority, canonical.SpatialAuthorityVersion);
Assert.Equal(placementCommit, canonical.PlacementCommitVersion);
Assert.Equal(clockEpoch, canonical.ObjectClockEpoch);
Assert.Equal(clockActive, canonical.ObjectClock.IsActive);
Assert.Equal(physicsOwnership, runtime.Physics.CaptureOwnership());
Assert.Equal(0, runtime.SpatialRootObjectCount);
Assert.Equal(1, resources.RegisterCount);
Assert.Empty(spatial.Entities);
Assert.Empty(runtime.VisibleRecords);
Assert.Empty(visibilityEdges);
WorldEntity retained = Assert.IsType<WorldEntity>(
runtime.MaterializeLiveEntity(
canonical,
cell,
id => Entity(id, guid),
LiveEntityProjectionKind.World,
initializeProjection: null,
out LiveEntityRecord? retainedRecord,
LiveEntityMaterializationResidence.AwaitRuntimePlacement));
Assert.Same(entity, retained);
Assert.Same(record, retainedRecord);
Assert.Equal(1, resources.RegisterCount);
Assert.False(runtime.RebucketLiveEntity(guid, cell));
Assert.Throws<InvalidOperationException>(() =>
runtime.MaterializeLiveEntity(
canonical,
cell,
id => Entity(id, guid),
LiveEntityProjectionKind.World,
initializeProjection: null,
out _,
LiveEntityMaterializationResidence.LegacyImmediate));
Assert.Empty(spatial.Entities);
}
[Fact]
public void LegacyMaterialization_CannotSwitchToRuntimePlacementResidence()
{
const uint guid = 0x70000082u;
const uint cell = 0x01010001u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new RecordingResources();
var runtime = LiveEntityRuntimeFixture.Create(spatial, resources);
RuntimeEntityRecord canonical = Assert.IsType<RuntimeEntityRecord>(
runtime.RegisterLiveEntity(
Spawn(guid, instance: 1, positionSequence: 1, cell)).Canonical);
WorldEntity entity = Assert.IsType<WorldEntity>(
runtime.MaterializeLiveEntity(
canonical,
cell,
id => Entity(id, guid),
LiveEntityProjectionKind.World,
initializeProjection: null,
out LiveEntityRecord? record));
Assert.Throws<InvalidOperationException>(() =>
runtime.MaterializeLiveEntity(
canonical,
cell,
id => Entity(id, guid),
LiveEntityProjectionKind.World,
initializeProjection: null,
out _,
LiveEntityMaterializationResidence.AwaitRuntimePlacement));
Assert.True(record!.IsSpatiallyProjected);
Assert.True(record.IsSpatiallyVisible);
Assert.True(entity.IsDrawVisible);
Assert.Single(spatial.Entities);
Assert.Equal(1, resources.RegisterCount);
}
[Fact]
public void StaticRootCommit_HiddenObjectUpdatesPoseWithoutRestoringShadow()
{