#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
|
||||
|
||||
**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
|
||||
|
|
|
|||
|
|
@ -73,9 +73,14 @@ public static class InteriorEntityPartition
|
|||
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;
|
||||
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.
|
||||
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<WorldEntity> _outdoorStaticScratch = new();
|
||||
private readonly List<WorldEntity> _cellStaticScratch = 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
|
||||
// bounds source the dispatcher's frustum cull uses.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// </summary>
|
||||
private static LoadedCell LoadCell(DatCollection dats, uint cellId)
|
||||
internal static LoadedCell LoadCell(DatCollection dats, uint cellId)
|
||||
{
|
||||
var envCell = dats.Get<DatEnvCell>(cellId)
|
||||
?? 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>();
|
||||
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