feat(render): Phase A8 RR3 — Building + BuildingRegistry + BuildingLoader

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-27 11:08:43 +02:00
parent f44a9bf943
commit f125fdb220
6 changed files with 470 additions and 0 deletions

View file

@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Phase A8 (2026-05-26): a logical building — one or more EnvCells linked
/// via the dat-level <c>LandBlockInfo.Buildings</c> entry. Building shells (cottage
/// walls, inn walls — <c>IsBuildingShell=true</c> 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.
///
/// <para>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.</para>
///
/// <para>WB reference: <c>WorldBuilder.Shared/Services/PortalService.cs</c>
/// (<c>BuildingPortalGroup</c>) and <c>PortalRenderManager.cs</c> step-5 lifecycle.
/// Retail reference: <c>docs/research/named-retail/acclient.h:32035</c>
/// (<c>BuildInfo</c>) + <c>32094</c> (<c>CBldPortal</c>).</para>
/// </summary>
public sealed class Building
{
/// <summary>Unique within a landblock; allocated sequentially by <see cref="BuildingLoader"/>
/// starting at 1 (0 is reserved for "no building" semantics on <c>LoadedCell</c>).</summary>
public required uint BuildingId { get; init; }
/// <summary>The EnvCells this building owns. Includes all cells reachable
/// from the building's entry portals via interior portals (no exit portals).
/// Populated by <see cref="BuildingLoader.Build"/> via BFS over
/// <see cref="AcDream.App.Rendering.LoadedCell.Portals"/>.</summary>
public required HashSet<uint> EnvCellIds { get; init; }
/// <summary>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
/// <see cref="BuildingLoader.Build"/> Step C by transforming cell-local portal
/// polygon vertices via <see cref="AcDream.App.Rendering.LoadedCell.WorldTransform"/>.</summary>
public required IReadOnlyList<Vector3[]> ExitPortalPolygons { get; init; }
// -------------------------------------------------------------------------
// Step 5 occlusion-query state (mutable, per-frame, RR9 scope).
// -------------------------------------------------------------------------
/// <summary>GL query object handle; lazily created on first use by the
/// Step 5 occlusion-query pass (RR9). 0 = not yet created.</summary>
public uint QueryId;
/// <summary>True after the first <c>BeginQuery</c> call; controls whether the
/// read-back path is safe to execute on the next frame.</summary>
public bool QueryStarted;
/// <summary>Previous-frame query result. When false, the building's interior
/// render is skipped (Step 5 early-out in RR9 + RR11).</summary>
public bool WasVisible;
}

View file

@ -0,0 +1,138 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Phase A8 (2026-05-26): static factory that builds a per-landblock
/// <see cref="BuildingRegistry"/> from a <see cref="LandBlockInfo"/>'s
/// <c>Buildings</c> array.
///
/// <para>Algorithm (mirrors WB's <c>PortalService.GetPortalsByBuilding</c> at
/// <c>WorldBuilder.Shared/Services/PortalService.cs:43-97</c>):</para>
/// <list type="bullet">
/// <item>Step A — seed the cell set from <c>BuildingInfo.Portals</c> entry portals.</item>
/// <item>Step B — BFS through <see cref="LoadedCell.Portals"/> to discover all
/// interior cells reachable from the entry portals (interior portals only;
/// exit portals — <c>OtherCellId == 0xFFFF</c> — terminate each BFS branch).</item>
/// <item>Step C — collect exit portal polygons in world space for the stencil
/// pipeline (Phase A8 Steps 1+2, RR7 scope).</item>
/// </list>
///
/// <para>Cells whose <c>LoadedCell</c> entries are missing from
/// <paramref name="cellsByCellId"/> are silently skipped (BFS bails at the
/// unloaded cell). In production, streaming loads all cells for a landblock
/// before <see cref="Build"/> runs, so the dict is always complete.</para>
///
/// <para><c>LoadedCell.BuildingId</c> stamping is wired in RR4, not here.</para>
///
/// <para>Retail references:
/// <c>docs/research/named-retail/acclient.h:32035</c> (<c>BuildInfo</c>) and
/// <c>:32094</c> (<c>CBldPortal</c>).</para>
/// </summary>
public static class BuildingLoader
{
/// <summary>
/// Builds a <see cref="BuildingRegistry"/> from the supplied landblock data.
/// Building IDs are allocated sequentially starting at 1 (0 is reserved for
/// "no building" semantics used by <c>LoadedCell.BuildingId</c> in RR4).
/// </summary>
/// <param name="info">The dat-loaded <see cref="LandBlockInfo"/> for this landblock.</param>
/// <param name="landblockId">The 32-bit landblock id (e.g. <c>0xA9B40000</c>).
/// The high 16 bits are ORed with each 16-bit <c>OtherCellId</c> to produce
/// the full cell id.</param>
/// <param name="cellsByCellId">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.</param>
/// <returns>A fully populated <see cref="BuildingRegistry"/>; never null.</returns>
public static BuildingRegistry Build(
LandBlockInfo info,
uint landblockId,
IReadOnlyDictionary<uint, LoadedCell> 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<uint>();
var exitPortalPolys = new List<Vector3[]>();
// 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<uint>(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;
}
}

View file

@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Phase A8 (2026-05-26): per-landblock registry of <see cref="Building"/>s.
/// Two-way indexed for O(1) cell→building and building-id→building lookups.
/// Built once per landblock at load time by <see cref="BuildingLoader"/>;
/// no mutations occur after initial population.
///
/// <para>The cell→building index uses a <c>List&lt;Building&gt;</c> 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
/// <c>EnvCellIds</c> sets. <see cref="GetBuildingsContainingCell"/> returns all
/// owners so RR7's render path can pick the correct one.</para>
///
/// <para>WB reference: <c>WorldBuilder.Shared/Services/PortalService.cs</c>
/// (<c>BuildingPortalGroup</c>). Design:
/// <c>docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md</c>.</para>
/// </summary>
public sealed class BuildingRegistry
{
// Index 1: cell-id → list of buildings containing that cell.
// Cells may belong to multiple buildings (rare; handled via List<Building>).
private readonly Dictionary<uint, List<Building>> _byCellId = new();
// Index 2: building-id → Building.
private readonly Dictionary<uint, Building> _byBuildingId = new();
/// <summary>
/// Adds a building to both indexes. Idempotent if the exact same
/// <see cref="Building"/> instance is added twice with the same
/// <see cref="Building.BuildingId"/>.
/// </summary>
/// <param name="b">The building to register.</param>
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<Building>();
_byCellId[cellId] = list;
}
if (!list.Contains(b)) list.Add(b);
}
}
/// <summary>
/// Returns the buildings containing <paramref name="cellId"/>.
/// Returns an empty read-only list when the cell isn't part of any
/// building (outdoor cells, dungeon cells not tagged by
/// <c>LandBlockInfo.Buildings</c>).
/// </summary>
/// <param name="cellId">Full 32-bit cell id.</param>
public IReadOnlyList<Building> GetBuildingsContainingCell(uint cellId) =>
_byCellId.TryGetValue(cellId, out var list) ? list : Array.Empty<Building>();
/// <summary>Returns the building with the given id, or <c>null</c> if not found.</summary>
/// <param name="buildingId">The building id as allocated by <see cref="BuildingLoader"/>.</param>
public Building? GetById(uint buildingId) =>
_byBuildingId.TryGetValue(buildingId, out var b) ? b : null;
/// <summary>Enumerates every registered building in unspecified order.</summary>
public IEnumerable<Building> All() => _byBuildingId.Values;
/// <summary>Number of registered buildings.</summary>
public int Count => _byBuildingId.Count;
}