diff --git a/src/AcDream.App/Rendering/PortalView.cs b/src/AcDream.App/Rendering/PortalView.cs index b48a38f9..6bf0b509 100644 --- a/src/AcDream.App/Rendering/PortalView.cs +++ b/src/AcDream.App/Rendering/PortalView.cs @@ -97,10 +97,21 @@ public sealed class CellView private const float DedupGridNdc = 1e-3f; // Canonical key for a view polygon: vertices snapped to the NDC grid, consecutive snap-duplicates - // removed (including wrap-around), 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 + // 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 // builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step. + // + // §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 + // BFS re-clip rounds re-emit the SAME geometric region with 1-2 extra collinear edge vertices. + // Without collinear canonicalization those re-emissions key as distinct, defeating the dedup and + // accumulating duplicate polygons (the pre-2026-06-06 unbounded-growth hang in miniature, and the + // exact reason the reciprocal clip was previously parked on the unstable divide-first path). + // Dropping collinear snapped points makes the key purely a function of the region's CORNERS, so + // any re-emission of the same shape — drifted, rotated, vertex-count-inflated — deduplicates. private static string? CanonicalKey(Vector2[]? verts) { if (verts is null || verts.Length < 3) return null; @@ -112,6 +123,30 @@ public sealed class CellView if (pts.Count == 0 || pts[^1] != q) pts.Add(q); } if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1); + + // 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. + bool removed = true; + while (removed && pts.Count >= 3) + { + removed = false; + for (int i = 0; i < pts.Count && pts.Count >= 3; i++) + { + var prev = pts[(i + pts.Count - 1) % pts.Count]; + var cur = pts[i]; + var next = pts[(i + 1) % pts.Count]; + long cross = (long)(cur.X - prev.X) * (next.Y - cur.Y) + - (long)(cur.Y - prev.Y) * (next.X - cur.X); + if (cross == 0) + { + pts.RemoveAt(i); + removed = true; + i--; + } + } + } if (pts.Count < 3) return null; int n = pts.Count; diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index e2c78f79..835d5208 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -317,7 +317,7 @@ public static class PortalVisibilityBuilder // in place before the union below. var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null; int preReciprocalCount = clippedRegion.Count; - ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj); + ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj); if (churnProbe) churnReciprocal!.Append(System.FormattableString.Invariant( $" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]")); @@ -512,7 +512,7 @@ public static class PortalVisibilityBuilder continue; var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null; - ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj); + ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj); if (clippedRegion.Count == 0) { if (preReciprocalClip is null) @@ -772,33 +772,51 @@ public static class PortalVisibilityBuilder // (< 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, LoadedCell neighbour, Matrix4x4 viewProj) + 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 - // Project the reciprocal opening through the NEIGHBOUR's transform (retail positionPush(3, - // &other_cell_ptr->pos) at 005a54d2), then normalize winding for the CCW-only clipper. - // NOTE: this stays on the divide-then-clip ProjectToNdc path on purpose. The reciprocal is a - // back-portal one hop away — never near the eye — so the homogeneous clip buys nothing here, - // and ProjectToNdc is float-stable across the BFS re-enqueue rounds. Routing it through - // ProjectToClip+ClipToRegion produced per-round float drift that defeated the CellView - // SamePolygon dedup, inflating a tight A<->B reciprocal view to ~4x its area - // (Build_AppliesReciprocalOtherPortalClip). The near-side clip (ClipPortalAgainstView) IS the - // homogeneous path; this secondary tightening is not. - Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj); - if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op - EnsureCcw(reciprocalNdc); + // §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 = ScreenPolygonClip.Intersect(reciprocalNdc, clippedRegion[k].Vertices); + var tightened = PortalProjection.ClipToRegion(reciprocalClip, clippedRegion[k].Vertices); if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened); else clippedRegion.RemoveAt(k); } diff --git a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs new file mode 100644 index 00000000..10c09cb1 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs @@ -0,0 +1,494 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; +using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; +using DatEnvironment = DatReaderWriter.DBObjs.Environment; + +namespace AcDream.App.Tests.Rendering; + +/// +/// §4 corner-press flood replay (2026-06-10). The §2b camera-collision hypothesis is +/// REFUTED (CameraCornerSealReplayTests, commit b21bb28): the eye legitimately enters +/// the neighbour room through the 0171↔0173↔0172 doorway chain. The user still sees +/// BACKGROUND at certain angles — so the defect is in the FLOOD/CLIP output for those +/// eye positions. This harness drives the REAL +/// over the REAL Holtburg building cells (dat-loaded, mirroring GameWindow.BuildLoadedCell) +/// along the captured corner eye path, and prints which cells the flood keeps/drops per +/// step. The player's room (0172) dropping while the player is visible on screen is the +/// background defect; the step where it happens pins the mechanism. +/// +/// Geometry (corner-seal-capture.log + the b21bb28 room map): all cells share origin +/// (161.93, 7.50, 94.00), local = R180z·(world−origin). The 0171-side doorway plane is +/// local x=4.10 (world x≈157.83), the 0172-side plane local x=3.90 (world x≈158.03); +/// the opening is 1.9 m wide, full height (z 94..96.5). Player dwell position during the +/// corner press: (159.94, 7.70, 94.00) in 0172; captured eyes hover near (157.4..157.5, +/// 7.91, 96.25) — inside 0171, 0.35 m past the doorway plane, near the ceiling. +/// +public class CornerFloodReplayTests +{ + private readonly ITestOutputHelper _out; + public CornerFloodReplayTests(ITestOutputHelper output) => _out = output; + + private const uint Landblock = 0xA9B40000u; + private const uint EnvironmentFilePrefix = 0x0D000000u; + + private static string? ResolveDatDir() + { + var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + var def = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + /// + /// Dat → LoadedCell, mirroring GameWindow.BuildLoadedCell (GameWindow.cs:5636-5776) + /// field-for-field: portals (with OtherPortalId back-link), clip planes from the + /// portal polygon's first 3 verts + centroid InsideSide, full portal polygons in + /// cell-local space, local AABB, world transform from EnvCell.Position. + /// + private static LoadedCell LoadCell(DatCollection dats, uint cellId) + { + var envCell = dats.Get(cellId) + ?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found"); + var environment = dats.Get(EnvironmentFilePrefix | envCell.EnvironmentId) + ?? throw new InvalidOperationException($"Environment 0x{envCell.EnvironmentId:X8} not found"); + if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct) || cellStruct is null) + throw new InvalidOperationException($"CellStruct {envCell.CellStructure} missing"); + + var cellTransform = + Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + Matrix4x4.CreateTranslation(envCell.Position.Origin); + Matrix4x4.Invert(cellTransform, out var inverse); + + var boundsMin = new Vector3(float.MaxValue); + var boundsMax = new Vector3(float.MinValue); + foreach (var kvp in cellStruct.VertexArray.Vertices) + { + var v = kvp.Value; + var pos = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z); + boundsMin = Vector3.Min(boundsMin, pos); + boundsMax = Vector3.Max(boundsMax, pos); + } + if (boundsMin.X == float.MaxValue) { boundsMin = Vector3.Zero; boundsMax = Vector3.Zero; } + + var portals = new List(); + var clipPlanes = new List(); + var portalPolygons = new List(); + var centroid = (boundsMin + boundsMax) * 0.5f; + + foreach (var portal in envCell.CellPortals) + { + portals.Add(new CellPortalInfo( + portal.OtherCellId, portal.PolygonId, (ushort)portal.Flags, portal.OtherPortalId)); + + if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly) + && poly.VertexIds.Count >= 3 + && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0) + && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1) + && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2)) + { + var p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z); + var p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z); + var p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z); + var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); + float d = -Vector3.Dot(normal, p0); + float centroidDot = Vector3.Dot(normal, centroid) + d; + clipPlanes.Add(new PortalClipPlane + { + Normal = normal, D = d, InsideSide = centroidDot >= 0 ? 0 : 1, + }); + } + else + { + clipPlanes.Add(default); + } + + Vector3[] polyVerts = Array.Empty(); + if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPoly) + && portalPoly.VertexIds.Count >= 3) + { + polyVerts = new Vector3[portalPoly.VertexIds.Count]; + bool allResolved = true; + for (int vi = 0; vi < portalPoly.VertexIds.Count; vi++) + { + if (cellStruct.VertexArray.Vertices.TryGetValue( + (ushort)portalPoly.VertexIds[vi], out var pv)) + polyVerts[vi] = new Vector3(pv.Origin.X, pv.Origin.Y, pv.Origin.Z); + else { allResolved = false; break; } + } + if (!allResolved) polyVerts = Array.Empty(); + } + portalPolygons.Add(polyVerts); + } + + uint lbPrefix = cellId & 0xFFFF0000u; + var visibleCells = new List(); + if (envCell.VisibleCells is not null) + foreach (var lowId in envCell.VisibleCells) + visibleCells.Add(lbPrefix | lowId); + + return new LoadedCell + { + CellId = cellId, + WorldPosition = envCell.Position.Origin, + WorldTransform = cellTransform, + InverseWorldTransform = inverse, + LocalBoundsMin = boundsMin, + LocalBoundsMax = boundsMax, + Portals = portals, + ClipPlanes = clipPlanes, + PortalPolygons = portalPolygons, + VisibleCells = visibleCells, + SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside), + }; + } + + private static Dictionary LoadBuilding(DatCollection dats) + { + var cells = new Dictionary(); + for (uint low = 0x016Fu; low <= 0x0175u; low++) + { + uint id = Landblock | low; + cells[id] = LoadCell(dats, id); + } + return cells; + } + + // Production projection: ChaseCamera/FlyCamera use FovY ~1.2, near 1, far 5000, + // 1280x720 (the capture's viewport). The flood's clip is near-independent + // (PortalProjection header), so near/far exactness is not load-bearing. + private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt) + { + var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f); + return view * proj; + } + + // World-x of the doorway planes (local x=4.10 / 3.90 under the R180z transform + // about origin x=161.93). + private const float Plane0171X = 161.93f - 4.10f; // ≈157.83 + private const float Plane0172X = 161.93f - 3.90f; // ≈158.03 + + private static uint RootCellFor(float eyeX) => + eyeX > Plane0172X ? (Landblock | 0x0172u) + : eyeX > Plane0171X ? (Landblock | 0x0173u) + : (Landblock | 0x0171u); + + /// + /// Diagnostic: full [pv-trace] decision log for one GOOD step (47) vs one GLITCH + /// step (48) of the sweep below — 2 cm apart, root 0171 both, yet 0172 (and its + /// downstream 016F + outside) vanish from the flood at 48. The trace diff pins the + /// exact gate that flips. + /// + [Fact] + public void Diagnostic_TraceGoodVsGlitchStep() + { + var datDir = ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cells = LoadBuilding(dats); + Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; + + var player = new Vector3(159.936676f, 7.701012f, 94.000000f); + var pivot = player + new Vector3(0f, 0f, 1.5f); + + foreach (int i in new[] { 47, 48 }) + { + float ex = 158.43f - i * 0.02f; + var eye = new Vector3(ex, 7.912722f, 96.248833f); + uint root = RootCellFor(ex); + + var sw = new StringWriter(); + var prev = Console.Out; + PortalVisibilityFrame frame; + try + { + Console.SetOut(sw); + AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled = true; + frame = PortalVisibilityBuilder.Build(cells[root], eye, lookup, ViewProjFor(eye, pivot)); + } + finally + { + AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled = false; + Console.SetOut(prev); + } + + _out.WriteLine($"########## step {i} (eyeX={ex:F2}) ##########"); + foreach (var line in sw.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries)) + _out.WriteLine(line.TrimEnd()); + + // Per-hop region vertices: where does the sliver come from? + foreach (uint low in new uint[] { 0x0171u, 0x0173u, 0x0172u }) + { + if (!frame.CellViews.TryGetValue(Landblock | low, out var view)) + { + _out.WriteLine($" view 0x{low:X4}: (absent)"); + continue; + } + foreach (var p in view.Polygons) + { + string verts = ""; + foreach (var v in p.Vertices) + verts += System.FormattableString.Invariant($" ({v.X:F5},{v.Y:F5})"); + _out.WriteLine($" view 0x{low:X4} [{p.Vertices.Length}]:{verts}"); + } + if (view.Polygons.Count == 0) + _out.WriteLine($" view 0x{low:X4}: 0 polygons (rejected by Add)"); + } + } + } + + /// + /// §4 conformance gate (2026-06-08 handoff §7's pre-gate, realized 2026-06-10): + /// a smooth monotonic eye moving through/near a doorway must produce a STABLE, + /// monotone flood — the full cell chain present on every step, the outside view + /// always reached, and the player-room region never collapsing nor oscillating. + /// Before the homogeneous-reciprocal fix this failed on ~10 of 61 steps (room + /// 0172 vanished entirely or collapsed to a sub-pixel sliver — the corner / + /// transition background strobe). Guards the ApplyReciprocalClip pipeline. + /// + [Fact] + public void CornerSweep_FloodIsCompleteAndMonotone() + { + var datDir = ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cells = LoadBuilding(dats); + Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; + + var player = new Vector3(159.936676f, 7.701012f, 94.000000f); + var pivot = player + new Vector3(0f, 0f, 1.5f); + + var failures = new List(); + float prevArea = float.MaxValue; + 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); + + var frame = PortalVisibilityBuilder.Build( + cells[root], eye, lookup, ViewProjFor(eye, pivot)); + + foreach (uint low in new uint[] { 0x0171u, 0x0172u, 0x0173u, 0x016Fu }) + 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}: 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) + 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) + failures.Add(System.FormattableString.Invariant( + $"step {i}: 0172 region grew {prevArea:F3}->{area:F3} (oscillation)")); + prevArea = area; + } + + Assert.True(failures.Count == 0, + "Flood instability under a monotonic eye sweep (the §4 strobe):\n " + + string.Join("\n ", failures)); + } + + /// + /// Diagnostic: microscope on the failing hop. Replays the two portal hops + /// (0171→0173, then 0173→0172) through the PUBLIC PortalProjection APIs for the + /// good step (47) and the glitch step (48), printing the homogeneous subject + /// vertices and full-precision outputs at every stage — the concrete numbers for + /// the collapsing intersection. + /// + [Fact] + public void Diagnostic_Hop2Microscope() + { + var datDir = ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cells = LoadBuilding(dats); + var c171 = cells[Landblock | 0x0171u]; + var c173 = cells[Landblock | 0x0173u]; + + var player = new Vector3(159.936676f, 7.701012f, 94.000000f); + var pivot = player + new Vector3(0f, 0f, 1.5f); + + // 0171's portal to 0173 is index 1; 0173's portal to 0172 is index 1 (room map). + var poly171to173 = c171.PortalPolygons[1]; + var poly173to172 = c173.PortalPolygons[1]; + + foreach (int i in new[] { 47, 48 }) + { + float ex = 158.43f - i * 0.02f; + var eye = new Vector3(ex, 7.912722f, 96.248833f); + var vp = ViewProjFor(eye, pivot); + + _out.WriteLine($"########## step {i} (eyeX={ex:F2}) ##########"); + + // Hop 1: 0171 -> 0173 against the full screen. + var subj1 = PortalProjection.ProjectToClip(poly171to173, c171.WorldTransform, vp); + DumpClip("hop1 subject (0171->0173 portal, clip space)", subj1); + var region173 = PortalProjection.ClipToRegion(subj1, new[] + { + new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f), + }); + DumpNdc("hop1 out = 0173 region", region173); + if (region173.Length < 3) continue; + + // Hop 2: 0173 -> 0172 against the 0173 region. + var subj2 = PortalProjection.ProjectToClip(poly173to172, c173.WorldTransform, vp); + DumpClip("hop2 subject (0173->0172 portal, clip space)", subj2); + var region172 = PortalProjection.ClipToRegion(subj2, region173); + DumpNdc("hop2 out = 0172 region", region172); + } + } + + /// Scratch: the homogeneous reciprocal primitive in isolation, on the + /// synthetic fixture geometry that Build_AppliesReciprocalOtherPortalClip uses. + [Fact] + public void Scratch_ReciprocalPrimitive_SyntheticPair() + { + var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f); + var vp = view * proj; + + Vector3[] Quad(float cx, float cy, float hw, float hh, float z) => new[] + { + new Vector3(cx - hw, cy - hh, z), new Vector3(cx + hw, cy - hh, z), + new Vector3(cx + hw, cy + hh, z), new Vector3(cx - hw, cy + hh, z), + }; + + var wide = Quad(0f, 0f, 0.9f, 0.9f, -3f); + var narrow = Quad(0f, 0f, 0.3f, 0.9f, -3f); + + // Near-side region: wide portal clipped against the full screen. + var wideClip = PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, vp); + DumpClip("wide subject", wideClip); + var wideRegion = PortalProjection.ClipToRegion(wideClip, new[] + { + new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f), + }); + DumpNdc("wide region (near-side)", wideRegion); + + // Reciprocal: narrow portal, homogeneous, clipped against the wide region. + var narrowClip = PortalProjection.ProjectToClip(narrow, Matrix4x4.Identity, vp); + DumpClip("narrow subject (reciprocal)", narrowClip); + var tightened = PortalProjection.ClipToRegion(narrowClip, wideRegion); + DumpNdc("tightened = narrow ∩ wide", tightened); + + // Now the FULL Build on the same synthetic pair (mirrors + // Build_AppliesReciprocalOtherPortalClip) with output dumps. + var a = new LoadedCell + { + CellId = 0x0001, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, + Portals = new List { new CellPortalInfo(0x0002, 0, 0, 0) }, + }; + a.PortalPolygons.Add(wide); + var b = new LoadedCell + { + CellId = 0x0002, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, + Portals = new List { new CellPortalInfo(0x0001, 0, 0, 0) }, + }; + b.PortalPolygons.Add(narrow); + var all = new Dictionary { [0x0001u] = a, [0x0002u] = b }; + + var f = PortalVisibilityBuilder.Build( + a, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp); + foreach (var kv in f.CellViews) + foreach (var p in kv.Value.Polygons) + { + string s = ""; + foreach (var v in p.Vertices) + s += System.FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})"); + _out.WriteLine($" Build view 0x{kv.Key:X4} [{p.Vertices.Length}]:{s}"); + } + } + + private void DumpClip(string label, Vector4[] verts) + { + string s = ""; + foreach (var v in verts) + s += System.FormattableString.Invariant($" ({v.X:F4},{v.Y:F4},{v.Z:F4},w={v.W:F4})"); + _out.WriteLine($" {label} [{verts.Length}]:{s}"); + } + + private void DumpNdc(string label, Vector2[] verts) + { + string s = ""; + foreach (var v in verts) + s += System.FormattableString.Invariant($" ({v.X:F7},{v.Y:F7})"); + _out.WriteLine($" {label} [{verts.Length}]:{s}"); + } + + /// + /// Diagnostic (no assertions yet): sweep the eye along the corner-press path — + /// from inside the player's room (0172), across the doorway threshold (0173), + /// into the neighbour room (0171) — looking back at the player throughout, and + /// print the flood result per step. The defect signature: the PLAYER's room + /// (0172) absent from the flood (or its view region empty) while the root is + /// 0171/0173 — the screen area where the player stands then shows background. + /// + [Fact] + public void Diagnostic_CornerPress_FloodAcrossDoorwayPlane() + { + var datDir = ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cells = LoadBuilding(dats); + Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; + + // The captured corner scenario: player parked in 0172, eye orbiting near the + // doorway plane at head height (z=96.25), looking at the pivot. + var player = new Vector3(159.936676f, 7.701012f, 94.000000f); + var pivot = player + new Vector3(0f, 0f, 1.5f); + + _out.WriteLine("step | eyeX (worldX | dPlane0171) | root | flood | 0171/0173/0172 polys | outside"); + for (int i = 0; i <= 60; i++) + { + // Sweep world-x 158.43 (inside 0172) -> 157.23 (inside 0171), 2 cm steps, + // crossing the 0172 plane (~158.03) and the 0171 plane (~157.83). + float ex = 158.43f - i * 0.02f; + var eye = new Vector3(ex, 7.912722f, 96.248833f); + uint root = RootCellFor(ex); + + var frame = PortalVisibilityBuilder.Build( + cells[root], eye, lookup, ViewProjFor(eye, pivot)); + + string flood = ""; + foreach (uint id in frame.OrderedVisibleCells) + flood += $" {id & 0xFFFFu:X4}"; + + string PolyInfo(uint low) + { + uint id = Landblock | low; + if (!frame.CellViews.TryGetValue(id, out var view)) return "-"; + int n = view.Polygons.Count; + float area = 0f; + foreach (var p in view.Polygons) + area += MathF.Max(0f, p.MaxX - p.MinX) * MathF.Max(0f, p.MaxY - p.MinY); + return System.FormattableString.Invariant($"{n}:{area:F3}"); + } + + string p71 = PolyInfo(0x0171u); + string p73 = PolyInfo(0x0173u); + string p72 = PolyInfo(0x0172u); + _out.WriteLine(System.FormattableString.Invariant( + $"{i,3} | {ex:F2} ({ex - Plane0171X,6:F3}) | {root & 0xFFFFu:X4} |{flood} | {p71} / {p73} / {p72} | out={frame.OutsideView.Polygons.Count}")); + } + } +}