From f125fdb220c1d1ae35bc80c522e9d990610e3934 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 27 May 2026 11:08:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20A8=20RR3=20=E2=80=94=20?= =?UTF-8?q?Building=20+=20BuildingRegistry=20+=20BuildingLoader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New per-landblock data model for WB-style per-building cell scoping: Building — BuildingId, EnvCellIds, ExitPortalPolygons, occlusion-query state (Step 5 lifecycle) BuildingRegistry — two-way indexed (by cellId + by buildingId); single source of truth per landblock BuildingLoader — static factory from LandBlockInfo.Buildings; walks interior portals to expand cell sets; collects exit portal polygons in world space 10 new unit tests cover data invariants + registry indexing + loader mapping per the algorithm resolved in RR2 findings. LoadedCell.BuildingId stamping wired in RR4. Render-time consumption arrives in RR7 (Steps 1-4) + RR9 (Step 5) + RR11 (RenderOutsideIn). Design: docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md Spike: docs/research/2026-05-26-a8-buildings-data-shape.md Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/Wb/Building.cs | 57 ++++++++ .../Rendering/Wb/BuildingLoader.cs | 138 ++++++++++++++++++ .../Rendering/Wb/BuildingRegistry.cs | 73 +++++++++ .../Rendering/Wb/BuildingLoaderTests.cs | 88 +++++++++++ .../Rendering/Wb/BuildingRegistryTests.cs | 70 +++++++++ .../Rendering/Wb/BuildingTests.cs | 44 ++++++ 6 files changed, 470 insertions(+) create mode 100644 src/AcDream.App/Rendering/Wb/Building.cs create mode 100644 src/AcDream.App/Rendering/Wb/BuildingLoader.cs create mode 100644 src/AcDream.App/Rendering/Wb/BuildingRegistry.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/BuildingRegistryTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/BuildingTests.cs diff --git a/src/AcDream.App/Rendering/Wb/Building.cs b/src/AcDream.App/Rendering/Wb/Building.cs new file mode 100644 index 0000000..cb8b242 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/Building.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Phase A8 (2026-05-26): a logical building — one or more EnvCells linked +/// via the dat-level LandBlockInfo.Buildings entry. Building shells (cottage +/// walls, inn walls — IsBuildingShell=true entities) render unconditionally +/// when the camera is inside this building's cells. The exit portal polygons +/// are stencil-marked so outdoor visibility leaks through portal silhouettes +/// only. +/// +/// Step 5 (cross-building visibility via 3-stencil-bit pipeline) uses +/// the occlusion-query state to skip rendering when the building's portals +/// weren't visible last frame. +/// +/// WB reference: WorldBuilder.Shared/Services/PortalService.cs +/// (BuildingPortalGroup) and PortalRenderManager.cs step-5 lifecycle. +/// Retail reference: docs/research/named-retail/acclient.h:32035 +/// (BuildInfo) + 32094 (CBldPortal). +/// +public sealed class Building +{ + /// Unique within a landblock; allocated sequentially by + /// starting at 1 (0 is reserved for "no building" semantics on LoadedCell). + public required uint BuildingId { get; init; } + + /// The EnvCells this building owns. Includes all cells reachable + /// from the building's entry portals via interior portals (no exit portals). + /// Populated by via BFS over + /// . + public required HashSet EnvCellIds { get; init; } + + /// Exit portal polygons in world space (each polygon is a triangle + /// fan from vertex 0). Stencil-marked + far-depth-punched at Steps 1+2 of + /// WB's RenderInsideOut pipeline (RR7). Collected during + /// Step C by transforming cell-local portal + /// polygon vertices via . + public required IReadOnlyList ExitPortalPolygons { get; init; } + + // ------------------------------------------------------------------------- + // Step 5 occlusion-query state (mutable, per-frame, RR9 scope). + // ------------------------------------------------------------------------- + + /// GL query object handle; lazily created on first use by the + /// Step 5 occlusion-query pass (RR9). 0 = not yet created. + public uint QueryId; + + /// True after the first BeginQuery call; controls whether the + /// read-back path is safe to execute on the next frame. + public bool QueryStarted; + + /// Previous-frame query result. When false, the building's interior + /// render is skipped (Step 5 early-out in RR9 + RR11). + public bool WasVisible; +} diff --git a/src/AcDream.App/Rendering/Wb/BuildingLoader.cs b/src/AcDream.App/Rendering/Wb/BuildingLoader.cs new file mode 100644 index 0000000..ad50bed --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/BuildingLoader.cs @@ -0,0 +1,138 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Phase A8 (2026-05-26): static factory that builds a per-landblock +/// from a 's +/// Buildings array. +/// +/// Algorithm (mirrors WB's PortalService.GetPortalsByBuilding at +/// WorldBuilder.Shared/Services/PortalService.cs:43-97): +/// +/// Step A — seed the cell set from BuildingInfo.Portals entry portals. +/// Step B — BFS through to discover all +/// interior cells reachable from the entry portals (interior portals only; +/// exit portals — OtherCellId == 0xFFFF — terminate each BFS branch). +/// Step C — collect exit portal polygons in world space for the stencil +/// pipeline (Phase A8 Steps 1+2, RR7 scope). +/// +/// +/// Cells whose LoadedCell entries are missing from +/// are silently skipped (BFS bails at the +/// unloaded cell). In production, streaming loads all cells for a landblock +/// before runs, so the dict is always complete. +/// +/// LoadedCell.BuildingId stamping is wired in RR4, not here. +/// +/// Retail references: +/// docs/research/named-retail/acclient.h:32035 (BuildInfo) and +/// :32094 (CBldPortal). +/// +public static class BuildingLoader +{ + /// + /// Builds a from the supplied landblock data. + /// Building IDs are allocated sequentially starting at 1 (0 is reserved for + /// "no building" semantics used by LoadedCell.BuildingId in RR4). + /// + /// The dat-loaded for this landblock. + /// The 32-bit landblock id (e.g. 0xA9B40000). + /// The high 16 bits are ORed with each 16-bit OtherCellId to produce + /// the full cell id. + /// Pre-loaded cells keyed by full 32-bit cell id. + /// Used for BFS extension (Step B) and exit-polygon collection (Step C). + /// An empty dict is valid for unit tests; Step B and C are skipped per cell. + /// A fully populated ; never null. + public static BuildingRegistry Build( + LandBlockInfo info, + uint landblockId, + IReadOnlyDictionary cellsByCellId) + { + var reg = new BuildingRegistry(); + if (info.Buildings is null || info.Buildings.Count == 0) + return reg; + + uint lbMask = landblockId & 0xFFFF0000u; + uint nextId = 1; + + foreach (var bInfo in info.Buildings) + { + var envCellIds = new HashSet(); + var exitPortalPolys = new List(); + + // Step A: seed the cell set from BuildingInfo.Portals (entry portals). + // Each BuildingPortal.OtherCellId is a 16-bit cell-local id; OR with + // the landblock prefix for the full id. + // Defensive: skip OtherCellId == 0xFFFF (exit-portal sentinel in + // BuildingInfo — rare but WB guards against it too at PortalService.cs:58). + if (bInfo.Portals is not null) + { + foreach (var portal in bInfo.Portals) + { + if (portal.OtherCellId == 0xFFFF) continue; + envCellIds.Add(lbMask | portal.OtherCellId); + } + } + + // Step B: BFS through interior CellPortals to find the full cell set. + // Uses pre-loaded LoadedCell.Portals (avoids a duplicate dat fetch per + // BFS step). Mirrors WB PortalService.cs:67-79. + // When cellsByCellId is empty (unit-test path), BFS immediately exits. + var queue = new Queue(envCellIds); + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!cellsByCellId.TryGetValue(current, out var cell)) continue; + foreach (var p in cell.Portals) + { + if (p.OtherCellId == 0xFFFF) continue; // exit portal — stop BFS here + uint neighbourId = lbMask | p.OtherCellId; + if (envCellIds.Add(neighbourId)) + queue.Enqueue(neighbourId); + } + } + + // Step C: collect exit portal polygons in world space. + // For each interior cell, iterate its portals; for each exit portal + // (OtherCellId == 0xFFFF), transform the portal polygon vertices from + // cell-local space to world space via WorldTransform. + // Mirrors WB PortalService.cs:81-86 (GetPortalsForCell return path). + foreach (var cellId in envCellIds) + { + if (!cellsByCellId.TryGetValue(cellId, out var cell)) continue; + for (int pi = 0; pi < cell.Portals.Count; pi++) + { + if (cell.Portals[pi].OtherCellId != 0xFFFF) continue; + if (pi >= cell.PortalPolygons.Count) continue; + var localPoly = cell.PortalPolygons[pi]; + if (localPoly.Length < 3) continue; + var worldPoly = new Vector3[localPoly.Length]; + for (int v = 0; v < localPoly.Length; v++) + worldPoly[v] = Vector3.Transform(localPoly[v], cell.WorldTransform); + exitPortalPolys.Add(worldPoly); + } + } + + // WB PortalService.cs:89: skip buildings with no interior cells. + if (envCellIds.Count == 0) continue; + + var building = new Building + { + BuildingId = nextId++, + EnvCellIds = envCellIds, + ExitPortalPolygons = exitPortalPolys, + }; + reg.Add(building); + + // NOTE: LoadedCell.BuildingId stamping is wired in RR4 (requires + // an internal setter on LoadedCell that doesn't exist yet). This + // comment is the placeholder called out in the plan's RR3-S11. + } + + return reg; + } +} diff --git a/src/AcDream.App/Rendering/Wb/BuildingRegistry.cs b/src/AcDream.App/Rendering/Wb/BuildingRegistry.cs new file mode 100644 index 0000000..998f64b --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/BuildingRegistry.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Phase A8 (2026-05-26): per-landblock registry of s. +/// Two-way indexed for O(1) cell→building and building-id→building lookups. +/// Built once per landblock at load time by ; +/// no mutations occur after initial population. +/// +/// The cell→building index uses a List<Building> value type +/// to handle the (rare but valid) case where two buildings share an EnvCell — +/// each building performs its own BFS so a shared boundary cell ends up in both +/// EnvCellIds sets. returns all +/// owners so RR7's render path can pick the correct one. +/// +/// WB reference: WorldBuilder.Shared/Services/PortalService.cs +/// (BuildingPortalGroup). Design: +/// docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md. +/// +public sealed class BuildingRegistry +{ + // Index 1: cell-id → list of buildings containing that cell. + // Cells may belong to multiple buildings (rare; handled via List). + private readonly Dictionary> _byCellId = new(); + + // Index 2: building-id → Building. + private readonly Dictionary _byBuildingId = new(); + + /// + /// Adds a building to both indexes. Idempotent if the exact same + /// instance is added twice with the same + /// . + /// + /// The building to register. + public void Add(Building b) + { + if (_byBuildingId.TryGetValue(b.BuildingId, out var existing) && ReferenceEquals(existing, b)) + return; + _byBuildingId[b.BuildingId] = b; + foreach (var cellId in b.EnvCellIds) + { + if (!_byCellId.TryGetValue(cellId, out var list)) + { + list = new List(); + _byCellId[cellId] = list; + } + if (!list.Contains(b)) list.Add(b); + } + } + + /// + /// Returns the buildings containing . + /// Returns an empty read-only list when the cell isn't part of any + /// building (outdoor cells, dungeon cells not tagged by + /// LandBlockInfo.Buildings). + /// + /// Full 32-bit cell id. + public IReadOnlyList GetBuildingsContainingCell(uint cellId) => + _byCellId.TryGetValue(cellId, out var list) ? list : Array.Empty(); + + /// Returns the building with the given id, or null if not found. + /// The building id as allocated by . + public Building? GetById(uint buildingId) => + _byBuildingId.TryGetValue(buildingId, out var b) ? b : null; + + /// Enumerates every registered building in unspecified order. + public IEnumerable All() => _byBuildingId.Values; + + /// Number of registered buildings. + public int Count => _byBuildingId.Count; +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs new file mode 100644 index 0000000..8294351 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class BuildingLoaderTests +{ + // Helper: build a minimal LandBlockInfo with one BuildingInfo per supplied tuple. + // OtherCellId values are 16-bit cell-local ids (low word of full cell id). + private static LandBlockInfo MakeInfo(params (uint modelId, uint[] portalOtherCellIds)[] buildings) + { + var bls = new List(); + foreach (var (modelId, portals) in buildings) + { + var portalList = new List(); + foreach (var ocid in portals) + { + portalList.Add(new BuildingPortal + { + OtherCellId = (ushort)(ocid & 0xFFFFu), + Flags = 0, + OtherPortalId = 0, + StabList = new List(), + }); + } + bls.Add(new BuildingInfo + { + ModelId = modelId, + Frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + Portals = portalList, + }); + } + return new LandBlockInfo + { + Objects = new List(), + Buildings = bls, + }; + } + + [Fact] + public void Empty_NoBuildings_EmptyRegistry() + { + var info = new LandBlockInfo { Objects = new List(), Buildings = new List() }; + var reg = BuildingLoader.Build(info, landblockId: 0xA9B40000u, cellsByCellId: new Dictionary()); + Assert.Equal(0, reg.Count); + } + + [Fact] + public void OneBuilding_OnePortal_MapsToOneCell() + { + // Building points to cell 0x0150 in landblock 0xA9B40000 → full cell id 0xA9B40150 + var info = MakeInfo((modelId: 0x02000123u, portalOtherCellIds: new[] { 0x0150u })); + // Pass an empty cell dict — loader seeds from BuildingInfo.Portals only + var reg = BuildingLoader.Build(info, landblockId: 0xA9B40000u, cellsByCellId: new Dictionary()); + Assert.Equal(1, reg.Count); + var building = System.Linq.Enumerable.First(reg.All()); + Assert.Contains(0xA9B40150u, building.EnvCellIds); + } + + [Fact] + public void OneBuilding_MultiplePortals_MapsToMultipleCells() + { + var info = MakeInfo((0x02000123u, new[] { 0x0150u, 0x0151u, 0x0152u })); + var reg = BuildingLoader.Build(info, 0xA9B40000u, new Dictionary()); + var building = System.Linq.Enumerable.First(reg.All()); + Assert.Equal(3, building.EnvCellIds.Count); + Assert.Contains(0xA9B40150u, building.EnvCellIds); + Assert.Contains(0xA9B40151u, building.EnvCellIds); + Assert.Contains(0xA9B40152u, building.EnvCellIds); + } + + [Fact] + public void TwoBuildings_AllocateSequentialIds() + { + var info = MakeInfo( + (0x02000001u, new[] { 0x0150u }), + (0x02000002u, new[] { 0x0160u })); + var reg = BuildingLoader.Build(info, 0xA9B40000u, new Dictionary()); + Assert.Equal(2, reg.Count); + var ids = new SortedSet(); + foreach (var b in reg.All()) ids.Add(b.BuildingId); + Assert.Equal(new SortedSet { 1, 2 }, ids); // sequential 1, 2 + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/BuildingRegistryTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/BuildingRegistryTests.cs new file mode 100644 index 0000000..07da37c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/BuildingRegistryTests.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class BuildingRegistryTests +{ + private static Building B(uint id, params uint[] cellIds) => new() + { + BuildingId = id, + EnvCellIds = new HashSet(cellIds), + ExitPortalPolygons = new List(), + }; + + [Fact] + public void Empty_NoBuildingsRegistered() + { + var reg = new BuildingRegistry(); + Assert.Equal(0, reg.Count); + Assert.Empty(reg.All()); + Assert.Empty(reg.GetBuildingsContainingCell(0xA9B40150u)); + Assert.Null(reg.GetById(0)); + } + + [Fact] + public void Add_IndexesBothDirections() + { + var reg = new BuildingRegistry(); + var b = B(1, 0xA9B40150u, 0xA9B40151u); + reg.Add(b); + + Assert.Equal(1, reg.Count); + Assert.Same(b, reg.GetById(1)); + Assert.Single(reg.GetBuildingsContainingCell(0xA9B40150u)); + Assert.Single(reg.GetBuildingsContainingCell(0xA9B40151u)); + Assert.Same(b, reg.GetBuildingsContainingCell(0xA9B40150u)[0]); + Assert.Empty(reg.GetBuildingsContainingCell(0xDEADBEEFu)); + } + + [Fact] + public void CellSharedBetweenTwoBuildings_GetBuildingsContainingCellReturnsBoth() + { + var reg = new BuildingRegistry(); + var b1 = B(1, 0xA9B40150u, 0xA9B40151u); + var b2 = B(2, 0xA9B40151u, 0xA9B40152u); // shares 0151 with b1 + reg.Add(b1); + reg.Add(b2); + + var bothAt0151 = reg.GetBuildingsContainingCell(0xA9B40151u); + Assert.Equal(2, bothAt0151.Count); + Assert.Contains(b1, bothAt0151); + Assert.Contains(b2, bothAt0151); + } + + [Fact] + public void All_EnumeratesEveryBuilding() + { + var reg = new BuildingRegistry(); + reg.Add(B(1, 0xA9B40150u)); + reg.Add(B(2, 0xA9B40160u)); + reg.Add(B(3, 0xA9B40170u)); + + var ids = new HashSet(); + foreach (var b in reg.All()) ids.Add(b.BuildingId); + + Assert.Equal(new HashSet { 1, 2, 3 }, ids); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/BuildingTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/BuildingTests.cs new file mode 100644 index 0000000..8e72878 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/BuildingTests.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class BuildingTests +{ + [Fact] + public void Building_RequiredFields_PopulateCorrectly() + { + var b = new Building + { + BuildingId = 42, + EnvCellIds = new HashSet { 0xA9B40150u, 0xA9B40151u }, + ExitPortalPolygons = new List + { + new[] { new Vector3(0, 0, 0), new Vector3(1, 0, 0), new Vector3(1, 1, 0) }, + }, + }; + + Assert.Equal(42u, b.BuildingId); + Assert.Equal(2, b.EnvCellIds.Count); + Assert.Contains(0xA9B40150u, b.EnvCellIds); + Assert.Single(b.ExitPortalPolygons); + Assert.Equal(3, b.ExitPortalPolygons[0].Length); + } + + [Fact] + public void Building_OcclusionQueryState_DefaultsZero() + { + var b = new Building + { + BuildingId = 0, + EnvCellIds = new HashSet(), + ExitPortalPolygons = new List(), + }; + + Assert.Equal(0u, b.QueryId); + Assert.False(b.QueryStarted); + Assert.False(b.WasVisible); + } +}