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 <noreply@anthropic.com>
This commit is contained in:
parent
d9a4ca2355
commit
864e4d9e75
3 changed files with 233 additions and 7 deletions
|
|
@ -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
|
— and CLIP_DEBUG=1 forced gl_ClipDistance slots only; the AABB-scissor degrade sibling
|
||||||
may never have been disabled.
|
may never have been disabled.
|
||||||
|
|
||||||
**Next step:** replay the flood headlessly (PortalVisibilityBuilder + LoadEnvCell
|
**ROOT CAUSE CHAIN (completed 2026-07-06 night, all headless/live-probed):**
|
||||||
fixtures) at eye=(49.15,−38.62,−3.98) ± a micron/mm grid; identify the flapping cell
|
1. The flapping cell is **0x8A020181** (`Issue181VisFlapReplayTests`: ±0.5 mm eye
|
||||||
and the exact threshold (#129/#130 NDC floor-ceil class, or AP-19's PortalSideEpsilon);
|
perturbation flips its admission across several gazes — at yaw 15/pitch −20 it flips
|
||||||
fix the root — either the threshold's correctness or the eye-noise divergence
|
for EVERY perturbation direction).
|
||||||
(retail-faithful candidates only, no hysteresis band-aids).
|
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+
|
**The fix:** replace camera-flood light scoping with retail's registration-time light
|
||||||
frames; frame-pair pixel diffs show no region-shaped changes; the user sees no flicker.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs
Normal file
105
tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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.
|
||||||
|
/// </summary>
|
||||||
|
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<uint> Flood(
|
||||||
|
Dictionary<uint, LoadedCell> cells, uint rootId, Vector3 eye, Vector3 gazeDir)
|
||||||
|
{
|
||||||
|
Func<uint, LoadedCell?> 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<uint>(frame.OrderedVisibleCells);
|
||||||
|
result.Sort();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CellSetString(IEnumerable<uint> 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue