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>
672 lines
32 KiB
C#
672 lines
32 KiB
C#
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·(world−origin). 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}"));
|
||
}
|
||
}
|
||
}
|