diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index de85f291..aa939074 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -3937,7 +3937,36 @@ AFTER the punch. Compare against the T1 (`579c8b0`) punch pass wiring.
## #118 — Character clipped + disappears for a moment when exiting houses
-**Status:** OPEN
+**Status:** FIXED 2026-06-11 — pending visual re-gate
+
+**Root cause (pinned by the exit-walk harness, `HouseExitWalkReplayTests`):**
+NOT the cone stack — candidates 1–3 all exonerated (cone-level walk passes
+every step; the camera publishes (eye, ViewerCellId) from the SAME SweepEye
+call and updates before the visibility read, so the pair is coherent; the
+side-test window is ≤ PortalSideEpsilon and never occurs under healthy
+resolution). The mechanism is DEPTH ORDERING: under an interior root, the
+exit-portal SEAL stamps the door fan at TRUE depth after the full depth
+clear, and T1's "ALL dynamics last" then draws the outdoor-classified player
+depth-tested — every fragment beyond the door plane z-fails against the seal
+across the whole aperture. Full vanish once the center exits (harness: the
+entire s=0.04→2.64 m window until the eye crosses, ~2.2 s at walk speed);
+the body's beyond-plane half clips at the plane while straddling.
+
+**Retail oracle:** PView::DrawCells (0x005a4840) runs LScape::draw FIRST
+(pc:432719), THEN the gated depth clear (pc:432731) + seals (pc:432786);
+outdoor cell objects draw inside the landscape stage via DrawBlock →
+DrawSortCell (0x005a17c0, pc:430124), and an object draws once per
+overlapped shadow cell (pc:430056-430064) — so a threshold-straddling body
+draws in both stages and neither half clips.
+
+**Fix (`RetailPViewRenderer`):** under an interior root, outdoor-classified
+dynamics draw in the OUTSIDE (landscape) stage — before the clear+seal, so
+the seal protects their pixels — and indoor dynamics whose sphere straddles
+an exit-portal plane draw in BOTH stages (`DynamicDrawsInOutsideStage`).
+Outdoor roots keep all-dynamics-last (the BR-2 punch lesson). Pins:
+`ExitWalk_PlayerStaysConeVisible_EveryStep`,
+`ExitWalk_PlayerSurvivesSealDepth_WhenConeVisible`,
+`ExitWalk_StraddlingPlayerDrawsInOutsideStage`.
**Severity:** MEDIUM-HIGH (every house exit, brief)
**Filed:** 2026-06-11 (T5 comprehensive gate, user item 10)
**Component:** render — dynamics handling at the indoor→outdoor transition
diff --git a/src/AcDream.App/Rendering/InteriorEntityPartition.cs b/src/AcDream.App/Rendering/InteriorEntityPartition.cs
index 276088e4..9ef43dda 100644
--- a/src/AcDream.App/Rendering/InteriorEntityPartition.cs
+++ b/src/AcDream.App/Rendering/InteriorEntityPartition.cs
@@ -73,9 +73,14 @@ public static class InteriorEntityPartition
return result;
}
- private static bool IsIndoorCellId(uint cellId)
+ /// Shared indoor classification — keep DrawDynamicsLast, the
+ /// outside-stage assignment (#118), and the partition in lockstep.
+ public static bool IsIndoorCellId(uint cellId)
{
uint low = cellId & 0xFFFFu;
return low >= 0x0100u && low != 0xFFFFu;
}
+
+ ///
+ public static bool IsIndoorCellId(uint? cellId) => cellId is uint c && IsIndoorCellId(c);
}
diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index a85b6d2c..83d3831f 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -109,12 +109,39 @@ public sealed class RetailPViewRenderer
// assembled slices + this frame's view-projection.
var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection);
+ // #118: stage assignment for dynamics under an INTERIOR root. Retail
+ // draws the OUTSIDE world's objects inside the landscape stage —
+ // PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated
+ // full depth clear (pc:432731-432732) and the exit-portal SEALS
+ // (pc:432785-432786); DrawBlock draws every landcell's objects via
+ // DrawSortCell (0x005a17c0, pc:430124). A dynamic deferred to our
+ // single last pass instead z-fails against the seal's true-depth stamp
+ // the moment it stands beyond the door plane — the house-exit
+ // clip+vanish (pinned by HouseExitWalkReplayTests). So under an
+ // interior root: outdoor-classified dynamics draw in the outside
+ // stage; an indoor dynamic whose sphere STRADDLES an exit portal
+ // draws in BOTH stages (retail's per-overlapped-cell shadow-part
+ // draw, DrawBlock pc:430056-430064) so neither body half clips at the
+ // plane. Outdoor roots keep ALL dynamics in the last pass — our
+ // z-buffered equivalent of retail's painter-ordered outdoor pass (the
+ // BR-2 punch-after-dynamics lesson, reverted 88be519).
+ _outsideStageDynamics.Clear();
+ if (!ctx.RootCell.IsOutdoorNode)
+ {
+ foreach (var e in partition.Dynamics)
+ {
+ EntitySphere(e, out var c, out float r);
+ if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.CellLookup))
+ _outsideStageDynamics.Add(e);
+ }
+ }
+
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone);
UseIndoorMembershipOnlyRouting();
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(pvFrame);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone);
- DrawDynamicsLast(ctx, partition, viewcone);
+ DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode);
return result;
}
@@ -216,6 +243,17 @@ public sealed class RetailPViewRenderer
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
+ // #118: outside-stage dynamics ride the landscape pass like retail's
+ // per-landcell DrawSortCell (DrawBlock 0x005a17c0, pc:430124) — drawn
+ // BEFORE the depth clear + seals so the seal PROTECTS their pixels in
+ // the aperture instead of z-killing them. Same per-slice cone test as
+ // the statics above. Empty under outdoor roots (see DrawInside).
+ foreach (var e in _outsideStageDynamics)
+ {
+ EntitySphere(e, out var c, out float r);
+ if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
+ _outdoorStaticScratch.Add(e);
+ }
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
}
@@ -375,7 +413,8 @@ public sealed class RetailPViewRenderer
private void DrawDynamicsLast(
IRetailPViewCellDrawContext ctx,
InteriorEntityPartition.Result partition,
- ViewconeCuller viewcone)
+ ViewconeCuller viewcone,
+ bool rootIsOutdoor)
{
if (partition.Dynamics.Count == 0)
return;
@@ -384,8 +423,16 @@ public sealed class RetailPViewRenderer
foreach (var e in partition.Dynamics)
{
EntitySphere(e, out var c, out float r);
- bool indoor = e.ParentCellId is uint cell
- && (cell & 0xFFFFu) >= 0x0100u && (cell & 0xFFFFu) != 0xFFFFu;
+ bool indoor = InteriorEntityPartition.IsIndoorCellId(e.ParentCellId);
+ // #118: under an interior root, outdoor-classified dynamics drew in
+ // the outside stage (pre-clear, seal-protected) — retail draws them
+ // via LScape::draw's per-landcell DrawSortCell, never in the
+ // post-seal cell-object epilogue (PView::DrawCells pc:432719 vs
+ // pc:432878). Drawing them here instead z-fails them against the
+ // seal. Indoor dynamics (incl. exit-portal straddlers, which drew
+ // in BOTH stages) stay — this pass is retail's loop C.
+ if (!rootIsOutdoor && !indoor)
+ continue;
bool visible = indoor
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
: viewcone.SphereVisibleOutside(c, r);
@@ -459,6 +506,57 @@ public sealed class RetailPViewRenderer
private readonly List _outdoorStaticScratch = new();
private readonly List _cellStaticScratch = new();
private readonly List _dynamicsScratch = new();
+ // #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
+ // only) — outdoor-classified + exit-portal straddlers. Cleared per frame.
+ private readonly List _outsideStageDynamics = new();
+
+ ///
+ /// #118 stage assignment for a dynamic under an INTERIOR root: does it draw
+ /// in the OUTSIDE (landscape) stage — before the gated depth clear and the
+ /// exit-portal seals — like retail's per-landcell object draw
+ /// (LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell pc:430124, run at
+ /// the top of PView::DrawCells pc:432719)?
+ ///
+ /// True for outdoor-classified dynamics (their fragments lie beyond the
+ /// door plane and would z-fail the seal in the last pass), and for INDOOR
+ /// dynamics whose sphere straddles an exit-portal plane of their flood-
+ /// visible cell — retail draws an object once per overlapped shadow cell
+ /// (DrawBlock pc:430056-430064), so a threshold-straddling body draws in
+ /// both stages and neither half clips at the plane. Pure — also driven
+ /// headlessly by HouseExitWalkReplayTests as the ordering contract.
+ ///
+ public static bool DynamicDrawsInOutsideStage(
+ uint? parentCellId,
+ Vector3 sphereCenter,
+ float sphereRadius,
+ HashSet drawableCells,
+ Func cellLookup)
+ {
+ if (!InteriorEntityPartition.IsIndoorCellId(parentCellId))
+ return true;
+
+ uint cellId = parentCellId!.Value;
+ if (!drawableCells.Contains(cellId))
+ return false; // not in the flood — the last-pass cone cull owns it
+ var cell = cellLookup(cellId);
+ if (cell is null)
+ return false;
+
+ var localC = Vector3.Transform(sphereCenter, cell.InverseWorldTransform);
+ int n = Math.Min(cell.Portals.Count, cell.ClipPlanes.Count);
+ for (int i = 0; i < n; i++)
+ {
+ if (cell.Portals[i].OtherCellId != 0xFFFF)
+ continue;
+ var plane = cell.ClipPlanes[i];
+ if (plane.Normal.LengthSquared() < 1e-8f)
+ continue;
+ float dist = Vector3.Dot(plane.Normal, localC) + plane.D;
+ if (MathF.Abs(dist) < sphereRadius)
+ return true; // sphere straddles the exit-portal plane
+ }
+ return false;
+ }
// Conservative bounding sphere from the entity's cached AABB — the same
// bounds source the dispatcher's frustum cull uses.
diff --git a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs
index b95a47d1..2212490a 100644
--- a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs
@@ -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.
///
- private static LoadedCell LoadCell(DatCollection dats, uint cellId)
+ internal static LoadedCell LoadCell(DatCollection dats, uint cellId)
{
var envCell = dats.Get(cellId)
?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found");
@@ -152,7 +152,7 @@ public class CornerFloodReplayTests
};
}
- private static Dictionary LoadBuilding(DatCollection dats)
+ internal static Dictionary LoadBuilding(DatCollection dats)
{
var cells = new Dictionary();
for (uint low = 0x016Fu; low <= 0x0175u; low++)
diff --git a/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs b/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs
new file mode 100644
index 00000000..a011beb5
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs
@@ -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;
+
+///
+/// #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) →
+/// → →
+/// → 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
+/// exactly as GameWindow builds it (full-screen
+/// OutsideView ⇒ outdoor dynamics trivially cone-pass).
+///
+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 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? 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 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();
+ 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(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;
+ }
+
+ ///
+ /// 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).
+ ///
+ 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 steps, Func? 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 ────────────────────────────────────────────────────────
+
+ ///
+ /// 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.
+ ///
+ [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");
+ }
+
+ ///
+ /// 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).
+ ///
+ [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)");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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");
+ }
+
+ /// Full per-step table for the handoff doc.
+ [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"));
+ }
+
+ ///
+ /// 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).
+ ///
+ [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 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}"));
+ }
+ }
+}