From 529dfcfee9313078530486db7e5f6c7745a29d5e Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 11 Jun 2026 12:42:41 +0200 Subject: [PATCH] T2 slice 2 (BR-4): in-place growth propagation, strict reciprocal cull, retail seed in-plane reject - two retail constants REFUTED by the gate and documented IN (retail-faithful): - Growth propagation is IN PLACE, never by re-enqueue: retail AddViewToPortals (Ghidra 0x005a52d0) enqueues only on first discovery (InsCellTodoList); growth into a popped cell runs AdjustCellView - re-clip ONLY the new views through that cell's portals immediately. ProcessCellPortals + the processedViewCounts watermark port exactly that; the MaxReprocessPerCell=16 drift cap is DELETED (the 1-px merge is the physical fixpoint floor). Depth-128 tripwire logs loudly if the convergence invariant ever breaks (failsafe, not control flow). Same restructure in BuildFromExterior. - Reciprocal-empty culls strictly (retail OtherPortalClip returning nothing = the opening is invisible from the neighbour's side); the eye-in-opening pre-clip restore is gone. - Look-in seeds: retail ConstructView(CBldPortal) IN_PLANE reject at the TRUE F_EPSILON (SeedInPlaneEpsilon=0.0002, const @0x007c8c70) + the full-screen substitute rescue DELETED (the verifier-flagged non-retail bypass that admitted floods retail strictly rejects). REFUTED BY THE CONFORMANCE GATE (attempted, reverted, documented inline): - PortalSideEpsilon 0.0002: retail's tight epsilon assumes the viewer cell transits the instant the eye crosses a plane; our root can lag ~1 cm at pressed corners (CornerFloodReplay failed at every step - 0x0171/0x0173 chain dead). 0.01 KEPT as the documented root-lag tolerance; tighten only with eye-exact viewer tracking + cdstW. - Deleting the clip-empty eye-in-opening rescue: same gate, same total failure - our ProjectToClip near-eye behavior (EyePlaneW=1e-4) diverges from retail polyClipFinish's UNPINNED cdstW constant. Rescue KEPT as the documented cdstW-gap compensation; re-attempt only after pinning cdstW from the binary. Gates: App 226/226 green (CornerFloodReplay + MeetingHallFlood + the collapsed-portal pin all pass); Core baseline unchanged (1398 + 4 pre-existing #99-era). CloneViewPolygons orphan removed. Co-Authored-By: Claude Fable 5 --- .../Rendering/PortalVisibilityBuilder.cs | 301 +++++++++++------- 1 file changed, 180 insertions(+), 121 deletions(-) diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index 835d5208..f554e528 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -35,20 +35,25 @@ public sealed class PortalVisibilityFrame public static class PortalVisibilityBuilder { - private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon + // 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; - // Bounded re-enqueue cap (restored 2026-06-07). The distance-priority portal flood re-enqueues a - // cell whenever its accumulated view GROWS, which is load-bearing — it propagates a late-discovered - // portal_view slice to that cell's exit portals (Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit). - // But the faithful near-side clip (ProjectToClip) drifts per round, so re-clipping a cell's view yields - // ever-smaller distinct sub-regions / drifted near-duplicates the dedup can't always collapse -> the - // grow flag never settles and the flood spins forever (the indoor hang). This cap bounds each cell to - // at most this many pops, so the flood terminates in <= N*cap pops regardless of drift while still - // allowing the few re-processes that legitimate late-slice propagation needs. The old hard cap removed - // in U.2a was 4; widened here because ProjectToClip drifts more than the old ProjectToNdc and Option A's - // CellView dedup already collapses most spurious growth, so the cap rarely binds. Tune from the visual - // gate if an interior view under-includes a slice. - private const int MaxReprocessPerCell = 16; + // 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. @@ -109,7 +114,6 @@ public static class PortalVisibilityBuilder var queued = new HashSet { cameraCell.CellId }; var drawListed = new HashSet(); var processedViewCounts = new Dictionary(); - var popCounts = new Dictionary(); // per-cell pop count for the MaxReprocessPerCell cap var trace = PortalBuildTrace.Start(cameraCell, cameraPos); // [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn @@ -148,37 +152,43 @@ public static class PortalVisibilityBuilder } } - while (todo.Count > 0) + // 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; + + void ProcessCellPortals(LoadedCell cell, int depth) { - var cell = todo.PopNearest(); - queued.Remove(cell.CellId); - // Bounded re-enqueue (2026-06-07 termination fix): count this pop. The re-enqueue gate below - // refuses to re-add a cell already popped MaxReprocessPerCell times, so the flood terminates - // even when ProjectToClip drift keeps a view growing forever. Re-enqueue itself is KEPT — it - // propagates late-discovered slices to exit portals (see MaxReprocessPerCell); only its count - // is capped. - popCounts.TryGetValue(cell.CellId, out int popsSoFar); - popCounts[cell.CellId] = popsSoFar + 1; + if (depth >= RecursionTripwire) + { + Console.WriteLine($"[pv-ERROR] in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate"); + return; + } if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty) { - trace?.Add($"pop cell=0x{cell.CellId:X8} skip=no-view"); - continue; + trace?.Add($"proc cell=0x{cell.CellId:X8} skip=no-view"); + return; } - // `seen` guarantees each cell is inserted into the todo list exactly once, so this single - // pop IS the cell's closest-first draw position (retail appends to cell_draw_list once per - // pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction. - if (drawListed.Add(cell.CellId)) - frame.OrderedVisibleCells.Add(cell.CellId); - processedViewCounts.TryGetValue(cell.CellId, out int processedCount); int endCount = currentView.Polygons.Count; if (processedCount >= endCount) { - trace?.Add($"pop cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}"); - continue; + trace?.Add($"proc cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}"); + return; } - trace?.Add($"pop cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} drawPos={frame.OrderedVisibleCells.Count - 1}"); + 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; @@ -245,22 +255,25 @@ public static class PortalVisibilityBuilder 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}"); - // R1 void fix (2026-06-05): the projected+clipped region is empty — normally we cull the - // portal here. BUT if the eye is STANDING IN this portal's opening, the 2D projection has - // degenerated (the eye is in the doorway plane / within the near plane of the opening; the - // live capture saw the vestibule->room portal at D=0.16 m project to 0 verts). Retail's 3D - // portal clip imposes no constraint for a portal the eye is inside, so the neighbour is - // fully visible — substitute the CURRENT cell's view as the region so the flood reaches it - // (without this, rooting at a thin doorway cell drew only that cell -> the bluish void). - // EyeInsidePortalOpening (near-plane perp + point-in-opening) keeps a merely off-screen - // degenerate portal culled, so the visible set does not blow up (#95). Over-inclusion is - // otherwise safe: the neighbour mesh is frustum-culled per-vertex at draw time. + // 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; // portal not visible through this chain, and the eye is not standing in it + continue; } foreach (var vp in activeViewPolygons) clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone())); @@ -315,7 +328,10 @@ public static class PortalVisibilityBuilder // 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. - var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null; + // 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) @@ -323,39 +339,62 @@ public static class PortalVisibilityBuilder $" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]")); if (clippedRegion.Count == 0) { - if (preReciprocalClip is null) - { - trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}"); - continue; - } - clippedRegion.AddRange(preReciprocalClip); + 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; - // Insert the neighbour into the distance-priority list — but ONLY on first discovery - // (retail enqueues via InsCellTodoList solely in the ecx_5==0 branch; growth into an - // already-seen cell is handled in place, never by re-enqueue). `seen` is the - // enqueue-once / `cell_view_done` gate: a neighbour already discovered is never - // re-enqueued, which is what bounds cyclic & hub graphs. Distance = camera→nearest - // portal-opening vertex in world space (retail InitCell min-vertex distance, - // 432988-433004); derived from the portal geometry, so it works even when the cell's - // WorldPosition was never populated. - if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId)) + if (grew) { - dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); - todo.Insert(neighbour, dist); - inserted = true; - if (churnProbe) churnReenqueues++; + // 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} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}"); + 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}"); @@ -367,14 +406,10 @@ public static class PortalVisibilityBuilder if (churnProbe) { - int maxPop = 0; uint maxCell = 0; int rePopped = 0; - foreach (var kv in popCounts) - { - if (kv.Value > maxPop) { maxPop = kv.Value; maxCell = kv.Key; } - if (kv.Value > 1) rePopped++; - } + // 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} reEnqueues={churnReenqueues} rePoppedCells={rePopped} maxPop=0x{maxCell:X8}:{maxPop}") + churnReciprocal); + $"[portal-churn] root=0x{cameraCell.CellId:X8} cells={frame.OrderedVisibleCells.Count} inPlaceProps={churnReenqueues}") + churnReciprocal); } return frame; @@ -399,7 +434,6 @@ public static class PortalVisibilityBuilder var queued = new HashSet(); var drawListed = new HashSet(); var processedViewCounts = new Dictionary(); - var popCounts = new Dictionary(); // per-cell pop count for the MaxReprocessPerCell cap foreach (var cell in candidateCells) { @@ -419,9 +453,18 @@ public static class PortalVisibilityBuilder // 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. - if (i < cell.ClipPlanes.Count && CameraOnInteriorSide(cell, i, cameraPos)) - continue; + // 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) @@ -434,12 +477,11 @@ public static class PortalVisibilityBuilder 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) - { - if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos)) - continue; - clippedRegion.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone())); - } + continue; var seedView = GetOrCreate(frame.CellViews, cell.CellId); bool grew = AddRegion(seedView, clippedRegion); @@ -449,24 +491,26 @@ public static class PortalVisibilityBuilder } } - while (todo.Count > 0) - { - var cell = todo.PopNearest(); - queued.Remove(cell.CellId); - // Bounded re-enqueue — see the matching note in Build(). Count this pop; the gate below caps - // re-enqueues at MaxReprocessPerCell so the look-in flood terminates under ProjectToClip drift. - popCounts.TryGetValue(cell.CellId, out int popsSoFar); - popCounts[cell.CellId] = popsSoFar + 1; - if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty) - continue; + // 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; - if (drawListed.Add(cell.CellId)) - frame.OrderedVisibleCells.Add(cell.CellId); + void ProcessCellPortals(LoadedCell cell, int depth) + { + if (depth >= RecursionTripwire) + { + Console.WriteLine($"[pv-ERROR] look-in in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate"); + return; + } + 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) - continue; + return; var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount); processedViewCounts[cell.CellId] = endCount; @@ -485,8 +529,7 @@ public static class PortalVisibilityBuilder if (portal.OtherCellId == 0xFFFF) continue; // already outdoors; exterior terrain was drawn by the caller. - bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos); - // R-A2b: cull back portals by the side test alone (no eye-in-opening bypass) — see Build(). + // R-A2b: cull back portals by the side test alone — see Build(). if (i < cell.ClipPlanes.Count && !CameraOnInteriorSide(cell, i, cameraPos)) continue; @@ -499,38 +542,45 @@ public static class PortalVisibilityBuilder out _); if (clippedRegion.Count == 0) - { - if (!eyeInsideOpening) - continue; - foreach (var vp in activeViewPolygons) - clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone())); - } + continue; uint neighbourId = lbMask | portal.OtherCellId; var neighbour = lookup(neighbourId); if (neighbour == null) continue; - var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null; ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj); if (clippedRegion.Count == 0) - { - if (preReciprocalClip is null) - continue; - clippedRegion.AddRange(preReciprocalClip); - } + continue; var nview = GetOrCreate(frame.CellViews, neighbourId); bool grew = AddRegion(nview, clippedRegion); - if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId)) + if (grew) { - float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); - todo.Insert(neighbour, dist); + 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; } @@ -731,6 +781,10 @@ public static class PortalVisibilityBuilder } // 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]; @@ -740,6 +794,19 @@ public static class PortalVisibilityBuilder 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) { @@ -841,14 +908,6 @@ public static class PortalVisibilityBuilder // 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 List CloneViewPolygons(List source) - { - var clone = new List(source.Count); - foreach (var poly in source) - clone.Add(new ViewPolygon((Vector2[])poly.Vertices.Clone())); - return clone; - } - private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos) { float best = float.MaxValue;