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>
486 lines
24 KiB
C#
486 lines
24 KiB
C#
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 1–3 (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}"));
|
||
}
|
||
}
|
||
}
|