acdream/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs
Erik 8257b9ba10 fix(#186): render side-cull mis-sided thin connectors — use dat PortalSide bit, not AABB centroid
The indoor GREY flap at a top-floor connecting room. The render portal side-cull
reconstructed each doorway's "interior side" (PortalClipPlane.InsideSide) from the
cell's AABB CENTROID. For a THIN connector cell (0xF6820118, 5 render polys), the
bounding-box center falls on the WRONG side of the 0118->0116 doorway, so the eye
read as a back-portal and the forward room 0116 was culled -> the aperture showed
the fog clear color = grey.

Retail's PView::InitCell (0x005a4b70) and acdream's own PHYSICS path
(CellTransit.cs:190) both read the explicit dat PortalSide bit ((Flags&2)==0)
instead of guessing from geometry. Port the render path (GameWindow.BuildLoadedCell)
to the same bit.

Proven by a live retail cdb trace (retail draws 0116 from the 0118 root at the grey
pose; tools/cdb/issue186-connector-decider.cdb) + an offline dat diagnostic
(Issue186...PortalSide_CentroidVsDatBit_AtGreyEye): the dat bit matches the old
centroid on every portal of these cells EXCEPT the one #186 breaks, so the switch is
surgical. Full regression green (App 741 / Core 2631); the CornerFlood + Issue113
dat-loading helpers updated to the same bit confirm every real Holtburg/tower/hall
cell floods identically. Touches neither PortalSideEpsilon nor the deleted
EyeInsidePortalOpening rescue (the two DO-NOT-RETRY traps).

Live-gated: user-confirmed no grey at any camera angle; probe shows 216 root=0118
frames, 0 still grey (0118->0116 now TRV, vis=4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:06:33 +02:00

674 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[]>();
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);
// InsideSide from the dat PortalSide bit — mirrors the production fix
// (GameWindow.BuildLoadedCell): retail InitCell (0x005a4b70) + physics
// CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin
// connectors (#186). Kept identical here so this replay stays a faithful mirror.
clipPlanes.Add(new PortalClipPlane
{
Normal = normal, D = d, InsideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0,
});
}
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}"));
}
}
}