From 74103f75b50e23e203900786e3e47c379a60a5ee Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 18:43:23 +0200 Subject: [PATCH] feat(app): stage live entities before runtime placement --- src/AcDream.App/World/LiveEntityRuntime.cs | 65 ++++++++- .../World/LiveEntityRuntimeTests.cs | 125 ++++++++++++++++++ 2 files changed, 187 insertions(+), 3 deletions(-) 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() {