using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; 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). /// internal sealed class BuildingRegistryPublication { internal BuildingRegistryPublication( BuildingInfo[] buildings, uint landblockId, IReadOnlyDictionary cellsByCellId) { Buildings = buildings; LandblockId = landblockId; CellsByCellId = cellsByCellId; PreparationCommitted = buildings.Length == 0; } internal BuildingInfo[] Buildings { get; } internal uint LandblockId { get; } internal IReadOnlyDictionary CellsByCellId { get; } internal BuildingRegistry Registry { get; } = new(); internal List<(LoadedCell Cell, uint BuildingId)> CellStamps { get; } = new(); internal int BuildingCursor { get; set; } internal uint NextBuildingId { get; set; } = 1; internal bool PreparationCommitted { get; set; } internal bool PublicationCommitted { get; set; } } 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). /// public static BuildingRegistry Build( LandBlockInfo info, uint landblockId, IReadOnlyDictionary cellsByCellId) { BuildingRegistryPublication publication = PreparePublication( info, landblockId, cellsByCellId); while (!AdvancePreparationOne(publication)) { } CommitPublication(publication); return publication.Registry; } internal static BuildingRegistryPublication PreparePublication( LandBlockInfo info, uint landblockId, IReadOnlyDictionary cellsByCellId) { ArgumentNullException.ThrowIfNull(info); ArgumentNullException.ThrowIfNull(cellsByCellId); int buildingCount = info.Buildings?.Count ?? 0; var buildings = new BuildingInfo[buildingCount]; for (int index = 0; index < buildingCount; index++) buildings[index] = info.Buildings![index]; return new BuildingRegistryPublication( buildings, landblockId, cellsByCellId); } internal static bool AdvancePreparationOne( BuildingRegistryPublication publication) { ArgumentNullException.ThrowIfNull(publication); if (publication.PreparationCommitted) return true; if (publication.BuildingCursor >= publication.Buildings.Length) { publication.PreparationCommitted = true; return true; } AddBuilding( publication, publication.Buildings[publication.BuildingCursor]); publication.BuildingCursor++; if (publication.BuildingCursor >= publication.Buildings.Length) publication.PreparationCommitted = true; return publication.PreparationCommitted; } internal static void CommitPublication( BuildingRegistryPublication publication) { ArgumentNullException.ThrowIfNull(publication); if (!publication.PreparationCommitted) { throw new InvalidOperationException( "A building registry cannot publish before preparation completes."); } if (publication.PublicationCommitted) return; foreach ((LoadedCell cell, uint buildingId) in publication.CellStamps) cell.BuildingId = buildingId; publication.PublicationCommitted = true; } private static void AddBuilding( BuildingRegistryPublication publication, BuildingInfo bInfo) { uint lbMask = publication.LandblockId & 0xFFFF0000u; var envCellIds = new HashSet(); var exitPortalPolys = new List(); // Step A: seed the cell set from BuildingInfo.Portals. 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 portals. var queue = new Queue(envCellIds); while (queue.Count > 0) { uint current = queue.Dequeue(); if (!publication.CellsByCellId.TryGetValue(current, out var cell)) continue; foreach (var portal in cell.Portals) { if (portal.OtherCellId == 0xFFFF) continue; uint neighbourId = lbMask | portal.OtherCellId; if (envCellIds.Add(neighbourId)) queue.Enqueue(neighbourId); } } // Step C: collect exit portal polygons in world space. foreach (uint cellId in envCellIds) { if (!publication.CellsByCellId.TryGetValue(cellId, out var cell)) continue; for (int portalIndex = 0; portalIndex < cell.Portals.Count; portalIndex++) { if (cell.Portals[portalIndex].OtherCellId != 0xFFFF) continue; if (portalIndex >= cell.PortalPolygons.Count) continue; Vector3[] localPolygon = cell.PortalPolygons[portalIndex]; if (localPolygon.Length < 3) continue; var worldPolygon = new Vector3[localPolygon.Length]; for (int vertexIndex = 0; vertexIndex < localPolygon.Length; vertexIndex++) { worldPolygon[vertexIndex] = Vector3.Transform( localPolygon[vertexIndex], cell.WorldTransform); } exitPortalPolys.Add(worldPolygon); } } bool hasPortalBounds = false; var portalMin = new Vector3(float.MaxValue); var portalMax = new Vector3(float.MinValue); foreach (Vector3[] polygon in exitPortalPolys) { foreach (Vector3 vertex in polygon) { hasPortalBounds = true; portalMin = Vector3.Min(portalMin, vertex); portalMax = Vector3.Max(portalMax, vertex); } } if (envCellIds.Count == 0) return; uint buildingId = publication.NextBuildingId++; publication.Registry.Add(new Building { BuildingId = buildingId, EnvCellIds = envCellIds, ExitPortalPolygons = exitPortalPolys, HasPortalBounds = hasPortalBounds, PortalBounds = hasPortalBounds ? new WbBoundingBox(portalMin, portalMax) : default, }); // Stamps remain off-side until the complete registry commits. foreach (uint cellId in envCellIds) { if (publication.CellsByCellId.TryGetValue(cellId, out var cell)) publication.CellStamps.Add((cell, buildingId)); } } }