S4-c1 per docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md §6.
S3 chunk 2 already landed the persistent portalsDrawnCount latch, the
gated clear, the exit-seal counting, and the look-in isolation — this
chunk covers only what §1/§2 of the packet name as still owed: C0-C3.
C0 — far-punch depth constant (R1: DrawPortalPolyInternal @0x0059bc90's
tail). portal_depth.vert's punch branch carried the decimal 0.99999988,
which reinterprets as bits 0x3F7FFFFE — fifteen ULPs FARTHER from the
camera than retail's real constant, bits 0x3F7FFFEF. Now writes
`uintBitsToFloat(0x3F7FFFEFu)` so the exact bits survive the GLSL/SPIR-V
compiler instead of trusting a decimal literal to round-trip unchanged.
Recompiled via tools/compile-shaders.ps1 (glslc 1.4.350.0 backend
recorded, managed shaderc path used); portal_depth.vert.spv's SHA-256
re-pinned in VulkanShaderManifestTests
(51c60d0924d62c61548efcf5f9e7672a121b1b68ca0a06755e32f1a4d73a8acf,
was 4ac1c452e7ac0d08a32f67fb03f21229af2d1605baa81f407240a3626251dfd7).
T1 (new Fact PortalDepthVert_FarPunchConstant_MatchesRetailExactBits in
VulkanShaderManifestTests.cs): a SOURCE pin — reads portal_depth.vert's
punch line and reinterprets whatever literal it carries (uintBitsToFloat
hex or a plain decimal) as raw bits, asserts == 0x3F7FFFEF. Verified
against the PRE-CHANGE source by hand-reverting the line to
`clipPos.z = clipPos.w * 0.99999988;` and re-running just this test:
Assert.Equal() Failure: Values differ
Expected: 1065353199
Actual: 1065353214
(1065353199 = 0x3F7FFFEF, 1065353214 = 0x3F7FFFFE). Line restored and
the test re-confirmed green afterward. MUTATION: any other literal fails
the same way.
C1 — the ±12 local-input reject (R2: 0x59BCD6-0x59BD28 then
0x59BD40-0x59BD66). The Ghidra arbitration table in
oh1-depth-lifecycle.md governs over the pseudo-C's own nested-if reading
of the four x87 FCOM results (BinaryNinja's `test ah, 0x44` condition
synthesis is FPU-flag-ambiguous and reads backward at face value — see
feedback_bn_decomp_field_names.md on decompiler flag mush as an artifact
class, not semantics): the table's row says "whole poly on any
local-input x/y == +/-12 boundary is rejected before count/clip" — taken
as written, not re-derived from the pseudo-C's literal branch nesting.
Ported as one shared predicate,
WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(ReadOnlySpan
<Vector3>): true iff any vertex's X or Y is exactly +12f/-12f (retail
tests LOCAL x/y before xformStart, the world transform). Wired at BOTH
producers that own the LOCAL polygon before it leaves cell/building
space:
- WalkFrameDriver.OnPunchGeometry (the walk's punch-event producer,
IWalkEventSink.OnPunchGeometry) — checked on the building-local
WalkPolygon.Vertices before TransformToWorld; a hit returns before
MarkIfGrown/any event append (retail's reject -> transform -> clip
-> count order).
- RetailPViewPassExecutor.DrawPortalDepthWrite (the exit-seal
enumeration behind DrawExitPortalMask, the sole caller) — checked on
cell.PortalPolygons[index]'s local vertices before the
Vector3.Transform loop; a hit `continue`s with no `submitted++`.
T2 (three layers):
1. WalkVisibilityMathTests.cs — direct unit tests of the predicate:
Boundary_guard_rejects_a_polygon_with_one_vertex_exactly_on_plus_minus_12
(Theory, x/y == +-12 each), Boundary_guard_admits_a_polygon_whose_
nearest_vertex_is_just_inside_12 (Theory, x/y == +-11.999),
Boundary_guard_rejects_the_whole_polygon_even_when_only_one_of_
several_vertices_hits_it, Boundary_guard_ignores_the_vertical_z_
component, Boundary_guard_admits_the_empty_polygon.
2. WalkFrameDriverTests.OnPunchGeometry_RejectsWholePolygonOnExact
PlusMinus12LocalVertex_ButPunchesJustInside — functional: feeds
OnPunchGeometry a polygon with a vertex at x=12 (no PunchFan/no
"PUNCH:" log line) then one at x=11.999 (punches normally,
leaf.Punches has exactly one entry, log has exactly one "PUNCH:3@v0").
3. RetailPViewPassExecutorTests.DrawPortalDepthWrite_RejectsDegenerate
LocalPolygons_BeforeTransformOrSubmission — a real functional test of
DrawPortalDepthWrite needs a live PortalDepthMaskRenderer the suite
has no fake for, so this is a compiled-call-graph pin (this file's
established pattern for exactly this situation): the guard call
precedes both the Vector3.Transform loop and
PortalDepthMaskRenderer.DrawDepthFan by IL offset, gated by a
conditional branch immediately after it.
MUTATION texts, all verified live during this session then reverted:
- OnPunchGeometry_RejectsWholePolygon... with the C1 guard deleted from
OnPunchGeometry:
Assert.Single() Failure: The collection contained 2 items
Collection: [WalkPolygon { Plane = WalkPlane { Normal = <0, 0, 1>, D = -3 }, Vertices = [<0, 0, 3>, <12, 0, 3>, <5, 5, 3>] }, WalkPolygon { Plane = WalkPlane { Normal = <0, 0, 1>, D = -3 }, Vertices = [<0, 0, 3>, <11.999, 0, 3>, <5, 5, 3>] }]
- DrawPortalDepthWrite_RejectsDegenerateLocalPolygons... with the C1
guard deleted from DrawPortalDepthWrite:
Expected call to WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard.
- Boundary_guard_admits_a_polygon_whose_nearest_vertex_is_just_inside_12
with the predicate widened to `MathF.Abs(x) >= 11.99f ||
MathF.Abs(y) >= 11.99f` (all four rows):
Assert.False() Failure
Expected: False
Actual: True
- Boundary_guard_rejects_a_polygon_with_one_vertex_exactly_on_plus_
minus_12 with the predicate narrowed to strict `x > 12f || x < -12f
|| y > 12f || y < -12f` (all four rows):
Assert.True() Failure
Expected: True
Actual: False
- Boundary_guard_rejects_the_whole_polygon_even_when_only_one_of_
several_vertices_hits_it with the guard checking only
localVertices[0] instead of looping every vertex:
Assert.True() Failure
Expected: True
Actual: False
C2 — no pipeline change for R3 (depth ALWAYS/write/no-cull, color writes
ENABLED with a zero-alpha SRCALPHA/INVSRCALPHA blend). acdream's
PortalDepthMaskRenderer.Rhi.cs:92,100 sets ColorWrite=false alongside
Blend=None; portal_depth.frag writes no color output at all. Provably
pixel-identical (retail's blend collapses to dst'=dst when srcAlpha is
fixed at 0, for any RGB) and the write mask is the SAFER mechanism going
forward (structurally blocks any future accidental color write,
independent of an authored zero-alpha invariant). Added register row
AD-119 to docs/architecture/retail-divergence-register.md (the next free
id after AD-118), citing DrawPortalPolyInternal @0x0059bc90 and
PortalDepthMaskRenderer.Rhi.cs; section 2's active-row count and running
header note updated (90 -> 91).
C3 — the truth table + cross-frame latch tests. The (root kind,
draw_landscape, outside-view count, previous count) table's cells are
mostly already covered by S3 chunk 2's own tests — this chunk adds only
the genuinely missing rows/cases, and leaves every existing test
untouched:
Pre-existing coverage (named, not reproduced):
- interior, ov==0, prior==0 ->
RunFrame_InteriorFloodWithNoExitView_SkipsLandscapeAndNeverFlushesClearsOrSeals
- interior, ov>0, prior==0 ->
RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells
and OnInteriorFloodDrawTurn_FirstOvFrameSkipsClear_SecondFrameArmedByFirstsSealsClears
(its own frame 1)
- interior, ov>0, prior>0 (T4's "frame 1 seals N>0 -> frame 2
clears" half) ->
OnInteriorFloodDrawTurn_FirstOvFrameSkipsClear_SecondFrameArmedByFirstsSealsClears
(its own frame 2)
- T4's "frame 1 seals 0 -> frame 2 does not clear" half (repeated
across three consecutive ov>0 frames, subsuming the two-frame
case) -> OnInteriorFloodDrawTurn_FloodWithNoExitPortal_NeverClearsAcrossFrames
- one look-in isolated from the root latch ->
LookInDrawCells_NeitherArmsNorConsumesThePortalsDrawnCounter
No further T4 test was added — the two existing facts above already
prove both halves of the two-consecutive-frames latch case exactly.
New rows added this chunk:
- WalkFrame_OutdoorRoot_NeverFiresTheInteriorClearSealMachinery: root
kind == OUTDOOR. RetailFrameWalk.WalkFrame's outdoor branch
((cameraCellId & 0xFFFF) < 0x100) calls DrawLandscape directly and
never calls DrawInside/OnInteriorFloodDrawTurn at all, so the whole
LFLUSH/stamp/CLEAR/SEALS mechanism structurally cannot fire —
driven end-to-end through RunFrame with an outdoor cameraCellId,
asserting SKY present, LFLUSH/CLEAR/SEALS absent, counter stays 0.
MUTATION (verified, then reverted): added a stray
`sink.OnInteriorFloodDrawTurn([], 1);` call to WalkFrame's outdoor
branch:
Assert.DoesNotContain() Failure: Item found in collection
↓ (pos 1)
Collection: ["SKY", "LFLUSH", "SEALS"]
Found: "LFLUSH"
- OnInteriorFloodDrawTurn_OvZeroAfterAPriorArmedCounter_LeavesTheLatch
CompletelyUntouched: interior, ov==0 immediately after an EARLIER
ov>0 frame armed the counter — proves the counter is left EXACTLY
as an earlier frame left it (not merely "not cleared this frame"),
since S3 §8.1 R3 gates the ENTIRE outside_view.view_count>0 block,
including the read-then-zero decision itself, on ov>0.
MUTATION (verified, then reverted): moved
`int armed = PortalsDrawnCount; PortalsDrawnCount = 0;` out of the
`if (outsideViewCount > 0)` gate in
WalkFrameDriver.OnInteriorFloodDrawTurn (unconditional
read-then-zero every call):
Assert.Equal() Failure: Values differ
Expected: 1
Actual: 0
(every OTHER WalkFrameDriverTests fact stayed green under this same
mutation — this new test is the only one that catches it).
- MultipleLookIns_WithinOneFrameAndAcrossFrames_NeverTouchTheRootLatch
(T5): extends the single-look-in fact to TWO look-ins in one frame
then a THIRD in a later frame. MUTATION (verified, then reverted):
a `_mutationLookInCalls` counter in HandleDrawCellsTurn's
LookInStatic branch that resets PortalsDrawnCount on the SECOND
look-in call:
Assert.Equal() Failure: Values differ
Expected: 1
Actual: 0
— while LookInDrawCells_NeitherArmsNorConsumesThePortalsDrawnCounter
(one look-in only) stayed green under the identical mutation,
confirming this test's incremental value over the existing single-
look-in fact.
Gates: dotnet build (App.Tests and App) 0 warnings/0 errors; hermetic
lane 6832/6832 passed; InstalledDat lane against
C:/Users/erikn/Documents/Asheron's Call — exactly the four known
failures (TowerAscentReplayTests.TowerAscent_StaircaseStaysConeVisible_
EveryStep, LayoutImporterMediaBearingChildSweepTests.
MainGameUiAndChatInput_MediaBearingChildrenNowBuildAsRealWidgets and
LayoutImporterInvisibleSweepTests.EveryAuthoredInvisibleWidget_
StartsHiddenAcrossAllLayouts — both #383 — and
WalkTraceConformanceTests.Oh_doorway_still_first_frame_diff #458),
243 passed / 1 skipped / 4 failed / 248 total, no new failures; shader
tests (VulkanShaderDescriptorContractTests/VulkanShaderManifestTests/
RenderPackSpirvValidatorTests/SkyVertexLayoutTests) 35/35; register
tests (Divergence|Register filter) 52/52.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2251 lines
101 KiB
C#
2251 lines
101 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Collections.ObjectModel;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Numerics;
|
|
using System.Reflection;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Gpu.Vk;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Rendering.Walk;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Meshing;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Lib.IO;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Walk;
|
|
|
|
/// <summary>
|
|
/// Campaign FW stage FW3.2b-1: <see cref="WalkFrameDriver"/>'s headless
|
|
/// referee suite — proves that driving <see cref="RetailFrameWalk"/> with the
|
|
/// driver as its <see cref="IWalkEventSink"/> produces retail's own turn
|
|
/// order (all shells then all contents in reverse flood order,
|
|
/// flush-before-every-leaf-action,
|
|
/// content-before-punch) through the REAL <see cref="WbDrawDispatcher.SubmitOrderedStream"/>
|
|
/// onto a <see cref="RecordingGpuDevice"/> — never a mock of the submission
|
|
/// path itself. No production wiring is exercised (<c>WorldSceneRenderer</c>
|
|
/// still does not construct this driver); every world-data/leaf-renderer
|
|
/// dependency here is a synthetic fake per plan §FW3.2b-1.
|
|
/// </summary>
|
|
public sealed partial class WalkFrameDriverTests
|
|
{
|
|
// ── Shared ordered log: BOTH the fake leaf renderer and the fake trace
|
|
// write into ONE list, so a single sequence assertion proves the FULL
|
|
// interleave (stream flushes interleaved with sky/terrain/shell/punch/
|
|
// alpha-barrier), not just each half in isolation. ─────────────────────
|
|
|
|
private sealed class RecordingLeafRenderer(
|
|
List<string> log,
|
|
RetailAlphaQueue? alpha = null) : IWalkFrameLeafRenderer
|
|
{
|
|
public readonly List<WalkPolygon> Punches = new();
|
|
public readonly List<uint> Shells = new();
|
|
public readonly List<int> AlphaPendingAtBarrier = new();
|
|
public readonly List<(uint LandblockId, int SideCellCount, int CellIndex)[]> LandCellBatches = new();
|
|
|
|
/// <summary>S3 chunk 3 fix round 1 (F2/F4c): cell ids
|
|
/// <see cref="HasRenderableEmittersInCell"/> reports as having NO
|
|
/// renderable emitter. Empty by default so EVERY pre-existing pin in
|
|
/// this file keeps its old "every StaticParticles/CellParticles turn
|
|
/// submits unconditionally" behavior unchanged; a test proving the
|
|
/// new "genuinely empty turn submits nothing and does not flush"
|
|
/// rule opts specific cells OUT via this set.</summary>
|
|
public readonly HashSet<uint> CellsWithoutEmitters = new();
|
|
|
|
/// <summary>S3 chunk 2: the exit-seal polygon count this fake
|
|
/// reports back to the driver (B2 — <see cref="DrawExitSeals"/>
|
|
/// returns the submitted count so the driver can re-arm its
|
|
/// persistent <c>PortalsDrawnCount</c> for the NEXT <c>ov>0</c>
|
|
/// frame's clear decision, R4). Defaults to 1 so a test that
|
|
/// doesn't care about the re-arm mechanism sees an ordinary
|
|
/// "some seal fired" outcome; a test proving "flood with no exit
|
|
/// portal never clears" sets this to 0.</summary>
|
|
public int SealPolygonsSubmitted = 1;
|
|
|
|
public void DrawSky() => log.Add("SKY");
|
|
|
|
public void DrawLandCellBatch(
|
|
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells)
|
|
{
|
|
LandCellBatches.Add(cells.ToArray());
|
|
log.Add("LANDCELL:" + string.Join(
|
|
',', cells.Select(c => $"{c.LandblockId:x8}:{c.SideCellCount}:{c.CellIndex}")));
|
|
}
|
|
|
|
public bool HasRenderableEmittersInCell(uint cellId) => !CellsWithoutEmitters.Contains(cellId);
|
|
|
|
public void DrawCellShell(uint cellId)
|
|
{
|
|
Shells.Add(cellId);
|
|
log.Add($"SHELL:{cellId:x8}");
|
|
}
|
|
|
|
public void FlushLandscape() => log.Add("LFLUSH");
|
|
|
|
public void ClearInteriorDepth() => log.Add("CLEAR");
|
|
|
|
public int DrawExitSeals()
|
|
{
|
|
log.Add("SEALS");
|
|
return SealPolygonsSubmitted;
|
|
}
|
|
|
|
public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex)
|
|
{
|
|
Punches.Add(worldPolygon);
|
|
log.Add($"PUNCH:{worldPolygon.Vertices.Length}@v{activeViewIndex}");
|
|
}
|
|
|
|
public void AlphaBarrier()
|
|
{
|
|
if (alpha is null)
|
|
{
|
|
log.Add("ALPHA");
|
|
return;
|
|
}
|
|
|
|
AlphaPendingAtBarrier.Add(alpha.PendingCount);
|
|
log.Add($"ALPHA:{alpha.PendingCount}");
|
|
alpha.Flush();
|
|
}
|
|
|
|
public void DrawStaticParticles(uint cellId) => log.Add($"PARTICLES:{cellId:x8}");
|
|
|
|
public void DrawCellParticles(uint cellId) => log.Add($"CELL-PARTICLES:{cellId:x8}");
|
|
}
|
|
|
|
private sealed class RecordingTrace(List<string> log) : IWalkFrameDriverTrace
|
|
{
|
|
public void OnFlush(int commandCount, IReadOnlyList<WalkDrawStage> stages) =>
|
|
log.Add($"FLUSH:{commandCount}:{string.Join(',', stages.Distinct())}");
|
|
}
|
|
|
|
private sealed class FakeWorldData : IWalkFrameWorldData
|
|
{
|
|
public readonly Dictionary<uint, WalkFrameStaticRecords> CellStaticsByCell = new();
|
|
public readonly Dictionary<uint, WalkFrameStaticRecords> CellDynamicsByCell = new();
|
|
public readonly Dictionary<uint, WalkFrameStaticRecords> OutdoorStaticsByCell = new();
|
|
public readonly Dictionary<uint, WalkFrameStaticRecords> OutdoorDynamicsByCell = new();
|
|
public readonly Dictionary<WalkBuilding, WalkFrameStaticRecords> ShellByBuilding = new();
|
|
public readonly Dictionary<WalkBuilding, Matrix4x4> WorldTransformByBuilding = new();
|
|
|
|
public WalkFrameStaticRecords GetCellStatics(uint cellId) =>
|
|
CellStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
|
|
|
|
public WalkFrameStaticRecords GetCellDynamics(uint cellId) =>
|
|
CellDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
|
|
|
|
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) =>
|
|
OutdoorStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
|
|
|
|
public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId) =>
|
|
OutdoorDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
|
|
|
|
public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) =>
|
|
ShellByBuilding.GetValueOrDefault(building, WalkFrameStaticRecords.Empty);
|
|
|
|
public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building) =>
|
|
WorldTransformByBuilding.GetValueOrDefault(building, Matrix4x4.Identity);
|
|
}
|
|
|
|
// ── The walk-level test context (interior flood + building portal pass) ─
|
|
|
|
private sealed class Caster : IWalkRayCaster
|
|
{
|
|
public Vector3 RayThrough(float screenX, float screenY) => new(screenX, screenY, 100f);
|
|
}
|
|
|
|
private sealed class TestContext : IWalkFrameContext, IRetailFrameWalkContext
|
|
{
|
|
public readonly Dictionary<uint, WalkCell> Cells = new();
|
|
public readonly Dictionary<WalkBuilding, float> ViewerDistances = new();
|
|
private readonly Matrix4x4 _viewProj;
|
|
private static readonly Vector2[] RootQuad =
|
|
[
|
|
new(0, 480), new(640, 480), new(640, 0), new(0, 0),
|
|
];
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
|
|
/// <summary>S3 chunk 4 (O1/O2/O3): overrides <see
|
|
/// cref="IRetailFrameWalkContext"/>'s default-false/-zero weather-turn
|
|
/// members so a test can arm the gate and pin the printed "OC" cell
|
|
/// id. Real production values wire <c>RetailPViewFrameInput.ViewerCellId</c>
|
|
/// / <c>RetailPViewPassExecutor.ShouldDrawWeatherOnce(...)</c> — see
|
|
/// <c>WalkProductionFrameContext</c>.</summary>
|
|
public uint ViewerCellId { get; set; }
|
|
public bool WeatherGateOpen { get; set; }
|
|
|
|
public Vector3 ViewpointInBuilding(WalkBuilding building) => Vector3.Zero;
|
|
|
|
public float ViewerDistanceTo(WalkBuilding building) =>
|
|
ViewerDistances.GetValueOrDefault(building, 0f);
|
|
|
|
public IWalkFrameContext CellContext => this;
|
|
public WalkPlane CyPlane => new(new Vector3(0, 0, 1), 0f);
|
|
public void SetActiveView(WalkPortalView views, int index) { }
|
|
|
|
public int ClipBuildingPolygon(
|
|
WalkBuilding building, WalkPolygon polygon, int side, Span<WalkScreenPoint> output)
|
|
{
|
|
Span<WalkScreenPoint> projected = stackalloc WalkScreenPoint[polygon.Vertices.Length];
|
|
for (int i = 0; i < polygon.Vertices.Length; i++)
|
|
projected[i] = WalkScreenClip.TransformToScreen(
|
|
polygon.Vertices[i], _viewProj, ViewportWidth, ViewportHeight);
|
|
if (side != 0)
|
|
projected.Reverse();
|
|
return WalkScreenClip.ClipAgainstView(projected, RootQuad, output);
|
|
}
|
|
}
|
|
|
|
private static WalkPolygon Quad(float z, bool facingViewer = true) => new()
|
|
{
|
|
Vertices =
|
|
[
|
|
new Vector3(-0.5f, -0.5f, z), new Vector3(0.5f, -0.5f, z),
|
|
new Vector3(0.5f, 0.5f, z), new Vector3(-0.5f, 0.5f, z),
|
|
],
|
|
Plane = new WalkPlane(new Vector3(0, 0, facingViewer ? 1f : -1f), facingViewer ? -z : z),
|
|
};
|
|
|
|
// ── Deliverable: RunFrame drives an interior two-cell flood using
|
|
// PView::DrawCells' exact two reverse loops: ALL shells far-to-near,
|
|
// then ALL object cells far-to-near. ─────────────────────────────────
|
|
|
|
// ── Deliverable (S3 chunk 2, superseding the 2026-08-30 decomp
|
|
// correction): PView::DrawCells @0x005a4840's actual DRAW order for an
|
|
// interior root's OWN flood is NOT the order its DrawInside/DrawCells
|
|
// EVENTS fire in (breakpoint-entry order, matching the FW0 oracle
|
|
// traces) — retail draws LScape::draw FIRST (pc:432719, only when exit
|
|
// views survived), then the landscape flush + device-stamp advance,
|
|
// then a GATED depth clear (pc:432731-432732 — R4: read-then-zero
|
|
// portalsDrawnCount @0x005a489c-0x005a489e, clear iff nonzero), then
|
|
// the exit-portal seals (pc:432785-432786), and ONLY THEN the flood's
|
|
// own cells far-to-near. This case has a surviving exit view (ov=1): DC
|
|
// records the flood list (no draw), the landscape turn runs (sky,
|
|
// terrain), THEN flush, THEN — since this is a FRESH driver's very
|
|
// first ov>0 flood, so portalsDrawnCount starts at 0 (R4's
|
|
// "first-frame no-clear quirk") — the clear is SKIPPED, then seals,
|
|
// then all shells and all contents in reverse order. ──────────────────
|
|
|
|
[Fact]
|
|
public void RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObjA = 0x0200_0001UL;
|
|
const ulong gfxObjB = 0x0200_0002UL;
|
|
InjectRenderData(fx.Manager, gfxObjA, MakeFlatMesh(
|
|
MakeBatch(0x08100001u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh(
|
|
MakeBatch(0x08100002u, TranslucencyKind.Opaque, 3, 4, 3, 2)));
|
|
|
|
var ctx = new TestContext();
|
|
var cell1 = new WalkCell
|
|
{
|
|
CellId = 0x100,
|
|
StabList = [0x101u],
|
|
Portals =
|
|
[
|
|
new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0,
|
|
},
|
|
// The exit portal (retail's "world beyond the door") — this
|
|
// is what raises ov to 1 and makes DrawInside run the
|
|
// landscape turn before clear+seals+the flood cells.
|
|
new WalkCellPortal
|
|
{
|
|
OtherCellId = 0xFFFFFFFF, PolygonIndex = 1, PortalSide = 0, OtherPortalId = -1,
|
|
},
|
|
],
|
|
PortalPolygons = [Quad(-2f), Quad(-3f)],
|
|
};
|
|
var cell2 = new WalkCell
|
|
{
|
|
CellId = 0x101,
|
|
Portals = [new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0,
|
|
}],
|
|
PortalPolygons = [Quad(-2f)],
|
|
};
|
|
ctx.Cells[cell1.CellId] = cell1;
|
|
ctx.Cells[cell2.CellId] = cell2;
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.CellStaticsByCell[0x100] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var trace = new RecordingTrace(log);
|
|
using ClipFrame clipFrame = ClipFrame.NoClip();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, leaf, worldData, trace, clipFrame);
|
|
var walk = new RetailFrameWalk();
|
|
// A minimal, no-op landscape (1x1 window, the one slot unpublished)
|
|
// — matches RetailFrameWalkTests' own exit-view fixture. LScape::draw
|
|
// still runs its full turn (sky/terrain) against it; there's simply
|
|
// nothing published to iterate blocks/cells/buildings for.
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.RunFrame(
|
|
walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: landscape,
|
|
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
// No CLEAR: R4's first-frame no-clear quirk — this driver's
|
|
// PortalsDrawnCount starts at 0, so the read-then-zero
|
|
// decision sees "not armed" even though ov=1.
|
|
"SKY", "LFLUSH", "SEALS",
|
|
"SHELL:00000101", "SHELL:00000100",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
|
|
},
|
|
log);
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
// This frame's own seals (RecordingLeafRenderer's default
|
|
// SealPolygonsSubmitted) armed the counter for a NEXT ov>0 frame.
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
|
|
List<GpuRecordedMultiDrawIndirect> mdiCalls =
|
|
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
|
|
Assert.Equal(2, mdiCalls.Count);
|
|
Assert.All(mdiCalls, c => Assert.Equal(1u, c.DrawCount));
|
|
// Nothing dropped: every populated record reached exactly one indirect draw.
|
|
Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount));
|
|
|
|
// Exit seals replay through the exact portal_view slices captured by
|
|
// this walk. The legacy PortalVisibilityFrame is not consulted.
|
|
Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(0));
|
|
Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(1));
|
|
Assert.Equal(4, driver.InteriorFloodViewClipPlanesAt(0, 0).Length);
|
|
Assert.Equal(4, driver.InteriorFloodViewClipPlanesAt(1, 0).Length);
|
|
Assert.True(clipFrame.SlotCount >= 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void InteriorFloodAfterLandscape_RearmsRetailPartStampForPostClearCellRepaint()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObj = 0x0200_0021UL;
|
|
const uint outdoorCellId = 0xF4180009u;
|
|
const uint interiorCellId = 0xF4180112u;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100021u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
RenderProjectionRecord sharedPart = MakeRecord(
|
|
0x4F41806Cu,
|
|
0,
|
|
Vector3.Zero,
|
|
[new MeshRef((uint)gfxObj, Matrix4x4.Identity)]);
|
|
var worldData = new FakeWorldData();
|
|
worldData.OutdoorStaticsByCell[outdoorCellId] =
|
|
new WalkFrameStaticRecords(new[] { sharedPart }, 0xF418u);
|
|
worldData.CellStaticsByCell[interiorCellId] =
|
|
new WalkFrameStaticRecords(new[] { sharedPart }, 0xF418u);
|
|
|
|
var ctx = new TestContext();
|
|
var interiorCell = new WalkCell { CellId = interiorCellId };
|
|
interiorCell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
interiorCell.TopView,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
ctx.Cells[interiorCellId] = interiorCell;
|
|
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher,
|
|
new RecordingLeafRenderer(log),
|
|
worldData,
|
|
new RecordingTrace(log));
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var landscapeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
landscapeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(landscapeViews);
|
|
sink.OnLandscapeCellTurn(outdoorCellId);
|
|
// A landscape turn ran (activeViewCount: 1 above), so this models
|
|
// retail's ov>0 case.
|
|
sink.OnInteriorFloodDrawTurn([interiorCellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
List<GpuRecordedMultiDrawIndirect> mdiCalls =
|
|
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
|
|
Assert.Equal(2, mdiCalls.Count);
|
|
Assert.All(mdiCalls, call => Assert.Equal(1u, call.DrawCount));
|
|
Assert.Equal(2, mdiCalls.Sum(call => (int)call.DrawCount));
|
|
Assert.Equal(2, log.Count(entry => entry == "FLUSH:1:OutdoorStatic"
|
|
|| entry == "FLUSH:1:CellStatic"));
|
|
}
|
|
|
|
// ── Deliverable (T1, flipped for S3 chunk 2): the ov==0 interior case —
|
|
// no exit view survives, so DrawInside never runs the landscape turn,
|
|
// and PView::DrawCells' whole landscape-flush/stamp/clear/seal turn
|
|
// (S3 §8.1 R3: all four gated on outside_view.view_count > 0) never
|
|
// runs either — straight to the flood's own cells after the
|
|
// (draw-nothing) DC event. ──────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void RunFrame_InteriorFloodWithNoExitView_SkipsLandscapeAndNeverFlushesClearsOrSeals()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObjA = 0x0200_0003UL;
|
|
const ulong gfxObjB = 0x0200_0004UL;
|
|
InjectRenderData(fx.Manager, gfxObjA, MakeFlatMesh(
|
|
MakeBatch(0x08100003u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh(
|
|
MakeBatch(0x08100004u, TranslucencyKind.Opaque, 3, 4, 3, 2)));
|
|
|
|
var ctx = new TestContext();
|
|
var cell1 = new WalkCell
|
|
{
|
|
CellId = 0x100,
|
|
StabList = [0x101u],
|
|
Portals = [new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0,
|
|
}],
|
|
PortalPolygons = [Quad(-2f)],
|
|
};
|
|
var cell2 = new WalkCell
|
|
{
|
|
CellId = 0x101,
|
|
Portals = [new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0,
|
|
}],
|
|
PortalPolygons = [Quad(-2f)],
|
|
};
|
|
ctx.Cells[cell1.CellId] = cell1;
|
|
ctx.Cells[cell2.CellId] = cell2;
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.CellStaticsByCell[0x100] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var trace = new RecordingTrace(log);
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, trace);
|
|
var walk = new RetailFrameWalk();
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.RunFrame(
|
|
walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: new WalkLandscape(),
|
|
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
|
|
|
|
// No SKY/TERRAIN — ov==0 means DrawInside never calls DrawLandscape
|
|
// at all. T1 (flipped): LFLUSH/CLEAR/SEALS and the device-stamp
|
|
// advance are ALSO absent now — S3 §8.1 R3 gates all four on
|
|
// outside_view.view_count > 0, and it is 0 here.
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"SHELL:00000101", "SHELL:00000100",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
|
|
},
|
|
log);
|
|
Assert.Equal(0, driver.PortalsDrawnCount);
|
|
|
|
List<GpuRecordedMultiDrawIndirect> mdiCalls =
|
|
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
|
|
Assert.Equal(2, mdiCalls.Count);
|
|
Assert.All(mdiCalls, c => Assert.Equal(1u, c.DrawCount));
|
|
// Nothing dropped: every populated record reached exactly one indirect draw.
|
|
Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount));
|
|
}
|
|
|
|
// ── T2 (S3 chunk 2, R4): a fresh driver's first ov>0 interior flood
|
|
// skips the gated clear (PortalsDrawnCount starts at 0); that same
|
|
// frame's own exit seals arm the counter at Replay, so the driver's
|
|
// SECOND ov>0 flood — even with a different flood cell, since the
|
|
// counter is driver-scoped, not cell-scoped — sees it nonzero and
|
|
// clears. ────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void OnInteriorFloodDrawTurn_FirstOvFrameSkipsClear_SecondFrameArmedByFirstsSealsClears()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
const uint cellId = 0xF4180200u;
|
|
var cell = new WalkCell { CellId = cellId };
|
|
cell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
cell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
ctx.Cells[cellId] = cell;
|
|
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
|
|
// R4: PortalsDrawnCount starts at 0 for a fresh driver — the
|
|
// 0x005a489c-0x005a489e read-then-zero decision sees "not armed" on
|
|
// this driver's very first ov>0 interior-root flood, so the gated
|
|
// depth clear (pc:432731-432732) is skipped even though a landscape
|
|
// turn just ran.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var views1 = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
views1, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(views1);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"SKY", "LFLUSH", "SEALS",
|
|
"SHELL:f4180200", "CELL-PARTICLES:f4180200",
|
|
},
|
|
log);
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
log.Clear();
|
|
|
|
// Frame 1's exit seals reported SealPolygonsSubmitted (default 1) at
|
|
// Replay, arming PortalsDrawnCount for THIS frame's read-then-zero.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var views2 = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
views2, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(views2);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"SKY", "LFLUSH", "CLEAR", "SEALS",
|
|
"SHELL:f4180200", "CELL-PARTICLES:f4180200",
|
|
},
|
|
log);
|
|
}
|
|
|
|
// ── T2 (S3 chunk 2, R4): a driver whose flood never reaches an exit
|
|
// portal (DrawExitSeals reports 0 submitted every turn) never clears,
|
|
// across any number of ov>0 frames — retail's counter is fed ONLY by
|
|
// actually-submitted seal fans. ─────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void OnInteriorFloodDrawTurn_FloodWithNoExitPortal_NeverClearsAcrossFrames()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log) { SealPolygonsSubmitted = 0 };
|
|
var ctx = new TestContext();
|
|
const uint cellId = 0xF4180201u;
|
|
var cell = new WalkCell { CellId = cellId };
|
|
cell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
cell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
ctx.Cells[cellId] = cell;
|
|
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
for (int frame = 0; frame < 3; frame++)
|
|
{
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var views = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
views, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(views);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
}
|
|
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
Assert.Equal(3, log.Count(entry => entry == "SEALS"));
|
|
Assert.Equal(0, driver.PortalsDrawnCount);
|
|
}
|
|
|
|
// ── S4-c1 C3 (T3): the depth-alpha packet's truth table over (root kind,
|
|
// draw_landscape, outside-view count, previous count) -> the exact
|
|
// LFLUSH/stamp/CLEAR/SEALS event subsequence. Chunk 2 already covers
|
|
// most of the table as direct Facts, so chunk 1 adds only the rows that
|
|
// were genuinely missing rather than re-deriving them:
|
|
// - interior, ov==0 (draw_landscape==false), prior==0 ->
|
|
// RunFrame_InteriorFloodWithNoExitView_SkipsLandscapeAndNeverFlushesClearsOrSeals
|
|
// - interior, ov>0 (draw_landscape==true), prior==0 ->
|
|
// RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells
|
|
// and OnInteriorFloodDrawTurn_FirstOvFrameSkipsClear_SecondFrameArmedByFirstsSealsClears
|
|
// (its own frame 1)
|
|
// - interior, ov>0, prior>0 ->
|
|
// OnInteriorFloodDrawTurn_FirstOvFrameSkipsClear_SecondFrameArmedByFirstsSealsClears
|
|
// (its own frame 2) — this pair IS T4, the two-consecutive-frames
|
|
// latch case, below
|
|
// The two rows below — root kind == OUTDOOR (where the entire LFLUSH/
|
|
// stamp/CLEAR/SEALS mechanism cannot fire at all, because
|
|
// RetailFrameWalk.WalkFrame's outdoor branch never calls DrawInside/
|
|
// OnInteriorFloodDrawTurn), and interior/ov==0 immediately AFTER a
|
|
// prior-armed nonzero counter (proving the counter is left completely
|
|
// UNTOUCHED, not merely "not cleared this frame") — were not yet pinned
|
|
// anywhere. ─────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void WalkFrame_OutdoorRoot_NeverFiresTheInteriorClearSealMachinery()
|
|
{
|
|
// Root kind == OUTDOOR: RetailFrameWalk.WalkFrame's
|
|
// (cameraCellId & 0xFFFF) < 0x100 branch calls DrawLandscape
|
|
// directly and never calls DrawInside — so OnInteriorFloodDrawTurn,
|
|
// the sole owner of LFLUSH/stamp/CLEAR/SEALS, never fires at all,
|
|
// for ANY outside-view-count/previous-count combination (there is
|
|
// no such combination reachable outdoors — this row of the table
|
|
// has no ov/prior axis).
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
var walk = new RetailFrameWalk();
|
|
// Same minimal, no-op 1x1 unpublished landscape the interior exit-
|
|
// view fixtures use — LScape::draw still runs its full sky/terrain
|
|
// turn against it; there's nothing published to iterate for.
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.RunFrame(
|
|
walk, cameraCellId: 0x00000050u, cameraCell: null, landscape: landscape,
|
|
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
|
|
|
|
// MUTATION: add a stray sink.OnInteriorFloodDrawTurn(...) call to
|
|
// RetailFrameWalk.WalkFrame's outdoor branch (e.g. a copy-paste from
|
|
// the interior branch) — SKY still appears, but LFLUSH/SEALS would
|
|
// too, and this fails.
|
|
Assert.Contains("SKY", log);
|
|
Assert.DoesNotContain("LFLUSH", log);
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
Assert.DoesNotContain("SEALS", log);
|
|
Assert.Equal(0, driver.PortalsDrawnCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnInteriorFloodDrawTurn_OvZeroAfterAPriorArmedCounter_LeavesTheLatchCompletelyUntouched()
|
|
{
|
|
// Interior root, ov==0 immediately after an EARLIER ov>0 frame armed
|
|
// the counter: S3 §8.1 R3 gates the ENTIRE outside_view.view_count>0
|
|
// block — including the read-then-zero decision itself — so ov==0
|
|
// must leave the counter EXACTLY as an earlier frame left it, not
|
|
// merely "not cleared this frame" (a read-then-zero-back-to-the-
|
|
// same-nonzero-value mistake would also leave PortalsDrawnCount
|
|
// looking untouched from the OUTSIDE — this test's real target is
|
|
// that no clear/seal machinery runs at all, proven by the empty log
|
|
// alongside the unchanged counter).
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
const uint cellId = 0xF4180310u;
|
|
var cell = new WalkCell { CellId = cellId };
|
|
cell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
cell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
ctx.Cells[cellId] = cell;
|
|
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
|
|
// Frame 1: an ordinary ov>0 flood arms the counter.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var views = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
views, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(views);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
log.Clear();
|
|
|
|
// Frame 2: ov==0 — retail's whole clear/seal gate is skipped by
|
|
// construction (no landscape turn either, matching DrawInside's own
|
|
// ov==0 shape).
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 0);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
// The ov==0 flood still draws its OWN cell's shell + contents (R1's
|
|
// "straight to the flood's own cells" tail) — the four LFLUSH/
|
|
// stamp/CLEAR/SEALS tokens are what must be absent, not the whole
|
|
// log; see RunFrame_InteriorFloodWithNoExitView_... above for the
|
|
// same non-empty-but-gate-free shape.
|
|
Assert.DoesNotContain("SKY", log);
|
|
Assert.DoesNotContain("LFLUSH", log);
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
Assert.DoesNotContain("SEALS", log);
|
|
// MUTATION: move the `int armed = PortalsDrawnCount; PortalsDrawnCount
|
|
// = 0;` read-then-zero in WalkFrameDriver.OnInteriorFloodDrawTurn
|
|
// outside the `if (outsideViewCount > 0)` gate — the counter reads
|
|
// back as 0 here instead of the untouched 1, and this fails.
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
}
|
|
|
|
// ── S4-c1 C3 (T4): the same fixture as
|
|
// OnInteriorFloodDrawTurn_FirstOvFrameSkipsClear_SecondFrameArmedByFirstsSealsClears
|
|
// above already proves the "frame 1 seals N>0 -> frame 2 clears" half of
|
|
// the two-consecutive-frames latch, and
|
|
// OnInteriorFloodDrawTurn_FloodWithNoExitPortal_NeverClearsAcrossFrames
|
|
// already proves "frame 1 seals 0 -> frame 2 does not clear" (repeated
|
|
// across three consecutive ov>0 frames, which subsumes the two-frame
|
|
// case). No further T4 test is added — see the commit body for the
|
|
// pre-existing-coverage inventory. ────────────────────────────────────
|
|
|
|
// ── T3 (S3 chunk 2, R1): a building look-in's own DrawCells re-enters
|
|
// with ov==0 unconditionally and neither ARMS nor CONSUMES the
|
|
// persistent PortalsDrawnCount counter — retail calls DrawCells
|
|
// re-entrantly there with no clear/seal step at all. ────────────────
|
|
|
|
[Fact]
|
|
public void LookInDrawCells_NeitherArmsNorConsumesThePortalsDrawnCounter()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
const uint rootCellId = 0xF4180301u;
|
|
const uint lookInCellId = 0xF4180302u;
|
|
var rootCell = new WalkCell { CellId = rootCellId };
|
|
rootCell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
rootCell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
ctx.Cells[rootCellId] = rootCell;
|
|
var lookInCell = new WalkCell { CellId = lookInCellId };
|
|
lookInCell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
lookInCell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
ctx.Cells[lookInCellId] = lookInCell;
|
|
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
|
|
// Arm PortalsDrawnCount with one throwaway ov>0 interior-root flood.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var rootViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
rootViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(rootViews);
|
|
sink.OnInteriorFloodDrawTurn([rootCellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
log.Clear();
|
|
|
|
// A building look-in's own DrawCells re-enters with ov==0
|
|
// unconditionally (R1) — no LFLUSH/clear/seal step at all ("DrawCells
|
|
// re-entrantly there with no clear/seal step"), so it must leave the
|
|
// already-armed counter alone.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.OnBuildingTurn(new WalkBuilding());
|
|
sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [lookInCellId]));
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.DoesNotContain("LFLUSH", log);
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
Assert.DoesNotContain("SEALS", log);
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
}
|
|
|
|
// ── S4-c1 C3 (T5): the ABOVE test proves one look-in is isolated from
|
|
// the root latch; this extends it to MULTIPLE look-ins — two in the
|
|
// SAME frame (two buildings' own portal passes), then a third in a
|
|
// LATER, separate frame — since retail's DrawCells re-entry (R1) has no
|
|
// per-call state of its own that could accumulate across repeats. ────
|
|
|
|
[Fact]
|
|
public void MultipleLookIns_WithinOneFrameAndAcrossFrames_NeverTouchTheRootLatch()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
const uint rootCellId = 0xF4180330u;
|
|
const uint lookInCellIdA = 0xF4180331u;
|
|
const uint lookInCellIdB = 0xF4180332u;
|
|
var rootCell = new WalkCell { CellId = rootCellId };
|
|
rootCell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
rootCell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
ctx.Cells[rootCellId] = rootCell;
|
|
foreach (uint lookInId in new[] { lookInCellIdA, lookInCellIdB })
|
|
{
|
|
var lookInCell = new WalkCell { CellId = lookInId };
|
|
lookInCell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
lookInCell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
ctx.Cells[lookInId] = lookInCell;
|
|
}
|
|
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
|
|
// Arm PortalsDrawnCount with one throwaway ov>0 interior-root flood.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var rootViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
rootViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(rootViews);
|
|
sink.OnInteriorFloodDrawTurn([rootCellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
log.Clear();
|
|
|
|
// TWO look-ins in the SAME frame.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.OnBuildingTurn(new WalkBuilding());
|
|
sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [lookInCellIdA]));
|
|
sink.OnBuildingTurn(new WalkBuilding());
|
|
sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [lookInCellIdB]));
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.DoesNotContain("LFLUSH", log);
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
Assert.DoesNotContain("SEALS", log);
|
|
// MUTATION (verified during S4-c1's own implementation, then
|
|
// reverted): a bug that only mishandles a REPEAT look-in call within
|
|
// one frame (e.g. resetting PortalsDrawnCount on the second
|
|
// HandleDrawCellsTurn(LookInStatic) call) leaves this exact
|
|
// assertion at 0 instead of 1, while
|
|
// LookInDrawCells_NeitherArmsNorConsumesThePortalsDrawnCounter above
|
|
// — which calls DrawCells exactly once — stays green throughout;
|
|
// that gap is this test's whole reason to exist over the single-
|
|
// look-in fact.
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
log.Clear();
|
|
|
|
// A THIRD look-in in a LATER, separate frame.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.OnBuildingTurn(new WalkBuilding());
|
|
sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [lookInCellIdA]));
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.DoesNotContain("LFLUSH", log);
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
Assert.DoesNotContain("SEALS", log);
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
}
|
|
|
|
// ── Deliverable: a building turn's alpha barrier precedes its portal
|
|
// pass (retail RenderDeviceD3D::DrawBuilding @0x0059f2a0:
|
|
// FlushAlphaList(0f) -> CPhysicsPart::Draw(parts,1) [the portal walk]
|
|
// -> CPhysicsPart::Draw(parts,0) [the shell] @0x0059f30b-0x0059f345);
|
|
// the punch pass runs with nothing of THIS building's own queued yet
|
|
// (the shell is not appended until the whole portal pass completes);
|
|
// the look-in DC turn draws shell-then-contents exactly like an ordinary
|
|
// interior flood; the building's own shell content is appended and
|
|
// flushed only AFTER the portal pass, at frame end; and the punch
|
|
// polygon reaches the leaf renderer transformed building-local ->
|
|
// world. ─────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void BeginEndFrame_BuildingTurnWithPunchAndLookIn_OrdersAlphaBarrierPortalPassThenShell()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong shellGfxObj = 0x0200_0010UL;
|
|
const ulong interiorGfxObj = 0x0200_0011UL;
|
|
const ulong dynamicGfxObj = 0x0200_0012UL;
|
|
InjectRenderData(fx.Manager, shellGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100010u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, interiorGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100011u, TranslucencyKind.Opaque, 3, 4, 3, 2)));
|
|
InjectRenderData(fx.Manager, dynamicGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100012u, TranslucencyKind.Opaque, 6, 8, 3, 3)));
|
|
|
|
var ctx = new TestContext();
|
|
var interior = new WalkCell
|
|
{
|
|
CellId = 0x104,
|
|
Portals = [new WalkCellPortal
|
|
{
|
|
OtherCellId = 0xFFFFFFFF, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0,
|
|
}],
|
|
PortalPolygons = [Quad(-2f)],
|
|
};
|
|
ctx.Cells[interior.CellId] = interior;
|
|
|
|
var building = new WalkBuilding
|
|
{
|
|
PositionCellId = 0xA9B4000Fu,
|
|
Portals =
|
|
[
|
|
new WalkBldPortal
|
|
{
|
|
PortalSide = 0, OtherCellId = 0x104, OtherPortalId = 0,
|
|
StabList = [0x104u],
|
|
},
|
|
],
|
|
// Viewpoint (0,0,0) is on the NEGATIVE side of this splitting
|
|
// plane (d=-5): the single PORT node's side==1 arm emits its
|
|
// portal exactly once per pass (WalkBuildingPortals.Walk).
|
|
DrawingBsp = new WalkBspNode
|
|
{
|
|
SplittingPlane = new WalkPlane(new Vector3(1, 0, 0), -5f),
|
|
InPortals = [new WalkPortalRef { PortalIndex = 0, Polygon = Quad(-2f) }],
|
|
},
|
|
};
|
|
ctx.ViewerDistances[building] = 12.5f;
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.ShellByBuilding[building] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(201, 0, Vector3.Zero, [new MeshRef((uint)shellGfxObj, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.CellStaticsByCell[0x104] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(202, 0, Vector3.Zero, [new MeshRef((uint)interiorGfxObj, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.CellDynamicsByCell[0x104] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(203, 0, Vector3.Zero, [new MeshRef((uint)dynamicGfxObj, Matrix4x4.Identity)], parentCellId: 0x104) }, 0x8C04u);
|
|
Matrix4x4 buildingWorld = Matrix4x4.CreateTranslation(10f, 0f, 0f);
|
|
worldData.WorldTransformByBuilding[building] = buildingWorld;
|
|
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var trace = new RecordingTrace(log);
|
|
using ClipFrame clipFrame = ClipFrame.NoClip();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, leaf, worldData, trace, clipFrame);
|
|
var walk = new RetailFrameWalk();
|
|
|
|
var activeView = new WalkPortalView();
|
|
activeView.ResetForPush();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
activeView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
Assert.Equal(1, activeView.ViewCount);
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
walk.DrawBuilding(building, activeView, ctx, driver);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
// Campaign OVERHAUL S2 chunk 6: a building's shell fires no
|
|
// particle turn of its own any more — interior emitters draw at
|
|
// their own cell's CellParticles turn instead.
|
|
new[]
|
|
{
|
|
"ALPHA", "PUNCH:4@v0", "SHELL:00000104",
|
|
"FLUSH:1:LookInStatic", "FLUSH:1:Dynamic",
|
|
"CELL-PARTICLES:00000104",
|
|
"FLUSH:1:BuildingShell",
|
|
},
|
|
log);
|
|
|
|
Assert.Equal([0x104u], driver.LookInCellTurns);
|
|
Assert.Equal([0x104u], driver.LookInCells);
|
|
Assert.Collection(
|
|
leaf.Shells,
|
|
shell => Assert.Equal(0x104u, shell));
|
|
WalkPortalView capturedView = ctx.Cells[0x104].PortalViews[0];
|
|
WalkViewPoly capturedPoly = Assert.Single(capturedView.View.Polys);
|
|
Vector2 capturedCenter = Vector2.Zero;
|
|
for (int edge = 0; edge < capturedPoly.VertexCount; edge++)
|
|
{
|
|
capturedCenter += capturedView.View.Vertices[
|
|
capturedPoly.VertexIndex + edge].Point;
|
|
}
|
|
capturedCenter /= capturedPoly.VertexCount;
|
|
Vector3 insideCone = ctx.Rays.RayThrough(capturedCenter.X, capturedCenter.Y);
|
|
Assert.True(driver.SphereVisibleInLookInTurn(
|
|
0, in insideCone, 0.1f));
|
|
Assert.False(driver.SphereVisibleInLookInTurn(
|
|
0, new Vector3(10_000f, 0f, 10f), 0.1f));
|
|
// S3 chunk 4 fix round 1 (K7): restores the pin the deleted
|
|
// VisibleClipSlotsInLookInTurn used to prove — a real look-in slice
|
|
// is packed into its own GPU clip slot, never left on the reserved
|
|
// no-clip slot 0 (the exit-seal KEEP reads planes through the SAME
|
|
// slot mechanism, _clipFrame.GetSlotPlanes(slice.ClipSlot)).
|
|
uint clipSlot = driver.LookInSliceClipSlotAt(0);
|
|
Assert.NotEqual(0u, clipSlot);
|
|
Assert.Equal(2, clipFrame.SlotCount);
|
|
|
|
// The punch polygon reached the leaf renderer in WORLD space: the
|
|
// building-local Quad(-2f) vertex (-0.5,-0.5,-2) translates by
|
|
// (10,0,0) under the caller-supplied building world transform.
|
|
WalkPolygon punch = Assert.Single(leaf.Punches);
|
|
Assert.Equal(new Vector3(9.5f, -0.5f, -2f), punch.Vertices[0]);
|
|
|
|
List<GpuRecordedMultiDrawIndirect> mdiCalls =
|
|
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
|
|
Assert.Equal(3, mdiCalls.Count);
|
|
Assert.Equal(3, mdiCalls.Sum(c => (int)c.DrawCount));
|
|
}
|
|
|
|
// ── S4-c1 C1/T2: DrawPortalPolyInternal's degenerate-input guard, ported
|
|
// at the punch-fan producer (WalkFrameDriver.OnPunchGeometry — the
|
|
// handler that owns the LOCAL polygon before TransformToWorld). A source
|
|
// vertex with local x/y exactly +/-12 drops the WHOLE polygon before any
|
|
// transform, event, or counter effect; the same shape at 11.999 is an
|
|
// ordinary polygon and punches normally. ─────────────────────────────
|
|
|
|
[Fact]
|
|
public void OnPunchGeometry_RejectsWholePolygonOnExactPlusMinus12LocalVertex_ButPunchesJustInside()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
var building = new WalkBuilding { PositionCellId = 0xA9B40040u };
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(new TestContext(), Matrix4x4.Identity, Vector3.Zero);
|
|
|
|
// Degenerate: one vertex sits exactly on the local x == +12 boundary.
|
|
// MUTATION: relax the guard's exact equality to a tolerance/
|
|
// inequality (e.g. x >= 12f) and this test's second assertion group
|
|
// (the admitted 11.999 polygon) starts failing instead — 11.999 is a
|
|
// real, non-degenerate local coordinate a fifth of a millimeter
|
|
// (retail units) inside the exact boundary.
|
|
sink.OnPunchGeometry(
|
|
building,
|
|
new WalkPolygon
|
|
{
|
|
Vertices = [new(0f, 0f, 3f), new(12f, 0f, 3f), new(5f, 5f, 3f)],
|
|
Plane = new WalkPlane(Vector3.UnitZ, -3f),
|
|
},
|
|
activeViewIndex: 0);
|
|
|
|
// Admitted: the nearest-boundary vertex is 11.999, not 12 — an
|
|
// ordinary polygon that must punch exactly like any other.
|
|
sink.OnPunchGeometry(
|
|
building,
|
|
new WalkPolygon
|
|
{
|
|
Vertices = [new(0f, 0f, 3f), new(11.999f, 0f, 3f), new(5f, 5f, 3f)],
|
|
Plane = new WalkPlane(Vector3.UnitZ, -3f),
|
|
},
|
|
activeViewIndex: 0);
|
|
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
// Exactly ONE punch reached the leaf — the rejected polygon produced
|
|
// no PunchFan event at all (not a punch that draws zero vertices; no
|
|
// event, full stop).
|
|
WalkPolygon punched = Assert.Single(leaf.Punches);
|
|
Assert.Equal(new Vector3(11.999f, 0f, 3f), punched.Vertices[1]);
|
|
Assert.Equal(1, log.Count(entry => entry == "PUNCH:3@v0"));
|
|
}
|
|
|
|
[Fact]
|
|
public void RepeatedFloodTurns_DrawEnvCellShellWholeOncePerRetailFrameStamp()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
const uint cellId = 0xF4180112u;
|
|
var cell = new WalkCell { CellId = cellId };
|
|
cell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
cell.TopView,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
ctx.Cells[cellId] = cell;
|
|
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher,
|
|
leaf,
|
|
new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
// Neither call models a surviving exit view (ov==0 both times — no
|
|
// WalkEvent.Landscape turn ran in this synthetic double-entry
|
|
// scenario), so per S3 §8.1 R3 the device-stamp advance never fires
|
|
// from either call: this isolates the frame-stamp shell dedup from
|
|
// the landscape-flush/clear/seal gate entirely.
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 0);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 0);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal([cellId], leaf.Shells);
|
|
Assert.Equal(1, log.Count(entry => entry == "SHELL:f4180112"));
|
|
Assert.Equal(0, log.Count(entry => entry == "LFLUSH"));
|
|
Assert.Equal(0, log.Count(entry => entry == "CLEAR"));
|
|
Assert.Equal(0, log.Count(entry => entry == "SEALS"));
|
|
Assert.Equal(0, driver.PortalsDrawnCount);
|
|
}
|
|
|
|
// Campaign OVERHAUL S2 chunk 6 pin — the portal-haze bug this chunk
|
|
// fixes: a hidden/suspended owner (a portalling player materializing
|
|
// into its arrival cell) publishes NO registry rows there, so
|
|
// GetCellStatics/GetCellDynamics report empty records for that cell —
|
|
// yet retail's CPhysicsObj::add_particle_shadow_to_cell (0x00514a70)
|
|
// still draws that owner's emitter at the cell's own object-list turn,
|
|
// because particle draw membership is the emitter's OWN cell, never a
|
|
// registry/owner lookup. The walk must fire the cell's particle turn
|
|
// regardless of whether any static/dynamic record names an owner there.
|
|
[Fact]
|
|
public void EmitCellContentsTurn_FiresCellParticlesEvenWhenTheCellHasNoStaticOrDynamicRecords()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
const uint arrivalCellId = 0x8A020141u;
|
|
var arrivalCell = new WalkCell { CellId = arrivalCellId };
|
|
ctx.Cells[arrivalCellId] = arrivalCell;
|
|
|
|
// FakeWorldData has NO entry at all for the arrival cell —
|
|
// GetCellStatics/GetCellDynamics both fall back to
|
|
// WalkFrameStaticRecords.Empty, exactly the "hidden owner, no
|
|
// registry rows" scenario.
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
// No landscape turn modeled here — ov==0.
|
|
sink.OnInteriorFloodDrawTurn([arrivalCellId], outsideViewCount: 0);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Contains("CELL-PARTICLES:8a020141", log);
|
|
}
|
|
|
|
[Fact]
|
|
public void LandscapeStampBoundary_RearmsWholeShellForPostClearRootRepaint()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
const uint cellId = 0xF4180112u;
|
|
var cell = new WalkCell { CellId = cellId };
|
|
cell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
cell.TopView,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
ctx.Cells[cellId] = cell;
|
|
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher,
|
|
new RecordingLeafRenderer(new List<string>()),
|
|
new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
|
|
// R4 (the "first-frame no-clear quirk", pinned explicitly by
|
|
// RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClear...):
|
|
// PortalsDrawnCount starts at 0 for a fresh driver, so ITS first
|
|
// ov>0 flood would skip the clear. Prime the counter with one
|
|
// throwaway ov>0 flood (this driver's own exit seals arm it) so the
|
|
// documented scenario below models a STEADY-STATE interior frame,
|
|
// where the clear actually fires.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var primerViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
primerViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(primerViews);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
driver.RebindFrame(leaf, clipFrame: null);
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
|
|
// LScape::draw has begun. A building look-in reached this cell before
|
|
// PView::DrawCells advances m_nFrameStamp and clears interior depth.
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var landscapeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
landscapeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(landscapeViews);
|
|
sink.OnBuildingTurn(new WalkBuilding());
|
|
sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [cellId]));
|
|
|
|
// The same shell must draw again after the retail stamp increment and
|
|
// full depth clear; otherwise the pre-clear color survives unpaired
|
|
// with depth and bleeds through the root's walls. ov=1 (the
|
|
// landscape turn above ran), and PortalsDrawnCount is armed from
|
|
// the primer frame's own seals, so the clear actually fires here.
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal([cellId, cellId], leaf.Shells);
|
|
Assert.Equal(2, log.Count(entry => entry == "SHELL:f4180112"));
|
|
Assert.Contains("CLEAR", log);
|
|
Assert.True(
|
|
log.IndexOf("SHELL:f4180112") < log.IndexOf("CLEAR"),
|
|
"The look-in shell must precede the interior clear.");
|
|
Assert.True(
|
|
log.LastIndexOf("SHELL:f4180112") > log.IndexOf("SEALS"),
|
|
"The rearmed root shell must repaint after the clear and seals.");
|
|
}
|
|
|
|
// ── Fail-loud: a DrawCells turn with no preceding DrawInside/Building
|
|
// turn is a walk/driver desync, not a silent skip. ─────────────────────
|
|
|
|
[Fact]
|
|
public void Emit_DrawCellsBeforeAnyDrawInsideOrBuildingTurn_ThrowsRatherThanSilentlyDropping()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => ((IWalkEventSink)driver).Emit(WalkEvent.DrawCells(0, [0x100u])));
|
|
}
|
|
|
|
// ── Fail-loud: BeginFrame is not re-entrant. ────────────────────────────
|
|
|
|
[Fact]
|
|
public void BeginFrame_CalledWhileAFrameIsAlreadyOpen_Throws()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => driver.BeginFrame(
|
|
ctx, Matrix4x4.Identity, Vector3.Zero));
|
|
|
|
driver.EndFrame();
|
|
// EndFrame cleared the open-frame guard: BeginFrame is usable again.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
driver.EndFrame();
|
|
}
|
|
|
|
// ── Campaign FW3.4a fail-loud: Replay without a completed Collect (no
|
|
// BeginFrame/EndFrame at all, or BeginFrame with no matching EndFrame)
|
|
// has nothing recorded to draw — throw rather than silently drawing
|
|
// nothing, which would look like an empty frame instead of a misuse. ──
|
|
|
|
[Fact]
|
|
public void Replay_WithNoPrecedingCollect_Throws()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
Assert.Throws<InvalidOperationException>(() => driver.Replay(draw.Frame, draw.Pass));
|
|
}
|
|
|
|
[Fact]
|
|
public void Replay_WhileCollectIsStillOpen_Throws()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
|
|
Assert.Throws<InvalidOperationException>(() => driver.Replay(draw.Frame, draw.Pass));
|
|
}
|
|
|
|
// ── Deliverable: Collect + Replay called as the SPLIT PAIR (never
|
|
// RunFrame) — the shape RetailPViewRenderer uses, since it must run other
|
|
// frame work (PrepareCellBatches/BuildAndBorrow) between the two. Proves
|
|
// the pair alone — with no GPU work happening until Replay — reproduces
|
|
// the same turn order RunFrame's combined call would. ──────────────────
|
|
|
|
[Fact]
|
|
public void CollectThenReplay_AsSeparateCalls_PerformsNoGpuWorkUntilReplay()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData(), new RecordingTrace(log));
|
|
var walk = new RetailFrameWalk();
|
|
// Outdoor root (camera cell low word < 0x100): a minimal, no-op
|
|
// landscape — LScape::draw still runs its sky/terrain turn against
|
|
// it even though nothing is published to walk cells/buildings for.
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.Collect(
|
|
walk, cameraCellId: 0u, cameraCell: null, landscape, ctx,
|
|
Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
|
|
|
|
// No GPU calls at all yet — Collect is CPU-only.
|
|
Assert.Empty(log);
|
|
Assert.Empty(fx.Device.Calls);
|
|
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(new[] { "SKY" }, log);
|
|
}
|
|
|
|
[Fact]
|
|
public void RebindFrame_ReusesTheDriverAndRoutesTheNextFrameToTheNewLeaf()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var firstLog = new List<string>();
|
|
var secondLog = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, new RecordingLeafRenderer(firstLog), new FakeWorldData());
|
|
var walk = new RetailFrameWalk();
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.Collect(
|
|
walk, 0u, null, landscape, ctx,
|
|
Matrix4x4.Identity, Vector3.Zero);
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
driver.RebindFrame(new RecordingLeafRenderer(secondLog), clipFrame: null);
|
|
driver.Collect(
|
|
walk, 0u, null, landscape, ctx,
|
|
Matrix4x4.Identity, Vector3.Zero);
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(new[] { "SKY" }, firstLog);
|
|
Assert.Equal(new[] { "SKY" }, secondLog);
|
|
}
|
|
|
|
[Fact]
|
|
public void RebindFrame_DiscardsAnIncompletePriorFrameAndRecovers()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, new RecordingLeafRenderer(new List<string>()), new FakeWorldData());
|
|
var walk = new RetailFrameWalk();
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
driver.RebindFrame(new RecordingLeafRenderer(log), clipFrame: null);
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.Collect(
|
|
walk, 0u, null, landscape, ctx,
|
|
Matrix4x4.Identity, Vector3.Zero);
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(new[] { "SKY" }, log);
|
|
}
|
|
|
|
// ── Deliverable: an outdoor landscape-cell turn with no building appends
|
|
// straight to the stream (no shell call — outdoor cells have no EnvCell
|
|
// shell), and the accumulated content flushes at frame end. ───────────
|
|
|
|
[Fact]
|
|
public void OnLandscapeCellTurn_AppendsOutdoorStaticsWithNoShellCallAndFlushesAtFrameEnd()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObj = 0x0200_0020UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100020u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var ctx = new TestContext();
|
|
var worldData = new FakeWorldData();
|
|
worldData.OutdoorStaticsByCell[0x8C040005u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(301, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.OutdoorDynamicsByCell[0x8C040005u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(302, 0x50000001, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, new RecordingLeafRenderer(log), worldData, new RecordingTrace(log));
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
var activeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
activeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
((IWalkEventSink)driver).OnLandscapeViews(activeViews);
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0x8C040005u);
|
|
Assert.Empty(log); // no GPU work at Collect time; nothing recorded to the log yet
|
|
driver.EndFrame();
|
|
Assert.Empty(log); // still nothing — EndFrame closes Collect, it does not Replay
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
// FW4 #132 positional invariant: the cell's emitters submit at its
|
|
// own landscape turn, after its meshes flush. Campaign OVERHAUL S2
|
|
// chunk 6: the leaf draws by CELL, not by owner set.
|
|
Assert.Equal(
|
|
new[] { "FLUSH:2:OutdoorStatic,Dynamic", "PARTICLES:8c040005" },
|
|
log);
|
|
var visibleCells = new HashSet<uint> { 0xDEAD_BEEFu };
|
|
driver.CopyVisibleCellsTo(visibleCells);
|
|
Assert.Equal(new[] { 0x8C040005u }, visibleCells);
|
|
GpuRecordedMultiDrawIndirect[] mdi =
|
|
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().ToArray();
|
|
Assert.Equal(2, mdi.Length);
|
|
Assert.Equal(2u, mdi.Aggregate(0u, static (sum, call) => sum + call.DrawCount));
|
|
}
|
|
|
|
// Campaign OVERHAUL S2 chunk 6: the mesh-dedup shape (a shadow alias's
|
|
// record entering two landscape cells' dictionaries) is a MESH-only
|
|
// concern — retail's per-part render membership is whole-object per
|
|
// cell. Particles are NOT part of that alias: an emitter owns exactly
|
|
// one shadow in its own current cell (add_particle_shadow_to_cell
|
|
// 0x00514a70), so each VISITED landscape cell fires its own independent
|
|
// StaticParticles turn with no cross-cell dedupe needed or performed.
|
|
[Fact]
|
|
public void OnLandscapeCellTurn_MultiCellShadowAlias_DrawsMeshOnceButParticlesPerVisitedCell()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObj = 0x0200_0021UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100021u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var ctx = new TestContext();
|
|
var worldData = new FakeWorldData();
|
|
RenderProjectionRecord shadowAlias = MakeRecord(
|
|
303,
|
|
0x50000002,
|
|
Vector3.Zero,
|
|
[new MeshRef((uint)gfxObj, Matrix4x4.Identity)]);
|
|
WalkFrameStaticRecords aliases = new(
|
|
new[] { shadowAlias },
|
|
0xF07Fu);
|
|
worldData.OutdoorDynamicsByCell[0xF07F0040u] = aliases;
|
|
worldData.OutdoorDynamicsByCell[0xF0800001u] = aliases;
|
|
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher,
|
|
new RecordingLeafRenderer(log),
|
|
worldData,
|
|
new RecordingTrace(log));
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
var activeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
activeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
((IWalkEventSink)driver).OnLandscapeViews(activeViews);
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0xF07F0040u);
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0xF0800001u);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
new[] { "FLUSH:1:Dynamic", "PARTICLES:f07f0040", "PARTICLES:f0800001" },
|
|
log);
|
|
GpuRecordedMultiDrawIndirect[] mdi =
|
|
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().ToArray();
|
|
Assert.Single(mdi);
|
|
Assert.Equal(1u, mdi[0].DrawCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void CoarseLandscapeCellTurn_ExpandsToEveryCoveredAuthoritativeOwnerCell()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0200_0022UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100022u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.OutdoorStaticsByCell[0xE43D0040u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(304, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
|
|
0xE43Du);
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher,
|
|
new RecordingLeafRenderer(new List<string>()),
|
|
worldData);
|
|
var ctx = new TestContext();
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
var activeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
activeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
((IWalkEventSink)driver).OnLandscapeViews(activeViews);
|
|
// A 2x2 coarse grid's cell 3 covers authoritative x/y cells 4..7,
|
|
// including original cell 64. The old cellIndex+1 lookup only asked
|
|
// for ids 1..4 and silently lost this record.
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0xE43DFFFFu, 2, 3);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Contains(0xE43D0040u, driver.VisitedLandscapeCellIds);
|
|
GpuRecordedMultiDrawIndirect call = Assert.Single(
|
|
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
|
|
Assert.Equal(1u, call.DrawCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Collect_DoesNotExposeNearAlphaToEarlierBuildingBarrier()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0200_0023UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100023u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1)));
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.OutdoorStaticsByCell[0xE43D0001u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(305, 0, new Vector3(0, 0, -20), [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
|
|
0xE43Du);
|
|
worldData.OutdoorStaticsByCell[0xE43D0002u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(306, 0, new Vector3(0, 0, -2), [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
|
|
0xE43Du);
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log, fx.AlphaQueue);
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
|
|
var ctx = new TestContext();
|
|
|
|
using DrawScope draw = fx.BeginDraw(beginAlpha: true);
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
var activeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
activeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
((IWalkEventSink)driver).OnLandscapeViews(activeViews);
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0xE43D0001u);
|
|
((IWalkEventSink)driver).OnBuildingTurn(new WalkBuilding());
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0xE43D0002u);
|
|
driver.EndFrame();
|
|
|
|
Assert.Equal(0, fx.AlphaQueue.PendingCount);
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(new[] { 1 }, leaf.AlphaPendingAtBarrier);
|
|
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
|
}
|
|
|
|
// ── F4(a) (S3 chunk 3 fix round 1 §9.6): an outdoor-root sequence — the
|
|
// LandCell terrain turn precedes its own cell's object-list turn, and
|
|
// (since nothing else intervenes) the whole frame's terrain stays one
|
|
// PENDING batch until Replay's own end. Drives RetailFrameWalk.
|
|
// DrawLandscape directly with a ZERO-view WalkPortalView — the SAME
|
|
// deterministic "CY-only" admission technique RetailFrameWalkTests'
|
|
// own outdoor tests use (a real WalkFrame root's 1-view default quad
|
|
// depends on the production ray-caster's screen geometry, which this
|
|
// suite's synthetic Caster does not model faithfully enough to predict
|
|
// block/cell admission from). A side=1 block's own object-list turn
|
|
// (WalkFrameDriver.OnLandscapeCellTurn's coarse-cell expansion) fires a
|
|
// StaticParticles turn for every one of the underlying 64 owner
|
|
// buckets; marking them all "no emitters" keeps this test's sequence
|
|
// to exactly SKY + one LANDCELL, the same way a genuinely empty turn
|
|
// stays silent (F2). ────────────────────────────────────────────────
|
|
|
|
private static IEnumerable<uint> CoarseLandscapeBuckets(uint landblockPrefix)
|
|
{
|
|
for (int x = 0; x < 8; x++)
|
|
for (int y = 0; y < 8; y++)
|
|
yield return landblockPrefix | (uint)(x * 8 + y + 1);
|
|
}
|
|
|
|
/// <summary>A ONE-view <see cref="WalkPortalView"/> whose single polygon
|
|
/// has ZERO vertices — <c>WalkLandscape.CheckBlocks</c> reads only
|
|
/// <c>poly.VertexCount</c> to build its edge-plane list, so this reaches
|
|
/// the SAME permissive <c>edgeCount == 0</c> ("CY-only") admission test
|
|
/// RetailFrameWalkTests' own outdoor fixtures use via a bare
|
|
/// zero-VIEW <see cref="WalkPortalView"/> — but with <c>ViewCount == 1</c>,
|
|
/// satisfying <see cref="WalkFrameDriver"/>'s fail-loud
|
|
/// "a Landscape turn needs at least one active view" guard, which a
|
|
/// driver-level test (unlike a bare <c>IWalkEventSink</c> recorder) must
|
|
/// pass through <c>RetailFrameWalk.DrawLandscape</c>'s real
|
|
/// <c>Emit(WalkEvent.Landscape(...))</c> call.</summary>
|
|
private static WalkPortalView OneDegenerateView()
|
|
{
|
|
var view = new WalkPortalView { ViewCount = 1 };
|
|
view.View.Polys.Add(new WalkViewPoly(0, 0, 0, 0, 0, 0));
|
|
return view;
|
|
}
|
|
|
|
[Fact]
|
|
public void OutdoorRoot_LandCellPrecedesItsOwnCellsObjectTurn_ThenFlushesAtReplaysEnd()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
// Every covered bucket but ONE reports no renderable emitter, so the
|
|
// block's object turns submit nothing (no flush) except bucket
|
|
// 0xF4180001, whose particle turn is a real submission — it must land
|
|
// AFTER the coarse cell's own terrain (retail DrawLandCell → DrawSortCell).
|
|
leaf.CellsWithoutEmitters.UnionWith(CoarseLandscapeBuckets(0xF4180000u));
|
|
leaf.CellsWithoutEmitters.Remove(0xF4180001u);
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
var walk = new RetailFrameWalk();
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
var block = new WalkLandBlock
|
|
{
|
|
LandblockId = 0xF4180000u, SideCellCount = 1, MaxZ = 10f, MinZ = 0f,
|
|
};
|
|
block.EnsureCellArrays();
|
|
landscape.Blocks[0] = block;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
walk.DrawLandscape(landscape, OneDegenerateView(), ctx, driver);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(new[] { "SKY", "LANDCELL:f4180000:1:0", "PARTICLES:f4180001" }, log);
|
|
Assert.Equal(new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF4180000u, 1, 0) },
|
|
Assert.Single(leaf.LandCellBatches));
|
|
}
|
|
|
|
// ── F4(b) (S3 chunk 3 fix round 1 §9.6): an interior root with one
|
|
// surviving exit view — the SAME fixture as
|
|
// RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells
|
|
// above, with one populated land block added to the (previously
|
|
// unpublished) landscape. Sequence: SKY, LANDCELL…, LFLUSH, SEALS,
|
|
// SHELL… — the LandCell terrain turn(s) drawn through the interior
|
|
// root's own exit view precede the landscape-flush/seal/flood-cell
|
|
// turns, exactly like the outdoor case above. ────────────────────────
|
|
|
|
[Fact]
|
|
public void InteriorRootWithExitView_DrawsLandCellThroughTheExitViewBeforeFlushSealsAndFlood()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObjA = 0x0200_0025UL;
|
|
const ulong gfxObjB = 0x0200_0026UL;
|
|
InjectRenderData(fx.Manager, gfxObjA, MakeFlatMesh(
|
|
MakeBatch(0x08100025u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh(
|
|
MakeBatch(0x08100026u, TranslucencyKind.Opaque, 3, 4, 3, 2)));
|
|
|
|
var ctx = new TestContext();
|
|
var cell1 = new WalkCell
|
|
{
|
|
CellId = 0x100,
|
|
StabList = [0x101u],
|
|
Portals =
|
|
[
|
|
new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0,
|
|
},
|
|
// The exit portal (retail's "world beyond the door") — this
|
|
// is what raises ov to 1 and drives the landscape (and now
|
|
// its own LandCell turns) before clear+seals+the flood cells.
|
|
new WalkCellPortal
|
|
{
|
|
OtherCellId = 0xFFFFFFFF, PolygonIndex = 1, PortalSide = 0, OtherPortalId = -1,
|
|
},
|
|
],
|
|
PortalPolygons = [Quad(-2f), Quad(-3f)],
|
|
};
|
|
var cell2 = new WalkCell
|
|
{
|
|
CellId = 0x101,
|
|
Portals = [new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0,
|
|
}],
|
|
PortalPolygons = [Quad(-2f)],
|
|
};
|
|
ctx.Cells[cell1.CellId] = cell1;
|
|
ctx.Cells[cell2.CellId] = cell2;
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.CellStaticsByCell[0x100] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
leaf.CellsWithoutEmitters.UnionWith(CoarseLandscapeBuckets(0xF4180000u));
|
|
var trace = new RecordingTrace(log);
|
|
using ClipFrame clipFrame = ClipFrame.NoClip();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, leaf, worldData, trace, clipFrame);
|
|
var walk = new RetailFrameWalk();
|
|
// The same 1x1 landscape RunFrame_InteriorFloodWithExitView... uses,
|
|
// but with a real block published in its one slot so the exit view
|
|
// has something to admit.
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
var block = new WalkLandBlock
|
|
{
|
|
LandblockId = 0xF4180000u, SideCellCount = 1, MaxZ = 10f, MinZ = 0f,
|
|
};
|
|
block.EnsureCellArrays();
|
|
landscape.Blocks[0] = block;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.RunFrame(
|
|
walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: landscape,
|
|
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"SKY", "LANDCELL:f4180000:1:0", "LFLUSH", "SEALS",
|
|
"SHELL:00000101", "SHELL:00000100",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
|
|
},
|
|
log);
|
|
}
|
|
|
|
// ── T4 (S3 chunk 3 §9.3), re-expressed for fix round 1's F2 rule
|
|
// (§9.6 F4c), then again for fix round 2's F10 rule (§9.7 F12): the
|
|
// batch is a single PENDING list Replay keeps across the WHOLE frame.
|
|
// Two LandCell turns of DIFFERENT landblocks merge into ONE
|
|
// DrawLandCellBatch call across EITHER of two things that used to (or,
|
|
// for a real StreamMark, still might look like they should) split it:
|
|
// an intervening EMPTY particle turn (F2, unchanged) AND an intervening
|
|
// StreamMark from a cell that DOES grow the stream but has no
|
|
// renderable emitter of its own (F10, new — proves the split doesn't
|
|
// come back just because content was appended). A cell with BOTH real
|
|
// stream content AND a renderable emitter still splits, because its
|
|
// StaticParticles turn itself is a flush point (F10) — the split just
|
|
// no longer comes from the StreamMark that precedes it. A building's
|
|
// own alpha barrier and its portal pass's punch fan each split too
|
|
// (F10: both remain flush points). ─────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void OnLandCellTurn_MergesAcrossLandblocksOverStreamMarksAndEmptyParticleTurns_RealFlushPointsSplit()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0200_0024UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100024u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var worldData = new FakeWorldData();
|
|
// Has REAL stream content (so its own turn records a genuine
|
|
// StreamMark event) but NO renderable emitter — the F10 case: the
|
|
// StreamMark must not split the pending terrain batch even though
|
|
// the ordered stream visibly grew.
|
|
worldData.OutdoorStaticsByCell[0xAAAA0002u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(311, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
|
|
0xAAAAu);
|
|
// Has real stream content AND a renderable emitter (default) — its
|
|
// StaticParticles turn is itself a flush point.
|
|
worldData.OutdoorStaticsByCell[0xBBBB0002u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(310, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
|
|
0xBBBBu);
|
|
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
// The 0xAAAA0001 particle turn has no world-data records AND is
|
|
// marked without emitters -> a genuinely empty turn (F2, unchanged).
|
|
// 0xAAAA0002 has world-data records (grows the stream, so Collect
|
|
// records a real StreamMark) but is ALSO marked without emitters ->
|
|
// the F10 case. 0xBBBB0002 keeps the default "has emitters".
|
|
leaf.CellsWithoutEmitters.Add(0xAAAA0001u);
|
|
leaf.CellsWithoutEmitters.Add(0xAAAA0002u);
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, new RecordingTrace(log));
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
// A REAL full-viewport landscape view: with an empty view the classifier
|
|
// admits nothing, the stream never grows, and no StreamMark ever fires
|
|
// (the round-2 review caught exactly that vacuity).
|
|
var landscapeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
landscapeViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(landscapeViews);
|
|
|
|
sink.OnLandCellTurn(0xF4180000u, 8, 0); // landblock A
|
|
sink.OnLandscapeCellTurn(0xAAAA0001u); // empty particle turn: no growth, no emitters -> no submit, no flush (F2)
|
|
sink.OnLandCellTurn(0xF3180000u, 8, 0); // landblock B, DIFFERENT -> still merges (F2)
|
|
|
|
sink.OnLandscapeCellTurn(0xAAAA0002u); // grows the stream (a real StreamMark fires) but no emitters -> the StreamMark does NOT flush (F10), and the particle turn doesn't either
|
|
sink.OnLandCellTurn(0xF2180001u, 8, 0); // landblock C -> still merges straight across that StreamMark (F10)
|
|
|
|
sink.OnLandscapeCellTurn(0xBBBB0002u); // grows the stream too, but HAS emitters (default) -> its StaticParticles turn flushes (F10), not the StreamMark ahead of it
|
|
|
|
sink.OnLandCellTurn(0xF3180000u, 8, 1); // new batch, started after that flush
|
|
|
|
sink.OnBuildingTurn(new WalkBuilding()); // the building's own alpha barrier flushes (F10)
|
|
|
|
sink.OnLandCellTurn(0xF2180000u, 8, 0); // new batch
|
|
|
|
sink.OnPunchGeometry(new WalkBuilding(), Quad(0f), 0); // the portal pass's punch fan flushes (F10)
|
|
|
|
sink.OnLandCellTurn(0xF1180000u, 8, 0); // final batch, flushed at Replay's own end
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"FLUSH:1:OutdoorStatic", // 0xAAAA0002's statics: the StreamMark draws its range but does NOT flush the terrain batch (F10)
|
|
"FLUSH:1:OutdoorStatic", // 0xBBBB0002's statics: same
|
|
"LANDCELL:f4180000:8:0,f3180000:8:0,f2180001:8:0",
|
|
"PARTICLES:bbbb0002",
|
|
"LANDCELL:f3180000:8:1",
|
|
"ALPHA",
|
|
"LANDCELL:f2180000:8:0",
|
|
"PUNCH:4@v0",
|
|
"LANDCELL:f1180000:8:0",
|
|
},
|
|
log);
|
|
Assert.DoesNotContain("PARTICLES:aaaa0001", log);
|
|
Assert.DoesNotContain("PARTICLES:aaaa0002", log);
|
|
// Two genuine StreamMarks fired (the ordered stream grew twice) — the
|
|
// F10 rule is exercised, not assumed.
|
|
Assert.Equal(2, log.Count(entry => entry.StartsWith("FLUSH:", StringComparison.Ordinal)));
|
|
Assert.Equal(4, leaf.LandCellBatches.Count);
|
|
Assert.Equal(
|
|
new (uint LandblockId, int SideCellCount, int CellIndex)[]
|
|
{
|
|
(0xF4180000u, 8, 0), (0xF3180000u, 8, 0), (0xF2180001u, 8, 0),
|
|
},
|
|
leaf.LandCellBatches[0]);
|
|
Assert.Equal(
|
|
new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF3180000u, 8, 1) },
|
|
leaf.LandCellBatches[1]);
|
|
Assert.Equal(
|
|
new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF2180000u, 8, 0) },
|
|
leaf.LandCellBatches[2]);
|
|
Assert.Equal(
|
|
new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF1180000u, 8, 0) },
|
|
leaf.LandCellBatches[3]);
|
|
}
|
|
|
|
// ── New punch-order pin (S3 chunk 3 fix round 2, §9.7 F12): a
|
|
// PunchFan flushes the pending terrain batch (F10 — PunchFan's
|
|
// DEPTHTEST_ALWAYS + write is exactly the ordering this chunk exists to
|
|
// preserve, §9.1 R4). LandCell(far), a building turn whose portal pass
|
|
// emits a PunchFan, LandCell(near) -> the leaf log reads
|
|
// LANDCELL(far batch), PUNCH, LANDCELL(near batch): the far terrain is
|
|
// flushed and drawn BEFORE the punch stamps its far-Z, and the near
|
|
// terrain starts a NEW batch drawn strictly AFTER it — so the nearer
|
|
// terrain still overwrites the punched depth, exactly as retail's own
|
|
// interleave requires. ──────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void OnPunchGeometry_FlushesPendingTerrainBatch_FarTerrainBeforePunchNearTerrainAfter()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.OnLandscapeViews(new WalkPortalView());
|
|
|
|
sink.OnLandCellTurn(0xF4180000u, 8, 0); // far land cell
|
|
sink.OnPunchGeometry(new WalkBuilding(), Quad(0f), 0); // the building's far-Z punch
|
|
sink.OnLandCellTurn(0xF4180000u, 8, 1); // near land cell (same landblock — the point is ORDER, not the landblock key)
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"LANDCELL:f4180000:8:0",
|
|
"PUNCH:4@v0",
|
|
"LANDCELL:f4180000:8:1",
|
|
},
|
|
log);
|
|
}
|
|
|
|
// ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture —
|
|
// FW3.2a's own referee) ─────────────────────────────────────────────────
|
|
|
|
private static RenderProjectionRecord MakeRecord(
|
|
uint localEntityId,
|
|
uint serverGuid,
|
|
Vector3 position,
|
|
IReadOnlyList<MeshRef> meshRefs,
|
|
bool isBuildingShell = false,
|
|
uint parentCellId = 0u) =>
|
|
new(
|
|
Id: RenderProjectionId.FromRaw(localEntityId),
|
|
ProjectionClass: RenderProjectionClass.OutdoorStatic,
|
|
OwnerIncarnation: RenderOwnerIncarnation.FromRaw(1),
|
|
Transform: new RenderTransform(Matrix4x4.CreateTranslation(position)),
|
|
PreviousTransform: default,
|
|
MeshSet: default,
|
|
Material: default,
|
|
Residency: default,
|
|
Bounds: default,
|
|
Flags: RenderProjectionFlags.Draw,
|
|
DegradeState: default,
|
|
SortKey: new RenderSortKey(0),
|
|
DirtyMask: default,
|
|
Source: new RenderSourceMetadata(
|
|
LocalEntityId: localEntityId,
|
|
ServerGuid: serverGuid,
|
|
SourceId: 0,
|
|
ParentCellId: parentCellId,
|
|
EffectCellId: 0,
|
|
BuildingShellAnchorCellId: 0,
|
|
TransformFingerprint: default,
|
|
GeometryFingerprint: default,
|
|
AppearanceFingerprint: default),
|
|
EntityPayload: new RenderEntityPayload(
|
|
MeshRefs: meshRefs,
|
|
PaletteOverride: null,
|
|
IsBuildingShell: isBuildingShell));
|
|
|
|
private static ObjectRenderBatch MakeBatch(
|
|
uint surfaceId,
|
|
TranslucencyKind translucency,
|
|
uint firstIndex,
|
|
int baseVertex,
|
|
int indexCount,
|
|
uint textureSlotIndex,
|
|
uint textureLayer = 0,
|
|
CullMode cullMode = CullMode.CounterClockwise) =>
|
|
new()
|
|
{
|
|
Key = new TextureKey { SurfaceId = surfaceId, IsSolid = false },
|
|
Translucency = translucency,
|
|
FirstIndex = firstIndex,
|
|
BaseVertex = (uint)baseVertex,
|
|
IndexCount = indexCount,
|
|
TextureSlot = new GpuTextureSlot(textureSlotIndex),
|
|
TextureIndex = (int)textureLayer,
|
|
};
|
|
|
|
private static ObjectRenderData MakeFlatMesh(params ObjectRenderBatch[] batches) =>
|
|
new() { Batches = new List<ObjectRenderBatch>(batches) };
|
|
|
|
private static void InjectRenderData(ObjectMeshManager manager, ulong id, ObjectRenderData data)
|
|
{
|
|
FieldInfo field = typeof(ObjectMeshManager).GetField(
|
|
"_renderData", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException(
|
|
"ObjectMeshManager._renderData field not found — test relies on this exact name.");
|
|
var dict = (ConcurrentDictionary<ulong, ObjectRenderData>)field.GetValue(manager)!;
|
|
dict[id] = data;
|
|
}
|
|
|
|
private readonly struct DrawScope : IDisposable
|
|
{
|
|
private readonly IDisposable _publication;
|
|
private readonly IGpuPassEncoder _pass;
|
|
private readonly RetailAlphaQueue? _alpha;
|
|
|
|
public DrawScope(
|
|
IGpuFrame frame,
|
|
IGpuPassEncoder pass,
|
|
IDisposable publication,
|
|
RetailAlphaQueue? alpha = null)
|
|
{
|
|
Frame = frame;
|
|
_pass = pass;
|
|
_publication = publication;
|
|
_alpha = alpha;
|
|
}
|
|
|
|
public IGpuFrame Frame { get; }
|
|
|
|
public IGpuPassEncoder Pass => _pass;
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_alpha?.IsCollecting == true)
|
|
_alpha.EndFrame();
|
|
_publication.Dispose();
|
|
_pass.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class DispatcherFixture : IDisposable
|
|
{
|
|
private readonly WbMeshAdapter _meshAdapter;
|
|
private readonly TextureCache _textures;
|
|
|
|
public DispatcherFixture()
|
|
{
|
|
Device = new RecordingGpuDevice();
|
|
FrameLifetime = new GpuDeviceFrameLifetime(Device);
|
|
Scope = new VulkanWorldPassScope(sampleCount: 1);
|
|
_textures = new TextureCache(Device, new NoopDatReaderWriter());
|
|
_meshAdapter = new WbMeshAdapter(
|
|
Device,
|
|
new NoopDatReaderWriter(),
|
|
new NullPreparedAssetSource(),
|
|
NullLogger<WbMeshAdapter>.Instance,
|
|
Device.Retirement);
|
|
var entitySpawnAdapter = new EntitySpawnAdapter(
|
|
_textures,
|
|
_ => throw new NotSupportedException("Not exercised by these tests."));
|
|
|
|
Dispatcher = new WbDrawDispatcher(
|
|
Device,
|
|
FrameLifetime,
|
|
Scope,
|
|
_textures,
|
|
_meshAdapter,
|
|
entitySpawnAdapter,
|
|
new EntityClassificationCache(),
|
|
new AcDream.Core.Rendering.TranslucencyFadeManager(),
|
|
alphaQueue: AlphaQueue);
|
|
}
|
|
|
|
public RecordingGpuDevice Device { get; }
|
|
|
|
public GpuDeviceFrameLifetime FrameLifetime { get; }
|
|
|
|
public VulkanWorldPassScope Scope { get; }
|
|
|
|
public WbDrawDispatcher Dispatcher { get; }
|
|
|
|
public RetailAlphaQueue AlphaQueue { get; } = new();
|
|
|
|
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
|
|
|
|
public DrawScope BeginDraw(bool beginAlpha = false)
|
|
{
|
|
if (beginAlpha)
|
|
{
|
|
Dispatcher.BeginFrame(frameSlot: 0);
|
|
AlphaQueue.BeginFrame();
|
|
}
|
|
FrameLifetime.BeginFrame();
|
|
IGpuFrame frame = FrameLifetime.CurrentFrame!;
|
|
IGpuPassEncoder pass = frame.BeginPass(
|
|
GpuPassDescription.BackbufferClear(
|
|
"fw3-2b-1-walk-frame-driver-test", Vector4.Zero, sampleCount: 1));
|
|
IDisposable publication = Scope.Publish(pass);
|
|
Device.Clear();
|
|
return new DrawScope(
|
|
frame,
|
|
pass,
|
|
publication,
|
|
beginAlpha ? AlphaQueue : null);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (AlphaQueue.IsCollecting)
|
|
AlphaQueue.AbortFrame();
|
|
Dispatcher.Dispose();
|
|
_meshAdapter.Dispose();
|
|
_textures.Dispose();
|
|
Device.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class NullPreparedAssetSource : IPreparedAssetSource
|
|
{
|
|
public PreparedAssetSourceStats Stats => default;
|
|
|
|
public CacheStats DecodedTextureCacheStats => default;
|
|
|
|
public PreparedAssetPresence Probe(
|
|
AcDream.Content.Pak.PakAssetType type,
|
|
uint sourceFileId) =>
|
|
PreparedAssetPresence.Missing;
|
|
|
|
public PreparedAssetReadResult Read(
|
|
in PreparedAssetRequest request,
|
|
CancellationToken cancellationToken = default) =>
|
|
PreparedAssetReadResult.Missing;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class NoopDatReaderWriter : IDatReaderWriter
|
|
{
|
|
private readonly StubDatabase _portal = new();
|
|
private readonly StubDatabase _highRes = new();
|
|
private readonly StubDatabase _language = new();
|
|
private readonly StubDatabase _cell = new();
|
|
|
|
public string SourceDirectory => string.Empty;
|
|
|
|
public IDatDatabase Portal => _portal;
|
|
|
|
public IDatDatabase Cell => _cell;
|
|
|
|
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
|
|
new(new Dictionary<uint, IDatDatabase>());
|
|
|
|
public IDatDatabase HighRes => _highRes;
|
|
|
|
public IDatDatabase Language => _language;
|
|
|
|
public IDatDatabase Local => _language;
|
|
|
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
|
new(new Dictionary<uint, uint>());
|
|
|
|
public int PortalIteration => 0;
|
|
|
|
public int CellIteration => 0;
|
|
|
|
public int HighResIteration => 0;
|
|
|
|
public int LanguageIteration => 0;
|
|
|
|
public bool TryGetFileBytes(
|
|
uint regionId,
|
|
uint fileId,
|
|
ref byte[] bytes,
|
|
out int bytesRead)
|
|
{
|
|
bytesRead = 0;
|
|
return false;
|
|
}
|
|
|
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
|
Array.Empty<uint>();
|
|
|
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
|
Array.Empty<IDatReaderWriter.IdResolution>();
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public bool TrySave<T>(
|
|
uint regionId,
|
|
T obj,
|
|
int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
[return: MaybeNull]
|
|
public T Get<T>(uint fileId) where T : IDBObj => default;
|
|
|
|
public bool TryGet<T>(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
private sealed class StubDatabase : IDatDatabase
|
|
{
|
|
public DatDatabase Db => throw new NotSupportedException();
|
|
|
|
public int Iteration => 0;
|
|
|
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
|
Array.Empty<uint>();
|
|
|
|
public bool TryGet<T>(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetFileBytes(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out byte[] value)
|
|
{
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetFileBytes(
|
|
uint fileId,
|
|
ref byte[] bytes,
|
|
out int bytesRead)
|
|
{
|
|
bytesRead = 0;
|
|
return false;
|
|
}
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|