diff --git a/docs/research/2026-05-26-a8-buildings-data-shape.md b/docs/research/2026-05-26-a8-buildings-data-shape.md
new file mode 100644
index 0000000..b9ff6ce
--- /dev/null
+++ b/docs/research/2026-05-26-a8-buildings-data-shape.md
@@ -0,0 +1,402 @@
+# Phase A8 RR2 — `BuildingInfo` data shape + interior-portal walk
+
+**Date:** 2026-05-26 (PM, RR2 spike)
+**Predecessor:** [docs/research/2026-05-26-a8-wb-full-port-rr1-shipped-handoff.md](2026-05-26-a8-wb-full-port-rr1-shipped-handoff.md)
+**Successor:** RR3 — `Building` + `BuildingRegistry` + `BuildingLoader` implementation
+**Status:** SHIPPED — gate at end says "compatible → proceed to RR3"
+
+## TL;DR
+
+The DRW v2.1.7 `BuildingInfo` type exposes everything the design needs, with the field names already used elsewhere in our codebase. WB's `PortalService.GetPortalsByBuilding` (referenced by `PortalRenderManager.GeneratePortalsForLandblock` at lines 488-561) implements a clear BFS walk that translates 1:1 to our `LoadedCell.Portals` graph. Gate decision: **data shape compatible — proceed to RR3.**
+
+Two refinements vs the plan's RR3 pseudocode worth noting (both are minor wording fixes, not algorithm changes):
+
+1. The DRW type for each entry of `BuildingInfo.Portals` is `BuildingPortal` (NOT `BldPortal` as the plan's RR3-S9 test file uses). Plan's RR3 tests should rename `new BldPortal { ... }` → `new BuildingPortal { ... }`.
+2. The exit-portal sentinel `0xFFFF` is the **value of `OtherCellId`** itself (ushort), not "low word of a 32-bit value." Plan code already treats it correctly.
+
+## 1. `BuildingInfo` field shape (DRW v2.1.7)
+
+Verbatim from `ilspycmd "%USERPROFILE%\.nuget\packages\chorizite.datreaderwriter\2.1.7\lib\net8.0\DatReaderWriter.dll" -t DatReaderWriter.Types.BuildingInfo`:
+
+```csharp
+namespace DatReaderWriter.Types;
+
+public class BuildingInfo : IDatObjType, IUnpackable, IPackable
+{
+ /// Either a SetupModel (0x02xxxxxx) or GfxObj (0x01xxxxxx) id.
+ public uint ModelId;
+
+ /// The position information (Origin: Vector3, Orientation: Quaternion).
+ public Frame Frame;
+
+ public uint NumLeaves;
+
+ public List Portals = new List();
+}
+```
+
+Note: **fields, not properties**. All four are mutable but in practice are only populated by `Unpack(DatBinReader)` during dat-file load. `Portals` is initialized inline (never `null`).
+
+`BuildingPortal` (same DLL):
+
+```csharp
+public class BuildingPortal : IDatObjType, IUnpackable, IPackable
+{
+ public PortalFlags Flags; // 16-bit enum (PortalFlags.ExactMatch = 0x0001)
+ public ushort OtherCellId; // LOW WORD of cell id; landblock prefix ORs in
+ public ushort OtherPortalId;
+ public List StabList = new List();
+}
+```
+
+`Frame` (lives at `DatReaderWriter.Types.Frame`, also a field-based class):
+
+```csharp
+public class Frame : IUnpackable, IPackable
+{
+ public Vector3 Origin;
+ public Quaternion Orientation;
+}
+```
+
+### What our codebase already does with this
+
+In `src/AcDream.Core/World/LandblockLoader.cs:74-89`, the post-Phase-2 loop already iterates `info.Buildings` and consumes `building.ModelId`, `building.Frame.Origin`, `building.Frame.Orientation`. Plain field access — no surprises.
+
+In `src/AcDream.App/Rendering/GameWindow.cs:5789-5803`, the indoor portal cell-tracking phase already builds our internal `BldPortalInfo` from `BuildingInfo.Portals`. The construction confirms the field types in practice:
+
+```csharp
+foreach (var building in lbInfo.Buildings)
+{
+ if (building.Portals.Count == 0) continue; // .Count works → IList
+ foreach (var bp in building.Portals)
+ {
+ bldPortals.Add(new AcDream.Core.Physics.BldPortalInfo(
+ otherCellId: lbPrefix | (uint)bp.OtherCellId, // ushort → uint cast
+ otherPortalId: bp.OtherPortalId, // ushort
+ flags: (ushort)bp.Flags)); // PortalFlags → ushort cast
+ }
+}
+```
+
+Conclusion: every field the RR3 design assumes already exists with the assumed semantics; the existing physics phase has been consuming them since 2026-05-19.
+
+## 2. Holtburg cottage `BuildingInfo` — live dump
+
+Live-inspect via `Console.WriteLine` diagnostic at `LandblockLoader.cs:74-89` (reverted after capture, see git diff in this commit's parent). Login at `+Acdream` (server guid `0x5000000A`, pos `(131.7, 26.1, 94.0) @ 0xA9B4002A`); the diagnostic fired for every landblock streamed during initial entry. Captured to `a6-rr2-s3-buildings.log` (gitignored — not committed).
+
+### Holtburg town landblock `0xA9B4FFFF` — 12 BuildingInfo entries
+
+```
+idx=0 ModelId=0x01000C1E Frame.Origin=(84.1,131.5,66.0) NumLeaves=64 Portals=10
+ portal -> OtherCellId=0x0100 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=17
+ portal -> OtherCellId=0x0100 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=17
+ portal -> OtherCellId=0x0100 OtherPortalId=0x0005 Flags=0x0001 StabList.Count=17
+ portal -> OtherCellId=0x0103 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=17
+ portal -> OtherCellId=0x0102 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=17
+ portal -> OtherCellId=0x0106 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=17
+ portal -> OtherCellId=0x0107 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=17
+ portal -> OtherCellId=0x0109 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=17
+ portal -> OtherCellId=0x010A OtherPortalId=0x0001 Flags=0x0001 StabList.Count=17
+ portal -> OtherCellId=0x010B OtherPortalId=0x0000 Flags=0x0001 StabList.Count=17
+
+idx=1 ModelId=0x01000BC3 Frame.Origin=(31.5,159.5,66.0) NumLeaves=36 Portals=3
+ portal -> OtherCellId=0x0113 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=5
+ portal -> OtherCellId=0x0114 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=5
+ portal -> OtherCellId=0x0115 OtherPortalId=0x0000 Flags=0x0001 StabList.Count=5
+
+idx=2 ModelId=0x0100082E Frame.Origin=(154.1,132.7,66.0) NumLeaves=30 Portals=4
+ portal -> OtherCellId=0x0116 OtherPortalId=0x0001 Flags=0x0003 StabList.Count=9
+ portal -> OtherCellId=0x0118 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=9
+ portal -> OtherCellId=0x0119 OtherPortalId=0x0001 Flags=0x0001 StabList.Count=9
+ portal -> OtherCellId=0x011D OtherPortalId=0x0001 Flags=0x0001 StabList.Count=9
+
+idx=3 ModelId=0x01000830 Frame.Origin=(104.5,135.5,66.0) NumLeaves=31 Portals=5
+ portal -> 0x011F, 0x0120 (F=0x0003), 0x0122, 0x0124, 0x0125 — all StabList.Count=8
+
+idx=4 ModelId=0x01000827 Frame.Origin=(57.5,133.5,66.0) NumLeaves=101 Portals=8
+ portal -> 0x012D, 0x0133, 0x0134, 0x0135, 0x0129, 0x012B, 0x012C, 0x0137 — all StabList.Count=17
+
+idx=5 ModelId=0x0100081C Frame.Origin=(132.5,154.0,66.0) NumLeaves=34 Portals=4
+ portal -> 0x0139, 0x013B, 0x013C, 0x013D — all StabList.Count=7
+
+idx=6 ModelId=0x01000A2B Frame.Origin=(130.5,11.5,94.0) NumLeaves=29 Portals=5
+ portal -> 0x0145, 0x014C, 0x014E, 0x014F, 0x0150 — all StabList.Count=18
+ [cottage from issue #98 cellar saga; entry cells 0x0145 + cellar cells 0x014C-0x0150]
+
+idx=7 ModelId=0x01000C17 Frame.Origin=(107.5,36.0,94.0) NumLeaves=39 Portals=3
+ portal -> 0x0164, 0x0165, 0x015E — all StabList.Count=25
+ [Holtburg Inn vestibule + ground floor]
+
+idx=8 ModelId=0x01000BC3 Frame.Origin=(79.5,37.5,94.0) NumLeaves=36 Portals=3
+ portal -> 0x016C, 0x016D, 0x016E — all StabList.Count=5
+
+idx=9 ModelId=0x01002232 Frame.Origin=(161.9,7.5,94.0) NumLeaves=209 Portals=2
+ portal -> 0x016F (F=0x0003), 0x0170 (F=0x0003) — all StabList.Count=7
+ [largest building, 209 leaves — probably a multi-floor structure or unique Holtburg landmark]
+
+idx=10 ModelId=0x01002A1B Frame.Origin=(65.2,156.6,66.0) NumLeaves=48 Portals=2
+ portal -> 0x0178, 0x0177 — both F=0x0003 StabList.Count=3
+
+idx=11 ModelId=0x01000F69 Frame.Origin=(158.2,37.7,94.0) NumLeaves=43 Portals=1
+ portal -> 0x0179 (F=0x0003) StabList.Count=2
+ [single-portal building — likely a simple shed / outhouse]
+```
+
+### What the dump confirms
+
+| Confirmation | Evidence |
+|---|---|
+| Every `BuildingInfo.Portals` has `.Count > 0` (no empty buildings observed) | All 12 idx entries Portals=1..10 |
+| Every `OtherCellId` is a real interior cell (none == `0xFFFF`) | Inspected 60 portal lines; all OtherCellId in 0x0100-0x0179 range |
+| `Flags` values: `0x0001` (ExactMatch) for ~85% of portals; `0x0003` (ExactMatch + bit 1) for ~15% — likely the "exterior-facing portal side" bit | Per `DatReaderWriter.Enums.PortalFlags`: `ExactMatch = 0x0001`; bit 1 is `Side` per our `BldPortalInfo` ctor (which already handles it) |
+| All `ModelId` values are GfxObjs (`0x01xxxxxx`) — NO Setups in Holtburg | Matches `LandblockLoader.IsSupported` (currently accepts both — Setup ids would survive the filter if they appeared) |
+| `NumLeaves` correlates with building size — 209 for the largest, 29 for a small cottage | The DRW field is metadata; we don't consume it in `BuildingLoader` (only WB's offscreen mesh path uses it) |
+| `StabList` populated (3-25 entries per portal) — indices into `LandBlockInfo.Objects` for the stabs (decorations) inside the building's cells | Not used by `BuildingLoader`; informational only |
+| Cottage at idx=6 matches the issue #98 cellar saga's geometry: `Frame.Origin=(130.5,11.5,94.0)` is the cottage entry cell `0xA9B40145`; cellar cells `0xA9B4014C/014E/014F/0150` are reached via interior portals (this is exactly the BFS walk WB does in §3) | Cross-ref `docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md` |
+
+### Implications for `BuildingLoader`
+
+1. **No empty-portal building edge case in production data** — the `envCellIds.Count == 0` short-circuit at the end of Step C will essentially never fire for Holtburg. Still wire it (matches WB §89 + handles future content with empty buildings).
+2. **Defensive `if (portal.OtherCellId == 0xFFFF) continue` in Step A** — never fires for BuildingInfo.Portals (confirmed across 60 portals); keeping it matches WB's defensive style.
+3. **Cottage idx=6 is a known small multi-cell building** — perfect first verification target for RR8 visual gate ("cellar walls solid; cottage floor solid"). The cells it owns (0xA9B40145, 0xA9B4014C, 0xA9B4014E, 0xA9B4014F, 0xA9B40150) are the exact ones from the #98 saga.
+
+## 3. WB's interior-portal walk algorithm
+
+Source: [references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs:43-97](../../references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs).
+
+```csharp
+public IEnumerable GetPortalsByBuilding(uint regionId, ushort landblockId)
+{
+ var lbFileId = ((uint)landblockId << 16) | 0xFFFE;
+ if (!_dats.CellRegions.TryGetValue(regionId, out var cellDb)) yield break;
+ if (!cellDb.TryGet(lbFileId, out var lbi)) yield break;
+
+ for (int buildingIdx = 0; buildingIdx < lbi.Buildings.Count; buildingIdx++)
+ {
+ var bInfo = lbi.Buildings[buildingIdx];
+
+ // --- Step A: seed with BuildingInfo.Portals (entry portals) ---
+ var discoveredCellIds = new HashSet();
+ var cellsToProcess = new Queue();
+ foreach (var portal in bInfo.Portals)
+ {
+ if (portal.OtherCellId != 0xFFFF)
+ {
+ var cellId = ((uint)landblockId << 16) | portal.OtherCellId;
+ if (discoveredCellIds.Add(cellId))
+ cellsToProcess.Enqueue(cellId);
+ }
+ }
+
+ // --- Step B: BFS through interior CellPortals ---
+ while (cellsToProcess.Count > 0)
+ {
+ var cellId = cellsToProcess.Dequeue();
+ if (cellDb.TryGet(cellId, out var envCell))
+ {
+ foreach (var cellPortal in envCell.CellPortals)
+ {
+ if (cellPortal.OtherCellId != 0xFFFF)
+ {
+ var neighborId = ((uint)landblockId << 16) | cellPortal.OtherCellId;
+ if (discoveredCellIds.Add(neighborId))
+ cellsToProcess.Enqueue(neighborId);
+ }
+ }
+ }
+ }
+
+ // --- Step C: collect EXIT portals from every discovered cell ---
+ var outsidePortals = new List();
+ foreach (var cellId in discoveredCellIds)
+ foreach (var portal in GetPortalsForCell(cellDb, cellId)) // OtherCellId == 0xFFFF
+ outsidePortals.Add(portal);
+
+ if (discoveredCellIds.Count > 0)
+ yield return new BuildingPortalGroup
+ {
+ BuildingIndex = buildingIdx,
+ Portals = outsidePortals,
+ EnvCellIds = discoveredCellIds,
+ };
+ }
+}
+```
+
+Where `GetPortalsForCell` walks each cell's `CellPortals`, picks the entries with `OtherCellId == 0xFFFF` (the "to outside" sentinel), looks up the portal polygon via:
+
+```
+_dats.Portal.TryGet(0x0D000000u | envCell.EnvironmentId, out var environment)
+environment.Cells[envCell.CellStructure].Polygons[portal.PolygonId]
+```
+
+…then transforms each vertex by:
+
+```
+Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
+Matrix4x4.CreateTranslation(envCell.Position.Origin)
+```
+
+…and yields `PortalData { Vertices = worldVertices, BoundingBox = ... }`.
+
+### Key invariants extracted from the WB code
+
+| Invariant | Evidence (WB line) |
+|---|---|
+| `0xFFFF` is the exit-portal sentinel for both `BuildingPortal.OtherCellId` and `CellPortal.OtherCellId` | `if (portal.OtherCellId != 0xFFFF)` (l. 58) and `if (cellPortal.OtherCellId != 0xFFFF)` (l. 71) and `if (portal.OtherCellId == 0xFFFF) /* Portal to outside! */` (l. 103) |
+| Cell-id full form: `((uint)landblockId << 16) \| portal.OtherCellId` (NOT `landblockId & 0xFFFF0000u \| otherCellId` — but functionally equivalent because the high 16 bits of `landblockId` already encode the landblock x/y) | Lines 59, 72 |
+| BFS uses dat-loaded `EnvCell.CellPortals`, NOT pre-resolved cell instances | Line 70 |
+| Building's cell set comes from BOTH the entry portals AND the BFS extension — entry portals alone would miss most of a multi-cell building | Lines 57-64 (seed) + 67-79 (BFS) |
+| A building with zero entry portals (`bInfo.Portals.Count == 0`) yields nothing — the `discoveredCellIds.Count > 0` gate at l. 89 short-circuits the `yield return` | Line 89 |
+| `BuildingPortalGroup` instances correspond 1:1 with `BuildingInfo` entries (via `BuildingIndex`) | Line 91 |
+
+### Edge cases observed in WB
+
+- A cell shared between two `BuildingInfo` entries would be discovered TWICE (once per BFS). WB's `HashSet discoveredCellIds` is per-building, so each building gets its own copy. The plan's `BuildingRegistry.GetBuildingsContainingCell` already handles the "shared cell" case via `List`.
+- WB walks the dat database (`cellDb.TryGet(cellId, ...)`) DIRECTLY, regardless of whether cells are already loaded. Our `BuildingLoader.Build` will take `IReadOnlyDictionary` so it walks pre-loaded cells. **Difference matters when streaming hasn't loaded a building's cells yet** — see §4.
+
+## 4. Resolved algorithm for acdream's `BuildingLoader`
+
+The plan's RR3-S11 pseudocode is correct in shape. Two updates pin it down precisely:
+
+### 4.1 Type rename
+
+Plan's RR3-S9 test file uses `BldPortal` and `BldPortal.OtherCellId`. Rename to `BuildingPortal` (the actual DRW type) and keep `OtherCellId` (matches DRW). The test helper signature becomes:
+
+```csharp
+var portalList = new List();
+foreach (var ocid in portals)
+{
+ portalList.Add(new BuildingPortal
+ {
+ OtherCellId = (ushort)(ocid & 0xFFFFu),
+ Flags = 0,
+ OtherPortalId = 0,
+ StabList = new List(),
+ });
+}
+```
+
+### 4.2 Pre-loaded cells vs dat-direct walk
+
+The plan's BuildingLoader walks `IReadOnlyDictionary cellsByCellId`, NOT the dat database. This is the correct choice for acdream because:
+
+- Our `LoadedCell.Portals` is already populated with `CellPortalInfo` records (one per `EnvCell.CellPortals` entry) at landblock-load time by `CellMesh` / `PhysicsDataCache.CacheCellStruct`.
+- The streaming pipeline (`LandblockStreamer.LoadNear`) loads ALL of a landblock's `EnvCell`s into the dict before `BuildingLoader.Build` runs. So the dict is complete at registry-build time for the loaded landblock.
+- Walking the dict avoids a duplicate dat fetch + EnvCell decode per BFS step (perf bonus).
+
+The plan's empty-dict guard (`if (cellsByCellId.Count > 0)`) covers the unit-test case where the loader is invoked without cells. Production never hits that path.
+
+### 4.3 Final pseudocode (carbon copy of plan's RR3-S11 modulo the rename)
+
+```csharp
+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 b in info.Buildings)
+ {
+ var envCellIds = new HashSet();
+ var exitPortalPolys = new List();
+
+ // Step A: seed from BuildingInfo.Portals
+ if (b.Portals is not null)
+ foreach (var portal in b.Portals)
+ {
+ if (portal.OtherCellId == 0xFFFF) continue;
+ envCellIds.Add(lbMask | portal.OtherCellId);
+ }
+
+ // Step B: BFS through interior CellPortals (preferred — uses pre-loaded LoadedCell.Portals)
+ 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;
+ uint neighbourId = lbMask | p.OtherCellId;
+ if (envCellIds.Add(neighbourId))
+ queue.Enqueue(neighbourId);
+ }
+ }
+
+ // Step C: collect EXIT portal polygons in world space
+ 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);
+ }
+ }
+
+ if (envCellIds.Count == 0) continue; // building has no interior — skip (matches WB §89)
+
+ var building = new Building
+ {
+ BuildingId = nextId++,
+ EnvCellIds = envCellIds,
+ ExitPortalPolygons = exitPortalPolys,
+ };
+ reg.Add(building);
+
+ foreach (var cellId in envCellIds)
+ if (cellsByCellId.TryGetValue(cellId, out var cell))
+ cell.BuildingId = building.BuildingId;
+ }
+
+ return reg;
+}
+```
+
+`cell.PortalPolygons` is already populated by `CellMesh.Build` / `PhysicsDataCache.CacheCellStruct` from the same dat lookup chain (`Environment.Cells[CellStructure].Polygons[PolygonId]`) — RR3 doesn't have to re-derive it.
+
+## 5. Edge cases
+
+1. **Building with zero portals** — skipped (matches WB `discoveredCellIds.Count > 0` gate at l. 89). The building entity (the cottage shell mesh) still ships via the existing `LandblockLoader` path with `IsBuildingShell = true`; the `BuildingRegistry` just doesn't list it.
+
+2. **Cell shared between two buildings** — handled by `BuildingRegistry._byCellId: Dictionary>` (plan's RR3-S7). `LoadedCell.BuildingId` will be stamped with the LAST building's id; consumers requiring all owners must use `BuildingRegistry.GetBuildingsContainingCell` (plural). RR7's render-path uses the plural lookup.
+
+3. **Building with portals pointing to unloaded cells** — Step B's BFS bails out at the unloaded cell (`!cellsByCellId.TryGetValue`); the building's `EnvCellIds` is short by however many cells weren't loaded. In production this doesn't happen (streaming loads all cells before the registry builds). In tests, the loader still returns a valid (possibly partial) building. Worth a doc comment in RR3's `BuildingLoader.cs`.
+
+4. **`BuildingInfo.Portals[i].OtherCellId == 0xFFFF`** — defensively skipped at Step A. Empirically WB's code includes the same defensive check (l. 58), so the case is anticipated even if not common.
+
+5. **Multi-landblock buildings** — none observed. `BuildingPortal.OtherCellId` is a 16-bit value scoped to the same landblock; the dat-level encoding can't reference a different landblock. Buildings are LB-local.
+
+6. **Dungeon cells** — dungeons are NOT enumerated in `LandBlockInfo.Buildings`. Their cells have `BuildingId == null` and flow through the outdoor render path. The plan calls this out explicitly; nothing changes here.
+
+## 6. Gate decision
+
+✅ **Data shape compatible — proceed to RR3.**
+
+The two corrections vs the plan's RR3 pseudocode (`BuildingPortal` rename, `cell.BuildingId` setter timing) are minor and confined to RR3's test-helper + setter call site. The algorithm is unchanged from the plan's expectation. No re-brainstorm needed.
+
+## 7. References
+
+- DRW v2.1.7 `BuildingInfo`: `%USERPROFILE%\.nuget\packages\chorizite.datreaderwriter\2.1.7\lib\net8.0\DatReaderWriter.dll` (decompiled via `ilspycmd -t DatReaderWriter.Types.BuildingInfo`)
+- DRW v2.1.7 `BuildingPortal`: ditto, `-t DatReaderWriter.Types.BuildingPortal`
+- DRW v2.1.7 `CellPortal`: ditto, `-t DatReaderWriter.Types.CellPortal`
+- WB walk: [references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs:43-97](../../references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs)
+- WB upload: [references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:488-628](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs)
+- Retail header for `BuildInfo` (renamed in DRW to `BuildingInfo`): [docs/research/named-retail/acclient.h:32035-32042](../research/named-retail/acclient.h)
+- Retail header for `CBldPortal` (renamed to `BuildingPortal`): [docs/research/named-retail/acclient.h:32094-32103](../research/named-retail/acclient.h)
+- Existing acdream consumer pattern: [src/AcDream.App/Rendering/GameWindow.cs:5789-5803](../../src/AcDream.App/Rendering/GameWindow.cs)
+- Existing `LoadedCell.Portals` shape: [src/AcDream.App/Rendering/CellVisibility.cs:51,79](../../src/AcDream.App/Rendering/CellVisibility.cs)