acdream/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs
Erik 5a80a2ee24 #118: outdoor dynamics draw in the outside stage under interior roots - the house-exit clip+vanish was the SEAL z-killing the player
Root cause (pinned by the new deterministic exit-walk harness, NOT guessed):
under an interior render root, the exit-portal SEAL stamps the door fan at
TRUE depth after the gated full depth clear, and T1's "ALL dynamics last"
pass then drew the outdoor-classified player depth-tested - every fragment
beyond the door plane z-failed against the seal across the whole aperture.
Harness measured the full window: from the moment the sphere center crosses
the plane until the eye follows (~2.6 m of camera lag, ~2.2 s at walk speed)
the player is invisible; while straddling, the beyond-plane body half clips
at the plane. The handoff's three cone-level candidates are all EXONERATED:
the cone walk passes every step; (eye, ViewerCellId) come from the same
SweepEye call with camera-update-before-visibility-read in the same frame;
the side-test window is sub-epsilon under healthy resolution.

Retail oracle (grep-named-first): PView::DrawCells 0x005a4840 runs
LScape::draw FIRST (pc:432719), then the gated depth clear (pc:432731-32)
and the exit-portal seals (pc:432785-86); outdoor cell objects draw inside
the landscape stage (DrawBlock 0x005a17c0 -> DrawSortCell pc:430124), and
an object draws once per overlapped shadow cell (pc:430056-64) - the
straddling body composes from both stages, neither half clips.

Fix: RetailPViewRenderer assigns dynamics to the OUTSIDE stage under an
interior root when outdoor-classified OR sphere-straddling an exit-portal
plane of their flood-visible cell (DynamicDrawsInOutsideStage - pure, the
harness drives it as the ordering contract); they ride the landscape slice
draw (pre-clear, seal-protected) with the same per-slice cone test as
outdoor statics. Indoor dynamics keep the last pass (retail loop C);
straddlers draw in both (retail shadow dual-draw). Outdoor roots keep
all-dynamics-last - the BR-2 punch-after-dynamics lesson (88be519) stands.

Apparatus: HouseExitWalkReplayTests - dat-backed corner-building exit walk
driving the production stack headlessly (RetailChaseCamera damping ->
healthy-sweep viewer resolution -> PortalVisibilityBuilder.Build ->
ClipFrameAssembler -> ViewconeCuller -> the DrawDynamicsLast predicate +
a CPU seal-depth model). 5 tests: cone pin, seal-depth pin, straddle
dual-draw pin, per-step table, stale-root window quantifier (#118 cand 2).

Suites: App 232 (227+5), Core 1416+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:49:29 +02:00

486 lines
24 KiB
C#
Raw Permalink 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.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #118 exit-walk harness (handoff 2026-06-11 §5): the character is clipped and then
/// vanishes for a moment when exiting a house — the window where the player is just
/// outside the door while the collided viewer (camera) is still indoors.
///
/// Per step of a deterministic eye+player path crossing the exit doorway of the
/// Holtburg corner building (cell 0xA9B40170, dat-loaded via the CornerFloodReplay
/// fixture loader), this drives the PRODUCTION decision stack headlessly:
/// viewer-cell resolution (healthy-sweep model, see below) →
/// <see cref="PortalVisibilityBuilder.Build"/> → <see cref="ClipFrameAssembler.Assemble"/> →
/// <see cref="ViewconeCuller.Build"/> → the exact DrawDynamicsLast visibility predicate
/// (RetailPViewRenderer.cs:375-401), PLUS the depth relationship DrawDynamicsLast is
/// subject to: under an INTERIOR root the exit-portal SEAL stamps the door fan at TRUE
/// depth (GameWindow.DrawRetailPViewPortalDepthWrite, forceFarZ=false) after the full
/// depth clear, and dynamics draw depth-tested AFTER it.
///
/// The four candidates this pins (handoff §5 + this session's read):
/// 1. eye/cell incoherence under damping — EXONERATED BY READ for clean exits:
/// RetailChaseCamera publishes (Position, ViewerCellId) from the SAME SweepEye call
/// (RetailChaseCamera.cs:188-203), published==damped when nothing collides (an open
/// doorway), and GameWindow updates the camera (≈:6889) BEFORE the visibility read
/// (≈:7361) in the same frame.
/// 2. exit-portal side test culling at an ε-outside eye → OutsideView EMPTY →
/// SphereVisibleOutside culls ALL outdoor dynamics. Quantified by the stale-root
/// diagnostic below (the healthy walk should never produce the incoherent pair).
/// 3. doorway-aperture cone tightness → tested per step by the predicate replica.
/// 4. (new, this session) SEAL-DEPTH vs dynamics-last ordering: a player whose
/// fragments lie BEYOND the door plane z-fails against the seal across the whole
/// aperture → invisible while fully outside (and clipped at the plane while
/// straddling). Tested per step by the CPU depth check (same viewProj math the GPU
/// consumes).
///
/// Healthy-sweep model: the corner-seal replay (b21bb28) + the camera read above prove
/// the sweep resolves the eye's ACTUAL cell same-frame, so the harness derives the
/// viewer cell geometrically: door-plane side decides indoor/outdoor; AABB containment
/// (smallest containing volume) picks the interior cell. The outdoor root is
/// <see cref="OutdoorCellNode.Build"/> exactly as GameWindow builds it (full-screen
/// OutsideView ⇒ outdoor dynamics trivially cone-pass).
/// </summary>
public class HouseExitWalkReplayTests
{
private readonly ITestOutputHelper _out;
public HouseExitWalkReplayTests(ITestOutputHelper output) => _out = output;
private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u;
// Production humanoid entity sphere (RetailPViewRenderer.EntitySphere: AABB center +
// half-diagonal). A ~1.8 m × 0.6 m character AABB gives center ≈ feet+0.9, r ≈ 1.0.
private const float PlayerSphereRadius = 1.0f;
private static readonly Vector3 PlayerSphereCenterOffset = new(0f, 0f, 0.9f);
private sealed record ExitDoor(
int PortalIndex,
Vector3[] WorldVerts,
Vector3 WorldCentroid,
Vector3 OutwardNormal, // unit, world space, pointing OUT of the building
float FloorZ);
private sealed record WalkStep(
int Index,
float WalkS, // raw walk parameter (feet travel along the XY exit direction)
float CenterS, // signed distance of the SPHERE CENTER to the door plane (the production quantity)
float EyeS, // signed distance of the published eye
Vector3 PlayerFeet,
Vector3 Eye,
uint RootCellId, // 0 = outdoor root
uint PlayerParentCellId,
bool ConeVisible,
bool OutsideStage, // production stage assignment (DynamicDrawsInOutsideStage)
bool DepthCheckApplies,
bool DepthPass,
int OutsidePolys,
int FloodCells);
// ── fixture / geometry ──────────────────────────────────────────────
private static ExitDoor FindExitDoor(LoadedCell cell)
{
int best = -1;
float bestMinZ = float.MaxValue;
for (int i = 0; i < cell.Portals.Count; i++)
{
if (cell.Portals[i].OtherCellId != 0xFFFF) continue;
if (i >= cell.PortalPolygons.Count) continue;
var poly = cell.PortalPolygons[i];
if (poly is null || poly.Length < 3) continue;
float minZ = float.MaxValue;
foreach (var v in poly)
minZ = MathF.Min(minZ, Vector3.Transform(v, cell.WorldTransform).Z);
// a DOOR reaches the floor; a window doesn't — pick the lowest-silled exit portal
if (minZ < bestMinZ) { bestMinZ = minZ; best = i; }
}
Assert.True(best >= 0, $"cell 0x{cell.CellId:X8} has no exit portal (OtherCellId==0xFFFF)");
var local = cell.PortalPolygons[best];
var world = new Vector3[local.Length];
var centroid = Vector3.Zero;
float floorZ = float.MaxValue;
for (int v = 0; v < local.Length; v++)
{
world[v] = Vector3.Transform(local[v], cell.WorldTransform);
centroid += world[v];
floorZ = MathF.Min(floorZ, world[v].Z);
}
centroid /= local.Length;
// Outward = away from the cell interior. ClipPlanes[i].InsideSide encodes which
// side the cell centroid is on (CornerFloodReplayTests.LoadCell): InsideSide==0
// ⇒ interior satisfies dot ≥ 0 ⇒ outward is Normal; InsideSide==1 ⇒ +Normal.
var plane = cell.ClipPlanes[best];
var outwardLocal = plane.InsideSide == 0 ? -plane.Normal : plane.Normal;
var outwardWorld = Vector3.Normalize(Vector3.TransformNormal(outwardLocal, cell.WorldTransform));
return new ExitDoor(best, world, centroid, outwardWorld, floorZ);
}
private static float SignedSide(ExitDoor door, Vector3 p)
=> Vector3.Dot(door.OutwardNormal, p - door.WorldCentroid);
private static uint? ResolveInteriorCellByAabb(
Dictionary<uint, LoadedCell> cells, Vector3 worldPoint, float margin = 0.05f)
{
uint? best = null;
float bestVolume = float.MaxValue;
foreach (var (id, cell) in cells)
{
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
var min = cell.LocalBoundsMin - new Vector3(margin);
var max = cell.LocalBoundsMax + new Vector3(margin);
if (local.X < min.X || local.Y < min.Y || local.Z < min.Z
|| local.X > max.X || local.Y > max.Y || local.Z > max.Z)
continue;
var ext = cell.LocalBoundsMax - cell.LocalBoundsMin;
float volume = MathF.Max(ext.X, 1e-3f) * MathF.Max(ext.Y, 1e-3f) * MathF.Max(ext.Z, 1e-3f);
if (volume < bestVolume) { bestVolume = volume; best = id; }
}
return best;
}
// Outdoor landcell id for a landblock-local position (dat EnvCell positions are
// landblock-local). Only the (low word < 0x100) classification is load-bearing for
// the DrawDynamicsLast predicate; the exact landcell is kept honest anyway.
private static uint OutdoorLandcellId(Vector3 landblockLocalPos)
{
int cx = Math.Clamp((int)MathF.Floor(landblockLocalPos.X / 24f), 0, 7);
int cy = Math.Clamp((int)MathF.Floor(landblockLocalPos.Y / 24f), 0, 7);
return CornerFloodReplayTests.Landblock | (uint)(cx * 8 + cy + 1);
}
// Exact replica of RetailPViewRenderer.DrawDynamicsLast's visibility predicate
// (RetailPViewRenderer.cs:386-391). Keep in lockstep with production.
private static bool DynamicsConeVisible(ViewconeCuller cone, uint parentCellId, Vector3 c, float r)
{
bool indoor = (parentCellId & 0xFFFFu) >= 0x0100u && (parentCellId & 0xFFFFu) != 0xFFFFu;
return indoor
? cone.SphereVisibleInCell(parentCellId, c, r)
: cone.SphereVisibleOutside(c, r);
}
// ── the walk ────────────────────────────────────────────────────────
private List<WalkStep>? RunExitWalk()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = CornerFloodReplayTests.LoadBuilding(dats);
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
var exitCell = cells[ExitCellId];
var door = FindExitDoor(exitCell);
_out.WriteLine(FormattableString.Invariant(
$"exit door: cell=0x{ExitCellId:X8} portal[{door.PortalIndex}] centroid=({door.WorldCentroid.X:F2},{door.WorldCentroid.Y:F2},{door.WorldCentroid.Z:F2}) outward=({door.OutwardNormal.X:F2},{door.OutwardNormal.Y:F2},{door.OutwardNormal.Z:F2}) floorZ={door.FloorZ:F2} verts={door.WorldVerts.Length}"));
// Walk straight out through the door centroid along the outward normal's XY
// projection (doors are vertical; assert so).
var out2d = Vector3.Normalize(new Vector3(door.OutwardNormal.X, door.OutwardNormal.Y, 0f));
Assert.True(MathF.Abs(door.OutwardNormal.Z) < 0.3f, "exit door is not vertical — walk path invalid");
float yaw = MathF.Atan2(out2d.Y, out2d.X);
const float dt = 1f / 60f;
const float stepLen = 0.02f; // 2 cm per frame = 1.2 m/s walk
const float sStart = -1.2f, sEnd = 3.0f; // door plane at s=0
var velocity = out2d * (stepLen / dt);
var camera = new RetailChaseCamera { Aspect = 1280f / 720f };
Vector3 FeetAt(float s) => new(
door.WorldCentroid.X + out2d.X * s,
door.WorldCentroid.Y + out2d.Y * s,
door.FloorZ);
// Warm the damped boom to convergence at the start pose (retail's convergence
// snap freezes it once the lerp step is sub-epsilon).
for (int i = 0; i < 240; i++)
camera.Update(FeetAt(sStart), yaw, Vector3.Zero, isOnGround: true, Vector3.UnitZ, dt);
var clipFrame = ClipFrame.NoClip();
var steps = new List<WalkStep>();
int stepCount = (int)MathF.Round((sEnd - sStart) / stepLen);
for (int i = 0; i <= stepCount; i++)
{
float s = sStart + i * stepLen;
var feet = FeetAt(s);
camera.Update(feet, yaw, velocity, isOnGround: true, Vector3.UnitZ, dt);
var eye = camera.Position;
var viewProj = camera.View * camera.Projection;
float eyeS = SignedSide(door, eye);
var sphereC = feet + PlayerSphereCenterOffset;
float playerS = SignedSide(door, sphereC);
// membership model: the controller's pick is center point-in-cell (physics
// digest, P1) — the player's ParentCellId flips when the sphere CENTER
// crosses the door plane.
uint playerCell = playerS <= 0f
? ExitCellId
: OutdoorLandcellId(feet);
// healthy-sweep viewer resolution: plane side decides indoor/outdoor;
// AABB containment picks the interior cell the eye is in.
uint rootCellId = 0u;
LoadedCell? root = null;
if (eyeS <= 0f)
{
var contained = ResolveInteriorCellByAabb(cells, eye);
Assert.True(contained.HasValue,
FormattableString.Invariant(
$"step {i}: eye=({eye.X:F2},{eye.Y:F2},{eye.Z:F2}) (eyeS={eyeS:F2}) is inside the door plane but no loaded cell AABB contains it — adjust the walk"));
rootCellId = contained.Value;
root = cells[rootCellId];
}
else
{
root = OutdoorCellNode.Build(OutdoorLandcellId(eye));
}
var pv = PortalVisibilityBuilder.Build(root, eye, lookup, viewProj);
var asm = ClipFrameAssembler.Assemble(clipFrame, pv);
var cone = ViewconeCuller.Build(asm, viewProj);
bool coneVisible = DynamicsConeVisible(cone, playerCell, sphereC, PlayerSphereRadius);
// Production stage assignment (#118 fix): an outside-stage dynamic draws
// BEFORE the depth clear + seal (retail LScape::draw → DrawSortCell), so
// the seal PROTECTS its pixels instead of z-killing them.
var drawable = new HashSet<uint>(pv.OrderedVisibleCells);
bool outsideStage = rootCellId != 0u
&& RetailPViewRenderer.DynamicDrawsInOutsideStage(
playerCell, sphereC, PlayerSphereRadius, drawable, lookup);
// Candidate 4: the seal depth check. Applies when the root is INTERIOR
// (ClearDepthForInterior + the TRUE-depth seal run, GameWindow:7719-7729),
// the player sphere center lies BEYOND the door plane on the ray from the
// eye, AND the player draws in the post-seal last pass (not outside-stage).
bool depthApplies = false, depthPass = true;
if (rootCellId != 0u && coneVisible && playerS > 0f && eyeS < 0f && !outsideStage)
{
depthApplies = TrySealDepthCheck(door, eye, sphereC, viewProj, out depthPass);
}
steps.Add(new WalkStep(
i, s, playerS, eyeS, feet, eye, rootCellId, playerCell, coneVisible, outsideStage,
depthApplies, depthPass,
pv.OutsideView.Polygons.Count, pv.OrderedVisibleCells.Count));
}
return steps;
}
/// <summary>
/// CPU model of the depth relationship at the player-sphere-center pixel:
/// the SEAL (door fan at true depth, drawn after the full depth clear and before
/// dynamics — PortalDepthMaskRenderer, forceFarZ=false) vs the player fragment.
/// Returns true (out: pass) when the player's NDC depth at that pixel is ≤ the
/// seal's. Returns false (check N/A) when the eye→center ray misses the door fan
/// (the seal doesn't cover that pixel; depth there is the cleared far plane).
/// </summary>
private static bool TrySealDepthCheck(
ExitDoor door, Vector3 eye, Vector3 sphereCenter, in Matrix4x4 viewProj, out bool depthPass)
{
depthPass = true;
float dEye = Vector3.Dot(door.OutwardNormal, eye - door.WorldCentroid);
float dC = Vector3.Dot(door.OutwardNormal, sphereCenter - door.WorldCentroid);
if (dEye >= 0f || dC <= 0f) return false; // plane not between eye and center
float t = dEye / (dEye - dC);
var hit = eye + (sphereCenter - eye) * t;
// is the ray-plane hit inside the door polygon? (2D point-in-polygon in the
// plane's basis — the seal only stamps pixels inside the fan)
if (!PointInPolygon(door, hit)) return false;
var pc = Vector4.Transform(new Vector4(sphereCenter, 1f), viewProj);
var hc = Vector4.Transform(new Vector4(hit, 1f), viewProj);
if (pc.W <= 1e-6f || hc.W <= 1e-6f) return false; // behind the eye — no pixel
float playerZ = pc.Z / pc.W;
float sealZ = hc.Z / hc.W;
// GL depth test default LESS/LEQUAL: smaller NDC z = nearer = passes.
depthPass = playerZ <= sealZ + 1e-4f;
return true;
}
private static bool PointInPolygon(ExitDoor door, Vector3 worldPoint)
{
// build a 2D basis in the door plane
var n = door.OutwardNormal;
var u = Vector3.Normalize(Vector3.Cross(n, Vector3.UnitZ));
if (u.LengthSquared() < 1e-6f) u = Vector3.UnitX;
var v = Vector3.Cross(n, u);
Vector2 Project(Vector3 p) => new(
Vector3.Dot(p - door.WorldCentroid, u),
Vector3.Dot(p - door.WorldCentroid, v));
var pt = Project(worldPoint);
bool inside = false;
for (int i = 0, j = door.WorldVerts.Length - 1; i < door.WorldVerts.Length; j = i++)
{
var a = Project(door.WorldVerts[i]);
var b = Project(door.WorldVerts[j]);
if ((a.Y > pt.Y) != (b.Y > pt.Y)
&& pt.X < (b.X - a.X) * (pt.Y - a.Y) / (b.Y - a.Y) + a.X)
inside = !inside;
}
return inside;
}
private void DumpSteps(IEnumerable<WalkStep> steps, Func<WalkStep, bool>? filter = null)
{
foreach (var st in steps)
{
if (filter is not null && !filter(st)) continue;
string root = st.RootCellId == 0 ? "OUTDOOR " : FormattableString.Invariant($"0x{st.RootCellId:X8}");
string cone = st.ConeVisible ? "VIS " : "CULL";
string stage = st.OutsideStage ? "outside" : "last ";
string depth = st.DepthCheckApplies ? (st.DepthPass ? "pass" : "FAIL") : "n/a ";
_out.WriteLine(FormattableString.Invariant(
$"step={st.Index,3} s={st.WalkS,6:F2} cS={st.CenterS,6:F2} eyeS={st.EyeS,6:F2} root={root} pCell=0x{st.PlayerParentCellId:X8} cone={cone} stage={stage} depth={depth} outPolys={st.OutsidePolys} flood={st.FloodCells}"));
}
}
// ── the pins ────────────────────────────────────────────────────────
/// <summary>
/// Candidates 13 (cone level): per step of the exit walk, the player sphere must
/// survive the exact DrawDynamicsLast cone predicate. A failing step pins the
/// side-test / cone-tightness / incoherence family with its exact geometry.
/// </summary>
[Fact]
public void ExitWalk_PlayerStaysConeVisible_EveryStep()
{
var steps = RunExitWalk();
if (steps is null) return;
var failures = steps.FindAll(s => !s.ConeVisible);
if (failures.Count > 0)
{
_out.WriteLine($"--- {failures.Count} cone-CULLED steps ---");
DumpSteps(failures);
}
Assert.True(failures.Count == 0,
$"{failures.Count}/{steps.Count} steps cone-cull the player (first at step {(failures.Count > 0 ? failures[0].Index : -1)}) — see output");
}
/// <summary>
/// Candidate 4 (depth level): on every step where the root is interior and the
/// cone admits the outdoor player, the player's fragments must also SURVIVE the
/// exit-portal SEAL's depth — otherwise DrawDynamicsLast paints nothing (the
/// vanish) and a straddling body is cut at the door plane (the clip).
/// </summary>
[Fact]
public void ExitWalk_PlayerSurvivesSealDepth_WhenConeVisible()
{
var steps = RunExitWalk();
if (steps is null) return;
var applicable = steps.FindAll(s => s.DepthCheckApplies);
_out.WriteLine($"depth check applies on {applicable.Count}/{steps.Count} steps");
var failures = applicable.FindAll(s => !s.DepthPass);
if (failures.Count > 0)
{
_out.WriteLine($"--- {failures.Count} seal-depth-FAILED steps ---");
DumpSteps(failures);
}
Assert.True(failures.Count == 0,
$"{failures.Count}/{applicable.Count} applicable steps z-fail the player against the exit-portal seal — " +
"dynamics drawn after the TRUE-depth seal cannot appear beyond the door plane (see output)");
}
/// <summary>
/// The straddle ("clipped") phase: while the player sphere crosses the door
/// plane under an interior root, it must be assigned to the OUTSIDE stage
/// (drawn pre-seal — for indoor-classified straddlers that is retail's
/// per-overlapped-shadow-cell dual draw, DrawBlock pc:430056-430064), or the
/// beyond-plane body half is cut at the plane by the seal.
/// </summary>
[Fact]
public void ExitWalk_StraddlingPlayerDrawsInOutsideStage()
{
var steps = RunExitWalk();
if (steps is null) return;
var straddling = steps.FindAll(s =>
s.RootCellId != 0u && MathF.Abs(s.CenterS) < PlayerSphereRadius);
Assert.True(straddling.Count > 0, "walk produced no interior-root straddle steps — geometry changed?");
var failures = straddling.FindAll(s => !s.OutsideStage);
if (failures.Count > 0)
{
_out.WriteLine($"--- {failures.Count} straddle steps NOT outside-stage ---");
DumpSteps(failures);
}
Assert.True(failures.Count == 0,
$"{failures.Count}/{straddling.Count} straddle steps are not outside-stage-assigned — the seal clips the body at the door plane");
}
/// <summary>Full per-step table for the handoff doc.</summary>
[Fact]
public void Diagnostic_ExitWalk_PerStepTable()
{
var steps = RunExitWalk();
if (steps is null) return;
DumpSteps(steps);
// transition summary
int firstOutPlayer = steps.FindIndex(s => s.CenterS > 0f);
int firstOutRoot = steps.FindIndex(s => s.RootCellId == 0u);
_out.WriteLine(FormattableString.Invariant(
$"player center exits at step {firstOutPlayer}; viewer root flips outdoor at step {firstOutRoot}; interior-root/outdoor-player window = {Math.Max(0, firstOutRoot - firstOutPlayer)} steps"));
}
/// <summary>
/// Candidate 2 quantifier (synthetic): the INCOHERENT (root=interior, eye outside)
/// pair the healthy sweep never produces. Documents how the flood degrades if the
/// root ever lagged the eye: within PortalSideEpsilon the exit portal still
/// traverses; beyond it the side test culls and OutsideView goes EMPTY (which would
/// cull ALL outdoor content — terrain included, not just the player).
/// </summary>
[Fact]
public void Diagnostic_StaleRootWindow_EyeJustOutside()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = CornerFloodReplayTests.LoadBuilding(dats);
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
var exitCell = cells[ExitCellId];
var door = FindExitDoor(exitCell);
var out2d = Vector3.Normalize(new Vector3(door.OutwardNormal.X, door.OutwardNormal.Y, 0f));
var eyeBase = new Vector3(door.WorldCentroid.X, door.WorldCentroid.Y, door.FloorZ + 1.8f);
var playerC = eyeBase + out2d * 1.5f; // player just outside, in front of the eye
var clipFrame = ClipFrame.NoClip();
foreach (float d in new[] { 0.005f, 0.02f, 0.05f, 0.10f, 0.25f })
{
var eye = eyeBase + out2d * d;
var view = Matrix4x4.CreateLookAt(eye, eye + out2d, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 1280f / 720f, 0.1f, 5000f);
var viewProj = view * proj;
var pv = PortalVisibilityBuilder.Build(exitCell, eye, lookup, viewProj);
var asm = ClipFrameAssembler.Assemble(clipFrame, pv);
var cone = ViewconeCuller.Build(asm, viewProj);
bool playerVisible = cone.SphereVisibleOutside(playerC, PlayerSphereRadius);
_out.WriteLine(FormattableString.Invariant(
$"eye {d * 100,5:F1} cm OUTSIDE + root still 0x{ExitCellId:X8}: outPolys={pv.OutsideView.Polygons.Count} flood={pv.OrderedVisibleCells.Count} playerConeVisible={playerVisible}"));
}
}
}