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 c8fdc5df..547f7545 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
@@ -23,7 +23,9 @@ retail-faithful gameplay
| 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 | complete | Exact `81e2f1a5` passed capped and uncapped nine-stop routes plus uncapped dense Arwic. Across the final checkpoints the referee completed 1,663 + 1,677 + 81 comparisons with zero mismatches, zero pending deltas, zero rejected operations, exact binary/source identity, and graceful shutdown. Evidence: `docs/research/2026-07-24-slice-f5-render-scene-shadow-gate.md`. |
-| G0–G5 | active | F's non-drawing referee is accepted. No production consumer has switched yet; G0 begins the borrowed double-buffered frame product. |
+| G0 — borrowed frame product | complete | Two reusable arenas publish exact generation/frame-stamped borrowed views for scene candidates, cell ranges, transforms, classification, light sets, selection, PView/clip references, counts, and source digest. Release/reuse invalidates every copied view; abort never publishes; frame N may build while N-1 is borrowed; the warm path allocates zero bytes. Eight focused tests pass inside the 3,743-App / 8,227-solution Release gate. |
+| G1 — scene-query replacement | active | Build the candidate product from the incremental scene and compare it with the current PView partition without changing the draw source. |
+| G2–G5 | pending | No production consumer has switched. |
The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is
non-drawing diagnostic infrastructure and therefore is not a visual-cutover
@@ -525,6 +527,18 @@ Rules:
- abort recycles the incomplete arena without publishing it;
- world generation and frame sequence must match at draw.
+**Completed 2026-07-24.** `RenderFrameExchange` owns exactly two retained
+arenas. A published view is accessible only through a validated borrow token;
+release invalidates every struct copy, and arena reuse advances its epoch.
+Publication requires a source digest from the same scene generation, while
+borrowing requires the exact expected generation and frame sequence. The
+non-published arena alone may be reused, so an aborted build cannot destroy the
+last completed product. PView/clip products remain borrowed references and all
+variable-size payloads use retained arrays. The 1,000-frame warm regression
+allocates zero bytes. The product is deliberately uncomposed until G1 supplies
+scene-query data; G0 therefore changes no production draw behavior and is not a
+visual rollback unit.
+
### G1 — Replace whole-world partition with scene queries
1. Query outdoor statics directly.
diff --git a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs
new file mode 100644
index 00000000..436d13e6
--- /dev/null
+++ b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs
@@ -0,0 +1,670 @@
+using System.Numerics;
+using AcDream.Core.Selection;
+
+namespace AcDream.App.Rendering.Scene;
+
+internal enum RenderFrameBlendClass : byte
+{
+ Opaque,
+ Alpha,
+}
+
+internal readonly record struct RenderFrameCellRange(
+ uint CellId,
+ int PViewOrder,
+ int Offset,
+ int Count);
+
+internal readonly record struct RenderFrameTransformRecord(
+ RenderProjectionId ProjectionId,
+ int PartIndex,
+ Matrix4x4 LocalToWorld);
+
+internal readonly record struct RenderFrameClassificationRecord(
+ RenderProjectionId ProjectionId,
+ int CandidateIndex,
+ int MeshRefIndex,
+ RenderFrameBlendClass BlendClass,
+ float Opacity);
+
+///
+/// The eight fixed-function-era light indices selected for one projected
+/// object. This App-owned value keeps the frame product independent of the
+/// current WorldBuilder dispatcher representation.
+///
+internal readonly record struct RenderFrameObjectLightSet(
+ RenderProjectionId ProjectionId,
+ int L0,
+ int L1,
+ int L2,
+ int L3,
+ int L4,
+ int L5,
+ int L6,
+ int L7);
+
+internal readonly record struct RenderFrameSelectionPart(
+ RenderProjectionId ProjectionId,
+ uint ServerGuid,
+ uint LocalEntityId,
+ int PartIndex,
+ uint GfxObjId,
+ Matrix4x4 LocalToWorld,
+ RetailSelectionMesh Mesh);
+
+internal readonly record struct RenderFrameDiagnosticCounts(
+ int OutdoorStaticCandidates,
+ int CellStaticCandidates,
+ int DynamicCandidates,
+ int TransformCount,
+ int OpaqueClassificationCount,
+ int AlphaClassificationCount,
+ int LightSetCount,
+ int SelectionPartCount);
+
+///
+/// A validated borrowed view over one reusable frame arena. Copies are safe:
+/// releasing any copy invalidates every copy through the arena's borrow token.
+/// Consumers must not retain this value past Release.
+///
+internal readonly struct RenderFrameView
+{
+ private readonly RenderFrameArena? _arena;
+ private readonly ulong _arenaEpoch;
+ private readonly ulong _borrowToken;
+
+ internal RenderFrameView(
+ RenderFrameArena arena,
+ ulong arenaEpoch,
+ ulong borrowToken)
+ {
+ _arena = arena;
+ _arenaEpoch = arenaEpoch;
+ _borrowToken = borrowToken;
+ }
+
+ public RenderSceneGeneration Generation
+ {
+ get
+ {
+ RenderFrameArena arena = Arena;
+ return arena.Generation;
+ }
+ }
+
+ public ulong FrameSequence
+ {
+ get
+ {
+ RenderFrameArena arena = Arena;
+ return arena.FrameSequence;
+ }
+ }
+
+ public ReadOnlySpan OutdoorStaticCandidates =>
+ Arena.OutdoorStaticCandidates;
+
+ public ReadOnlySpan CellStaticCandidates =>
+ Arena.CellStaticCandidates;
+
+ public ReadOnlySpan CellRanges =>
+ Arena.CellRanges;
+
+ public ReadOnlySpan DynamicCandidates =>
+ Arena.DynamicCandidates;
+
+ public ReadOnlySpan Transforms =>
+ Arena.Transforms;
+
+ public ReadOnlySpan Classifications =>
+ Arena.Classifications;
+
+ public ReadOnlySpan LightSets =>
+ Arena.LightSets;
+
+ public ReadOnlySpan SelectionParts =>
+ Arena.SelectionParts;
+
+ public PortalVisibilityFrame? PortalFrame => Arena.PortalFrame;
+
+ public ClipFrameAssembly? ClipAssembly => Arena.ClipAssembly;
+
+ public RenderFrameDiagnosticCounts DiagnosticCounts =>
+ Arena.DiagnosticCounts;
+
+ public RenderSceneDigest SourceDigest => Arena.SourceDigest;
+
+ internal RenderFrameArena Owner =>
+ Arena;
+
+ internal ulong ArenaEpoch => _arenaEpoch;
+
+ internal ulong BorrowToken => _borrowToken;
+
+ private RenderFrameArena Arena
+ {
+ get
+ {
+ RenderFrameArena? arena = _arena;
+ if (arena is null
+ || !arena.IsBorrowValid(_arenaEpoch, _borrowToken))
+ {
+ throw new InvalidOperationException(
+ "The render-frame view is no longer borrowed from its arena.");
+ }
+
+ return arena;
+ }
+ }
+}
+
+///
+/// One side of the double-buffered render-frame product. Arrays grow only
+/// when a new high-water mark is reached and are reused on every later frame.
+///
+internal sealed class RenderFrameArena
+{
+ private RenderProjectionRecord[] _outdoor = [];
+ private RenderProjectionRecord[] _cellStatics = [];
+ private RenderFrameCellRange[] _cellRanges = [];
+ private RenderProjectionRecord[] _dynamics = [];
+ private RenderFrameTransformRecord[] _transforms = [];
+ private RenderFrameClassificationRecord[] _classifications = [];
+ private RenderFrameObjectLightSet[] _lightSets = [];
+ private RenderFrameSelectionPart[] _selectionParts = [];
+ private int _outdoorCount;
+ private int _cellStaticCount;
+ private int _cellRangeCount;
+ private int _dynamicCount;
+ private int _transformCount;
+ private int _classificationCount;
+ private int _lightSetCount;
+ private int _selectionPartCount;
+ private int _opaqueClassificationCount;
+ private int _alphaClassificationCount;
+ private ulong _epoch;
+ private ulong _activeBorrowToken;
+ private bool _sourceDigestSet;
+ private ArenaState _state;
+
+ public RenderSceneGeneration Generation { get; private set; }
+
+ public ulong FrameSequence { get; private set; }
+
+ public PortalVisibilityFrame? PortalFrame { get; private set; }
+
+ public ClipFrameAssembly? ClipAssembly { get; private set; }
+
+ public RenderSceneDigest SourceDigest { get; private set; }
+
+ public RenderFrameDiagnosticCounts DiagnosticCounts =>
+ new(
+ _outdoorCount,
+ _cellStaticCount,
+ _dynamicCount,
+ _transformCount,
+ _opaqueClassificationCount,
+ _alphaClassificationCount,
+ _lightSetCount,
+ _selectionPartCount);
+
+ internal ReadOnlySpan OutdoorStaticCandidates =>
+ _outdoor.AsSpan(0, _outdoorCount);
+
+ internal ReadOnlySpan CellStaticCandidates =>
+ _cellStatics.AsSpan(0, _cellStaticCount);
+
+ internal ReadOnlySpan CellRanges =>
+ _cellRanges.AsSpan(0, _cellRangeCount);
+
+ internal ReadOnlySpan DynamicCandidates =>
+ _dynamics.AsSpan(0, _dynamicCount);
+
+ internal ReadOnlySpan Transforms =>
+ _transforms.AsSpan(0, _transformCount);
+
+ internal ReadOnlySpan Classifications =>
+ _classifications.AsSpan(0, _classificationCount);
+
+ internal ReadOnlySpan LightSets =>
+ _lightSets.AsSpan(0, _lightSetCount);
+
+ internal ReadOnlySpan SelectionParts =>
+ _selectionParts.AsSpan(0, _selectionPartCount);
+
+ internal ulong BeginWrite(
+ RenderSceneGeneration generation,
+ ulong frameSequence)
+ {
+ if (_state is ArenaState.Building or ArenaState.Borrowed)
+ {
+ throw new InvalidOperationException(
+ "A render-frame arena cannot be reused while building or borrowed.");
+ }
+ if (frameSequence == 0)
+ throw new ArgumentOutOfRangeException(nameof(frameSequence));
+
+ ClearReferenceStorage();
+ _outdoorCount = 0;
+ _cellStaticCount = 0;
+ _cellRangeCount = 0;
+ _dynamicCount = 0;
+ _transformCount = 0;
+ _classificationCount = 0;
+ _lightSetCount = 0;
+ _selectionPartCount = 0;
+ _opaqueClassificationCount = 0;
+ _alphaClassificationCount = 0;
+ PortalFrame = null;
+ ClipAssembly = null;
+ SourceDigest = default;
+ _sourceDigestSet = false;
+ Generation = generation;
+ FrameSequence = frameSequence;
+ _activeBorrowToken = 0;
+ _epoch = checked(_epoch + 1);
+ _state = ArenaState.Building;
+ return _epoch;
+ }
+
+ internal void SetBorrowedProducts(
+ ulong epoch,
+ PortalVisibilityFrame? portalFrame,
+ ClipFrameAssembly? clipAssembly)
+ {
+ EnsureBuilding(epoch);
+ PortalFrame = portalFrame;
+ ClipAssembly = clipAssembly;
+ }
+
+ internal void SetSourceDigest(ulong epoch, in RenderSceneDigest digest)
+ {
+ EnsureBuilding(epoch);
+ if (digest.Generation != Generation)
+ {
+ throw new InvalidOperationException(
+ $"The source digest belongs to {digest.Generation}, "
+ + $"but the frame arena is building {Generation}.");
+ }
+
+ SourceDigest = digest;
+ _sourceDigestSet = true;
+ }
+
+ internal void AddOutdoor(
+ ulong epoch,
+ in RenderProjectionRecord record)
+ {
+ EnsureBuilding(epoch);
+ EnsureCapacity(ref _outdoor, _outdoorCount + 1);
+ _outdoor[_outdoorCount++] = record;
+ }
+
+ internal void AddCellRange(
+ ulong epoch,
+ uint cellId,
+ int pviewOrder,
+ ReadOnlySpan records)
+ {
+ EnsureBuilding(epoch);
+ if (pviewOrder < 0)
+ throw new ArgumentOutOfRangeException(nameof(pviewOrder));
+
+ int offset = _cellStaticCount;
+ EnsureCapacity(ref _cellStatics, checked(offset + records.Length));
+ records.CopyTo(_cellStatics.AsSpan(offset));
+ _cellStaticCount += records.Length;
+ EnsureCapacity(ref _cellRanges, _cellRangeCount + 1);
+ _cellRanges[_cellRangeCount++] = new RenderFrameCellRange(
+ cellId,
+ pviewOrder,
+ offset,
+ records.Length);
+ }
+
+ internal void AddCellRange(
+ ulong epoch,
+ uint cellId,
+ int pviewOrder,
+ in RenderProjectionRecord record)
+ {
+ EnsureBuilding(epoch);
+ if (pviewOrder < 0)
+ throw new ArgumentOutOfRangeException(nameof(pviewOrder));
+
+ int offset = _cellStaticCount;
+ EnsureCapacity(ref _cellStatics, offset + 1);
+ _cellStatics[_cellStaticCount++] = record;
+ EnsureCapacity(ref _cellRanges, _cellRangeCount + 1);
+ _cellRanges[_cellRangeCount++] = new RenderFrameCellRange(
+ cellId,
+ pviewOrder,
+ offset,
+ 1);
+ }
+
+ internal void AddDynamic(
+ ulong epoch,
+ in RenderProjectionRecord record)
+ {
+ EnsureBuilding(epoch);
+ EnsureCapacity(ref _dynamics, _dynamicCount + 1);
+ _dynamics[_dynamicCount++] = record;
+ }
+
+ internal void AddTransform(
+ ulong epoch,
+ in RenderFrameTransformRecord transform)
+ {
+ EnsureBuilding(epoch);
+ EnsureCapacity(ref _transforms, _transformCount + 1);
+ _transforms[_transformCount++] = transform;
+ }
+
+ internal void AddClassification(
+ ulong epoch,
+ in RenderFrameClassificationRecord classification)
+ {
+ EnsureBuilding(epoch);
+ EnsureCapacity(ref _classifications, _classificationCount + 1);
+ _classifications[_classificationCount++] = classification;
+ if (classification.BlendClass is RenderFrameBlendClass.Alpha)
+ _alphaClassificationCount++;
+ else
+ _opaqueClassificationCount++;
+ }
+
+ internal void AddLightSet(
+ ulong epoch,
+ in RenderFrameObjectLightSet lightSet)
+ {
+ EnsureBuilding(epoch);
+ EnsureCapacity(ref _lightSets, _lightSetCount + 1);
+ _lightSets[_lightSetCount++] = lightSet;
+ }
+
+ internal void AddSelectionPart(
+ ulong epoch,
+ in RenderFrameSelectionPart part)
+ {
+ EnsureBuilding(epoch);
+ ArgumentNullException.ThrowIfNull(part.Mesh);
+ EnsureCapacity(ref _selectionParts, _selectionPartCount + 1);
+ _selectionParts[_selectionPartCount++] = part;
+ }
+
+ internal void Publish(ulong epoch)
+ {
+ EnsureBuilding(epoch);
+ if (!_sourceDigestSet)
+ {
+ throw new InvalidOperationException(
+ "A render-frame arena cannot publish without its source digest.");
+ }
+ _state = ArenaState.Published;
+ }
+
+ internal void Abort(ulong epoch)
+ {
+ EnsureBuilding(epoch);
+ ClearReferenceStorage();
+ PortalFrame = null;
+ ClipAssembly = null;
+ SourceDigest = default;
+ _sourceDigestSet = false;
+ _state = ArenaState.Available;
+ }
+
+ internal ulong Borrow(ulong epoch, ulong borrowToken)
+ {
+ if (_state is not ArenaState.Published || _epoch != epoch)
+ {
+ throw new InvalidOperationException(
+ "Only the exact published render-frame arena may be borrowed.");
+ }
+ if (borrowToken == 0)
+ throw new ArgumentOutOfRangeException(nameof(borrowToken));
+
+ _activeBorrowToken = borrowToken;
+ _state = ArenaState.Borrowed;
+ return _activeBorrowToken;
+ }
+
+ internal void Release(ulong epoch, ulong borrowToken)
+ {
+ if (!IsBorrowValid(epoch, borrowToken))
+ {
+ throw new InvalidOperationException(
+ "The render-frame borrow is stale or has already been released.");
+ }
+
+ _activeBorrowToken = 0;
+ _state = ArenaState.Published;
+ }
+
+ internal bool IsBorrowValid(ulong epoch, ulong borrowToken) =>
+ _state is ArenaState.Borrowed
+ && _epoch == epoch
+ && borrowToken != 0
+ && _activeBorrowToken == borrowToken;
+
+ internal bool CanBuild => _state is ArenaState.Available or ArenaState.Published;
+
+ internal ulong Epoch => _epoch;
+
+ private void EnsureBuilding(ulong epoch)
+ {
+ if (_state is not ArenaState.Building || _epoch != epoch)
+ {
+ throw new InvalidOperationException(
+ "The render-frame writer no longer owns this arena.");
+ }
+ }
+
+ private void ClearReferenceStorage()
+ {
+ if (_selectionPartCount > 0)
+ Array.Clear(_selectionParts, 0, _selectionPartCount);
+ }
+
+ private static void EnsureCapacity(ref T[] values, int required)
+ {
+ if (values.Length >= required)
+ return;
+
+ int capacity = values.Length == 0 ? 4 : values.Length;
+ while (capacity < required)
+ capacity = checked(capacity * 2);
+ Array.Resize(ref values, capacity);
+ }
+
+ private enum ArenaState : byte
+ {
+ Available,
+ Building,
+ Published,
+ Borrowed,
+ }
+}
+
+///
+/// Allocation-free write facade for the exact arena selected by
+/// .
+///
+internal readonly struct RenderFrameWriter
+{
+ private readonly RenderFrameExchange? _owner;
+ private readonly RenderFrameArena? _arena;
+ private readonly int _arenaIndex;
+ private readonly ulong _epoch;
+
+ internal RenderFrameWriter(
+ RenderFrameExchange owner,
+ RenderFrameArena arena,
+ int arenaIndex,
+ ulong epoch)
+ {
+ _owner = owner;
+ _arena = arena;
+ _arenaIndex = arenaIndex;
+ _epoch = epoch;
+ }
+
+ public void SetBorrowedProducts(
+ PortalVisibilityFrame? portalFrame,
+ ClipFrameAssembly? clipAssembly) =>
+ Arena.SetBorrowedProducts(_epoch, portalFrame, clipAssembly);
+
+ public void SetSourceDigest(in RenderSceneDigest digest) =>
+ Arena.SetSourceDigest(_epoch, in digest);
+
+ public void AddOutdoor(in RenderProjectionRecord record) =>
+ Arena.AddOutdoor(_epoch, in record);
+
+ public void AddCellRange(
+ uint cellId,
+ int pviewOrder,
+ ReadOnlySpan records) =>
+ Arena.AddCellRange(_epoch, cellId, pviewOrder, records);
+
+ public void AddCellRange(
+ uint cellId,
+ int pviewOrder,
+ in RenderProjectionRecord record) =>
+ Arena.AddCellRange(_epoch, cellId, pviewOrder, in record);
+
+ public void AddDynamic(in RenderProjectionRecord record) =>
+ Arena.AddDynamic(_epoch, in record);
+
+ public void AddTransform(in RenderFrameTransformRecord transform) =>
+ Arena.AddTransform(_epoch, in transform);
+
+ public void AddClassification(
+ in RenderFrameClassificationRecord classification) =>
+ Arena.AddClassification(_epoch, in classification);
+
+ public void AddLightSet(in RenderFrameObjectLightSet lightSet) =>
+ Arena.AddLightSet(_epoch, in lightSet);
+
+ public void AddSelectionPart(in RenderFrameSelectionPart part) =>
+ Arena.AddSelectionPart(_epoch, in part);
+
+ public void Publish() =>
+ Owner.Publish(_arenaIndex, _epoch);
+
+ public void Abort() =>
+ Owner.Abort(_arenaIndex, _epoch);
+
+ private RenderFrameExchange Owner =>
+ _owner
+ ?? throw new InvalidOperationException(
+ "The render-frame writer is uninitialized.");
+
+ private RenderFrameArena Arena =>
+ _arena
+ ?? throw new InvalidOperationException(
+ "The render-frame writer is uninitialized.");
+}
+
+///
+/// Owns exactly two reusable arenas. It permits frame N to build in one arena
+/// while frame N-1 remains borrowed from the other, but never permits mutation
+/// of a borrowed arena or publication of an incomplete frame.
+///
+internal sealed class RenderFrameExchange
+{
+ private readonly RenderFrameArena[] _arenas =
+ [new RenderFrameArena(), new RenderFrameArena()];
+ private int _buildingIndex = -1;
+ private int _publishedIndex = -1;
+ private ulong _nextBorrowToken;
+
+ public RenderFrameWriter BeginBuild(
+ RenderSceneGeneration generation,
+ ulong frameSequence)
+ {
+ if (_buildingIndex >= 0)
+ {
+ throw new InvalidOperationException(
+ "A render-frame build is already in progress.");
+ }
+
+ int index = _publishedIndex < 0
+ ? 0
+ : 1 - _publishedIndex;
+ if (!_arenas[index].CanBuild)
+ index = -1;
+ if (index < 0)
+ {
+ throw new InvalidOperationException(
+ "The non-published render-frame arena is still borrowed.");
+ }
+
+ RenderFrameArena arena = _arenas[index];
+ ulong epoch = arena.BeginWrite(generation, frameSequence);
+ _buildingIndex = index;
+ return new RenderFrameWriter(this, arena, index, epoch);
+ }
+
+ public RenderFrameView BorrowLatest(
+ RenderSceneGeneration expectedGeneration,
+ ulong expectedFrameSequence)
+ {
+ if (_publishedIndex < 0)
+ {
+ throw new InvalidOperationException(
+ "No completed render frame has been published.");
+ }
+
+ RenderFrameArena arena = _arenas[_publishedIndex];
+ if (arena.Generation != expectedGeneration
+ || arena.FrameSequence != expectedFrameSequence)
+ {
+ throw new InvalidOperationException(
+ $"Published frame {arena.Generation}/{arena.FrameSequence} "
+ + $"does not match requested {expectedGeneration}/{expectedFrameSequence}.");
+ }
+
+ ulong token = checked(++_nextBorrowToken);
+ arena.Borrow(arena.Epoch, token);
+ return new RenderFrameView(arena, arena.Epoch, token);
+ }
+
+ public void Release(in RenderFrameView view)
+ {
+ RenderFrameArena arena = view.Owner;
+ if (!ReferenceEquals(arena, _arenas[0])
+ && !ReferenceEquals(arena, _arenas[1]))
+ {
+ throw new InvalidOperationException(
+ "The render-frame view belongs to another exchange.");
+ }
+
+ arena.Release(view.ArenaEpoch, view.BorrowToken);
+ }
+
+ internal void Publish(int index, ulong epoch)
+ {
+ EnsureWriter(index);
+ RenderFrameArena arena = _arenas[index];
+ arena.Publish(epoch);
+ _publishedIndex = index;
+ _buildingIndex = -1;
+ }
+
+ internal void Abort(int index, ulong epoch)
+ {
+ EnsureWriter(index);
+ _arenas[index].Abort(epoch);
+ _buildingIndex = -1;
+ }
+
+ private void EnsureWriter(int index)
+ {
+ if (_buildingIndex != index)
+ {
+ throw new InvalidOperationException(
+ "The render-frame writer is stale or has already completed.");
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs
new file mode 100644
index 00000000..1ac539b0
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/RenderFrameProductTests.cs
@@ -0,0 +1,275 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Scene;
+using AcDream.Core.Selection;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class RenderFrameProductTests
+{
+ private static readonly RenderSceneGeneration Generation =
+ RenderSceneGeneration.FromRaw(7);
+
+ [Fact]
+ public void PublishesAndBorrowsTheFrameBuiltForTheSameSequence()
+ {
+ var exchange = new RenderFrameExchange();
+ RenderFrameWriter writer = exchange.BeginBuild(Generation, 41);
+ RenderProjectionRecord outdoor = Projection(1);
+ RenderProjectionRecord cellA = Projection(2);
+ RenderProjectionRecord cellB = Projection(3);
+ RenderProjectionRecord dynamic = Projection(4);
+ writer.AddOutdoor(in outdoor);
+ writer.AddCellRange(0x12340102, 5, [cellA, cellB]);
+ writer.AddDynamic(in dynamic);
+ writer.AddTransform(new RenderFrameTransformRecord(
+ dynamic.Id,
+ 0,
+ Matrix4x4.CreateTranslation(1, 2, 3)));
+ writer.AddClassification(new RenderFrameClassificationRecord(
+ outdoor.Id,
+ 0,
+ 0,
+ RenderFrameBlendClass.Opaque,
+ 1f));
+ writer.AddClassification(new RenderFrameClassificationRecord(
+ dynamic.Id,
+ 1,
+ 0,
+ RenderFrameBlendClass.Alpha,
+ 0.5f));
+ writer.AddLightSet(new RenderFrameObjectLightSet(
+ dynamic.Id,
+ 0, 1, 2, 3, 4, 5, 6, 7));
+ writer.AddSelectionPart(new RenderFrameSelectionPart(
+ dynamic.Id,
+ 0x50000001,
+ 4,
+ 0,
+ 0x01000001,
+ Matrix4x4.Identity,
+ SelectionMesh()));
+ var digest = new RenderSceneDigest(
+ Generation,
+ new RenderProjectionCounts(Total: 4, 1, 2, 1, 0, 0),
+ new RenderSceneHash128(11, 12));
+ writer.SetSourceDigest(in digest);
+ writer.Publish();
+
+ RenderFrameView view = exchange.BorrowLatest(Generation, 41);
+
+ Assert.Equal((ulong)41, view.FrameSequence);
+ Assert.Equal(Generation, view.Generation);
+ Assert.Equal(outdoor.Id, view.OutdoorStaticCandidates[0].Id);
+ Assert.Equal([cellA.Id, cellB.Id],
+ view.CellStaticCandidates.ToArray().Select(static item => item.Id));
+ Assert.Equal(new RenderFrameCellRange(0x12340102, 5, 0, 2),
+ view.CellRanges[0]);
+ Assert.Equal(dynamic.Id, view.DynamicCandidates[0].Id);
+ Assert.Equal(1, view.Transforms.Length);
+ Assert.Equal(2, view.Classifications.Length);
+ Assert.Equal(1, view.LightSets.Length);
+ Assert.Equal(1, view.SelectionParts.Length);
+ Assert.Equal(
+ new RenderFrameDiagnosticCounts(1, 2, 1, 1, 1, 1, 1, 1),
+ view.DiagnosticCounts);
+ Assert.Equal(digest, view.SourceDigest);
+
+ exchange.Release(in view);
+ }
+
+ [Fact]
+ public void BuildsInOtherArenaWhilePriorFrameRemainsBorrowed()
+ {
+ var exchange = new RenderFrameExchange();
+ PublishEmpty(exchange, Generation, 1);
+ RenderFrameView first = exchange.BorrowLatest(Generation, 1);
+
+ PublishEmpty(exchange, Generation, 2);
+
+ Assert.Equal((ulong)1, first.FrameSequence);
+ RenderFrameView second = exchange.BorrowLatest(Generation, 2);
+ Assert.Equal((ulong)2, second.FrameSequence);
+ Assert.Throws(
+ () => exchange.BeginBuild(Generation, 3));
+
+ exchange.Release(in first);
+ exchange.Release(in second);
+ }
+
+ [Fact]
+ public void ReuseInvalidatesEveryCopyOfReleasedView()
+ {
+ var exchange = new RenderFrameExchange();
+ PublishEmpty(exchange, Generation, 1);
+ RenderFrameView first = exchange.BorrowLatest(Generation, 1);
+ RenderFrameView copy = first;
+ exchange.Release(in first);
+
+ Assert.Throws(() => _ = copy.FrameSequence);
+
+ PublishEmpty(exchange, Generation, 2);
+ RenderFrameView second = exchange.BorrowLatest(Generation, 2);
+ exchange.Release(in second);
+ PublishEmpty(exchange, Generation, 3);
+
+ Assert.Throws(() => _ = copy.Generation);
+ }
+
+ [Fact]
+ public void AbortDoesNotPublishIncompleteFrame()
+ {
+ var exchange = new RenderFrameExchange();
+ PublishEmpty(exchange, Generation, 1);
+ RenderFrameWriter writer = exchange.BeginBuild(Generation, 2);
+ RenderProjectionRecord record = Projection(8);
+ writer.AddOutdoor(in record);
+ writer.Abort();
+
+ RenderFrameView prior = exchange.BorrowLatest(Generation, 1);
+ Assert.Equal(0, prior.OutdoorStaticCandidates.Length);
+ exchange.Release(in prior);
+
+ PublishEmpty(exchange, Generation, 2);
+ RenderFrameView replacement = exchange.BorrowLatest(Generation, 2);
+ Assert.Equal(0, replacement.OutdoorStaticCandidates.Length);
+ exchange.Release(in replacement);
+ }
+
+ [Fact]
+ public void BorrowRequiresExactGenerationAndFrameSequence()
+ {
+ var exchange = new RenderFrameExchange();
+ PublishEmpty(exchange, Generation, 9);
+
+ Assert.Throws(
+ () => exchange.BorrowLatest(
+ RenderSceneGeneration.FromRaw(8),
+ 9));
+ Assert.Throws(
+ () => exchange.BorrowLatest(Generation, 10));
+
+ RenderFrameView view = exchange.BorrowLatest(Generation, 9);
+ exchange.Release(in view);
+ }
+
+ [Fact]
+ public void SourceDigestMustMatchArenaGeneration()
+ {
+ var exchange = new RenderFrameExchange();
+ RenderFrameWriter writer = exchange.BeginBuild(Generation, 1);
+ var digest = new RenderSceneDigest(
+ RenderSceneGeneration.FromRaw(8),
+ default,
+ default);
+
+ Assert.Throws(
+ () => writer.SetSourceDigest(in digest));
+ writer.Abort();
+ }
+
+ [Fact]
+ public void PublishRequiresSourceDigest()
+ {
+ var exchange = new RenderFrameExchange();
+ RenderFrameWriter writer = exchange.BeginBuild(Generation, 1);
+
+ Assert.Throws(writer.Publish);
+ writer.Abort();
+ }
+
+ [Fact]
+ public void WarmProductBuildAndBorrowAllocateNothing()
+ {
+ var exchange = new RenderFrameExchange();
+ RenderProjectionRecord record = Projection(1);
+ RenderFrameSelectionPart selection = new(
+ record.Id,
+ 1,
+ 1,
+ 0,
+ 1,
+ Matrix4x4.Identity,
+ SelectionMesh());
+
+ for (ulong sequence = 1; sequence <= 8; sequence++)
+ BuildPopulated(exchange, sequence, in record, in selection);
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ for (ulong sequence = 9; sequence <= 1_008; sequence++)
+ BuildPopulated(exchange, sequence, in record, in selection);
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Equal(0, allocated);
+ }
+
+ private static void BuildPopulated(
+ RenderFrameExchange exchange,
+ ulong sequence,
+ in RenderProjectionRecord record,
+ in RenderFrameSelectionPart selection)
+ {
+ RenderFrameWriter writer = exchange.BeginBuild(Generation, sequence);
+ writer.AddOutdoor(in record);
+ writer.AddCellRange(0x01010001, 0, in record);
+ writer.AddDynamic(in record);
+ writer.AddTransform(new RenderFrameTransformRecord(
+ record.Id,
+ 0,
+ Matrix4x4.Identity));
+ writer.AddClassification(new RenderFrameClassificationRecord(
+ record.Id,
+ 0,
+ 0,
+ RenderFrameBlendClass.Opaque,
+ 1f));
+ writer.AddLightSet(new RenderFrameObjectLightSet(
+ record.Id,
+ 0, 1, 2, 3, 4, 5, 6, 7));
+ writer.AddSelectionPart(in selection);
+ writer.SetSourceDigest(new RenderSceneDigest(
+ Generation,
+ new RenderProjectionCounts(3, 1, 1, 1, 0, 0),
+ default));
+ writer.Publish();
+ RenderFrameView view = exchange.BorrowLatest(Generation, sequence);
+ _ = view.DiagnosticCounts;
+ exchange.Release(in view);
+ }
+
+ private static void PublishEmpty(
+ RenderFrameExchange exchange,
+ RenderSceneGeneration generation,
+ ulong sequence)
+ {
+ RenderFrameWriter writer = exchange.BeginBuild(generation, sequence);
+ writer.SetSourceDigest(new RenderSceneDigest(
+ generation,
+ default,
+ default));
+ writer.Publish();
+ }
+
+ private static RenderProjectionRecord Projection(ulong value) =>
+ new RenderProjectionRecord() with
+ {
+ Id = RenderProjectionId.FromRaw(value),
+ OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1),
+ MeshSet = new RenderMeshSet(
+ RenderAssetHandle.FromRaw(value),
+ 1,
+ 1),
+ Material = new RenderMaterialVariant(0, 0, 1),
+ Transform = new RenderTransform(Matrix4x4.Identity),
+ PreviousTransform = new PreviousRenderTransform(Matrix4x4.Identity),
+ Flags = RenderProjectionFlags.Draw,
+ };
+
+ private static RetailSelectionMesh SelectionMesh() =>
+ new(
+ Vector3.Zero,
+ 1f,
+ [new RetailSelectionPolygon(
+ [Vector3.Zero, Vector3.UnitX, Vector3.UnitY],
+ SingleSided: false)]);
+}