diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index ac17ed8..6b2736d 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -5895,7 +5895,17 @@ public sealed class GameWindow : IDisposable
uint lbRegistryKey = lb.LandblockId & 0xFFFF0000u;
_buildingRegistries[lbRegistryKey] =
AcDream.App.Rendering.Wb.BuildingLoader.Build(
- lbInfo, lb.LandblockId, _cellVisibility.AllLoadedCells);
+ lbInfo,
+ lb.LandblockId,
+ _cellVisibility.AllLoadedCells,
+ // RR7.3: dat-driven BFS — completes regardless of which
+ // cells have streamed into _cellVisibility by the time
+ // lbInfo arrives. Without this, large multi-room
+ // buildings (Holtburg Inn = 209 leaves, 2 entry portals)
+ // had EnvCellIds short of the building's actual cell
+ // set when intermediate cells weren't yet loaded.
+ dats: _dats,
+ landblockOrigin: origin);
}
}
diff --git a/src/AcDream.App/Rendering/Wb/BuildingLoader.cs b/src/AcDream.App/Rendering/Wb/BuildingLoader.cs
index cca29f3..a6c1dc1 100644
--- a/src/AcDream.App/Rendering/Wb/BuildingLoader.cs
+++ b/src/AcDream.App/Rendering/Wb/BuildingLoader.cs
@@ -1,7 +1,9 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
+using DatReaderWriter;
using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
namespace AcDream.App.Rendering.Wb;
@@ -14,20 +16,21 @@ namespace AcDream.App.Rendering.Wb;
/// WorldBuilder.Shared/Services/PortalService.cs:43-97):
///
/// - Step A — seed the cell set from BuildingInfo.Portals entry portals.
-/// - Step B — BFS through to discover all
+///
- Step B — BFS through to discover all
/// interior cells reachable from the entry portals (interior portals only;
-/// exit portals — OtherCellId == 0xFFFF — terminate each BFS branch).
+/// exit portals — OtherCellId == 0xFFFF — terminate each BFS branch).
+/// Walks the dat directly so BFS completes regardless of which cells happen
+/// to be pre-loaded into (RR7.3 fix —
+/// prior versions short-circuited on unloaded cells, missing large multi-
+/// room buildings like the Holtburg Inn).
/// - Step C — collect exit portal polygons in world space for the stencil
-/// pipeline (Phase A8 Steps 1+2, RR7 scope).
+/// pipeline (Phase A8 Steps 1+2, RR7 scope). Uses pre-loaded
+/// entries for the world transform when available;
+/// falls back to the dat-side envCell.Position when not.
+/// - Step D — stamp on pre-loaded cells.
+/// Cells loaded later get stamped by the drain hook in GameWindow.
///
///
-/// Cells whose LoadedCell entries are missing from
-/// are silently skipped (BFS bails at the
-/// unloaded cell). In production, streaming loads all cells for a landblock
-/// before runs, so the dict is always complete.
-///
-/// LoadedCell.BuildingId is stamped here in RR4 after reg.Add(building).
-///
/// Retail references:
/// docs/research/named-retail/acclient.h:32035 (BuildInfo) and
/// :32094 (CBldPortal).
@@ -36,21 +39,28 @@ public static class BuildingLoader
{
///
/// Builds a from the supplied landblock data.
- /// Building IDs are allocated sequentially starting at 1 (0 is reserved for
- /// "no building" semantics used by LoadedCell.BuildingId in RR4).
+ /// Building IDs are allocated sequentially starting at 1.
///
/// The dat-loaded for this landblock.
/// The 32-bit landblock id (e.g. 0xA9B40000).
/// The high 16 bits are ORed with each 16-bit OtherCellId to produce
/// the full cell id.
/// Pre-loaded cells keyed by full 32-bit cell id.
- /// Used for BFS extension (Step B) and exit-polygon collection (Step C).
- /// An empty dict is valid for unit tests; Step B and C are skipped per cell.
+ /// Used for stamping (Step D) and as a fast path for exit-polygon collection.
+ /// Cells not in the dict are looked up from if non-null.
+ /// Dat collection for dat-driven BFS + fallback polygon
+ /// resolution. May be null for unit tests (Step B short-circuits to the
+ /// dict-only walk, matching the pre-RR7.3 behavior).
+ /// World-space origin of the landblock. Used
+ /// to translate dat-side cell positions for the polygon-fallback path.
+ /// Unused when is null.
/// A fully populated ; never null.
public static BuildingRegistry Build(
LandBlockInfo info,
uint landblockId,
- IReadOnlyDictionary cellsByCellId)
+ IReadOnlyDictionary cellsByCellId,
+ DatCollection? dats = null,
+ Vector3 landblockOrigin = default)
{
var reg = new BuildingRegistry();
if (info.Buildings is null || info.Buildings.Count == 0)
@@ -61,14 +71,10 @@ public static class BuildingLoader
foreach (var bInfo in info.Buildings)
{
- var envCellIds = new HashSet();
+ var envCellIds = new HashSet();
var exitPortalPolys = new List();
// Step A: seed the cell set from BuildingInfo.Portals (entry portals).
- // Each BuildingPortal.OtherCellId is a 16-bit cell-local id; OR with
- // the landblock prefix for the full id.
- // Defensive: skip OtherCellId == 0xFFFF (exit-portal sentinel in
- // BuildingInfo — rare but WB guards against it too at PortalService.cs:58).
if (bInfo.Portals is not null)
{
foreach (var portal in bInfo.Portals)
@@ -78,46 +84,42 @@ public static class BuildingLoader
}
}
- // Step B: BFS through interior CellPortals to find the full cell set.
- // Uses pre-loaded LoadedCell.Portals (avoids a duplicate dat fetch per
- // BFS step). Mirrors WB PortalService.cs:67-79.
- // When cellsByCellId is empty (unit-test path), BFS immediately exits.
+ // Step B: BFS — dat-driven (RR7.3). Falls back to LoadedCell.Portals
+ // when dats is null (unit-test path).
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)
+ IReadOnlyList<(ushort OtherCellId, ushort PolygonId)>? neighbours =
+ LookupNeighbours(current, cellsByCellId, dats);
+ if (neighbours is null) continue;
+
+ foreach (var (otherCellId, _) in neighbours)
{
- if (p.OtherCellId == 0xFFFF) continue; // exit portal — stop BFS here
- uint neighbourId = lbMask | p.OtherCellId;
+ if (otherCellId == 0xFFFF) continue;
+ uint neighbourId = lbMask | otherCellId;
if (envCellIds.Add(neighbourId))
queue.Enqueue(neighbourId);
}
}
// Step C: collect exit portal polygons in world space.
- // For each interior cell, iterate its portals; for each exit portal
- // (OtherCellId == 0xFFFF), transform the portal polygon vertices from
- // cell-local space to world space via WorldTransform.
- // Mirrors WB PortalService.cs:81-86 (GetPortalsForCell return path).
+ // Fast path: use pre-loaded LoadedCell when available (already has
+ // WorldTransform + resolved PortalPolygons).
+ // Fallback path: walk the dat for cells not yet loaded so the
+ // stencil mask is complete on first render after registry build.
foreach (var cellId in envCellIds)
{
- if (!cellsByCellId.TryGetValue(cellId, out var cell)) continue;
- for (int pi = 0; pi < cell.Portals.Count; pi++)
+ if (cellsByCellId.TryGetValue(cellId, out var cell))
{
- 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);
+ CollectExitPolygonsFromLoadedCell(cell, exitPortalPolys);
+ }
+ else if (dats is not null)
+ {
+ CollectExitPolygonsFromDat(cellId, dats, landblockOrigin, exitPortalPolys);
}
}
- // WB PortalService.cs:89: skip buildings with no interior cells.
if (envCellIds.Count == 0) continue;
var building = new Building
@@ -128,9 +130,9 @@ public static class BuildingLoader
};
reg.Add(building);
- // Step 4: stamp BuildingId on each cell (Option C — both directions
- // O(1)). The internal setter on LoadedCell.BuildingId is accessible
- // because this class lives in the same assembly (AcDream.App).
+ // Step D: stamp BuildingId on each pre-loaded cell. Cells loaded
+ // later are stamped by the drain hook in GameWindow.cs that runs
+ // _buildingRegistries.TryGetValue + GetBuildingsContainingCell.
foreach (var cellId in envCellIds)
{
if (cellsByCellId.TryGetValue(cellId, out var cell))
@@ -140,4 +142,99 @@ public static class BuildingLoader
return reg;
}
+
+ ///
+ /// RR7.3: dat-or-loaded neighbour lookup. Returns the cell's portal list as
+ /// (OtherCellId, PolygonId) tuples so the BFS body can stay agnostic about
+ /// the source.
+ ///
+ private static IReadOnlyList<(ushort OtherCellId, ushort PolygonId)>? LookupNeighbours(
+ uint cellId,
+ IReadOnlyDictionary cellsByCellId,
+ DatCollection? dats)
+ {
+ if (cellsByCellId.TryGetValue(cellId, out var loaded))
+ {
+ var result = new (ushort, ushort)[loaded.Portals.Count];
+ for (int i = 0; i < loaded.Portals.Count; i++)
+ result[i] = (loaded.Portals[i].OtherCellId, loaded.Portals[i].PolygonId);
+ return result;
+ }
+
+ if (dats is null) return null;
+
+ var envCell = dats.Get(cellId);
+ if (envCell is null) return null;
+
+ var fromDat = new (ushort, ushort)[envCell.CellPortals.Count];
+ for (int i = 0; i < envCell.CellPortals.Count; i++)
+ fromDat[i] = (envCell.CellPortals[i].OtherCellId, envCell.CellPortals[i].PolygonId);
+ return fromDat;
+ }
+
+ private static void CollectExitPolygonsFromLoadedCell(LoadedCell cell, List sink)
+ {
+ 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);
+ sink.Add(worldPoly);
+ }
+ }
+
+ ///
+ /// RR7.3 fallback: resolve a cell's exit portal polygons directly from the
+ /// dat when the cell hasn't yet streamed into .
+ /// Mirrors PortalService.GetPortalsForCell at
+ /// WorldBuilder.Shared/Services/PortalService.cs:99-139.
+ ///
+ private static void CollectExitPolygonsFromDat(
+ uint cellId,
+ DatCollection dats,
+ Vector3 landblockOrigin,
+ List sink)
+ {
+ var envCell = dats.Get(cellId);
+ if (envCell is null) return;
+ if (envCell.EnvironmentId == 0) return;
+
+ var environment = dats.Get(0x0D000000u | envCell.EnvironmentId);
+ if (environment is null) return;
+ if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) return;
+
+ var cellOriginWorld = envCell.Position.Origin + landblockOrigin;
+ var cellTransform =
+ Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
+ Matrix4x4.CreateTranslation(cellOriginWorld);
+
+ foreach (var portal in envCell.CellPortals)
+ {
+ if (portal.OtherCellId != 0xFFFF) continue;
+ if (!cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)) continue;
+ if (poly.VertexIds.Count < 3) continue;
+
+ var localPoly = new Vector3[poly.VertexIds.Count];
+ bool allFound = true;
+ for (int v = 0; v < poly.VertexIds.Count; v++)
+ {
+ if (!cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[v], out var vtx))
+ {
+ allFound = false;
+ break;
+ }
+ localPoly[v] = vtx.Origin;
+ }
+ if (!allFound) continue;
+
+ var worldPoly = new Vector3[localPoly.Length];
+ for (int v = 0; v < localPoly.Length; v++)
+ worldPoly[v] = Vector3.Transform(localPoly[v], cellTransform);
+ sink.Add(worldPoly);
+ }
+ }
}