diff --git a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md index cff746cf..b8effed6 100644 --- a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md +++ b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md @@ -460,6 +460,25 @@ destination pose, while passive synchronization still cannot resurrect a removed attachment. The exact corrected commit must repeat the capped route before uncapped and dense-town gates. +The exact `bb1f4a64` capped route showed that destination-pose recovery covers +attachments which are recomposed, but Sawato's retained population has seven +children whose accepted relation is not replayed on revisit. The first missing +identity changed with that population (`projection:03000000000F4286`); +baseline had 43 roots + 8 children and revisit had the same 43 roots but only +1 retained child. Existing rendering still presents those children through +the rehydrated spatial entity set. + +The lifecycle correction is to distinguish spatial withdrawal from logical +unregistration. `ProjectionRemoved` means an equipped child left cell +presentation; it now retains the exact scene identity with `Draw` inactive. +The existing projection-visibility edge can reactivate that record on +same-location rehydration, and a destination pose can still refresh it. +`OnResourceUnregister` remains the only logical teardown edge. Tests now pin +inactive retention, passive non-reactivation, exact reactivation, duplicate +removal, and final logical teardown. Missing-projection diagnostics also carry +the expected class, server GUID, local ID, and parent cell for any further +failure. Repeat capped F5 on the exact corrective commit before advancing. + ## 6. Slice G — Frame product and production cutover ### G0 — Borrowed double-buffered frame product diff --git a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs index 0b6ff53a..cbdb2639 100644 --- a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs @@ -91,7 +91,21 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink && tracked.Projection.ProjectionClass is RenderProjectionClass.EquippedChild) { - Remove(tracked); + if (_runtime.IsCurrentRecord(tracked.Record) + && ReferenceEquals( + tracked.Record.WorldEntity, + tracked.Entity)) + { + // Leaving cell presentation is not logical destruction. Keep + // the projection identity inactive so a later residency or + // destination-pose edge can reactivate the same attachment + // without replaying EntityReady. + Upsert(tracked.Record, tracked.Entity, spatiallyVisible: false); + } + else + { + Remove(tracked); + } } } diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs index 5f21f5b1..11a8b71f 100644 --- a/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs +++ b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs @@ -323,7 +323,11 @@ internal sealed class RenderSceneShadowComparisonController : if (!query.TryGet(id, out RenderProjectionRecord actual)) { return $"sourceChannel={Channel(id)} id={id} field=presence " - + "expected=present actual=missing"; + + "expected=present actual=missing " + + $"expectedClass={fingerprint.ProjectionClass} " + + $"serverGuid=0x{fingerprint.ServerGuid:X8} " + + $"localEntityId=0x{fingerprint.EntityId:X8} " + + $"parentCell=0x{fingerprint.ParentCellId:X8}"; } string? mismatch = CompareProjection( diff --git a/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs index 871ffc9b..6a7389f4 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs @@ -140,13 +140,14 @@ public sealed class LiveRenderProjectionJournalTests Assert.Equal( incarnation, harness.Journal.Pending[0].Record.OwnerIncarnation); + Assert.True(harness.Runtime.WithdrawLiveEntityProjection(Guid)); harness.Projections.OnProjectionRemoved(record.WorldEntity.Id); harness.Projections.OnProjectionRemoved(record.WorldEntity.Id); Assert.Equal(2, harness.Journal.Count); Assert.Equal( - RenderProjectionDeltaKind.Unregister, + RenderProjectionDeltaKind.UpdateFlags, harness.Journal.Pending[1].Kind); - Assert.Equal(0, harness.Projections.ProjectionCount); + Assert.Equal(1, harness.Projections.ProjectionCount); } [Fact] @@ -266,7 +267,7 @@ public sealed class LiveRenderProjectionJournalTests } [Fact] - public void SynchronizeActiveSources_DoesNotResurrectRemovedAttachment() + public void SynchronizeActiveSources_DoesNotReactivateWithdrawnAttachment() { Harness harness = CreateHarness(loadLandblock: true); LiveEntityRecord record = Materialize( @@ -277,13 +278,18 @@ public sealed class LiveRenderProjectionJournalTests Assert.True(harness.Projections.OnEntityReady( LiveEntityReadyCandidate.Capture(record))); harness.Journal.DrainTo(harness.Scene); + Assert.True(harness.Runtime.WithdrawLiveEntityProjection(Guid)); harness.Projections.OnProjectionRemoved(record.WorldEntity!.Id); harness.Journal.DrainTo(harness.Scene); harness.Projections.SynchronizeActiveSources(); Assert.Equal(0, harness.Journal.Count); - Assert.Equal(0, harness.Projections.ProjectionCount); + Assert.Equal(1, harness.Projections.ProjectionCount); + Assert.True(harness.Scene.OpenQuery().TryGet( + LiveRenderProjectionJournal.ProjectionId(record.WorldEntity!.Id), + out RenderProjectionRecord inactive)); + Assert.True((inactive.Flags & RenderProjectionFlags.Draw) == 0); } [Fact] @@ -298,20 +304,46 @@ public sealed class LiveRenderProjectionJournalTests Assert.True(harness.Projections.OnEntityReady( LiveEntityReadyCandidate.Capture(record))); harness.Journal.DrainTo(harness.Scene); + Assert.True(harness.Runtime.WithdrawLiveEntityProjection(Guid)); harness.Projections.OnProjectionRemoved(record.WorldEntity!.Id); harness.Journal.DrainTo(harness.Scene); + Assert.True(harness.Runtime.RebucketLiveEntity(Guid, CellId)); harness.Projections.OnProjectionPoseReady(record.ServerGuid); - RenderProjectionDelta recovered = - Assert.Single(harness.Journal.Pending.ToArray()); - Assert.Equal(RenderProjectionDeltaKind.Register, recovered.Kind); - Assert.Equal( - RenderProjectionClass.EquippedChild, - recovered.Record.ProjectionClass); + RenderProjectionDelta recovered = Assert.Single( + harness.Journal.Pending.ToArray(), + delta => delta.Kind is RenderProjectionDeltaKind.UpdateFlags); Assert.Equal(record.LocalEntityId, recovered.Record.Source.LocalEntityId); } + [Fact] + public void WithdrawnAttachment_LogicalTeardownUnregistersRetainedIdentity() + { + Harness harness = CreateHarness(loadLandblock: true); + const ushort instance = 11; + LiveEntityRecord record = Materialize( + harness, + Guid, + instance, + projectionKind: LiveEntityProjectionKind.Attached); + Assert.True(harness.Projections.OnEntityReady( + LiveEntityReadyCandidate.Capture(record))); + harness.Journal.DrainTo(harness.Scene); + Assert.True(harness.Runtime.WithdrawLiveEntityProjection(Guid)); + harness.Projections.OnProjectionRemoved(record.WorldEntity!.Id); + harness.Journal.DrainTo(harness.Scene); + + Assert.True(harness.Runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(Guid, instance), + isLocalPlayer: false)); + + RenderProjectionDelta removed = + Assert.Single(harness.Journal.Pending.ToArray()); + Assert.Equal(RenderProjectionDeltaKind.Unregister, removed.Kind); + Assert.Equal(0, harness.Projections.ProjectionCount); + } + private static Harness CreateHarness(bool loadLandblock) { var state = new GpuWorldState();