#118: outdoor dynamics draw in the outside stage under interior roots - the house-exit clip+vanish was the SEAL z-killing the player
Root cause (pinned by the new deterministic exit-walk harness, NOT guessed):
under an interior render root, the exit-portal SEAL stamps the door fan at
TRUE depth after the gated full depth clear, and T1's "ALL dynamics last"
pass then drew the outdoor-classified player depth-tested - every fragment
beyond the door plane z-failed against the seal across the whole aperture.
Harness measured the full window: from the moment the sphere center crosses
the plane until the eye follows (~2.6 m of camera lag, ~2.2 s at walk speed)
the player is invisible; while straddling, the beyond-plane body half clips
at the plane. The handoff's three cone-level candidates are all EXONERATED:
the cone walk passes every step; (eye, ViewerCellId) come from the same
SweepEye call with camera-update-before-visibility-read in the same frame;
the side-test window is sub-epsilon under healthy resolution.
Retail oracle (grep-named-first): PView::DrawCells 0x005a4840 runs
LScape::draw FIRST (pc:432719), then the gated depth clear (pc:432731-32)
and the exit-portal seals (pc:432785-86); outdoor cell objects draw inside
the landscape stage (DrawBlock 0x005a17c0 -> DrawSortCell pc:430124), and
an object draws once per overlapped shadow cell (pc:430056-64) - the
straddling body composes from both stages, neither half clips.
Fix: RetailPViewRenderer assigns dynamics to the OUTSIDE stage under an
interior root when outdoor-classified OR sphere-straddling an exit-portal
plane of their flood-visible cell (DynamicDrawsInOutsideStage - pure, the
harness drives it as the ordering contract); they ride the landscape slice
draw (pre-clear, seal-protected) with the same per-slice cone test as
outdoor statics. Indoor dynamics keep the last pass (retail loop C);
straddlers draw in both (retail shadow dual-draw). Outdoor roots keep
all-dynamics-last - the BR-2 punch-after-dynamics lesson (88be519) stands.
Apparatus: HouseExitWalkReplayTests - dat-backed corner-building exit walk
driving the production stack headlessly (RetailChaseCamera damping ->
healthy-sweep viewer resolution -> PortalVisibilityBuilder.Build ->
ClipFrameAssembler -> ViewconeCuller -> the DrawDynamicsLast predicate +
a CPU seal-depth model). 5 tests: cone pin, seal-depth pin, straddle
dual-draw pin, per-step table, stale-root window quantifier (#118 cand 2).
Suites: App 232 (227+5), Core 1416+2skip, UI 420, Net 294.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
acaaeae434
commit
5a80a2ee24
5 changed files with 628 additions and 10 deletions
|
|
@ -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
|
## #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)
|
**Severity:** MEDIUM-HIGH (every house exit, brief)
|
||||||
**Filed:** 2026-06-11 (T5 comprehensive gate, user item 10)
|
**Filed:** 2026-06-11 (T5 comprehensive gate, user item 10)
|
||||||
**Component:** render — dynamics handling at the indoor→outdoor transition
|
**Component:** render — dynamics handling at the indoor→outdoor transition
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,14 @@ public static class InteriorEntityPartition
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsIndoorCellId(uint cellId)
|
/// <summary>Shared indoor classification — keep DrawDynamicsLast, the
|
||||||
|
/// outside-stage assignment (#118), and the partition in lockstep.</summary>
|
||||||
|
public static bool IsIndoorCellId(uint cellId)
|
||||||
{
|
{
|
||||||
uint low = cellId & 0xFFFFu;
|
uint low = cellId & 0xFFFFu;
|
||||||
return low >= 0x0100u && low != 0xFFFFu;
|
return low >= 0x0100u && low != 0xFFFFu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="IsIndoorCellId(uint)"/>
|
||||||
|
public static bool IsIndoorCellId(uint? cellId) => cellId is uint c && IsIndoorCellId(c);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,12 +109,39 @@ public sealed class RetailPViewRenderer
|
||||||
// assembled slices + this frame's view-projection.
|
// assembled slices + this frame's view-projection.
|
||||||
var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection);
|
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);
|
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone);
|
||||||
UseIndoorMembershipOnlyRouting();
|
UseIndoorMembershipOnlyRouting();
|
||||||
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
|
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
|
||||||
DrawEnvCellShells(pvFrame);
|
DrawEnvCellShells(pvFrame);
|
||||||
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone);
|
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone);
|
||||||
DrawDynamicsLast(ctx, partition, viewcone);
|
DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
@ -216,6 +243,17 @@ public sealed class RetailPViewRenderer
|
||||||
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
|
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
|
||||||
_outdoorStaticScratch.Add(e);
|
_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++;
|
probeSliceIndex++;
|
||||||
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
|
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
|
||||||
}
|
}
|
||||||
|
|
@ -375,7 +413,8 @@ public sealed class RetailPViewRenderer
|
||||||
private void DrawDynamicsLast(
|
private void DrawDynamicsLast(
|
||||||
IRetailPViewCellDrawContext ctx,
|
IRetailPViewCellDrawContext ctx,
|
||||||
InteriorEntityPartition.Result partition,
|
InteriorEntityPartition.Result partition,
|
||||||
ViewconeCuller viewcone)
|
ViewconeCuller viewcone,
|
||||||
|
bool rootIsOutdoor)
|
||||||
{
|
{
|
||||||
if (partition.Dynamics.Count == 0)
|
if (partition.Dynamics.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
@ -384,8 +423,16 @@ public sealed class RetailPViewRenderer
|
||||||
foreach (var e in partition.Dynamics)
|
foreach (var e in partition.Dynamics)
|
||||||
{
|
{
|
||||||
EntitySphere(e, out var c, out float r);
|
EntitySphere(e, out var c, out float r);
|
||||||
bool indoor = e.ParentCellId is uint cell
|
bool indoor = InteriorEntityPartition.IsIndoorCellId(e.ParentCellId);
|
||||||
&& (cell & 0xFFFFu) >= 0x0100u && (cell & 0xFFFFu) != 0xFFFFu;
|
// #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
|
bool visible = indoor
|
||||||
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
|
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
|
||||||
: viewcone.SphereVisibleOutside(c, r);
|
: viewcone.SphereVisibleOutside(c, r);
|
||||||
|
|
@ -459,6 +506,57 @@ public sealed class RetailPViewRenderer
|
||||||
private readonly List<WorldEntity> _outdoorStaticScratch = new();
|
private readonly List<WorldEntity> _outdoorStaticScratch = new();
|
||||||
private readonly List<WorldEntity> _cellStaticScratch = new();
|
private readonly List<WorldEntity> _cellStaticScratch = new();
|
||||||
private readonly List<WorldEntity> _dynamicsScratch = new();
|
private readonly List<WorldEntity> _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<WorldEntity> _outsideStageDynamics = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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.
|
||||||
|
/// </summary>
|
||||||
|
public static bool DynamicDrawsInOutsideStage(
|
||||||
|
uint? parentCellId,
|
||||||
|
Vector3 sphereCenter,
|
||||||
|
float sphereRadius,
|
||||||
|
HashSet<uint> drawableCells,
|
||||||
|
Func<uint, LoadedCell?> 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
|
// Conservative bounding sphere from the entity's cached AABB — the same
|
||||||
// bounds source the dispatcher's frustum cull uses.
|
// bounds source the dispatcher's frustum cull uses.
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ public class CornerFloodReplayTests
|
||||||
private readonly ITestOutputHelper _out;
|
private readonly ITestOutputHelper _out;
|
||||||
public CornerFloodReplayTests(ITestOutputHelper output) => _out = output;
|
public CornerFloodReplayTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
private const uint Landblock = 0xA9B40000u;
|
internal const uint Landblock = 0xA9B40000u;
|
||||||
private const uint EnvironmentFilePrefix = 0x0D000000u;
|
private const uint EnvironmentFilePrefix = 0x0D000000u;
|
||||||
|
|
||||||
private static string? ResolveDatDir()
|
internal static string? ResolveDatDir()
|
||||||
{
|
{
|
||||||
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
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
|
/// portal polygon's first 3 verts + centroid InsideSide, full portal polygons in
|
||||||
/// cell-local space, local AABB, world transform from EnvCell.Position.
|
/// cell-local space, local AABB, world transform from EnvCell.Position.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static LoadedCell LoadCell(DatCollection dats, uint cellId)
|
internal static LoadedCell LoadCell(DatCollection dats, uint cellId)
|
||||||
{
|
{
|
||||||
var envCell = dats.Get<DatEnvCell>(cellId)
|
var envCell = dats.Get<DatEnvCell>(cellId)
|
||||||
?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found");
|
?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found");
|
||||||
|
|
@ -152,7 +152,7 @@ public class CornerFloodReplayTests
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<uint, LoadedCell> LoadBuilding(DatCollection dats)
|
internal static Dictionary<uint, LoadedCell> LoadBuilding(DatCollection dats)
|
||||||
{
|
{
|
||||||
var cells = new Dictionary<uint, LoadedCell>();
|
var cells = new Dictionary<uint, LoadedCell>();
|
||||||
for (uint low = 0x016Fu; low <= 0x0175u; low++)
|
for (uint low = 0x016Fu; low <= 0x0175u; low++)
|
||||||
|
|
|
||||||
486
tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs
Normal file
486
tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #118 exit-walk harness (handoff 2026-06-11 §5): the character is clipped and then
|
||||||
|
/// vanishes for a moment when exiting a house — the window where the player is just
|
||||||
|
/// outside the door while the collided viewer (camera) is still indoors.
|
||||||
|
///
|
||||||
|
/// Per step of a deterministic eye+player path crossing the exit doorway of the
|
||||||
|
/// Holtburg corner building (cell 0xA9B40170, dat-loaded via the CornerFloodReplay
|
||||||
|
/// fixture loader), this drives the PRODUCTION decision stack headlessly:
|
||||||
|
/// viewer-cell resolution (healthy-sweep model, see below) →
|
||||||
|
/// <see cref="PortalVisibilityBuilder.Build"/> → <see cref="ClipFrameAssembler.Assemble"/> →
|
||||||
|
/// <see cref="ViewconeCuller.Build"/> → the exact DrawDynamicsLast visibility predicate
|
||||||
|
/// (RetailPViewRenderer.cs:375-401), PLUS the depth relationship DrawDynamicsLast is
|
||||||
|
/// subject to: under an INTERIOR root the exit-portal SEAL stamps the door fan at TRUE
|
||||||
|
/// depth (GameWindow.DrawRetailPViewPortalDepthWrite, forceFarZ=false) after the full
|
||||||
|
/// depth clear, and dynamics draw depth-tested AFTER it.
|
||||||
|
///
|
||||||
|
/// The four candidates this pins (handoff §5 + this session's read):
|
||||||
|
/// 1. eye/cell incoherence under damping — EXONERATED BY READ for clean exits:
|
||||||
|
/// RetailChaseCamera publishes (Position, ViewerCellId) from the SAME SweepEye call
|
||||||
|
/// (RetailChaseCamera.cs:188-203), published==damped when nothing collides (an open
|
||||||
|
/// doorway), and GameWindow updates the camera (≈:6889) BEFORE the visibility read
|
||||||
|
/// (≈:7361) in the same frame.
|
||||||
|
/// 2. exit-portal side test culling at an ε-outside eye → OutsideView EMPTY →
|
||||||
|
/// SphereVisibleOutside culls ALL outdoor dynamics. Quantified by the stale-root
|
||||||
|
/// diagnostic below (the healthy walk should never produce the incoherent pair).
|
||||||
|
/// 3. doorway-aperture cone tightness → tested per step by the predicate replica.
|
||||||
|
/// 4. (new, this session) SEAL-DEPTH vs dynamics-last ordering: a player whose
|
||||||
|
/// fragments lie BEYOND the door plane z-fails against the seal across the whole
|
||||||
|
/// aperture → invisible while fully outside (and clipped at the plane while
|
||||||
|
/// straddling). Tested per step by the CPU depth check (same viewProj math the GPU
|
||||||
|
/// consumes).
|
||||||
|
///
|
||||||
|
/// Healthy-sweep model: the corner-seal replay (b21bb28) + the camera read above prove
|
||||||
|
/// the sweep resolves the eye's ACTUAL cell same-frame, so the harness derives the
|
||||||
|
/// viewer cell geometrically: door-plane side decides indoor/outdoor; AABB containment
|
||||||
|
/// (smallest containing volume) picks the interior cell. The outdoor root is
|
||||||
|
/// <see cref="OutdoorCellNode.Build"/> exactly as GameWindow builds it (full-screen
|
||||||
|
/// OutsideView ⇒ outdoor dynamics trivially cone-pass).
|
||||||
|
/// </summary>
|
||||||
|
public class HouseExitWalkReplayTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public HouseExitWalkReplayTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u;
|
||||||
|
|
||||||
|
// Production humanoid entity sphere (RetailPViewRenderer.EntitySphere: AABB center +
|
||||||
|
// half-diagonal). A ~1.8 m × 0.6 m character AABB gives center ≈ feet+0.9, r ≈ 1.0.
|
||||||
|
private const float PlayerSphereRadius = 1.0f;
|
||||||
|
private static readonly Vector3 PlayerSphereCenterOffset = new(0f, 0f, 0.9f);
|
||||||
|
|
||||||
|
private sealed record ExitDoor(
|
||||||
|
int PortalIndex,
|
||||||
|
Vector3[] WorldVerts,
|
||||||
|
Vector3 WorldCentroid,
|
||||||
|
Vector3 OutwardNormal, // unit, world space, pointing OUT of the building
|
||||||
|
float FloorZ);
|
||||||
|
|
||||||
|
private sealed record WalkStep(
|
||||||
|
int Index,
|
||||||
|
float WalkS, // raw walk parameter (feet travel along the XY exit direction)
|
||||||
|
float CenterS, // signed distance of the SPHERE CENTER to the door plane (the production quantity)
|
||||||
|
float EyeS, // signed distance of the published eye
|
||||||
|
Vector3 PlayerFeet,
|
||||||
|
Vector3 Eye,
|
||||||
|
uint RootCellId, // 0 = outdoor root
|
||||||
|
uint PlayerParentCellId,
|
||||||
|
bool ConeVisible,
|
||||||
|
bool OutsideStage, // production stage assignment (DynamicDrawsInOutsideStage)
|
||||||
|
bool DepthCheckApplies,
|
||||||
|
bool DepthPass,
|
||||||
|
int OutsidePolys,
|
||||||
|
int FloodCells);
|
||||||
|
|
||||||
|
// ── fixture / geometry ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static ExitDoor FindExitDoor(LoadedCell cell)
|
||||||
|
{
|
||||||
|
int best = -1;
|
||||||
|
float bestMinZ = float.MaxValue;
|
||||||
|
for (int i = 0; i < cell.Portals.Count; i++)
|
||||||
|
{
|
||||||
|
if (cell.Portals[i].OtherCellId != 0xFFFF) continue;
|
||||||
|
if (i >= cell.PortalPolygons.Count) continue;
|
||||||
|
var poly = cell.PortalPolygons[i];
|
||||||
|
if (poly is null || poly.Length < 3) continue;
|
||||||
|
|
||||||
|
float minZ = float.MaxValue;
|
||||||
|
foreach (var v in poly)
|
||||||
|
minZ = MathF.Min(minZ, Vector3.Transform(v, cell.WorldTransform).Z);
|
||||||
|
// a DOOR reaches the floor; a window doesn't — pick the lowest-silled exit portal
|
||||||
|
if (minZ < bestMinZ) { bestMinZ = minZ; best = i; }
|
||||||
|
}
|
||||||
|
Assert.True(best >= 0, $"cell 0x{cell.CellId:X8} has no exit portal (OtherCellId==0xFFFF)");
|
||||||
|
|
||||||
|
var local = cell.PortalPolygons[best];
|
||||||
|
var world = new Vector3[local.Length];
|
||||||
|
var centroid = Vector3.Zero;
|
||||||
|
float floorZ = float.MaxValue;
|
||||||
|
for (int v = 0; v < local.Length; v++)
|
||||||
|
{
|
||||||
|
world[v] = Vector3.Transform(local[v], cell.WorldTransform);
|
||||||
|
centroid += world[v];
|
||||||
|
floorZ = MathF.Min(floorZ, world[v].Z);
|
||||||
|
}
|
||||||
|
centroid /= local.Length;
|
||||||
|
|
||||||
|
// Outward = away from the cell interior. ClipPlanes[i].InsideSide encodes which
|
||||||
|
// side the cell centroid is on (CornerFloodReplayTests.LoadCell): InsideSide==0
|
||||||
|
// ⇒ interior satisfies dot ≥ 0 ⇒ outward is −Normal; InsideSide==1 ⇒ +Normal.
|
||||||
|
var plane = cell.ClipPlanes[best];
|
||||||
|
var outwardLocal = plane.InsideSide == 0 ? -plane.Normal : plane.Normal;
|
||||||
|
var outwardWorld = Vector3.Normalize(Vector3.TransformNormal(outwardLocal, cell.WorldTransform));
|
||||||
|
|
||||||
|
return new ExitDoor(best, world, centroid, outwardWorld, floorZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float SignedSide(ExitDoor door, Vector3 p)
|
||||||
|
=> Vector3.Dot(door.OutwardNormal, p - door.WorldCentroid);
|
||||||
|
|
||||||
|
private static uint? ResolveInteriorCellByAabb(
|
||||||
|
Dictionary<uint, LoadedCell> cells, Vector3 worldPoint, float margin = 0.05f)
|
||||||
|
{
|
||||||
|
uint? best = null;
|
||||||
|
float bestVolume = float.MaxValue;
|
||||||
|
foreach (var (id, cell) in cells)
|
||||||
|
{
|
||||||
|
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
|
||||||
|
var min = cell.LocalBoundsMin - new Vector3(margin);
|
||||||
|
var max = cell.LocalBoundsMax + new Vector3(margin);
|
||||||
|
if (local.X < min.X || local.Y < min.Y || local.Z < min.Z
|
||||||
|
|| local.X > max.X || local.Y > max.Y || local.Z > max.Z)
|
||||||
|
continue;
|
||||||
|
var ext = cell.LocalBoundsMax - cell.LocalBoundsMin;
|
||||||
|
float volume = MathF.Max(ext.X, 1e-3f) * MathF.Max(ext.Y, 1e-3f) * MathF.Max(ext.Z, 1e-3f);
|
||||||
|
if (volume < bestVolume) { bestVolume = volume; best = id; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outdoor landcell id for a landblock-local position (dat EnvCell positions are
|
||||||
|
// landblock-local). Only the (low word < 0x100) classification is load-bearing for
|
||||||
|
// the DrawDynamicsLast predicate; the exact landcell is kept honest anyway.
|
||||||
|
private static uint OutdoorLandcellId(Vector3 landblockLocalPos)
|
||||||
|
{
|
||||||
|
int cx = Math.Clamp((int)MathF.Floor(landblockLocalPos.X / 24f), 0, 7);
|
||||||
|
int cy = Math.Clamp((int)MathF.Floor(landblockLocalPos.Y / 24f), 0, 7);
|
||||||
|
return CornerFloodReplayTests.Landblock | (uint)(cx * 8 + cy + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact replica of RetailPViewRenderer.DrawDynamicsLast's visibility predicate
|
||||||
|
// (RetailPViewRenderer.cs:386-391). Keep in lockstep with production.
|
||||||
|
private static bool DynamicsConeVisible(ViewconeCuller cone, uint parentCellId, Vector3 c, float r)
|
||||||
|
{
|
||||||
|
bool indoor = (parentCellId & 0xFFFFu) >= 0x0100u && (parentCellId & 0xFFFFu) != 0xFFFFu;
|
||||||
|
return indoor
|
||||||
|
? cone.SphereVisibleInCell(parentCellId, c, r)
|
||||||
|
: cone.SphereVisibleOutside(c, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the walk ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private List<WalkStep>? RunExitWalk()
|
||||||
|
{
|
||||||
|
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; }
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var cells = CornerFloodReplayTests.LoadBuilding(dats);
|
||||||
|
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||||
|
|
||||||
|
var exitCell = cells[ExitCellId];
|
||||||
|
var door = FindExitDoor(exitCell);
|
||||||
|
_out.WriteLine(FormattableString.Invariant(
|
||||||
|
$"exit door: cell=0x{ExitCellId:X8} portal[{door.PortalIndex}] centroid=({door.WorldCentroid.X:F2},{door.WorldCentroid.Y:F2},{door.WorldCentroid.Z:F2}) outward=({door.OutwardNormal.X:F2},{door.OutwardNormal.Y:F2},{door.OutwardNormal.Z:F2}) floorZ={door.FloorZ:F2} verts={door.WorldVerts.Length}"));
|
||||||
|
|
||||||
|
// Walk straight out through the door centroid along the outward normal's XY
|
||||||
|
// projection (doors are vertical; assert so).
|
||||||
|
var out2d = Vector3.Normalize(new Vector3(door.OutwardNormal.X, door.OutwardNormal.Y, 0f));
|
||||||
|
Assert.True(MathF.Abs(door.OutwardNormal.Z) < 0.3f, "exit door is not vertical — walk path invalid");
|
||||||
|
|
||||||
|
float yaw = MathF.Atan2(out2d.Y, out2d.X);
|
||||||
|
const float dt = 1f / 60f;
|
||||||
|
const float stepLen = 0.02f; // 2 cm per frame = 1.2 m/s walk
|
||||||
|
const float sStart = -1.2f, sEnd = 3.0f; // door plane at s=0
|
||||||
|
var velocity = out2d * (stepLen / dt);
|
||||||
|
|
||||||
|
var camera = new RetailChaseCamera { Aspect = 1280f / 720f };
|
||||||
|
|
||||||
|
Vector3 FeetAt(float s) => new(
|
||||||
|
door.WorldCentroid.X + out2d.X * s,
|
||||||
|
door.WorldCentroid.Y + out2d.Y * s,
|
||||||
|
door.FloorZ);
|
||||||
|
|
||||||
|
// Warm the damped boom to convergence at the start pose (retail's convergence
|
||||||
|
// snap freezes it once the lerp step is sub-epsilon).
|
||||||
|
for (int i = 0; i < 240; i++)
|
||||||
|
camera.Update(FeetAt(sStart), yaw, Vector3.Zero, isOnGround: true, Vector3.UnitZ, dt);
|
||||||
|
|
||||||
|
var clipFrame = ClipFrame.NoClip();
|
||||||
|
var steps = new List<WalkStep>();
|
||||||
|
int stepCount = (int)MathF.Round((sEnd - sStart) / stepLen);
|
||||||
|
|
||||||
|
for (int i = 0; i <= stepCount; i++)
|
||||||
|
{
|
||||||
|
float s = sStart + i * stepLen;
|
||||||
|
var feet = FeetAt(s);
|
||||||
|
camera.Update(feet, yaw, velocity, isOnGround: true, Vector3.UnitZ, dt);
|
||||||
|
var eye = camera.Position;
|
||||||
|
var viewProj = camera.View * camera.Projection;
|
||||||
|
|
||||||
|
float eyeS = SignedSide(door, eye);
|
||||||
|
var sphereC = feet + PlayerSphereCenterOffset;
|
||||||
|
float playerS = SignedSide(door, sphereC);
|
||||||
|
|
||||||
|
// membership model: the controller's pick is center point-in-cell (physics
|
||||||
|
// digest, P1) — the player's ParentCellId flips when the sphere CENTER
|
||||||
|
// crosses the door plane.
|
||||||
|
uint playerCell = playerS <= 0f
|
||||||
|
? ExitCellId
|
||||||
|
: OutdoorLandcellId(feet);
|
||||||
|
|
||||||
|
// healthy-sweep viewer resolution: plane side decides indoor/outdoor;
|
||||||
|
// AABB containment picks the interior cell the eye is in.
|
||||||
|
uint rootCellId = 0u;
|
||||||
|
LoadedCell? root = null;
|
||||||
|
if (eyeS <= 0f)
|
||||||
|
{
|
||||||
|
var contained = ResolveInteriorCellByAabb(cells, eye);
|
||||||
|
Assert.True(contained.HasValue,
|
||||||
|
FormattableString.Invariant(
|
||||||
|
$"step {i}: eye=({eye.X:F2},{eye.Y:F2},{eye.Z:F2}) (eyeS={eyeS:F2}) is inside the door plane but no loaded cell AABB contains it — adjust the walk"));
|
||||||
|
rootCellId = contained.Value;
|
||||||
|
root = cells[rootCellId];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
root = OutdoorCellNode.Build(OutdoorLandcellId(eye));
|
||||||
|
}
|
||||||
|
|
||||||
|
var pv = PortalVisibilityBuilder.Build(root, eye, lookup, viewProj);
|
||||||
|
var asm = ClipFrameAssembler.Assemble(clipFrame, pv);
|
||||||
|
var cone = ViewconeCuller.Build(asm, viewProj);
|
||||||
|
|
||||||
|
bool coneVisible = DynamicsConeVisible(cone, playerCell, sphereC, PlayerSphereRadius);
|
||||||
|
|
||||||
|
// Production stage assignment (#118 fix): an outside-stage dynamic draws
|
||||||
|
// BEFORE the depth clear + seal (retail LScape::draw → DrawSortCell), so
|
||||||
|
// the seal PROTECTS its pixels instead of z-killing them.
|
||||||
|
var drawable = new HashSet<uint>(pv.OrderedVisibleCells);
|
||||||
|
bool outsideStage = rootCellId != 0u
|
||||||
|
&& RetailPViewRenderer.DynamicDrawsInOutsideStage(
|
||||||
|
playerCell, sphereC, PlayerSphereRadius, drawable, lookup);
|
||||||
|
|
||||||
|
// Candidate 4: the seal depth check. Applies when the root is INTERIOR
|
||||||
|
// (ClearDepthForInterior + the TRUE-depth seal run, GameWindow:7719-7729),
|
||||||
|
// the player sphere center lies BEYOND the door plane on the ray from the
|
||||||
|
// eye, AND the player draws in the post-seal last pass (not outside-stage).
|
||||||
|
bool depthApplies = false, depthPass = true;
|
||||||
|
if (rootCellId != 0u && coneVisible && playerS > 0f && eyeS < 0f && !outsideStage)
|
||||||
|
{
|
||||||
|
depthApplies = TrySealDepthCheck(door, eye, sphereC, viewProj, out depthPass);
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.Add(new WalkStep(
|
||||||
|
i, s, playerS, eyeS, feet, eye, rootCellId, playerCell, coneVisible, outsideStage,
|
||||||
|
depthApplies, depthPass,
|
||||||
|
pv.OutsideView.Polygons.Count, pv.OrderedVisibleCells.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CPU model of the depth relationship at the player-sphere-center pixel:
|
||||||
|
/// the SEAL (door fan at true depth, drawn after the full depth clear and before
|
||||||
|
/// dynamics — PortalDepthMaskRenderer, forceFarZ=false) vs the player fragment.
|
||||||
|
/// Returns true (out: pass) when the player's NDC depth at that pixel is ≤ the
|
||||||
|
/// seal's. Returns false (check N/A) when the eye→center ray misses the door fan
|
||||||
|
/// (the seal doesn't cover that pixel; depth there is the cleared far plane).
|
||||||
|
/// </summary>
|
||||||
|
private static bool TrySealDepthCheck(
|
||||||
|
ExitDoor door, Vector3 eye, Vector3 sphereCenter, in Matrix4x4 viewProj, out bool depthPass)
|
||||||
|
{
|
||||||
|
depthPass = true;
|
||||||
|
|
||||||
|
float dEye = Vector3.Dot(door.OutwardNormal, eye - door.WorldCentroid);
|
||||||
|
float dC = Vector3.Dot(door.OutwardNormal, sphereCenter - door.WorldCentroid);
|
||||||
|
if (dEye >= 0f || dC <= 0f) return false; // plane not between eye and center
|
||||||
|
float t = dEye / (dEye - dC);
|
||||||
|
var hit = eye + (sphereCenter - eye) * t;
|
||||||
|
|
||||||
|
// is the ray-plane hit inside the door polygon? (2D point-in-polygon in the
|
||||||
|
// plane's basis — the seal only stamps pixels inside the fan)
|
||||||
|
if (!PointInPolygon(door, hit)) return false;
|
||||||
|
|
||||||
|
var pc = Vector4.Transform(new Vector4(sphereCenter, 1f), viewProj);
|
||||||
|
var hc = Vector4.Transform(new Vector4(hit, 1f), viewProj);
|
||||||
|
if (pc.W <= 1e-6f || hc.W <= 1e-6f) return false; // behind the eye — no pixel
|
||||||
|
|
||||||
|
float playerZ = pc.Z / pc.W;
|
||||||
|
float sealZ = hc.Z / hc.W;
|
||||||
|
// GL depth test default LESS/LEQUAL: smaller NDC z = nearer = passes.
|
||||||
|
depthPass = playerZ <= sealZ + 1e-4f;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PointInPolygon(ExitDoor door, Vector3 worldPoint)
|
||||||
|
{
|
||||||
|
// build a 2D basis in the door plane
|
||||||
|
var n = door.OutwardNormal;
|
||||||
|
var u = Vector3.Normalize(Vector3.Cross(n, Vector3.UnitZ));
|
||||||
|
if (u.LengthSquared() < 1e-6f) u = Vector3.UnitX;
|
||||||
|
var v = Vector3.Cross(n, u);
|
||||||
|
|
||||||
|
Vector2 Project(Vector3 p) => new(
|
||||||
|
Vector3.Dot(p - door.WorldCentroid, u),
|
||||||
|
Vector3.Dot(p - door.WorldCentroid, v));
|
||||||
|
|
||||||
|
var pt = Project(worldPoint);
|
||||||
|
bool inside = false;
|
||||||
|
for (int i = 0, j = door.WorldVerts.Length - 1; i < door.WorldVerts.Length; j = i++)
|
||||||
|
{
|
||||||
|
var a = Project(door.WorldVerts[i]);
|
||||||
|
var b = Project(door.WorldVerts[j]);
|
||||||
|
if ((a.Y > pt.Y) != (b.Y > pt.Y)
|
||||||
|
&& pt.X < (b.X - a.X) * (pt.Y - a.Y) / (b.Y - a.Y) + a.X)
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DumpSteps(IEnumerable<WalkStep> steps, Func<WalkStep, bool>? filter = null)
|
||||||
|
{
|
||||||
|
foreach (var st in steps)
|
||||||
|
{
|
||||||
|
if (filter is not null && !filter(st)) continue;
|
||||||
|
string root = st.RootCellId == 0 ? "OUTDOOR " : FormattableString.Invariant($"0x{st.RootCellId:X8}");
|
||||||
|
string cone = st.ConeVisible ? "VIS " : "CULL";
|
||||||
|
string stage = st.OutsideStage ? "outside" : "last ";
|
||||||
|
string depth = st.DepthCheckApplies ? (st.DepthPass ? "pass" : "FAIL") : "n/a ";
|
||||||
|
_out.WriteLine(FormattableString.Invariant(
|
||||||
|
$"step={st.Index,3} s={st.WalkS,6:F2} cS={st.CenterS,6:F2} eyeS={st.EyeS,6:F2} root={root} pCell=0x{st.PlayerParentCellId:X8} cone={cone} stage={stage} depth={depth} outPolys={st.OutsidePolys} flood={st.FloodCells}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the pins ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Candidates 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}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue