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 7adf6bc0..4a3bf91a 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 @@ -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. | | G0–G5 | 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 diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs index 0c2601b2..a1538c54 100644 --- a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs +++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs @@ -211,6 +211,8 @@ internal sealed class CurrentRenderSceneOracle : new(ReferenceEqualityComparer.Instance); private readonly List _selectionParts = []; + private readonly Dictionary + _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) diff --git a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs index f30af866..12d2b4f6 100644 --- a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs @@ -25,6 +25,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink private readonly LiveEntityRuntime _runtime; private readonly RenderProjectionJournal _journal; private readonly Dictionary _byLocalId = []; + private readonly List _activeRootScratch = []; private readonly List _activeScratch = []; public LiveRenderProjectionJournal( @@ -107,10 +108,34 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink /// 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(); } diff --git a/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs b/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs index 9e4f98c0..abdf712c 100644 --- a/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs index 62435b46..2c4e47db 100644 --- a/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CurrentRenderSceneOracleTests.cs @@ -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, diff --git a/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs index 5c4c1e76..9e85d63d 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs @@ -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(); diff --git a/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs index 704462d2..622ece6c 100644 --- a/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs @@ -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) =>