// 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; /// Per-frame output of the portal-frame BFS. public sealed class PortalVisibilityFrame { /// Screen region (NDC) where outdoor terrain/scenery may draw — exit portals /// recursively clipped to their portal chain. The cellar-flap fix. public CellView OutsideView { get; } = new(); /// Per-cell accumulated clip region, keyed by full cell id (wire-in #2). public Dictionary CellViews { get; } = new(); /// 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. public List OrderedVisibleCells { get; } = new(); /// 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). public Dictionary 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 s_pvDumpCount = new(); /// /// #120 observable: total convergence-tripwire firings across both the /// interior 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. /// public static int ConvergenceTripwireCount; /// /// #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. /// private static void DumpPropagationChain(uint[] chain, int depth, uint rootCellId, Vector3 eye) { int n = Math.Min(depth, chain.Length); var freq = new Dictionary(); 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}"); } /// Resolve a full cell id to its LoadedCell, or null if not loaded. /// 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. public static PortalVisibilityFrame Build( LoadedCell cameraCell, Vector3 cameraPos, Func lookup, Matrix4x4 viewProj, Func? 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 { cameraCell.CellId }; var drawListed = new HashSet(); var processedViewCounts = new Dictionary(); 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; // (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 the side test. // 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} D={(float.IsNaN(sideD) ? "na" : sideD.ToString("F2"))}"); } // 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). if (i < cell.ClipPlanes.Count && !CameraOnInteriorSide(cell, i, cameraPos)) { trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side"); 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}"); // Empty clip = no flood through this portal, period — retail's empty-GetClip rule // (polyClipFinish <3 survivors → reject; ClipPortals adds no view). The // EyeInsidePortalOpening rescue that used to substitute the current view here was // the documented compensation for ProjectToClip's old EyePlaneW=1e-4 divergence // from polyClipFinish's exact W=0 clip; with the W=0 port (2026-06-11, pseudocode // at docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md) an eye-crossing // portal projects to its true half-region and the rescue is DELETED. if (clippedRegion.Count == 0) { trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty clipVerts={clipVerts}"); continue; } 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}"); 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; } /// /// Build a portal visibility frame for an OUTDOOR viewer looking into one or more /// outside-facing cell portals. This is the reciprocal of : /// 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. /// public static PortalVisibilityFrame BuildFromExterior( IEnumerable candidateCells, Vector3 cameraPos, Func lookup, Matrix4x4 viewProj, float maxSeedDistance = float.PositiveInfinity) { var frame = new PortalVisibilityFrame(); var todo = new CellTodoList(); var queued = new HashSet(); var drawListed = new HashSet(); var processedViewCounts = new Dictionary(); 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; } /// /// Retail per-building flood — PView::ConstructView(CBldPortal*, …) (decomp:433827), /// reached from BSPPORTAL::portal_draw_portals_only (0x53d870) → DrawPortal /// (0x5a5ab0) during the terrain BSP walk. Floods ONE building's cells from its outside-facing /// entrance portal(s). Identical machinery to , 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. /// public static PortalVisibilityFrame ConstructViewBuilding( IEnumerable buildingCells, Vector3 cameraPos, Func 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 ClipPortalAgainstView( Vector3[] localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj, IReadOnlyList viewPolygons, out int clipVertexCount) { var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj); clipVertexCount = portalClip.Length; var clippedRegion = new List(); 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 s_portalTraceLastSignature = new(); private static int s_portalTraceEmits; private sealed class PortalBuildTrace { private readonly uint _rootCellId; private readonly Vector3 _eye; private readonly List _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 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 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 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); } /// /// 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; 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. /// 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; } } }