From f44a9bf9435d0054fe55ecb858179c38ebcdbb27 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 27 May 2026 11:01:39 +0200 Subject: [PATCH] =?UTF-8?q?docs(research):=20Phase=20A8=20RR2=20=E2=80=94?= =?UTF-8?q?=20BuildingInfo=20data=20shape=20+=20interior-portal=20walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spike findings before RR3 (BuildingLoader impl). Documents: - DatReaderWriter.Types.BuildingInfo field shape (verbatim ilspy decomp of DRW 2.1.7 — type is BuildingInfo with field BuildingPortal, not the plan's tentative BldPortal; same OtherCellId semantics) - WB PortalService.GetPortalsByBuilding interior-portal walk algorithm (BFS through EnvCell.CellPortals; 0xFFFF == exit-portal sentinel) - Holtburg town landblock 0xA9B4FFFF live BuildingInfo dump: 12 buildings, 1-10 portals each, including the cottage from the #98 cellar saga at idx=6 (cells 0xA9B40145/014C/014E/014F/0150) - Resolved BuildingLoader algorithm + 2 minor rename corrections vs the plan's RR3 pseudocode (BuildingPortal not BldPortal; defensive 0xFFFF skip kept matching WB) - 6 edge cases (empty portals, shared cells, unloaded interiors, etc.) Gate decision: data shape compatible — proceed to RR3. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-26-a8-buildings-data-shape.md | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 docs/research/2026-05-26-a8-buildings-data-shape.md 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)