acdream/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs
Erik 03f63686cc feat(render) Campaign FW3.2b-1: the walk frame driver
WalkFrameDriver executes one full static-content frame from the walk's
turns so GPU command-buffer order equals retail's walk order. One rule
does the interleaving: the accumulated OrderedDrawStream flushes
through SubmitOrderedStream immediately before EVERY non-stream draw
(sky, terrain slice, cell shell, punch fan, alpha barrier).

Turn script, all decomp-cited and two of them corrected in review:
- Interior flood: per cell IN FLOOD ORDER, shell first then contents
  (PView::DrawCells @0x005a4840: DrawEnvCell @0x005a4abe precedes
  DrawObjCellForDummies @0x005a4b0d).
- Landscape: sky once, terrain per active slice, then blocks
  far-to-near; per cell the building turn precedes the cell's outdoor
  statics (DrawSortCell @0x0059f140).
- Building (DrawBuilding @0x0059f2a0): the BLD probe event stays at
  entry, but the ENTIRE body - alpha barrier, portal passes, shell -
  sits inside retail's gfxobj[deg_level]!=0 gate @0x0059f2d3, and the
  order is FlushAlphaList @0x0059f30b -> the two-pass punch/look-in
  walk -> THEN the shell draw @0x0059f345. The driver review caught
  both the missing gate and a shell-before-punch inversion; fixed
  with the addresses cited.

Walk seam: three additive default-implemented IWalkEventSink hooks
(OnLandscapeCellTurn / OnBuildingTurn / OnBuildingShellTurn /
OnPunchGeometry) - every existing sink and all FW1 conformance
fixtures unchanged. WalkLandBlock gains LandblockId for the cell-id
encoding. Leaf draws go through IWalkFrameLeafRenderer so FW3.2b-2
wires the real renderers and the referee suite runs on fakes +
RecordingGpuDevice.

Flagged for FW3.2b-2/FW4 adjudication (documented in code):
FlushFartherThan(building distance) vs retail flush-all
FlushAlphaList(0f); terrain-before-statics within the landscape turn.

Suites: full Release build 0 warnings; Walk lane 200/1 skip;
InstalledDat Walk conformance 40/1 untouched; hermetic 6,752/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:23:17 +02:00

194 lines
8.2 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.Numerics;
namespace AcDream.App.Rendering.Walk;
/// <summary>
/// Campaign FW3.1 — the production sibling of the FW1 conformance harness's
/// <c>WalkLandscapeDatBuilder</c>
/// (tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeDatBuilder.cs): owns
/// retail's viewer-centred block grid (LScape <c>mid_radius</c> = 25 → a
/// 51×51 window, recon 2026-08-30) and its LOD ring pyramid
/// (<see cref="SideCellCountForRing"/>).
///
/// Unlike the test builder — which re-scans the whole grid from a
/// <c>DatCollection</c> on every call — this class is fed INCREMENTALLY from
/// landblock commit/retire (<see cref="PublishLandblock"/>/
/// <see cref="RetireLandblock"/>), the same lifecycle
/// <c>Wb.BuildingRegistry</c> and <c>CellVisibility</c> already follow. Each
/// landblock's z-slab and building placements are computed ONCE, on the
/// streaming worker thread, at landblock-build time
/// (<c>LandblockBuildFactory</c>/<c>WalkBuildingFactory</c>) — publishing
/// here is a pure dictionary write, no DAT or GfxObj work.
///
/// <see cref="SetViewer"/> recentres the grid on the camera's block, porting
/// the test builder's viewer-cell recompute; it goes further by also
/// reflowing the 51×51 window when the camera crosses into a different
/// landblock (the test builder never needs this — every moving fixture stays
/// within its anchor block). Reflow only touches already-published data (a
/// dictionary lookup per slot); no DAT access ever happens here.
///
/// No frame currently calls <see cref="SetViewer"/> or reads
/// <see cref="Landscape"/> — FW3.2 wires <c>RetailFrameWalk</c> into the
/// render loop. This class exists now so that wiring is additive, and so the
/// FW3.1 conformance gate can exercise the SAME assembly code path
/// production will drive.
/// </summary>
public sealed class WalkLandscapeAssembler
{
/// <summary>Retail LScape <c>mid_radius</c> (recon 2026-08-30) — NOT
/// acdream's streaming radius (two-tier N1/N2), which is smaller. Blocks
/// outside the streamed window simply stay unpublished (null slots).</summary>
public const int MidRadius = 25;
public const int GridWidth = MidRadius * 2 + 1;
private sealed class BlockData
{
public required float MaxZ;
public required float MinZ;
public required IReadOnlyList<WalkBuildingFactory.Entry> Buildings;
}
private readonly Dictionary<(int X, int Y), BlockData> _blocks = new();
private int _viewerBlockX = int.MinValue;
private int _viewerBlockY = int.MinValue;
public WalkLandscape Landscape { get; } = new()
{
MidWidth = GridWidth,
Blocks = new WalkLandBlock?[GridWidth * GridWidth],
ViewerBlockX = MidRadius,
ViewerBlockY = MidRadius,
};
/// <summary>Landblock commit
/// (<c>LandblockRenderPublisher.AdvanceCompleteOne</c>'s
/// <c>BuildingRegistryCommitted</c> step): register/replace this
/// landblock's z-slab and building placements. If the landblock falls
/// within the CURRENT window, the visible slot refreshes immediately.</summary>
public void PublishLandblock(
uint landblockId, float maxZ, float minZ,
IReadOnlyList<WalkBuildingFactory.Entry> buildings)
{
(int bx, int by) = BlockCoords(landblockId);
_blocks[(bx, by)] = new BlockData { MaxZ = maxZ, MinZ = minZ, Buildings = buildings };
RefreshSlotIfWindowed(bx, by);
}
/// <summary>Landblock retirement (<c>LandblockRenderPublisher
/// .RemoveBuildingRegistry</c>): drop this landblock's data. Safe to call
/// on a landblock that never published (no-op).</summary>
public void RetireLandblock(uint landblockId)
{
(int bx, int by) = BlockCoords(landblockId);
_blocks.Remove((bx, by));
RefreshSlotIfWindowed(bx, by);
}
/// <summary>Recentres the grid on the camera's block and updates the
/// sub-block cell offsets (retail's viewer recentre —
/// <c>WalkLandscapeDatBuilder.SetViewer</c>'s port). When the camera's
/// block hasn't changed since the last call, this only updates
/// <see cref="WalkLandscape.ViewerCellX"/>/<see cref="WalkLandscape.ViewerCellY"/>
/// — zero allocation, matching the test builder's lightweight path. A
/// block crossing additionally reflows the 51×51 window from already-
/// published data (a bounded, allocation-free dictionary-lookup pass —
/// no DAT/GfxObj work, which already happened at publish time).</summary>
public void SetViewer(uint cameraCellId, Vector3 cameraOrigin)
{
int cameraBlockX = (int)(cameraCellId >> 24);
int cameraBlockY = (int)((cameraCellId >> 16) & 0xFF);
if (cameraBlockX != _viewerBlockX || cameraBlockY != _viewerBlockY)
{
_viewerBlockX = cameraBlockX;
_viewerBlockY = cameraBlockY;
RebuildWindow();
}
uint low = cameraCellId & 0xFFFFu;
if (low >= 1 && low <= 0x40)
{
int cellIndex = (int)low - 1;
Landscape.ViewerCellX = cellIndex / 8;
Landscape.ViewerCellY = cellIndex % 8;
}
else
{
// Interior camera: derive the outside-projected landcell from
// the camera origin, matching the test builder's fallback
// (Position::get_outside_cell_id via SmartBox::RenderNormalMode's
// seen_outside arm).
Landscape.ViewerCellX = Math.Clamp((int)MathF.Floor(cameraOrigin.X / 24f), 0, 7);
Landscape.ViewerCellY = Math.Clamp((int)MathF.Floor(cameraOrigin.Y / 24f), 0, 7);
}
}
/// <summary><c>ring &lt;= 1 ? 8 : ring == 2 ? 4 : ring &lt;= 4 ? 2 : 1</c>
/// — the live-observed resolution pyramid (recon 2026-08-30): 8×8 in the
/// 3×3 core, 4×4 at ring 2, 2×2 at rings 34, 1×1 beyond. Buildings only
/// attach at full resolution (ring ≤ 1) — the traces show no BLD beyond
/// ±1 block.</summary>
internal static int SideCellCountForRing(int ring)
=> ring <= 1 ? 8 : ring == 2 ? 4 : ring <= 4 ? 2 : 1;
internal static int RingOf(int gridX, int gridY)
=> Math.Max(Math.Abs(gridX - MidRadius), Math.Abs(gridY - MidRadius));
private void RebuildWindow()
{
for (int gx = 0; gx < GridWidth; gx++)
{
for (int gy = 0; gy < GridWidth; gy++)
{
int bx = _viewerBlockX + gx - MidRadius;
int by = _viewerBlockY + gy - MidRadius;
Landscape.Blocks[gx * GridWidth + gy] = BuildSlot(bx, by, gx, gy);
}
}
}
private void RefreshSlotIfWindowed(int bx, int by)
{
if (_viewerBlockX == int.MinValue)
return; // SetViewer has never run — no window to refresh yet.
int gx = bx - _viewerBlockX + MidRadius;
int gy = by - _viewerBlockY + MidRadius;
if (gx < 0 || gx >= GridWidth || gy < 0 || gy >= GridWidth)
return;
Landscape.Blocks[gx * GridWidth + gy] = BuildSlot(bx, by, gx, gy);
}
private WalkLandBlock? BuildSlot(int bx, int by, int gx, int gy)
{
if (bx < 0 || bx > 0xFF || by < 0 || by > 0xFF)
return null;
if (!_blocks.TryGetValue((bx, by), out BlockData? data))
return null;
int sideCellCount = SideCellCountForRing(RingOf(gx, gy));
var block = new WalkLandBlock
{
// Additive (Campaign FW3.2b-1): same (bx<<24 | by<<16) encoding
// BlockCoords decodes below — see WalkLandBlock.LandblockId.
LandblockId = (uint)bx << 24 | (uint)by << 16,
SideCellCount = sideCellCount,
MaxZ = data.MaxZ,
MinZ = data.MinZ,
};
block.EnsureCellArrays();
if (sideCellCount == 8)
{
foreach (WalkBuildingFactory.Entry entry in data.Buildings)
{
int cellIndex = (int)(entry.Building.PositionCellId & 0xFFFFu) - 1;
if (cellIndex >= 0 && cellIndex < 64)
block.CellBuildings[cellIndex] = entry.Building;
}
}
return block;
}
private static (int X, int Y) BlockCoords(uint landblockId)
=> ((int)((landblockId >> 24) & 0xFFu), (int)((landblockId >> 16) & 0xFFu));
}