From 2d150842437cb82d01fbd47fe2d071151b8dec55 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 11 Jun 2026 15:57:25 +0200 Subject: [PATCH] #120: arm the propagation tripwire for self-attribution + two convergence regression pins Investigation: retail's growth propagation RECURSES natively too (AddViewToPortals -> FixCellList -> AdjustCellView -> AddViewToPortals, Ghidra 0x005a52d0/0x005a5250/0x005a5770, no depth guard) - the in-place recursion shape is faithful; retail's safety is fast convergence. Our depth-128 firing means slow/non-saturating growth (each lap of a portal cycle nests one recursion level), not necessarily a true infinite loop. Two dat-backed sweeps over the corner-building cell set could NOT reproduce the T5 firing: - PortalPlaneCrossings_InPlacePropagationConverges: +/-6cm eye sweep across every portal plane, seeded from both sides. - InCellDirectionSweep_InPlacePropagationConverges: 3024 builds, in-cell eye grid x 8 yaw x 3 pitch (the walking-and-turning regime). Both pass with 0 firings -> production-only ingredients suspected (full lookup graph - one T5 firing was 0x0162, another building - and/or the real camera path). Armed: PortalVisibilityBuilder.ConvergenceTripwireCount (test observable, both Build + look-in sites) + DumpPropagationChain - on the next firing the log carries root cell, eye, per-cell frequency summary, and the 24-entry chain tail, so the cycle's structure (A<->B ping-pong vs 3-cycle laps) reads directly off the output. Both sweeps stay as regression pins. App tests: 227 green (was 225; +2 pins). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 23 ++- .../Rendering/PortalVisibilityBuilder.cs | 50 +++++++ .../Rendering/CornerFloodReplayTests.cs | 136 ++++++++++++++++++ 3 files changed, 206 insertions(+), 3 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9d668ad2..e22f39cb 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -3992,9 +3992,26 @@ cells the user was walking). T2's in-place growth (which replaced the `MaxReprocessPerCell=16` cap) re-propagated one cell's view 128 times within a single build — a re-emission cycle the dedup misses, or growth ping-ponging through a reciprocal portal pair. May be load-bearing for -#117/#118 (runaway view growth → wrong clip/punch volumes). Investigate -FIRST among the post-T5 set: loudest signal, cheapest repro (the tripwire -self-reports), and a correctness invariant of the new code. +#117/#118 (runaway view growth → wrong clip/punch volumes). + +**Investigation (2026-06-11, post-T5):** retail RECURSES natively too +(`AddViewToPortals → FixCellList → AdjustCellView → AddViewToPortals`, +Ghidra 0x005a52d0/0x005a5250/0x005a5770 — no depth guard), so the +recursion shape is faithful and retail's safety is FAST CONVERGENCE; our +depth-128 means slow/non-saturation our dedup admits (each lap of a +portal cycle nests one level deeper). Two dat-backed harness sweeps over +the full corner-building cell set could NOT reproduce +(`CornerFloodReplayTests.PortalPlaneCrossings_InPlacePropagationConverges` +— ±6 cm across every portal plane, both seed sides — and +`InCellDirectionSweep_InPlacePropagationConverges` — 3024 builds, in-cell +eye grid × 8 yaw × 3 pitch): firings = 0. Production-only ingredients +suspected: the full lookup graph (production reaches far more cells; one +T5 firing was 0x0162, a different building) and/or the real camera path. +**Tripwire armed for self-attribution** (`DumpPropagationChain`): the next +firing logs the root cell, eye, per-cell frequency, and the chain tail — +the cycle's structure reads directly off the log. Both sweeps stay as +regression pins (`PortalVisibilityBuilder.ConvergenceTripwireCount`). +Revisit on the next firing (the #117/#118 re-gate launch will carry it). --- diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index f554e528..8b188e34 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -61,6 +61,42 @@ public static class PortalVisibilityBuilder Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1"; private static readonly Dictionary s_pvDumpCount = new(); + /// + /// #120 observable: total convergence-tripwire firings across both the + /// interior and the exterior look-in propagation. + /// The tripwire firing means the in-place growth's fixpoint invariant + /// broke (T2/BR-4) — tests reset this and assert it stays 0. + /// + public static int ConvergenceTripwireCount; + + /// + /// #120 self-attribution dump: the growth-recursion path that exceeded + /// the tripwire, as a per-cell frequency summary plus the chain tail — + /// the cycle's structure (e.g. 0174↔0175 ping-pong vs a 3-cycle lap) + /// reads directly off the output. + /// + private static void DumpPropagationChain(uint[] chain, int depth, uint rootCellId, Vector3 eye) + { + int n = Math.Min(depth, chain.Length); + var freq = new Dictionary(); + for (int i = 0; i < n; i++) + { + freq.TryGetValue(chain[i], out int c); + freq[chain[i]] = c + 1; + } + var summary = new System.Text.StringBuilder(256); + foreach (var kvp in freq) + summary.Append(System.FormattableString.Invariant($" 0x{kvp.Key:X8}x{kvp.Value}")); + + var tail = new System.Text.StringBuilder(256); + for (int i = Math.Max(0, n - 24); i < n; i++) + tail.Append(System.FormattableString.Invariant($" 0x{chain[i] & 0xFFFFu:X4}")); + + Console.WriteLine(System.FormattableString.Invariant( + $"[pv-ERROR] chain root=0x{rootCellId:X8} eye=({eye.X:F3},{eye.Y:F3},{eye.Z:F3}) cells:{summary}")); + Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}"); + } + /// Resolve a full cell id to its LoadedCell, or null if not loaded. /// Optional: true if a cell id is in the camera building's cell /// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of @@ -167,14 +203,24 @@ public static class PortalVisibilityBuilder // is a loud failsafe, not control flow: it firing means the convergence // invariant broke and must be fixed, not tuned. const int RecursionTripwire = 128; + // #120 self-attribution: the recursion path (cell id per depth), so a + // tripwire firing names the growth CYCLE instead of just the tip. + // Harness sweeps (CornerFloodReplayTests *Converges tests) could not + // reproduce the T5 firing — production-only ingredients (full lookup + // graph / real camera path) are suspected; this dump pins them on the + // next natural occurrence. + var propagationChain = new uint[RecursionTripwire]; void ProcessCellPortals(LoadedCell cell, int depth) { if (depth >= RecursionTripwire) { + System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount); Console.WriteLine($"[pv-ERROR] in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate"); + DumpPropagationChain(propagationChain, depth, cameraCell.CellId, cameraPos); return; } + propagationChain[depth] = cell.CellId; if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty) { trace?.Add($"proc cell=0x{cell.CellId:X8} skip=no-view"); @@ -496,14 +542,18 @@ public static class PortalVisibilityBuilder // re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues // are deleted (empty clip culls, period). const int RecursionTripwire = 128; + var propagationChain = new uint[RecursionTripwire]; // #120 self-attribution — see Build() void ProcessCellPortals(LoadedCell cell, int depth) { if (depth >= RecursionTripwire) { + System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount); Console.WriteLine($"[pv-ERROR] look-in in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate"); + DumpPropagationChain(propagationChain, depth, 0u, cameraPos); return; } + propagationChain[depth] = cell.CellId; if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty) return; diff --git a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs index 10c09cb1..b95a47d1 100644 --- a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs @@ -306,6 +306,142 @@ public class CornerFloodReplayTests + string.Join("\n ", failures)); } + /// + /// #120 repro (T5 gate, 2026-06-11): the T5 launch log fired + /// `[pv-ERROR] in-place propagation tripwire at depth 128` on cottage + /// interior cells 0xA9B40175/0174 (+0162, a different building) while + /// the user walked the doorways — i.e. the eye-on-portal-plane regime. + /// Sweep the eye ACROSS every portal plane of every cell in this + /// building (±6 cm in 5 mm steps, looking through the opening), seeding + /// from BOTH sides' cells. + /// The in-place growth's fixpoint invariant must hold at every step — + /// the tripwire count stays 0. + /// + [Fact] + public void PortalPlaneCrossings_InPlacePropagationConverges() + { + 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; + + PortalVisibilityBuilder.ConvergenceTripwireCount = 0; + var firings = new List(); + + foreach (var cell in cells.Values) + { + for (int i = 0; i < cell.Portals.Count && i < cell.PortalPolygons.Count; i++) + { + var poly = cell.PortalPolygons[i]; + if (poly == null || poly.Length < 3) continue; + if (i >= cell.ClipPlanes.Count) continue; + var plane = cell.ClipPlanes[i]; + if (plane.Normal.LengthSquared() < 1e-6f) continue; + + // Portal centroid + plane normal in WORLD space. + var centroidLocal = Vector3.Zero; + foreach (var v in poly) centroidLocal += v; + centroidLocal /= poly.Length; + var centroidWorld = Vector3.Transform(centroidLocal, cell.WorldTransform); + var normalWorld = Vector3.Normalize( + Vector3.TransformNormal(plane.Normal, cell.WorldTransform)); + + uint neighbourId = cell.Portals[i].OtherCellId == 0xFFFF + ? 0u + : (Landblock | cell.Portals[i].OtherCellId); + var roots = new List { cell }; + if (neighbourId != 0u && cells.TryGetValue(neighbourId, out var nb)) + roots.Add(nb); + + for (int step = -12; step <= 12; step++) + { + var eye = centroidWorld + normalWorld * (step * 0.005f); + // Look through the opening (along -normal), slightly down + // — the portal fills the view, maximizing flood activity. + var lookAt = centroidWorld - normalWorld * 2f + new Vector3(0f, 0f, -0.2f); + + foreach (var root in roots) + { + int before = PortalVisibilityBuilder.ConvergenceTripwireCount; + PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, lookAt)); + int after = PortalVisibilityBuilder.ConvergenceTripwireCount; + if (after != before) + firings.Add(System.FormattableString.Invariant( + $"cell=0x{cell.CellId:X8} portal#{i}->0x{cell.Portals[i].OtherCellId:X4} step={step} root=0x{root.CellId:X8} eye=({eye.X:F4},{eye.Y:F4},{eye.Z:F4})")); + } + } + } + } + + Assert.True(firings.Count == 0, + "#120: in-place propagation convergence tripwire fired during the " + + "portal-plane sweep:\n " + string.Join("\n ", firings)); + } + + /// + /// #120 repro attempt 2: eyes INSIDE each cell's volume (3×3 XY grid × + /// 2 Z levels) with full yaw (8) × pitch (3) direction sweeps — the + /// walking-and-turning regime of the T5 session, including the steep + /// pitches of the cellar stairs. Same invariant: the tripwire count + /// stays 0 across every Build. + /// + [Fact] + public void InCellDirectionSweep_InPlacePropagationConverges() + { + 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; + + PortalVisibilityBuilder.ConvergenceTripwireCount = 0; + var firings = new List(); + int builds = 0; + + foreach (var cell in cells.Values) + { + var lo = cell.LocalBoundsMin; + var hi = cell.LocalBoundsMax; + if (hi.X - lo.X < 0.05f || hi.Y - lo.Y < 0.05f) continue; + + for (int gx = 0; gx < 3; gx++) + for (int gy = 0; gy < 3; gy++) + for (int gz = 0; gz < 2; gz++) + { + var local = new Vector3( + lo.X + (hi.X - lo.X) * (0.2f + 0.3f * gx), + lo.Y + (hi.Y - lo.Y) * (0.2f + 0.3f * gy), + lo.Z + (hi.Z - lo.Z) * (gz == 0 ? 0.3f : 0.8f)); + var eye = Vector3.Transform(local, cell.WorldTransform); + + for (int yaw = 0; yaw < 8; yaw++) + for (int pitch = -1; pitch <= 1; pitch++) + { + float a = yaw * MathF.PI / 4f; + var dir = new Vector3( + MathF.Cos(a), MathF.Sin(a), pitch * 0.9f); + var lookAt = eye + Vector3.Normalize(dir) * 3f; + + int before = PortalVisibilityBuilder.ConvergenceTripwireCount; + PortalVisibilityBuilder.Build(cell, eye, lookup, ViewProjFor(eye, lookAt)); + builds++; + int after = PortalVisibilityBuilder.ConvergenceTripwireCount; + if (after != before) + firings.Add(System.FormattableString.Invariant( + $"cell=0x{cell.CellId:X8} eye=({eye.X:F3},{eye.Y:F3},{eye.Z:F3}) yaw={yaw} pitch={pitch}")); + } + } + } + + _out.WriteLine($"builds={builds} firings={firings.Count}"); + Assert.True(firings.Count == 0, + "#120: convergence tripwire fired during the in-cell direction sweep:\n " + + string.Join("\n ", firings)); + } + /// /// Diagnostic: microscope on the failing hop. Replays the two portal hops /// (0171→0173, then 0173→0172) through the PUBLIC PortalProjection APIs for the