diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs index 6c048447..bb673bab 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs @@ -10,11 +10,6 @@ namespace AcDream.App.Rendering; /// methods — the cutover changes ORDER only. internal sealed partial class RetailPViewPassExecutor { - private IReadOnlySet? _walkTerrainInViewLandcells; - - internal void SetWalkTerrainInViewLandcells(IReadOnlySet? cells) => - _walkTerrainInViewLandcells = cells; - /// The walk's single sky turn: retail draws GameSky once /// inside LScape::draw, 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 } } - /// The walk's per-slice terrain turn — the terrain block of - /// DrawLandscapeSlice (scissor + terrain clip + slice planes + - /// NDC AABB), without the sky/entity halves the walk owns separately. - internal void DrawWalkTerrainSlice( - RetailPViewFrameInput frame, ClipFrameAssembly clipAssembly, int sliceIndex) + /// S3 chunk 3 (§9.2 B1): the walk's per-land-cell terrain + /// turn — retail RenderDeviceD3D::DrawLandCell @0x0059f120, + /// batched (S3 §9.2 B2). UNCLIPPED for BOTH root kinds — retail never + /// view-clips terrain (LScape::draw draws whole blocks; the + /// walk's own CellInView 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. + internal void DrawWalkLandCellBatch( + RetailPViewFrameInput frame, + uint landblockId, + IReadOnlyList<(int SideCellCount, int CellIndex)> cells) { - ReadOnlySpan 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(); } /// The walk's punch-fan turn — DrawPortalPolyInternal @@ -182,8 +139,10 @@ internal sealed partial class RetailPViewPassExecutor /// inside LScape::draw clipped by the active views; the per-slice /// scissor+clip here reproduces today's pixels while the driver still sees /// ONE sky turn). -/// → the terrain block of -/// DrawLandscapeSlice (scissor + terrain clip + slice planes). +/// → 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. /// EnvCellRenderer opaque + /// transparent-ordered for ONE cell (retail DrawEnvCell /// @0x0059f170 draws per cell at its flood turn). @@ -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) { diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index ed6be9cb..11de8e88 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -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 diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index 67381655..33f6ed34 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -71,6 +71,11 @@ public sealed partial class TerrainModernRenderer : IDisposable private readonly HashSet _walkVisibleLandblocks = new(); private DrawElementsIndirectCommand[] _deicScratch = Array.Empty(); + // 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 } } + /// + /// S3 chunk 3 (§9.2 B1/B2): the walk's per-land-cell terrain draw — + /// retail RenderDeviceD3D::DrawLandCell @0x0059f120, batched. + /// Submits 's index runs for every + /// entry of as ONE indirect draw, UNCLIPPED: + /// retail never view-clips terrain — the walk's own per-cell + /// CellInView admission (RetailFrameWalk.DrawLandscape's + /// DrawLandCell gate) already decided which cells reach here, so + /// there is no frustum test and no inViewLandcells filter here, + /// unlike the non-walk entry point this supersedes + /// for the walk path only ( 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. + /// + 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); + } + + /// + /// S3 chunk 3 (§9.1 R3): retail LOD cell (side , LOD coords (X, Y) = (cellIndex / side, + /// cellIndex % side)) covers cx ∈ [X·8/n, (X+1)·8/n), + /// cy ∈ [Y·8/n, (Y+1)·8/n) of the 8×8 cell-major mesh index + /// buffer (LandblockMesh.Build: cy outer, cx + /// inner, 6 indices per cell, indices[i] = i). Appends one + /// contiguous run per covered cy 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. + /// + 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) diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs index 48faaf42..32ac7217 100644 --- a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs +++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs @@ -147,7 +147,11 @@ public sealed class RetailFrameWalk } /// LScape::draw: visibility per active view, then blocks - /// far-to-near, cells far-to-near, buildings at their cell's turn. + /// far-to-near, cells far-to-near; per cell, S3 chunk 3's + /// (this cell's terrain, + /// in-view cells only) fires BEFORE the building's turn and the + /// object-list turn (, + /// every cell under alwaysDrawObjects). 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 diff --git a/src/AcDream.App/Rendering/Walk/WalkEvents.cs b/src/AcDream.App/Rendering/Walk/WalkEvents.cs index 6fac78f5..037166b8 100644 --- a/src/AcDream.App/Rendering/Walk/WalkEvents.cs +++ b/src/AcDream.App/Rendering/Walk/WalkEvents.cs @@ -85,6 +85,29 @@ public interface IWalkEventSink /// void OnLandscapeViews(WalkPortalView activeViews) { } + /// + /// S3 chunk 3 (§9.2 B1): fires once per admitted land cell, STRICTLY + /// BEFORE that same cell's building/object-list turn — + /// RenderDeviceD3D::DrawBlock @0x005a17c0's loop 2 + /// (@0x005a197d): if (IsInView) DrawLandCell(cell) + /// @0x005a19c0, THEN if (alwaysDrawObjects || IsInView) + /// DrawSortCell(cell) @0x005a19e6. Retail draws this land cell's + /// terrain at the block's current LOD — + /// names one cell in the block's CURRENT × coarse grid, + /// the SAME LOD cell + /// 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 (CellInView[cellIndex] != Outside); + /// an out-of-view cell under alwaysDrawObjects still gets its + /// object-list turn but + /// NO terrain turn (retail's own DrawSortCell-without- + /// DrawLandCell case). Default no-op — every pre-S3-chunk-3 sink + /// continues to compile and behave identically. + /// + void OnLandCellTurn(uint landblockId, int sideCellCount, int cellIndex) { } + /// /// Fires once per visited landscape cell, AFTER that cell's building /// turn (if any) — RenderDeviceD3D::DrawSortCell @0x0059f140 diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index a9e0bd31..4de980e8 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -102,17 +102,24 @@ internal interface IWalkFrameLeafRenderer /// calls this exactly once per frame's Landscape turn). void DrawSky(); - /// LScape::grab_visible_cells's terrain mesh, once per - /// ACTIVE clip slice — is caller-supplied - /// ('s activeTerrainSliceCount) - /// since FW3.2b-1 does not wire ClipFrameAssembler/ - /// ViewconeCuller (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 DrawLandCell/ - /// DrawObjCell interleave, recorded here rather than ported, since - /// terrain itself carries no walk event today. - void DrawTerrainSlice(int sliceIndex); + /// S3 chunk 3 (§9.2 B1/B2): retail RenderDeviceD3D:: + /// DrawLandCell @0x0059f120 — one landblock's terrain at one or + /// more admitted LOD cells, submitted together as ONE indirect draw. + /// merges consecutive same-slot + /// 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 / + /// stream mark interposes. carries (side, + /// cellIndex) pairs in event (draw) order — the R3 index-run arithmetic + /// (contiguous runs inside the landblock's 384-index slot, + /// TerrainModernRenderer.AppendCellIndexRuns) runs per entry. + /// Draws UNCLIPPED for both root kinds: retail never view-clips terrain + /// (LScape::draw draws whole blocks; the exit-seal/interior- + /// repaint turn owns aperture exactness afterward, S3 §9.1 R4/R5). + void DrawLandCellBatch( + uint landblockId, IReadOnlyList<(int SideCellCount, int CellIndex)> cells); /// One committed cell's EnvCell shell — /// PView::DrawCells's DrawEnvCell @0x005a4abe. Retail first @@ -259,9 +266,16 @@ internal enum WalkFrameEventKind : byte /// . Sky, - /// — - /// is the slice index. - TerrainSlice, + /// S3 chunk 3: + /// for ONE admitted land cell — is + /// the owning landblock id, packs + /// (sideCellCount << 8) | cellIndex (side ∈ {1,2,4,8}, + /// cellIndex < side², both fit comfortably below the 8-bit shift). + /// merges a run of consecutive same-landblock + /// entries into ONE + /// call (B2's order-preserving batching) rather than replaying them + /// one at a time. + LandCell, /// — /// 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 /// /// The one mark rule that reproduces the whole frame script: /// before EVERY leaf-renderer event (, -/// TerrainSlice, CellShell, LandscapeFlush, +/// LandCell, CellShell, LandscapeFlush, /// ClearInteriorDepth, ExitSeals, PunchFan) and before /// every event, Collect /// records a if the stream grew @@ -549,6 +563,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource private readonly List _floodViewRouteScratch = new(); private WalkPlane _lookInCyPlane; + /// S3 chunk 3 (§9.2 B2): 's reusable + /// batch-merge scratch — the (side, cellIndex) pairs of a run of + /// consecutive same-landblock + /// events, cleared and repopulated at each batch boundary. + private readonly List<(int SideCellCount, int CellIndex)> _landCellBatchScratch = new(); + private readonly HashSet _cellShellsDrawnThisFrame = new(); // Chunk 6 review F1: one particle turn per cell per render stamp (see EmitCellContentsTurn). private readonly HashSet _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 } } + /// S3 chunk 3 (§9.2 B1): records this admitted land cell's + /// terrain turn. sideCellCount/cellIndex 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. + 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 cells) diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index d527789f..aa361e8e 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -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 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), diff --git a/tests/AcDream.App.Tests/Rendering/TerrainLandCellIndexRunsTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainLandCellIndexRunsTests.cs new file mode 100644 index 00000000..6845c7e1 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TerrainLandCellIndexRunsTests.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Linq; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// S3 chunk 3 (§9.3 T3): retail LOD cell (side n, LOD coords +/// (X, Y)) covers cx ∈ [X·8/n, (X+1)·8/n), cy ∈ +/// [Y·8/n, (Y+1)·8/n) of the 8×8 cell-major mesh index buffer +/// (LandblockMesh.Build: cy outer, cx inner, 6 indices +/// per cell). is +/// pure arithmetic — no baked range table — so this pins it directly, with +/// no GPU device needed. +/// +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( + () => 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( + () => TerrainModernRenderer.AppendCellIndexRuns(side, cellIndex, runs)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs index 681e011a..68f5a896 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs @@ -8,7 +8,36 @@ public sealed class RetailFrameWalkTests private sealed class Recorder : IWalkEventSink { public readonly List Events = new(); - public void Emit(in WalkEvent walkEvent) => Events.Add(walkEvent); + + /// S3 chunk 3 (§9.3 T2): the SAME sequence + /// reads plus LC/SC land-cell turns interleaved at the + /// exact point fires + /// them — proves the terrain/building/object-turn ORDER, not just + /// each vocabulary in isolation. + public readonly List 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(); + var insideIds = new List(); + 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() { diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index 87a6c906..cd001a5c 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -47,6 +47,7 @@ public sealed class WalkFrameDriverTests public readonly List Punches = new(); public readonly List Shells = new(); public readonly List AlphaPendingAtBarrier = new(); + public readonly List<(uint LandblockId, int CellCount)> LandCellBatches = new(); /// S3 chunk 2: the exit-seal polygon count this fake /// reports back to the driver (B2 — @@ -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(); + 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) ───────────────────────────────────────────────── diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkLandCellOrderTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkLandCellOrderTests.cs new file mode 100644 index 00000000..f6a0172c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkLandCellOrderTests.cs @@ -0,0 +1,181 @@ +using AcDream.App.Rendering.Walk; + +namespace AcDream.App.Tests.Rendering.Walk; + +/// +/// S3 chunk 3 (§9.3 T1): + +/// + +/// reproduce frame +/// 2's LC and SC 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. +/// +/// Viewer: block f4/18, sq cell (1, 1) (the frame-2 `P` +/// line: x 36.6, y 24.0 — floor(36.6/24)=1, floor(24.0/24)=1). +/// Grid radius (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 — +/// skips a null/Outside slot regardless of why it's empty); each block's +/// is set DIRECTLY from the +/// transcript's own LC membership (this test is an ORDER pin, not a second +/// visibility-math proof — that is WalkVisibilityMathTests's +/// job) so is the ONLY production +/// order machinery under test. +/// +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 frames = WalkOracleTrace.Load( + "docs/research/2026-09-01-overhaul/oh-capture", fixtureName + ".walk"); + WalkOracleFrame frame2 = Assert.Single(frames, f => f.Number == 2); + + List 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(); + var blockSeen = new HashSet(); + var sideByBlock = new Dictionary(); + var scCountByBlock = new Dictionary(); + var lcMembersByBlock = new Dictionary>(); + + 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(); + } + + 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 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(); + var producedSc = new List(); + 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 expectedLc = landCellEvents + .Where(e => e.Kind == WalkOracleEventKind.LandCell) + .Select(e => e.CellId!.Value) + .ToList(); + List expectedSc = landCellEvents + .Where(e => e.Kind == WalkOracleEventKind.SortCell) + .Select(e => e.CellId!.Value) + .ToList(); + + Assert.Equal(expectedSc, producedSc); + Assert.Equal(expectedLc, producedLc); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTrace.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTrace.cs index 11d30e08..9ee75af0 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTrace.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTrace.cs @@ -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 Load(string fixtureName) + => Load("docs/research/2026-08-30-fw-walk-oracle", fixtureName); + + /// S3 chunk 3 (§9.3 T1): loads a fixture from an arbitrary + /// repo-relative — the OH captures live under + /// docs/research/2026-09-01-overhaul/oh-capture/, a different + /// directory than the FW0 still fixtures the single-argument overload + /// defaults to. + public static IReadOnlyList 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); } /// The camera pose dumped at the frame marker (raw dwords from @@ -170,6 +196,14 @@ public enum WalkOracleEventKind Building, DrawInside, DrawCells, + + /// S3 chunk 3: LC <cellid>RenderDeviceD3D:: + /// DrawLandCell @0x0059f120 entry. + LandCell, + + /// S3 chunk 3: SC <cellid>RenderDeviceD3D:: + /// DrawSortCell @0x0059f140 entry. + SortCell, } public sealed record WalkOracleEvent( @@ -191,4 +225,10 @@ public sealed record WalkOracleEvent( public static WalkOracleEvent DrawCells( int outsideViewCount, int declaredCount, IReadOnlyList cells) => new(WalkOracleEventKind.DrawCells, null, outsideViewCount, declaredCount, cells); + + public static WalkOracleEvent LandCell(uint cellId) + => new(WalkOracleEventKind.LandCell, cellId, 0, 0, Array.Empty()); + + public static WalkOracleEvent SortCell(uint cellId) + => new(WalkOracleEventKind.SortCell, cellId, 0, 0, Array.Empty()); }