feat(render) Campaign FW3.1: production walk world data behind the seam

The retail frame walk's world model now materializes from production
landblock-build owners through the legal IDatReaderWriter seam, with
zero frame wiring (FW3.2 roots the frame):

- WalkCellFactory: WalkCell built in the SAME pass as LoadedCell
  (EnvCellLandblockBuild.BuildVisibilityCell) from the raw portal
  Flags/polygons/planes/stab lists already parsed there; stored as
  LoadedCell.Walk, committed atomically with the cell. The
  fixture-pinned decodes (inverse-0x2 portal side, 0xFFFF->0xFFFFFFFF
  exit widening) live here.
- WalkBuildingFactory + WalkBuildingRegistry: the production
  WalkBuilding build (drawing BSP with PORT nodes, degrade ladder,
  portal sides/stab lists, sort center, model frame) from the SAME
  LandBlockInfo the streaming build already fetches, under the
  factory's existing DAT lock - closing the gap where BuildingLoader
  drops every walk field at load.
- WalkLandscapeAssembler: the retail 51x51 viewer-centred grid
  (mid_radius 25) fed incrementally from landblock publish/retire;
  per-block z-slab (heightTable[max]+200 / [min]-1) computed
  worker-side in LandblockBuildFactory from the heights already in
  hand. O(1) SetViewer on same-block frames.
- WalkProductionFrameContext: the walk's frame contexts over
  CellVisibility + WalkBuildingRegistry with a generic
  inverse-view-projection ray caster (rays feed cross products only -
  scale-free) and the znear=0.1 CY plane.
- Publication: LandblockRenderPublisher owns both walk registries,
  publishing in the same AdvanceCompleteOne step as BuildingRegistry
  and retiring in RemoveBuildingRegistry - same commit, same
  retirement, no new ticket stage.

Conformance: ALL TEN oracle fixtures replay identically through the
PRODUCTION builders (WalkProductionWorldConformanceTests) - same
signatures as the test adapter, first run. Known gap documented for
FW3.2: far-tier landblocks carry no EnvCell transaction, so their
z-slab never reaches the assembler.

Suites: full Release build 0 warnings; Walk lane 186/1 skip;
hermetic 6,738/0 (+24); RuntimeDatAccessArchitectureTests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-30 13:19:22 +02:00
parent b95850defe
commit b10ad662b0
14 changed files with 1711 additions and 2 deletions

View file

@ -12,6 +12,7 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Walk;
namespace AcDream.App.Rendering;
@ -114,6 +115,21 @@ public sealed class LoadedCell
/// test fixtures use low ids for interior cells, so keying on the id would misfire.
/// </summary>
public bool IsOutdoorNode;
/// <summary>
/// Campaign FW3.1: the retail frame walk's model of this cell (portal
/// side/exact-match decode, portal polygons + planes, stab list,
/// UNLIFTED transform), built atomically alongside this
/// <see cref="LoadedCell"/> by
/// <c>EnvCellLandblockBuildBuilder.BuildVisibilityCell</c> — see
/// <c>WalkCellFactory.FromParsed</c>. Null only for hand-built test
/// fixtures that construct a <see cref="LoadedCell"/> directly instead of
/// through the streaming build. <c>WalkProductionFrameContext.GetVisible</c>
/// is the walk's sole read of this field — the committed registry
/// (<see cref="CellVisibility.TryGetCell"/>) is the walk's only cell
/// source; its dead BFS role is unrelated.
/// </summary>
public WalkCell? Walk { get; internal set; }
}
/// <summary>

View file

@ -0,0 +1,191 @@
using System.Numerics;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering.Walk;
/// <summary>
/// Campaign FW3.1 — production <see cref="WalkBuilding"/> construction,
/// ported from the FW1 conformance harness
/// (tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs::BuildBuildings
/// /ConvertDrawingBsp/BuildGfxPolygon) to the legal
/// <see cref="IDatReaderWriter"/> seam. This closes the FW3.1 gap: today's
/// <c>Wb.BuildingLoader.AddBuilding</c> (update thread) reads
/// <c>BuildingInfo.Portals</c> only for BFS seeding and drops ModelId,
/// Frame, portal Flags/StabList, and the drawing BSP the frame walk needs —
/// this factory reads the SAME <c>BuildingInfo</c> plus the GfxObj/
/// GfxObjDegradeInfo chain, on the worker thread inside
/// <c>LandblockBuildFactory</c> where <see cref="IDatReaderWriter"/> access
/// is legal (<c>BuildingLoader</c> itself never touches DATs — it runs
/// update-thread, after the streaming build already completed).
///
/// Adjudication toggles the FW1 harness carried as public mutable fields for
/// sweep testing are PINNED here to their winning arms (2026-08-30 data-pin,
/// verified against the ten oracle traces):
/// <list type="bullet">
/// <item>GfxObj portal-polygon planes are NOT flipped (the plain
/// first-three-vertices cross survives every building-portal fixture).</item>
/// <item>Building portal_side is the INVERSE of the cell decode's 0x2 bit
/// (<c>WalkWorldDatAdapter.BuildingSideMode == 1</c>'s winning arm).</item>
/// </list>
/// </summary>
public static class WalkBuildingFactory
{
/// <summary>One built <see cref="WalkBuilding"/> plus its landblock-local
/// placement transform (<paramref name="WorldTransform"/> already has the
/// caller's block offset baked in — see <see cref="Build"/>).</summary>
public sealed record Entry(
WalkBuilding Building, Matrix4x4 WorldTransform, Matrix4x4 InverseWorldTransform);
/// <summary>Builds every building of one landblock. <paramref name="lbOffset"/>
/// is the SAME landblock-local world offset every other production
/// builder in this streaming job uses (<c>LandblockBuildFactory</c>'s
/// <c>lbOffset</c>/<c>worldOffset</c> convention) — baked directly into
/// each building's world transform, which is mathematically identical to
/// the FW1 harness's two-step compose (build landblock-local, then
/// post-multiply the block offset translation): translations commute,
/// so <c>R·T(origin)·T(lbOffset) == R·T(origin+lbOffset)</c>.</summary>
public static List<Entry> Build(
IDatReaderWriter dats, uint landblockId,
IReadOnlyList<BuildingInfo>? buildingInfos, Vector3 lbOffset)
{
var result = new List<Entry>();
if (buildingInfos is null)
return result;
uint lbMask = landblockId & 0xFFFF0000u;
foreach (BuildingInfo buildingInfo in buildingInfos)
{
Vector3 origin = new(
buildingInfo.Frame.Origin.X,
buildingInfo.Frame.Origin.Y,
buildingInfo.Frame.Origin.Z);
int cellX = (int)MathF.Floor(origin.X / 24f);
int cellY = (int)MathF.Floor(origin.Y / 24f);
uint positionCellId = lbMask | (uint)(cellX * 8 + cellY + 1);
var portals = new WalkBldPortal[buildingInfo.Portals.Count];
for (int i = 0; i < portals.Length; i++)
{
BuildingPortal portal = buildingInfo.Portals[i];
portals[i] = new WalkBldPortal
{
PortalSide = DecodeBuildingSide((ushort)portal.Flags),
ExactMatch = ((ushort)portal.Flags & 0x1) != 0,
OtherCellId = portal.OtherCellId == 0xFFFF
? 0xFFFFFFFFu
: lbMask | portal.OtherCellId,
OtherPortalId = unchecked((short)portal.OtherPortalId),
StabList = portal.StabList.Select(s => lbMask | s).ToArray(),
};
}
WalkBspNode? bsp = null;
Vector3 sortCenter = Vector3.Zero;
var degradeLevels = new List<WalkBuildingDegradeLevel>();
if (dats.Get<GfxObj>(buildingInfo.ModelId) is GfxObj gfxObj)
{
bsp = ConvertDrawingBsp(gfxObj, gfxObj.DrawingBSP?.Root);
sortCenter = new Vector3(gfxObj.SortCenter.X, gfxObj.SortCenter.Y, gfxObj.SortCenter.Z);
if (gfxObj.DIDDegrade != 0
&& dats.Get<GfxObjDegradeInfo>(gfxObj.DIDDegrade)
is GfxObjDegradeInfo degradeInfo)
{
foreach (GfxObjInfo level in degradeInfo.Degrades)
{
WalkBspNode? levelBsp = null;
if (level.Id != 0
&& dats.Get<GfxObj>((uint)level.Id) is GfxObj levelGfx)
{
levelBsp = ConvertDrawingBsp(levelGfx, levelGfx.DrawingBSP?.Root);
}
degradeLevels.Add(
new WalkBuildingDegradeLevel(level.MinDist, level.IdealDist, level.MaxDist, levelBsp));
}
}
}
Matrix4x4 worldTransform =
Matrix4x4.CreateFromQuaternion(buildingInfo.Frame.Orientation)
* Matrix4x4.CreateTranslation(origin + lbOffset);
Matrix4x4.Invert(worldTransform, out Matrix4x4 inverse);
result.Add(new Entry(
new WalkBuilding
{
PositionCellId = positionCellId,
Portals = portals,
DrawingBsp = bsp,
DegradeLevels = degradeLevels.ToArray(),
SortCenter = sortCenter,
},
worldTransform,
inverse));
}
return result;
}
private static int DecodeBuildingSide(ushort flags) => (flags & 0x2) != 0 ? 0 : 1;
private static WalkBspNode? ConvertDrawingBsp(GfxObj gfxObj, DrawingBSPNode? node)
{
if (node is null) return null;
var converted = new WalkBspNode
{
SplittingPlane = new WalkPlane(node.SplittingPlane.Normal, node.SplittingPlane.D),
IsFail = node.Type == BSPNodeType.Leaf,
PosNode = ConvertDrawingBsp(gfxObj, node.PosNode),
NegNode = ConvertDrawingBsp(gfxObj, node.NegNode),
};
if (node.Type == BSPNodeType.Portal && node.Portals is not null)
{
var refs = new List<WalkPortalRef>(node.Portals.Count);
foreach (PortalRef portalRef in node.Portals)
{
WalkPolygon? polygon = BuildGfxPolygon(gfxObj, portalRef.PolyId);
if (polygon is not null)
refs.Add(new WalkPortalRef
{
PortalIndex = portalRef.PortalIndex,
Polygon = polygon,
});
}
converted.InPortals = refs.ToArray();
}
return converted;
}
private static WalkPolygon? BuildGfxPolygon(GfxObj gfxObj, ushort polygonId)
{
if (!gfxObj.Polygons.TryGetValue(polygonId, out Polygon? poly)
|| poly is null || poly.VertexIds.Count < 3)
{
return null;
}
return BuildPolygonFromVertices(
poly.VertexIds,
id => gfxObj.VertexArray.Vertices.TryGetValue((ushort)id, out SWVertex? v)
? new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z)
: null);
}
private static WalkPolygon? BuildPolygonFromVertices(
IReadOnlyList<short> vertexIds, Func<short, Vector3?> resolve)
{
var vertices = new Vector3[vertexIds.Count];
for (int i = 0; i < vertexIds.Count; i++)
{
Vector3? v = resolve(vertexIds[i]);
if (v is null) return null;
vertices[i] = v.Value;
}
Vector3 normal = Vector3.Normalize(
Vector3.Cross(vertices[1] - vertices[0], vertices[2] - vertices[0]));
return new WalkPolygon
{
Vertices = vertices,
Plane = new WalkPlane(normal, -Vector3.Dot(normal, vertices[0])),
};
}
}

View file

@ -0,0 +1,75 @@
using System.Diagnostics.CodeAnalysis;
namespace AcDream.App.Rendering.Walk;
/// <summary>
/// Campaign FW3.1 — the walk's per-landblock <see cref="WalkBuilding"/>
/// registry: the production sibling of
/// <see cref="AcDream.App.Rendering.Wb.BuildingRegistry"/>. Publish/retire
/// mirror that registry's landblock lifecycle exactly — both are committed
/// together in <c>LandblockRenderPublisher.AdvanceCompleteOne</c>'s
/// <c>BuildingRegistryCommitted</c> step and retired together from
/// <c>LandblockRenderPublisher.RemoveBuildingRegistry</c> — because a walk
/// building and its BFS-derived <c>Wb.Building</c> counterpart come from the
/// SAME <c>BuildingInfo</c> array at the SAME landblock commit.
///
/// The reverse index (<see cref="TryGetEntry"/>) exists because
/// <see cref="WalkProductionFrameContext"/> resolves a building's placement
/// by REFERENCE during the portal pass — <c>ViewpointInBuilding</c>,
/// <c>ViewerDistanceTo</c>, and <c>ClipBuildingPolygon</c> are called many
/// times per building per frame once FW3.2 wires the walk into the render
/// loop, so this must be O(1), not a per-landblock scan.
/// </summary>
public sealed class WalkBuildingRegistry
{
private readonly Dictionary<uint, IReadOnlyList<WalkBuildingFactory.Entry>> _byLandblock = new();
private readonly Dictionary<WalkBuilding, WalkBuildingFactory.Entry> _byBuilding = new();
/// <summary>Atomically replaces one landblock's complete building set.
/// Mirrors <c>Wb.BuildingRegistry</c>'s "no partial landblock" commit
/// discipline — <paramref name="entries"/> is the immutable, fully-built
/// list carried by the streaming worker's
/// <c>Wb.EnvCellLandblockBuild.WalkBuildings</c>.</summary>
public void Publish(uint landblockId, IReadOnlyList<WalkBuildingFactory.Entry> entries)
{
uint key = landblockId & 0xFFFF0000u;
if (_byLandblock.TryGetValue(key, out IReadOnlyList<WalkBuildingFactory.Entry>? previous))
{
foreach (WalkBuildingFactory.Entry entry in previous)
_byBuilding.Remove(entry.Building);
}
_byLandblock[key] = entries;
foreach (WalkBuildingFactory.Entry entry in entries)
_byBuilding[entry.Building] = entry;
}
/// <summary>Removes every building of one landblock. Safe to call on a
/// landblock that never published (no-op).</summary>
public void Retire(uint landblockId)
{
uint key = landblockId & 0xFFFF0000u;
if (_byLandblock.Remove(key, out IReadOnlyList<WalkBuildingFactory.Entry>? previous))
{
foreach (WalkBuildingFactory.Entry entry in previous)
_byBuilding.Remove(entry.Building);
}
}
/// <summary>The buildings of one landblock, or empty when none are
/// published (unloaded, far-tier, or no <c>BuildingInfo</c> entries).</summary>
public IReadOnlyList<WalkBuildingFactory.Entry> GetBuildings(uint landblockId) =>
_byLandblock.TryGetValue(landblockId & 0xFFFF0000u, out IReadOnlyList<WalkBuildingFactory.Entry>? list)
? list
: Array.Empty<WalkBuildingFactory.Entry>();
/// <summary>O(1) reverse lookup: this building's committed placement.
/// False means the building is not (or no longer) committed — the walk
/// must treat that as a hard desync, never a silent skip (the FW
/// fail-loud rule).</summary>
public bool TryGetEntry(
WalkBuilding building, [MaybeNullWhen(false)] out WalkBuildingFactory.Entry entry) =>
_byBuilding.TryGetValue(building, out entry);
/// <summary>Number of landblocks with committed buildings (diagnostics).</summary>
public int LandblockCount => _byLandblock.Count;
}

View file

@ -0,0 +1,167 @@
using System.Numerics;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Environment = DatReaderWriter.DBObjs.Environment;
namespace AcDream.App.Rendering.Walk;
/// <summary>
/// Campaign FW3.1 — production <see cref="WalkCell"/> construction, ported
/// from the FW1 conformance harness
/// (tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs::BuildCell)
/// to the legal <see cref="IDatReaderWriter"/> seam
/// (<c>RuntimeDatAccessArchitectureTests</c> forbids raw
/// <c>DatCollection</c> anywhere in this assembly outside
/// <c>RuntimeDatCollectionFactory</c>/<c>DatCollectionAdapter</c>).
///
/// <see cref="FromParsed"/> is the shared core: it takes an ALREADY-FETCHED
/// <see cref="EnvCell"/>/<see cref="CellStruct"/> pair and the production
/// UNLIFTED cell transform. <c>EnvCellLandblockBuildBuilder.BuildVisibilityCell</c>
/// (src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs) calls this
/// directly — it already has both parsed and the transform in hand from its
/// own <see cref="LoadedCell"/> construction, so no extra DAT read is spent.
/// <see cref="BuildCell"/> is the standalone DAT-reading convenience for
/// callers that only have a cell id (the walk landscape's stab-list
/// look-in cells, and the FW3 conformance harness).
///
/// Conventions (fixture-pinned; the ten oracle traces are the referee — see
/// <c>docs/research/2026-08-30-fw-walk-oracle/</c>):
/// <list type="bullet">
/// <item>portal_side = <c>(Flags &amp; 0x2) != 0 ? 0 : 1</c> — the INVERSE of
/// the bit, matching <c>EnvCellLandblockBuildBuilder.BuildVisibilityCell</c>'s
/// existing <c>PortalClipPlane.InsideSide</c> decode
/// (<c>(Flags &amp; 0x2) == 0 ? 1 : 0</c> — the same formula).</item>
/// <item>Exit-portal sentinel widening: dat <c>0xFFFF</c> →
/// <c>0xFFFFFFFF</c> (<see cref="WalkCellPortal.OtherCellId"/> is a full
/// cell id; the dat field is a 16-bit local id).</item>
/// <item>Portal-polygon plane: first-three-vertices cross,
/// <c>d = dot(N, p0)</c> — the same formula
/// <c>BuildVisibilityCell</c>'s <c>PortalClipPlane</c> already uses.</item>
/// </list>
/// </summary>
public static class WalkCellFactory
{
/// <summary>DAT-reading entry point: fetches EnvCell/Environment/CellStruct
/// and builds their landblock-local (<paramref name="blockOffset"/>
/// added) UNLIFTED transform before delegating to <see cref="FromParsed"/>.
/// Returns null when the cell, its environment, or its cell structure
/// cannot be resolved (unregistered/degenerate cell — same as the FW1
/// test adapter).</summary>
public static WalkCell? BuildCell(IDatReaderWriter dats, uint cellId, Vector3 blockOffset)
{
if (dats.Get<EnvCell>(cellId) is not EnvCell envCell)
return null;
if (dats.Get<Environment>(0x0D000000u | envCell.EnvironmentId) is not Environment environment
|| !environment.Cells.TryGetValue(envCell.CellStructure, out CellStruct? cellStruct)
|| cellStruct is null)
{
return null;
}
Matrix4x4 worldTransform =
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation)
* Matrix4x4.CreateTranslation(
envCell.Position.Origin.X + blockOffset.X,
envCell.Position.Origin.Y + blockOffset.Y,
envCell.Position.Origin.Z + blockOffset.Z);
Matrix4x4.Invert(worldTransform, out Matrix4x4 inverse);
return FromParsed(cellId, envCell, cellStruct, worldTransform, inverse);
}
/// <summary>Builds a <see cref="WalkCell"/> from an already-fetched
/// EnvCell/CellStruct pair and the production cell transform. No DAT
/// access — safe to call from a context that already holds the reader
/// lock for an unrelated read.</summary>
public static WalkCell FromParsed(
uint cellId, EnvCell envCell, CellStruct cellStruct,
Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform)
{
uint lbMask = cellId & 0xFFFF0000u;
int portalCount = envCell.CellPortals.Count;
var portals = new WalkCellPortal[portalCount];
var polygons = new WalkPolygon[portalCount];
for (int i = 0; i < portalCount; i++)
{
CellPortal portal = envCell.CellPortals[i];
portals[i] = new WalkCellPortal
{
OtherCellId = portal.OtherCellId == 0xFFFF
? 0xFFFFFFFFu
: lbMask | portal.OtherCellId,
PolygonIndex = i,
PortalSide = ((ushort)portal.Flags & 0x2) != 0 ? 0 : 1,
ExactMatch = ((ushort)portal.Flags & 0x1) != 0,
OtherPortalId = unchecked((short)portal.OtherPortalId),
};
polygons[i] = BuildPolygon(cellStruct, portal.PolygonId) ?? new WalkPolygon();
}
return new WalkCell
{
CellId = cellId,
Portals = portals,
PortalPolygons = polygons,
StabList = envCell.VisibleCells.Select(v => lbMask | v).ToArray(),
WorldTransform = worldTransform,
InverseWorldTransform = inverseWorldTransform,
};
}
/// <summary>Every interior cell of one landblock, bounded by
/// <c>LandBlockInfo.NumCells</c> (the production bound —
/// <c>LandblockBuildFactory.BuildInteriorEntitiesForStreaming</c> uses
/// the same count; the FW1 test adapter instead scans until the first
/// miss, which is equivalent for well-formed installed DATs).</summary>
public static Dictionary<uint, WalkCell> BuildInteriorCells(
IDatReaderWriter dats, uint landblockId, Vector3 blockOffset,
Dictionary<uint, WalkCell>? into = null)
{
uint lbMask = landblockId & 0xFFFF0000u;
Dictionary<uint, WalkCell> cells = into ?? new Dictionary<uint, WalkCell>();
if (dats.Get<LandBlockInfo>(lbMask | 0xFFFEu) is not LandBlockInfo info)
return cells;
uint firstCellId = lbMask | 0x0100u;
for (uint offset = 0; offset < info.NumCells; offset++)
{
WalkCell? cell = BuildCell(dats, firstCellId + offset, blockOffset);
if (cell is not null)
cells[cell.CellId] = cell;
}
return cells;
}
private static WalkPolygon? BuildPolygon(CellStruct cellStruct, ushort polygonId)
{
if (!cellStruct.Polygons.TryGetValue(polygonId, out Polygon? poly)
|| poly is null || poly.VertexIds.Count < 3)
{
return null;
}
return BuildPolygonFromVertices(
poly.VertexIds,
id => cellStruct.VertexArray.Vertices.TryGetValue((ushort)id, out SWVertex? v)
? new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z)
: null);
}
private static WalkPolygon? BuildPolygonFromVertices(
IReadOnlyList<short> vertexIds, Func<short, Vector3?> resolve)
{
var vertices = new Vector3[vertexIds.Count];
for (int i = 0; i < vertexIds.Count; i++)
{
Vector3? v = resolve(vertexIds[i]);
if (v is null) return null;
vertices[i] = v.Value;
}
Vector3 normal = Vector3.Normalize(
Vector3.Cross(vertices[1] - vertices[0], vertices[2] - vertices[0]));
return new WalkPolygon
{
Vertices = vertices,
Plane = new WalkPlane(normal, -Vector3.Dot(normal, vertices[0])),
};
}
}

View file

@ -0,0 +1,191 @@
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
{
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));
}

View file

@ -0,0 +1,174 @@
using System.Numerics;
namespace AcDream.App.Rendering.Walk;
/// <summary>
/// Campaign FW3.1 — the production <see cref="IWalkFrameContext"/> /
/// <see cref="IRetailFrameWalkContext"/> / <see cref="IWalkBuildingFrameContext"/>
/// implementation: cells resolve through the committed
/// <see cref="CellVisibility"/> registry (<c>LoadedCell.Walk</c>, per the FW
/// binding rule that the walk consumes ONLY the committed registry —
/// <c>CellVisibility.TryGetCell</c>, never a synthetic/dead-code path);
/// buildings resolve through <see cref="WalkBuildingRegistry"/>; the camera
/// pose, projection, and viewport are supplied by the caller.
///
/// Deliberately NOT coupled to any concrete camera type — FW3.2 will supply
/// live values from <c>WorldCameraFrame</c>; wiring that in is additive.
/// The ray caster is a GENERIC inverse-view-projection unprojection, not the
/// capture client's exact <c>Render::xinvscale</c>/<c>tx</c>/<c>vdst</c>
/// constants the FW1 conformance harness's <c>WalkTraceReplayContext</c>
/// uses — those are FIXTURE PINS specific to the 1024×720 capture client,
/// not production values. This is safe because
/// <see cref="WalkCopyView.Append"/> only ever CROSS-PRODUCTS these rays to
/// build view-edge planes: a uniform scale or additive offset along a ray
/// cancels out of every cross product it feeds, so any two points along the
/// true eye ray (near/far unprojection) are observably equivalent to
/// retail's exact construction for this contract.
///
/// One instance is a per-frame value (like <c>WalkTraceReplayContext</c>):
/// construct fresh each frame with that frame's camera pose.
/// </summary>
public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrameWalkContext
{
/// <summary><c>Render::znear</c> @0x0081ec84 / <c>set_vdst</c> @0x0054b240.</summary>
public const float ZNear = 0.1f;
private sealed class InverseViewProjectionRayCaster : IWalkRayCaster
{
private readonly Matrix4x4 _inverseViewProjection;
private readonly float _viewportWidth;
private readonly float _viewportHeight;
public InverseViewProjectionRayCaster(
Matrix4x4 viewProjection, float viewportWidth, float viewportHeight)
{
if (!Matrix4x4.Invert(viewProjection, out _inverseViewProjection))
{
throw new ArgumentException(
"The walk's view-projection matrix must be invertible.",
nameof(viewProjection));
}
_viewportWidth = viewportWidth;
_viewportHeight = viewportHeight;
}
/// <summary>Screen space: origin top-left, +Y down — matching
/// <see cref="WalkScreenClip.TransformToScreen"/> and
/// <see cref="WalkCopyView.AppendFullViewportQuad"/>'s quad
/// winding.</summary>
public Vector3 RayThrough(float screenX, float screenY)
{
float ndcX = screenX / _viewportWidth * 2f - 1f;
float ndcY = 1f - screenY / _viewportHeight * 2f;
Vector4 near = Vector4.Transform(
new Vector4(ndcX, ndcY, 0f, 1f), _inverseViewProjection);
Vector4 far = Vector4.Transform(
new Vector4(ndcX, ndcY, 1f, 1f), _inverseViewProjection);
Vector3 nearWorld = new Vector3(near.X, near.Y, near.Z) / near.W;
Vector3 farWorld = new Vector3(far.X, far.Y, far.Z) / far.W;
return farWorld - nearWorld;
}
}
private readonly CellVisibility _cells;
private readonly WalkBuildingRegistry _buildings;
private readonly Matrix4x4 _viewProjection;
private readonly IWalkRayCaster _rays;
private Vector2[] _activeViewVerts = new Vector2[32];
private int _activeViewVertCount;
public WalkProductionFrameContext(
CellVisibility cells,
WalkBuildingRegistry buildings,
Vector3 worldViewpoint,
Vector3 forward,
Matrix4x4 viewProjection,
float viewportWidth,
float viewportHeight)
{
_cells = cells ?? throw new ArgumentNullException(nameof(cells));
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
WorldViewpoint = worldViewpoint;
_viewProjection = viewProjection;
ViewportWidth = viewportWidth;
ViewportHeight = viewportHeight;
_rays = new InverseViewProjectionRayCaster(viewProjection, viewportWidth, viewportHeight);
// The retail CY near plane: N = forward, d = -dot(eye, forward) - znear.
CyPlane = new WalkPlane(forward, -Vector3.Dot(worldViewpoint, forward) - ZNear);
}
public Vector3 WorldViewpoint { get; }
public float ViewportWidth { get; }
public float ViewportHeight { get; }
public WalkPlane CyPlane { get; }
public IWalkRayCaster Rays => _rays;
public IWalkFrameContext CellContext => this;
public Vector3 ViewpointIn(WalkCell cell)
=> Vector3.Transform(WorldViewpoint, cell.InverseWorldTransform);
public Matrix4x4 ObjectToClip(WalkCell cell) => cell.WorldTransform * _viewProjection;
/// <summary>Resolves through the committed <see cref="CellVisibility"/>
/// registry only — a missing/uncommitted cell returns null (retail's
/// portal-skip behavior; the FW binding rule requires any such miss to
/// be diagnostically counted under a flag rather than silently swallowed
/// once a caller wires one up — this seam does not itself log, matching
/// <see cref="IWalkFrameContext.GetVisible"/>'s documented contract).</summary>
public WalkCell? GetVisible(uint cellId)
=> _cells.TryGetCell(cellId, out LoadedCell? cell) ? cell?.Walk : null;
public void SetActiveView(WalkPortalView views, int index)
{
WalkViewPoly poly = views.View.Polys[index];
if (_activeViewVerts.Length < poly.VertexCount)
_activeViewVerts = new Vector2[poly.VertexCount];
for (int k = 0; k < poly.VertexCount; k++)
_activeViewVerts[k] = views.View.Vertices[poly.VertexIndex + k].Point;
_activeViewVertCount = poly.VertexCount;
}
public Vector3 ViewpointInBuilding(WalkBuilding building)
=> Vector3.Transform(WorldViewpoint, GetEntry(building).InverseWorldTransform);
/// <summary><c>CPhysicsPart::UpdateViewerDistance</c> @0x0050e030: the
/// distance to the part's SCALED sort center, not the position origin.</summary>
public float ViewerDistanceTo(WalkBuilding building)
{
WalkBuildingFactory.Entry entry = GetEntry(building);
return Vector3.Distance(
WorldViewpoint, Vector3.Transform(building.SortCenter, entry.WorldTransform));
}
public int ClipBuildingPolygon(
WalkBuilding building, WalkPolygon polygon, int side, Span<WalkScreenPoint> output)
{
Matrix4x4 objectToClip = GetEntry(building).WorldTransform * _viewProjection;
Span<WalkScreenPoint> projected = stackalloc WalkScreenPoint[polygon.Vertices.Length];
for (int i = 0; i < polygon.Vertices.Length; i++)
{
projected[i] = WalkScreenClip.TransformToScreen(
polygon.Vertices[i], objectToClip, ViewportWidth, ViewportHeight);
}
if (side != 0)
projected.Reverse();
return WalkScreenClip.ClipAgainstView(
projected, _activeViewVerts.AsSpan(0, _activeViewVertCount), output);
}
private WalkBuildingFactory.Entry GetEntry(WalkBuilding building)
{
if (!_buildings.TryGetEntry(building, out var entry))
{
// Fail loud (the PV3 post-mortem rule): a building the walk is
// actively placing MUST be committed in the same registry the
// walk was handed — a miss here is a walk/registry desync, never
// a silently-skipped building.
throw new InvalidOperationException(
"WalkProductionFrameContext was asked to place a WalkBuilding " +
"that is not committed in its WalkBuildingRegistry.");
}
return entry;
}
}

View file

@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.App.Rendering.Walk;
using AcDream.Core.Rendering.Wb;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
@ -34,22 +35,48 @@ public sealed class EnvCellLandblockBuild
public EnvCellLandblockBuild(
uint landblockId,
IEnumerable<LoadedCell> visibilityCells,
IEnumerable<EnvCellShellPlacement> shells)
IEnumerable<EnvCellShellPlacement> shells,
IEnumerable<WalkBuildingFactory.Entry>? walkBuildings = null,
float walkMaxZ = 0f,
float walkMinZ = 0f)
{
LandblockId = landblockId;
VisibilityCells = visibilityCells.ToImmutableArray();
Shells = shells.ToImmutableArray();
WalkBuildings = (walkBuildings ?? Enumerable.Empty<WalkBuildingFactory.Entry>()).ToImmutableArray();
WalkMaxZ = walkMaxZ;
WalkMinZ = walkMinZ;
uint expectedPrefix = landblockId & 0xFFFF0000u;
if (VisibilityCells.Any(cell => (cell.CellId & 0xFFFF0000u) != expectedPrefix))
throw new ArgumentException("A visibility cell belongs to a different landblock.", nameof(visibilityCells));
if (Shells.Any(shell => (shell.CellId & 0xFFFF0000u) != expectedPrefix))
throw new ArgumentException("A render shell belongs to a different landblock.", nameof(shells));
if (WalkBuildings.Any(entry => (entry.Building.PositionCellId & 0xFFFF0000u) != expectedPrefix))
throw new ArgumentException("A walk building belongs to a different landblock.", nameof(walkBuildings));
}
public uint LandblockId { get; }
public ImmutableArray<LoadedCell> VisibilityCells { get; }
public ImmutableArray<EnvCellShellPlacement> Shells { get; }
/// <summary>Campaign FW3.1: this landblock's walk building placements
/// (<c>WalkBuildingFactory.Build</c>, computed worker-side alongside
/// <see cref="VisibilityCells"/>). Committed atomically with the rest of
/// this transaction — see <c>WalkBuildingRegistry.Publish</c> at
/// <c>LandblockRenderPublisher.AdvanceCompleteOne</c>.</summary>
public ImmutableArray<WalkBuildingFactory.Entry> WalkBuildings { get; }
/// <summary>Campaign FW3.1: this landblock's retail z-slab
/// (<c>heightTable[maxByte] + 200</c>) — the walk landscape's per-block
/// visibility bound (<c>WalkLandscapeAssembler.PublishLandblock</c>).
/// Computed once, worker-side, from the SAME heightmap bytes + height
/// table the terrain mesh build already reads — no extra DAT access.</summary>
public float WalkMaxZ { get; }
/// <summary>Campaign FW3.1: this landblock's retail z-slab
/// (<c>heightTable[minByte] 1</c>). See <see cref="WalkMaxZ"/>.</summary>
public float WalkMinZ { get; }
}
/// <summary>
@ -62,6 +89,9 @@ public sealed class EnvCellLandblockBuildBuilder
private readonly uint _landblockId;
private readonly List<LoadedCell> _visibilityCells = new();
private readonly List<EnvCellShellPlacement> _shells = new();
private readonly List<WalkBuildingFactory.Entry> _walkBuildings = new();
private float _walkMaxZ;
private float _walkMinZ;
private bool _built;
public EnvCellLandblockBuildBuilder(uint landblockId)
@ -69,6 +99,31 @@ public sealed class EnvCellLandblockBuildBuilder
_landblockId = landblockId;
}
/// <summary>Campaign FW3.1: registers this landblock's walk building
/// placements (<c>WalkBuildingFactory.Build</c>'s output). Additive to
/// the existing cell/shell accumulation — called once per landblock from
/// <c>LandblockBuildFactory.BuildInteriorEntitiesForStreaming</c>, where
/// <c>LandBlockInfo</c> is already in hand.</summary>
public void AddWalkBuildings(IEnumerable<WalkBuildingFactory.Entry> entries)
{
if (_built)
throw new InvalidOperationException("This landblock cell build is already complete.");
_walkBuildings.AddRange(entries);
}
/// <summary>Campaign FW3.1: registers this landblock's retail z-slab
/// (<see cref="EnvCellLandblockBuild.WalkMaxZ"/>/<see cref="EnvCellLandblockBuild.WalkMinZ"/>).
/// Independent of interior-cell/building presence — every near-tier
/// landblock has outdoor terrain heights, so
/// <c>LandblockBuildFactory.BuildLocked</c> calls this unconditionally.</summary>
public void SetWalkZSlab(float maxZ, float minZ)
{
if (_built)
throw new InvalidOperationException("This landblock cell build is already complete.");
_walkMaxZ = maxZ;
_walkMinZ = minZ;
}
public void AddCell(
uint envCellId,
EnvCell envCell,
@ -116,7 +171,8 @@ public sealed class EnvCellLandblockBuildBuilder
if (_built)
throw new InvalidOperationException("This landblock cell build is already complete.");
_built = true;
return new EnvCellLandblockBuild(_landblockId, _visibilityCells, _shells);
return new EnvCellLandblockBuild(
_landblockId, _visibilityCells, _shells, _walkBuildings, _walkMaxZ, _walkMinZ);
}
/// <summary>
@ -290,6 +346,20 @@ public sealed class EnvCellLandblockBuildBuilder
PortalPolygons = portalPolygons,
VisibleCells = visibleCells,
SeenOutside = envCell.Flags.HasFlag(EnvCellFlags.SeenOutside),
// Campaign FW3.1: the walk's model of this cell, built from the
// SAME EnvCell/CellStruct/transform this LoadedCell was just
// built from — WalkCellFactory.FromParsed does its own portal
// pass (independent of the clipPlanes/portalPolygons above; the
// two consumers decode the same dat fields for different needs)
// so it stays a direct, auditable port of the FW1 test adapter
// rather than reshaping LoadedCell's own list layout around it.
// cellTransform/inverse here are UNLIFTED (this method's
// cellOrigin/cellTransform params are the caller's
// physicsCellOrigin/physicsCellTransform — see
// LandblockBuildFactory.BuildInteriorEntitiesForStreaming, which
// keeps the +0.02 m ShellDrawLiftZ out of this transform) —
// exactly what the walk wants.
Walk = WalkCellFactory.FromParsed(envCellId, envCell, cellStruct, cellTransform, inverse),
};
}
}

View file

@ -178,6 +178,15 @@ public sealed class LandblockBuildFactory
worldOffset,
_heightTable));
var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
// Campaign FW3.1: the walk landscape's per-block z-slab
// (CLandBlock::calc_lighting-adjacent unpack @0x0052f1d0). Every
// near-tier landblock has outdoor terrain heights regardless of
// whether it has interior cells or buildings, so this is
// unconditional — unlike WalkBuildings (added below, inside
// BuildInteriorEntitiesForStreaming, where LandBlockInfo is already
// in hand).
(float walkMaxZ, float walkMinZ) = ComputeWalkZSlab(baseLoaded.Heightmap.Height);
envCellBuild.SetWalkZSlab(walkMaxZ, walkMinZ);
merged.AddRange(BuildInteriorEntitiesForStreaming(
landblockId,
lbX,
@ -448,6 +457,17 @@ public sealed class LandblockBuildFactory
(lbY - origin.CenterY) * 192f,
0f);
// Campaign FW3.1: the walk's building placements (portals + drawing
// BSP + degrade ladder + sort center), read from the SAME
// LandBlockInfo.Buildings this method already fetched above — no
// extra DAT read. This closes the gap where Wb.BuildingLoader
// .AddBuilding (update thread, no DAT access) keeps only
// ModelId/Frame/portal-flags/stab-lists for its own BFS seeding and
// drops everything the frame walk needs.
envCellBuild.AddWalkBuildings(
AcDream.App.Rendering.Walk.WalkBuildingFactory.Build(
_dats, landblockId, lbInfo.Buildings, lbOffset));
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
// for the full bit layout + history. Distinct from scenery (0x80000000+) and
// landblock stabs (0xC0000000+, ids from LandblockLoader).
@ -649,6 +669,24 @@ public sealed class LandblockBuildFactory
}
/// <summary>Campaign FW3.1: retail's per-block z-slab
/// (<c>WalkLandscapeDatBuilder</c>'s port target —
/// <c>CLandBlock::calc_lighting</c>-adjacent unpack @0x0052f1d0):
/// <c>max_zval = heightTable[maxByte] + 200</c>,
/// <c>min_zval = heightTable[minByte] - 1</c>, over the landblock's own
/// 81-byte heightmap. Uses <see cref="_heightTable"/> — already a
/// constructor field, so this needs no additional DAT read.</summary>
private (float MaxZ, float MinZ) ComputeWalkZSlab(byte[] heights)
{
byte maxByte = 0, minByte = 255;
foreach (byte h in heights)
{
if (h > maxByte) maxByte = h;
if (h < minByte) minByte = h;
}
return (_heightTable[maxByte] + 200f, _heightTable[minByte] - 1f);
}
private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY)
{
uint landblockX = (block.Id >> 24) & 0xFFu;

View file

@ -3,6 +3,7 @@ using System.Diagnostics;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Walk;
using AcDream.Core.Terrain;
namespace AcDream.App.Streaming;
@ -102,6 +103,17 @@ public sealed class LandblockRenderPublisher
private readonly Action<EnvCellLandblockBuild>? _prepareEnvCells;
private readonly Action<uint>? _removeEnvCells;
private readonly Dictionary<uint, BuildingRegistry> _buildingRegistries = new();
// Campaign FW3.1: the walk's production world-data siblings of
// _buildingRegistries. Owned here (not injected) exactly like
// _buildingRegistries — no caller constructs a LandblockRenderPublisher
// with pre-existing walk state. Committed/retired at the same points as
// the building registry (see AdvanceCompleteOne / RemoveBuildingRegistry
// below) because both come from the same landblock's BuildingInfo array
// at the same commit. No frame reads these yet (FW3.2 wires the walk
// into the render loop) — publication happens now so FW3.1's
// conformance gate exercises the production commit path.
private readonly WalkBuildingRegistry _walkBuildingRegistry = new();
private readonly WalkLandscapeAssembler _walkLandscape = new();
private long _beginCount;
private long _completeCount;
@ -148,6 +160,15 @@ public sealed class LandblockRenderPublisher
public IReadOnlyCollection<BuildingRegistry> BuildingRegistries =>
_buildingRegistries.Values;
/// <summary>Campaign FW3.1: the walk's production building placements,
/// keyed by landblock, committed alongside <see cref="BuildingRegistries"/>.</summary>
public WalkBuildingRegistry WalkBuildings => _walkBuildingRegistry;
/// <summary>Campaign FW3.1: the walk's production viewer-centred
/// landscape grid, fed from the same landblock commits as
/// <see cref="WalkBuildings"/>.</summary>
public WalkLandscapeAssembler WalkLandscape => _walkLandscape;
public LandblockRenderPublisherDiagnostics Diagnostics => new(
_beginCount,
_completeCount,
@ -339,6 +360,21 @@ public sealed class LandblockRenderPublisher
_buildingRegistries[registryKey] =
completedBuildings.Registry;
}
// Campaign FW3.1: publish this landblock's walk world data in
// the same step — build.EnvCells carries WalkBuildings/WalkMaxZ
// /WalkMinZ (computed worker-side by LandblockBuildFactory) for
// every near-tier build, even one with zero interior cells
// (the z-slab is unconditional; WalkBuildings is naturally empty
// when LandBlockInfo has none).
if (build.EnvCells is { } walkEnvCells)
{
_walkBuildingRegistry.Publish(landblockId, walkEnvCells.WalkBuildings);
_walkLandscape.PublishLandblock(
landblockId,
walkEnvCells.WalkMaxZ,
walkEnvCells.WalkMinZ,
walkEnvCells.WalkBuildings);
}
publication.BuildingRegistryCommitted = true;
}
else if (publication.EnvCellPublication is { } envCellPublication
@ -397,6 +433,14 @@ public sealed class LandblockRenderPublisher
public void RemoveBuildingRegistry(uint landblockId)
{
_buildingRegistries.Remove(landblockId & 0xFFFF0000u);
// Campaign FW3.1: retire the walk's siblings at the SAME retirement
// stage — they were committed together above, so they retire
// together (no new LandblockRetirementStage; folding into the
// existing BuildingRegistry stage keeps the retirement ticket state
// machine unchanged, which is the minimal/additive choice for a
// slice that does no frame wiring yet).
_walkBuildingRegistry.Retire(landblockId);
_walkLandscape.RetireLandblock(landblockId);
_buildingRegistryRemovalCount++;
}