From 864e4d9e75aa650dfa2f5b7c2d82691340f6e320 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 6 Jul 2026 21:04:34 +0200 Subject: [PATCH] diag #181: root-cause chain complete - flapping cell 0x0181, wall-press excitation, and the REAL defect: camera-flood-scoped light application Issue181VisFlapReplayTests: headless flood replay at the live parked pose names cell 0x8A020181 - +-0.5mm eye perturbations flip its admission (at yaw15/pitch-20 for every direction). Issue181CameraParkStabilityTests: the camera loop parks BIT-EXACT with static inputs (0.00um over 2000 frames) - the live ~1mm/frame wander is the wall-press equilibrium (sought steps a*gap ~4mm into the wall per frame, the clip lands within adjust_to_plane's parametric 0.02 window; player physics bit-frozen per [resolve]). That wobble is retail-class. The defect is OURS: the A7 adaptation scopes light application to the camera flood's per-frame admission, so a sliver cell flapping flips whole lit regions; retail computes light reach once at registration (Render::add_static_light -> CObjCell::add_lights), camera-independent. Fix direction: registration-time per-light reach sets via the portal graph (pseudocode docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md). ISSUES #181 updated with the full chain. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 35 ++++-- .../Issue181CameraParkStabilityTests.cs | 100 +++++++++++++++++ .../Rendering/Issue181VisFlapReplayTests.cs | 105 ++++++++++++++++++ 3 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/Issue181CameraParkStabilityTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 8d401aba..1cb02752 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -73,14 +73,35 @@ WHILE the #180 camera strobe was live, so none of them cleanly cleared the rende — and CLIP_DEBUG=1 forced gl_ClipDistance slots only; the AABB-scissor degrade sibling may never have been disabled. -**Next step:** replay the flood headlessly (PortalVisibilityBuilder + LoadEnvCell -fixtures) at eye=(49.15,−38.62,−3.98) ± a micron/mm grid; identify the flapping cell -and the exact threshold (#129/#130 NDC floor-ceil class, or AP-19's PortalSideEpsilon); -fix the root — either the threshold's correctness or the eye-noise divergence -(retail-faithful candidates only, no hysteresis band-aids). +**ROOT CAUSE CHAIN (completed 2026-07-06 night, all headless/live-probed):** +1. The flapping cell is **0x8A020181** (`Issue181VisFlapReplayTests`: ±0.5 mm eye + perturbation flips its admission across several gazes — at yaw 15/pitch −20 it flips + for EVERY perturbation direction). +2. The excitation is the WALL-PRESS wobble, not µm noise: at the parked spot the camera + is pressed into a wall (`[resolve]` hit=yes n=(0,−1,0) every frame; the player physics + position is BIT-FROZEN — in=tgt=out). The sought steps α·gap ≈ 4 mm into the wall per + frame and the sweep clips it back with `adjust_to_plane`'s parametric 0.02 termination + window (retail's own constant) → the published eye slides ~1 mm/frame along the wall. + `Issue181CameraParkStabilityTests`: with static inputs and no wall the camera parks + BIT-EXACT — the loop is healthy; a wall-press wobble of this scale is retail-class. +3. **The AMPLIFIER is ours and is the actual defect: the A7 adaptation scopes light + APPLICATION to the camera flood's per-frame admission** (light registration + `cellId:` scoping). Retail computes a light's reach through the portal graph ONCE at + registration (`Render::add_static_light` → `CObjCell::add_lights`), camera- + independent — a sliver cell flapping costs retail a few pixels; it costs us whole lit + regions (the washed-region strobe). -**Acceptance:** parked camera in the washed spot → vis count constant across 10k+ -frames; frame-pair pixel diffs show no region-shaped changes; the user sees no flicker. +**The fix:** replace camera-flood light scoping with retail's registration-time light +reach sets (per-light cell-set walk from the light's own cell through the portal graph; +decomp pseudocode already on disk: +`docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md`). The wall-press wobble +and the binary knife-edge admission then match retail's visible behavior (sub-pixel +consequences). No hysteresis/damping band-aids on the flood. + +**Acceptance:** parked pressed camera in the washed spot → lit regions steady (the +`[flap]` vis 31↔32 flap may legitimately persist at sub-pixel visual cost); frame-pair +pixel diffs show no region-shaped changes; the user sees no flicker; #176 re-gate +(steady purple wedge) passes. --- diff --git a/tests/AcDream.App.Tests/Rendering/Issue181CameraParkStabilityTests.cs b/tests/AcDream.App.Tests/Rendering/Issue181CameraParkStabilityTests.cs new file mode 100644 index 00000000..3e2950db --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue181CameraParkStabilityTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.Rendering; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #181 excitation probe — the live parked camera wanders ~0.9 mm/frame +/// (launch-176-leakfix.log [flap-sweep]: 19,889 distinct sought values in 20k +/// parked sweeps), which keeps the portal flood's knife-edge admissions +/// flapping. Retail's parked viewer is a bit-exact fixed point (UpdateCamera +/// dead-band `return viewer`). +/// +/// This test drives RetailChaseCamera.Update with BIT-IDENTICAL inputs at the +/// live frame rate: if the camera parks bit-stable here, the live wobble comes +/// from its INPUTS (player position/yaw jitter out of GameWindow); if it +/// wobbles here, the camera loop itself fails to reach the fixed point. +/// +public class Issue181CameraParkStabilityTests +{ + private readonly ITestOutputHelper _out; + public Issue181CameraParkStabilityTests(ITestOutputHelper output) => _out = output; + + private sealed class PassthroughProbe : ICameraCollisionProbe + { + public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos) + => new(desiredEye, cellId); + } + + [Fact] + public void ParkedCamera_StaticInputs_ReachesBitStableFixedPoint() + { + bool savedAlign = CameraDiagnostics.AlignToSlope; + bool savedColl = CameraDiagnostics.CollideCamera; + float savedT = CameraDiagnostics.TranslationStiffness; + float savedR = CameraDiagnostics.RotationStiffness; + try + { + CameraDiagnostics.AlignToSlope = true; // production default + CameraDiagnostics.CollideCamera = true; + CameraDiagnostics.TranslationStiffness = 0.45f; + CameraDiagnostics.RotationStiffness = 0.45f; + + var cam = new RetailChaseCamera { CollisionProbe = new PassthroughProbe() }; + + // Live-like parked pose: static player, static yaw, zero velocity, + // grounded on a flat contact plane, ~1500 fps. + var playerPos = new Vector3(49.5f, -39.9f, -5.9f); + float yaw = 1.83f; + float dt = 1f / 1500f; + + void Step() => cam.Update( + playerPosition: playerPos, + playerYaw: yaw, + playerVelocity: Vector3.Zero, + isOnGround: true, + contactPlaneNormal: Vector3.UnitZ, + dt: dt, + cellId: 0x8A020142u, + selfEntityId: 0x5); + + // Converge: at α≈0.003/frame the boom needs a few thousand frames + // to settle from the init pose into the dead-band. + for (int i = 0; i < 20000; i++) Step(); + + Vector3 a = cam.Position; + var fwdA = cam.View; // full view matrix — includes the forward half + + // 2000 further frames with bit-identical inputs: every one must be + // the exact fixed point (retail parks verbatim). + float maxDelta = 0f; + Vector3 prev = a; + bool viewChanged = false; + for (int i = 0; i < 2000; i++) + { + Step(); + maxDelta = MathF.Max(maxDelta, Vector3.Distance(cam.Position, prev)); + prev = cam.Position; + if (cam.View != fwdA) viewChanged = true; + } + + _out.WriteLine(FormattableString.Invariant( + $"post-convergence maxConsecDelta={maxDelta * 1e6f:F2}um viewChanged={viewChanged} pos=({cam.Position.X:F7},{cam.Position.Y:F7},{cam.Position.Z:F7})")); + + Assert.True(maxDelta == 0f, + $"parked camera must be a bit-exact fixed point, wandered up to {maxDelta * 1e6f:F1}um/frame"); + Assert.False(viewChanged, "view matrix must be frozen at park"); + } + finally + { + CameraDiagnostics.AlignToSlope = savedAlign; + CameraDiagnostics.CollideCamera = savedColl; + CameraDiagnostics.TranslationStiffness = savedT; + CameraDiagnostics.RotationStiffness = savedR; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs new file mode 100644 index 00000000..a035bed0 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs @@ -0,0 +1,105 @@ +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; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #181 — the portal-flood admitted set flaps 31↔32 on MICRON eye noise at a +/// parked camera (live: launch-176-leakfix.log, root 0x8A020142, vis flips +/// every ~100–200 frames for 517k frames; the swept eye carries ~7 µm +/// float-roundtrip noise). The flapping cell's visibility-scoped lights + the +/// union-AABB scissor rect strobe = the #176 washed-region flicker. +/// +/// This replay hunts the knife edge headlessly: at the live parked eye +/// (49.15, −38.62, −3.98), sweep gazes and perturb the eye by ±0.5 mm per +/// axis; ANY admitted-set difference at sub-mm perturbation is the #181 +/// instability, and the symmetric difference names the flapping cell. +/// +public class Issue181VisFlapReplayTests +{ + private const uint FacilityHub = 0x8A020000u; + private const uint LiveRoot = FacilityHub | 0x0142u; + + private static readonly Vector3 LiveEye = new(49.15f, -38.62f, -3.98f); + + private readonly ITestOutputHelper _out; + public Issue181VisFlapReplayTests(ITestOutputHelper output) => _out = output; + + private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 gazeDir) + { + var view = Matrix4x4.CreateLookAt(eye, eye + gazeDir, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 5000f); + return view * proj; + } + + private static List Flood( + Dictionary cells, uint rootId, Vector3 eye, Vector3 gazeDir) + { + Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; + var frame = PortalVisibilityBuilder.Build( + cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir), + buildingMembership: null, + drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); + var result = new List(frame.OrderedVisibleCells); + result.Sort(); + return result; + } + + private static string CellSetString(IEnumerable ids) + => string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}")); + + [Fact] + public void Diagnostic_SubMillimeterEyePerturbation_MustNotChangeAdmission() + { + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub); + Assert.True(cells.ContainsKey(LiveRoot), "live root 0x0142 not loaded"); + + // ±0.5 mm per axis — an order of magnitude above the live µm noise, well + // below anything visually meaningful. + var perturbations = new[] + { + new Vector3( 0.0005f, 0, 0), new Vector3(-0.0005f, 0, 0), + new Vector3(0, 0.0005f, 0), new Vector3(0, -0.0005f, 0), + new Vector3(0, 0, 0.0005f), new Vector3(0, 0, -0.0005f), + }; + + int unstableGazes = 0; + for (int yawDeg = 0; yawDeg < 360; yawDeg += 15) + { + foreach (float pitchDeg in new[] { 0f, -20f, -35f }) + { + float yaw = yawDeg * MathF.PI / 180f, pitch = pitchDeg * MathF.PI / 180f; + var gaze = new Vector3( + MathF.Cos(pitch) * MathF.Cos(yaw), + MathF.Cos(pitch) * MathF.Sin(yaw), + MathF.Sin(pitch)); + + var baseline = Flood(cells, LiveRoot, LiveEye, gaze); + foreach (var d in perturbations) + { + var perturbed = Flood(cells, LiveRoot, LiveEye + d, gaze); + if (perturbed.SequenceEqual(baseline)) continue; + + unstableGazes++; + var removed = baseline.Except(perturbed).ToList(); + var added = perturbed.Except(baseline).ToList(); + _out.WriteLine(FormattableString.Invariant( + $"UNSTABLE yaw={yawDeg} pitch={pitchDeg} d=({d.X * 1000:F1},{d.Y * 1000:F1},{d.Z * 1000:F1})mm base={baseline.Count} pert={perturbed.Count} removed=[{CellSetString(removed)}] added=[{CellSetString(added)}]")); + } + } + } + + _out.WriteLine($"unstable (gaze, perturbation) pairs: {unstableGazes}"); + // Diagnostic first: report, don't assert — the fix turns this into a hard pin. + } +}