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>
194 lines
8.2 KiB
C#
194 lines
8.2 KiB
C#
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 <= 1 ? 8 : ring == 2 ? 4 : ring <= 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 3–4, 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));
|
||
}
|