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); + } + } }