acdream/tests/AcDream.App.Tests/Rendering/TerrainLandCellIndexRunsTests.cs
Erik 4ee2866a7b 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>
2026-09-03 09:57:34 +02:00

123 lines
4.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));
}
}