using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering; using DatReaderWriter.DBObjs; namespace AcDream.App.Rendering.Wb; /// /// Phase A8 (2026-05-26): static factory that builds a per-landblock /// from a 's /// Buildings array. /// /// Algorithm (mirrors WB's PortalService.GetPortalsByBuilding at /// 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 /// interior cells reachable from the entry portals (interior portals only; /// exit portals — OtherCellId == 0xFFFF — terminate each BFS branch). /// Step C — collect exit portal polygons in world space for the stencil /// pipeline (Phase A8 Steps 1+2, RR7 scope). /// /// /// 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). /// 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). /// /// 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. /// A fully populated ; never null. public static BuildingRegistry Build( LandBlockInfo info, uint landblockId, IReadOnlyDictionary 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 bInfo in info.Buildings) { 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) { if (portal.OtherCellId == 0xFFFF) continue; envCellIds.Add(lbMask | portal.OtherCellId); } } // 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. 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) { if (p.OtherCellId == 0xFFFF) continue; // exit portal — stop BFS here uint neighbourId = lbMask | p.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). 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); } } // WB PortalService.cs:89: skip buildings with no interior cells. if (envCellIds.Count == 0) continue; var building = new Building { BuildingId = nextId++, EnvCellIds = envCellIds, ExitPortalPolygons = exitPortalPolys, }; 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). foreach (var cellId in envCellIds) { if (cellsByCellId.TryGetValue(cellId, out var cell)) cell.BuildingId = building.BuildingId; } } return reg; } }