// CellVisibility.cs — portal-based interior cell visibility system. // // Stage 3 (2026-06-02): FindCameraCell + grace-frame AABB fallback deleted. // The physics membership answer (CellGraph.CurrCell) is now the mandatory root; // ComputeVisibilityFromRoot(null, …) returns null (outdoor root) rather than // falling back to an independent AABB position resolve. This matches retail's // CellManager::ChangePosition (0x004559B0) which does not re-derive the cell // from a static position — it reads the swept transition-owned CurrCell. // // This file is intentionally free of GL / rendering types. It depends only on // System.Numerics so it can be unit-tested without a GPU context. using System.Collections.Generic; using System.Numerics; namespace AcDream.App.Rendering; // --------------------------------------------------------------------------- // Data structures // --------------------------------------------------------------------------- /// /// A loaded EnvCell with portal connectivity and spatial data, used by /// for portal-traversal visibility decisions. /// public sealed class LoadedCell { /// Full 32-bit cell ID, e.g. 0xA9B40105. public uint CellId; /// Cell origin in world space (used for neighbour distance checks). public Vector3 WorldPosition; /// Cell-to-world transform (rotation + translation from EnvCell placement). public Matrix4x4 WorldTransform; /// /// Cached inverse of . Pre-computed at load time so /// PointInCell doesn't pay the inversion cost per frame. /// public Matrix4x4 InverseWorldTransform; /// Local-space AABB minimum, computed from CellStruct vertices. public Vector3 LocalBoundsMin; /// Local-space AABB maximum, computed from CellStruct vertices. public Vector3 LocalBoundsMax; /// /// Ordered portal connections. Index i in Portals corresponds to index i in /// (when ClipPlanes.Count > i). /// public List Portals = new(); /// /// One clip plane per portal polygon, in cell-local space. Used by the /// portal-side test to decide whether the camera can see through a portal. /// Derived from portal polygon geometry during cell preparation. /// public List ClipPlanes = new(); /// /// Portal polygon vertices in cell-local space, one Vector3[] per /// entry in . Index i /// in this list corresponds to index i in and /// . An empty array means the portal's polygon /// could not be resolved at load time (degenerate cell or missing /// polygon entry). /// /// Used by the Phase A8 indoor-cell stencil pipeline to build a /// per-frame triangle-fan mesh for portal silhouette masking. /// /// public List PortalPolygons = new(); /// /// Phase A8 (2026-05-26): the building this cell belongs to, if any. /// Set exactly once by immediately after /// LandblockLoader produces the cells. Null when the cell isn't part of /// any building (outdoor surface cells; dungeon cells not enumerated in /// LandBlockInfo.Buildings). /// /// Used by the render frame to derive the camera-buildings set /// via /// and route IndoorPass cell scoping. /// public uint? BuildingId { get; internal set; } /// /// Phase U.4c: the stab_list PVS as full (landblock-prefixed) cell ids — retail /// CEnvCell.stab_list (acclient.h ~30925), the stable set of cells potentially /// visible from this cell, precomputed by the AC content tools. Refreshed only at /// hydration (= retail's per-cell-entry grab_visible_cells, decomp:311878). /// PortalVisibilityBuilder grounds set membership in it so a brittle per-frame /// portal-side test can't drop a potentially-visible cell from the visible set. /// Empty when the dat carried no stab list (degenerate / old cell). /// public IReadOnlyList VisibleCells = System.Array.Empty(); /// /// Phase U.4c: retail CEnvCell.seen_outside (acclient.h ~30925) — this cell sees /// the exterior (an exit portal is reachable from it). Retail gates the landscape /// data + draw decision on the camera cell's value (RenderNormalMode decomp:92649, /// grab_visible_cells decomp:311878). The stable anchor for the terrain-draw test. /// public bool SeenOutside; } /// /// Portal connection to a neighbouring cell. /// OtherCellId == 0xFFFF indicates an exit portal to the outdoor world. /// /// is the dat's reciprocal back-link: the index of /// the portal WITHIN the neighbour cell's portal list that points back through /// this same opening. Retail indexes the reciprocal directly via this field /// (arg2->other_portal_id, decomp:433557) rather than scanning — which /// is what lets a cell with TWO portals to the same neighbour resolve each /// opening against its OWN reciprocal polygon instead of the first match. /// /// public readonly record struct CellPortalInfo( ushort OtherCellId, ushort PolygonId, ushort Flags, ushort OtherPortalId); /// /// Clip plane derived from a portal polygon, in cell-local space. /// Plane equation: Normal.X*x + Normal.Y*y + Normal.Z*z + D = 0. /// public struct PortalClipPlane { /// Plane normal (cell-local space, unit length). public Vector3 Normal; /// Plane offset so that Dot(Normal, point) + D = 0 on the plane. public float D; /// /// Which half-space is "inside" this cell (the side from which you look outward /// through the portal): /// 0 → camera dot-product must be >= 0 (positive half-space is inside) /// 1 → camera dot-product must be <= 0 (negative half-space is inside) /// Determined from cell centroid position relative to the portal plane. /// Ported from ACME EnvCellManager.cs ~line 404. /// public int InsideSide; } /// /// Phase U.4c flap probe (diagnostic — OBSOLETE as of Stage 3). Previously tracked /// which branch of FindCameraCell (now deleted) resolved the camera cell. Retained /// for binary compatibility with the [flap-cam] probe log site in GameWindow.cs that /// still prints (always None post-Stage 3). /// public enum CameraCellResolution { /// No cell contains the eye (outdoors), or not yet resolved. None, /// The eye is inside the previously-cached cell (fast path). Cache, /// The eye is inside a one-hop portal neighbour of the cached cell. Neighbour, /// The eye is inside a cell found by the full brute-force scan. BruteForce, /// The eye is inside NO cell, but the previous cell is kept alive for a /// few grace frames — the "stale root" case the flap probe watches for. Grace, } /// /// Result of a portal-based visibility BFS from the camera cell. /// public sealed class VisibilityResult { /// Full cell IDs (e.g. 0x01D90105) that should be rendered this frame. public HashSet VisibleCellIds { get; init; } = new(); /// /// True when at least one exit portal (OtherCellId == 0xFFFF) was reached during /// traversal. The caller should render outdoor terrain when this is set. /// public bool HasExitPortalVisible { get; set; } /// The cell the camera is currently inside. public LoadedCell? CameraCell { get; set; } } // --------------------------------------------------------------------------- // CellVisibility // --------------------------------------------------------------------------- /// /// Pure-logic portal visibility system for EnvCell interior rooms. /// /// Maintains a per-landblock registry of objects and /// performs a BFS through portal connections each frame to determine which cells /// should be rendered given the current camera position. /// /// Ported faithfully from ACME's EnvCellManager.cs portal-visibility region. /// Constants and control flow match the ACME implementation. /// public sealed class CellVisibility { // ------------------------------------------------------------------ // Constants (ACME ground-truth values) // ------------------------------------------------------------------ /// /// Epsilon applied to AABB containment tests so that a position sitting /// exactly on a cell wall is still considered inside. /// Source: ACME EnvCellManager.cs PointInCellEpsilon = 0.01f. /// private const float PointInCellEpsilon = 0.01f; // ------------------------------------------------------------------ // State // ------------------------------------------------------------------ /// Per-landblock lists of loaded cells. Key = upper 16 bits of a cell ID. private readonly Dictionary> _cellsByLandblock = new(); /// Full-ID lookup for O(1) neighbour resolution during BFS. private readonly Dictionary _cellLookup = new(); /// The last visibility result produced by . public VisibilityResult? LastVisibilityResult { get; private set; } /// /// Stage 3 (2026-06-02): always — the FindCameraCell /// AABB grace-frame resolver was deleted; the physics membership answer is the sole root. /// Retained for the [flap-cam] probe log line in GameWindow.cs. /// public CameraCellResolution LastCameraCellResolution { get; private set; } = CameraCellResolution.None; // ------------------------------------------------------------------ // Registration // ------------------------------------------------------------------ /// /// Registers a newly-loaded cell. Called from the streaming loader after /// CPU preparation (transforms, clip planes, bounds) is complete. /// Thread-safety: caller must not call this concurrently with rendering. /// public void AddCell(LoadedCell cell) { uint lbId = cell.CellId >> 16; if (!_cellsByLandblock.TryGetValue(lbId, out var list)) { list = new List(); _cellsByLandblock[lbId] = list; } list.Add(cell); _cellLookup[cell.CellId] = cell; } /// /// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a /// landblock prefix. Used by GameWindow.ApplyLoadedTerrainLocked when /// building the per-landblock BuildingRegistry — the per-frame /// drainedCells dict misses cells loaded on prior frames, so the /// stamping loop in needs access to /// every cell currently in the landblock to ensure BuildingId is set. /// /// Upper 16 bits of the landblock key (e.g. 0xA9B4 /// for landblock 0xA9B40000). NOT the full 32-bit landblock id. public IReadOnlyList GetCellsForLandblock(uint lbId) { return _cellsByLandblock.TryGetValue(lbId, out var list) ? list : System.Array.Empty(); } /// /// Looks up a currently loaded cell by full 32-bit cell id. /// public bool TryGetCell(uint cellId, out LoadedCell? cell) => _cellLookup.TryGetValue(cellId, out cell); /// /// Removes all cells belonging to (upper 16 bits of /// the landblock key, e.g. 0xA9B4 for landblock 0xA9B40000). Called when a /// landblock unloads. /// public void RemoveLandblock(uint lbId) { if (!_cellsByLandblock.TryGetValue(lbId, out var list)) return; foreach (var cell in list) { _cellLookup.Remove(cell.CellId); } _cellsByLandblock.Remove(lbId); } // ------------------------------------------------------------------ // Per-frame entry points // ------------------------------------------------------------------ /// /// Computes portal-based visibility from using the /// AABB FindCameraCell resolver. Retained for test compatibility only; production /// code should use with a physics-supplied /// root (Stage 3 demotes the AABB resolver to test-only use). /// Returns null when no loaded cell contains . /// public VisibilityResult? ComputeVisibility(Vector3 cameraPos) { if (_cellLookup.Count == 0) { LastVisibilityResult = null; return null; } LastVisibilityResult = GetVisibleCells(cameraPos); return LastVisibilityResult; } /// /// UCG W2/Stage 3: compute visibility from a supplied root cell (the physics membership /// answer). When is null (pre-spawn, or player outside all indoor /// cells), returns null — the caller interprets null as the outdoor root (no portal /// frame, everything slot 0, terrain ungated). The legacy AABB FindCameraCell fallback is /// deleted as of Stage 3; is the sole authority. /// Retail anchor: CellManager::ChangePosition @ 0x004559B0 reads the transition-owned /// curr_cell — it does NOT re-derive from a static position. /// /// /// The render-registered that physics determined the player is inside, /// or null when pre-spawn or the player is in an outdoor landcell. Null → outdoor root path. /// /// /// Used as the viewer position for the portal-side test in the BFS when root is non-null. /// Should be the player/physics position (stable inside the cell), not the chase-camera eye. /// The name "fallback" is historical; it is no longer used as a fallback position. /// public VisibilityResult? ComputeVisibilityFromRoot(LoadedCell? root, Vector3 fallbackPos) { if (root is null) return null; // outdoor root: caller handles null as "player is outside" // Stage 3: FindCameraCell AABB grace-frame fallback deleted. // Retail: CellManager::ChangePosition (0x004559B0) uses transition-owned CurrCell. if (_cellLookup.Count == 0) { LastVisibilityResult = null; return null; } LastVisibilityResult = GetVisibleCellsFromRoot(root, fallbackPos); return LastVisibilityResult; } // ------------------------------------------------------------------ // FindCameraCell — DELETED in Stage 3 (2026-06-02) // ------------------------------------------------------------------ // The AABB + grace-frame camera-cell resolver was removed. Production code // now exclusively uses ComputeVisibilityFromRoot(root, …) where root is the // transition-owned CellGraph.CurrCell (set by ResolveCellId/Stage 2 physics). // Retail anchor: CellManager::ChangePosition (0x004559B0) reads curr_cell // from the sweep — it never re-derives from a static position. // // GetVisibleCells (used by ComputeVisibility below for test compatibility) // still uses the brute-force AABB scan internally. // ------------------------------------------------------------------ // ------------------------------------------------------------------ // PointInCell // ------------------------------------------------------------------ /// /// Returns true when lies inside /// 's local-space AABB (within epsilon). /// /// The point is transformed into cell-local space via the pre-computed /// and then tested against /// / . /// /// Ported from ACME EnvCellManager.cs PointInCell(). /// public static bool PointInCell(Vector3 worldPoint, LoadedCell cell) { // Degenerate cell (no geometry baked yet). if (cell.LocalBoundsMin.X >= cell.LocalBoundsMax.X) return false; var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform); return local.X >= cell.LocalBoundsMin.X - PointInCellEpsilon && local.X <= cell.LocalBoundsMax.X + PointInCellEpsilon && local.Y >= cell.LocalBoundsMin.Y - PointInCellEpsilon && local.Y <= cell.LocalBoundsMax.Y + PointInCellEpsilon && local.Z >= cell.LocalBoundsMin.Z - PointInCellEpsilon && local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon; } /// /// Brute-force scan of every loaded cell to test whether /// is inside any of them. Safe to call /// independently of in the same /// frame for a different position. /// public bool IsInsideAnyCell(Vector3 worldPoint) { foreach (var cell in _cellLookup.Values) if (PointInCell(worldPoint, cell)) return true; return false; } // ------------------------------------------------------------------ // GetVisibleCells (BFS) // ------------------------------------------------------------------ /// /// Performs portal-based BFS visibility traversal starting from the camera /// cell found by an AABB brute-force scan. Returns null when no loaded cell /// contains . Used only by /// (test-compatibility path); production code /// uses with the physics-supplied root. /// public VisibilityResult? GetVisibleCells(Vector3 cameraPos) { // Brute-force AABB scan (test-compatibility; FindCameraCell was deleted in Stage 3). LoadedCell? cameraCell = null; foreach (var kvp in _cellsByLandblock) foreach (var cell in kvp.Value) if (PointInCell(cameraPos, cell)) { cameraCell = cell; break; } if (cameraCell == null) return null; return GetVisibleCellsFromRoot(cameraCell, cameraPos); } /// /// UCG W2: BFS visibility traversal from a pre-resolved root cell. /// The root is the correct membership answer (supplied by the caller — /// physics CurrCell via , or AABB /// scan via for test compat). /// /// The BFS body is byte-identical to the original GetVisibleCells /// implementation — only root acquisition was extracted out. /// private VisibilityResult? GetVisibleCellsFromRoot(LoadedCell cameraCell, Vector3 cameraPos) { var result = new VisibilityResult { CameraCell = cameraCell }; var visited = new HashSet(); var queue = new Queue(); visited.Add(cameraCell.CellId); result.VisibleCellIds.Add(cameraCell.CellId); queue.Enqueue(cameraCell); // All portals in a dungeon connect cells in the same landblock. uint lbMask = cameraCell.CellId & 0xFFFF0000u; while (queue.Count > 0) { var cell = queue.Dequeue(); for (int i = 0; i < cell.Portals.Count; i++) { var portal = cell.Portals[i]; // Exit portal → outdoor terrain should be visible. if (portal.OtherCellId == 0xFFFF) { result.HasExitPortalVisible = true; continue; } uint neighbourId = lbMask | portal.OtherCellId; if (visited.Contains(neighbourId)) continue; if (!_cellLookup.TryGetValue(neighbourId, out var neighbour)) continue; // Portal-side test: camera must be on the interior side of the // portal clip plane to see through into the neighbouring cell. if (i < cell.ClipPlanes.Count) { var plane = cell.ClipPlanes[i]; var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform); float dot = Vector3.Dot(plane.Normal, localCam) + plane.D; // InsideSide == 0 → inside is positive half-space; reject if dot < -ε. // InsideSide == 1 → inside is negative half-space; reject if dot > ε. // Source: ACME EnvCellManager.cs lines 1458-1459. if (plane.InsideSide == 0 && dot < -PointInCellEpsilon) continue; if (plane.InsideSide == 1 && dot > PointInCellEpsilon) continue; } visited.Add(neighbourId); result.VisibleCellIds.Add(neighbourId); queue.Enqueue(neighbour); } } return result; } }