acdream/tests/AcDream.App.Tests/Rendering/Walk/WalkPViewFloodTests.cs
Erik be9b4c1f29 feat(render): S3 chunk 2 — gate the interior turn on the real outside-view count
RetailFrameWalk.DrawInside now passes _interiorPView.OutsideView.ViewCount
into IWalkEventSink.OnInteriorFloodDrawTurn. WalkFrameDriver's own
implementation reproduces PView::DrawCells @0x005a4840's exact gating
(0x005a4852-0x005a49eb, all inside `if (outside_view.view_count > 0)`):
the landscape flush (retail FlushAlphaList(0f) @0x005a4872 plus the
pre-clear dynamics hook), the device-stamp advance @0x005a4886, a GATED
depth clear (pc:432731-432732), and the exit-portal seals
(pc:432785-432786) — all four skipped entirely when outsideViewCount == 0.

The depth clear is gated on a new driver field, PortalsDrawnCount, which
models retail's D3DPolyRender::portalsDrawnCount (uint16 @0x008719b4):
read-then-zeroed at the interior root's own flood turn
(@0x005a489c-0x005a489e), and re-armed at Replay by the count
IWalkFrameLeafRenderer.DrawExitSeals now returns (the SAME portal
enumeration RetailPViewPassExecutor.DrawPortalDepthWrite already performs
— OtherCellId==0xFFFF, >=3 vertices). The field persists across frames
(never cleared by BeginFrame/AbortFrame/EndFrame/Replay), reproducing
retail's documented quirk: a fresh driver's first ov>0 frame never clears;
every later ov>0 frame clears because the previous frame's own seals armed
the counter.

_skyDrawnThisFrame — the proxy for outside_view.view_count != 0 that used
to gate the stamp re-arm — is deleted; its "second Landscape turn in one
frame" fail-loud guard moves to a frame-scoped counter
(_landscapeTurnsThisFrame). RetailPViewRenderer.ClearWalkInteriorDepth
splits into FlushWalkLandscape (pre-clear dynamics + FlushLandscapeAlpha)
and ClearWalkInteriorDepth (the Z clear only), both wired through the new
IWalkFrameLeafRenderer.FlushLandscape leaf and the WalkLeaf production
adapter.

Tests: flipped the ov==0 pin to expect no landscape-flush/clear/seals at
all (T1); added the two-frame first-frame-no-clear / armed-clear pin plus
a no-exit-portal-never-clears pin (T2); added a look-in-neither-arms-
nor-consumes-the-counter pin (T3); added RetailFrameWalk's two-PView
draw_landscape wiring pin and a WalkPView.ConstructView reset pin (T4);
updated every direct OnInteriorFloodDrawTurn caller to pass the
outsideViewCount it models (T5). The four per-category leaf-contract pins
(whole-once shell, Boolean sphere admission, portal-polygon-only clip,
local-player repeated submission) already existed and needed no additions
(B4).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 07:46:11 +02:00

232 lines
9.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.App.Rendering.Walk;
namespace AcDream.App.Tests.Rendering.Walk;
public sealed class WalkPViewFloodTests
{
private sealed class TestContext : IWalkFrameContext
{
private sealed class Caster : IWalkRayCaster
{
public Vector3 RayThrough(float screenX, float screenY)
=> new(screenX, screenY, 100f);
}
public readonly Dictionary<uint, WalkCell> Cells = new();
private readonly Matrix4x4 _viewProj;
public TestContext()
{
Matrix4x4 view = Matrix4x4.CreateLookAt(
Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
Matrix4x4 proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f);
_viewProj = view * proj;
}
// All test cells sit at identity transforms: cell-local == world.
public Vector3 ViewpointIn(WalkCell cell) => Vector3.Zero;
public Matrix4x4 ObjectToClip(WalkCell cell) => _viewProj;
public WalkCell? GetVisible(uint cellId) => Cells.GetValueOrDefault(cellId);
public IWalkRayCaster Rays { get; } = new Caster();
public Vector3 WorldViewpoint => Vector3.Zero;
public float ViewportWidth => 640f;
public float ViewportHeight => 480f;
}
private static WalkPolygon Quad(float z, float half = 0.5f, bool facingViewer = true) => new()
{
Vertices =
[
new Vector3(-half, -half, z), new Vector3(half, -half, z),
new Vector3(half, half, z), new Vector3(-half, half, z),
],
// Plane through the quad: for z=-2 facing +z, N=(0,0,1), D=2 (eye at
// origin sits on the POSITIVE side: d = +2).
Plane = new WalkPlane(new Vector3(0, 0, facingViewer ? 1f : -1f), facingViewer ? -z : z),
};
private static WalkCell Cell(
TestContext ctx, uint id, params (WalkCellPortal Portal, WalkPolygon Polygon)[] portals)
{
var cell = new WalkCell
{
CellId = id,
Portals = portals.Select(p => p.Portal).ToArray(),
PortalPolygons = portals.Select(p => p.Polygon).ToArray(),
};
cell.PushView(); // add_views/stab-list stand-in: one pushed slot
ctx.Cells[id] = cell;
return cell;
}
private static WalkPView SeedAndFlood(TestContext ctx, WalkCell seed)
{
var pview = new WalkPView();
WalkCopyView.AppendFullViewportQuad(
seed.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
pview.ConstructView(seed, 0xFFFF, ctx);
return pview;
}
[Fact]
public void Flood_traverses_a_look_through_portal_into_the_neighbor()
{
var ctx = new TestContext();
// Eye on the positive side of the portal plane (d=+2 → side 0);
// PortalSide=0 → side == PortalSide → an OPENING (look-through).
WalkCell seed = Cell(ctx, 0x100,
(new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
Quad(-2f)));
WalkCell neighbor = Cell(ctx, 0x101,
(new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0 },
Quad(-2f)));
WalkPView pview = SeedAndFlood(ctx, seed);
Assert.Equal(new[] { 0x100u, 0x101u }, pview.CellDrawList.Select(c => c.CellId));
Assert.Equal(1, neighbor.TopView.ViewCount); // the clipped view arrived
Assert.True(neighbor.TopView.CellViewDone); // and the neighbor was processed
}
[Fact]
public void Facing_portal_is_not_traversed_but_feeds_the_distance_key()
{
var ctx = new TestContext();
// PortalSide=1 while the eye computes side 0 → the portal FACES the
// viewer (a visible surface, not an opening).
WalkCell seed = Cell(ctx, 0x100,
(new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0 },
Quad(-2f)));
Cell(ctx, 0x101,
(new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
Quad(-2f)));
WalkPView pview = SeedAndFlood(ctx, seed);
Assert.Equal(new[] { 0x100u }, pview.CellDrawList.Select(c => c.CellId));
// max_indist = squared distance to the farthest facing-portal vertex:
// (±0.5, ±0.5, 2) from the origin → 0.25 + 0.25 + 4.
Assert.Equal(4.5f, seed.TopView.MaxInDistSquared, 3);
}
[Fact]
public void Exit_portal_raises_the_outside_view_only_when_landscape_is_drawn()
{
var ctx = new TestContext();
WalkCell seed = Cell(ctx, 0x100,
(new WalkCellPortal { OtherCellId = 0xFFFFFFFF, PolygonIndex = 0, PortalSide = 0, OtherPortalId = -1 },
Quad(-2f)));
WalkPView pview = SeedAndFlood(ctx, seed);
Assert.Equal(1, pview.OutsideView.ViewCount);
// draw_landscape == 0 discards exit views entirely.
seed.PopView();
seed.PushView();
var noLandscape = new WalkPView { DrawLandscape = false };
WalkCopyView.AppendFullViewportQuad(
seed.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
noLandscape.ConstructView(seed, 0xFFFF, ctx);
Assert.Equal(0, noLandscape.OutsideView.ViewCount);
}
[Fact]
public void Flood_never_walks_back_through_the_entry_portal()
{
var ctx = new TestContext();
// Two cells looking into each other; both sides traversable by
// geometry. Without the entry-portal force, the flood would ping-pong.
WalkCell a = Cell(ctx, 0x100,
(new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
Quad(-2f)));
WalkCell b = Cell(ctx, 0x101,
(new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
Quad(-2f)));
WalkPView pview = SeedAndFlood(ctx, a);
Assert.Equal(2, pview.CellDrawList.Count);
Assert.Equal(2, pview.CellDrawList.Select(c => c.CellId).Distinct().Count());
}
[Fact]
public void Deeper_chain_floods_in_nearest_first_pop_order()
{
var ctx = new TestContext();
// seed → mid (portal at z=-2) → far (portal at z=-4): the draw list
// appends in pop order (nearest first), so the end-first draw walk
// is far-to-near.
WalkCell seed = Cell(ctx, 0x100,
(new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
Quad(-2f)));
WalkCell mid = Cell(ctx, 0x101,
(new WalkCellPortal { OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0 },
Quad(-2f)),
(new WalkCellPortal { OtherCellId = 0x102, PolygonIndex = 1, PortalSide = 0, OtherPortalId = 0 },
Quad(-4f, half: 0.4f)));
WalkCell far = Cell(ctx, 0x102,
(new WalkCellPortal { OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 1 },
Quad(-4f, half: 0.4f)));
WalkPView pview = SeedAndFlood(ctx, seed);
Assert.Equal(
new[] { 0x100u, 0x101u, 0x102u },
pview.CellDrawList.Select(c => c.CellId));
Assert.Equal(1, far.TopView.ViewCount);
}
// ── T4 / R2 (S3 chunk 2): PView::ConstructView @0x005a57b0 resets
// outside_view.view_count = 0, master_timestamp++, cell_todo_num = 0,
// and cell_draw_num = 0 BEFORE InitCell — a second flood on the SAME
// pview instance must not accumulate the first flood's draw list or
// outside view. ──────────────────────────────────────────────────────
[Fact]
public void ConstructView_CalledTwiceOnTheSameInstance_ResetsCellDrawListAndOutsideViewEachTime()
{
var ctx = new TestContext();
WalkCell first = Cell(ctx, 0x200,
(new WalkCellPortal { OtherCellId = 0xFFFFFFFF, PolygonIndex = 0, PortalSide = 0, OtherPortalId = -1 },
Quad(-2f)));
WalkCell second = Cell(ctx, 0x300,
(new WalkCellPortal { OtherCellId = 0x301, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
Quad(-2f)));
Cell(ctx, 0x301,
(new WalkCellPortal { OtherCellId = 0x300, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0 },
Quad(-2f)));
var pview = new WalkPView();
WalkCopyView.AppendFullViewportQuad(
first.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
pview.ConstructView(first, 0xFFFF, ctx);
Assert.Equal(new[] { 0x200u }, pview.CellDrawList.Select(c => c.CellId));
Assert.Equal(1, pview.OutsideView.ViewCount);
int timestampAfterFirst = WalkPView.MasterTimestampForDiagnostics;
WalkCopyView.AppendFullViewportQuad(
second.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
pview.ConstructView(second, 0xFFFF, ctx);
// The second flood's own cells only — NOT the first flood's 0x200
// still sitting in CellDrawList, and the exit-facing portal from the
// first flood does not leave OutsideView.ViewCount stuck at 1.
Assert.Equal(new[] { 0x300u, 0x301u }, pview.CellDrawList.Select(c => c.CellId));
Assert.Equal(0, pview.OutsideView.ViewCount);
Assert.True(WalkPView.MasterTimestampForDiagnostics > timestampAfterFirst);
}
[Fact]
public void Unloaded_neighbor_is_silently_skipped()
{
var ctx = new TestContext();
WalkCell seed = Cell(ctx, 0x100,
(new WalkCellPortal { OtherCellId = 0x0DEAD, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 },
Quad(-2f)));
WalkPView pview = SeedAndFlood(ctx, seed);
Assert.Equal(new[] { 0x100u }, pview.CellDrawList.Select(c => c.CellId));
}
}