fix(rendering): retain withdrawn attachment identities

Separate attached-child spatial withdrawal from logical teardown. ProjectionRemoved now deactivates the retained scene identity, allowing residency or destination-pose callbacks to reactivate it; resource unregister remains the sole destruction edge.

Release gate: 3,735 App tests / 3 skips; 8,219 complete-solution tests / 5 skips.

Co-Authored-By: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-24 23:26:30 +02:00
parent bb1f4a64bf
commit 81e2f1a575
4 changed files with 81 additions and 12 deletions

View file

@ -460,6 +460,25 @@ destination pose, while passive synchronization still cannot resurrect a
removed attachment. The exact corrected commit must repeat the capped route removed attachment. The exact corrected commit must repeat the capped route
before uncapped and dense-town gates. 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 ## 6. Slice G — Frame product and production cutover
### G0 — Borrowed double-buffered frame product ### G0 — Borrowed double-buffered frame product

View file

@ -90,10 +90,24 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
out TrackedProjection? tracked) out TrackedProjection? tracked)
&& tracked.Projection.ProjectionClass is && tracked.Projection.ProjectionClass is
RenderProjectionClass.EquippedChild) RenderProjectionClass.EquippedChild)
{
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); Remove(tracked);
} }
} }
}
public void OnResourceUnregister(WorldEntity entity) public void OnResourceUnregister(WorldEntity entity)
{ {

View file

@ -323,7 +323,11 @@ internal sealed class RenderSceneShadowComparisonController :
if (!query.TryGet(id, out RenderProjectionRecord actual)) if (!query.TryGet(id, out RenderProjectionRecord actual))
{ {
return $"sourceChannel={Channel(id)} id={id} field=presence " 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( string? mismatch = CompareProjection(

View file

@ -140,13 +140,14 @@ public sealed class LiveRenderProjectionJournalTests
Assert.Equal( Assert.Equal(
incarnation, incarnation,
harness.Journal.Pending[0].Record.OwnerIncarnation); harness.Journal.Pending[0].Record.OwnerIncarnation);
Assert.True(harness.Runtime.WithdrawLiveEntityProjection(Guid));
harness.Projections.OnProjectionRemoved(record.WorldEntity.Id); harness.Projections.OnProjectionRemoved(record.WorldEntity.Id);
harness.Projections.OnProjectionRemoved(record.WorldEntity.Id); harness.Projections.OnProjectionRemoved(record.WorldEntity.Id);
Assert.Equal(2, harness.Journal.Count); Assert.Equal(2, harness.Journal.Count);
Assert.Equal( Assert.Equal(
RenderProjectionDeltaKind.Unregister, RenderProjectionDeltaKind.UpdateFlags,
harness.Journal.Pending[1].Kind); harness.Journal.Pending[1].Kind);
Assert.Equal(0, harness.Projections.ProjectionCount); Assert.Equal(1, harness.Projections.ProjectionCount);
} }
[Fact] [Fact]
@ -266,7 +267,7 @@ public sealed class LiveRenderProjectionJournalTests
} }
[Fact] [Fact]
public void SynchronizeActiveSources_DoesNotResurrectRemovedAttachment() public void SynchronizeActiveSources_DoesNotReactivateWithdrawnAttachment()
{ {
Harness harness = CreateHarness(loadLandblock: true); Harness harness = CreateHarness(loadLandblock: true);
LiveEntityRecord record = Materialize( LiveEntityRecord record = Materialize(
@ -277,13 +278,18 @@ public sealed class LiveRenderProjectionJournalTests
Assert.True(harness.Projections.OnEntityReady( Assert.True(harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record))); LiveEntityReadyCandidate.Capture(record)));
harness.Journal.DrainTo(harness.Scene); harness.Journal.DrainTo(harness.Scene);
Assert.True(harness.Runtime.WithdrawLiveEntityProjection(Guid));
harness.Projections.OnProjectionRemoved(record.WorldEntity!.Id); harness.Projections.OnProjectionRemoved(record.WorldEntity!.Id);
harness.Journal.DrainTo(harness.Scene); harness.Journal.DrainTo(harness.Scene);
harness.Projections.SynchronizeActiveSources(); harness.Projections.SynchronizeActiveSources();
Assert.Equal(0, harness.Journal.Count); 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] [Fact]
@ -298,20 +304,46 @@ public sealed class LiveRenderProjectionJournalTests
Assert.True(harness.Projections.OnEntityReady( Assert.True(harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record))); LiveEntityReadyCandidate.Capture(record)));
harness.Journal.DrainTo(harness.Scene); harness.Journal.DrainTo(harness.Scene);
Assert.True(harness.Runtime.WithdrawLiveEntityProjection(Guid));
harness.Projections.OnProjectionRemoved(record.WorldEntity!.Id); harness.Projections.OnProjectionRemoved(record.WorldEntity!.Id);
harness.Journal.DrainTo(harness.Scene); harness.Journal.DrainTo(harness.Scene);
Assert.True(harness.Runtime.RebucketLiveEntity(Guid, CellId));
harness.Projections.OnProjectionPoseReady(record.ServerGuid); harness.Projections.OnProjectionPoseReady(record.ServerGuid);
RenderProjectionDelta recovered = RenderProjectionDelta recovered = Assert.Single(
Assert.Single(harness.Journal.Pending.ToArray()); harness.Journal.Pending.ToArray(),
Assert.Equal(RenderProjectionDeltaKind.Register, recovered.Kind); delta => delta.Kind is RenderProjectionDeltaKind.UpdateFlags);
Assert.Equal(
RenderProjectionClass.EquippedChild,
recovered.Record.ProjectionClass);
Assert.Equal(record.LocalEntityId, recovered.Record.Source.LocalEntityId); 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) private static Harness CreateHarness(bool loadLandblock)
{ {
var state = new GpuWorldState(); var state = new GpuWorldState();