acdream/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs
Erik 4ad6fb9184 close #127 (user-gated + desk pin): distant-building flood flap died with the W=0 clip port
User re-gate 2026-06-12: ran past distant buildings, 'Seems to have
been fixed' - no flicker/vanish. The per-building flood-admission
bistability (#127, the building-flap mechanism behind the tower roof
flap and #123 'buildings vanish when running past') is gone.

Root: the bistable knife-edge admission 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. The captured-pair evidence (tower-viewer-capture.log,
2026-06-11) PRE-dates all of those - it was that same near-eye knife
edge, not a separate distant mechanism.

Desk confirmation (both green at HEAD):
- CapturedFlipPair_AdmissionIsStable: the original 4 cm flip pair is
  now |A|=|B| with zero diff across all FOVs and both pre-gate states.
- DistantBuildingStrafe_NoAdmissionChurn (new regression pin): 0
  admission churn across all 21 building groups x {10,30,60,120,190} m
  x 100 mm-step run-past strafes, both pre-gate states. A stable flood
  toggles each cell at most once over a monotone eye path; this asserts
  no cell toggles >=2x.

ISSUES.md #127 -> CLOSED with the DO-NOT-RETRY note (no re-opening the
BuildFromExterior seed gates for a flap symptom without a fresh HEAD
repro - the captured-pair lead is dead). Render digest banner updated.

Suites: App 264+1skip / Core 1443+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 10:03:33 +02:00

350 lines
16 KiB
C#

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;
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #127 — per-building flood admissions are BISTABLE per frame under the
/// outdoor root (the building-flap mechanism: tower roof/edges flap, #123
/// buildings vanishing when running past). Captured live with the [viewer]
/// fwd= probe (flap-fwd-capture2.log): a ~4 cm eye wobble flips the merged
/// flood 24↔25 with a byte-identical gaze:
/// flood=25 eye=(129.051,-15.636,99.512) fwd=(-0.7565,0.5877,-0.2869)
/// flood=24 eye=(129.009,-15.610,99.503) fwd=(same)
/// flood=25 eye=(129.051,-15.642,99.514) fwd=(same)
/// (player parked at pCell=0xA9B40019, Holtburg town, A9B4-anchored world
/// frame — A9B4-local == world; A9B3 cells get a (0,-192,0) block offset.)
///
/// This harness replays the captured (eye, fwd) pairs through the
/// production outdoor pipeline — Build(outdoorNode) + per-building
/// ConstructViewBuilding merged (RetailPViewRenderer.MergeNearbyBuildingFloods
/// shape) — and DIFFS the admitted cell sets: the flipping cell names the
/// bistable admission, and the bisect along the eye segment names the knife
/// edge.
/// </summary>
public class Issue127FloodFlipReplayTests
{
private readonly ITestOutputHelper _out;
public Issue127FloodFlipReplayTests(ITestOutputHelper output) => _out = output;
// The captured flip pair (A9B4-anchored world frame).
private static readonly Vector3 EyeA = new(129.051f, -15.636f, 99.512f);
private static readonly Vector3 EyeB = new(129.009f, -15.610f, 99.503f);
private static readonly Vector3 Fwd = Vector3.Normalize(new Vector3(-0.7565f, 0.5877f, -0.2869f));
private sealed record World(
Dictionary<uint, LoadedCell> Cells,
List<List<LoadedCell>> BuildingGroups,
Func<uint, LoadedCell?> Lookup);
private static void LoadLandblockBuildings(
DatCollection dats, uint landblock, Vector3 offset,
Dictionary<uint, LoadedCell> cells, List<List<LoadedCell>> groups)
{
var lbi = dats.Get<DatLandBlockInfo>(landblock | 0xFFFEu);
if (lbi is null) return;
// all interior cells (the lookup needs every portal-reachable cell)
for (uint low = 0x0100u; low < 0x0100u + lbi.NumCells; low++)
{
uint id = landblock | low;
try { cells[id] = CornerFloodReplayTests.LoadCell(dats, id, offset); }
catch (InvalidOperationException) { }
}
// per-building groups from the bldPortal stab lists (the dat's own
// building→cells mapping; mirrors BuildingLoader's seed+walk result)
if (lbi.Buildings is null) return;
foreach (var bld in lbi.Buildings)
{
var set = new HashSet<uint>();
if (bld.Portals is not null)
foreach (var bp in bld.Portals)
if (bp.StabList is not null)
foreach (ushort low in bp.StabList)
set.Add(landblock | low);
var group = new List<LoadedCell>();
foreach (uint id in set)
if (cells.TryGetValue(id, out var c))
group.Add(c);
if (group.Count > 0)
groups.Add(group);
}
}
private World? LoadWorld()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = new Dictionary<uint, LoadedCell>();
var groups = new List<List<LoadedCell>>();
// A9B4 = the anchor (world == local); the live gather spans the
// streaming near window, so load a 5x5 block neighbourhood with
// block offsets relative to the anchor.
for (int dx = -2; dx <= 2; dx++)
for (int dy = -2; dy <= 2; dy++)
{
uint lbX = (uint)(0xA9 + dx);
uint lbY = (uint)(0xB4 + dy);
uint lb = (lbX << 24) | (lbY << 16);
LoadLandblockBuildings(dats, lb, new Vector3(dx * 192f, dy * 192f, 0f), cells, groups);
}
_out.WriteLine($"loaded {cells.Count} cells in {groups.Count} building groups");
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
return new World(cells, groups, lookup);
}
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 fwd, float fovY)
{
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, 1280f / 720f, 0.1f, 5000f);
return view * proj;
}
// PortalBounds per group: the union AABB of the group's exit-portal
// polygons in world space — exactly BuildingLoader's construction
// (BuildingLoader.cs:114-144).
private static (bool Has, Vector3 Min, Vector3 Max) PortalBoundsFor(List<LoadedCell> group)
{
bool has = false;
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
foreach (var cell in group)
{
int n = Math.Min(cell.Portals.Count, cell.PortalPolygons.Count);
for (int i = 0; i < n; i++)
{
if (cell.Portals[i].OtherCellId != 0xFFFF) continue;
var poly = cell.PortalPolygons[i];
if (poly is null) continue;
foreach (var v in poly)
{
var world = Vector3.Transform(v, cell.WorldTransform);
has = true;
min = Vector3.Min(min, world);
max = Vector3.Max(max, world);
}
}
}
return (has, min, max);
}
// The production outdoor pipeline: full-screen outdoor root + per-building
// exterior floods merged as a union (RetailPViewRenderer.DrawInside +
// MergeNearbyBuildingFloods + MergeBuildingFrame), INCLUDING the gather's
// per-building frustum pre-gate on PortalBounds (GameWindow:7542-7545) —
// controlled by <paramref name="withPreGate"/> so the gate itself can be
// implicated or exonerated.
private static HashSet<uint> AdmittedCells(World w, Vector3 eye, Vector3 fwd, float fovY, bool withPreGate)
{
var viewProj = ViewProjFor(eye, fwd, fovY);
var frustum = FrustumPlanes.FromViewProjection(viewProj);
var outdoor = OutdoorCellNode.Build(0xA9B40019u);
var pv = PortalVisibilityBuilder.Build(outdoor, eye, w.Lookup, viewProj);
foreach (var group in w.BuildingGroups)
{
if (withPreGate)
{
var (has, min, max) = PortalBoundsFor(group);
if (has && !FrustumCuller.IsAabbVisible(frustum, min, max))
continue;
}
var bf = PortalVisibilityBuilder.ConstructViewBuilding(group, eye, w.Lookup, viewProj);
foreach (uint cellId in bf.OrderedVisibleCells)
{
if (!bf.CellViews.TryGetValue(cellId, out var srcView)) continue;
if (pv.CellViews.TryGetValue(cellId, out var existing))
{
foreach (var p in srcView.Polygons) existing.Add(p);
continue;
}
pv.CellViews[cellId] = srcView;
pv.OrderedVisibleCells.Add(cellId);
}
}
return new HashSet<uint>(pv.OrderedVisibleCells);
}
/// <summary>
/// Reproduce + name: the captured 4 cm pair must admit the SAME cell set
/// (a stable world does not flap a building interior for a 4 cm eye
/// wobble at identical gaze). The diff names the bistable cell(s); the
/// bisect prints the knife-edge location and transition count.
/// </summary>
[Fact]
public void CapturedFlipPair_AdmissionIsStable()
{
var w = LoadWorld();
if (w is null) return;
foreach (bool preGate in new[] { false, true })
foreach (float fov in new[] { MathF.PI / 3f, 1.2f, MathF.PI / 2f, 0.9f })
{
var a = AdmittedCells(w, EyeA, Fwd, fov, preGate);
var b = AdmittedCells(w, EyeB, Fwd, fov, preGate);
var onlyA = a.Except(b).OrderBy(x => x).ToList();
var onlyB = b.Except(a).OrderBy(x => x).ToList();
_out.WriteLine(FormattableString.Invariant(
$"preGate={preGate} fov={fov:F2}: |A|={a.Count} |B|={b.Count} onlyA=[{string.Join(",", onlyA.Select(x => $"0x{x:X8}"))}] onlyB=[{string.Join(",", onlyB.Select(x => $"0x{x:X8}"))}]"));
if (onlyA.Count == 0 && onlyB.Count == 0) continue;
// bisect the segment: count admission transitions per flipping cell
foreach (uint cell in onlyA.Concat(onlyB))
{
int transitions = 0;
bool? prev = null;
for (int i = 0; i <= 40; i++)
{
var eye = Vector3.Lerp(EyeA, EyeB, i / 40f);
bool admitted = AdmittedCells(w, eye, Fwd, fov, preGate).Contains(cell);
if (prev.HasValue && admitted != prev.Value) transitions++;
prev = admitted;
}
_out.WriteLine(FormattableString.Invariant(
$" cell 0x{cell:X8}: {transitions} admission transition(s) along the 4 cm segment"));
}
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<LoadedCell> 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<uint> BuildingAdmits(
World w, List<LoadedCell> group, Vector3 eye, Matrix4x4 viewProj,
FrustumPlanes frustum, bool withPreGate)
{
var result = new HashSet<uint>();
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;
}
/// <summary>
/// #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.
/// </summary>
[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<uint, int>();
var prevIn = new Dictionary<uint, bool>();
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<uint>(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<uint>(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");
}
}