From b10ad662b0bb89fe8bdf42c45a8838397580e1af Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 13:19:22 +0200 Subject: [PATCH] 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 --- src/AcDream.App/Rendering/CellVisibility.cs | 16 + .../Rendering/Walk/WalkBuildingFactory.cs | 191 +++++++++++ .../Rendering/Walk/WalkBuildingRegistry.cs | 75 ++++ .../Rendering/Walk/WalkCellFactory.cs | 167 +++++++++ .../Rendering/Walk/WalkLandscapeAssembler.cs | 191 +++++++++++ .../Walk/WalkProductionFrameContext.cs | 174 ++++++++++ .../Rendering/Wb/EnvCellLandblockBuild.cs | 74 +++- .../Streaming/LandblockBuildFactory.cs | 38 +++ .../Streaming/LandblockRenderPublisher.cs | 44 +++ .../Walk/WalkBuildingRegistryTests.cs | 75 ++++ .../Walk/WalkLandscapeAssemblerTests.cs | 148 ++++++++ .../Walk/WalkProductionFrameContextTests.cs | 131 +++++++ .../WalkProductionWorldConformanceTests.cs | 321 ++++++++++++++++++ .../Streaming/LandblockBuildFactoryTests.cs | 68 ++++ 14 files changed, 1711 insertions(+), 2 deletions(-) create mode 100644 src/AcDream.App/Rendering/Walk/WalkBuildingFactory.cs create mode 100644 src/AcDream.App/Rendering/Walk/WalkBuildingRegistry.cs create mode 100644 src/AcDream.App/Rendering/Walk/WalkCellFactory.cs create mode 100644 src/AcDream.App/Rendering/Walk/WalkLandscapeAssembler.cs create mode 100644 src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingRegistryTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkLandscapeAssemblerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs 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() {