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:
parent
dffd70557c
commit
4ee2866a7b
12 changed files with 858 additions and 121 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 << 8) | cellIndex</c> (side ∈ {1,2,4,8},
|
||||
/// cellIndex < 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)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public sealed class RetailPViewPassExecutorTests
|
|||
public void Concrete_executor_brackets_terrain_diagnostics()
|
||||
{
|
||||
MethodInfo landscape = typeof(RetailPViewPassExecutor).GetMethod(
|
||||
"DrawWalkTerrainSlice",
|
||||
"DrawWalkLandCellBatch",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
IReadOnlyList<CompiledCall> landscapeCalls = CompiledCallGraph.Read(landscape);
|
||||
int diagnosticsBegin = RequiredCallIndex(
|
||||
|
|
@ -40,7 +40,7 @@ public sealed class RetailPViewPassExecutorTests
|
|||
int terrainDraw = RequiredCallIndex(
|
||||
landscapeCalls,
|
||||
typeof(TerrainModernRenderer),
|
||||
nameof(TerrainModernRenderer.Draw));
|
||||
nameof(TerrainModernRenderer.DrawLandCellRuns));
|
||||
int diagnosticsComplete = RequiredCallIndex(
|
||||
landscapeCalls,
|
||||
typeof(TerrainDrawDiagnosticsController),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// S3 chunk 3 (§9.3 T3): retail LOD cell (side <c>n</c>, LOD coords
|
||||
/// <c>(X, Y)</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). <see cref="TerrainModernRenderer.AppendCellIndexRuns"/> is
|
||||
/// pure arithmetic — no baked range table — so this pins it directly, with
|
||||
/// no GPU device needed.
|
||||
/// </summary>
|
||||
public sealed class TerrainLandCellIndexRunsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(8)]
|
||||
[InlineData(4)]
|
||||
[InlineData(2)]
|
||||
[InlineData(1)]
|
||||
public void EveryCellOfASide_CoversDisjointRunsTotalingTheWholeLandblock(int side)
|
||||
{
|
||||
var covered = new bool[384];
|
||||
int expectedTotalPerCell = (8 / side) * (8 / side) * 6;
|
||||
|
||||
for (int cellIndex = 0; cellIndex < side * side; cellIndex++)
|
||||
{
|
||||
var runs = new List<(int Start, int Count)>();
|
||||
TerrainModernRenderer.AppendCellIndexRuns(side, cellIndex, runs);
|
||||
|
||||
Assert.Equal(expectedTotalPerCell, runs.Sum(r => r.Count));
|
||||
foreach ((int start, int count) in runs)
|
||||
{
|
||||
Assert.InRange(start, 0, 383);
|
||||
Assert.InRange(start + count, 1, 384);
|
||||
for (int i = start; i < start + count; i++)
|
||||
{
|
||||
Assert.False(
|
||||
covered[i],
|
||||
$"index {i} double-covered by side={side} cellIndex={cellIndex}");
|
||||
covered[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every side's full cell set covers the ENTIRE 384-index landblock
|
||||
// exactly once — disjoint AND exhaustive.
|
||||
Assert.All(covered, c => Assert.True(c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Side1_EmitsOneRunOfTheWholeLandblock()
|
||||
{
|
||||
// R3: the single coarse cell's 8 per-row runs are mutually
|
||||
// contiguous (the mesh's rows sit back-to-back), so they collapse
|
||||
// into ONE run of all 384 indices — not 8 runs of 48.
|
||||
var runs = new List<(int Start, int Count)>();
|
||||
TerrainModernRenderer.AppendCellIndexRuns(1, 0, runs);
|
||||
|
||||
Assert.Equal(new[] { (0, 384) }, runs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Side4_EmitsTwoRunsOfTwelve()
|
||||
{
|
||||
var runs = new List<(int Start, int Count)>();
|
||||
TerrainModernRenderer.AppendCellIndexRuns(4, 0, runs);
|
||||
|
||||
Assert.Equal(2, runs.Count);
|
||||
Assert.All(runs, r => Assert.Equal(12, r.Count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Side2_EmitsFourRunsOfTwentyFour()
|
||||
{
|
||||
var runs = new List<(int Start, int Count)>();
|
||||
TerrainModernRenderer.AppendCellIndexRuns(2, 0, runs);
|
||||
|
||||
Assert.Equal(4, runs.Count);
|
||||
Assert.All(runs, r => Assert.Equal(24, r.Count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Side8_EmitsOneSixIndexRunAtTheCellsExactOffset()
|
||||
{
|
||||
// LOD coords (X=3, Y=5) at full resolution -> mesh cell (cx=3, cy=5)
|
||||
// -> vi = (cy*8 + cx)*6 = (5*8 + 3)*6 = 258 (LandblockMesh.Build:
|
||||
// cy outer, cx inner).
|
||||
int cellIndex = 3 * 8 + 5; // cellIndex = X*side + Y
|
||||
var runs = new List<(int Start, int Count)>();
|
||||
TerrainModernRenderer.AppendCellIndexRuns(8, cellIndex, runs);
|
||||
|
||||
Assert.Equal(new[] { (258, 6) }, runs);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(3)]
|
||||
[InlineData(5)]
|
||||
[InlineData(6)]
|
||||
[InlineData(7)]
|
||||
public void InvalidSideCellCount_Throws(int side)
|
||||
{
|
||||
var runs = new List<(int Start, int Count)>();
|
||||
Assert.Throws<System.ArgumentOutOfRangeException>(
|
||||
() => TerrainModernRenderer.AppendCellIndexRuns(side, 0, runs));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(8, 64)]
|
||||
[InlineData(8, -1)]
|
||||
[InlineData(4, 16)]
|
||||
[InlineData(1, 1)]
|
||||
public void OutOfRangeCellIndex_Throws(int side, int cellIndex)
|
||||
{
|
||||
var runs = new List<(int Start, int Count)>();
|
||||
Assert.Throws<System.ArgumentOutOfRangeException>(
|
||||
() => TerrainModernRenderer.AppendCellIndexRuns(side, cellIndex, runs));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,36 @@ public sealed class RetailFrameWalkTests
|
|||
private sealed class Recorder : IWalkEventSink
|
||||
{
|
||||
public readonly List<WalkEvent> Events = new();
|
||||
public void Emit(in WalkEvent walkEvent) => Events.Add(walkEvent);
|
||||
|
||||
/// <summary>S3 chunk 3 (§9.3 T2): the SAME sequence <see cref="Signature"/>
|
||||
/// reads plus <c>LC</c>/<c>SC</c> land-cell turns interleaved at the
|
||||
/// exact point <see cref="RetailFrameWalk.DrawLandscape"/> fires
|
||||
/// them — proves the terrain/building/object-turn ORDER, not just
|
||||
/// each vocabulary in isolation.</summary>
|
||||
public readonly List<string> Combined = new();
|
||||
|
||||
public void Emit(in WalkEvent walkEvent)
|
||||
{
|
||||
Events.Add(walkEvent);
|
||||
Combined.Add(walkEvent.Kind switch
|
||||
{
|
||||
WalkEventKind.Landscape => "LS",
|
||||
WalkEventKind.Building => $"BLD:{walkEvent.CellId:x8}",
|
||||
WalkEventKind.DrawInside => $"DI:{walkEvent.CellId:x8}",
|
||||
WalkEventKind.DrawCells =>
|
||||
$"DC:ov={walkEvent.OutsideViewCount}:{string.Join(',', walkEvent.Cells.Select(c => c.ToString("x8")))}",
|
||||
_ => "?",
|
||||
});
|
||||
}
|
||||
|
||||
// S3 chunk 3: LC uses the SAME "(landblockId & 0xFFFF0000) |
|
||||
// (cellIndex+1)" convention as the interface's own OnLandscapeCellTurn
|
||||
// default (below) — comparable directly against SC for the same cell.
|
||||
public void OnLandCellTurn(uint landblockId, int sideCellCount, int cellIndex)
|
||||
=> Combined.Add(
|
||||
$"LC:{(landblockId & 0xFFFF0000u) | (uint)(cellIndex + 1):x8}");
|
||||
|
||||
public void OnLandscapeCellTurn(uint cellId) => Combined.Add($"SC:{cellId:x8}");
|
||||
|
||||
public string Signature()
|
||||
=> string.Join("|", Events.Select(e => e.Kind switch
|
||||
|
|
@ -53,7 +82,13 @@ public sealed class RetailFrameWalkTests
|
|||
public float ViewerDistanceTo(WalkBuilding building) => 0f;
|
||||
public IWalkFrameContext CellContext => this;
|
||||
// Permissive near plane: every column wholly inside.
|
||||
public WalkPlane CyPlane => new(new Vector3(0, 0, 1), 0f);
|
||||
// S3 chunk 3 (T2): settable, default unchanged — a permissive
|
||||
// horizontal near plane every pre-existing test in this file still
|
||||
// gets. T2 overrides it with a VERTICAL plane to carve a real
|
||||
// out-of-view cell out of an admitted block via WalkVisibilityMath's
|
||||
// OWN geometry (not a hand-set CellInView CheckBlocks would just
|
||||
// overwrite).
|
||||
public WalkPlane CyPlane { get; set; } = new(new Vector3(0, 0, 1), 0f);
|
||||
public void SetActiveView(WalkPortalView views, int index) { }
|
||||
|
||||
public int ClipBuildingPolygon(
|
||||
|
|
@ -128,6 +163,138 @@ public sealed class RetailFrameWalkTests
|
|||
Assert.Equal("BLD:f518002e", recorder.Signature());
|
||||
}
|
||||
|
||||
// ── T2 (S3 chunk 3 §9.3): the per-cell interleave — DrawLandCell (LC)
|
||||
// fires BEFORE its own cell's building/object turn, cells draw
|
||||
// far-to-near, an out-of-view cell under alwaysDrawObjects keeps its
|
||||
// sort turn (SC) but gets NO LC, and no whole-stage terrain event
|
||||
// exists any more (superseded — S3 §9.1 R4). Real CalcDrawOrder/
|
||||
// CheckBlocks run (not hand-set and bypassed): a VERTICAL CyPlane
|
||||
// (N=(1,0,0)) at X-threshold 30 carves an honest "one column
|
||||
// out-of-view" result out of the viewer's own 8-side block via
|
||||
// WalkVisibilityMath's OWN corner-vs-plane test — LandCellCheck's
|
||||
// n!=8 branch marks EVERY cell PartiallyInside unconditionally, so an
|
||||
// out-of-view CELL (as opposed to a whole out-of-view BLOCK, which
|
||||
// DrawLandscape's own block-level gate skips entirely, LC and SC
|
||||
// alike) is only reachable at full n==8 resolution — hence the near
|
||||
// block's own SC count is unavoidably 64 (AlwaysDrawObjects fires for
|
||||
// every cell of an admitted block regardless of individual
|
||||
// visibility), so this test asserts STRUCTURALLY (membership + local
|
||||
// order) rather than as one 64-cell literal sequence; T1 separately
|
||||
// pins the exact draw ORDER via LandWalkOrder + CalcDrawOrder against
|
||||
// the real retail transcripts. ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DrawLandscape_InterleavesLandCellTurnsWithBuildingAndObjectTurns_FarToNear()
|
||||
{
|
||||
var ctx = new TestContext
|
||||
{
|
||||
// Vertical plane (Z-independent): GetPointLimit's third arm
|
||||
// reduces to "d = x*1 + D < -eps => Outside" at (x, y, 0) — a
|
||||
// pure world-X half-space test, the CY-only arm (ViewCount==0
|
||||
// below) is the sole plane BlockCheck consults.
|
||||
CyPlane = new WalkPlane(new Vector3(1, 0, 0), -30f),
|
||||
};
|
||||
var landscape = new WalkLandscape
|
||||
{
|
||||
MidWidth = 2,
|
||||
Blocks = new WalkLandBlock?[4],
|
||||
ViewerBlockX = 0,
|
||||
ViewerBlockY = 0,
|
||||
ViewerCellX = 0,
|
||||
ViewerCellY = 0,
|
||||
};
|
||||
|
||||
// The viewer's own block (grid slot 0 = (0,0)): world X/Y in
|
||||
// [0, 192). Threshold 30 excludes the cx=0 column (X in [0,24], both
|
||||
// corners < 30) and admits every other column (cx=1's west corner
|
||||
// straddles at X=24 -> PartiallyInside; cx>=2 is fully >= 30).
|
||||
var nearBlock = new WalkLandBlock
|
||||
{
|
||||
LandblockId = 0x11110000u, SideCellCount = 8, MaxZ = 10f, MinZ = 0f,
|
||||
};
|
||||
nearBlock.EnsureCellArrays();
|
||||
// A building at an ordinary in-view cell (cx=3, cy=0), away from
|
||||
// both the excluded column and the closest/farthest cells.
|
||||
var building = new WalkBuilding { PositionCellId = 0x11110019u, HasGeometry = false };
|
||||
nearBlock.CellBuildings[3 * 8 + 0] = building;
|
||||
|
||||
// The far block (grid slot 3 = (1,1), the diagonal corner — drawn
|
||||
// FIRST since GetBlockOrder puts ring-1 slots ahead of the viewer's
|
||||
// own and DrawLandscape walks that list BACKWARDS). World X/Y in
|
||||
// [192, 384) sits comfortably east of the threshold -> the block
|
||||
// itself is EntirelyInside; its one LOD-1 cell still classifies
|
||||
// PartiallyInside (LandCellCheck's n!=8 branch marks every cell of
|
||||
// a coarse block that way unconditionally) — either way, != Outside,
|
||||
// so it still gets an LC turn, which is all this test needs.
|
||||
var farBlock = new WalkLandBlock
|
||||
{
|
||||
LandblockId = 0x22220000u, SideCellCount = 1, MaxZ = 10f, MinZ = 0f,
|
||||
};
|
||||
farBlock.EnsureCellArrays();
|
||||
|
||||
landscape.Blocks[0] = nearBlock;
|
||||
landscape.Blocks[3] = farBlock;
|
||||
|
||||
var walk = new RetailFrameWalk(); // AlwaysDrawObjects defaults true (retail default).
|
||||
var recorder = new Recorder();
|
||||
|
||||
// ViewCount == 0 exercises the CY-only visibility arm deterministically
|
||||
// (matches Outdoor_frame_emits_landscape_then_buildings_far_to_near's
|
||||
// own convention) — the CyPlane above is the ONLY plane consulted.
|
||||
walk.DrawLandscape(landscape, new WalkPortalView(), ctx, recorder);
|
||||
|
||||
Assert.Equal("LS", recorder.Combined[0]);
|
||||
Assert.DoesNotContain(
|
||||
recorder.Combined, e => e.StartsWith("TERRAIN", StringComparison.Ordinal));
|
||||
|
||||
const uint farCellId = 0x22220001u;
|
||||
int farLcIndex = recorder.Combined.IndexOf($"LC:{farCellId:x8}");
|
||||
int farScIndex = recorder.Combined.IndexOf($"SC:{farCellId:x8}");
|
||||
Assert.True(farLcIndex >= 0 && farScIndex > farLcIndex, "far block: LC before SC");
|
||||
|
||||
var outsideIds = new List<uint>();
|
||||
var insideIds = new List<uint>();
|
||||
for (int cx = 0; cx < 8; cx++)
|
||||
for (int cy = 0; cy < 8; cy++)
|
||||
{
|
||||
uint cellId = 0x11110000u | (uint)(cx * 8 + cy + 1);
|
||||
(cx == 0 ? outsideIds : insideIds).Add(cellId);
|
||||
}
|
||||
|
||||
// Far block draws entirely before ANY near-block content — the
|
||||
// far-to-near block order.
|
||||
int nearFirstIndex = insideIds.Concat(outsideIds)
|
||||
.Select(id => recorder.Combined.IndexOf($"SC:{id:x8}"))
|
||||
.Where(i => i >= 0)
|
||||
.Min();
|
||||
Assert.True(farScIndex < nearFirstIndex, "far block must draw before the near block");
|
||||
|
||||
// The excluded column: SC fires (AlwaysDrawObjects), LC never does.
|
||||
foreach (uint cellId in outsideIds)
|
||||
{
|
||||
Assert.Contains($"SC:{cellId:x8}", recorder.Combined);
|
||||
Assert.DoesNotContain($"LC:{cellId:x8}", recorder.Combined);
|
||||
}
|
||||
|
||||
// Every other near-block cell: LC before SC (with the one
|
||||
// building's BLD sitting strictly between them for its own cell).
|
||||
Assert.Equal(56, insideIds.Count);
|
||||
foreach (uint cellId in insideIds)
|
||||
{
|
||||
int lc = recorder.Combined.IndexOf($"LC:{cellId:x8}");
|
||||
int sc = recorder.Combined.IndexOf($"SC:{cellId:x8}");
|
||||
Assert.True(lc >= 0, $"cell 0x{cellId:x8} must have an LC turn");
|
||||
Assert.True(sc > lc, $"cell 0x{cellId:x8}: LC must precede SC");
|
||||
}
|
||||
|
||||
const uint buildingCellId = 0x11110019u;
|
||||
int bldIndex = recorder.Combined.IndexOf($"BLD:{buildingCellId:x8}");
|
||||
int buildingLc = recorder.Combined.IndexOf($"LC:{buildingCellId:x8}");
|
||||
int buildingSc = recorder.Combined.IndexOf($"SC:{buildingCellId:x8}");
|
||||
Assert.True(bldIndex >= 0, "the building's own cell must emit BLD");
|
||||
Assert.True(buildingLc < bldIndex && bldIndex < buildingSc, "LC, then BLD, then SC");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interior_frame_without_exit_views_skips_the_landscape()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ public sealed class WalkFrameDriverTests
|
|||
public readonly List<WalkPolygon> Punches = new();
|
||||
public readonly List<uint> Shells = new();
|
||||
public readonly List<int> AlphaPendingAtBarrier = new();
|
||||
public readonly List<(uint LandblockId, int CellCount)> LandCellBatches = new();
|
||||
|
||||
/// <summary>S3 chunk 2: the exit-seal polygon count this fake
|
||||
/// reports back to the driver (B2 — <see cref="DrawExitSeals"/>
|
||||
|
|
@ -60,7 +61,12 @@ public sealed class WalkFrameDriverTests
|
|||
|
||||
public void DrawSky() => log.Add("SKY");
|
||||
|
||||
public void DrawTerrainSlice(int sliceIndex) => log.Add($"TERRAIN:{sliceIndex}");
|
||||
public void DrawLandCellBatch(
|
||||
uint landblockId, IReadOnlyList<(int SideCellCount, int CellIndex)> cells)
|
||||
{
|
||||
LandCellBatches.Add((landblockId, cells.Count));
|
||||
log.Add($"LANDCELL:{landblockId:x8}:{cells.Count}");
|
||||
}
|
||||
|
||||
public void DrawCellShell(uint cellId)
|
||||
{
|
||||
|
|
@ -295,7 +301,7 @@ public sealed class WalkFrameDriverTests
|
|||
// No CLEAR: R4's first-frame no-clear quirk — this driver's
|
||||
// PortalsDrawnCount starts at 0, so the read-then-zero
|
||||
// decision sees "not armed" even though ov=1.
|
||||
"SKY", "TERRAIN:0", "LFLUSH", "SEALS",
|
||||
"SKY", "LFLUSH", "SEALS",
|
||||
"SHELL:00000101", "SHELL:00000100",
|
||||
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
|
||||
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
|
||||
|
|
@ -513,7 +519,7 @@ public sealed class WalkFrameDriverTests
|
|||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"SKY", "TERRAIN:0", "LFLUSH", "SEALS",
|
||||
"SKY", "LFLUSH", "SEALS",
|
||||
"SHELL:f4180200", "CELL-PARTICLES:f4180200",
|
||||
},
|
||||
log);
|
||||
|
|
@ -536,7 +542,7 @@ public sealed class WalkFrameDriverTests
|
|||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"SKY", "TERRAIN:0", "LFLUSH", "CLEAR", "SEALS",
|
||||
"SKY", "LFLUSH", "CLEAR", "SEALS",
|
||||
"SHELL:f4180200", "CELL-PARTICLES:f4180200",
|
||||
},
|
||||
log);
|
||||
|
|
@ -1045,7 +1051,7 @@ public sealed class WalkFrameDriverTests
|
|||
|
||||
driver.Replay(draw.Frame, draw.Pass);
|
||||
|
||||
Assert.Equal(new[] { "SKY", "TERRAIN:0" }, log);
|
||||
Assert.Equal(new[] { "SKY" }, log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1072,8 +1078,8 @@ public sealed class WalkFrameDriverTests
|
|||
Matrix4x4.Identity, Vector3.Zero);
|
||||
driver.Replay(draw.Frame, draw.Pass);
|
||||
|
||||
Assert.Equal(new[] { "SKY", "TERRAIN:0" }, firstLog);
|
||||
Assert.Equal(new[] { "SKY", "TERRAIN:0" }, secondLog);
|
||||
Assert.Equal(new[] { "SKY" }, firstLog);
|
||||
Assert.Equal(new[] { "SKY" }, secondLog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1096,7 +1102,7 @@ public sealed class WalkFrameDriverTests
|
|||
Matrix4x4.Identity, Vector3.Zero);
|
||||
driver.Replay(draw.Frame, draw.Pass);
|
||||
|
||||
Assert.Equal(new[] { "SKY", "TERRAIN:0" }, log);
|
||||
Assert.Equal(new[] { "SKY" }, log);
|
||||
}
|
||||
|
||||
// ── Deliverable: an outdoor landscape-cell turn with no building appends
|
||||
|
|
@ -1295,6 +1301,52 @@ public sealed class WalkFrameDriverTests
|
|||
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
||||
}
|
||||
|
||||
// ── T4 (S3 chunk 3 §9.3): order-preserving batching — consecutive
|
||||
// same-landblock LandCell turns with NO intervening event merge into
|
||||
// ONE DrawLandCellBatch call; any other event (here, a building's own
|
||||
// alpha barrier) splits the batch; a later run of the SAME landblock
|
||||
// after a split is its own new batch; a different landblock never
|
||||
// merges with a prior one even when adjacent. ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnLandCellTurn_ConsecutiveSameSlotEventsMergeIntoOneBatch_AnyOtherEventSplits()
|
||||
{
|
||||
using var fx = new DispatcherFixture();
|
||||
var log = new List<string>();
|
||||
var leaf = new RecordingLeafRenderer(log);
|
||||
var ctx = new TestContext();
|
||||
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
||||
IWalkEventSink sink = driver;
|
||||
|
||||
using DrawScope draw = fx.BeginDraw();
|
||||
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
||||
sink.OnLandCellTurn(0xF4180000u, 8, 0);
|
||||
sink.OnLandCellTurn(0xF4180000u, 8, 1); // same slot, nothing between -> merges
|
||||
sink.OnBuildingTurn(new WalkBuilding()); // intervening event splits the batch
|
||||
sink.OnLandCellTurn(0xF4180000u, 8, 2); // same slot as before the split, but a NEW batch
|
||||
sink.OnLandCellTurn(0xF3180000u, 8, 0); // a different slot never merges
|
||||
driver.EndFrame();
|
||||
driver.Replay(draw.Frame, draw.Pass);
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"LANDCELL:f4180000:2",
|
||||
"ALPHA",
|
||||
"LANDCELL:f4180000:1",
|
||||
"LANDCELL:f3180000:1",
|
||||
},
|
||||
log);
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
(0xF4180000u, 2),
|
||||
(0xF4180000u, 1),
|
||||
(0xF3180000u, 1),
|
||||
},
|
||||
leaf.LandCellBatches);
|
||||
}
|
||||
|
||||
// ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture —
|
||||
// FW3.2a's own referee) ─────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
181
tests/AcDream.App.Tests/Rendering/Walk/WalkLandCellOrderTests.cs
Normal file
181
tests/AcDream.App.Tests/Rendering/Walk/WalkLandCellOrderTests.cs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
using AcDream.App.Rendering.Walk;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Walk;
|
||||
|
||||
/// <summary>
|
||||
/// S3 chunk 3 (§9.3 T1): <see cref="LandWalkOrder"/> +
|
||||
/// <see cref="WalkLandscape.CalcDrawOrder"/> +
|
||||
/// <see cref="WalkLandscapeAssembler.SideCellCountForRing"/> reproduce frame
|
||||
/// 2's <c>LC</c> and <c>SC</c> sequences of BOTH OH cathedral captures
|
||||
/// exactly — order, block membership, and per-block LOD side all pinned in
|
||||
/// one pass, at the depth the two owner-verified transcripts (S3 §9.1 R2)
|
||||
/// recorded them.
|
||||
///
|
||||
/// <para>Viewer: block <c>f4/18</c>, sq cell <c>(1, 1)</c> (the frame-2 `P`
|
||||
/// line: x 36.6, y 24.0 — <c>floor(36.6/24)=1</c>, <c>floor(24.0/24)=1</c>).
|
||||
/// Grid radius <see cref="WalkLandscapeAssembler.MidRadius"/> (25) — the
|
||||
/// captured blocks span Chebyshev ring 24 (§9.1 R2 fact i), comfortably
|
||||
/// inside it. Only the SET of blocks/cells the transcript itself visited is
|
||||
/// placed on the grid (a real 51×51 window's un-streamed slots stay null
|
||||
/// exactly the same way in production — <see cref="WalkLandscape.DrawLandscape"/>
|
||||
/// skips a null/Outside slot regardless of why it's empty); each block's
|
||||
/// <see cref="WalkLandBlock.CellInView"/> is set DIRECTLY from the
|
||||
/// transcript's own LC membership (this test is an ORDER pin, not a second
|
||||
/// visibility-math proof — that is <c>WalkVisibilityMathTests</c>'s
|
||||
/// job) so <see cref="WalkLandscape.CalcDrawOrder"/> is the ONLY production
|
||||
/// order machinery under test.</para>
|
||||
/// </summary>
|
||||
public sealed class WalkLandCellOrderTests
|
||||
{
|
||||
private const int ViewerBlockX = 0xf4;
|
||||
private const int ViewerBlockY = 0x18;
|
||||
private const int ViewerSqX = 1;
|
||||
private const int ViewerSqY = 1;
|
||||
|
||||
[Theory]
|
||||
[InlineData("cathedral-arrival")]
|
||||
[InlineData("cathedral-leak")]
|
||||
public void Frame2_ProducedLandCellSequence_MatchesTheCapturedRetailOrderExactly(
|
||||
string fixtureName)
|
||||
{
|
||||
IReadOnlyList<WalkOracleFrame> frames = WalkOracleTrace.Load(
|
||||
"docs/research/2026-09-01-overhaul/oh-capture", fixtureName + ".walk");
|
||||
WalkOracleFrame frame2 = Assert.Single(frames, f => f.Number == 2);
|
||||
|
||||
List<WalkOracleEvent> landCellEvents = frame2.Events
|
||||
.Where(e => e.Kind is WalkOracleEventKind.LandCell or WalkOracleEventKind.SortCell)
|
||||
.ToList();
|
||||
Assert.NotEmpty(landCellEvents);
|
||||
|
||||
// Extract the block visit order (first-appearance order = retail's
|
||||
// OWN far-to-near block sequence) and, per block, its side (from
|
||||
// the observed SC cell count — every SC-visited block emits
|
||||
// side² SC turns under AlwaysDrawObjects, R1) and its LC membership.
|
||||
var blockOrder = new List<uint>();
|
||||
var blockSeen = new HashSet<uint>();
|
||||
var sideByBlock = new Dictionary<uint, int>();
|
||||
var scCountByBlock = new Dictionary<uint, int>();
|
||||
var lcMembersByBlock = new Dictionary<uint, HashSet<uint>>();
|
||||
|
||||
foreach (WalkOracleEvent e in landCellEvents)
|
||||
{
|
||||
uint cellId = e.CellId!.Value;
|
||||
uint blockPrefix = cellId & 0xFFFF0000u;
|
||||
uint lodId = cellId & 0xFFFFu;
|
||||
if (blockSeen.Add(blockPrefix))
|
||||
{
|
||||
blockOrder.Add(blockPrefix);
|
||||
lcMembersByBlock[blockPrefix] = new HashSet<uint>();
|
||||
}
|
||||
|
||||
if (e.Kind == WalkOracleEventKind.SortCell)
|
||||
{
|
||||
scCountByBlock[blockPrefix] = scCountByBlock.GetValueOrDefault(blockPrefix) + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
lcMembersByBlock[blockPrefix].Add(lodId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((uint blockPrefix, int scCount) in scCountByBlock)
|
||||
{
|
||||
int side = (int)Math.Round(Math.Sqrt(scCount));
|
||||
Assert.Equal(scCount, side * side);
|
||||
sideByBlock[blockPrefix] = side;
|
||||
|
||||
// Cross-check against the LOD-by-ring pyramid the ownership
|
||||
// map's R2 names explicitly (ring 0-1 -> 8, ring 2 -> 4, ring
|
||||
// 3-4 -> 2, ring >= 5 -> 1) — an independent confirmation that
|
||||
// the transcript's own per-block cell count matches production's
|
||||
// ring rule, not just an internally-consistent side count.
|
||||
int bx = (int)(blockPrefix >> 24) & 0xFF;
|
||||
int by = (int)(blockPrefix >> 16) & 0xFF;
|
||||
int ring = Math.Max(Math.Abs(bx - ViewerBlockX), Math.Abs(by - ViewerBlockY));
|
||||
Assert.Equal(WalkLandscapeAssembler.SideCellCountForRing(ring), side);
|
||||
}
|
||||
|
||||
// Build the synthetic grid: only the transcript-visited blocks are
|
||||
// populated (production leaves everything else null too), each at
|
||||
// its REAL grid offset from the viewer block, with the REAL side
|
||||
// WalkLandscapeAssembler.SideCellCountForRing computed above.
|
||||
const int midWidth = WalkLandscapeAssembler.GridWidth;
|
||||
const int midRadius = WalkLandscapeAssembler.MidRadius;
|
||||
var landscape = new WalkLandscape
|
||||
{
|
||||
MidWidth = midWidth,
|
||||
Blocks = new WalkLandBlock?[midWidth * midWidth],
|
||||
ViewerBlockX = midRadius,
|
||||
ViewerBlockY = midRadius,
|
||||
ViewerCellX = ViewerSqX,
|
||||
ViewerCellY = ViewerSqY,
|
||||
};
|
||||
|
||||
foreach (uint blockPrefix in blockOrder)
|
||||
{
|
||||
int bx = (int)(blockPrefix >> 24) & 0xFF;
|
||||
int by = (int)(blockPrefix >> 16) & 0xFF;
|
||||
int gridX = bx - ViewerBlockX + midRadius;
|
||||
int gridY = by - ViewerBlockY + midRadius;
|
||||
Assert.InRange(gridX, 0, midWidth - 1);
|
||||
Assert.InRange(gridY, 0, midWidth - 1);
|
||||
|
||||
int side = sideByBlock[blockPrefix];
|
||||
var block = new WalkLandBlock
|
||||
{
|
||||
LandblockId = blockPrefix, SideCellCount = side, MaxZ = 0f, MinZ = 0f,
|
||||
};
|
||||
block.EnsureCellArrays();
|
||||
HashSet<uint> lcMembers = lcMembersByBlock[blockPrefix];
|
||||
for (int cellIndex = 0; cellIndex < side * side; cellIndex++)
|
||||
{
|
||||
int coarseX = cellIndex / side;
|
||||
int coarseY = cellIndex % side;
|
||||
uint retailLodId = (uint)(coarseX * 8 + coarseY + 1);
|
||||
block.CellInView[cellIndex] = lcMembers.Contains(retailLodId)
|
||||
? WalkBoundingType.EntirelyInside
|
||||
: WalkBoundingType.Outside;
|
||||
}
|
||||
block.InView = WalkBoundingType.PartiallyInside; // admitted; never Outside
|
||||
landscape.Blocks[gridX * midWidth + gridY] = block;
|
||||
}
|
||||
|
||||
// LandWalkOrder.GetBlockOrder + FillCellOrderFarToNear (the ONLY
|
||||
// production order machinery under test — CheckBlocks is
|
||||
// deliberately not called; CellInView above is authoritative).
|
||||
landscape.CalcDrawOrder();
|
||||
|
||||
var producedLc = new List<uint>();
|
||||
var producedSc = new List<uint>();
|
||||
for (int i = landscape.BlockDrawCount - 1; i >= 0; i--)
|
||||
{
|
||||
WalkLandBlock? block = landscape.Blocks[landscape.BlockDrawList[i]];
|
||||
if (block is null)
|
||||
continue;
|
||||
|
||||
int cellCount = block.SideCellCount * block.SideCellCount;
|
||||
for (int k = 0; k < cellCount; k++)
|
||||
{
|
||||
int cellIndex = block.DrawArray[k];
|
||||
int coarseX = cellIndex / block.SideCellCount;
|
||||
int coarseY = cellIndex % block.SideCellCount;
|
||||
uint fullId = block.LandblockId | (uint)(coarseX * 8 + coarseY + 1);
|
||||
if (block.CellInView[cellIndex] != WalkBoundingType.Outside)
|
||||
producedLc.Add(fullId);
|
||||
producedSc.Add(fullId); // AlwaysDrawObjects: every cell of an admitted block.
|
||||
}
|
||||
}
|
||||
|
||||
List<uint> expectedLc = landCellEvents
|
||||
.Where(e => e.Kind == WalkOracleEventKind.LandCell)
|
||||
.Select(e => e.CellId!.Value)
|
||||
.ToList();
|
||||
List<uint> expectedSc = landCellEvents
|
||||
.Where(e => e.Kind == WalkOracleEventKind.SortCell)
|
||||
.Select(e => e.CellId!.Value)
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(expectedSc, producedSc);
|
||||
Assert.Equal(expectedLc, producedLc);
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,22 @@ public static class WalkOracleTrace
|
|||
declaredCount: int.Parse(
|
||||
cellsMatch.Groups[2].Value, CultureInfo.InvariantCulture),
|
||||
cells));
|
||||
continue;
|
||||
}
|
||||
// S3 chunk 3 (§9.3 T1): LC/SC — RenderDeviceD3D::DrawLandCell
|
||||
// 0x0059f120 / DrawSortCell 0x0059f140, added to the OH capture
|
||||
// templates 2026-09-03 (tools/walk-oracle/oh/oh-capture-walk.cdb.template).
|
||||
Match landCellMatch = LandCellPattern.Match(line);
|
||||
if (landCellMatch.Success)
|
||||
{
|
||||
current.Add(WalkOracleEvent.LandCell(ParseId(landCellMatch.Groups[1].Value)));
|
||||
continue;
|
||||
}
|
||||
Match sortCellMatch = SortCellPattern.Match(line);
|
||||
if (sortCellMatch.Success)
|
||||
{
|
||||
current.Add(WalkOracleEvent.SortCell(ParseId(sortCellMatch.Groups[1].Value)));
|
||||
continue;
|
||||
}
|
||||
// Anything else is cdb chrome (banner, prompts, symbol notes) — ignored.
|
||||
}
|
||||
|
|
@ -103,10 +119,18 @@ public static class WalkOracleTrace
|
|||
}
|
||||
|
||||
public static IReadOnlyList<WalkOracleFrame> Load(string fixtureName)
|
||||
=> Load("docs/research/2026-08-30-fw-walk-oracle", fixtureName);
|
||||
|
||||
/// <summary>S3 chunk 3 (§9.3 T1): loads a fixture from an arbitrary
|
||||
/// repo-relative <paramref name="root"/> — the OH captures live under
|
||||
/// <c>docs/research/2026-09-01-overhaul/oh-capture/</c>, a different
|
||||
/// directory than the FW0 still fixtures the single-argument overload
|
||||
/// defaults to.</summary>
|
||||
public static IReadOnlyList<WalkOracleFrame> Load(string root, string fixtureName)
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
string repoRoot = FindRepositoryRoot();
|
||||
string path = Path.Combine(
|
||||
root, "docs", "research", "2026-08-30-fw-walk-oracle", fixtureName + ".log");
|
||||
repoRoot, Path.Combine(root.Split('/')), fixtureName + ".log");
|
||||
return Parse(File.ReadLines(path));
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +158,8 @@ public static class WalkOracleTrace
|
|||
private static readonly Regex DrawInsidePattern = new(@"^DI ([0-9a-f]{8})\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex DrawCellsPattern = new(
|
||||
@"^DC pv=[0-9a-f]{8} ov=(\d+) n=(\d+):((?: [0-9a-f]{8})*)\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex LandCellPattern = new(@"^LC ([0-9a-f]{8})\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex SortCellPattern = new(@"^SC ([0-9a-f]{8})\s*$", RegexOptions.Compiled);
|
||||
}
|
||||
|
||||
/// <summary>The camera pose dumped at the frame marker (raw dwords from
|
||||
|
|
@ -170,6 +196,14 @@ public enum WalkOracleEventKind
|
|||
Building,
|
||||
DrawInside,
|
||||
DrawCells,
|
||||
|
||||
/// <summary>S3 chunk 3: <c>LC <cellid></c> — <c>RenderDeviceD3D::
|
||||
/// DrawLandCell</c> @0x0059f120 entry.</summary>
|
||||
LandCell,
|
||||
|
||||
/// <summary>S3 chunk 3: <c>SC <cellid></c> — <c>RenderDeviceD3D::
|
||||
/// DrawSortCell</c> @0x0059f140 entry.</summary>
|
||||
SortCell,
|
||||
}
|
||||
|
||||
public sealed record WalkOracleEvent(
|
||||
|
|
@ -191,4 +225,10 @@ public sealed record WalkOracleEvent(
|
|||
public static WalkOracleEvent DrawCells(
|
||||
int outsideViewCount, int declaredCount, IReadOnlyList<uint> cells)
|
||||
=> new(WalkOracleEventKind.DrawCells, null, outsideViewCount, declaredCount, cells);
|
||||
|
||||
public static WalkOracleEvent LandCell(uint cellId)
|
||||
=> new(WalkOracleEventKind.LandCell, cellId, 0, 0, Array.Empty<uint>());
|
||||
|
||||
public static WalkOracleEvent SortCell(uint cellId)
|
||||
=> new(WalkOracleEventKind.SortCell, cellId, 0, 0, Array.Empty<uint>());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue