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>
This commit is contained in:
parent
a5d2244467
commit
f44a9bf943
1 changed files with 402 additions and 0 deletions
402
docs/research/2026-05-26-a8-buildings-data-shape.md
Normal file
402
docs/research/2026-05-26-a8-buildings-data-shape.md
Normal file
|
|
@ -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
|
||||
{
|
||||
/// <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):
|
||||
|
||||
```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<ushort> StabList = new List<ushort>();
|
||||
}
|
||||
```
|
||||
|
||||
`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<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:
|
||||
|
||||
```csharp
|
||||
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 `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<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
|
||||
|
||||
- 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue