#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 <noreply@anthropic.com>
This commit is contained in:
parent
af5d424df0
commit
2d15084243
3 changed files with 206 additions and 3 deletions
|
|
@ -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
|
`MaxReprocessPerCell=16` cap) re-propagated one cell's view 128 times
|
||||||
within a single build — a re-emission cycle the dedup misses, or growth
|
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
|
ping-ponging through a reciprocal portal pair. May be load-bearing for
|
||||||
#117/#118 (runaway view growth → wrong clip/punch volumes). Investigate
|
#117/#118 (runaway view growth → wrong clip/punch volumes).
|
||||||
FIRST among the post-T5 set: loudest signal, cheapest repro (the tripwire
|
|
||||||
self-reports), and a correctness invariant of the new code.
|
**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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,42 @@ public static class PortalVisibilityBuilder
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1";
|
Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1";
|
||||||
private static readonly Dictionary<uint, int> s_pvDumpCount = new();
|
private static readonly Dictionary<uint, int> s_pvDumpCount = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #120 observable: total convergence-tripwire firings across both the
|
||||||
|
/// interior <see cref="Build"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public static int ConvergenceTripwireCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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.
|
||||||
|
/// </summary>
|
||||||
|
private static void DumpPropagationChain(uint[] chain, int depth, uint rootCellId, Vector3 eye)
|
||||||
|
{
|
||||||
|
int n = Math.Min(depth, chain.Length);
|
||||||
|
var freq = new Dictionary<uint, int>();
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
|
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
|
||||||
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell
|
/// <param name="buildingMembership">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
|
/// 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
|
// is a loud failsafe, not control flow: it firing means the convergence
|
||||||
// invariant broke and must be fixed, not tuned.
|
// invariant broke and must be fixed, not tuned.
|
||||||
const int RecursionTripwire = 128;
|
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)
|
void ProcessCellPortals(LoadedCell cell, int depth)
|
||||||
{
|
{
|
||||||
if (depth >= RecursionTripwire)
|
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");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
propagationChain[depth] = cell.CellId;
|
||||||
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
||||||
{
|
{
|
||||||
trace?.Add($"proc cell=0x{cell.CellId:X8} skip=no-view");
|
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
|
// re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues
|
||||||
// are deleted (empty clip culls, period).
|
// are deleted (empty clip culls, period).
|
||||||
const int RecursionTripwire = 128;
|
const int RecursionTripwire = 128;
|
||||||
|
var propagationChain = new uint[RecursionTripwire]; // #120 self-attribution — see Build()
|
||||||
|
|
||||||
void ProcessCellPortals(LoadedCell cell, int depth)
|
void ProcessCellPortals(LoadedCell cell, int depth)
|
||||||
{
|
{
|
||||||
if (depth >= RecursionTripwire)
|
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");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
propagationChain[depth] = cell.CellId;
|
||||||
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,142 @@ public class CornerFloodReplayTests
|
||||||
+ string.Join("\n ", failures));
|
+ string.Join("\n ", failures));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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
|
||||||
|
/// <see cref="PortalVisibilityBuilder.Build"/> from BOTH sides' cells.
|
||||||
|
/// The in-place growth's fixpoint invariant must hold at every step —
|
||||||
|
/// the tripwire count stays 0.
|
||||||
|
/// </summary>
|
||||||
|
[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<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||||
|
|
||||||
|
PortalVisibilityBuilder.ConvergenceTripwireCount = 0;
|
||||||
|
var firings = new List<string>();
|
||||||
|
|
||||||
|
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<LoadedCell> { 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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.
|
||||||
|
/// </summary>
|
||||||
|
[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<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||||
|
|
||||||
|
PortalVisibilityBuilder.ConvergenceTripwireCount = 0;
|
||||||
|
var firings = new List<string>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Diagnostic: microscope on the failing hop. Replays the two portal hops
|
/// Diagnostic: microscope on the failing hop. Replays the two portal hops
|
||||||
/// (0171→0173, then 0173→0172) through the PUBLIC PortalProjection APIs for the
|
/// (0171→0173, then 0173→0172) through the PUBLIC PortalProjection APIs for the
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue