knife-edge port: polyClipFinish W=0 eye-plane clip + degenerate-view propagation; EyeInsidePortalOpening rescue DELETED

Ports retail ACRender::polyClipFinish (0x006b6d00, pc:702749) near-eye
semantics into PortalProjection.ProjectToClip - the fundamental fix for
the in-plane portal clip family (climb strobes, tower-top roof/floor
flap while turning; live-corroborated this session: [viewer-diff]
0xAAB30108 strobing 27x mid-climb, whole interior dropping at the top).
Pseudocode: docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md.

Three legs, all decomp-driven:

1. ProjectToClip clips at w >= 0 EXACTLY (was EyePlaneW=1e-4), with
   retail's any-negative-w gate. Boundary intersections land at w == 0
   (homogeneous directions), so a portal the eye is CROSSING yields the
   correct unbounded half-region that the bounded view-region clip cuts
   to the screen. A w=0 vertex cannot survive a bounded region clip
   into the divide (direction fails some edge of any bounded convex
   region); the measure-zero corner case is guarded non-finite->empty.

2. CellView.CanonicalKey keys ALL-COLLINEAR (zero-area) views as their
   snapped segment ("L:" + extremes) instead of rejecting them - retail
   PROPAGATES degenerate views (ClipPortals decomp:433651-433711
   forwards any count!=0 GetClip output, no area gate anywhere), keeping
   the cell behind an exactly-in-plane portal in the draw list (cells
   draw whole; onward floods die naturally). Rejection dropped the
   whole chain for the frame - the parked-eye knife-edge band. Finite
   key space unchanged -> dedup + strict-growth convergence intact.

3. The EyeInsidePortalOpening rescue is DELETED (the T2-documented
   compensation for the 1e-4 divergence) along with EyeStandingPerpDist
   + PointInPoly2D. Empty clip = no flood, period (retail's rule).
   CornerFloodReplay - the gate that REFUTED the previous deletion
   attempt - passes WITHOUT the rescue under the W=0 port.

Harness criterion corrected to retail's rules (it codified the rescue):
cells fully BEHIND the camera are not required (all-behind portals clip
empty in retail); monotone area holds per root regime; the two
manufactured exact-on-plane steps assert root-only (boundary root pick
is ambiguous; the in-plane portal there is ~perpendicular to the gaze =
genuinely off-screen). Build_CollapsedInteriorPortalNearEye test
inverted to pin the retail empty-clip rule (it pinned the rescue).

New pins: eye-crossing portal -> w==0 boundary verts + half-region (not
sliver); gaze-along-plane degenerate view accepted + segment-key dedup;
non-finite guard. Replay harnesses (CornerFloodReplay, Issue120,
TowerAscent, HouseExit, Issue127) all green.

Suites: App 246+1skip / Core 1430+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-11 21:44:23 +02:00
parent 2163308032
commit 987313aa54
7 changed files with 357 additions and 130 deletions

View file

@ -70,29 +70,42 @@ public static class PortalProjection
return ndc;
}
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the w=0 clip of
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the W=0 clip of
/// ACRender::polyClipFinish, decomp 424310 / 702749): transform the portal to clip space and clip
/// ONLY the eye plane (w &gt;= <see cref="EyePlaneW"/>), keeping homogeneous coords — NO perspective
/// divide, NO frustum side-plane clamp. The screen bound is applied later by <see cref="ClipToRegion"/>
/// ONLY the eye plane (w &gt;= 0, EXACT), keeping homogeneous coords — NO perspective divide, NO
/// frustum side-plane clamp. The screen bound is applied later by <see cref="ClipToRegion"/>
/// against the view region (the root region is the full screen), exactly as retail clips the portal
/// against the accumulated portal_view rather than fixed side planes. Keeping w means a near/grazing
/// portal never collapses to a zero-area edge sliver (the flap) nor blows up under an early divide
/// (the void). Returns &lt;3 verts when the portal is entirely behind the eye.</summary>
/// against the accumulated portal_view rather than fixed side planes.
///
/// <para>The W=0 clip is exact on purpose (the knife-edge port, 2026-06-11; pseudocode at
/// docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md): boundary intersections land
/// at w == 0 — homogeneous DIRECTIONS — so a portal the eye is crossing (stair openings, decks)
/// yields the correct UNBOUNDED half-region, which the bounded view-region clip then cuts to the
/// screen. The previous EyePlaneW = 1e-4 produced finite ~1e4-NDC boundary verts whose region
/// intersections sat at the dedup/merge degeneracy threshold — the climb-strobe class. A w=0
/// vertex can never survive ClipToRegion into its divide (a nonzero direction fails at least one
/// edge test of any BOUNDED convex region), so no divide-by-zero path exists; the measure-zero
/// corner case is guarded in ClipToRegion. Matches polyClipFinish part 1: clip pass runs only
/// when some vertex has w &lt; 0; &lt;3 survivors → reject (empty).</para></summary>
public static Vector4[] ProjectToClip(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
{
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector4>();
Matrix4x4 m = cellToWorld * viewProj;
var clip = new List<Vector4>(localPoly.Count);
bool anyBehind = false;
foreach (var lp in localPoly)
clip.Add(Vector4.Transform(new Vector4(lp, 1f), m));
{
var v = Vector4.Transform(new Vector4(lp, 1f), m);
if (v.W < 0f) anyBehind = true;
clip.Add(v);
}
// Eye plane ONLY (w >= EyePlaneW), in clip space, homogeneous — no side planes, no divide.
// Retail's polyClipFinish clips at w = 0; EyePlaneW is a hair above 0 so the later divide in
// ClipToRegion never hits the w = 0 singularity. Everything in front of the eye is kept,
// including a portal the camera is standing in (it covers the screen) — the screen bound comes
// from ClipToRegion against the view region, not from a near plane here.
clip = ClipPlane(clip, v => v.W - EyePlaneW);
// polyClipFinish part 1 (0x006b6d5d): the W pass runs only when some vertex sits behind
// the eye plane (w < 0); an all-in-front polygon passes through untouched (and an
// all-behind one clips to empty inside the pass).
if (anyBehind)
clip = ClipPlane(clip, v => v.W);
return clip.Count >= 3 ? clip.ToArray() : System.Array.Empty<Vector4>();
}
@ -123,11 +136,21 @@ public static class PortalProjection
// Divide survivors → NDC. They are inside the region now, so |x| ≤ |w| and |y| ≤ |w|: the
// divide is bounded by construction (this is why the homogeneous clip avoids the early-divide
// blow-up). Normalize to CCW so the result is a valid clip region for the next portal hop.
//
// W=0 port (2026-06-11): with ProjectToClip clipping at exactly w >= 0, a w == 0 vertex
// (a direction) cannot survive the bounded region clip above — a nonzero direction fails at
// least one edge's inside test of any bounded convex region — EXCEPT the measure-zero case
// of a direction lying exactly on a region corner with d == 0 on the adjoining edges. That
// case divides to ±Inf/NaN; treat it as the degenerate knife-edge sliver it is and return
// empty (retail's effective result for the same input: a <1 px degenerate region).
var ndc = new Vector2[poly.Count];
for (int i = 0; i < poly.Count; i++)
{
float w = poly[i].W;
ndc[i] = new Vector2(poly[i].X / w, poly[i].Y / w);
var v = new Vector2(poly[i].X / w, poly[i].Y / w);
if (!float.IsFinite(v.X) || !float.IsFinite(v.Y))
return System.Array.Empty<Vector2>();
ndc[i] = v;
}
// T2 (BR-4): retail's post-divide vertex merge — Render::copy_view
@ -227,11 +250,6 @@ public static class PortalProjection
if (area2 < 0f) System.Array.Reverse(poly);
}
// Eye plane for the homogeneous clip — a hair above retail's w = 0 so the post-region divide in
// ClipToRegion never divides by zero. Far closer than any near plane: a portal the eye is standing
// in is kept (it covers the screen), so the cell behind it stays visible.
private const float EyePlaneW = 1e-4f;
// Minimum clip-space w (≈ metres in front of the eye) to keep a vertex. Excludes the eye
// (w=0) singularity and the ~5 cm right at it (bounding the perspective divide), but is
// INTENTIONALLY far closer than the projection's 1.0 m near plane so a doorway the camera is

View file

@ -167,10 +167,20 @@ public sealed class CellView
// Canonical key for a view polygon: vertices snapped to the NDC grid, consecutive snap-duplicates
// removed (including wrap-around), COLLINEAR points removed (exact integer cross-products on the
// snapped grid), then rotated to start at the lexicographically smallest vertex so a rotated
// emission of the same cycle yields the same key. Returns null when fewer than 3 distinct
// snapped vertices survive (a degenerate sliver, not a real region). Winding is already CCW for every
// emission of the same cycle yields the same key. Winding is already CCW for every
// builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step.
//
// W=0 port (2026-06-11): an ALL-COLLINEAR polygon (zero area) keys as its snapped segment
// ("L:" + extreme points) instead of null. A portal whose plane contains the eye projects to
// exactly this — and retail PROPAGATES it: PView::ClipPortals (decomp:433651-433711) forwards
// any GetClip output with count != 0 to copy_view/OtherPortalClip with no area gate anywhere,
// so the neighbour cell stays in the draw list (cells draw whole; onward floods die naturally
// against the zero-area region). Rejecting these views dropped the whole chain behind an
// exactly-in-plane portal for the frame — the parked-eye knife-edge band (tower deck, spiral
// landings). The segment key space is finite like the area-key space, so dedup + the strict
// growth convergence invariant are unchanged. Returns null only when fewer than 2 distinct
// snapped points survive (a true sub-grid point — not a real region OR segment).
//
// §4 corner/doorway fix (2026-06-10) — the collinear pass: the homogeneous region clipper
// (PortalProjection.ClipToRegion, used by the forward AND — as of today — the reciprocal hop)
// legitimately inserts intersection vertices ON a subject edge when a region edge grazes it, so
@ -192,10 +202,15 @@ public sealed class CellView
}
if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1);
// Snapshot the distinct snapped points BEFORE collinear removal — the all-collinear
// fallback keys off the segment EXTREMES of the full point set (stable across
// re-emissions regardless of the removal loop's order).
List<(int X, int Y)>? preCollinear = pts.Count >= 2 ? new List<(int, int)>(pts) : null;
// Remove collinear points: for consecutive (prev, cur, next) around the cycle, drop cur when
// cross(cur-prev, next-cur) == 0 — exact in integer grid coordinates (deltas ≤ ~4000, products
// ≤ ~1.6e7, no overflow). Loop to a fixpoint: removing one point can make its neighbour
// collinear. Degenerate inputs (all points on one line) reduce below 3 → rejected below.
// collinear. All-collinear inputs reduce below 3 → the segment-key fallback below.
bool removed = true;
while (removed && pts.Count >= 3)
{
@ -215,7 +230,22 @@ public sealed class CellView
}
}
}
if (pts.Count < 3) return null;
if (pts.Count < 3)
{
// Zero-area (all-collinear) view — key as its snapped segment so retail's
// degenerate-view propagation works (see method doc). Extremes are the
// lexicographic min/max of the full snapped point set.
if (preCollinear is null) return null;
var lo = preCollinear[0];
var hi = preCollinear[0];
foreach (var q in preCollinear)
{
if (q.X < lo.X || (q.X == lo.X && q.Y < lo.Y)) lo = q;
if (q.X > hi.X || (q.X == hi.X && q.Y > hi.Y)) hi = q;
}
if (lo == hi) return null; // a sub-grid point — not a region or a segment
return $"L:{lo.X},{lo.Y};{hi.X},{hi.Y};";
}
int n = pts.Count;
int best = 0;

View file

@ -255,35 +255,29 @@ public static class PortalVisibilityBuilder
}
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.
// 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} eyeIn={eyeInsideOpening} D={(float.IsNaN(sideD) ? "na" : sideD.ToString("F2"))}");
trace.Add($"sidechk cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} camInterior={camInterior} 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.
// back portals show camInterior=False eyeIn=True).
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}");
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;
}
@ -301,28 +295,17 @@ 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}");
// 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.
// 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)
{
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()));
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty clipVerts={clipVerts}");
continue;
}
if (portal.OtherCellId == 0xFFFF)
@ -336,7 +319,7 @@ public static class PortalVisibilityBuilder
}
// 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}");
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}");
continue;
}
@ -970,68 +953,6 @@ public static class PortalVisibilityBuilder
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