feat(render): S3 chunk 3 — draw terrain per land cell in retail's interleave

Ports RenderDeviceD3D::DrawBlock @0x005a17c0's real per-cell order:
loop 1 (@0x005a1876) prepares shadow lists; loop 2 (@0x005a197d)
DrawLandCell(cell) @0x005a19c0 fires ONLY when the cell is in view,
STRICTLY BEFORE DrawSortCell(cell) @0x005a19e6, which fires whenever
alwaysDrawObjects (retail default 1 @0x00820ed4) or the cell is in
view. RetailFrameWalk.DrawLandscape now emits sink.OnLandCellTurn(
landblockId, side, cellIndex) at that exact point, per admitted cell,
before the existing DrawBuilding + OnLandscapeCellTurn (the
DrawSortCell half). WalkFrameDriver records one LandCell frame event
per turn and deletes the whole-stage TerrainSlice(0) emission and the
DrawTerrainSlice leaf outright — drawing all terrain before every
building let a nearer building's far-Z punch survive under farther
terrain drawn afterward, the doorway-behind-a-hill fragment bug from
the owner's G2 Holtburg screenshot; this chunk removes it by ORDER
alone, with no depth-compare change (S4's punch z-func question is
untouched, per the contract).

Index-run arithmetic (S3 §9.1 R3): the terrain mesh is cell-major
(LandblockMesh.Build: cy outer, cx inner, 6 indices/cell, 384/land-
block). A retail LOD cell (side n, LOD coords X,Y) covers cx in
[X*8/n,(X+1)*8/n), cy in [Y*8/n,(Y+1)*8/n) — one contiguous run per
covered cy row: side 8 -> one run of 6, side 4 -> two runs of 12,
side 2 -> four runs of 24; side 1's single coarse cell covers every
row contiguously so its 8 per-row runs collapse into ONE run of all
384 indices. TerrainModernRenderer.AppendCellIndexRuns (pure, no GPU,
no baked table) and DrawLandCellRuns (resolves the landblock's slot,
builds one DrawElementsIndirectCommand per run, reuses the existing
DrawRhi bind-and-submit path) implement this; TerrainModernRenderer
.Draw(...) is untouched and keeps serving non-walk callers (directional-
shadow receivers, the flat terrain path).

Order-preserving batching (S3 §9.2 B2): WalkFrameDriver.Replay merges
consecutive same-landblock LandCell events with no intervening event
into ONE DrawLandCellBatch leaf call; any other event splits the
batch. In production this rarely fires because retail's own
DrawSortCell (AlwaysDrawObjects=true) always interposes an object-
list turn between one cell's LandCell event and the next's — see
perfNote in the task report for the resulting command-count increase.

Deleted as dead: the _walkTerrainInViewLandcells field and
SetWalkTerrainInViewLandcells setter on RetailPViewPassExecutor (fed
only DrawWalkTerrainSlice's inViewLandcells filter, which no longer
exists — the walk's own per-cell CellInView admission is now the
sole terrain-visibility authority) and its two call sites in
RetailPViewRenderer.DrawWalkDrivenStatics.

Weather placement (S3 §9.1 R5): confirmed unchanged. GameSky's
weather pass (RenderWeather, gated on is_player_outside) already
runs after every LandCell event for both root kinds — for an
outdoor root, DrawLandscapeDynamicsPhase is called directly after
driver.Replay() completes (which processes the whole per-cell
_events list first); for an interior root with ov>0, it fires via
the LandscapeFlush leaf (RetailPViewRenderer.FlushWalkLandscape ->
_walkPreClearDynamics), and OnInteriorFloodDrawTurn only emits
LandscapeFlush AFTER DrawLandscape's per-cell loop has fully run
and recorded every LandCell event ahead of it in the same _events
list Replay walks in order. No code change needed; verified by
reading the call sites.

Tests: WalkEvents/RetailFrameWalk/WalkFrameDriver's existing pins
updated (every "TERRAIN:0" expectation deleted, matching the deleted
event); new coverage for T1 (WalkLandCellOrderTests — LandWalkOrder +
WalkLandscape.CalcDrawOrder + WalkLandscapeAssembler
.SideCellCountForRing reproduce both cathedral captures' frame-2
LC/SC sequences byte-for-byte, 533/698 arrival and 531/757 leak, cross-
verified against the retail ring-to-LOD table), T2 (a synthetic two-
block landscape with a real WalkVisibilityMath-driven out-of-view
column, proving LC-before-building/statics, far-to-near, no-LC-but-
keeps-SC for the excluded cells, and no TerrainSlice event of any
kind), T3 (TerrainLandCellIndexRunsTests — the index-run arithmetic
for every side/cell, disjoint and exhaustive over the 384-index
landblock), T4 (batching merge/split), and the RetailPViewPassExecutor
CompiledCallGraph pin retargeted at DrawWalkLandCellBatch /
DrawLandCellRuns. WalkOracleTrace gains LC/SC event kinds and a
Load(root, name) overload for the OH capture directory — the one
parser change this chunk needs, no validator, no other infrastructure.

App hermetic lane: 6,786/6,786 (6,765 baseline + 21 new). InstalledDat
lane: 241 passed, 3 accepted failures (2 pre-existing #383 layout
fixture-drift tests, 1 TowerAscent Status=KnownFailure) — unchanged
from baseline. Core terrain tests: 116/116 (Core untouched by this
chunk).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-03 08:38:57 +02:00
parent dffd70557c
commit 4ee2866a7b
12 changed files with 858 additions and 121 deletions

View file

@ -10,11 +10,6 @@ namespace AcDream.App.Rendering;
/// methods — the cutover changes ORDER only.</summary>
internal sealed partial class RetailPViewPassExecutor
{
private IReadOnlySet<uint>? _walkTerrainInViewLandcells;
internal void SetWalkTerrainInViewLandcells(IReadOnlySet<uint>? cells) =>
_walkTerrainInViewLandcells = cells;
/// <summary>The walk's single sky turn: retail draws GameSky once
/// inside <c>LScape::draw</c>, clipped by the active views; looping
/// today's per-slice scissor + terrain-clip block reproduces today's
@ -77,61 +72,23 @@ internal sealed partial class RetailPViewPassExecutor
}
}
/// <summary>The walk's per-slice terrain turn — the terrain block of
/// <c>DrawLandscapeSlice</c> (scissor + terrain clip + slice planes +
/// NDC AABB), without the sky/entity halves the walk owns separately.</summary>
internal void DrawWalkTerrainSlice(
RetailPViewFrameInput frame, ClipFrameAssembly clipAssembly, int sliceIndex)
/// <summary>S3 chunk 3 (§9.2 B1): the walk's per-land-cell terrain
/// turn — retail <c>RenderDeviceD3D::DrawLandCell</c> @0x0059f120,
/// batched (S3 §9.2 B2). UNCLIPPED for BOTH root kinds — retail never
/// view-clips terrain (<c>LScape::draw</c> draws whole blocks; the
/// walk's own <c>CellInView</c> admission already decided which cells
/// reach here, and the exit-seal/interior-repaint turn owns aperture
/// exactness afterward, S3 §9.1 R4/R5), so unlike the whole-stage
/// turn this superseded, there is no scissor, no terrain clip, and no
/// per-slice loop — one call per (already order-merged) batch.</summary>
internal void DrawWalkLandCellBatch(
RetailPViewFrameInput frame,
uint landblockId,
IReadOnlyList<(int SideCellCount, int CellIndex)> cells)
{
ReadOnlySpan<ClipViewSlice> slices = clipAssembly.OutsideViewSlices;
if ((uint)sliceIndex >= (uint)slices.Length)
{
throw new ArgumentOutOfRangeException(
nameof(sliceIndex), sliceIndex,
$"walk terrain turn: slice {sliceIndex} of {slices.Length}");
}
// FW4 slice 6: an INTERIOR root draws the terrain UNCLIPPED —
// retail's LScape::draw draws whole blocks (view-culled, never
// view-clipped; "retail never clips ordinary meshes per view"),
// and aperture exactness comes from the depth clear + exit seals +
// interior repaint that follow in the walk's own order. The former
// per-slice scissor+clip under-painted terrain COLOR AND DEPTH
// wherever the authored exit-portal polygons are narrower than the
// real opening (the #456 cathedral seam bands) — leaving stale
// color and empty depth that unclipped alpha (the falls) then
// painted straight across. The outdoor root keeps its single
// full-screen slice (equivalent to unclipped by construction).
if (!frame.RootCell.IsOutdoorNode)
{
_terrainDiagnostics.Begin();
_terrain?.Draw(
frame.Camera,
frame.Frustum,
neverCullLandblockId: frame.PlayerLandblockId,
clipPlanes: default,
ndcClipAabb: new Vector4(-1f, -1f, 1f, 1f),
inViewLandcells: _walkTerrainInViewLandcells);
_terrainDiagnostics.Complete();
return;
}
ClipViewSlice slice = slices[sliceIndex];
bool scissor = BeginDoorwayScissor(slice.NdcAabb);
_surface.BindTerrainClip();
EnableClipDistances();
_terrainDiagnostics.Begin();
_terrain?.Draw(
frame.Camera,
frame.Frustum,
neverCullLandblockId: frame.PlayerLandblockId,
clipPlanes: slice.Planes,
ndcClipAabb: slice.NdcAabb,
inViewLandcells: _walkTerrainInViewLandcells);
_terrain?.DrawLandCellRuns(landblockId, frame.ViewProjection, cells);
_terrainDiagnostics.Complete();
DisableClipDistances();
if (scissor)
_surface.EndScissor();
}
/// <summary>The walk's punch-fan turn — <c>DrawPortalPolyInternal</c>
@ -182,8 +139,10 @@ internal sealed partial class RetailPViewPassExecutor
/// inside <c>LScape::draw</c> clipped by the active views; the per-slice
/// scissor+clip here reproduces today's pixels while the driver still sees
/// ONE sky turn).</item>
/// <item><see cref="DrawTerrainSlice"/> → the terrain block of
/// <c>DrawLandscapeSlice</c> (scissor + terrain clip + slice planes).</item>
/// <item><see cref="DrawLandCellBatch"/> → S3 chunk 3's per-land-cell
/// terrain turn, UNCLIPPED for both root kinds (retail never view-clips
/// terrain) — no scissor, no terrain clip, one call per order-merged
/// batch.</item>
/// <item><see cref="DrawCellShell"/> → <c>EnvCellRenderer</c> opaque +
/// transparent-ordered for ONE cell (retail <c>DrawEnvCell</c>
/// @0x0059f170 draws per cell at its flood turn).</item>
@ -249,8 +208,9 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
public void DrawSky() => _passes.DrawWalkSky(_frame, _clipAssembly);
public void DrawTerrainSlice(int sliceIndex) =>
_passes.DrawWalkTerrainSlice(_frame, _clipAssembly, sliceIndex);
public void DrawLandCellBatch(
uint landblockId, IReadOnlyList<(int SideCellCount, int CellIndex)> cells) =>
_passes.DrawWalkLandCellBatch(_frame, landblockId, cells);
public void DrawCellShell(uint cellId)
{

View file

@ -583,15 +583,12 @@ internal sealed class RetailPViewRenderer
Walk.WalkFrameDriver driver)
{
var (frame, encoder) = passes.RequireWalkSubmission();
passes.SetWalkTerrainInViewLandcells(driver.VisitedLandscapeCellIds);
try
{
driver.Replay(frame, encoder);
}
finally
{
passes.SetWalkTerrainInViewLandcells(null);
}
// S3 chunk 3 (§9.2 B3): the whole-stage terrain draw this fed
// (RetailPViewPassExecutor.DrawWalkTerrainSlice's inViewLandcells
// filter) is gone — the walk's own per-cell LandCell turns are now
// the sole terrain-visibility authority, so there is no plumbing
// left to feed here.
driver.Replay(frame, encoder);
// Landscape-stage static-owner particles (candles, the cathedral
// falls) submit AT THEIR OWN WALK TURNS inside Replay

View file

@ -71,6 +71,11 @@ public sealed partial class TerrainModernRenderer : IDisposable
private readonly HashSet<uint> _walkVisibleLandblocks = new();
private DrawElementsIndirectCommand[] _deicScratch = Array.Empty<DrawElementsIndirectCommand>();
// S3 chunk 3 (§9.2 B1): DrawLandCellRuns' reusable index-run scratch —
// repopulated per call by AppendCellIndexRuns, one (Start, Count) pair
// per contiguous run (side 8: one run/cell; side 1: one run total).
private readonly List<(int Start, int Count)> _cellRunScratch = new();
// Diag.
public int LoadedSlots => _alloc.LoadedCount;
public int VisibleSlots => _visibleSlots.Count;
@ -288,6 +293,108 @@ public sealed partial class TerrainModernRenderer : IDisposable
}
}
/// <summary>
/// S3 chunk 3 (§9.2 B1/B2): the walk's per-land-cell terrain draw —
/// retail <c>RenderDeviceD3D::DrawLandCell</c> @0x0059f120, batched.
/// Submits <see cref="AppendCellIndexRuns"/>'s index runs for every
/// entry of <paramref name="cells"/> as ONE indirect draw, UNCLIPPED:
/// retail never view-clips terrain — the walk's own per-cell
/// <c>CellInView</c> admission (<c>RetailFrameWalk.DrawLandscape</c>'s
/// <c>DrawLandCell</c> gate) already decided which cells reach here, so
/// there is no frustum test and no <c>inViewLandcells</c> filter here,
/// unlike the non-walk <see cref="Draw"/> entry point this supersedes
/// for the walk path only (<see cref="Draw"/> keeps serving non-walk
/// callers, e.g. the flat/directional-shadow paths). A landblockId with
/// no uploaded slot (a streaming-timing race between the walk's own
/// block graph and this renderer's landblock upload) is a silent
/// no-op — the walk's visited-cell bookkeeping is the timing authority,
/// not this call.
/// </summary>
public void DrawLandCellRuns(
uint landblockId,
Matrix4x4 viewProjection,
IReadOnlyList<(int SideCellCount, int CellIndex)> cells)
{
ArgumentNullException.ThrowIfNull(cells);
if (cells.Count == 0) return;
if (!_idToSlot.TryGetValue(landblockId, out int slot)) return;
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
_cellRunScratch.Clear();
for (int i = 0; i < cells.Count; i++)
AppendCellIndexRuns(cells[i].SideCellCount, cells[i].CellIndex, _cellRunScratch);
int commandCount = _cellRunScratch.Count;
if (commandCount == 0) return;
if (_deicScratch.Length < commandCount)
_deicScratch = new DrawElementsIndirectCommand[Math.Max(commandCount, 64)];
uint baseFirstIndex = (uint)(slot * IndicesPerLandblock);
for (int i = 0; i < commandCount; i++)
{
(int start, int count) = _cellRunScratch[i];
_deicScratch[i] = new DrawElementsIndirectCommand
{
Count = (uint)count,
InstanceCount = 1u,
FirstIndex = baseFirstIndex + (uint)start,
BaseVertex = 0, // baked into indices on upload
BaseInstance = 0,
};
}
DrawRhi(viewProjection, commandCount);
}
/// <summary>
/// S3 chunk 3 (§9.1 R3): retail LOD cell (side <paramref
/// name="sideCellCount"/>, LOD coords <c>(X, Y) = (cellIndex / side,
/// cellIndex % side)</c>) covers <c>cx ∈ [X·8/n, (X+1)·8/n)</c>,
/// <c>cy ∈ [Y·8/n, (Y+1)·8/n)</c> of the 8×8 cell-major mesh index
/// buffer (<c>LandblockMesh.Build</c>: <c>cy</c> outer, <c>cx</c>
/// inner, 6 indices per cell, <c>indices[i] = i</c>). Appends one
/// contiguous run per covered <c>cy</c> row — side 8 emits one run of
/// 6 indices per cell, side 4 two runs of 12, side 2 four runs of 24;
/// side 1's single coarse cell covers EVERY row contiguously (the
/// mesh's rows sit back-to-back with no gap), so its 8 per-row runs
/// concatenate into ONE run of all 384 indices. Pure arithmetic — no
/// baked range table, no per-frame rebuild.
/// </summary>
internal static void AppendCellIndexRuns(
int sideCellCount, int cellIndex, List<(int Start, int Count)> runs)
{
ArgumentNullException.ThrowIfNull(runs);
if (sideCellCount is not (1 or 2 or 4 or 8))
{
throw new ArgumentOutOfRangeException(
nameof(sideCellCount),
sideCellCount,
"A landscape LOD grid must be 1, 2, 4, or 8 cells per side.");
}
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
throw new ArgumentOutOfRangeException(nameof(cellIndex));
int span = LandblockMesh.CellsPerSide / sideCellCount;
int coarseX = cellIndex / sideCellCount;
int coarseY = cellIndex % sideCellCount;
int firstCx = coarseX * span;
int firstCy = coarseY * span;
if (span == LandblockMesh.CellsPerSide)
{
// The block's only coarse cell (side 1) covers cx AND cy 0..7 —
// every row's run then abuts the next (row cy's run ends at
// (cy*8+8)*6 = (cy+1)*8*6, exactly row cy+1's start), so the 8
// per-row runs are one contiguous 384-index range.
runs.Add((0, VertsPerLandblock));
return;
}
int runLength = span * LandblockMesh.VerticesPerCell;
for (int cy = firstCy; cy < firstCy + span; cy++)
{
int start = (cy * LandblockMesh.CellsPerSide + firstCx) * LandblockMesh.VerticesPerCell;
runs.Add((start, runLength));
}
}
public void Dispose()
{
if (_disposed)

View file

@ -147,7 +147,11 @@ public sealed class RetailFrameWalk
}
/// <summary><c>LScape::draw</c>: visibility per active view, then blocks
/// far-to-near, cells far-to-near, buildings at their cell's turn.</summary>
/// far-to-near, cells far-to-near; per cell, S3 chunk 3's
/// <see cref="IWalkEventSink.OnLandCellTurn"/> (this cell's terrain,
/// in-view cells only) fires BEFORE the building's turn and the
/// object-list turn (<see cref="IWalkEventSink.OnLandscapeCellTurn(uint,int,int)"/>,
/// every cell under <c>alwaysDrawObjects</c>).</summary>
public void DrawLandscape(
WalkLandscape landscape, WalkPortalView activeViews,
IRetailFrameWalkContext ctx, IWalkEventSink sink)
@ -166,15 +170,25 @@ public sealed class RetailFrameWalk
for (int k = 0; k < cellCount; k++)
{
int cellIndex = block.DrawArray[k];
// RenderDeviceD3D::DrawBlock @0x005a19d9: DrawSortCell runs
// when alwaysDrawObjects != 0 (retail .data default 1
// @0x00820ed4) OR the cell IsInView; terrain (DrawLandCell)
// emits no walk event.
if (!AlwaysDrawObjects
&& block.CellInView[cellIndex] == WalkBoundingType.Outside)
bool cellInView =
block.CellInView[cellIndex] != WalkBoundingType.Outside;
// S3 chunk 3 (§9.2 B1): RenderDeviceD3D::DrawBlock
// @0x005a17c0 loop 2 @0x005a197d: DrawLandCell(cell)
// @0x005a19c0 fires ONLY for an in-view cell, STRICTLY
// BEFORE DrawSortCell (the building + object-list turn
// below) — this cell's terrain draws before its contents.
if (cellInView)
{
continue;
sink.OnLandCellTurn(
block.LandblockId, block.SideCellCount, cellIndex);
}
// @0x005a19e6: DrawSortCell runs when alwaysDrawObjects != 0
// (retail .data default 1 @0x00820ed4) OR the cell IsInView.
if (!AlwaysDrawObjects && !cellInView)
continue;
// RenderDeviceD3D::DrawSortCell @0x0059f140 (decomp-
// confirmed 2026-08-30): DrawBuilding(building) FIRST, then
// DrawObjCell(cell) UNCONDITIONALLY — the building's turn

View file

@ -85,6 +85,29 @@ public interface IWalkEventSink
/// </summary>
void OnLandscapeViews(WalkPortalView activeViews) { }
/// <summary>
/// S3 chunk 3 (§9.2 B1): fires once per admitted land cell, STRICTLY
/// BEFORE that same cell's building/object-list turn —
/// <c>RenderDeviceD3D::DrawBlock</c> @0x005a17c0's loop 2
/// (@0x005a197d): <c>if (IsInView) DrawLandCell(cell)</c>
/// @0x005a19c0, THEN <c>if (alwaysDrawObjects || IsInView)
/// DrawSortCell(cell)</c> @0x005a19e6. Retail draws this land cell's
/// terrain at the block's current LOD — <paramref name="cellIndex"/>
/// names one cell in the block's CURRENT <paramref
/// name="sideCellCount"/>×<paramref name="sideCellCount"/> coarse grid,
/// the SAME LOD cell <see cref="OnLandscapeCellTurn(uint,int,int)"/>
/// expands into its covered 8×8 owner buckets for object-list content —
/// this hook is terrain-only and never expands (the index-run
/// arithmetic runs directly at LOD resolution, S3 §9.1 R3). Only fires
/// when the cell is admitted (<c>CellInView[cellIndex] != Outside</c>);
/// an out-of-view cell under <c>alwaysDrawObjects</c> still gets its
/// <see cref="OnLandscapeCellTurn(uint,int,int)"/> object-list turn but
/// NO terrain turn (retail's own <c>DrawSortCell</c>-without-
/// <c>DrawLandCell</c> case). Default no-op — every pre-S3-chunk-3 sink
/// continues to compile and behave identically.
/// </summary>
void OnLandCellTurn(uint landblockId, int sideCellCount, int cellIndex) { }
/// <summary>
/// Fires once per visited landscape cell, AFTER that cell's building
/// turn (if any) — <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140

View file

@ -102,17 +102,24 @@ internal interface IWalkFrameLeafRenderer
/// calls this exactly once per frame's Landscape turn).</summary>
void DrawSky();
/// <summary><c>LScape::grab_visible_cells</c>'s terrain mesh, once per
/// ACTIVE clip slice — <paramref name="sliceIndex"/> is caller-supplied
/// (<see cref="WalkFrameDriver.Collect"/>'s <c>activeTerrainSliceCount</c>)
/// since FW3.2b-1 does not wire <c>ClipFrameAssembler</c>/
/// <c>ViewconeCuller</c> (FW3.2b-2's job — see plan §FW3.2's dynamic-route
/// survival note). Terrain draws FULLY before any per-cell building/
/// outdoor-static turn in this stage's turn order — an intra-stage
/// simplification of retail's true per-cell <c>DrawLandCell</c>/
/// <c>DrawObjCell</c> interleave, recorded here rather than ported, since
/// terrain itself carries no walk event today.</summary>
void DrawTerrainSlice(int sliceIndex);
/// <summary>S3 chunk 3 (§9.2 B1/B2): retail <c>RenderDeviceD3D::
/// DrawLandCell</c> @0x0059f120 — one landblock's terrain at one or
/// more admitted LOD cells, submitted together as ONE indirect draw.
/// <see cref="WalkFrameDriver.Replay"/> merges consecutive same-slot
/// <see cref="WalkFrameEventKind.LandCell"/> events with no intervening
/// event before calling this once (order-preserving batching — any
/// other event, e.g. a building's alpha barrier or the SAME cell's own
/// object-list turn, splits the batch), so a single-cell call is the
/// common case whenever a cell's own <see cref="DrawStaticParticles"/>/
/// stream mark interposes. <paramref name="cells"/> carries (side,
/// cellIndex) pairs in event (draw) order — the R3 index-run arithmetic
/// (contiguous runs inside the landblock's 384-index slot,
/// <c>TerrainModernRenderer.AppendCellIndexRuns</c>) runs per entry.
/// Draws UNCLIPPED for both root kinds: retail never view-clips terrain
/// (<c>LScape::draw</c> draws whole blocks; the exit-seal/interior-
/// repaint turn owns aperture exactness afterward, S3 §9.1 R4/R5).</summary>
void DrawLandCellBatch(
uint landblockId, IReadOnlyList<(int SideCellCount, int CellIndex)> cells);
/// <summary>One committed cell's EnvCell shell —
/// <c>PView::DrawCells</c>'s <c>DrawEnvCell</c> @0x005a4abe. Retail first
@ -259,9 +266,16 @@ internal enum WalkFrameEventKind : byte
/// <summary><see cref="IWalkFrameLeafRenderer.DrawSky"/>.</summary>
Sky,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawTerrainSlice"/> —
/// <see cref="WalkFrameEvent.IntArg"/> is the slice index.</summary>
TerrainSlice,
/// <summary>S3 chunk 3: <see cref="IWalkFrameLeafRenderer.DrawLandCellBatch"/>
/// for ONE admitted land cell — <see cref="WalkFrameEvent.CellId"/> is
/// the owning landblock id, <see cref="WalkFrameEvent.IntArg"/> packs
/// <c>(sideCellCount &lt;&lt; 8) | cellIndex</c> (side ∈ {1,2,4,8},
/// cellIndex &lt; side², both fit comfortably below the 8-bit shift).
/// <see cref="Replay"/> merges a run of consecutive same-landblock
/// entries into ONE <see cref="IWalkFrameLeafRenderer.DrawLandCellBatch"/>
/// call (B2's order-preserving batching) rather than replaying them
/// one at a time.</summary>
LandCell,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawCellShell"/> —
/// <see cref="WalkFrameEvent.CellId"/> is the cell. Retail's
@ -404,8 +418,8 @@ internal readonly struct WalkFrameEvent
internal static WalkFrameEvent Sky() =>
new(WalkFrameEventKind.Sky, 0, 0, 0f, null);
internal static WalkFrameEvent TerrainSlice(int sliceIndex) =>
new(WalkFrameEventKind.TerrainSlice, sliceIndex, 0, 0f, null);
internal static WalkFrameEvent LandCell(uint landblockId, int sideCellCount, int cellIndex) =>
new(WalkFrameEventKind.LandCell, (sideCellCount << 8) | cellIndex, landblockId, 0f, null);
internal static WalkFrameEvent CellShell(uint cellId) =>
new(WalkFrameEventKind.CellShell, 0, cellId, 0f, null);
@ -473,7 +487,7 @@ internal readonly struct WalkFrameEvent
///
/// <para><b>The one mark rule that reproduces the whole frame script:</b>
/// before EVERY leaf-renderer event (<see cref="WalkFrameEventKind.Sky"/>,
/// <c>TerrainSlice</c>, <c>CellShell</c>, <c>LandscapeFlush</c>,
/// <c>LandCell</c>, <c>CellShell</c>, <c>LandscapeFlush</c>,
/// <c>ClearInteriorDepth</c>, <c>ExitSeals</c>, <c>PunchFan</c>) and before
/// every <see cref="WalkFrameEventKind.AlphaBarrier"/> event, Collect
/// records a <see cref="WalkFrameEventKind.StreamMark"/> if the stream grew
@ -549,6 +563,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private readonly List<int> _floodViewRouteScratch = new();
private WalkPlane _lookInCyPlane;
/// <summary>S3 chunk 3 (§9.2 B2): <see cref="Replay"/>'s reusable
/// batch-merge scratch — the (side, cellIndex) pairs of a run of
/// consecutive same-landblock <see cref="WalkFrameEventKind.LandCell"/>
/// events, cleared and repopulated at each batch boundary.</summary>
private readonly List<(int SideCellCount, int CellIndex)> _landCellBatchScratch = new();
private readonly HashSet<uint> _cellShellsDrawnThisFrame = new();
// Chunk 6 review F1: one particle turn per cell per render stamp (see EmitCellContentsTurn).
private readonly HashSet<uint> _cellParticleTurnsDrawnThisFrame = new();
@ -939,9 +959,34 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
case WalkFrameEventKind.Sky:
_leafRenderer.DrawSky();
break;
case WalkFrameEventKind.TerrainSlice:
_leafRenderer.DrawTerrainSlice(e.IntArg);
case WalkFrameEventKind.LandCell:
{
// S3 chunk 3 (§9.2 B2): order-preserving batching —
// merge this run of CONSECUTIVE same-landblock
// LandCell events into ONE DrawLandCellBatch call.
// Any other event kind sits between two runs in
// _events (Collect recorded it there in retail's
// own order), so this lookahead naturally splits the
// batch there without inspecting event kinds itself.
uint landblockId = e.CellId;
_landCellBatchScratch.Clear();
_landCellBatchScratch.Add((e.IntArg >> 8, e.IntArg & 0xFF));
int j = i + 1;
while (j < _events.Count)
{
WalkFrameEvent next = _events[j];
if (next.Kind != WalkFrameEventKind.LandCell
|| next.CellId != landblockId)
{
break;
}
_landCellBatchScratch.Add((next.IntArg >> 8, next.IntArg & 0xFF));
j++;
}
_leafRenderer.DrawLandCellBatch(landblockId, _landCellBatchScratch);
i = j - 1;
break;
}
case WalkFrameEventKind.CellShell:
_leafRenderer.DrawCellShell(e.CellId);
break;
@ -1063,8 +1108,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
case WalkFrameEventKind.Sky:
order.Append('>').Append(i).Append(":SKY");
break;
case WalkFrameEventKind.TerrainSlice:
order.Append('>').Append(i).Append(":T").Append(e.IntArg);
case WalkFrameEventKind.LandCell:
order.Append('>').Append(i).Append(":LC").Append(e.CellId.ToString("x8"));
break;
case WalkFrameEventKind.CellShell when IsCathedralCell(e.CellId):
order.Append('>').Append(i).Append(":S")
@ -1129,6 +1174,30 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
}
}
/// <summary>S3 chunk 3 (§9.2 B1): records this admitted land cell's
/// terrain turn. <c>sideCellCount</c>/<c>cellIndex</c> travel packed
/// (not expanded) — R3's index-run arithmetic runs at LOD resolution in
/// the production leaf, not at 8×8-bucket resolution like the object-
/// list turn's expansion.</summary>
void IWalkEventSink.OnLandCellTurn(uint landblockId, int sideCellCount, int cellIndex)
{
RequireOpenFrame();
if (sideCellCount is not (1 or 2 or 4 or 8))
{
throw new ArgumentOutOfRangeException(
nameof(sideCellCount),
sideCellCount,
"A landscape LOD grid must be 1, 2, 4, or 8 cells per side.");
}
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
throw new ArgumentOutOfRangeException(nameof(cellIndex));
// The one mark rule (this type's own doc comment): flush any
// already-queued stream content ahead of this leaf-renderer event.
MarkIfGrown();
_events.Add(WalkFrameEvent.LandCell(landblockId, sideCellCount, cellIndex));
}
void IWalkEventSink.OnLandscapeCellTurn(uint cellId)
=> HandleLandscapeCellTurn(cellId);
@ -1399,14 +1468,18 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
MarkIfGrown();
_events.Add(WalkFrameEvent.Sky());
_landscapeTurnsThisFrame++;
// FW4 slice 6 (correcting slice 1's per-view fan): retail's
// LScape::draw draws the terrain blocks ONCE per landscape turn —
// the active views feed only the block-level visibility union
// (CheckBlocks); terrain cells are ordinary meshes and retail never
// clips those per view (pixel exactness = the depth clear + seals +
// interior repaint afterward). One terrain turn, always; the walk's
// views still own the punch fans and dynamics apertures.
_events.Add(WalkFrameEvent.TerrainSlice(0));
// S3 chunk 3 (§9.1 R4/§9.2 B1): the FW4-slice-6 whole-stage terrain
// turn (retail draws terrain blocks ONCE per landscape turn) is
// SUPERSEDED — retail's true per-cell DrawLandCell/DrawSortCell
// interleave now emits one WalkFrameEventKind.LandCell turn per
// admitted land cell (IWalkEventSink.OnLandCellTurn, fired from
// RetailFrameWalk.DrawLandscape's own per-cell loop), not a single
// TerrainSlice(0) here. Drawing terrain BEFORE every building would
// let a nearer building's far-Z punch survive under farther terrain
// that draws afterward and overwrite it — the doorway-behind-a-hill
// fragment bug the interleave removes by ORDER alone. The walk's
// active views still own only the punch fans and dynamics
// apertures; terrain itself is UNCLIPPED for both root kinds.
}
private void HandleDrawCellsTurn(IReadOnlyList<uint> cells)