acdream/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs
Erik 987313aa54 knife-edge port: polyClipFinish W=0 eye-plane clip + degenerate-view propagation; EyeInsidePortalOpening rescue DELETED
Ports retail ACRender::polyClipFinish (0x006b6d00, pc:702749) near-eye
semantics into PortalProjection.ProjectToClip - the fundamental fix for
the in-plane portal clip family (climb strobes, tower-top roof/floor
flap while turning; live-corroborated this session: [viewer-diff]
0xAAB30108 strobing 27x mid-climb, whole interior dropping at the top).
Pseudocode: docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md.

Three legs, all decomp-driven:

1. ProjectToClip clips at w >= 0 EXACTLY (was EyePlaneW=1e-4), with
   retail's any-negative-w gate. Boundary intersections land at w == 0
   (homogeneous directions), so a portal the eye is CROSSING yields the
   correct unbounded half-region that the bounded view-region clip cuts
   to the screen. A w=0 vertex cannot survive a bounded region clip
   into the divide (direction fails some edge of any bounded convex
   region); the measure-zero corner case is guarded non-finite->empty.

2. CellView.CanonicalKey keys ALL-COLLINEAR (zero-area) views as their
   snapped segment ("L:" + extremes) instead of rejecting them - retail
   PROPAGATES degenerate views (ClipPortals decomp:433651-433711
   forwards any count!=0 GetClip output, no area gate anywhere), keeping
   the cell behind an exactly-in-plane portal in the draw list (cells
   draw whole; onward floods die naturally). Rejection dropped the
   whole chain for the frame - the parked-eye knife-edge band. Finite
   key space unchanged -> dedup + strict-growth convergence intact.

3. The EyeInsidePortalOpening rescue is DELETED (the T2-documented
   compensation for the 1e-4 divergence) along with EyeStandingPerpDist
   + PointInPoly2D. Empty clip = no flood, period (retail's rule).
   CornerFloodReplay - the gate that REFUTED the previous deletion
   attempt - passes WITHOUT the rescue under the W=0 port.

Harness criterion corrected to retail's rules (it codified the rescue):
cells fully BEHIND the camera are not required (all-behind portals clip
empty in retail); monotone area holds per root regime; the two
manufactured exact-on-plane steps assert root-only (boundary root pick
is ambiguous; the in-plane portal there is ~perpendicular to the gaze =
genuinely off-screen). Build_CollapsedInteriorPortalNearEye test
inverted to pin the retail empty-clip rule (it pinned the rescue).

New pins: eye-crossing portal -> w==0 boundary verts + half-region (not
sliver); gaze-along-plane degenerate view accepted + segment-key dedup;
non-finite guard. Replay harnesses (CornerFloodReplay, Issue120,
TowerAscent, HouseExit, Issue127) all green.

Suites: App 246+1skip / Core 1430+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:44:23 +02:00

672 lines
32 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;
internal const uint Landblock = 0xA9B40000u;
private const uint EnvironmentFilePrefix = 0x0D000000u;
internal 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>
internal static LoadedCell LoadCell(DatCollection dats, uint cellId)
=> LoadCell(dats, cellId, Vector3.Zero);
/// <summary>worldOffset: block offset for multi-landblock fixtures (dat cell
/// positions are landblock-local; a neighbour block needs ±192 per axis).</summary>
internal static LoadedCell LoadCell(DatCollection dats, uint cellId, Vector3 worldOffset)
{
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 + worldOffset);
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 + worldOffset,
WorldTransform = cellTransform,
InverseWorldTransform = inverse,
LocalBoundsMin = boundsMin,
LocalBoundsMax = boundsMax,
Portals = portals,
ClipPlanes = clipPlanes,
PortalPolygons = portalPolygons,
VisibleCells = visibleCells,
SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside),
};
}
internal 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);
// W=0 port (2026-06-11) — the criterion is retail's, not the rescue's:
// - Cells BEHIND the camera are NOT required (retail polyClipFinish clips an
// all-behind portal to empty; the rescue used to flood them anyway). The eye
// looks AT the player throughout, so the doorway chain is behind the camera
// until the eye recedes through it: require 0173 from root=0173 on, 0171
// from root=0171 on. 0172 (the looked-at room with the player) is required
// at EVERY step — that is THE user-visible §4 invariant.
// - The two KNIFE-EDGE steps (eye exactly ON a doorway plane, the sweep grid
// lands on the plane constants) propagate retail's zero-area degenerate view:
// the chain stays in the draw list (cells draw whole) but the 0172 region is
// legitimately zero-area and the onward flood (016F/outside) legitimately
// dies for that frame — exempt those assertions there.
// - Monotone shrink holds WITHIN a root regime; the root flip is a legitimate
// discontinuity (FullScreen root view -> portal-clipped view).
var failures = new List<string>();
float prevArea = float.MaxValue;
uint prevRoot = 0;
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);
bool knifeEdge = MathF.Abs(ex - Plane0172X) < 0.005f || MathF.Abs(ex - Plane0171X) < 0.005f;
var frame = PortalVisibilityBuilder.Build(
cells[root], eye, lookup, ViewProjFor(eye, pivot));
// Knife-edge steps: the eye sits EXACTLY on a cell-boundary plane, so the
// root pick itself is ambiguous (production BSP picks either side; a damped
// float eye never lands exactly on the plane). The portal whose plane
// contains the eye is ~perpendicular to the gaze here — genuinely
// off-screen, retail's screen-bounded clip floods nothing through it from
// the far-side root. Require only the root; the neighbouring steps (±2 cm,
// where the real strobe class lived) carry the full criterion.
var required = new List<uint>();
if (knifeEdge)
{
required.Add(root & 0xFFFFu);
}
else
{
required.Add(0x0172u);
if (root != (Landblock | 0x0172u)) required.Add(0x0173u);
if (root == (Landblock | 0x0171u)) required.Add(0x0171u);
required.Add(0x016Fu);
}
foreach (uint low in required)
if (!frame.OrderedVisibleCells.Contains(Landblock | low))
failures.Add($"step {i}: 0x{low:X4} missing from flood (root=0x{root & 0xFFFFu:X4}{(knifeEdge ? ", knife-edge" : "")})");
if (!knifeEdge && 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 (!knifeEdge && 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.
// Reset at root flips + knife-edge frames (legitimate discontinuities).
if (root == prevRoot && !knifeEdge && prevArea != float.MaxValue && area > prevArea + 0.01f)
failures.Add(System.FormattableString.Invariant(
$"step {i}: 0172 region grew {prevArea:F3}->{area:F3} (oscillation)"));
prevArea = knifeEdge ? float.MaxValue : area;
prevRoot = root;
}
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}"));
}
}
}