acdream/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs
Erik 2d15084243 #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>
2026-06-11 15:57:25 +02:00

630 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// §4 corner-press flood replay (2026-06-10). The §2b camera-collision hypothesis is
/// REFUTED (CameraCornerSealReplayTests, commit b21bb28): the eye legitimately enters
/// the neighbour room through the 0171↔0173↔0172 doorway chain. The user still sees
/// BACKGROUND at certain angles — so the defect is in the FLOOD/CLIP output for those
/// eye positions. This harness drives the REAL <see cref="PortalVisibilityBuilder.Build"/>
/// over the REAL Holtburg building cells (dat-loaded, mirroring GameWindow.BuildLoadedCell)
/// along the captured corner eye path, and prints which cells the flood keeps/drops per
/// step. The player's room (0172) dropping while the player is visible on screen is the
/// background defect; the step where it happens pins the mechanism.
///
/// Geometry (corner-seal-capture.log + the b21bb28 room map): all cells share origin
/// (161.93, 7.50, 94.00), local = R180z·(worldorigin). The 0171-side doorway plane is
/// local x=4.10 (world x≈157.83), the 0172-side plane local x=3.90 (world x≈158.03);
/// the opening is 1.9 m wide, full height (z 94..96.5). Player dwell position during the
/// corner press: (159.94, 7.70, 94.00) in 0172; captured eyes hover near (157.4..157.5,
/// 7.91, 96.25) — inside 0171, 0.35 m past the doorway plane, near the ceiling.
/// </summary>
public class CornerFloodReplayTests
{
private readonly ITestOutputHelper _out;
public CornerFloodReplayTests(ITestOutputHelper output) => _out = output;
private const uint Landblock = 0xA9B40000u;
private const uint EnvironmentFilePrefix = 0x0D000000u;
private static string? ResolveDatDir()
{
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
var def = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(def) ? def : null;
}
/// <summary>
/// Dat → LoadedCell, mirroring GameWindow.BuildLoadedCell (GameWindow.cs:5636-5776)
/// field-for-field: portals (with OtherPortalId back-link), clip planes from the
/// portal polygon's first 3 verts + centroid InsideSide, full portal polygons in
/// cell-local space, local AABB, world transform from EnvCell.Position.
/// </summary>
private static LoadedCell LoadCell(DatCollection dats, uint cellId)
{
var envCell = dats.Get<DatEnvCell>(cellId)
?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found");
var environment = dats.Get<DatEnvironment>(EnvironmentFilePrefix | envCell.EnvironmentId)
?? throw new InvalidOperationException($"Environment 0x{envCell.EnvironmentId:X8} not found");
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct) || cellStruct is null)
throw new InvalidOperationException($"CellStruct {envCell.CellStructure} missing");
var cellTransform =
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
Matrix4x4.CreateTranslation(envCell.Position.Origin);
Matrix4x4.Invert(cellTransform, out var inverse);
var boundsMin = new Vector3(float.MaxValue);
var boundsMax = new Vector3(float.MinValue);
foreach (var kvp in cellStruct.VertexArray.Vertices)
{
var v = kvp.Value;
var pos = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
boundsMin = Vector3.Min(boundsMin, pos);
boundsMax = Vector3.Max(boundsMax, pos);
}
if (boundsMin.X == float.MaxValue) { boundsMin = Vector3.Zero; boundsMax = Vector3.Zero; }
var portals = new List<CellPortalInfo>();
var clipPlanes = new List<PortalClipPlane>();
var portalPolygons = new List<Vector3[]>();
var centroid = (boundsMin + boundsMax) * 0.5f;
foreach (var portal in envCell.CellPortals)
{
portals.Add(new CellPortalInfo(
portal.OtherCellId, portal.PolygonId, (ushort)portal.Flags, portal.OtherPortalId));
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
&& poly.VertexIds.Count >= 3
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0)
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1)
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2))
{
var p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z);
var p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z);
var p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
float d = -Vector3.Dot(normal, p0);
float centroidDot = Vector3.Dot(normal, centroid) + d;
clipPlanes.Add(new PortalClipPlane
{
Normal = normal, D = d, InsideSide = centroidDot >= 0 ? 0 : 1,
});
}
else
{
clipPlanes.Add(default);
}
Vector3[] polyVerts = Array.Empty<Vector3>();
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPoly)
&& portalPoly.VertexIds.Count >= 3)
{
polyVerts = new Vector3[portalPoly.VertexIds.Count];
bool allResolved = true;
for (int vi = 0; vi < portalPoly.VertexIds.Count; vi++)
{
if (cellStruct.VertexArray.Vertices.TryGetValue(
(ushort)portalPoly.VertexIds[vi], out var pv))
polyVerts[vi] = new Vector3(pv.Origin.X, pv.Origin.Y, pv.Origin.Z);
else { allResolved = false; break; }
}
if (!allResolved) polyVerts = Array.Empty<Vector3>();
}
portalPolygons.Add(polyVerts);
}
uint lbPrefix = cellId & 0xFFFF0000u;
var visibleCells = new List<uint>();
if (envCell.VisibleCells is not null)
foreach (var lowId in envCell.VisibleCells)
visibleCells.Add(lbPrefix | lowId);
return new LoadedCell
{
CellId = cellId,
WorldPosition = envCell.Position.Origin,
WorldTransform = cellTransform,
InverseWorldTransform = inverse,
LocalBoundsMin = boundsMin,
LocalBoundsMax = boundsMax,
Portals = portals,
ClipPlanes = clipPlanes,
PortalPolygons = portalPolygons,
VisibleCells = visibleCells,
SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside),
};
}
private static Dictionary<uint, LoadedCell> LoadBuilding(DatCollection dats)
{
var cells = new Dictionary<uint, LoadedCell>();
for (uint low = 0x016Fu; low <= 0x0175u; low++)
{
uint id = Landblock | low;
cells[id] = LoadCell(dats, id);
}
return cells;
}
// Production projection: ChaseCamera/FlyCamera use FovY ~1.2, near 1, far 5000,
// 1280x720 (the capture's viewport). The flood's clip is near-independent
// (PortalProjection header), so near/far exactness is not load-bearing.
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
{
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f);
return view * proj;
}
// World-x of the doorway planes (local x=4.10 / 3.90 under the R180z transform
// about origin x=161.93).
private const float Plane0171X = 161.93f - 4.10f; // ≈157.83
private const float Plane0172X = 161.93f - 3.90f; // ≈158.03
private static uint RootCellFor(float eyeX) =>
eyeX > Plane0172X ? (Landblock | 0x0172u)
: eyeX > Plane0171X ? (Landblock | 0x0173u)
: (Landblock | 0x0171u);
/// <summary>
/// Diagnostic: full [pv-trace] decision log for one GOOD step (47) vs one GLITCH
/// step (48) of the sweep below — 2 cm apart, root 0171 both, yet 0172 (and its
/// downstream 016F + outside) vanish from the flood at 48. The trace diff pins the
/// exact gate that flips.
/// </summary>
[Fact]
public void Diagnostic_TraceGoodVsGlitchStep()
{
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;
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
var pivot = player + new Vector3(0f, 0f, 1.5f);
foreach (int i in new[] { 47, 48 })
{
float ex = 158.43f - i * 0.02f;
var eye = new Vector3(ex, 7.912722f, 96.248833f);
uint root = RootCellFor(ex);
var sw = new StringWriter();
var prev = Console.Out;
PortalVisibilityFrame frame;
try
{
Console.SetOut(sw);
AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled = true;
frame = PortalVisibilityBuilder.Build(cells[root], eye, lookup, ViewProjFor(eye, pivot));
}
finally
{
AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled = false;
Console.SetOut(prev);
}
_out.WriteLine($"########## step {i} (eyeX={ex:F2}) ##########");
foreach (var line in sw.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries))
_out.WriteLine(line.TrimEnd());
// Per-hop region vertices: where does the sliver come from?
foreach (uint low in new uint[] { 0x0171u, 0x0173u, 0x0172u })
{
if (!frame.CellViews.TryGetValue(Landblock | low, out var view))
{
_out.WriteLine($" view 0x{low:X4}: (absent)");
continue;
}
foreach (var p in view.Polygons)
{
string verts = "";
foreach (var v in p.Vertices)
verts += System.FormattableString.Invariant($" ({v.X:F5},{v.Y:F5})");
_out.WriteLine($" view 0x{low:X4} [{p.Vertices.Length}]:{verts}");
}
if (view.Polygons.Count == 0)
_out.WriteLine($" view 0x{low:X4}: 0 polygons (rejected by Add)");
}
}
}
/// <summary>
/// §4 conformance gate (2026-06-08 handoff §7's pre-gate, realized 2026-06-10):
/// a smooth monotonic eye moving through/near a doorway must produce a STABLE,
/// monotone flood — the full cell chain present on every step, the outside view
/// always reached, and the player-room region never collapsing nor oscillating.
/// Before the homogeneous-reciprocal fix this failed on ~10 of 61 steps (room
/// 0172 vanished entirely or collapsed to a sub-pixel sliver — the corner /
/// transition background strobe). Guards the ApplyReciprocalClip pipeline.
/// </summary>
[Fact]
public void CornerSweep_FloodIsCompleteAndMonotone()
{
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;
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
var pivot = player + new Vector3(0f, 0f, 1.5f);
var failures = new List<string>();
float prevArea = float.MaxValue;
for (int i = 0; i <= 60; i++)
{
float ex = 158.43f - i * 0.02f;
var eye = new Vector3(ex, 7.912722f, 96.248833f);
uint root = RootCellFor(ex);
var frame = PortalVisibilityBuilder.Build(
cells[root], eye, lookup, ViewProjFor(eye, pivot));
foreach (uint low in new uint[] { 0x0171u, 0x0172u, 0x0173u, 0x016Fu })
if (!frame.OrderedVisibleCells.Contains(Landblock | low))
failures.Add($"step {i}: 0x{low:X4} missing from flood");
if (frame.OutsideView.Polygons.Count == 0)
failures.Add($"step {i}: outside view empty");
float area = 0f;
if (frame.CellViews.TryGetValue(Landblock | 0x0172u, out var view))
foreach (var p in view.Polygons)
area += MathF.Max(0f, p.MaxX - p.MinX) * MathF.Max(0f, p.MaxY - p.MinY);
if (area < 0.5f)
failures.Add(System.FormattableString.Invariant(
$"step {i}: 0172 region collapsed (area={area:F3})"));
// Monotone shrink as the eye recedes — allow float-noise upticks only.
if (area > prevArea + 0.01f)
failures.Add(System.FormattableString.Invariant(
$"step {i}: 0172 region grew {prevArea:F3}->{area:F3} (oscillation)"));
prevArea = area;
}
Assert.True(failures.Count == 0,
"Flood instability under a monotonic eye sweep (the §4 strobe):\n "
+ 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>
/// Diagnostic: microscope on the failing hop. Replays the two portal hops
/// (0171→0173, then 0173→0172) through the PUBLIC PortalProjection APIs for the
/// good step (47) and the glitch step (48), printing the homogeneous subject
/// vertices and full-precision outputs at every stage — the concrete numbers for
/// the collapsing intersection.
/// </summary>
[Fact]
public void Diagnostic_Hop2Microscope()
{
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);
var c171 = cells[Landblock | 0x0171u];
var c173 = cells[Landblock | 0x0173u];
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
var pivot = player + new Vector3(0f, 0f, 1.5f);
// 0171's portal to 0173 is index 1; 0173's portal to 0172 is index 1 (room map).
var poly171to173 = c171.PortalPolygons[1];
var poly173to172 = c173.PortalPolygons[1];
foreach (int i in new[] { 47, 48 })
{
float ex = 158.43f - i * 0.02f;
var eye = new Vector3(ex, 7.912722f, 96.248833f);
var vp = ViewProjFor(eye, pivot);
_out.WriteLine($"########## step {i} (eyeX={ex:F2}) ##########");
// Hop 1: 0171 -> 0173 against the full screen.
var subj1 = PortalProjection.ProjectToClip(poly171to173, c171.WorldTransform, vp);
DumpClip("hop1 subject (0171->0173 portal, clip space)", subj1);
var region173 = PortalProjection.ClipToRegion(subj1, new[]
{
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
});
DumpNdc("hop1 out = 0173 region", region173);
if (region173.Length < 3) continue;
// Hop 2: 0173 -> 0172 against the 0173 region.
var subj2 = PortalProjection.ProjectToClip(poly173to172, c173.WorldTransform, vp);
DumpClip("hop2 subject (0173->0172 portal, clip space)", subj2);
var region172 = PortalProjection.ClipToRegion(subj2, region173);
DumpNdc("hop2 out = 0172 region", region172);
}
}
/// <summary>Scratch: the homogeneous reciprocal primitive in isolation, on the
/// synthetic fixture geometry that Build_AppliesReciprocalOtherPortalClip uses.</summary>
[Fact]
public void Scratch_ReciprocalPrimitive_SyntheticPair()
{
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
var vp = view * proj;
Vector3[] Quad(float cx, float cy, float hw, float hh, float z) => new[]
{
new Vector3(cx - hw, cy - hh, z), new Vector3(cx + hw, cy - hh, z),
new Vector3(cx + hw, cy + hh, z), new Vector3(cx - hw, cy + hh, z),
};
var wide = Quad(0f, 0f, 0.9f, 0.9f, -3f);
var narrow = Quad(0f, 0f, 0.3f, 0.9f, -3f);
// Near-side region: wide portal clipped against the full screen.
var wideClip = PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, vp);
DumpClip("wide subject", wideClip);
var wideRegion = PortalProjection.ClipToRegion(wideClip, new[]
{
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
});
DumpNdc("wide region (near-side)", wideRegion);
// Reciprocal: narrow portal, homogeneous, clipped against the wide region.
var narrowClip = PortalProjection.ProjectToClip(narrow, Matrix4x4.Identity, vp);
DumpClip("narrow subject (reciprocal)", narrowClip);
var tightened = PortalProjection.ClipToRegion(narrowClip, wideRegion);
DumpNdc("tightened = narrow ∩ wide", tightened);
// Now the FULL Build on the same synthetic pair (mirrors
// Build_AppliesReciprocalOtherPortalClip) with output dumps.
var a = new LoadedCell
{
CellId = 0x0001, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
Portals = new List<CellPortalInfo> { new CellPortalInfo(0x0002, 0, 0, 0) },
};
a.PortalPolygons.Add(wide);
var b = new LoadedCell
{
CellId = 0x0002, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
Portals = new List<CellPortalInfo> { new CellPortalInfo(0x0001, 0, 0, 0) },
};
b.PortalPolygons.Add(narrow);
var all = new Dictionary<uint, LoadedCell> { [0x0001u] = a, [0x0002u] = b };
var f = PortalVisibilityBuilder.Build(
a, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp);
foreach (var kv in f.CellViews)
foreach (var p in kv.Value.Polygons)
{
string s = "";
foreach (var v in p.Vertices)
s += System.FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})");
_out.WriteLine($" Build view 0x{kv.Key:X4} [{p.Vertices.Length}]:{s}");
}
}
private void DumpClip(string label, Vector4[] verts)
{
string s = "";
foreach (var v in verts)
s += System.FormattableString.Invariant($" ({v.X:F4},{v.Y:F4},{v.Z:F4},w={v.W:F4})");
_out.WriteLine($" {label} [{verts.Length}]:{s}");
}
private void DumpNdc(string label, Vector2[] verts)
{
string s = "";
foreach (var v in verts)
s += System.FormattableString.Invariant($" ({v.X:F7},{v.Y:F7})");
_out.WriteLine($" {label} [{verts.Length}]:{s}");
}
/// <summary>
/// Diagnostic (no assertions yet): sweep the eye along the corner-press path —
/// from inside the player's room (0172), across the doorway threshold (0173),
/// into the neighbour room (0171) — looking back at the player throughout, and
/// print the flood result per step. The defect signature: the PLAYER's room
/// (0172) absent from the flood (or its view region empty) while the root is
/// 0171/0173 — the screen area where the player stands then shows background.
/// </summary>
[Fact]
public void Diagnostic_CornerPress_FloodAcrossDoorwayPlane()
{
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;
// The captured corner scenario: player parked in 0172, eye orbiting near the
// doorway plane at head height (z=96.25), looking at the pivot.
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
var pivot = player + new Vector3(0f, 0f, 1.5f);
_out.WriteLine("step | eyeX (worldX | dPlane0171) | root | flood | 0171/0173/0172 polys | outside");
for (int i = 0; i <= 60; i++)
{
// Sweep world-x 158.43 (inside 0172) -> 157.23 (inside 0171), 2 cm steps,
// crossing the 0172 plane (~158.03) and the 0171 plane (~157.83).
float ex = 158.43f - i * 0.02f;
var eye = new Vector3(ex, 7.912722f, 96.248833f);
uint root = RootCellFor(ex);
var frame = PortalVisibilityBuilder.Build(
cells[root], eye, lookup, ViewProjFor(eye, pivot));
string flood = "";
foreach (uint id in frame.OrderedVisibleCells)
flood += $" {id & 0xFFFFu:X4}";
string PolyInfo(uint low)
{
uint id = Landblock | low;
if (!frame.CellViews.TryGetValue(id, out var view)) return "-";
int n = view.Polygons.Count;
float area = 0f;
foreach (var p in view.Polygons)
area += MathF.Max(0f, p.MaxX - p.MinX) * MathF.Max(0f, p.MaxY - p.MinY);
return System.FormattableString.Invariant($"{n}:{area:F3}");
}
string p71 = PolyInfo(0x0171u);
string p73 = PolyInfo(0x0173u);
string p72 = PolyInfo(0x0172u);
_out.WriteLine(System.FormattableString.Invariant(
$"{i,3} | {ex:F2} ({ex - Plane0171X,6:F3}) | {root & 0xFFFFu:X4} |{flood} | {p71} / {p73} / {p72} | out={frame.OutsideView.Polygons.Count}"));
}
}
}