fix(rendering): reconcile shadow roots from live runtime

Recover ordinary live roots from LiveEntityRuntime's canonical active spatial workset instead of relying on already-retained derived records. Keep attached children callback-authoritative, cache immutable selection geometry fingerprints, and pin zero-allocation retained update paths with regression tests.

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

Co-Authored-By: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-24 22:45:14 +02:00
parent 056bbd4efd
commit 91463db551
7 changed files with 272 additions and 15 deletions

View file

@ -22,7 +22,7 @@ retail-faithful gameplay
| F2 — static projection journal | complete | Static and EnvCell-shell projections now append ordered deltas only after the final activation receipt and exact detach receipt. Same-landblock rehydrate reconciles retained/new/omitted identities; stale Far completions are inert. Seven focused lifecycle tests pass inside the 3,708-App / 8,192-solution Release gate. The scene remains unconstructed and non-drawing in production. |
| F3 — live/equipped projection | complete | Exact EntityReady/resource teardown, visibility, attachment pose/removal, and active-only final-frame seams now drive generation/incarnation-gated live records. Duplicate CreateObject, pending/loaded rebucket, hidden/appearance, reentrancy, session clear, attachment, and GUID-generation replacement pass 11 focused tests. Production still constructs no render scene. |
| F4 — dynamic indices | complete | The contained scene now maintains outdoor/static, per-cell static/dynamic, special dynamic-route, translucent, selectable, light-candidate, and dirty indices incrementally. Final-frame live/equipped synchronization remains active-only, and active animated statics are synchronized from the scheduler's active workset rather than a resident-world scan. The production scene remains unconstructed and non-drawing. |
| F5 | active — connected gate | Lifecycle automation now constructs the otherwise-absent non-drawing scene, drains ordered static/live deltas after post-network spatial reconciliation, compares the accepted current partition every 30 rendered frames and at every canonical checkpoint, retains only the first field-level mismatch, and publishes scene/journal/index/memory evidence in checkpoint JSON. Focused and complete App suites are green; capped/uncapped nine-stop and dense-Arwic physical gates remain. |
| F5 | active — connected redrive | Lifecycle automation now constructs the otherwise-absent non-drawing scene, drains ordered static/live deltas after post-network spatial reconciliation, compares the accepted current partition every 30 rendered frames and at every canonical checkpoint, retains only the first field-level mismatch, and publishes scene/journal/index/memory evidence in checkpoint JSON. The first exact `056bbd4e` capped route passed the harness and graceful-shutdown gates but correctly exposed missing live roots after a same-location Sawato revisit. Root synchronization now recovers ordinary roots from `LiveEntityRuntime`'s canonical active spatial workset while attached children remain callback-authoritative. Immutable selection geometry is fingerprinted once per GfxObj/mesh identity, and retained scene/journal update paths have zero-allocation regression tests. Release is green at 3,733 App tests / 3 skips and 8,217 complete-solution tests / 5 skips; all three connected routes must be redriven before F5 closes. |
| G0G5 | pending | No production consumer has switched. |
The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is
@ -404,6 +404,31 @@ switched. F may temporarily cost memory and a small amount of CPU in diagnostic
mode; those costs are reported separately and shadow comparison is disabled by
default after its gate.
#### F5 connected-gate correction record
The first physical capped nine-stop route on exact commit `056bbd4e` completed
the harness and graceful shutdown, but the shadow referee rejected the route at
the same-location Sawato revisit. The first mismatch was a missing live root
(`sourceChannel=live`, `field=presence`), while journal pending and rejected
delta counts remained zero. This proved the problem was source-workset
reconciliation rather than a drain failure.
The derived live journal had been synchronizing only records it already
retained. Once a root projection was absent, that loop could not recover it.
The correction sources ordinary world roots from the canonical active spatial
workset already owned by `LiveEntityRuntime`. Attached children deliberately
remain callback-authoritative and are refreshed only while retained, preventing
removed equipment from being resurrected.
The same route also showed that the diagnostic referee was re-hashing immutable
selection polygons and vertices every sampled frame. Geometry fingerprints are
now cached by GfxObj and mesh identity, and all retained projection collection
walks use allocation-free indexed access. Permanent tests cover root recovery,
attachment non-resurrection, cached selection geometry, retained transform
updates, and active animated-static synchronization. The capped, uncapped, and
dense-town routes are all rerun from the corrective commit; no F5 success is
claimed from the rejected `056bbd4e` route.
## 6. Slice G — Frame product and production cutover
### G0 — Borrowed double-buffered frame product

View file

@ -211,6 +211,8 @@ internal sealed class CurrentRenderSceneOracle :
new(ReferenceEqualityComparer.Instance);
private readonly List<CurrentRenderSelectionFingerprint>
_selectionParts = [];
private readonly Dictionary<uint, SelectionGeometryCacheEntry>
_selectionGeometryByGfxObj = [];
private ulong _frameSequence;
private int _abortedFrames;
private int _outdoorStaticCount;
@ -637,16 +639,15 @@ internal sealed class CurrentRenderSceneOracle :
}
ArgumentNullException.ThrowIfNull(mesh);
StableRenderHash128 geometry = StableRenderHash128.Create();
geometry.Add(mesh.SphereCenter);
geometry.Add(mesh.SphereRadius);
geometry.Add(mesh.Polygons.Count);
foreach (RetailSelectionPolygon polygon in mesh.Polygons)
if (!_selectionGeometryByGfxObj.TryGetValue(
gfxObjId,
out SelectionGeometryCacheEntry cachedGeometry)
|| !ReferenceEquals(cachedGeometry.Mesh, mesh))
{
geometry.Add(polygon.SingleSided);
geometry.Add(polygon.Vertices.Count);
foreach (Vector3 vertex in polygon.Vertices)
geometry.Add(vertex);
cachedGeometry = new SelectionGeometryCacheEntry(
mesh,
FingerprintSelectionGeometry(mesh));
_selectionGeometryByGfxObj[gfxObjId] = cachedGeometry;
}
var fingerprint = new CurrentRenderSelectionFingerprint(
@ -656,7 +657,7 @@ internal sealed class CurrentRenderSceneOracle :
PartIndex: partIndex,
GfxObjId: gfxObjId,
LocalToWorld: localToWorld,
Geometry: geometry.Finish());
Geometry: cachedGeometry.Fingerprint);
_selectionParts.Add(fingerprint);
_selectionHash.Add(fingerprint.Sequence);
_selectionHash.Add(fingerprint.ServerGuid);
@ -722,8 +723,11 @@ internal sealed class CurrentRenderSceneOracle :
StableRenderHash128 geometry = StableRenderHash128.Create();
geometry.Add(entity.MeshRefs.Count);
foreach (MeshRef mesh in entity.MeshRefs)
for (int meshIndex = 0;
meshIndex < entity.MeshRefs.Count;
meshIndex++)
{
MeshRef mesh = entity.MeshRefs[meshIndex];
geometry.Add(mesh.GfxObjId);
geometry.Add(mesh.PartTransform);
}
@ -734,8 +738,12 @@ internal sealed class CurrentRenderSceneOracle :
appearance.Add(true);
appearance.Add(palette.BasePaletteId);
appearance.Add(palette.SubPalettes.Count);
foreach (PaletteOverride.SubPaletteRange range in palette.SubPalettes)
for (int rangeIndex = 0;
rangeIndex < palette.SubPalettes.Count;
rangeIndex++)
{
PaletteOverride.SubPaletteRange range =
palette.SubPalettes[rangeIndex];
appearance.Add(range.SubPaletteId);
appearance.Add(range.Offset);
appearance.Add(range.Length);
@ -747,8 +755,11 @@ internal sealed class CurrentRenderSceneOracle :
}
appearance.Add(entity.PartOverrides.Count);
foreach (PartOverride part in entity.PartOverrides)
for (int partIndex = 0;
partIndex < entity.PartOverrides.Count;
partIndex++)
{
PartOverride part = entity.PartOverrides[partIndex];
appearance.Add(part.PartIndex);
appearance.Add(part.GfxObjId);
}
@ -784,6 +795,31 @@ internal sealed class CurrentRenderSceneOracle :
Appearance: appearance.Finish());
}
private static RenderSceneHash128 FingerprintSelectionGeometry(
RetailSelectionMesh mesh)
{
StableRenderHash128 geometry = StableRenderHash128.Create();
geometry.Add(mesh.SphereCenter);
geometry.Add(mesh.SphereRadius);
geometry.Add(mesh.Polygons.Count);
for (int polygonIndex = 0;
polygonIndex < mesh.Polygons.Count;
polygonIndex++)
{
RetailSelectionPolygon polygon = mesh.Polygons[polygonIndex];
geometry.Add(polygon.SingleSided);
geometry.Add(polygon.Vertices.Count);
for (int vertexIndex = 0;
vertexIndex < polygon.Vertices.Count;
vertexIndex++)
{
geometry.Add(polygon.Vertices[vertexIndex]);
}
}
return geometry.Finish();
}
private static InteriorEntityPartition.ProjectionClass ProjectionClassOf(
WorldEntity entity) =>
entity.ServerGuid != 0
@ -792,6 +828,10 @@ internal sealed class CurrentRenderSceneOracle :
? InteriorEntityPartition.ProjectionClass.CellStatic
: InteriorEntityPartition.ProjectionClass.OutdoorStatic;
private readonly record struct SelectionGeometryCacheEntry(
RetailSelectionMesh Mesh,
RenderSceneHash128 Fingerprint);
private static void AddPViewCandidate(
ref StableRenderHash128 hash,
in CurrentRenderPViewCandidateFingerprint candidate)

View file

@ -25,6 +25,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
private readonly LiveEntityRuntime _runtime;
private readonly RenderProjectionJournal _journal;
private readonly Dictionary<uint, TrackedProjection> _byLocalId = [];
private readonly List<LiveEntityRecord> _activeRootScratch = [];
private readonly List<TrackedProjection> _activeScratch = [];
public LiveRenderProjectionJournal(
@ -107,10 +108,34 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
/// </summary>
public void SynchronizeActiveSources()
{
// The canonical live-runtime workset is the source of truth for world
// roots. Walking only this journal's already-tracked values would make
// a missed/reordered derived callback permanent and prevent shadow
// comparison from converging after a same-location revisit.
_runtime.CopySpatialRootObjectRecordsTo(_activeRootScratch);
for (int i = 0; i < _activeRootScratch.Count; i++)
{
LiveEntityRecord record = _activeRootScratch[i];
if (!record.ResourcesRegistered
|| record.WorldEntity is not { } entity
|| !_runtime.IsCurrentSpatialRootObject(record))
{
continue;
}
Upsert(record, entity, record.IsSpatiallyVisible);
}
// Attached children are not members of the ordinary root workset.
// Their exact EntityReady/pose/removal callbacks remain authoritative;
// synchronization only refreshes children whose attachment projection
// is still retained by this derived scene.
_activeScratch.Clear();
foreach (TrackedProjection tracked in _byLocalId.Values)
{
if (tracked.Record.IsSpatiallyProjected)
if (tracked.Record.IsSpatiallyProjected
&& tracked.Record.ProjectionKind is
LiveEntityProjectionKind.Attached)
_activeScratch.Add(tracked);
}
@ -137,6 +162,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
public void ResetTracking()
{
_byLocalId.Clear();
_activeRootScratch.Clear();
_activeScratch.Clear();
}

View file

@ -290,6 +290,45 @@ public sealed class ArchRenderSceneTests
Assert.Equal(current.Bounds, stored.Bounds);
}
[Fact]
public void TransformUpdateBatch_ReusesRetainedStorage()
{
const int count = 1_000;
RenderSceneGeneration generation = Generation(11);
using var scene = new ArchRenderScene(generation);
var registrations = new RenderProjectionDelta[count];
var updates = new RenderProjectionDelta[count];
for (int index = 0; index < count; index++)
{
RenderProjectionRecord record = Record(
(ulong)index + 1,
1,
RenderProjectionClass.ActiveAnimatedStatic,
x: index);
registrations[index] = RenderProjectionDelta.Register(
generation,
(ulong)index + 1,
record);
updates[index] = RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateTransform,
generation,
(ulong)count + (ulong)index + 1,
record with
{
Transform = new RenderTransform(
Matrix4x4.CreateTranslation(index + 1, 0, 0)),
});
}
scene.Apply(registrations);
long before = GC.GetAllocatedBytesForCurrentThread();
scene.Apply(updates);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(allocated == 0, $"Allocated {allocated:N0} bytes.");
}
[Fact]
public void Digest_IsStableAcrossRegistrationOrderAndBufferReuse()
{

View file

@ -2,6 +2,7 @@ using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Selection;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
@ -363,6 +364,48 @@ public sealed class CurrentRenderSceneOracleTests
Assert.Empty(oracle.DispatcherCandidates);
}
[Fact]
public void SelectionGeometryFingerprint_IsCachedAndFrameStorageIsReused()
{
const int partCount = 1_000;
var oracle = new CurrentRenderSceneOracle();
var mesh = new RetailSelectionMesh(
Vector3.Zero,
2f,
[new RetailSelectionPolygon(
[
new(-1f, -1f, 0f),
new(1f, -1f, 0f),
new(1f, 1f, 0f),
new(-1f, 1f, 0f),
],
SingleSided: false)]);
PublishSelectionFrame();
long before = GC.GetAllocatedBytesForCurrentThread();
PublishSelectionFrame();
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(partCount, oracle.Snapshot.SelectionPartCount);
Assert.True(allocated == 0, $"Allocated {allocated:N0} bytes.");
void PublishSelectionFrame()
{
oracle.BeginSelectionFrame();
for (int index = 0; index < partCount; index++)
{
oracle.ObserveSelectionPart(
serverGuid: (uint)index + 1,
localEntityId: (uint)index + 1,
partIndex: index,
gfxObjId: 0x0100_0001u,
Matrix4x4.CreateTranslation(index, 0, 0),
mesh);
}
oracle.CompleteSelectionFrame();
}
}
private static CurrentRenderProjectionFingerprint CaptureSingle(
WorldEntity entity,
uint landblockId,

View file

@ -244,6 +244,48 @@ public sealed class LiveRenderProjectionJournalTests
Assert.Same(record, harness.Runtime.Records.Single());
}
[Fact]
public void SynchronizeActiveSources_RecoversCurrentRootFromCanonicalWorkset()
{
Harness harness = CreateHarness(loadLandblock: true);
LiveEntityRecord record = Materialize(harness, Guid, instance: 8);
Assert.True(harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record)));
harness.Journal.DrainTo(harness.Scene);
harness.Projections.ResetTracking();
harness.Projections.SynchronizeActiveSources();
RenderProjectionDelta recovered =
Assert.Single(harness.Journal.Pending.ToArray());
Assert.Equal(RenderProjectionDeltaKind.Register, recovered.Kind);
Assert.Equal(
RenderProjectionClass.LiveDynamicRoot,
recovered.Record.ProjectionClass);
Assert.Equal(record.LocalEntityId, recovered.Record.Source.LocalEntityId);
}
[Fact]
public void SynchronizeActiveSources_DoesNotResurrectRemovedAttachment()
{
Harness harness = CreateHarness(loadLandblock: true);
LiveEntityRecord record = Materialize(
harness,
Guid,
instance: 9,
projectionKind: LiveEntityProjectionKind.Attached);
Assert.True(harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record)));
harness.Journal.DrainTo(harness.Scene);
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);
}
private static Harness CreateHarness(bool loadLandblock)
{
var state = new GpuWorldState();

View file

@ -263,6 +263,48 @@ public sealed class StaticRenderProjectionJournalTests
unchanged.ProjectionClass);
}
[Fact]
public void ActiveAnimatedSynchronization_ReusesRetainedJournalStorage()
{
const int count = 1_000;
RenderSceneGeneration generation = Generation(9);
var journal = new RenderProjectionJournal(generation);
var statics = new StaticRenderProjectionJournal(journal);
var animated = new WorldEntity[count];
for (int index = 0; index < animated.Length; index++)
animated[index] = Entity((uint)index + 1);
LandblockBuild build = Build(
LandblockId,
animated,
includeShell: false);
statics.Reconcile(build, Publication(build));
using var scene = new ArchRenderScene(generation);
journal.DrainTo(scene);
statics.SynchronizeActiveAnimatedSources(animated);
journal.DrainTo(scene);
for (int index = 0; index < animated.Length; index++)
{
animated[index].Rotation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
0.25f);
}
statics.SynchronizeActiveAnimatedSources(animated);
journal.DrainTo(scene);
for (int index = 0; index < animated.Length; index++)
{
animated[index].Rotation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
0.5f);
}
long before = GC.GetAllocatedBytesForCurrentThread();
statics.SynchronizeActiveAnimatedSources(animated);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(count, journal.Count);
Assert.True(allocated == 0, $"Allocated {allocated:N0} bytes.");
}
private static GpuLandblockSpatialPublication Publication(
LandblockBuild build,
bool requiresActivation = true) =>