From dede7e491cc3e51b430ae53ee1d7120ba691cde4 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 11 Jun 2026 17:32:21 +0200 Subject: [PATCH] #120: CellView containment rejection - the reciprocal ping-pong converges; tripwire firings reproduced + killed The armed tripwire self-attributed on the re-gate launch (regate-118-119-launch.log): a pure TWO-CELL reciprocal ping-pong, 64 laps each - chain root=0xA9B4015C eye=(109.995,37.158,96.249) cells 0162x64 015Cx64, and root=0xA9B3010F eye=(175.771,-107.310,118.814) cells 0103x64 010Fx64 (A9B3 = the hill cottage the user reports going all-transparent on entry - likely the same mechanism, verify at the next gate). Mechanism: with the eye within PortalSideEpsilon (+-1 cm; the T2 refuted-to-tighten root-lag tolerance - retail's is 0.0002) of a portal plane, the in-plane case counts as interior for BOTH cells, so views lap A->B->A...; each lap re-clips through two near-edge-on apertures whose intersection numerics wobble by more than CellView's 1e-3 dedup grid, so every lap keys as NEW and the in-place growth recurses to the depth-128 failsafe. The prior convergence sweeps could not reproduce because they only load the corner building 0x016F-0x0175 - both firing pairs are outside that set. Issue120ReciprocalPingPongTests loads the full landblock's interior cells and drives the +-epsilon window directly: deterministic firings + 65-polygon CellView piles pre-fix. Fix (the handoff's own predicted class - dedup admitting near-duplicates per lap, NOT a limit tune): CellView.Add rejects a polygon CONTAINED in one already stored (convex edge test, DedupGridNdc slack). A round-trip re-emission is, in exact arithmetic, a SUBSET of the polygon that originated it - containment rejection makes union growth strictly area-increasing, so no new visible area means no propagation. Bonus: back-emission into a full-screen view (the root cell) now always rejects. The corner-flood completeness pins stay green - no real region is dropped. Suites: App 236 (232+4), Core 1419+2skip, UI 420, Net 294. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/Rendering/PortalView.cs | 68 ++++++ .../Issue120ReciprocalPingPongTests.cs | 195 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 tests/AcDream.App.Tests/Rendering/Issue120ReciprocalPingPongTests.cs diff --git a/src/AcDream.App/Rendering/PortalView.cs b/src/AcDream.App/Rendering/PortalView.cs index 6bf0b509..87511cab 100644 --- a/src/AcDream.App/Rendering/PortalView.cs +++ b/src/AcDream.App/Rendering/PortalView.cs @@ -83,6 +83,21 @@ public sealed class CellView if (key is null) return false; // degenerate after snap (< 3 distinct vertices) if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant) + // #120 convergence (2026-06-11): reject a polygon CONTAINED in one already + // stored. The reciprocal ping-pong (eye within PortalSideEpsilon of a + // portal plane → BOTH side tests pass → views lap A→B→A…) re-emits, each + // lap, a region that is — in exact arithmetic — a SUBSET of the polygon + // that originated it; near-edge-on apertures make the re-clip wobble by + // more than the 1e-3 key grid, so every lap keyed as "new" and the + // in-place growth recursed to the depth-128 tripwire (chain dumps: + // 0xA9B4015C↔0x0162, 0xA9B30103↔0x010F; Issue120ReciprocalPingPongTests + // reproduces deterministically). Containment rejection makes growth + // strictly area-increasing — no new visible area, no propagation. The + // key stays recorded so the exact emission also short-circuits later. + // Bonus: back-emission into a full-screen view (the root cell) is now + // always rejected outright. + if (ContainedInExisting(p)) return false; + Polygons.Add(p); if (p.MinX < MinX) MinX = p.MinX; if (p.MinY < MinY) MinY = p.MinY; @@ -91,6 +106,59 @@ public sealed class CellView return true; } + // #120: is polygon p entirely inside ONE stored polygon (with DedupGridNdc + // slack)? Single-polygon containment is sufficient for the ping-pong class — + // a round-trip re-emission descends from exactly one originator. Stored + // polygons are convex (Sutherland-Hodgman / full-screen seed outputs); the + // edge test adapts to either winding via the polygon's signed area. + private bool ContainedInExisting(in ViewPolygon p) + { + const float eps = DedupGridNdc; + for (int i = 0; i < Polygons.Count; i++) + { + var e = Polygons[i]; + // bounding-rect quick reject (with slack) + if (p.MinX < e.MinX - eps || p.MaxX > e.MaxX + eps + || p.MinY < e.MinY - eps || p.MaxY > e.MaxY + eps) + continue; + if (ContainsAllVertices(e.Vertices, p.Vertices, eps)) + return true; + } + return false; + } + + private static bool ContainsAllVertices(Vector2[] convex, Vector2[] pts, float eps) + { + if (convex.Length < 3) return false; + + // signed area → winding (CCW positive); inside = left of every CCW edge. + float area2 = 0f; + for (int i = 0; i < convex.Length; i++) + { + var a = convex[i]; + var b = convex[(i + 1) % convex.Length]; + area2 += a.X * b.Y - b.X * a.Y; + } + float sign = area2 >= 0f ? 1f : -1f; + + for (int i = 0; i < convex.Length; i++) + { + var a = convex[i]; + var b = convex[(i + 1) % convex.Length]; + var ab = b - a; + float len = ab.Length(); + if (len < 1e-9f) continue; // degenerate edge — no constraint + foreach (var pt in pts) + { + // signed perpendicular distance of pt from edge a→b (positive = inside for CCW) + float cross = sign * (ab.X * (pt.Y - a.Y) - ab.Y * (pt.X - a.X)); + if (cross < -eps * len) + return false; // a vertex lies outside this edge by more than eps + } + } + return true; + } + // NDC dedup grid. 1e-3 is ~0.5 px at 1080p — finer than the gap between distinct portal openings // (so real regions stay distinct) yet far coarser than the per-round float drift of a re-clipped // region (so a drifted duplicate snaps onto its predecessor). The finite grid is what bounds growth. diff --git a/tests/AcDream.App.Tests/Rendering/Issue120ReciprocalPingPongTests.cs b/tests/AcDream.App.Tests/Rendering/Issue120ReciprocalPingPongTests.cs new file mode 100644 index 00000000..cdf7c00f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue120ReciprocalPingPongTests.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; +using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #120 reproduction (2026-06-11): the armed tripwire self-attributed on the +/// re-gate launch (regate-118-119-launch.log) — a pure TWO-CELL reciprocal +/// ping-pong, 64 laps each: `chain root=0xA9B4015C eye=(109.995,37.158,96.249) +/// cells: 0xA9B40162x64 0xA9B4015Cx64` and `root=0xA9B3010F +/// eye=(175.771,-107.310,118.814) cells: 0xA9B30103x64 0xA9B3010Fx64`. +/// +/// Mechanism: with the eye within PortalSideEpsilon (±1 cm — the T2-refuted- +/// to-tighten root-lag tolerance; retail's is 0.0002) of the portal plane, +/// the in-plane case counts as interior for BOTH cells, so views flow +/// A→B→A…; each lap re-clips through two near-edge-on apertures whose +/// intersection numerics wobble by more than CellView's 1e-3 dedup grid → +/// every lap keys as a NEW polygon → 128-deep in-place recursion. The prior +/// convergence sweeps (CornerFloodReplayTests) could not reproduce because +/// they only load the corner building 0x016F-0x0175 — both firing pairs are +/// OUTSIDE that set. +/// +/// The fix class is the handoff's own prediction ("dedup admitting +/// near-duplicates per lap"): a round-trip re-emission is, in exact math, a +/// SUBSET of the region that originated it — CellView.Add must reject +/// contained polygons, which makes union growth strictly area-increasing and +/// the flood convergent regardless of clip-numerics drift. +/// +public class Issue120ReciprocalPingPongTests +{ + private readonly ITestOutputHelper _out; + public Issue120ReciprocalPingPongTests(ITestOutputHelper output) => _out = output; + + private static Dictionary LoadAllInteriorCells(DatCollection dats, uint landblock) + { + var lbi = dats.Get(landblock | 0xFFFEu); + Assert.NotNull(lbi); + var cells = new Dictionary(); + for (uint low = 0x0100u; low < 0x0100u + lbi!.NumCells; low++) + { + uint id = landblock | low; + try { cells[id] = CornerFloodReplayTests.LoadCell(dats, id); } + catch (InvalidOperationException) { /* sparse cell ids — skip */ } + } + return cells; + } + + private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt) + { + var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f); + return view * proj; + } + + public static readonly TheoryData CapturedSites = new() + { + // landblock, cellA (root at firing), cellB, eye x, y, z — straight from the chain dumps + { 0xA9B40000u, 0x015Cu, 0x0162u, 109.995f, 37.158f, 96.249f }, + { 0xA9B30000u, 0x010Fu, 0x0103u, 175.771f, -107.310f, 118.814f }, + }; + + /// + /// The captured firing sites, swept over orientations (the dump has no view + /// matrix) and over both cells as root. Invariants: the tripwire stays 0 + /// AND no CellView accumulates a pathological polygon pile (the duplicate + /// build-up is the defect even below the depth-128 failsafe). + /// + [Theory] + [MemberData(nameof(CapturedSites))] + public void CapturedPingPongSites_Converge(uint landblock, uint lowA, uint lowB, float ex, float ey, float ez) + { + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cells = LoadAllInteriorCells(dats, landblock); + Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; + + Assert.True(cells.ContainsKey(landblock | lowA), $"cell 0x{landblock | lowA:X8} not loaded"); + Assert.True(cells.ContainsKey(landblock | lowB), $"cell 0x{landblock | lowB:X8} not loaded"); + + var eye = new Vector3(ex, ey, ez); + var roots = new[] { cells[landblock | lowA], cells[landblock | lowB] }; + + PortalVisibilityBuilder.ConvergenceTripwireCount = 0; + var failures = new List(); + int builds = 0, maxPolys = 0; + + foreach (var root in roots) + { + for (int yaw = 0; yaw < 8; yaw++) + { + float a = yaw * MathF.PI / 4f; + foreach (float pitch in new[] { -0.4f, 0f, 0.4f }) + { + var dir = Vector3.Normalize(new Vector3(MathF.Cos(a), MathF.Sin(a), pitch)); + int before = PortalVisibilityBuilder.ConvergenceTripwireCount; + var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, eye + dir * 3f)); + builds++; + int after = PortalVisibilityBuilder.ConvergenceTripwireCount; + + int polys = frame.CellViews.Count == 0 ? 0 : frame.CellViews.Values.Max(v => v.Polygons.Count); + if (polys > maxPolys) maxPolys = polys; + + if (after != before || polys > 32) + failures.Add(FormattableString.Invariant( + $"root=0x{root.CellId:X8} yaw={yaw} pitch={pitch} tripwire={after - before} maxCellPolys={polys}")); + } + } + } + + _out.WriteLine($"builds={builds} maxCellPolys={maxPolys} failures={failures.Count}"); + foreach (var f in failures) _out.WriteLine($" {f}"); + Assert.True(failures.Count == 0, + $"{failures.Count}/{builds} builds broke convergence at the captured #120 site (see output)"); + } + + /// + /// The geometric trigger, driven directly: eye swept through the + /// both-sides-pass window (±PortalSideEpsilon = ±1 cm) of the reciprocal + /// portal plane between the two captured cells, looking across and along + /// the aperture. Same convergence invariants. + /// + [Theory] + [MemberData(nameof(CapturedSites))] + public void PortalPlaneWindow_BothSidesPass_Converges(uint landblock, uint lowA, uint lowB, float ex, float ey, float ez) + { + _ = ex; _ = ey; _ = ez; // geometric variant derives its own eyes + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cells = LoadAllInteriorCells(dats, landblock); + Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; + + var cellA = cells[landblock | lowA]; + uint cellBId = landblock | lowB; + + // the portal from A to B (the ping-pong pair) + int portalIdx = -1; + for (int i = 0; i < cellA.Portals.Count; i++) + if (cellA.Portals[i].OtherCellId == (ushort)lowB) { portalIdx = i; break; } + Assert.True(portalIdx >= 0, $"no portal 0x{lowA:X4}->0x{lowB:X4}"); + var poly = cellA.PortalPolygons[portalIdx]; + Assert.True(poly is { Length: >= 3 }, "portal polygon degenerate"); + + var centroidLocal = Vector3.Zero; + foreach (var v in poly!) centroidLocal += v; + centroidLocal /= poly.Length; + var centroidWorld = Vector3.Transform(centroidLocal, cellA.WorldTransform); + var plane = cellA.ClipPlanes[portalIdx]; + var normalWorld = Vector3.Normalize(Vector3.TransformNormal(plane.Normal, cellA.WorldTransform)); + + PortalVisibilityBuilder.ConvergenceTripwireCount = 0; + var failures = new List(); + int builds = 0, maxPolys = 0; + + foreach (float off in new[] { -0.009f, -0.004f, 0f, 0.004f, 0.009f }) + { + var eye = centroidWorld + normalWorld * off; + foreach (var root in new[] { cellA, cells[cellBId] }) + { + for (int yaw = 0; yaw < 8; yaw++) + { + float a = yaw * MathF.PI / 4f; + var dir = Vector3.Normalize(new Vector3(MathF.Cos(a), MathF.Sin(a), 0.1f)); + int before = PortalVisibilityBuilder.ConvergenceTripwireCount; + var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, eye + dir * 3f)); + builds++; + int after = PortalVisibilityBuilder.ConvergenceTripwireCount; + + int polys = frame.CellViews.Count == 0 ? 0 : frame.CellViews.Values.Max(v => v.Polygons.Count); + if (polys > maxPolys) maxPolys = polys; + + if (after != before || polys > 32) + failures.Add(FormattableString.Invariant( + $"off={off:F3} root=0x{root.CellId:X8} yaw={yaw} tripwire={after - before} maxCellPolys={polys}")); + } + } + } + + _out.WriteLine($"builds={builds} maxCellPolys={maxPolys} failures={failures.Count}"); + foreach (var f in failures) _out.WriteLine($" {f}"); + Assert.True(failures.Count == 0, + $"{failures.Count}/{builds} builds broke convergence in the ±ε portal-plane window (see output)"); + } +}