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