acdream/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
Erik 2d15084243 #120: arm the propagation tripwire for self-attribution + two convergence regression pins
Investigation: retail's growth propagation RECURSES natively too
(AddViewToPortals -> FixCellList -> AdjustCellView -> AddViewToPortals,
Ghidra 0x005a52d0/0x005a5250/0x005a5770, no depth guard) - the in-place
recursion shape is faithful; retail's safety is fast convergence. Our
depth-128 firing means slow/non-saturating growth (each lap of a portal
cycle nests one recursion level), not necessarily a true infinite loop.

Two dat-backed sweeps over the corner-building cell set could NOT
reproduce the T5 firing:
- PortalPlaneCrossings_InPlacePropagationConverges: +/-6cm eye sweep
  across every portal plane, seeded from both sides.
- InCellDirectionSweep_InPlacePropagationConverges: 3024 builds, in-cell
  eye grid x 8 yaw x 3 pitch (the walking-and-turning regime).
Both pass with 0 firings -> production-only ingredients suspected (full
lookup graph - one T5 firing was 0x0162, another building - and/or the
real camera path).

Armed: PortalVisibilityBuilder.ConvergenceTripwireCount (test
observable, both Build + look-in sites) + DumpPropagationChain - on the
next firing the log carries root cell, eye, per-cell frequency summary,
and the 24-entry chain tail, so the cycle's structure (A<->B ping-pong
vs 3-cycle laps) reads directly off the output. Both sweeps stay as
regression pins.

App tests: 227 green (was 225; +2 pins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:57:25 +02:00

1070 lines
57 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PortalVisibilityBuilder.cs
//
// Phase A8.F: recursive portal-clip visibility (the builder). Port of retail
// PView::ConstructView (decomp:433750) -> ClipPortals (433572) -> AddViewToPortals
// (433446). Walks the portal graph from the camera cell, accumulating a per-cell
// screen-space CellView; exit portals union their clipped region into OutsideView.
// GL-free; unit-tested without a GPU context.
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>Per-frame output of the portal-frame BFS.</summary>
public sealed class PortalVisibilityFrame
{
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
public CellView OutsideView { get; } = new();
/// <summary>Per-cell accumulated clip region, keyed by full cell id (wire-in #2).</summary>
public Dictionary<uint, CellView> CellViews { get; } = new();
/// <summary>Visible interior cells in the exact order they were first dequeued from the
/// distance-priority work list — closest-first (Phase U.2a). Mirrors retail's
/// PView::cell_draw_list, appended in PView::ConstructView (decomp:433783) as each cell pops
/// off the nearest-vertex-sorted cell_todo_list (InsCellTodoList 433183). Deduplicated: a cell
/// appears exactly once, on its first dequeue. The camera cell is always first.</summary>
public List<uint> OrderedVisibleCells { get; } = new();
/// <summary>Entry clip regions for other buildings reached through our portals, keyed by the
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
}
public static class PortalVisibilityBuilder
{
// Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995
// (const @0x007c8c70; PView::InitCell Ghidra 0x005a4b70). T2 (BR-4)
// attempted the retail value and the CornerFloodReplay gate REFUTED it:
// retail's tight epsilon works because retail's viewer cell transits the
// INSTANT the eye crosses a portal plane (the sweep's curr_cell), so the
// side test never sees a stale root more than F_EPSILON past a plane. Our
// root can lag the eye by up to ~1 cm at pressed corners (the harness's
// fixed-root sweep models this), and 0.01 is that documented root-lag
// tolerance — NOT a retail constant. Tighten to F_EPSILON only together
// with eye-exact viewer-cell tracking verification (the #108-membership
// family) + the cdstW near-clip pin.
private const float PortalSideEpsilon = 0.01f;
// Retail F_EPSILON proper — used where the semantic is knife-edge
// REJECTION (ConstructView(CBldPortal) Sidedness IN_PLANE → return 0,
// Ghidra 0x005a59a0), which must NOT inherit the root-lag tolerance above
// (a 1 cm-wide in-plane band would reject look-in seeds whenever the eye
// stands near a doorway plane).
private const float SeedInPlaneEpsilon = 0.0002f;
// TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the
// local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell.
private static readonly bool s_pvDump =
Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1";
private static readonly Dictionary<uint, int> s_pvDumpCount = new();
/// <summary>
/// #120 observable: total convergence-tripwire firings across both the
/// interior <see cref="Build"/> and the exterior look-in propagation.
/// The tripwire firing means the in-place growth's fixpoint invariant
/// broke (T2/BR-4) — tests reset this and assert it stays 0.
/// </summary>
public static int ConvergenceTripwireCount;
/// <summary>
/// #120 self-attribution dump: the growth-recursion path that exceeded
/// the tripwire, as a per-cell frequency summary plus the chain tail —
/// the cycle's structure (e.g. 0174↔0175 ping-pong vs a 3-cycle lap)
/// reads directly off the output.
/// </summary>
private static void DumpPropagationChain(uint[] chain, int depth, uint rootCellId, Vector3 eye)
{
int n = Math.Min(depth, chain.Length);
var freq = new Dictionary<uint, int>();
for (int i = 0; i < n; i++)
{
freq.TryGetValue(chain[i], out int c);
freq[chain[i]] = c + 1;
}
var summary = new System.Text.StringBuilder(256);
foreach (var kvp in freq)
summary.Append(System.FormattableString.Invariant($" 0x{kvp.Key:X8}x{kvp.Value}"));
var tail = new System.Text.StringBuilder(256);
for (int i = Math.Max(0, n - 24); i < n; i++)
tail.Append(System.FormattableString.Invariant($" 0x{chain[i] & 0xFFFFu:X4}"));
Console.WriteLine(System.FormattableString.Invariant(
$"[pv-ERROR] chain root=0x{rootCellId:X8} eye=({eye.X:F3},{eye.Y:F3},{eye.Z:F3}) cells:{summary}"));
Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}");
}
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell
/// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of
/// continuing the in-building BFS. Pass null to treat all reachable cells as in-building.</param>
public static PortalVisibilityFrame Build(
LoadedCell cameraCell,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null)
{
var frame = new PortalVisibilityFrame();
if (cameraCell == null) return frame;
// Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells);
// building-boundary crossings are handled separately via the buildingMembership escape hatch.
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
frame.CellViews[cameraCell.CellId] = CellView.FullScreen();
// Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor
// node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC
// quad. ClipFrameAssembler turns that into a full-screen OutsideView slice, so DrawInside's
// DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's "shell" —
// the very same callback that already draws the doorway slice when an INTERIOR root reaches
// outdoors. Keyed on the explicit IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a
// cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior
// ids, so an id test would misfire. An interior root never sets this flag, so the indoor
// exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region.
if (cameraCell.IsOutdoorNode)
frame.OutsideView.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
// Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first;
// each cell carries the camera→nearest-portal-vertex distance that put it on the list
// (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The
// camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it
// always pops first.
var todo = new CellTodoList();
todo.Insert(cameraCell, 0f);
// Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the
// retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38
// (current view_count) decision in AddViewToPortals (433446): a cell is INSERTED into the
// todo list exactly once — on first discovery (retail's ecx_5==0 branch calls
// InsCellTodoList; the ecx_5!=eax_2 growth branch calls AddToCell IN PLACE and never
// re-enqueues). Later growth into an already-discovered cell is unioned into its CellView but
// does NOT re-enqueue it — the `cell_view_done` guarantee (ConstructView sets it at 433784
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
var queued = new HashSet<uint> { cameraCell.CellId };
var drawListed = new HashSet<uint>();
var processedViewCounts = new Dictionary<uint, int>();
var trace = PortalBuildTrace.Start(cameraCell, cameraPos);
// [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn
// + reciprocal pre/post region counts, emitted as one summary line at end of Build. Inert when off.
bool churnProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbePortalChurnEnabled;
int churnReenqueues = 0;
var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null;
bool pvDump = false;
if (s_pvDump)
{
lock (s_pvDumpCount)
{
s_pvDumpCount.TryGetValue(cameraCell.CellId, out int dc);
if (dc < 2) { s_pvDumpCount[cameraCell.CellId] = dc + 1; pvDump = true; }
}
if (pvDump)
{
Console.WriteLine($"[pv-dump] camCell=0x{cameraCell.CellId:X8} portals={cameraCell.Portals.Count} polyLists={cameraCell.PortalPolygons.Count} vp[M11={viewProj.M11:F3} M22={viewProj.M22:F3} M33={viewProj.M33:F3} M34={viewProj.M34:F3} M43={viewProj.M43:F3} M44={viewProj.M44:F3}]");
// Camera-cell portal census (A8.F triage 2026-05-29): report, for EVERY
// portal, the exact inputs the BFS guards read — BEFORE the guards run, so
// a portal the loop silently `continue`s past is still visible here. An
// empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty
// polygon from BuildLoadedCell), interiorSide=false (camera back-facing the
// portal — a legitimately-empty result, not a bug), or (if both OK) a
// downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines.
for (int ci = 0; ci < cameraCell.Portals.Count; ci++)
{
int plen = ci < cameraCell.PortalPolygons.Count
? (cameraCell.PortalPolygons[ci]?.Length ?? -1) : -2;
bool hasPlane = ci < cameraCell.ClipPlanes.Count;
bool interiorSide = !hasPlane || CameraOnInteriorSide(cameraCell, ci, cameraPos);
var n = hasPlane ? cameraCell.ClipPlanes[ci].Normal : Vector3.Zero;
Console.WriteLine($"[pv-dump] CAMPORTAL[{ci}] other=0x{cameraCell.Portals[ci].OtherCellId:X4} polyLen={plen} hasPlane={hasPlane} interiorSide={interiorSide} planeN=({n.X:F3},{n.Y:F3},{n.Z:F3})");
}
}
}
// T2 (BR-4): retail's growth propagation is IN PLACE, never by re-enqueue
// — PView::AddViewToPortals (Ghidra 0x005a52d0, pc:433446): first
// discovery enqueues via InsCellTodoList; growth into a cell whose
// cell_view_done is set calls AdjustCellView (pc:433741-433745), which
// re-clips ONLY the new views (the update_count watermark) through that
// cell's portals immediately. Our processedViewCounts IS that watermark,
// so in-place propagation = call ProcessCellPortals on the grown
// neighbour; it processes exactly the new tail and recurses further
// growth. Termination is physical: recursion fires only when AddRegion
// added a DISTINCT polygon (CanonicalKey dedup) that survived the 1-px
// vertex merge — the finite fixpoint floor that replaced the old
// MaxReprocessPerCell=16 drift cap (deleted). The depth tripwire below
// is a loud failsafe, not control flow: it firing means the convergence
// invariant broke and must be fixed, not tuned.
const int RecursionTripwire = 128;
// #120 self-attribution: the recursion path (cell id per depth), so a
// tripwire firing names the growth CYCLE instead of just the tip.
// Harness sweeps (CornerFloodReplayTests *Converges tests) could not
// reproduce the T5 firing — production-only ingredients (full lookup
// graph / real camera path) are suspected; this dump pins them on the
// next natural occurrence.
var propagationChain = new uint[RecursionTripwire];
void ProcessCellPortals(LoadedCell cell, int depth)
{
if (depth >= RecursionTripwire)
{
System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount);
Console.WriteLine($"[pv-ERROR] in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate");
DumpPropagationChain(propagationChain, depth, cameraCell.CellId, cameraPos);
return;
}
propagationChain[depth] = cell.CellId;
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
{
trace?.Add($"proc cell=0x{cell.CellId:X8} skip=no-view");
return;
}
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
int endCount = currentView.Polygons.Count;
if (processedCount >= endCount)
{
trace?.Add($"proc cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}");
return;
}
trace?.Add($"proc cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} depth={depth}");
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount;
for (int i = 0; i < cell.Portals.Count; i++)
{
var portal = cell.Portals[i];
if (i >= cell.PortalPolygons.Count)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=no-poly-slot");
continue;
}
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=degenerate-poly len={(poly?.Length ?? -1)}");
continue;
}
bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF;
bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos);
// (R-A2b Phase 1 pin, throwaway) Log the side-test inputs for EVERY portal so a back-portal
// traversal (cell=0x..0173 p->0x0171) can be attributed to B1 (eyeInsideOpening bypasses the
// side-cull) vs B2 (CameraOnInteriorSide returns interior where retail's InitCell culls).
// |D|<=1.75 means eyeInsideOpening is in range. Strip with the rest of the [pv-trace] apparatus.
if (trace != null)
{
bool camInterior = i >= cell.ClipPlanes.Count || CameraOnInteriorSide(cell, i, cameraPos);
float sideD = (i < cell.ClipPlanes.Count && cell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
? Vector3.Dot(cell.ClipPlanes[i].Normal, Vector3.Transform(cameraPos, cell.InverseWorldTransform)) + cell.ClipPlanes[i].D
: float.NaN;
trace.Add($"sidechk cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} camInterior={camInterior} eyeIn={eyeInsideOpening} D={(float.IsNaN(sideD) ? "na" : sideD.ToString("F2"))}");
}
bool sideAllowed = true;
// Portal-side test (retail PView::InitCell side test, decomp:432962): only traverse a portal
// the camera is on the INTERIOR side of. Retail culls the back-facing portal (the doorway just
// flooded through) by this test ALONE — there is NO eye-in-opening bypass. R-A2b: the old
// `&& !eyeInsideOpening` bypass let a back portal within 1.75 m through, forming the
// 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap (pinned in flap-sidechk.log:
// back portals show camInterior=False eyeIn=True). The forward-portal clip-empty void rescue
// (below, the `clippedRegion.Count == 0` branch) is a SEPARATE path and stays.
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos))
{
sideAllowed = false;
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}");
continue;
}
// Retail PView::ClipPortals calls GetClip(..., finish=1): transform to
// homogeneous clip space, clip at the eye, then clip against the current
// portal_view region before the divide. Do the same here; the old early
// ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways.
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
activeViewPolygons,
out int clipVerts);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
// T2 (BR-4) attempted to delete this eye-in-opening rescue as
// non-retail (retail's empty GetClip = no flood, no bypass) and
// the CornerFloodReplay conformance gate REFUTED the deletion:
// with the eye pressed at the 0x0172 corner, the 0x0173/0x0171
// doorway chain clipped EMPTY at every sweep step — our
// ProjectToClip near-eye behavior (EyePlaneW=1e-4) diverges from
// retail polyClipFinish's near-W clip at its UNPINNED constant
// cdstW (comparison doc open question). Until cdstW is read from
// the binary and our near-eye clip matched to it, this rescue is
// the documented compensation for that gap: a portal whose
// opening the eye stands in (≤1.75 m perp + inside the opening)
// substitutes the current view. Re-attempt the deletion ONLY
// against the corner harness after pinning cdstW.
if (clippedRegion.Count == 0)
{
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty side={sideAllowed} eyeIn={eyeInsideOpening} clipVerts={clipVerts}");
continue;
}
foreach (var vp in activeViewPolygons)
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
}
if (portal.OtherCellId == 0xFFFF)
{
if (pvDump)
{
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} clipPolys={clippedRegion.Count}");
Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]");
foreach (var cp in clippedRegion)
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
}
// Exit portal -> outdoors visible through this (clipped) opening.
AddRegion(frame.OutsideView, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts} eyeIn={eyeInsideOpening}");
continue;
}
uint neighbourId = lbMask | portal.OtherCellId;
// Cross-building boundary: route to CrossBuildingViews, don't continue in-building BFS.
// (Cross-building entry is retail's CBldPortal/AddToCell channel, not OtherPortalClip;
// the reciprocal clip below is interior-cell-to-cell only, matching the OtherPortalClip
// call inside ConstructView at decomp:433692.)
if (buildingMembership != null && !buildingMembership(neighbourId))
{
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
bool grewCross = AddRegion(xview, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}");
continue;
}
var neighbour = lookup(neighbourId);
if (neighbour == null)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=lookup-miss polys={clippedRegion.Count}");
continue;
}
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
// decomp:433524). The portal opening seen from THIS cell may be wider than the
// SAME opening seen from the neighbour (skewed/oblique apertures), so retail
// re-clips the already-near-side-clipped region against the neighbour's matching
// (reciprocal) portal polygon — the propagated region is the intersection of the
// opening "seen from A" AND "seen from B". This can only TIGHTEN, never widen, and
// degrades to the prior near-side-only region when the reciprocal is unresolvable
// (over-include is the safe default). The reciprocal is the portal at index
// `portal.OtherPortalId` in the NEIGHBOUR's portal list — retail's direct back-link
// (arg2->other_portal_id, 433557), NOT a scan for the first OtherCellId match. The
// direct index is what lets a cell with TWO portals to the same neighbour clip each
// opening against its OWN reciprocal instead of the first one. Mutates clippedRegion
// in place before the union below.
// T2 (BR-4): reciprocal-empty culls — retail OtherPortalClip
// returning nothing means the opening is invisible from the
// neighbour's side; the old eye-in-opening restore was part of
// the deleted rescue.
int preReciprocalCount = clippedRegion.Count;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
if (churnProbe)
churnReciprocal!.Append(System.FormattableString.Invariant(
$" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]"));
if (clippedRegion.Count == 0)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}");
continue;
}
// Union the clipped region into the neighbour's accumulated view.
var nview = GetOrCreate(frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion);
bool inserted = false;
bool inPlace = false;
float dist = float.NaN;
if (grew)
{
// First discovery → enqueue once (retail InsCellTodoList in
// the ecx_5==0 branch). Distance = camera→nearest portal-
// opening vertex (retail InitCell min-vertex distance,
// pc:432988-433004).
if (queued.Add(neighbourId))
{
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
inserted = true;
}
// Growth into an already-POPPED cell → retail AdjustCellView:
// process only the new views, immediately, in place. A cell
// discovered but still pending in the todo list needs nothing
// — its pop processes everything to date via the watermark.
else if (drawListed.Contains(neighbourId))
{
inPlace = true;
if (churnProbe) churnReenqueues++;
ProcessCellPortals(neighbour, depth + 1);
}
}
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} addCell polys={clippedRegion.Count} clipVerts={clipVerts} recip={preReciprocalCount}->{clippedRegion.Count} grew={grew} queued={inserted} inPlace={inPlace} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}");
}
}
while (todo.Count > 0)
{
var cell = todo.PopNearest();
// Single pop per cell (enqueue-once) IS the cell's closest-first
// draw position (retail appends to cell_draw_list once per pop,
// pc:433783). Note: retail also RE-SORTS the draw list when a
// late-grown cell's dependency order changes (AdjustCellPlace,
// pc:433247); we keep first-pop order — under T1's whole-cell
// far→near draws + depth testing, order affects only transparent-
// pass compositing in exotic chains (documented residual for T5).
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
trace?.Add($"pop cell=0x{cell.CellId:X8} drawPos={frame.OrderedVisibleCells.Count - 1}");
ProcessCellPortals(cell, 0);
}
if (pvDump)
Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}");
// Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — read-only per-frame snapshot of the
// root cell's per-portal side-test + projection + the frame's exit/visible counts.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
EmitFlapProbe(cameraCell, cameraPos, viewProj, frame);
trace?.Emit(frame);
if (churnProbe)
{
// T2: pops are enqueue-once now; churnReenqueues counts retail-style
// IN-PLACE propagations (AdjustCellView equivalents) instead.
Console.WriteLine(System.FormattableString.Invariant(
$"[portal-churn] root=0x{cameraCell.CellId:X8} cells={frame.OrderedVisibleCells.Count} inPlaceProps={churnReenqueues}") + churnReciprocal);
}
return frame;
}
/// <summary>
/// Build a portal visibility frame for an OUTDOOR viewer looking into one or more
/// outside-facing cell portals. This is the reciprocal of <see cref="Build"/>:
/// the seed view is the projected exit-portal opening instead of a full-screen
/// camera cell. It keeps the same retail distance-priority traversal and
/// neighbour reciprocal clipping once inside the building.
/// </summary>
public static PortalVisibilityFrame BuildFromExterior(
IEnumerable<LoadedCell> candidateCells,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity)
{
var frame = new PortalVisibilityFrame();
var todo = new CellTodoList();
var queued = new HashSet<uint>();
var drawListed = new HashSet<uint>();
var processedViewCounts = new Dictionary<uint, int>();
foreach (var cell in candidateCells)
{
if (cell is null) continue;
for (int i = 0; i < cell.Portals.Count; i++)
{
var portal = cell.Portals[i];
if (portal.OtherCellId != 0xFFFF)
continue;
if (i >= cell.PortalPolygons.Count)
continue;
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
continue;
// Exterior peering starts from the OUTSIDE face of an exit portal.
// If the camera is on the cell-interior side, the normal indoor
// DrawInside path owns this portal instead. T2 (BR-4): a seed
// portal the eye is IN-PLANE with (|dist| <= F_EPSILON) rejects
// OUTRIGHT — retail ConstructView(CBldPortal) returns 0 on
// Sidedness IN_PLANE (Ghidra 0x005a59a0); no degenerate view is
// ever built from a knife-edge aperture.
if (i < cell.ClipPlanes.Count)
{
if (CameraOnInteriorSide(cell, i, cameraPos))
continue;
if (EyeInPlaneOfPortal(cell, i, cameraPos))
continue;
}
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
if (seedDistance > maxSeedDistance)
continue;
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
FullScreenRegion,
out _);
// T2 (BR-4): empty clip = no seed, no exceptions (retail's
// empty-GetClip rule; the full-screen substitute rescue is
// deleted — see Build()).
if (clippedRegion.Count == 0)
continue;
var seedView = GetOrCreate(frame.CellViews, cell.CellId);
bool grew = AddRegion(seedView, clippedRegion);
if (grew && queued.Add(cell.CellId))
todo.Insert(cell, seedDistance);
}
}
// T2 (BR-4): in-place growth propagation — mirrors Build()'s
// ProcessCellPortals (retail AdjustCellView via the watermark); the
// re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues
// are deleted (empty clip culls, period).
const int RecursionTripwire = 128;
var propagationChain = new uint[RecursionTripwire]; // #120 self-attribution — see Build()
void ProcessCellPortals(LoadedCell cell, int depth)
{
if (depth >= RecursionTripwire)
{
System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount);
Console.WriteLine($"[pv-ERROR] look-in in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate");
DumpPropagationChain(propagationChain, depth, 0u, cameraPos);
return;
}
propagationChain[depth] = cell.CellId;
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
return;
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
int endCount = currentView.Polygons.Count;
if (processedCount >= endCount)
return;
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount;
uint lbMask = cell.CellId & 0xFFFF0000u;
for (int i = 0; i < cell.Portals.Count; i++)
{
if (i >= cell.PortalPolygons.Count)
continue;
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
continue;
var portal = cell.Portals[i];
if (portal.OtherCellId == 0xFFFF)
continue; // already outdoors; exterior terrain was drawn by the caller.
// R-A2b: cull back portals by the side test alone — see Build().
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos))
continue;
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
activeViewPolygons,
out _);
if (clippedRegion.Count == 0)
continue;
uint neighbourId = lbMask | portal.OtherCellId;
var neighbour = lookup(neighbourId);
if (neighbour == null)
continue;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
if (clippedRegion.Count == 0)
continue;
var nview = GetOrCreate(frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion);
if (grew)
{
if (queued.Add(neighbourId))
{
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
}
else if (drawListed.Contains(neighbourId))
{
ProcessCellPortals(neighbour, depth + 1);
}
}
}
}
while (todo.Count > 0)
{
var cell = todo.PopNearest();
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
ProcessCellPortals(cell, 0);
}
return frame;
}
/// <summary>
/// Retail per-building flood — <c>PView::ConstructView(CBldPortal*, …)</c> (decomp:433827),
/// reached from <c>BSPPORTAL::portal_draw_portals_only</c> (0x53d870) → <c>DrawPortal</c>
/// (0x5a5ab0) during the terrain BSP walk. Floods ONE building's cells from its outside-facing
/// entrance portal(s). Identical machinery to <see cref="BuildFromExterior"/>, but the CONTRACT is
/// per-building: the caller passes exactly one building's cells, so the seed is that building's
/// FINITE entrance opening (bounded flood depth → the stable ~2-cell view retail draws per visible
/// building, measured live §3.4). This differs from the synthetic outdoor node's single unified
/// flood whose full-screen-ish seed reaches variable depth into a building as the eye moves — the
/// 2↔6 oscillation. Robustness is validated by the conformance test, not assumed.
/// </summary>
public static PortalVisibilityFrame ConstructViewBuilding(
IEnumerable<LoadedCell> buildingCells,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity)
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance);
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
private static readonly Vector2[] FullScreenQuad =
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
private static readonly ViewPolygon[] FullScreenRegion =
{ new ViewPolygon(FullScreenQuad) };
private static List<ViewPolygon> ClipPortalAgainstView(
Vector3[] localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
IReadOnlyList<ViewPolygon> viewPolygons,
out int clipVertexCount)
{
var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj);
clipVertexCount = portalClip.Length;
var clippedRegion = new List<ViewPolygon>();
if (portalClip.Length < 3)
return clippedRegion;
foreach (var vp in viewPolygons)
{
if (vp.IsEmpty)
continue;
var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices);
if (clipped.Length >= 3)
clippedRegion.Add(new ViewPolygon(clipped));
}
return clippedRegion;
}
private const int PortalTraceEmitLimit = 160;
private static readonly object s_portalTraceLock = new();
private static readonly Dictionary<uint, string> s_portalTraceLastSignature = new();
private static int s_portalTraceEmits;
private sealed class PortalBuildTrace
{
private readonly uint _rootCellId;
private readonly Vector3 _eye;
private readonly List<string> _lines = new();
private PortalBuildTrace(uint rootCellId, Vector3 eye)
{
_rootCellId = rootCellId;
_eye = eye;
}
public static PortalBuildTrace? Start(LoadedCell root, Vector3 eye)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
return null;
if (!IsHoltburgIndoorProbeCell(root.CellId))
return null;
return new PortalBuildTrace(root.CellId, eye);
}
public void Add(string line)
{
if (_lines.Count < 96)
_lines.Add(line);
}
public void Emit(PortalVisibilityFrame frame)
{
string signature = BuildSignature(frame);
lock (s_portalTraceLock)
{
if (s_portalTraceEmits >= PortalTraceEmitLimit)
return;
if (s_portalTraceLastSignature.TryGetValue(_rootCellId, out var last) &&
string.Equals(last, signature, StringComparison.Ordinal))
return;
s_portalTraceLastSignature[_rootCellId] = signature;
s_portalTraceEmits++;
}
Console.WriteLine($"[pv-trace] root=0x{_rootCellId:X8} eye=({_eye.X:F2},{_eye.Y:F2},{_eye.Z:F2}) {signature}");
foreach (var line in _lines)
Console.WriteLine("[pv-trace] " + line);
}
}
private static bool IsHoltburgIndoorProbeCell(uint cellId)
{
if ((cellId & 0xFFFF0000u) != 0xA9B40000u)
return false;
uint low = cellId & 0xFFFFu;
return low >= 0x016F && low <= 0x0175;
}
private static string BuildSignature(PortalVisibilityFrame frame)
{
var sb = new System.Text.StringBuilder(160);
sb.Append("outPolys=").Append(frame.OutsideView.Polygons.Count);
sb.Append(" cells=[");
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
{
if (i != 0) sb.Append(',');
sb.Append("0x").Append((frame.OrderedVisibleCells[i] & 0xFFFFu).ToString("X4"));
}
sb.Append("] views=[");
bool first = true;
foreach (var kvp in frame.CellViews)
{
if (!first) sb.Append(',');
first = false;
sb.Append("0x").Append((kvp.Key & 0xFFFFu).ToString("X4")).Append(':').Append(kvp.Value.Polygons.Count);
}
sb.Append(']');
return sb.ToString();
}
// Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal
// signed distance D (eye→portal plane), traverse/cull decision, and NDC projection
// vertex count, plus the frame's OutsideView polygon count + visible-cell count.
// `localEye` is the eye in root-local space — its component along an interior portal
// plane reveals when the eye has crossed past that plane (the stale-root region that
// makes the side test cull a still-needed portal). Read-only recompute; no effect on
// the returned frame. Throwaway apparatus — strip with the probe.
private static void EmitFlapProbe(
LoadedCell cameraCell, Vector3 cameraPos, Matrix4x4 viewProj, PortalVisibilityFrame frame)
{
var localEye = Vector3.Transform(cameraPos, cameraCell.InverseWorldTransform);
var sb = new System.Text.StringBuilder(220);
sb.Append("[flap] root=0x").Append(cameraCell.CellId.ToString("X8"));
sb.Append(" eye=(").Append(cameraPos.X.ToString("F2")).Append(',')
.Append(cameraPos.Y.ToString("F2")).Append(',').Append(cameraPos.Z.ToString("F2")).Append(')');
sb.Append(" localEye=(").Append(localEye.X.ToString("F2")).Append(',')
.Append(localEye.Y.ToString("F2")).Append(',').Append(localEye.Z.ToString("F2")).Append(')');
for (int i = 0; i < cameraCell.Portals.Count; i++)
{
var portal = cameraCell.Portals[i];
float d = float.NaN;
bool side = true;
if (i < cameraCell.ClipPlanes.Count && cameraCell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
{
var pl = cameraCell.ClipPlanes[i];
d = Vector3.Dot(pl.Normal, localEye) + pl.D;
side = CameraOnInteriorSide(cameraCell, i, cameraPos);
}
// Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so
// proj/clip mean the same as production: proj = clip-space verts in front of the eye,
// clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is
// genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands.
int projN = -1, clipN = -1;
string ndcText = "";
if (i < cameraCell.PortalPolygons.Count)
{
var poly = cameraCell.PortalPolygons[i];
if (poly != null && poly.Length >= 3)
{
var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj);
projN = clip.Length;
if (clip.Length >= 3)
{
var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad);
clipN = ndc.Length;
var ns = new System.Text.StringBuilder(48);
foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')');
ndcText = ns.ToString();
}
}
}
sb.Append(" | p").Append(i).Append("->0x").Append(portal.OtherCellId.ToString("X4"));
sb.Append(" D=").Append(float.IsNaN(d) ? "na" : d.ToString("F2"));
sb.Append(side ? " TRV" : " CULL");
sb.Append(" proj=").Append(projN).Append(" clip=").Append(clipN);
if (ndcText.Length > 0) sb.Append(" ndc=").Append(ndcText);
}
sb.Append(" || outPolys=").Append(frame.OutsideView.Polygons.Count);
sb.Append(" vis=").Append(frame.OrderedVisibleCells.Count);
Console.WriteLine(sb.ToString());
}
// Mirrors CellVisibility's portal-side test (InsideSide convention).
// In-plane (|dot| <= PortalSideEpsilon) counts as interior-side — retail
// InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra
// 0x005a4b70); building/exterior SEED portals additionally reject in-plane
// via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0).
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
{
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
}
// T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject
// (Ghidra 0x005a59a0): |eye·N + d| <= F_EPSILON → the building/exterior
// portal contributes nothing this frame (knife-edge aperture). Uses the
// true retail epsilon, NOT the side test's root-lag tolerance.
private static bool EyeInPlaneOfPortal(LoadedCell cell, int portalIndex, Vector3 cameraPos)
{
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f) return false;
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
return MathF.Abs(dot) <= SeedInPlaneEpsilon;
}
// Reverse vertex order in place if the polygon is wound clockwise (signed area < 0).
private static void EnsureCcw(Vector2[] poly)
{
float area2 = 0f;
for (int i = 0; i < poly.Length; i++)
{
var p = poly[i]; var q = poly[(i + 1) % poly.Length];
area2 += p.X * q.Y - q.X * p.Y;
}
if (area2 < 0f) Array.Reverse(poly);
}
// Phase U.2b — reciprocal OtherPortalClip (retail PView::OtherPortalClip decomp:433524).
// Resolves the neighbour's reciprocal back-portal by DIRECT INDEX (`otherPortalId`), projects
// that reciprocal polygon through the NEIGHBOUR's world transform to NDC, and intersects it into
// every polygon of `clippedRegion` (already clipped against the near-side opening + current
// view). The net region is "opening seen from the near cell" ∩ "opening seen from the
// neighbour" — a strict tightening that prevents over-inclusion through skewed apertures.
//
// `otherPortalId` is the near-side portal's reciprocal back-link, straight from the dat's
// CellPortal.OtherPortalId. Retail indexes the neighbour's portal array with it directly —
// `portals->portal[arg2->other_portal_id ...]` at 005a54b2/005a54f6 — rather than scanning for
// the first OtherCellId match. A scan picks the FIRST back-portal for EVERY near-side portal to
// the same neighbour, so a cell with two openings into one neighbour clips both against the same
// (first) reciprocal — hiding the second opening when the apertures are disjoint (under-inclusion
// bug #102 M-4). The direct index gives each opening its own reciprocal.
//
// GUARDS — degrade to over-include (leave `clippedRegion` untouched), NEVER clip against a
// guessed polygon: the index is out of range, OR the indexed polygon is missing/degenerate
// (< 3 verts), OR it projects entirely behind the camera. Over-inclusion is the safe default;
// mis-resolution is the bug this method exists to remove. PortalPolygons is in lockstep with
// Portals, so index `otherPortalId` selects the reciprocal polygon. NEVER throws.
// Dat CellPortal flags bit 0 (DatReaderWriter.Enums.PortalFlags.ExactMatch; retail
// CCellPortal.exact_match at +0x14, acclient.h:32300).
private const ushort PortalFlagExactMatch = 0x0001;
private static void ApplyReciprocalClip(
List<ViewPolygon> clippedRegion, ushort otherPortalId, ushort portalFlags,
LoadedCell neighbour, Matrix4x4 viewProj)
{
if (clippedRegion.Count == 0) return;
// Retail skips OtherPortalClip entirely for exact-match portals — both cells share
// the SAME opening polygon, so re-clipping against the reciprocal can only re-derive
// the near-side clip: PView::ClipPortals decomp:433689
// `if (exact_match != 0 || other_portal_id < 0) goto propagate-without-reciprocal`.
if ((portalFlags & PortalFlagExactMatch) != 0) return;
// Direct back-link index (retail arg2->other_portal_id). Out-of-range → over-include.
if (otherPortalId >= neighbour.PortalPolygons.Count) return;
Vector3[]? reciprocalPoly = neighbour.PortalPolygons[otherPortalId];
if (reciprocalPoly == null || reciprocalPoly.Length < 3) return; // missing/degenerate → over-include
// §4 corner/doorway fix (2026-06-10): the reciprocal clip now runs the SAME homogeneous
// pipeline as the forward clip — retail PView::OtherPortalClip (decomp:433524-433563) routes
// the reciprocal polygon through the very same GetClip(finish=1) → ACRender::polyClipFinish
// homogeneous clipper as the near-side portal; there is no divide-first special case.
//
// HISTORY: this used to be ProjectToNdc + 2D ScreenPolygonClip.Intersect, justified by "the
// reciprocal is a back-portal one hop away — never near the eye". That assumption is FALSE
// exactly at doorways/corners: the reciprocal IS the same opening whose plane the eye presses
// against (2-60 cm). ProjectToNdc's MinW=0.05 eye-clip + side-plane clip + divide is knife-edge
// there — 2 cm eye moves flipped its output between "covers the region" and a duplicated-vertex
// hairline, which CellView.Add's snap-dedup then rejected → the neighbour room dropped from the
// flood for isolated frames → the corner/transition background strobe (CornerFloodReplayTests
// pins this deterministically; the glitch steps die with this change). The old path's other
// rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete:
// CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction.
var reciprocalClip = PortalProjection.ProjectToClip(reciprocalPoly, neighbour.WorldTransform, viewProj);
if (reciprocalClip.Length < 3) return; // reciprocal entirely behind the eye → no constraint (over-include)
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
// ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same
// region-edge homogeneous Sutherland-Hodgman the forward hop uses (polyClipFinish port).
for (int k = clippedRegion.Count - 1; k >= 0; k--)
{
var tightened = PortalProjection.ClipToRegion(reciprocalClip, clippedRegion[k].Vertices);
if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened);
else clippedRegion.RemoveAt(k);
}
}
private static CellView GetOrCreate(Dictionary<uint, CellView> map, uint key)
{
if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; }
return v;
}
private static bool AddRegion(CellView view, List<ViewPolygon> region)
{
bool grew = false;
foreach (var poly in region)
grew |= view.Add(poly);
return grew;
}
// Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal
// min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list:
// it walks the portal's vertices, transforms each to world space, and keeps the smallest
// straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell
// origin) is both retail-faithful and robust to cells whose WorldPosition was never populated.
private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos)
{
float best = float.MaxValue;
for (int i = 0; i < localPoly.Length; i++)
{
var world = Vector3.Transform(localPoly[i], worldTransform);
float d2 = Vector3.DistanceSquared(world, cameraPos);
if (d2 < best) best = d2;
}
return best == float.MaxValue ? 0f : MathF.Sqrt(best);
}
// "Eye standing in the opening": the eye is within this perpendicular distance of a portal's
// plane. Live captures hit two retail-valid degenerate cases: cottage doorway D=0.16 m and
// cellar->stair portal D=1.41 m, both traversable but ProjectToNdc returned zero vertices. We still
// require the perpendicular projection to land inside the opening, so side/offscreen portals stay
// culled; this only covers active portals whose 2D projection collapses near the chase camera.
private const float EyeStandingPerpDist = 1.75f;
/// <summary>
/// True when the camera eye is "standing in" <paramref name="localPoly"/>'s opening: within
/// <see cref="EyeStandingPerpDist"/> of the portal plane AND its perpendicular projection onto
/// that plane falls inside the portal polygon. This is the case where the 2D portal projection
/// degenerates to empty (the eye is in the doorway plane) yet the neighbour is genuinely visible
/// — retail's 3D portal clip imposes no constraint there. Used only as the gate that lets such a
/// portal flood its neighbour with the current view; a degenerate portal the eye is NOT inside
/// (off-screen / across the room) returns false and stays culled, so the visible set cannot blow up.
/// </summary>
private static bool EyeInsidePortalOpening(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 eyeWorld)
{
if (localPoly == null || localPoly.Length < 3) return false;
var p0 = Vector3.Transform(localPoly[0], worldTransform);
var p1 = Vector3.Transform(localPoly[1], worldTransform);
var p2 = Vector3.Transform(localPoly[2], worldTransform);
var n = Vector3.Cross(p1 - p0, p2 - p0);
float nl = n.Length();
if (nl < 1e-8f) return false; // degenerate polygon — no plane
n /= nl;
float perp = Vector3.Dot(n, eyeWorld - p0);
if (MathF.Abs(perp) > EyeStandingPerpDist) return false; // eye not close to the portal plane
// In-plane 2D basis (u along the first edge, v = n × u). Project the eye + every vertex into
// it (the perpendicular component drops out of the dot products) and run a point-in-polygon test.
var u = p1 - p0;
float ul = u.Length();
if (ul < 1e-8f) return false;
u /= ul;
var v = Vector3.Cross(n, u);
var rel = eyeWorld - p0;
var eye2 = new Vector2(Vector3.Dot(rel, u), Vector3.Dot(rel, v));
var poly2 = new Vector2[localPoly.Length];
for (int k = 0; k < localPoly.Length; k++)
{
var w = Vector3.Transform(localPoly[k], worldTransform) - p0;
poly2[k] = new Vector2(Vector3.Dot(w, u), Vector3.Dot(w, v));
}
return PointInPoly2D(eye2, poly2);
}
// Standard ray-crossing (even-odd) point-in-polygon test.
private static bool PointInPoly2D(Vector2 p, Vector2[] poly)
{
bool inside = false;
for (int i = 0, j = poly.Length - 1; i < poly.Length; j = i++)
{
var a = poly[i];
var b = poly[j];
if (((a.Y > p.Y) != (b.Y > p.Y)) &&
(p.X < (b.X - a.X) * (p.Y - a.Y) / (b.Y - a.Y) + a.X))
inside = !inside;
}
return inside;
}
/// <summary>
/// Distance-sorted work list for the portal BFS, ported from retail PView::cell_todo_list +
/// InsCellTodoList (decomp:433183). Insertion keeps the list ordered so the NEAREST cell sits at
/// the tail; <see cref="PopNearest"/> removes the tail — giving closest-first traversal exactly
/// as ConstructView's pop-from-(cell_todo_num-1) does (433767-433769). The insertion only shifts
/// entries strictly farther than the newcomer (retail's flag test breaks on the first
/// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST —
/// LIFO on ties, matching retail's break-on-first-not-greater + pop-from-tail.
/// </summary>
private sealed class CellTodoList
{
private readonly List<(LoadedCell Cell, float Distance)> _items = new();
public int Count => _items.Count;
public void Insert(LoadedCell cell, float distance)
{
// Find the slot: scan from the tail (nearest) toward the head while existing entries are
// strictly nearer than `distance`, so the newcomer lands just ABOVE every entry that is
// farther-or-equal — i.e. nearest-at-tail order, LIFO on ties (an equal-distance
// newcomer inserts at the tail and pops first).
int idx = _items.Count;
while (idx > 0 && _items[idx - 1].Distance < distance)
idx--;
_items.Insert(idx, (cell, distance));
}
public LoadedCell PopNearest()
{
int last = _items.Count - 1;
var cell = _items[last].Cell;
_items.RemoveAt(last);
return cell;
}
}
}