diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs
index 36650ef7..e4080dee 100644
--- a/src/AcDream.App/World/LiveEntityRuntime.cs
+++ b/src/AcDream.App/World/LiveEntityRuntime.cs
@@ -30,6 +30,18 @@ public enum LiveEntityProjectionKind
Attached,
}
+///
+/// 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.
+///
+internal enum LiveEntityMaterializationResidence
+{
+ LegacyImmediate,
+ AwaitRuntimePlacement,
+}
+
///
/// Logical-resource seam coordinated by .
/// 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 factory,
LiveEntityProjectionKind projectionKind,
Action? 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
diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
index e462ad0b..d4704ef4 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
@@ -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(
+ 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();
+ 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(
+ 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(
+ 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(() =>
+ 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(
+ runtime.RegisterLiveEntity(
+ Spawn(guid, instance: 1, positionSequence: 1, cell)).Canonical);
+ WorldEntity entity = Assert.IsType(
+ runtime.MaterializeLiveEntity(
+ canonical,
+ cell,
+ id => Entity(id, guid),
+ LiveEntityProjectionKind.World,
+ initializeProjection: null,
+ out LiveEntityRecord? record));
+
+ Assert.Throws(() =>
+ 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()
{