diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0683b4ae..15684e22 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4460,8 +4460,21 @@ not raw terrain. Note the snap line even shows a candidate it rejected ## #127 — Per-building flood admissions are BISTABLE per frame under the outdoor root (the building-flap mechanism) -**Status:** OPEN — HIGH (the live mechanism behind the tower roof/edge -flap; almost certainly #123 and related flap reports) +**Status:** CLOSED 2026-06-12 — user re-gate ("Seems to have been +fixed" — ran past distant buildings, no flicker/vanish) + desk +confirmation. The bistable-admission mechanism died with the **W=0 +polyClipFinish clip port** (`987313a`, the #119/#120 work that +"kills the knife-edge class everywhere") plus the #120 containment- +rejection growth fix. NOTE the captured-pair evidence in +`tower-viewer-capture.log` predates all of those fixes — it was the +near-eye knife edge, the same class. Pins (both green at HEAD): +`Issue127FloodFlipReplayTests.CapturedFlipPair_AdmissionIsStable` +(the original 4 cm flip pair now |A|=|B|, zero diff, all FOVs, both +pre-gate states) + `DistantBuildingStrafe_NoAdmissionChurn` (the +regression pin: 0 churn across 21 building groups × {10,30,60,120,190} m +× 100 mm-steps run-past strafe, both pre-gate states). DO-NOT-RETRY: +do not re-open the BuildFromExterior seed gates for flap symptoms +without a FRESH repro at HEAD — the captured-pair lead is dead. **Filed:** 2026-06-11 (tower capture run) **Component:** render — BuildFromExterior seed admission / per-building flood stability diff --git a/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs index 581b5a52..72bd3385 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs @@ -218,4 +218,133 @@ public class Issue127FloodFlipReplayTests Assert.Fail($"flood admission differs across the captured 4 cm pair (preGate={preGate}, fov={fov:F2}) — see output for the flipping cells"); } } + + // Centre of a building group's exit-portal AABB (world space). + private static (bool Has, Vector3 Center) PortalCenterFor(List group) + { + var (has, min, max) = PortalBoundsFor(group); + return (has, (min + max) * 0.5f); + } + + // Per-building admitted cells (this group only) at one (eye, gaze) — the + // production per-building flood + optional PortalBounds frustum pre-gate. + private static HashSet BuildingAdmits( + World w, List group, Vector3 eye, Matrix4x4 viewProj, + FrustumPlanes frustum, bool withPreGate) + { + var result = new HashSet(); + if (withPreGate) + { + var (has, min, max) = PortalBoundsFor(group); + if (has && !FrustumCuller.IsAabbVisible(frustum, min, max)) + return result; + } + var bf = PortalVisibilityBuilder.ConstructViewBuilding(group, eye, w.Lookup, viewProj); + foreach (uint id in bf.OrderedVisibleCells) + result.Add(id); + return result; + } + + /// + /// #127 distant-building churn detector. The captured 4 cm pair is now + /// stable (the near-eye W=0 clip port), but the user symptom is buildings + /// flickering when RUNNING PAST at a distance. This strafes the eye past + /// each loaded building at several distances in 1 mm steps with the gaze + /// fixed forward (the run-past geometry) and counts, per building cell, how + /// many times its admission toggles over the monotone strafe. A stable + /// flood toggles a cell AT MOST ONCE along a monotone eye path (it enters + /// or leaves the view a single time); >=2 toggles is churn — the building + /// flickers. preGate off vs on separates flood-math churn from the + /// PortalBounds frustum pre-gate. + /// + /// RESULT (2026-06-12, HEAD post-W=0-clip-port + #120 containment): ZERO + /// churning cases across all 21 building groups x {10,30,60,120,190} m x + /// 100 mm-steps, both preGate states. The near-eye knife-edge class the + /// W=0 polyClipFinish port (987313a) killed was the distant-building + /// flicker too; the user re-gate ("Seems to have been fixed") agrees. + /// Now the REGRESSION PIN — it asserts zero churn. + /// + [Fact] + public void DistantBuildingStrafe_NoAdmissionChurn() + { + var w = LoadWorld(); + if (w is null) return; + + const float fovY = MathF.PI / 3f; + const float eyeHeight = 1.8f; + const float strafeSpanM = 0.10f; // 10 cm strafe + const int strafeSteps = 100; // 1 mm/step + var distances = new[] { 10f, 30f, 60f, 120f, 190f }; + + int totalChurn = 0; + foreach (bool preGate in new[] { false, true }) + { + int worstToggles = 0; + string worstDesc = "(none)"; + int churningCases = 0; + + for (int gi = 0; gi < w.BuildingGroups.Count; gi++) + { + var group = w.BuildingGroups[gi]; + var (has, center) = PortalCenterFor(group); + if (!has) continue; + + foreach (float dist in distances) + { + // Eye south of the building at eye height, gaze NORTH toward + // the building centre; strafe along world +X (run-past). + var gaze = Vector3.Normalize(new Vector3(0f, 1f, -0.05f)); + var strafeDir = Vector3.Normalize(Vector3.Cross(Vector3.UnitZ, gaze)); // ~world +X + var eyeBase = new Vector3(center.X, center.Y - dist, center.Z + eyeHeight) + - strafeDir * (strafeSpanM * 0.5f); + + var toggleCount = new Dictionary(); + var prevIn = new Dictionary(); + for (int s = 0; s <= strafeSteps; s++) + { + var eye = eyeBase + strafeDir * (strafeSpanM * s / strafeSteps); + var vp = ViewProjFor(eye, gaze, fovY); + var frustum = FrustumPlanes.FromViewProjection(vp); + var admits = BuildingAdmits(w, group, eye, vp, frustum, preGate); + + var seen = new HashSet(admits); + foreach (uint id in seen) + { + bool wasIn = prevIn.TryGetValue(id, out var p) && p; + if (!wasIn && prevIn.ContainsKey(id)) + toggleCount[id] = toggleCount.GetValueOrDefault(id) + 1; + prevIn[id] = true; + } + foreach (var id in new List(prevIn.Keys)) + if (!seen.Contains(id)) + { + if (prevIn[id]) + toggleCount[id] = toggleCount.GetValueOrDefault(id) + 1; + prevIn[id] = false; + } + } + + foreach (var (id, toggles) in toggleCount) + { + if (toggles < 2) continue; // <=1 = clean enter/leave + churningCases++; + if (toggles > worstToggles) + { + worstToggles = toggles; + worstDesc = FormattableString.Invariant( + $"group#{gi} dist={dist:F0}m cell=0x{id:X8} toggles={toggles}"); + } + } + } + } + + _out.WriteLine(FormattableString.Invariant( + $"preGate={preGate}: churningCases={churningCases} worst={worstDesc} (worstToggles={worstToggles})")); + totalChurn += churningCases; + } + + Assert.True(totalChurn == 0, + $"{totalChurn} distant-building admission churn case(s) — a building's cells toggle >=2x " + + "over a monotone run-past strafe (the #127 flicker); see output for the worst building/distance/cell"); + } }