#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:
Erik 2026-06-11 16:49:29 +02:00
parent acaaeae434
commit 5a80a2ee24
5 changed files with 628 additions and 10 deletions

View file

@ -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++)