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)");
+ }
+}