Three-lens review of c7ab5b6d8 found the round-0 port of
DrawPortalPolyInternal's degenerate-input guard inverted retail's
quantifier: it rejected a polygon when ANY vertex sat on ANY of the
four +/-12 planes. The PDB-paired bytes (VA 0x59BCD6-0x59BD66: four
per-plane fld/fcomp 12.0/fnstsw/test ah,0x44/jnp sequences, each
accumulating a "every vertex on this plane" boolean, cleared by the
first vertex NOT on it) and the named decomp's own nested-if reading
of the post-loop branch (0x59bd42-0x59bd6c) agree: retail rejects only
when EVERY local vertex lies on the SAME one of the four planes — a
polygon degenerate onto a plane, not one that merely touches a plane
at one vertex.
F1 — the quantifier. WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard
now tracks four booleans (everyVertexOnPlusX/MinusX/PlusY/MinusY), each
starting true and cleared by the first vertex that fails its own plane
test; the guard rejects iff any of the four survives the whole vertex
loop. T2 (WalkVisibilityMathTests.cs) is rewritten to the five §7
cases: every vertex on x=+12 -> rejected; every vertex on y=-12 ->
rejected; one vertex on x=+12, rest inside -> KEPT; every vertex at
11.999 -> kept; vertices split across x=+12 and y=+12 (no common
plane) -> kept. WalkFrameDriverTests' punch-side pin
(OnPunchGeometry_RejectsOnlyWhenEveryVertexSharesOnePlane_ButPunchesAnyOtherShape)
carries the same correction — its old "one vertex on the plane"
polygon was wrongly rejected under round 0; it punches now, and a new
all-on-plane polygon is added as the actual rejected case. The doc
comment's "essentially never" sentence is deleted and replaced with a
fresh DAT-scan citation (see datScan below).
MUTATION (verified): restore the any-vertex form -> the one-vertex and
split-plane admit cases fail (Assert.False Expected:False Actual:True,
x2; the WalkFrameDriverTests punch pin's Assert.Equal Expected:2
Actual:1). Delete the guard (always return false) -> the all-on-plane
reject cases fail (Assert.True Expected:True Actual:False, x3 —
plusX-plane, minusY-plane, and the vacuous-empty-polygon pin; the
punch pin's Assert.Equal Expected:2 Actual:3).
datScan (DAT counts backing F1's doc comment): a throwaway xunit test
(deleted after this commit — not part of the diff) walked the full
0x0000-0xFFFF landblock-prefix space (LandBlockInfo/EnvCell carry no
usable DatCollection.GetAllIdsOfType range — both are landblock-
relative — matching the method CellStructSurfaceConstructionInstalledDatTests
already established), resolved each EnvCell's Environment/CellStruct,
and for every CellPortal's polygon tested each vertex's local x/y
against +/-12. Against the installed client_cell_1.dat: 3,405
landblocks with cells, 1,854,237 total portal polygons. 2,889 polygons
have at least one vertex on a +/-12 plane; 2,163 have EVERY vertex on
the SAME plane — all 2,163 are EXIT portals (OtherCellId==0xFFFF, out
of 16,939 exit portals total); 0 interior (non-exit) portals ever
qualify (interior: 1,837,298 total, 340 any-vertex, 0 all-on-plane).
Duration ~15s. This exactly reproduces the review's cited numbers.
F2 — seal-path count order. RetailPViewPassExecutor's exit-seal loop
dropped the `localVertices.Length < 3` pre-filter that ran BEFORE both
the boundary guard and the `submitted` increment (round 0's order:
length check -> guard -> transform -> draw -> count). Retail's real
order is guard -> transform -> clip -> count, with the counter
incremented BEFORE polyClipFinish runs (0x59BD70-0x59BD74 precedes
0x59BDB0) — the counter records accepted ATTEMPTS, not successful GPU
fans. `submitted++` now runs immediately after the guard-gated
transform+EmitSeamMask, before DrawDepthFan (whose own `< 3` guard is
this port's stand-in for retail's post-clip `var_4 >= 3` check). A new
pin (RetailPViewPassExecutorTests.DrawExitPortalMask_CountsAnUnclippableTwoVertexPolygon_ButDrawsNothing)
constructs a real PortalDepthMaskRenderer (RecordingGpuDevice-backed)
and a constructor-bypassed executor (RuntimeHelpers.GetUninitializedObject,
the same pattern WalkOutsideViewReassemblyTests already uses) with a
2-vertex ordinary-coordinate portal polygon: DrawExitPortalMask returns
submitted=1 while zero draw calls reach the device.
MUTATION (verified): restore the `< 3` pre-filter ahead of the guard
-> the pin's Assert.Equal fails, Expected:1 Actual:0.
F3 — the depth-event transcript gate, automated. New
WalkAlphaDepthTrace.cs (beside WalkOracleTrace.cs) parses the four
`PM poly=<ptr> mode=<0|1> counterBefore=<hex>` / `PC ov=<n>
counter=<hex> fc=<0|1>` lines per oh-capture/*.alphadepth.log frame,
using the same F-marker frame delimiting as WalkOracleTrace. New
WalkTraceConformanceTests.AlphaDepthTranscript.cs (beside
WalkTraceConformanceTests.cs, which is now `partial`) replays each
pose's frame 2 through the real WalkFrameDriver/RetailFrameWalk over
WalkLandscapeDatBuilder-built installed-DAT world data, with:
- AlphaDepthCollectSink: an IWalkEventSink decorator forwarding every
hook to the real driver unchanged, while recording mode=1 punch
events (OnPunchGeometry, counterBefore = driver.PortalsDrawnCount
at COLLECT time — Collect finishes entirely before Replay starts,
so reading the counter at Replay time would already see the SAME
turn's post-reset value; holtburg-doorway-still's own capture
proves this: its punches show counterBefore=2 immediately followed
by its seals' counterBefore=0/1 in the identical turn) and every
PC tuple (Emit, WalkEventKind.DrawCells — fires for the root's own
turn AND every building look-in's re-entrant call, matching
retail's breakpoint not distinguishing them).
- AlphaDepthLeaf: an IWalkFrameLeafRenderer whose DrawExitSeals
reproduces DrawPortalDepthWrite's real per-portal loop (F1's guard,
F2's count-before-clip order) directly over
driver.InteriorFloodCells / WalkCell.Portals / .PortalPolygons —
the identical data the render-side production path reads — since
the interface gives DrawExitSeals no cell/portal parameter at all.
- Two Collect+Replay passes per pose (a fresh driver reaches the SAME
steady-state a running session already carries by the time
retail's capture began — holtburg-doorway-still and foundry-deep
both show a stable per-frame accepted-seal count that regenerates
identically every captured frame): the first pass's own output is
discarded, only the second is compared.
Five Facts (one per pose — the spec's "four" undercounts the five
*.alphadepth.log files actually present in oh-capture/):
AlphaDepthTranscript_CathedralArrival_MatchesRetailFrame2 — PASS
AlphaDepthTranscript_CathedralLeak_MatchesRetailFrame2 — PASS
AlphaDepthTranscript_FoundryDeep_MatchesRetailFrame2 — PASS
AlphaDepthTranscript_HoltburgDoorwayStill_MatchesRetailFrame2 — PASS
(proves the incrementing 0/1/2 accepted-seal sequence AND the
pre-reset counterBefore=2 punches, end to end, against real DAT
portal data)
AlphaDepthTranscript_TerraceEdge_MatchesRetailFrame2 — KnownFailure
(tagged [Trait("Status","KnownFailure")]): terrace-edge never runs
a qualifying interior turn in its own capture (every PC line reads
ov=0) — its punches' counterBefore=2 is a session value carried
over from BEFORE the capture even started (the file's own pre-"F 1"
content already reads 2; nothing inside the captured frames ever
touches the counter). A fresh two-pass replay from
PortalsDrawnCount=0 has no mechanism to derive that leftover value.
Printed divergence: expected [(mode=1,counterBefore=2) x2], actual
[(mode=1,counterBefore=0) x2]; the PC sequence (ov=0 both times)
matches exactly. An initial-condition gap in the fixture, not a
guard/count defect — the identical harness reproduces holtburg-
doorway-still's and foundry-deep's own steady states exactly from
cold starts.
MUTATION (verified): swap AlphaDepthLeaf's recorded seal mode from 0
to 1 -> AlphaDepthTranscript_HoltburgDoorwayStill_MatchesRetailFrame2
fails (Assert.Equal Collections differ at pos 5: expected
(0,0),(0,1)... actual (1,0),(1,1)...).
DispatcherFixture and DrawScope (WalkFrameDriverTests.cs) are widened
from private to internal so the new gate can drive a real
WbDrawDispatcher without duplicating that fixture.
F4 — manifest comment truth. VulkanShaderManifestTests.cs's
portal_depth.vert.spv re-pin comment had the ULP direction backward
(claimed the OLD 0x3F7FFFFE literal was NEARER the camera than
retail's real 0x3F7FFFEF constant; portal_depth.vert's own shader-
source comment already had this correct — FARTHER, larger z/w) and
cited T1 as living in WalkVisibilityMathTests when
PortalDepthVert_FarPunchConstant_MatchesRetailExactBits is in this
same file. Both corrected.
Gates: dotnet build tests/AcDream.App.Tests -c Release -> 0 warnings/0
errors. Hermetic lane (Lane!=InstalledDat&...&Status!=KnownFailure):
6829/6829 passed. InstalledDat lane (real client_cell_1.dat): 253
total, 247 passed, 5 failed — exactly the two #383 layout tests
(LayoutImporterMediaBearingChildSweepTests/LayoutImporterInvisibleSweepTests),
TowerAscentReplayTests' KnownFailure, Oh_doorway_still_first_frame_diff
(#458), and this round's own AlphaDepthTranscript_TerraceEdge
KnownFailure — no other divergence. Shader tests (VulkanShaderDescriptorContractTests
+ VulkanShaderManifestTests + RenderPackSpirvValidatorTests +
SkyVertexLayoutTests): 35/35. Register/divergence tests: 52/52.
dotnet build src/AcDream.App -c Release: 0 warnings/0 errors.
Register: no new AD row. F1/F2 are straight bug fixes toward retail
fidelity (not deviations); AD-119 (the C2 blend-vs-write-mask row)
does not describe the count order, so its neighbour needed no
correction per F2's own instruction.
Deviation from the fix-round spec's literal text: §7 F1 said "swap the
any-vertex hits instead of the four per-plane accumulators" as the
split-plane case's mutation — the code actually ported uses per-vertex
`if (x != 12f) flag = false;` clears, not a per-vertex OR; the mutation
verified above (restore the any-vertex FORM entirely) exercises the
same defect class and is the mutation actually run. §7 also names
"four" alphadepth.log fixtures where five exist on disk
(cathedral-arrival, cathedral-leak, foundry-deep,
holtburg-doorway-still, terrace-edge); all five are gated rather than
four, with cathedral-leak an unremarked addition alongside the four
the spec names.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2270 lines
102 KiB
C#
2270 lines
102 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 (quantifier corrected at fix round 1 F1): DrawPortalPoly
|
|
// Internal's degenerate-input guard, ported at the punch-fan producer
|
|
// (WalkFrameDriver.OnPunchGeometry — the handler that owns the LOCAL
|
|
// polygon before TransformToWorld). The polygon is dropped before any
|
|
// transform, event, or counter effect ONLY when EVERY local vertex lies
|
|
// on the SAME +/-12 plane; a polygon with just one vertex there — or at
|
|
// 11.999, just inside — is ordinary and punches normally. ────────────
|
|
|
|
[Fact]
|
|
public void OnPunchGeometry_RejectsOnlyWhenEveryVertexSharesOnePlane_ButPunchesAnyOtherShape()
|
|
{
|
|
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: EVERY vertex sits exactly on the local x == +12
|
|
// boundary (degenerate onto the plane) — the only shape retail
|
|
// actually rejects.
|
|
// MUTATION: restore the any-vertex form (any single vertex ==
|
|
// +/-12 rejects the whole polygon) — the "one vertex only" case
|
|
// below starts failing (wrongly rejected) instead.
|
|
sink.OnPunchGeometry(
|
|
building,
|
|
new WalkPolygon
|
|
{
|
|
Vertices = [new(12f, -3f, 3f), new(12f, 0f, 3f), new(12f, 5f, 3f)],
|
|
Plane = new WalkPlane(Vector3.UnitZ, -3f),
|
|
},
|
|
activeViewIndex: 0);
|
|
|
|
// Admitted: only ONE vertex sits on x == +12; the other two do not
|
|
// share that plane. Retail draws this polygon (punched, counted).
|
|
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 TWO punches reached the leaf — the all-on-plane polygon
|
|
// produced no PunchFan event at all (not a punch that draws zero
|
|
// vertices; no event, full stop); the other two (one-vertex-on-plane,
|
|
// just-inside) are ordinary and both punched.
|
|
Assert.Equal(2, leaf.Punches.Count);
|
|
Assert.Equal(new Vector3(12f, 0f, 3f), leaf.Punches[0].Vertices[1]);
|
|
Assert.Equal(new Vector3(11.999f, 0f, 3f), leaf.Punches[1].Vertices[1]);
|
|
Assert.Equal(2, 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;
|
|
}
|
|
|
|
// S4-c1 fix round 1 F3: DrawScope/DispatcherFixture widened to internal
|
|
// so WalkTraceConformanceTests.AlphaDepthTranscriptTests.cs can drive a
|
|
// REAL WbDrawDispatcher for the depth-event transcript gate without
|
|
// duplicating this entire fixture (RecordingGpuDevice + WbMeshAdapter +
|
|
// TextureCache + EntitySpawnAdapter wiring) a second time.
|
|
internal 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();
|
|
}
|
|
}
|
|
|
|
internal 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()
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|