acdream/docs/research/2026-05-26-a8-buildings-data-shape.md
Erik f44a9bf943 docs(research): Phase A8 RR2 — BuildingInfo data shape + interior-portal walk
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>
2026-05-27 11:01:39 +02:00

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):

  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:

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

  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.

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 BuildingInfo entries would be discovered TWICE (once per BFS). WB's HashSet<uint> discoveredCellIds is per-building, so each building gets its own copy. The plan's BuildingRegistry.GetBuildingsContainingCell already handles the "shared cell" case via List<Building>.
  • WB walks the dat database (cellDb.TryGet<EnvCell>(cellId, ...)) DIRECTLY, regardless of whether cells are already loaded. Our BuildingLoader.Build will take IReadOnlyDictionary<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.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 EnvCells 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)

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

  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<uint, List<Building>> (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