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) <noreply@anthropic.com>
22 KiB
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
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):
- The DRW type for each entry of
BuildingInfo.PortalsisBuildingPortal(NOTBldPortalas the plan's RR3-S9 test file uses). Plan's RR3 tests should renamenew BldPortal { ... }→new BuildingPortal { ... }. - The exit-portal sentinel
0xFFFFis the value ofOtherCellIditself (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:
namespace DatReaderWriter.Types;
public class BuildingInfo : IDatObjType, IUnpackable, IPackable
{
/// <summary>Either a SetupModel (0x02xxxxxx) or GfxObj (0x01xxxxxx) id.</summary>
public uint ModelId;
/// <summary>The position information (Origin: Vector3, Orientation: Quaternion).</summary>
public Frame Frame;
public uint NumLeaves;
public List<BuildingPortal> Portals = new List<BuildingPortal>();
}
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):
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<ushort> StabList = new List<ushort>();
}
Frame (lives at DatReaderWriter.Types.Frame, also a field-based class):
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:
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
- No empty-portal building edge case in production data — the
envCellIds.Count == 0short-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). - Defensive
if (portal.OtherCellId == 0xFFFF) continuein Step A — never fires for BuildingInfo.Portals (confirmed across 60 portals); keeping it matches WB's defensive style. - 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.
public IEnumerable<BuildingPortalGroup> GetPortalsByBuilding(uint regionId, ushort landblockId)
{
var lbFileId = ((uint)landblockId << 16) | 0xFFFE;
if (!_dats.CellRegions.TryGetValue(regionId, out var cellDb)) yield break;
if (!cellDb.TryGet<LandBlockInfo>(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<uint>();
var cellsToProcess = new Queue<uint>();
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<EnvCell>(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<PortalData>();
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<DatReaderWriter.DBObjs.Environment>(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
BuildingInfoentries would be discovered TWICE (once per BFS). WB'sHashSet<uint> discoveredCellIdsis per-building, so each building gets its own copy. The plan'sBuildingRegistry.GetBuildingsContainingCellalready handles the "shared cell" case viaList<Building>. - WB walks the dat database (
cellDb.TryGet<EnvCell>(cellId, ...)) DIRECTLY, regardless of whether cells are already loaded. OurBuildingLoader.Buildwill takeIReadOnlyDictionary<uint, LoadedCell>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:
var portalList = new List<BuildingPortal>();
foreach (var ocid in portals)
{
portalList.Add(new BuildingPortal
{
OtherCellId = (ushort)(ocid & 0xFFFFu),
Flags = 0,
OtherPortalId = 0,
StabList = new List<ushort>(),
});
}
4.2 Pre-loaded cells vs dat-direct walk
The plan's BuildingLoader walks IReadOnlyDictionary<uint, LoadedCell> cellsByCellId, NOT the dat database. This is the correct choice for acdream because:
- Our
LoadedCell.Portalsis already populated withCellPortalInforecords (one perEnvCell.CellPortalsentry) at landblock-load time byCellMesh/PhysicsDataCache.CacheCellStruct. - The streaming pipeline (
LandblockStreamer.LoadNear) loads ALL of a landblock'sEnvCells into the dict beforeBuildingLoader.Buildruns. 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)
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 b in info.Buildings)
{
var envCellIds = new HashSet<uint>();
var exitPortalPolys = new List<Vector3[]>();
// 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<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;
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
-
Building with zero portals — skipped (matches WB
discoveredCellIds.Count > 0gate at l. 89). The building entity (the cottage shell mesh) still ships via the existingLandblockLoaderpath withIsBuildingShell = true; theBuildingRegistryjust doesn't list it. -
Cell shared between two buildings — handled by
BuildingRegistry._byCellId: Dictionary<uint, List<Building>>(plan's RR3-S7).LoadedCell.BuildingIdwill be stamped with the LAST building's id; consumers requiring all owners must useBuildingRegistry.GetBuildingsContainingCell(plural). RR7's render-path uses the plural lookup. -
Building with portals pointing to unloaded cells — Step B's BFS bails out at the unloaded cell (
!cellsByCellId.TryGetValue); the building'sEnvCellIdsis 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'sBuildingLoader.cs. -
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. -
Multi-landblock buildings — none observed.
BuildingPortal.OtherCellIdis a 16-bit value scoped to the same landblock; the dat-level encoding can't reference a different landblock. Buildings are LB-local. -
Dungeon cells — dungeons are NOT enumerated in
LandBlockInfo.Buildings. Their cells haveBuildingId == nulland 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 viailspycmd -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
- WB upload: references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:488-628
- Retail header for
BuildInfo(renamed in DRW toBuildingInfo): docs/research/named-retail/acclient.h:32035-32042 - Retail header for
CBldPortal(renamed toBuildingPortal): docs/research/named-retail/acclient.h:32094-32103 - Existing acdream consumer pattern: src/AcDream.App/Rendering/GameWindow.cs:5789-5803
- Existing
LoadedCell.Portalsshape: src/AcDream.App/Rendering/CellVisibility.cs:51,79