// CellVisibility.cs — portal-based interior cell visibility system.
//
// Campaign FW4 (2026-08-31): the obsolete per-frame portal BFS is deleted.
// The committed cell registry remains the production walk's authoritative
// source. Physics supplies the root and RetailFrameWalk owns visibility.
//
// 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;
using AcDream.App.Rendering.Walk;
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;
///
/// Render unification (2026-06-07): true for the synthetic OUTDOOR cell node built by
/// — the outdoor world modelled as a flood-graph cell whose
/// shell is the landscape. seeds OutsideView
/// full-screen when the root carries this flag (so terrain/sky/scenery draw as the node's shell).
/// An explicit flag, not a cell-id heuristic: interior EnvCell ids are >= 0x100 in production but
/// test fixtures use low ids for interior cells, so keying on the id would misfire.
///
public bool IsOutdoorNode;
///
/// Campaign FW3.1: the retail frame walk's model of this cell (portal
/// side/exact-match decode, portal polygons + planes, stab list,
/// UNLIFTED transform), built atomically alongside this
/// by
/// EnvCellLandblockBuildBuilder.BuildVisibilityCell — see
/// WalkCellFactory.FromParsed. Null only for hand-built test
/// fixtures that construct a directly instead of
/// through the streaming build. WalkProductionFrameContext.GetVisible
/// is the walk's sole read of this field — the committed registry
/// () is the walk's only cell
/// source; its dead BFS role is unrelated.
///
public WalkCell? Walk { get; internal set; }
}
///
/// 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,
}
///
/// Committed EnvCell registry shared by streaming, physics diagnostics, and the
/// retail frame walk. It owns no visibility algorithm;
/// is the sole per-frame visibility authority.
///
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 used by the production frame walk.
private readonly Dictionary _cellLookup = new();
///
/// 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;
}
///
/// Atomically replaces one landblock's complete portal-cell set on the
/// render thread. Streaming workers build the input privately; no partially
/// hydrated landblock is ever visible to the per-frame flood.
///
/// Full landblock id, e.g. 0xA9B4FFFF.
public void CommitLandblock(uint landblockId, IReadOnlyList cells)
{
uint prefix = landblockId >> 16;
if (cells.Any(cell => (cell.CellId >> 16) != prefix))
throw new ArgumentException(
"A visibility cell belongs to a different landblock.",
nameof(cells));
if (_cellsByLandblock.TryGetValue(prefix, out var previous))
{
foreach (var cell in previous)
_cellLookup.Remove(cell.CellId);
}
var committed = new List(cells);
_cellsByLandblock[prefix] = committed;
foreach (var cell in committed)
_cellLookup[cell.CellId] = cell;
}
///
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
/// landblock prefix. Used by LandblockRenderPublisher 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);
}
// ------------------------------------------------------------------
// 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.
///
public bool IsInsideAnyCell(Vector3 worldPoint)
{
foreach (var cell in _cellLookup.Values)
if (PointInCell(worldPoint, cell)) return true;
return false;
}
}