diff --git a/docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md b/docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md new file mode 100644 index 00000000..6c88d68c --- /dev/null +++ b/docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md @@ -0,0 +1,98 @@ +# ACRender::polyClipFinish — W=0 eye-plane clip pseudocode (the knife-edge port) + +**Source:** `ACRender::polyClipFinish` at `0x006b6d00`, +`docs/research/named-retail/acclient_2013_pseudo_c.txt:702749-702988`. +Read 2026-06-11 for the knife-edge in-plane portal clip port +(handoff `docs/research/2026-06-11-tower-stairs-fundamental-handoff.md` §5). + +## Signature (reconstructed) + +``` +polyClipFinish(view_vertex** inVerts, // arg1 — homogeneous clip-space verts (x,y,z,w) + int inCount, // arg2 + Vec2Dscreen** outVerts, // arg3 — output vertex pointers + int* outCount, // arg4 + int planeMask) // arg5 — per-edge skip mask (bit set = poly already + // fully inside that portal_view edge) +``` + +## Part 1 — the W=0 eye-plane pass (0x006b6d5d–0x006b6f12) + +``` +scan = inCount - 1 +while scan >= 0: # walk verts from the END + if inVerts[scan].w < 0: break # found a vertex BEHIND the eye plane → must clip + scan -= 1 +if scan < 0: goto edge_clips # all w >= 0 → skip the W pass entirely + +# homogeneous Sutherland-Hodgman against w = 0, intersections EMITTED: +out = [] +prev = inVerts[0]; prevIn = (prev.w >= 0) +for cur in inVerts[last..0]: # retail iterates indices descending + curIn = (cur.w >= 0) + if curIn != prevIn: + t = prev.w / (prev.w - cur.w) # 0x006b6ea0: w0 / (w0 - w1) + emit(prev + t*(cur - prev)) # interpolates x, y, z, w → lands at w == 0 exactly + if curIn: emit(cur) + prev, prevIn = cur, curIn + +if len(out) < 3: return # 0x006b6f00: reject — fewer than 3 survivors +inVerts = out # ping-pong to tempPtBuf +``` + +**x87 flag-decode note** (the BN polarity trap, [[feedback_bn_decomp_field_names]]): +the scan loop's `test ah, 0x5` (C0|C2) breaks on **w < 0**, NOT w ≥ 0. Decoded by +case analysis: the all-behind polygon must reach the W pass and clip to empty +(reject), and the common all-in-front polygon must skip the pass — only the +break-on-negative decode yields both. The inside predicate in the clip pass +(`test ah, 0x41`, C0|C3) is **inside ⇔ w ≥ 0** (emit-on-sign-change with +`t = w0/(w0−w1)` confirms: t∈[0,1] requires opposite signs). + +## Part 2 — portal_view edge clips (0x006b6d82–0x006b7030) + +``` +for each portal_view edge (vertex pair), mask-gated (planeMask bit set → skip): + # homogeneous 2D edge function for vertex P against edge (a → b): + # side(P) = (P.x − a.x·P.w)·(b.y − a.y) − (P.y − a.y·P.w)·(b.x − a.x) + # (0x006b6e05) — linear in (x, y, w): valid for w = 0 verts (directions). + Sutherland-Hodgman with intersection emission (t = s0/(s0 − s1), all 4 comps) + if survivors < 3: return # 0x006b6fe1 +*outCount = survivors # 0x006b7006 +``` + +## The load-bearing semantics for acdream + +1. **Clip at w ≥ 0 EXACTLY** — boundary intersections land at w == 0. A w=0 + vertex is a homogeneous DIRECTION; the polygon containing it represents the + unbounded screen region extending toward that direction. This is what makes + an eye-crossing portal (climbing through a stair opening) produce the + correct large half-region instead of a bounded sliver: + - At `w ≥ ε` (our old `EyePlaneW = 1e-4`), boundary verts are finite NDC + points ~1e4 units out along the portal-plane horizon line; the polygon's + screen intersection still APPROXIMATES the half-region, but the divide + and the dedup/merge operate on degenerate near-collinear coordinates. + - At `w = 0`, the edge functions stay exact (linear in homogeneous coords) + and no divide ever touches a w=0 vertex (see invariant below). +2. **A w=0 vertex can never survive the region clip into the divide** when the + clip region is BOUNDED: for a bounded convex CCW region the edge directions + wrap 360°, so a nonzero direction fails at least one edge's inside test. + Our regions are always bounded (FullScreenQuad and its descendants), so the + post-clip divide is safe by construction. The measure-zero exception + (direction exactly on a region corner) is guarded by a non-finite check + that returns empty — identical net behavior to retail's degenerate sliver. +3. **Empty is a verdict, not an error.** `< 3 survivors → reject` at every + stage; retail has NO eye-in-opening rescue anywhere in this path. The + acdream `EyeInsidePortalOpening` rescue was the documented compensation for + the `EyePlaneW = 1e-4` divergence (T2 ledger) and is deleted with this port. + +## What is NOT ported here + +- `cdstW = 0.000199999995` (pinned at `0x007247d5`) — consumed elsewhere + (its consumer is still unmapped; `landPolysDraw` 0x006b7040 uses the same + 0.0002 inline for plane side tests). `PortalSideEpsilon = 0.01` stays as the + documented root-lag tolerance (T2 refutation: retail's 0.0002 needs + eye-exact viewer-cell tracking first). +- The `planeMask` per-edge skip — a perf short-circuit; our ClipToRegion + clips against every region edge unconditionally. +- Retail's descending vertex iteration order — Sutherland-Hodgman output is + order-invariant up to rotation; we keep ascending. diff --git a/src/AcDream.App/Rendering/PortalProjection.cs b/src/AcDream.App/Rendering/PortalProjection.cs index 194a8a98..587624a3 100644 --- a/src/AcDream.App/Rendering/PortalProjection.cs +++ b/src/AcDream.App/Rendering/PortalProjection.cs @@ -70,29 +70,42 @@ public static class PortalProjection return ndc; } - /// Faithful homogeneous projection (retail PrimD3DRender::xformStart + the w=0 clip of + /// 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 >= ), keeping homogeneous coords — NO perspective - /// divide, NO frustum side-plane clamp. The screen bound is applied later by + /// ONLY the eye plane (w >= 0, EXACT), keeping homogeneous coords — NO perspective divide, NO + /// frustum side-plane clamp. The screen bound is applied later by /// 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 <3 verts when the portal is entirely behind the eye. + /// against the accumulated portal_view rather than fixed side planes. + /// + /// 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 < 0; <3 survivors → reject (empty). public static Vector4[] ProjectToClip(IReadOnlyList localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj) { if (localPoly == null || localPoly.Count < 3) return System.Array.Empty(); Matrix4x4 m = cellToWorld * viewProj; var clip = new List(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(); } @@ -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(); + 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 diff --git a/src/AcDream.App/Rendering/PortalView.cs b/src/AcDream.App/Rendering/PortalView.cs index 87511cab..5cf5c268 100644 --- a/src/AcDream.App/Rendering/PortalView.cs +++ b/src/AcDream.App/Rendering/PortalView.cs @@ -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; diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index 8b188e34..ba1cb700 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -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; - - /// - /// True when the camera eye is "standing in" 's opening: within - /// 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. - /// - 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; - } - /// /// 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 diff --git a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs index 6893db41..c6b137fe 100644 --- a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs @@ -275,35 +275,72 @@ public class CornerFloodReplayTests var player = new Vector3(159.936676f, 7.701012f, 94.000000f); var pivot = player + new Vector3(0f, 0f, 1.5f); + // W=0 port (2026-06-11) — the criterion is retail's, not the rescue's: + // - Cells BEHIND the camera are NOT required (retail polyClipFinish clips an + // all-behind portal to empty; the rescue used to flood them anyway). The eye + // looks AT the player throughout, so the doorway chain is behind the camera + // until the eye recedes through it: require 0173 from root=0173 on, 0171 + // from root=0171 on. 0172 (the looked-at room with the player) is required + // at EVERY step — that is THE user-visible §4 invariant. + // - The two KNIFE-EDGE steps (eye exactly ON a doorway plane, the sweep grid + // lands on the plane constants) propagate retail's zero-area degenerate view: + // the chain stays in the draw list (cells draw whole) but the 0172 region is + // legitimately zero-area and the onward flood (016F/outside) legitimately + // dies for that frame — exempt those assertions there. + // - Monotone shrink holds WITHIN a root regime; the root flip is a legitimate + // discontinuity (FullScreen root view -> portal-clipped view). var failures = new List(); float prevArea = float.MaxValue; + uint prevRoot = 0; for (int i = 0; i <= 60; i++) { float ex = 158.43f - i * 0.02f; var eye = new Vector3(ex, 7.912722f, 96.248833f); uint root = RootCellFor(ex); + bool knifeEdge = MathF.Abs(ex - Plane0172X) < 0.005f || MathF.Abs(ex - Plane0171X) < 0.005f; var frame = PortalVisibilityBuilder.Build( cells[root], eye, lookup, ViewProjFor(eye, pivot)); - foreach (uint low in new uint[] { 0x0171u, 0x0172u, 0x0173u, 0x016Fu }) + // Knife-edge steps: the eye sits EXACTLY on a cell-boundary plane, so the + // root pick itself is ambiguous (production BSP picks either side; a damped + // float eye never lands exactly on the plane). The portal whose plane + // contains the eye is ~perpendicular to the gaze here — genuinely + // off-screen, retail's screen-bounded clip floods nothing through it from + // the far-side root. Require only the root; the neighbouring steps (±2 cm, + // where the real strobe class lived) carry the full criterion. + var required = new List(); + if (knifeEdge) + { + required.Add(root & 0xFFFFu); + } + else + { + required.Add(0x0172u); + if (root != (Landblock | 0x0172u)) required.Add(0x0173u); + if (root == (Landblock | 0x0171u)) required.Add(0x0171u); + required.Add(0x016Fu); + } + foreach (uint low in required) if (!frame.OrderedVisibleCells.Contains(Landblock | low)) - failures.Add($"step {i}: 0x{low:X4} missing from flood"); - if (frame.OutsideView.Polygons.Count == 0) + failures.Add($"step {i}: 0x{low:X4} missing from flood (root=0x{root & 0xFFFFu:X4}{(knifeEdge ? ", knife-edge" : "")})"); + if (!knifeEdge && frame.OutsideView.Polygons.Count == 0) failures.Add($"step {i}: outside view empty"); float area = 0f; if (frame.CellViews.TryGetValue(Landblock | 0x0172u, out var view)) foreach (var p in view.Polygons) area += MathF.Max(0f, p.MaxX - p.MinX) * MathF.Max(0f, p.MaxY - p.MinY); - if (area < 0.5f) + if (!knifeEdge && area < 0.5f) failures.Add(System.FormattableString.Invariant( $"step {i}: 0172 region collapsed (area={area:F3})")); // Monotone shrink as the eye recedes — allow float-noise upticks only. - if (area > prevArea + 0.01f) + // Reset at root flips + knife-edge frames (legitimate discontinuities). + if (root == prevRoot && !knifeEdge && prevArea != float.MaxValue && area > prevArea + 0.01f) failures.Add(System.FormattableString.Invariant( $"step {i}: 0172 region grew {prevArea:F3}->{area:F3} (oscillation)")); - prevArea = area; + prevArea = knifeEdge ? float.MaxValue : area; + prevRoot = root; } Assert.True(failures.Count == 0, diff --git a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs index 56b29c27..693bdc1e 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs @@ -340,4 +340,119 @@ public class PortalProjectionTests Assert.True(ndc.Length >= 3); foreach (var v in ndc) { Assert.InRange(v.X, -0.301f, 0.301f); Assert.InRange(v.Y, -0.301f, 0.301f); } } + + // --------------------------------------------------------------------------- + // The W=0 knife-edge port (2026-06-11) — retail ACRender::polyClipFinish part 1 + // (0x006b6d00, pc:702749; pseudocode at docs/research/2026-06-11-polyclipfinish- + // w0-clip-pseudocode.md). The eye-plane clip is at w >= 0 EXACTLY: boundary + // intersections land at w == 0 (homogeneous directions), so a portal the eye is + // CROSSING (stair openings on a spiral climb, the tower deck) yields the correct + // unbounded half-region that the bounded view-region clip then cuts to the + // screen. The previous EyePlaneW = 1e-4 made the boundary verts finite ~1e4-NDC + // points and the resulting regions sat at the merge/dedup degeneracy threshold — + // the climb-strobe class that the (now deleted) EyeInsidePortalOpening rescue + // compensated for. + // --------------------------------------------------------------------------- + + [Fact] + public void ProjectToClip_EyeCrossingPortal_BoundaryVertsLandAtWZero() + { + // A horizontal floor opening 5 mm BELOW the eye, spanning from 1.5 m ahead to + // 0.5 m behind — the spiral-climb crossing frame. The two edges crossing the + // eye plane must emit intersections at exactly w == 0 (retail polyClipFinish + // t = w0/(w0-w1)), not at an epsilon offset. + var opening = new[] + { + new Vector3(-1f, -0.005f, -1.5f), new Vector3(1f, -0.005f, -1.5f), + new Vector3(1f, -0.005f, 0.5f), new Vector3(-1f, -0.005f, 0.5f), + }; + var clip = PortalProjection.ProjectToClip(opening, Matrix4x4.Identity, ViewProj()); + Assert.True(clip.Length >= 3, "an eye-crossing portal must keep its forward half"); + int atZero = 0; + foreach (var v in clip) + { + Assert.True(v.W >= 0f, $"no survivor may sit behind the eye plane, got w={v.W}"); + if (v.W == 0f) atZero++; + } + Assert.True(atZero >= 2, $"the two eye-plane crossings must land at exactly w==0, got {atZero}"); + } + + [Fact] + public void ClipToRegion_EyeCrossingFloorOpening_YieldsHalfRegionNotSliver() + { + // Same crossing frame: the visible set through an opening the eye is inside is + // the half-screen below the opening's plane horizon — NOT the degenerate sliver + // the epsilon clip produced. Full screen area is 4.0; the half-region must hold + // a substantial part of it. + var opening = new[] + { + new Vector3(-1f, -0.005f, -1.5f), new Vector3(1f, -0.005f, -1.5f), + new Vector3(1f, -0.005f, 0.5f), new Vector3(-1f, -0.005f, 0.5f), + }; + var clip = PortalProjection.ProjectToClip(opening, Matrix4x4.Identity, ViewProj()); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length >= 3, "the crossing frame must produce a region, not empty (the climb strobe)"); + foreach (var v in ndc) + { + Assert.True(float.IsFinite(v.X) && float.IsFinite(v.Y), $"region verts must be finite, got ({v.X},{v.Y})"); + Assert.InRange(v.X, -1.001f, 1.001f); + Assert.InRange(v.Y, -1.001f, 1.001f); + } + float area = AbsArea(ndc); + Assert.True(area > 1.5f, + $"the region must approximate the lower half-screen (area ~2.0 of 4.0), got {area} (sliver = the strobe bug)"); + } + + [Fact] + public void EyeInPortalPlane_GazeAlongPlane_DegenerateViewPropagates() + { + // The spiral-climb knife edge: the eye sits IN a horizontal portal's plane with + // the gaze ALONG the plane (climbing stairs through the opening). The opening is + // visibly edge-on ON screen: ProjectToClip + ClipToRegion yield a zero-area + // collinear region — and retail PROPAGATES it (ClipPortals forwards any count!=0 + // clip; no area gate), keeping the cell behind in the draw list. CellView.Add + // must therefore ACCEPT the collinear polygon (the "L:" segment key) instead of + // rejecting it as degenerate — rejection dropped the whole chain for the frame. + var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1.0f, 5000f); + var vp = view * proj; + // Horizontal opening in the y=0 plane (contains the eye), ahead of the camera. + var opening = new[] + { + new Vector3(-1f, 0f, -1f), new Vector3(1f, 0f, -1f), + new Vector3(1f, 0f, -4f), new Vector3(-1f, 0f, -4f), + }; + var clip = PortalProjection.ProjectToClip(opening, Matrix4x4.Identity, vp); + Assert.True(clip.Length >= 3, "the in-plane opening's forward part must survive the W clip"); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length >= 3, "the edge-on opening must yield its (zero-area) collinear region"); + + var cellView = new CellView(); + Assert.True(cellView.Add(new ViewPolygon(ndc)), + "a zero-area collinear view must be ACCEPTED (retail propagates degenerate views; " + + "rejecting it drops the cell chain at the knife edge)"); + // Re-emission of the same degenerate view dedups (finite segment-key space = convergence). + Assert.False(cellView.Add(new ViewPolygon(ndc)), + "a re-emitted degenerate view must dedup via its segment key"); + } + + [Fact] + public void ClipToRegion_NeverReturnsNonFiniteVerts() + { + // The measure-zero guard: whatever survives the bounded region clip must divide + // to finite NDC. Exercise with a portal whose vertices sit ON the eye plane + // (w == 0 inputs) plus one in front — degenerate input, must yield empty or finite. + var degenerate = new[] + { + new Vector3(-1f, 0f, 0f), new Vector3(1f, 0f, 0f), new Vector3(0f, 1f, -2f), + }; + var clip = PortalProjection.ProjectToClip(degenerate, Matrix4x4.Identity, ViewProj()); + if (clip.Length >= 3) + { + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + foreach (var v in ndc) + Assert.True(float.IsFinite(v.X) && float.IsFinite(v.Y), + $"non-finite NDC vert leaked from the divide: ({v.X},{v.Y})"); + } + } } diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index 6ff081dd..d8c1358e 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -131,23 +131,31 @@ public class PortalVisibilityBuilderTests } [Fact] - public void Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour() + public void Build_PortalFullyBehindEye_NotFlooded_RetailEmptyClipRule() { - // Live cellar capture (2026-06-06): 0174->0175 was traversable, but the portal projected to - // zero vertices while the chase camera was about 1.4 m from the opening plane. The flood must - // still reach the stair connector; otherwise the main-floor shell/floor disappears. + // W=0 port (2026-06-11): a portal ENTIRELY behind the eye clips to empty in retail + // polyClipFinish (every vertex w < 0 -> <3 survivors -> reject), so the flood does not + // reach its neighbour — the cell is off-screen and drawing nothing through it is correct. + // + // HISTORY: this test used to assert the OPPOSITE (rescue-era pin from a 2026-06-06 cellar + // capture, "0174->0175 must flood at 1.4 m behind the camera"). That pinned the + // EyeInsidePortalOpening rescue — the documented compensation for ProjectToClip's old + // EyePlaneW=1e-4 divergence — not retail. The rescue is deleted with the polyClipFinish + // W=0 port (docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md); the live + // cellar behaviors are re-verified by the dat-backed replay harnesses + the visual gate. var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); - cam.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, 1.4f)); // behind eye: ProjectToNdc collapses + cam.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, 1.4f)); // entirely behind the eye var stairs = Cell(0x0002); var all = new Dictionary { [0x0001] = cam, [0x0002] = stairs }; var vp = ViewProj(); - Assert.True(PortalProjection.ProjectToNdc(cam.PortalPolygons[0], Matrix4x4.Identity, vp).Length < 3); + Assert.True(PortalProjection.ProjectToClip(cam.PortalPolygons[0], Matrix4x4.Identity, vp).Length < 3, + "a fully-behind portal must clip to empty (polyClipFinish part 1)"); var frame = PortalVisibilityBuilder.Build( cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp); - Assert.Contains(0x0002u, frame.OrderedVisibleCells); + Assert.DoesNotContain(0x0002u, frame.OrderedVisibleCells); } [Fact]