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}")); + } + } +}