diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs
index 31b9ebca..26034263 100644
--- a/src/AcDream.App/Rendering/CellVisibility.cs
+++ b/src/AcDream.App/Rendering/CellVisibility.cs
@@ -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.
///
public bool IsOutdoorNode;
+
+ ///
+ /// 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
+ /// by
+ /// EnvCellLandblockBuildBuilder.BuildVisibilityCell — see
+ /// WalkCellFactory.FromParsed. Null only for hand-built test
+ /// fixtures that construct a directly instead of
+ /// through the streaming build. WalkProductionFrameContext.GetVisible
+ /// is the walk's sole read of this field — the committed registry
+ /// () is the walk's only cell
+ /// source; its dead BFS role is unrelated.
+ ///
+ public WalkCell? Walk { get; internal set; }
}
///
diff --git a/src/AcDream.App/Rendering/Walk/WalkBuildingFactory.cs b/src/AcDream.App/Rendering/Walk/WalkBuildingFactory.cs
new file mode 100644
index 00000000..680d3124
--- /dev/null
+++ b/src/AcDream.App/Rendering/Walk/WalkBuildingFactory.cs
@@ -0,0 +1,191 @@
+using System.Numerics;
+using AcDream.Content;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+
+namespace AcDream.App.Rendering.Walk;
+
+///
+/// Campaign FW3.1 — production construction,
+/// ported from the FW1 conformance harness
+/// (tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs::BuildBuildings
+/// /ConvertDrawingBsp/BuildGfxPolygon) to the legal
+/// seam. This closes the FW3.1 gap: today's
+/// Wb.BuildingLoader.AddBuilding (update thread) reads
+/// BuildingInfo.Portals only for BFS seeding and drops ModelId,
+/// Frame, portal Flags/StabList, and the drawing BSP the frame walk needs —
+/// this factory reads the SAME BuildingInfo plus the GfxObj/
+/// GfxObjDegradeInfo chain, on the worker thread inside
+/// LandblockBuildFactory where access
+/// is legal (BuildingLoader 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):
+///
+/// - GfxObj portal-polygon planes are NOT flipped (the plain
+/// first-three-vertices cross survives every building-portal fixture).
+/// - Building portal_side is the INVERSE of the cell decode's 0x2 bit
+/// (WalkWorldDatAdapter.BuildingSideMode == 1's winning arm).
+///
+///
+public static class WalkBuildingFactory
+{
+ /// One built plus its landblock-local
+ /// placement transform ( already has the
+ /// caller's block offset baked in — see ).
+ public sealed record Entry(
+ WalkBuilding Building, Matrix4x4 WorldTransform, Matrix4x4 InverseWorldTransform);
+
+ /// Builds every building of one landblock.
+ /// is the SAME landblock-local world offset every other production
+ /// builder in this streaming job uses (LandblockBuildFactory's
+ /// lbOffset/worldOffset 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 R·T(origin)·T(lbOffset) == R·T(origin+lbOffset).
+ public static List Build(
+ IDatReaderWriter dats, uint landblockId,
+ IReadOnlyList? buildingInfos, Vector3 lbOffset)
+ {
+ var result = new List();
+ 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();
+ if (dats.Get(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(gfxObj.DIDDegrade)
+ is GfxObjDegradeInfo degradeInfo)
+ {
+ foreach (GfxObjInfo level in degradeInfo.Degrades)
+ {
+ WalkBspNode? levelBsp = null;
+ if (level.Id != 0
+ && dats.Get((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(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 vertexIds, Func 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])),
+ };
+ }
+}
diff --git a/src/AcDream.App/Rendering/Walk/WalkBuildingRegistry.cs b/src/AcDream.App/Rendering/Walk/WalkBuildingRegistry.cs
new file mode 100644
index 00000000..db1ad45c
--- /dev/null
+++ b/src/AcDream.App/Rendering/Walk/WalkBuildingRegistry.cs
@@ -0,0 +1,75 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace AcDream.App.Rendering.Walk;
+
+///
+/// Campaign FW3.1 — the walk's per-landblock
+/// registry: the production sibling of
+/// . Publish/retire
+/// mirror that registry's landblock lifecycle exactly — both are committed
+/// together in LandblockRenderPublisher.AdvanceCompleteOne's
+/// BuildingRegistryCommitted step and retired together from
+/// LandblockRenderPublisher.RemoveBuildingRegistry — because a walk
+/// building and its BFS-derived Wb.Building counterpart come from the
+/// SAME BuildingInfo array at the SAME landblock commit.
+///
+/// The reverse index () exists because
+/// resolves a building's placement
+/// by REFERENCE during the portal pass — ViewpointInBuilding,
+/// ViewerDistanceTo, and ClipBuildingPolygon 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.
+///
+public sealed class WalkBuildingRegistry
+{
+ private readonly Dictionary> _byLandblock = new();
+ private readonly Dictionary _byBuilding = new();
+
+ /// Atomically replaces one landblock's complete building set.
+ /// Mirrors Wb.BuildingRegistry's "no partial landblock" commit
+ /// discipline — is the immutable, fully-built
+ /// list carried by the streaming worker's
+ /// Wb.EnvCellLandblockBuild.WalkBuildings.
+ public void Publish(uint landblockId, IReadOnlyList entries)
+ {
+ uint key = landblockId & 0xFFFF0000u;
+ if (_byLandblock.TryGetValue(key, out IReadOnlyList? previous))
+ {
+ foreach (WalkBuildingFactory.Entry entry in previous)
+ _byBuilding.Remove(entry.Building);
+ }
+ _byLandblock[key] = entries;
+ foreach (WalkBuildingFactory.Entry entry in entries)
+ _byBuilding[entry.Building] = entry;
+ }
+
+ /// Removes every building of one landblock. Safe to call on a
+ /// landblock that never published (no-op).
+ public void Retire(uint landblockId)
+ {
+ uint key = landblockId & 0xFFFF0000u;
+ if (_byLandblock.Remove(key, out IReadOnlyList? previous))
+ {
+ foreach (WalkBuildingFactory.Entry entry in previous)
+ _byBuilding.Remove(entry.Building);
+ }
+ }
+
+ /// The buildings of one landblock, or empty when none are
+ /// published (unloaded, far-tier, or no BuildingInfo entries).
+ public IReadOnlyList GetBuildings(uint landblockId) =>
+ _byLandblock.TryGetValue(landblockId & 0xFFFF0000u, out IReadOnlyList? list)
+ ? list
+ : Array.Empty();
+
+ /// 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).
+ public bool TryGetEntry(
+ WalkBuilding building, [MaybeNullWhen(false)] out WalkBuildingFactory.Entry entry) =>
+ _byBuilding.TryGetValue(building, out entry);
+
+ /// Number of landblocks with committed buildings (diagnostics).
+ public int LandblockCount => _byLandblock.Count;
+}
diff --git a/src/AcDream.App/Rendering/Walk/WalkCellFactory.cs b/src/AcDream.App/Rendering/Walk/WalkCellFactory.cs
new file mode 100644
index 00000000..ccd0bd5b
--- /dev/null
+++ b/src/AcDream.App/Rendering/Walk/WalkCellFactory.cs
@@ -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;
+
+///
+/// Campaign FW3.1 — production construction, ported
+/// from the FW1 conformance harness
+/// (tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs::BuildCell)
+/// to the legal seam
+/// (RuntimeDatAccessArchitectureTests forbids raw
+/// DatCollection anywhere in this assembly outside
+/// RuntimeDatCollectionFactory/DatCollectionAdapter).
+///
+/// is the shared core: it takes an ALREADY-FETCHED
+/// / pair and the production
+/// UNLIFTED cell transform. EnvCellLandblockBuildBuilder.BuildVisibilityCell
+/// (src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs) calls this
+/// directly — it already has both parsed and the transform in hand from its
+/// own construction, so no extra DAT read is spent.
+/// 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
+/// docs/research/2026-08-30-fw-walk-oracle/):
+///
+/// - portal_side = (Flags & 0x2) != 0 ? 0 : 1 — the INVERSE of
+/// the bit, matching EnvCellLandblockBuildBuilder.BuildVisibilityCell's
+/// existing PortalClipPlane.InsideSide decode
+/// ((Flags & 0x2) == 0 ? 1 : 0 — the same formula).
+/// - Exit-portal sentinel widening: dat 0xFFFF →
+/// 0xFFFFFFFF ( is a full
+/// cell id; the dat field is a 16-bit local id).
+/// - Portal-polygon plane: first-three-vertices cross,
+/// d = −dot(N, p0) — the same formula
+/// BuildVisibilityCell's PortalClipPlane already uses.
+///
+///
+public static class WalkCellFactory
+{
+ /// DAT-reading entry point: fetches EnvCell/Environment/CellStruct
+ /// and builds their landblock-local (
+ /// added) UNLIFTED transform before delegating to .
+ /// Returns null when the cell, its environment, or its cell structure
+ /// cannot be resolved (unregistered/degenerate cell — same as the FW1
+ /// test adapter).
+ public static WalkCell? BuildCell(IDatReaderWriter dats, uint cellId, Vector3 blockOffset)
+ {
+ if (dats.Get(cellId) is not EnvCell envCell)
+ return null;
+ if (dats.Get(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);
+ }
+
+ /// Builds a 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.
+ 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,
+ };
+ }
+
+ /// Every interior cell of one landblock, bounded by
+ /// LandBlockInfo.NumCells (the production bound —
+ /// LandblockBuildFactory.BuildInteriorEntitiesForStreaming uses
+ /// the same count; the FW1 test adapter instead scans until the first
+ /// miss, which is equivalent for well-formed installed DATs).
+ public static Dictionary BuildInteriorCells(
+ IDatReaderWriter dats, uint landblockId, Vector3 blockOffset,
+ Dictionary? into = null)
+ {
+ uint lbMask = landblockId & 0xFFFF0000u;
+ Dictionary cells = into ?? new Dictionary();
+ if (dats.Get(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 vertexIds, Func 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])),
+ };
+ }
+}
diff --git a/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs b/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs
new file mode 100644
index 00000000..2eda6ecb
--- /dev/null
+++ b/src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs
@@ -0,0 +1,191 @@
+using System.Numerics;
+
+namespace AcDream.App.Rendering.Walk;
+
+///
+/// Campaign FW3.1 — the production sibling of the FW1 conformance harness's
+/// WalkLandscapeDatBuilder
+/// (tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeDatBuilder.cs): owns
+/// retail's viewer-centred block grid (LScape mid_radius = 25 → a
+/// 51×51 window, recon 2026-08-30) and its LOD ring pyramid
+/// ().
+///
+/// Unlike the test builder — which re-scans the whole grid from a
+/// DatCollection on every call — this class is fed INCREMENTALLY from
+/// landblock commit/retire (/
+/// ), the same lifecycle
+/// Wb.BuildingRegistry and CellVisibility already follow. Each
+/// landblock's z-slab and building placements are computed ONCE, on the
+/// streaming worker thread, at landblock-build time
+/// (LandblockBuildFactory/WalkBuildingFactory) — publishing
+/// here is a pure dictionary write, no DAT or GfxObj work.
+///
+/// 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 or reads
+/// — FW3.2 wires RetailFrameWalk 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.
+///
+public sealed class WalkLandscapeAssembler
+{
+ /// Retail LScape mid_radius (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).
+ 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 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,
+ };
+
+ /// Landblock commit
+ /// (LandblockRenderPublisher.AdvanceCompleteOne's
+ /// BuildingRegistryCommitted step): register/replace this
+ /// landblock's z-slab and building placements. If the landblock falls
+ /// within the CURRENT window, the visible slot refreshes immediately.
+ public void PublishLandblock(
+ uint landblockId, float maxZ, float minZ,
+ IReadOnlyList buildings)
+ {
+ (int bx, int by) = BlockCoords(landblockId);
+ _blocks[(bx, by)] = new BlockData { MaxZ = maxZ, MinZ = minZ, Buildings = buildings };
+ RefreshSlotIfWindowed(bx, by);
+ }
+
+ /// Landblock retirement (LandblockRenderPublisher
+ /// .RemoveBuildingRegistry): drop this landblock's data. Safe to call
+ /// on a landblock that never published (no-op).
+ public void RetireLandblock(uint landblockId)
+ {
+ (int bx, int by) = BlockCoords(landblockId);
+ _blocks.Remove((bx, by));
+ RefreshSlotIfWindowed(bx, by);
+ }
+
+ /// Recentres the grid on the camera's block and updates the
+ /// sub-block cell offsets (retail's viewer recentre —
+ /// WalkLandscapeDatBuilder.SetViewer's port). When the camera's
+ /// block hasn't changed since the last call, this only updates
+ /// /
+ /// — 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).
+ 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);
+ }
+ }
+
+ /// ring <= 1 ? 8 : ring == 2 ? 4 : ring <= 4 ? 2 : 1
+ /// — 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.
+ 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));
+}
diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs b/src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs
new file mode 100644
index 00000000..a71ea982
--- /dev/null
+++ b/src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs
@@ -0,0 +1,174 @@
+using System.Numerics;
+
+namespace AcDream.App.Rendering.Walk;
+
+///
+/// Campaign FW3.1 — the production /
+/// /
+/// implementation: cells resolve through the committed
+/// registry (LoadedCell.Walk, per the FW
+/// binding rule that the walk consumes ONLY the committed registry —
+/// CellVisibility.TryGetCell, never a synthetic/dead-code path);
+/// buildings resolve through ; 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 WorldCameraFrame; wiring that in is additive.
+/// The ray caster is a GENERIC inverse-view-projection unprojection, not the
+/// capture client's exact Render::xinvscale/tx/vdst
+/// constants the FW1 conformance harness's WalkTraceReplayContext
+/// uses — those are FIXTURE PINS specific to the 1024×720 capture client,
+/// not production values. This is safe because
+/// 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 WalkTraceReplayContext):
+/// construct fresh each frame with that frame's camera pose.
+///
+public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrameWalkContext
+{
+ /// Render::znear @0x0081ec84 / set_vdst @0x0054b240.
+ 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;
+ }
+
+ /// Screen space: origin top-left, +Y down — matching
+ /// and
+ /// 's quad
+ /// winding.
+ 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;
+
+ /// Resolves through the committed
+ /// 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
+ /// 's documented contract).
+ 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);
+
+ /// CPhysicsPart::UpdateViewerDistance @0x0050e030: the
+ /// distance to the part's SCALED sort center, not the position origin.
+ 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 output)
+ {
+ Matrix4x4 objectToClip = GetEntry(building).WorldTransform * _viewProjection;
+ Span 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;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs b/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs
index 9a1c02a0..66dff504 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs
@@ -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 visibilityCells,
- IEnumerable shells)
+ IEnumerable shells,
+ IEnumerable? walkBuildings = null,
+ float walkMaxZ = 0f,
+ float walkMinZ = 0f)
{
LandblockId = landblockId;
VisibilityCells = visibilityCells.ToImmutableArray();
Shells = shells.ToImmutableArray();
+ WalkBuildings = (walkBuildings ?? Enumerable.Empty()).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 VisibilityCells { get; }
public ImmutableArray Shells { get; }
+
+ /// Campaign FW3.1: this landblock's walk building placements
+ /// (WalkBuildingFactory.Build, computed worker-side alongside
+ /// ). Committed atomically with the rest of
+ /// this transaction — see WalkBuildingRegistry.Publish at
+ /// LandblockRenderPublisher.AdvanceCompleteOne.
+ public ImmutableArray WalkBuildings { get; }
+
+ /// Campaign FW3.1: this landblock's retail z-slab
+ /// (heightTable[maxByte] + 200) — the walk landscape's per-block
+ /// visibility bound (WalkLandscapeAssembler.PublishLandblock).
+ /// Computed once, worker-side, from the SAME heightmap bytes + height
+ /// table the terrain mesh build already reads — no extra DAT access.
+ public float WalkMaxZ { get; }
+
+ /// Campaign FW3.1: this landblock's retail z-slab
+ /// (heightTable[minByte] − 1). See .
+ public float WalkMinZ { get; }
}
///
@@ -62,6 +89,9 @@ public sealed class EnvCellLandblockBuildBuilder
private readonly uint _landblockId;
private readonly List _visibilityCells = new();
private readonly List _shells = new();
+ private readonly List _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;
}
+ /// Campaign FW3.1: registers this landblock's walk building
+ /// placements (WalkBuildingFactory.Build's output). Additive to
+ /// the existing cell/shell accumulation — called once per landblock from
+ /// LandblockBuildFactory.BuildInteriorEntitiesForStreaming, where
+ /// LandBlockInfo is already in hand.
+ public void AddWalkBuildings(IEnumerable entries)
+ {
+ if (_built)
+ throw new InvalidOperationException("This landblock cell build is already complete.");
+ _walkBuildings.AddRange(entries);
+ }
+
+ /// Campaign FW3.1: registers this landblock's retail z-slab
+ /// (/).
+ /// Independent of interior-cell/building presence — every near-tier
+ /// landblock has outdoor terrain heights, so
+ /// LandblockBuildFactory.BuildLocked calls this unconditionally.
+ 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);
}
///
@@ -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),
};
}
}
diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs
index 5654ebb8..14e508cd 100644
--- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs
+++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs
@@ -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
}
+ /// Campaign FW3.1: retail's per-block z-slab
+ /// (WalkLandscapeDatBuilder's port target —
+ /// CLandBlock::calc_lighting-adjacent unpack @0x0052f1d0):
+ /// max_zval = heightTable[maxByte] + 200,
+ /// min_zval = heightTable[minByte] - 1, over the landblock's own
+ /// 81-byte heightmap. Uses — already a
+ /// constructor field, so this needs no additional DAT read.
+ 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;
diff --git a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs
index 5578bc1a..8b5ff5c0 100644
--- a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs
+++ b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs
@@ -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? _prepareEnvCells;
private readonly Action? _removeEnvCells;
private readonly Dictionary _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 BuildingRegistries =>
_buildingRegistries.Values;
+ /// Campaign FW3.1: the walk's production building placements,
+ /// keyed by landblock, committed alongside .
+ public WalkBuildingRegistry WalkBuildings => _walkBuildingRegistry;
+
+ /// Campaign FW3.1: the walk's production viewer-centred
+ /// landscape grid, fed from the same landblock commits as
+ /// .
+ 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++;
}
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingRegistryTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingRegistryTests.cs
new file mode 100644
index 00000000..84a50776
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingRegistryTests.cs
@@ -0,0 +1,75 @@
+using System.Numerics;
+using AcDream.App.Rendering.Walk;
+
+namespace AcDream.App.Tests.Rendering.Walk;
+
+/// Campaign FW3.1 — hermetic (no DAT) coverage of the walk's
+/// production building registry: landblock-keyed publish/retire and the
+/// O(1) reverse index relies on.
+public sealed class WalkBuildingRegistryTests
+{
+ private static WalkBuildingFactory.Entry Entry(uint positionCellId) =>
+ new(new WalkBuilding { PositionCellId = positionCellId }, Matrix4x4.Identity, Matrix4x4.Identity);
+
+ [Fact]
+ public void Publish_MakesBuildingsFindableByLandblockAndByReference()
+ {
+ var registry = new WalkBuildingRegistry();
+ WalkBuildingFactory.Entry entry = Entry(0xA9B40001u);
+
+ registry.Publish(0xA9B4FFFFu, new[] { entry });
+
+ Assert.Same(entry, Assert.Single(registry.GetBuildings(0xA9B40100u)));
+ Assert.True(registry.TryGetEntry(entry.Building, out var found));
+ Assert.Same(entry, found);
+ Assert.Equal(1, registry.LandblockCount);
+ }
+
+ [Fact]
+ public void Publish_ReplacesThePreviousLandblockAndDropsItsReverseIndexEntries()
+ {
+ var registry = new WalkBuildingRegistry();
+ WalkBuildingFactory.Entry first = Entry(0xA9B40001u);
+ WalkBuildingFactory.Entry second = Entry(0xA9B40002u);
+ registry.Publish(0xA9B4FFFFu, new[] { first });
+
+ registry.Publish(0xA9B4FFFFu, new[] { second });
+
+ Assert.Same(second, Assert.Single(registry.GetBuildings(0xA9B4FFFFu)));
+ Assert.False(registry.TryGetEntry(first.Building, out _));
+ Assert.True(registry.TryGetEntry(second.Building, out _));
+ Assert.Equal(1, registry.LandblockCount);
+ }
+
+ [Fact]
+ public void Retire_RemovesTheLandblockAndItsReverseIndexEntries()
+ {
+ var registry = new WalkBuildingRegistry();
+ WalkBuildingFactory.Entry entry = Entry(0xA9B40001u);
+ registry.Publish(0xA9B4FFFFu, new[] { entry });
+
+ registry.Retire(0xA9B40100u); // any id sharing the landblock prefix
+
+ Assert.Empty(registry.GetBuildings(0xA9B4FFFFu));
+ Assert.False(registry.TryGetEntry(entry.Building, out _));
+ Assert.Equal(0, registry.LandblockCount);
+ }
+
+ [Fact]
+ public void Retire_UnknownLandblockIsANoOp()
+ {
+ var registry = new WalkBuildingRegistry();
+
+ registry.Retire(0xA9B4FFFFu);
+
+ Assert.Equal(0, registry.LandblockCount);
+ }
+
+ [Fact]
+ public void GetBuildings_UnpublishedLandblockReturnsEmpty()
+ {
+ var registry = new WalkBuildingRegistry();
+
+ Assert.Empty(registry.GetBuildings(0xA9B4FFFFu));
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeAssemblerTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeAssemblerTests.cs
new file mode 100644
index 00000000..99ff9734
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeAssemblerTests.cs
@@ -0,0 +1,148 @@
+using System.Numerics;
+using AcDream.App.Rendering.Walk;
+
+namespace AcDream.App.Tests.Rendering.Walk;
+
+/// Campaign FW3.1 — hermetic (no DAT) coverage of the production
+/// landscape assembler: publish/retire lifecycle, the LOD ring pyramid, and
+/// the viewer-recentre grid reflow. The DAT-backed equivalence to the FW1
+/// test builder is proven by WalkProductionWorldConformanceTests
+/// (Lane=InstalledDat); this class covers the incremental publish/retire
+/// machinery that harness doesn't exercise (it publishes and calls
+/// SetViewer exactly once per fixture).
+public sealed class WalkLandscapeAssemblerTests
+{
+ private const uint LandblockId = 0xA9B4FFFFu; // block (0xA9, 0xB4)
+ private const uint CameraCellId = 0xA9B40001u; // same block, outdoor landcell 1
+
+ private static int GridIndex(int gx, int gy) => gx * WalkLandscapeAssembler.GridWidth + gy;
+
+ private static int CenterIndex() =>
+ GridIndex(WalkLandscapeAssembler.MidRadius, WalkLandscapeAssembler.MidRadius);
+
+ [Fact]
+ public void PublishBeforeSetViewer_IsVisibleOnceSetViewerRuns()
+ {
+ var assembler = new WalkLandscapeAssembler();
+
+ assembler.PublishLandblock(LandblockId, maxZ: 100f, minZ: -5f, Array.Empty());
+ assembler.SetViewer(CameraCellId, Vector3.Zero);
+
+ WalkLandBlock? block = assembler.Landscape.Blocks[CenterIndex()];
+ Assert.NotNull(block);
+ Assert.Equal(8, block!.SideCellCount);
+ Assert.Equal(100f, block.MaxZ);
+ Assert.Equal(-5f, block.MinZ);
+ }
+
+ [Fact]
+ public void PublishAfterSetViewer_RefreshesTheAlreadyWindowedSlotImmediately()
+ {
+ var assembler = new WalkLandscapeAssembler();
+ assembler.SetViewer(CameraCellId, Vector3.Zero);
+
+ assembler.PublishLandblock(LandblockId, maxZ: 42f, minZ: 3f, Array.Empty());
+
+ Assert.Equal(42f, assembler.Landscape.Blocks[CenterIndex()]!.MaxZ);
+ }
+
+ [Fact]
+ public void RingPyramid_FarBlockDegradesSideCellCountAndNeverAttachesBuildings()
+ {
+ var assembler = new WalkLandscapeAssembler();
+ var building = new WalkBuildingFactory.Entry(
+ new WalkBuilding { PositionCellId = (LandblockId & 0xFFFF0000u) | 1u },
+ Matrix4x4.Identity, Matrix4x4.Identity);
+ assembler.PublishLandblock(LandblockId, 1f, 0f, new[] { building });
+
+ // Three blocks north of the camera's own block -> ring 3 -> SideCellCount 2.
+ const uint FarLandblockId = 0xA9B7FFFFu;
+ assembler.PublishLandblock(FarLandblockId, 1f, 0f, new[] { building });
+ assembler.SetViewer(CameraCellId, Vector3.Zero);
+
+ WalkLandBlock center = assembler.Landscape.Blocks[CenterIndex()]!;
+ WalkLandBlock far = assembler.Landscape.Blocks[
+ GridIndex(WalkLandscapeAssembler.MidRadius, WalkLandscapeAssembler.MidRadius + 3)]!;
+ Assert.Equal(8, center.SideCellCount);
+ Assert.Contains(center.CellBuildings, b => b is not null);
+ Assert.Equal(2, far.SideCellCount);
+ Assert.All(far.CellBuildings, Assert.Null);
+ }
+
+ [Fact]
+ public void RetireLandblock_NullsTheWindowedSlot()
+ {
+ var assembler = new WalkLandscapeAssembler();
+ assembler.PublishLandblock(LandblockId, 1f, 0f, Array.Empty());
+ assembler.SetViewer(CameraCellId, Vector3.Zero);
+ Assert.NotNull(assembler.Landscape.Blocks[CenterIndex()]);
+
+ assembler.RetireLandblock(LandblockId);
+
+ Assert.Null(assembler.Landscape.Blocks[CenterIndex()]);
+ }
+
+ [Fact]
+ public void RetireLandblock_UnpublishedLandblockIsANoOp()
+ {
+ var assembler = new WalkLandscapeAssembler();
+
+ assembler.RetireLandblock(LandblockId);
+
+ Assert.Null(assembler.Landscape.Blocks[CenterIndex()]);
+ }
+
+ [Fact]
+ public void SetViewer_RecentresTheWindowWhenTheCameraCrossesIntoAnotherBlock()
+ {
+ var assembler = new WalkLandscapeAssembler();
+ assembler.PublishLandblock(LandblockId, 7f, -1f, Array.Empty());
+ assembler.SetViewer(CameraCellId, Vector3.Zero);
+ Assert.NotNull(assembler.Landscape.Blocks[CenterIndex()]);
+
+ // Move the camera one block east; the published landblock should now
+ // sit one grid slot WEST of center instead of at center.
+ assembler.SetViewer(0xAAB40001u, Vector3.Zero);
+
+ Assert.Null(assembler.Landscape.Blocks[CenterIndex()]);
+ WalkLandBlock? shifted = assembler.Landscape.Blocks[
+ GridIndex(WalkLandscapeAssembler.MidRadius - 1, WalkLandscapeAssembler.MidRadius)];
+ Assert.NotNull(shifted);
+ Assert.Equal(7f, shifted!.MaxZ);
+ }
+
+ [Fact]
+ public void SetViewer_LandcellIndexDerivesViewerCellFromLowWord()
+ {
+ var assembler = new WalkLandscapeAssembler();
+
+ // Low word 10 -> landcell index 9 -> (9/8, 9%8) = (1, 1).
+ assembler.SetViewer(0xA9B4000Au, Vector3.Zero);
+
+ Assert.Equal(1, assembler.Landscape.ViewerCellX);
+ Assert.Equal(1, assembler.Landscape.ViewerCellY);
+ }
+
+ [Fact]
+ public void SetViewer_InteriorCameraDerivesViewerCellFromOrigin()
+ {
+ var assembler = new WalkLandscapeAssembler();
+
+ assembler.SetViewer(0xA9B40105u, new Vector3(50f, 74f, 0f));
+
+ Assert.Equal(2, assembler.Landscape.ViewerCellX); // floor(50 / 24) = 2
+ Assert.Equal(3, assembler.Landscape.ViewerCellY); // floor(74 / 24) = 3
+ }
+
+ [Fact]
+ public void SetViewer_SameBlockRepeatCallDoesNotClearAlreadyPublishedSlots()
+ {
+ var assembler = new WalkLandscapeAssembler();
+ assembler.PublishLandblock(LandblockId, 1f, 0f, Array.Empty());
+ assembler.SetViewer(CameraCellId, Vector3.Zero);
+
+ assembler.SetViewer(CameraCellId, new Vector3(5f, 5f, 0f));
+
+ Assert.NotNull(assembler.Landscape.Blocks[CenterIndex()]);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs
new file mode 100644
index 00000000..e8daa1dc
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs
@@ -0,0 +1,131 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Walk;
+
+namespace AcDream.App.Tests.Rendering.Walk;
+
+/// Campaign FW3.1 — hermetic (no DAT) coverage of the production
+/// frame context: cell resolution through ,
+/// building resolution through , and the
+/// CyPlane/ray-cast wiring. This proves the SEAM (does the context read the
+/// right registries the right way); the ten-fixture conformance gate proves
+/// the WORLD DATA those registries are fed is correct.
+public sealed class WalkProductionFrameContextTests
+{
+ private static Matrix4x4 SimpleViewProjection() =>
+ Matrix4x4.CreateLookAt(Vector3.Zero, Vector3.UnitY, Vector3.UnitZ)
+ * Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 4f / 3f, 0.1f, 1000f);
+
+ [Fact]
+ public void GetVisible_ResolvesThroughTheCommittedCellVisibilityRegistry()
+ {
+ var cellVisibility = new CellVisibility();
+ var walkCell = new WalkCell { CellId = 0xA9B40100u };
+ var loaded = new LoadedCell { CellId = 0xA9B40100u, Walk = walkCell };
+ cellVisibility.CommitLandblock(0xA9B4FFFFu, new[] { loaded });
+ var ctx = new WalkProductionFrameContext(
+ cellVisibility, new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY,
+ SimpleViewProjection(), 1024f, 768f);
+
+ Assert.Same(walkCell, ctx.GetVisible(0xA9B40100u));
+ Assert.Null(ctx.GetVisible(0xA9B40101u));
+ }
+
+ [Fact]
+ public void GetVisible_ReturnsNullWhenTheCommittedCellHasNoWalkModel()
+ {
+ // A hand-built LoadedCell that never went through
+ // EnvCellLandblockBuildBuilder.BuildVisibilityCell (test-only
+ // shortcut some existing fixtures take) — Walk stays null.
+ var cellVisibility = new CellVisibility();
+ var loaded = new LoadedCell { CellId = 0xA9B40100u };
+ cellVisibility.CommitLandblock(0xA9B4FFFFu, new[] { loaded });
+ var ctx = new WalkProductionFrameContext(
+ cellVisibility, new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY,
+ SimpleViewProjection(), 1024f, 768f);
+
+ Assert.Null(ctx.GetVisible(0xA9B40100u));
+ }
+
+ [Fact]
+ public void ObjectToClipAndViewpointIn_UseTheCellsOwnTransforms()
+ {
+ var cell = new WalkCell
+ {
+ CellId = 1,
+ WorldTransform = Matrix4x4.CreateTranslation(10f, 0f, 0f),
+ InverseWorldTransform = Matrix4x4.CreateTranslation(-10f, 0f, 0f),
+ };
+ Matrix4x4 vp = SimpleViewProjection();
+ var ctx = new WalkProductionFrameContext(
+ new CellVisibility(), new WalkBuildingRegistry(), new Vector3(10f, 0f, 0f), Vector3.UnitY,
+ vp, 1024f, 768f);
+
+ Assert.Equal(cell.WorldTransform * vp, ctx.ObjectToClip(cell));
+ Assert.Equal(Vector3.Zero, ctx.ViewpointIn(cell));
+ }
+
+ [Fact]
+ public void ViewpointInBuilding_ResolvesThroughWalkBuildingRegistry()
+ {
+ var registry = new WalkBuildingRegistry();
+ var building = new WalkBuilding { PositionCellId = 1 };
+ Matrix4x4 world = Matrix4x4.CreateTranslation(5f, 0f, 0f);
+ Matrix4x4.Invert(world, out Matrix4x4 inverse);
+ registry.Publish(0xA9B4FFFFu, new[] { new WalkBuildingFactory.Entry(building, world, inverse) });
+ var ctx = new WalkProductionFrameContext(
+ new CellVisibility(), registry, new Vector3(5f, 0f, 0f), Vector3.UnitY,
+ SimpleViewProjection(), 1024f, 768f);
+
+ Assert.Equal(Vector3.Zero, ctx.ViewpointInBuilding(building));
+ }
+
+ [Fact]
+ public void ViewpointInBuilding_ThrowsWhenTheBuildingIsNotCommitted()
+ {
+ // Fail loud (the PV3 post-mortem rule): a walk/registry desync must
+ // never resolve to a silently-skipped building.
+ var ctx = new WalkProductionFrameContext(
+ new CellVisibility(), new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY,
+ SimpleViewProjection(), 1024f, 768f);
+ var unregistered = new WalkBuilding { PositionCellId = 1 };
+
+ Assert.Throws(() => ctx.ViewpointInBuilding(unregistered));
+ }
+
+ [Fact]
+ public void ViewerDistanceTo_MeasuresToTheBuildingsTransformedSortCenter()
+ {
+ var registry = new WalkBuildingRegistry();
+ var building = new WalkBuilding { PositionCellId = 1, SortCenter = new Vector3(0f, 3f, 0f) };
+ Matrix4x4 world = Matrix4x4.CreateTranslation(0f, 10f, 0f);
+ Matrix4x4.Invert(world, out Matrix4x4 inverse);
+ registry.Publish(0xA9B4FFFFu, new[] { new WalkBuildingFactory.Entry(building, world, inverse) });
+ var ctx = new WalkProductionFrameContext(
+ new CellVisibility(), registry, Vector3.Zero, Vector3.UnitY,
+ SimpleViewProjection(), 1024f, 768f);
+
+ Assert.Equal(13f, ctx.ViewerDistanceTo(building));
+ }
+
+ [Fact]
+ public void CyPlane_MatchesTheRetailNearPlaneFormula()
+ {
+ Vector3 forward = Vector3.UnitY;
+ var eye = new Vector3(0f, 5f, 0f);
+ var ctx = new WalkProductionFrameContext(
+ new CellVisibility(), new WalkBuildingRegistry(), eye, forward,
+ SimpleViewProjection(), 1024f, 768f);
+
+ Assert.Equal(forward, ctx.CyPlane.Normal);
+ Assert.Equal(-Vector3.Dot(eye, forward) - WalkProductionFrameContext.ZNear, ctx.CyPlane.D);
+ }
+
+ [Fact]
+ public void Constructor_RejectsANonInvertibleViewProjection()
+ {
+ Assert.Throws(() => new WalkProductionFrameContext(
+ new CellVisibility(), new WalkBuildingRegistry(), Vector3.Zero, Vector3.UnitY,
+ default, 1024f, 768f));
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs
new file mode 100644
index 00000000..b78307fc
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs
@@ -0,0 +1,321 @@
+using AcDream.App.Rendering;
+using AcDream.App.Tests.Rendering;
+using AcDream.App.Rendering.Walk;
+using AcDream.Content;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Options;
+using System.Numerics;
+
+namespace AcDream.App.Tests.Rendering.Walk;
+
+///
+/// Campaign FW3.1's load-bearing deliverable: the ten pose-stamped oracle
+/// fixtures (docs/research/2026-08-30-fw-walk-oracle/), replayed through the
+/// PRODUCTION world-data builders
+/// (/
+/// / — the same code
+/// EnvCellLandblockBuildBuilder/LandblockBuildFactory run at
+/// real landblock-build time, consuming ONLY the legal
+/// seam), must reproduce the identical walk
+/// output already proves against the
+/// FW1 test adapter (WalkWorldDatAdapter/WalkLandscapeDatBuilder).
+///
+/// This class deliberately does NOT touch
+/// (frozen — must stay green untouched) but reuses its driver/signature
+/// helpers verbatim: ,
+/// , ,
+/// .
+/// is typed against the TEST adapter's WalkWorldDatAdapter.BuildingEntry
+/// record — structurally identical to
+/// (same three fields) — so below adapts
+/// one into the other rather than touching the shared context type.
+///
+/// mirrors the FW1 harness's
+/// WalkLandscapeDatBuilder.Build loop structure and ring math
+/// EXACTLY (51×51 grid, WalkLandscapeAssembler.MidRadius = 25,
+/// buildings/stab-cells only at full resolution), but every DATA-BUILDING
+/// step below it — cells, buildings, the drawing BSP, z-slab — calls the
+/// PRODUCTION functions. That is the whole conformance claim: the grid/ring
+/// assembly is proven once (FW1's existing gate, via
+/// 's own port of that same math); this
+/// class proves the DATA those slots are filled with is identical to the
+/// FW1 test adapter's.
+///
+[Trait("Lane", "InstalledDat")]
+public sealed class WalkProductionWorldConformanceTests
+{
+ private sealed class Recorder : IWalkEventSink
+ {
+ public readonly List Events = new();
+ public void Emit(in WalkEvent walkEvent) => Events.Add(walkEvent);
+ }
+
+ private static DatCollection OpenDats()
+ {
+ string? datDir = CornerFloodReplayTests.ResolveDatDir();
+ if (datDir is null)
+ {
+ Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
+ }
+ return new DatCollection(datDir!, DatAccessType.Read);
+ }
+
+ /// Builds the walk's world data (landscape + interior cells +
+ /// buildings) for a camera pose through the PRODUCTION builders, in the
+ /// exact shape expects. Mirrors
+ /// WalkLandscapeDatBuilder.Build's grid loop; every cell/building
+ /// constructed inside it comes from /
+ /// .
+ private static (WalkLandscapeAssembler Assembler, Dictionary Cells,
+ Dictionary Buildings)
+ BuildProductionWorld(IDatReaderWriter dats, uint cameraCellId, Vector3 cameraOrigin)
+ {
+ int cameraBlockX = (int)(cameraCellId >> 24);
+ int cameraBlockY = (int)((cameraCellId >> 16) & 0xFF);
+ var assembler = new WalkLandscapeAssembler();
+ var cells = new Dictionary();
+ var buildings = new Dictionary();
+ Region region = (Region)dats.Get(0x13000000u)!;
+ float[] heightTable = region.LandDefs.LandHeightTable;
+
+ for (int gx = 0; gx < WalkLandscapeAssembler.GridWidth; gx++)
+ {
+ for (int gy = 0; gy < WalkLandscapeAssembler.GridWidth; gy++)
+ {
+ int blockX = cameraBlockX + gx - WalkLandscapeAssembler.MidRadius;
+ int blockY = cameraBlockY + gy - WalkLandscapeAssembler.MidRadius;
+ if (blockX < 0 || blockX > 0xFF || blockY < 0 || blockY > 0xFF)
+ continue;
+ uint landblockId = (uint)((blockX << 24) | (blockY << 16));
+ if (dats.Get(landblockId | 0xFFFFu) is not LandBlock landBlock)
+ continue;
+
+ byte maxByte = 0, minByte = 255;
+ foreach (byte h in landBlock.Height)
+ {
+ if (h > maxByte) maxByte = h;
+ if (h < minByte) minByte = h;
+ }
+ float maxZ = heightTable[maxByte] + 200f;
+ float minZ = heightTable[minByte] - 1f;
+
+ var blockOffset = new Vector3(
+ (gx - WalkLandscapeAssembler.MidRadius) * WalkLandscape.BlockLength,
+ (gy - WalkLandscapeAssembler.MidRadius) * WalkLandscape.BlockLength,
+ 0f);
+
+ var blockBuildings = new List();
+ if (WalkLandscapeAssembler.SideCellCountForRing(WalkLandscapeAssembler.RingOf(gx, gy)) == 8)
+ {
+ LandBlockInfo? info = dats.Get(landblockId | 0xFFFEu);
+ blockBuildings = WalkBuildingFactory.Build(dats, landblockId, info?.Buildings, blockOffset);
+ foreach (WalkBuildingFactory.Entry entry in blockBuildings)
+ {
+ buildings[entry.Building] = new WalkWorldDatAdapter.BuildingEntry(
+ entry.Building, entry.WorldTransform, entry.InverseWorldTransform);
+
+ // Retail's loaded-interior rule (CLandBlock::init_buildings
+ // @0052fd80 -> add_to_stablist -> grab_visible_cells): a
+ // full-res block loads exactly its buildings' portal stab
+ // cells, matching WalkLandscapeDatBuilder's harness scope.
+ foreach (WalkBldPortal portal in entry.Building.Portals)
+ {
+ if (portal.OtherCellId != 0xFFFFFFFFu && !cells.ContainsKey(portal.OtherCellId))
+ {
+ WalkCell? c = WalkCellFactory.BuildCell(dats, portal.OtherCellId, blockOffset);
+ if (c is not null) cells[c.CellId] = c;
+ }
+ foreach (uint stab in portal.StabList)
+ {
+ if (cells.ContainsKey(stab)) continue;
+ WalkCell? c = WalkCellFactory.BuildCell(dats, stab, blockOffset);
+ if (c is not null) cells[c.CellId] = c;
+ }
+ }
+ }
+ }
+ assembler.PublishLandblock(landblockId, maxZ, minZ, blockBuildings);
+ }
+ }
+ assembler.SetViewer(cameraCellId, cameraOrigin);
+ return (assembler, cells, buildings);
+ }
+
+ [Fact]
+ public void Street_outdoor_first_frame_diff()
+ {
+ IReadOnlyList frames = WalkOracleTrace.Load("posed/holtburg-street-outdoor");
+ Assert.NotEmpty(frames);
+ using DatCollection dats = OpenDats();
+ using var adapter = new DatCollectionAdapter(dats);
+ WalkOracleFrame frame = frames[1];
+ Assert.NotNull(frame.Pose);
+ (WalkLandscapeAssembler assembler, Dictionary cells,
+ Dictionary buildings) =
+ BuildProductionWorld(adapter, frame.Pose!.CellId, frame.Pose.Origin);
+ var ctx = new WalkTraceReplayContext(frame.Pose, cells) { Buildings = buildings };
+ var walk = new RetailFrameWalk();
+ var recorder = new Recorder();
+
+ walk.WalkFrame(frame.Pose.CellId, null, assembler.Landscape, ctx, recorder);
+
+ string expected = WalkTraceReplayContext.Signature(frame);
+ string actual = WalkTraceReplayContext.Signature(recorder.Events);
+ Assert.True(
+ expected == actual,
+ $"production walk diverged from the FW1 test adapter\nEXPECTED: {expected}\nACTUAL: {actual}");
+ }
+
+ [Fact]
+ public void Doorway_still_first_frame_diff()
+ {
+ IReadOnlyList frames = WalkOracleTrace.Load("posed/holtburg-doorway-still");
+ Assert.NotEmpty(frames);
+ using DatCollection dats = OpenDats();
+ using var adapter = new DatCollectionAdapter(dats);
+ WalkOracleFrame frame = frames[1];
+ Assert.NotNull(frame.Pose);
+ (WalkLandscapeAssembler assembler, Dictionary cells,
+ Dictionary buildings) =
+ BuildProductionWorld(adapter, frame.Pose!.CellId, frame.Pose.Origin);
+ var ctx = new WalkTraceReplayContext(frame.Pose, cells) { Buildings = buildings };
+ WalkCell camera = Assert.Contains(frame.Pose.CellId, cells);
+ // Matches WalkTraceConformanceTests.Doorway_still_first_frame_diff:
+ // this capture ran under cdb load with Render::deg_mul depressed to
+ // the portless-arm threshold. Same environment pin, same reason.
+ var walk = new RetailFrameWalk { DegradeMultiplier = 0f };
+ var recorder = new Recorder();
+
+ walk.WalkFrame(frame.Pose.CellId, camera, assembler.Landscape, ctx, recorder);
+
+ string expected = WalkTraceReplayContext.Signature(frame);
+ string actual = WalkTraceReplayContext.Signature(recorder.Events);
+ Assert.True(
+ expected == actual,
+ $"production walk diverged from the FW1 test adapter\nEXPECTED: {expected}\nACTUAL: {actual}");
+ }
+
+ [Theory]
+ [InlineData("posed/terrace-center")]
+ [InlineData("posed/terrace-edge")]
+ [InlineData("posed/cathedral-arrival")]
+ public void Still_fixture_first_frame_reproduces_exactly(string fixture)
+ {
+ IReadOnlyList frames = WalkOracleTrace.Load(fixture);
+ Assert.NotEmpty(frames);
+ using DatCollection dats = OpenDats();
+ using var adapter = new DatCollectionAdapter(dats);
+ WalkOracleFrame frame = frames[1];
+ Assert.NotNull(frame.Pose);
+ (WalkLandscapeAssembler assembler, Dictionary cells,
+ Dictionary buildings) =
+ BuildProductionWorld(adapter, frame.Pose!.CellId, frame.Pose.Origin);
+ var ctx = new WalkTraceReplayContext(frame.Pose, cells) { Buildings = buildings };
+ WalkCell? camera = (frame.Pose.CellId & 0xFFFFu) >= 0x100
+ ? Assert.Contains(frame.Pose.CellId, cells)
+ : null;
+ var walk = new RetailFrameWalk();
+ var recorder = new Recorder();
+
+ walk.WalkFrame(frame.Pose.CellId, camera, assembler.Landscape, ctx, recorder);
+
+ string expected = WalkTraceReplayContext.Signature(frame);
+ string actual = WalkTraceReplayContext.Signature(recorder.Events);
+ Assert.True(
+ expected == actual,
+ $"production walk diverged from the FW1 test adapter ({fixture})\nEXPECTED: {expected}\nACTUAL: {actual}");
+ }
+
+ [Fact]
+ public void Foundry_entry_reproduces_every_frame_before_the_f67_order_segment()
+ {
+ // Same F67-F79 parked segment as WalkTraceConformanceTests (the
+ // building-a9b40036-root-plane viewpoint question is a walk/ported-
+ // algorithm question, not a world-data question — out of scope for
+ // this class). Frames 1-66 must still reproduce exactly.
+ MovingFixtureReplay("posed/foundry-entry", stopBeforeFrame: 67);
+ }
+
+ [Theory]
+ [InlineData("posed/holtburg-walkout")]
+ [InlineData("posed/holtburg-transitions")]
+ [InlineData("posed/holtburg-walkabout")]
+ public void Moving_fixture_reproduces_every_pairable_frame(string fixture)
+ => MovingFixtureReplay(fixture);
+
+ private void MovingFixtureReplay(string fixture, int stopBeforeFrame = int.MaxValue)
+ {
+ IReadOnlyList frames = WalkOracleTrace.Load(fixture);
+ Assert.True(frames.Count >= 3);
+ using DatCollection dats = OpenDats();
+ using var adapter = new DatCollectionAdapter(dats);
+ WalkOraclePose anchor = frames[1].Pose!;
+ (WalkLandscapeAssembler assembler, Dictionary cells,
+ Dictionary buildings) =
+ BuildProductionWorld(adapter, anchor.CellId, anchor.Origin);
+ var walk = new RetailFrameWalk();
+
+ for (int n = 1; n < frames.Count - 1; n++)
+ {
+ WalkOracleFrame frame = frames[n];
+ if (frame.Number >= stopBeforeFrame) break;
+ string expected = WalkTraceReplayContext.Signature(frame);
+ string? firstActual = null;
+ bool matched = false;
+ foreach (WalkOraclePose pose in new[] { frames[n + 1].Pose!, frame.Pose! })
+ {
+ Assert.NotNull(pose);
+ assembler.SetViewer(pose.CellId, pose.Origin);
+ var ctx = new WalkTraceReplayContext(pose, cells) { Buildings = buildings };
+ WalkCell? camera = null;
+ if ((pose.CellId & 0xFFFFu) >= 0x100)
+ {
+ Assert.True(
+ cells.TryGetValue(pose.CellId, out camera),
+ $"frame {frame.Number}: interior camera cell {pose.CellId:x8} not loaded");
+ }
+ var recorder = new Recorder();
+ walk.WalkFrame(pose.CellId, camera, assembler.Landscape, ctx, recorder);
+ string actual = WalkTraceReplayContext.Signature(recorder.Events);
+ firstActual ??= actual;
+ if (actual == expected)
+ {
+ matched = true;
+ break;
+ }
+ }
+ Assert.True(
+ matched,
+ $"frame {frame.Number} diverged under both adjacent poses ({fixture})\n"
+ + $"EXPECTED: {expected}\nACTUAL: {firstActual}");
+ }
+ }
+
+ [Fact]
+ public void Foundry_deep_reproduces_every_complete_frame_exactly()
+ {
+ IReadOnlyList frames = WalkOracleTrace.Load("posed/foundry-deep");
+ Assert.NotEmpty(frames);
+ using DatCollection dats = OpenDats();
+ using var adapter = new DatCollectionAdapter(dats);
+ Dictionary cells =
+ WalkCellFactory.BuildInteriorCells(adapter, 0xA9B40000u, Vector3.Zero);
+ var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
+
+ foreach (WalkOracleFrame frame in frames)
+ {
+ Assert.NotNull(frame.Pose);
+ WalkCell camera = Assert.Contains(frame.Pose!.CellId, cells);
+ var ctx = new WalkTraceReplayContext(frame.Pose, cells);
+ var walk = new RetailFrameWalk();
+ var recorder = new Recorder();
+
+ walk.WalkFrame(frame.Pose.CellId, camera, landscape, ctx, recorder);
+
+ Assert.Equal(
+ WalkTraceReplayContext.Signature(frame),
+ WalkTraceReplayContext.Signature(recorder.Events));
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs
index 17a7e2ed..402c05ef 100644
--- a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs
@@ -89,6 +89,74 @@ public sealed class LandblockBuildFactoryTests
Assert.Empty(result.Collisions.EnvCells);
}
+ [Fact]
+ public void BuildNear_PopulatesWalkZSlabUnconditionallyAndWalkBuildingsFromLandBlockInfo()
+ {
+ // Campaign FW3.1: EnvCellLandblockBuild.WalkMaxZ/WalkMinZ/WalkBuildings
+ // must be populated by the production streaming build — hermetic
+ // (no installed DAT needed), mirroring the RecordingDatProxy fixture
+ // pattern the rest of this file already uses.
+ var dat = CreateDat(out RecordingDatProxy proxy);
+ AddNearFixture(proxy, LandblockId, environmentId: 1);
+ var heights = new byte[81];
+ Array.Fill(heights, (byte)10);
+ heights[0] = 200; // one outlier byte -> a distinct maxByte from the rest.
+ proxy.Add(LandblockId, new LandBlock { Id = LandblockId, Height = heights });
+ proxy.Add(
+ (LandblockId & 0xFFFF0000u) | 0xFFFEu,
+ new LandBlockInfo
+ {
+ NumCells = 1,
+ Buildings = new List
+ {
+ new BuildingInfo
+ {
+ ModelId = 0x01234567u,
+ Frame = new Frame
+ {
+ Origin = new Vector3(12f, 12f, 0f),
+ Orientation = Quaternion.Identity,
+ },
+ Portals = new List(),
+ },
+ },
+ });
+ var heightTable = new float[256];
+ heightTable[10] = 5f;
+ heightTable[200] = 40f;
+ var factory = new LandblockBuildFactory(
+ dat, TestPreparedCollisionSource.Instance, new object(), heightTable);
+
+ LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadNear));
+
+ Assert.NotNull(result);
+ var envCells = Assert.IsType(
+ result.EnvCells);
+ Assert.Equal(240f, envCells.WalkMaxZ); // heightTable[200] + 200
+ Assert.Equal(4f, envCells.WalkMinZ); // heightTable[10] - 1
+ AcDream.App.Rendering.Walk.WalkBuildingFactory.Entry buildingEntry =
+ Assert.Single(envCells.WalkBuildings);
+ // origin (12,12) -> cellX=cellY=0 -> low word 0*8+0+1 = 1.
+ Assert.Equal((LandblockId & 0xFFFF0000u) | 1u, buildingEntry.Building.PositionCellId);
+ }
+
+ [Fact]
+ public void BuildFar_NeverPopulatesWalkDataBecauseEnvCellsIsNull()
+ {
+ // Documents the known FW3.1 scope gap: far-tier (LoadFar) landblocks
+ // carry no EnvCellLandblockBuild transaction at all (terrain-only),
+ // so their z-slab/buildings never reach WalkLandscapeAssembler until
+ // a follow-up wires the far-tier path too.
+ var dat = CreateDat(out RecordingDatProxy proxy);
+ proxy.Add(LandblockId, new LandBlock { Id = LandblockId });
+ var factory = Factory(dat, new object());
+
+ LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadFar));
+
+ Assert.NotNull(result);
+ Assert.Null(result.EnvCells);
+ }
+
[Fact]
public void NearPreparedFaultRejectsWholeGenerationWhileFarNeverReadsCollision()
{