#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>
This commit is contained in:
Erik 2026-06-11 16:49:29 +02:00
parent acaaeae434
commit 5a80a2ee24
5 changed files with 628 additions and 10 deletions

View file

@ -35,10 +35,10 @@ public class CornerFloodReplayTests
private readonly ITestOutputHelper _out;
public CornerFloodReplayTests(ITestOutputHelper output) => _out = output;
private const uint Landblock = 0xA9B40000u;
internal const uint Landblock = 0xA9B40000u;
private const uint EnvironmentFilePrefix = 0x0D000000u;
private static string? ResolveDatDir()
internal static string? ResolveDatDir()
{
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
@ -55,7 +55,7 @@ public class CornerFloodReplayTests
/// portal polygon's first 3 verts + centroid InsideSide, full portal polygons in
/// cell-local space, local AABB, world transform from EnvCell.Position.
/// </summary>
private static LoadedCell LoadCell(DatCollection dats, uint cellId)
internal static LoadedCell LoadCell(DatCollection dats, uint cellId)
{
var envCell = dats.Get<DatEnvCell>(cellId)
?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found");
@ -152,7 +152,7 @@ public class CornerFloodReplayTests
};
}
private static Dictionary<uint, LoadedCell> LoadBuilding(DatCollection dats)
internal static Dictionary<uint, LoadedCell> LoadBuilding(DatCollection dats)
{
var cells = new Dictionary<uint, LoadedCell>();
for (uint low = 0x016Fu; low <= 0x0175u; low++)

View file

@ -0,0 +1,486 @@
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}"));
}
}
}