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);
+ }
+}