Ports RenderDeviceD3D::DrawBlock @0x005a17c0's real per-cell order: loop 1 (@0x005a1876) prepares shadow lists; loop 2 (@0x005a197d) DrawLandCell(cell) @0x005a19c0 fires ONLY when the cell is in view, STRICTLY BEFORE DrawSortCell(cell) @0x005a19e6, which fires whenever alwaysDrawObjects (retail default 1 @0x00820ed4) or the cell is in view. RetailFrameWalk.DrawLandscape now emits sink.OnLandCellTurn( landblockId, side, cellIndex) at that exact point, per admitted cell, before the existing DrawBuilding + OnLandscapeCellTurn (the DrawSortCell half). WalkFrameDriver records one LandCell frame event per turn and deletes the whole-stage TerrainSlice(0) emission and the DrawTerrainSlice leaf outright — drawing all terrain before every building let a nearer building's far-Z punch survive under farther terrain drawn afterward, the doorway-behind-a-hill fragment bug from the owner's G2 Holtburg screenshot; this chunk removes it by ORDER alone, with no depth-compare change (S4's punch z-func question is untouched, per the contract). Index-run arithmetic (S3 §9.1 R3): the terrain mesh is cell-major (LandblockMesh.Build: cy outer, cx inner, 6 indices/cell, 384/land- block). A retail LOD cell (side n, LOD coords X,Y) covers cx in [X*8/n,(X+1)*8/n), cy in [Y*8/n,(Y+1)*8/n) — one contiguous run per covered cy row: side 8 -> one run of 6, side 4 -> two runs of 12, side 2 -> four runs of 24; side 1's single coarse cell covers every row contiguously so its 8 per-row runs collapse into ONE run of all 384 indices. TerrainModernRenderer.AppendCellIndexRuns (pure, no GPU, no baked table) and DrawLandCellRuns (resolves the landblock's slot, builds one DrawElementsIndirectCommand per run, reuses the existing DrawRhi bind-and-submit path) implement this; TerrainModernRenderer .Draw(...) is untouched and keeps serving non-walk callers (directional- shadow receivers, the flat terrain path). Order-preserving batching (S3 §9.2 B2): WalkFrameDriver.Replay merges consecutive same-landblock LandCell events with no intervening event into ONE DrawLandCellBatch leaf call; any other event splits the batch. In production this rarely fires because retail's own DrawSortCell (AlwaysDrawObjects=true) always interposes an object- list turn between one cell's LandCell event and the next's — see perfNote in the task report for the resulting command-count increase. Deleted as dead: the _walkTerrainInViewLandcells field and SetWalkTerrainInViewLandcells setter on RetailPViewPassExecutor (fed only DrawWalkTerrainSlice's inViewLandcells filter, which no longer exists — the walk's own per-cell CellInView admission is now the sole terrain-visibility authority) and its two call sites in RetailPViewRenderer.DrawWalkDrivenStatics. Weather placement (S3 §9.1 R5): confirmed unchanged. GameSky's weather pass (RenderWeather, gated on is_player_outside) already runs after every LandCell event for both root kinds — for an outdoor root, DrawLandscapeDynamicsPhase is called directly after driver.Replay() completes (which processes the whole per-cell _events list first); for an interior root with ov>0, it fires via the LandscapeFlush leaf (RetailPViewRenderer.FlushWalkLandscape -> _walkPreClearDynamics), and OnInteriorFloodDrawTurn only emits LandscapeFlush AFTER DrawLandscape's per-cell loop has fully run and recorded every LandCell event ahead of it in the same _events list Replay walks in order. No code change needed; verified by reading the call sites. Tests: WalkEvents/RetailFrameWalk/WalkFrameDriver's existing pins updated (every "TERRAIN:0" expectation deleted, matching the deleted event); new coverage for T1 (WalkLandCellOrderTests — LandWalkOrder + WalkLandscape.CalcDrawOrder + WalkLandscapeAssembler .SideCellCountForRing reproduce both cathedral captures' frame-2 LC/SC sequences byte-for-byte, 533/698 arrival and 531/757 leak, cross- verified against the retail ring-to-LOD table), T2 (a synthetic two- block landscape with a real WalkVisibilityMath-driven out-of-view column, proving LC-before-building/statics, far-to-near, no-LC-but- keeps-SC for the excluded cells, and no TerrainSlice event of any kind), T3 (TerrainLandCellIndexRunsTests — the index-run arithmetic for every side/cell, disjoint and exhaustive over the 384-index landblock), T4 (batching merge/split), and the RetailPViewPassExecutor CompiledCallGraph pin retargeted at DrawWalkLandCellBatch / DrawLandCellRuns. WalkOracleTrace gains LC/SC event kinds and a Load(root, name) overload for the OH capture directory — the one parser change this chunk needs, no validator, no other infrastructure. App hermetic lane: 6,786/6,786 (6,765 baseline + 21 new). InstalledDat lane: 241 passed, 3 accepted failures (2 pre-existing #383 layout fixture-drift tests, 1 TowerAscent Status=KnownFailure) — unchanged from baseline. Core terrain tests: 116/116 (Core untouched by this chunk). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1666 lines
70 KiB
C#
1666 lines
70 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 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 CellCount)> LandCellBatches = 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(
|
|
uint landblockId, IReadOnlyList<(int SideCellCount, int CellIndex)> cells)
|
|
{
|
|
LandCellBatches.Add((landblockId, cells.Count));
|
|
log.Add($"LANDCELL:{landblockId:x8}:{cells.Count}");
|
|
}
|
|
|
|
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;
|
|
|
|
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);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// ── 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));
|
|
uint clipSlot = Assert.Single(driver.VisibleClipSlotsInLookInTurn(
|
|
0, in insideCone, 0.1f, testSphere: true));
|
|
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));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
// ── T4 (S3 chunk 3 §9.3): order-preserving batching — consecutive
|
|
// same-landblock LandCell turns with NO intervening event merge into
|
|
// ONE DrawLandCellBatch call; any other event (here, a building's own
|
|
// alpha barrier) splits the batch; a later run of the SAME landblock
|
|
// after a split is its own new batch; a different landblock never
|
|
// merges with a prior one even when adjacent. ────────────────────────
|
|
|
|
[Fact]
|
|
public void OnLandCellTurn_ConsecutiveSameSlotEventsMergeIntoOneBatch_AnyOtherEventSplits()
|
|
{
|
|
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.OnLandCellTurn(0xF4180000u, 8, 0);
|
|
sink.OnLandCellTurn(0xF4180000u, 8, 1); // same slot, nothing between -> merges
|
|
sink.OnBuildingTurn(new WalkBuilding()); // intervening event splits the batch
|
|
sink.OnLandCellTurn(0xF4180000u, 8, 2); // same slot as before the split, but a NEW batch
|
|
sink.OnLandCellTurn(0xF3180000u, 8, 0); // a different slot never merges
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"LANDCELL:f4180000:2",
|
|
"ALPHA",
|
|
"LANDCELL:f4180000:1",
|
|
"LANDCELL:f3180000:1",
|
|
},
|
|
log);
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
(0xF4180000u, 2),
|
|
(0xF4180000u, 1),
|
|
(0xF3180000u, 1),
|
|
},
|
|
leaf.LandCellBatches);
|
|
}
|
|
|
|
// ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture —
|
|
// FW3.2a's own referee) ─────────────────────────────────────────────────
|
|
|
|
private static RenderProjectionRecord MakeRecord(
|
|
uint localEntityId,
|
|
uint serverGuid,
|
|
Vector3 position,
|
|
IReadOnlyList<MeshRef> meshRefs,
|
|
bool isBuildingShell = false,
|
|
uint parentCellId = 0u) =>
|
|
new(
|
|
Id: RenderProjectionId.FromRaw(localEntityId),
|
|
ProjectionClass: RenderProjectionClass.OutdoorStatic,
|
|
OwnerIncarnation: RenderOwnerIncarnation.FromRaw(1),
|
|
Transform: new RenderTransform(Matrix4x4.CreateTranslation(position)),
|
|
PreviousTransform: default,
|
|
MeshSet: default,
|
|
Material: default,
|
|
Residency: default,
|
|
Bounds: default,
|
|
Flags: RenderProjectionFlags.Draw,
|
|
DegradeState: default,
|
|
SortKey: new RenderSortKey(0),
|
|
DirtyMask: default,
|
|
Source: new RenderSourceMetadata(
|
|
LocalEntityId: localEntityId,
|
|
ServerGuid: serverGuid,
|
|
SourceId: 0,
|
|
ParentCellId: parentCellId,
|
|
EffectCellId: 0,
|
|
BuildingShellAnchorCellId: 0,
|
|
TransformFingerprint: default,
|
|
GeometryFingerprint: default,
|
|
AppearanceFingerprint: default),
|
|
EntityPayload: new RenderEntityPayload(
|
|
MeshRefs: meshRefs,
|
|
PaletteOverride: null,
|
|
IsBuildingShell: isBuildingShell));
|
|
|
|
private static ObjectRenderBatch MakeBatch(
|
|
uint surfaceId,
|
|
TranslucencyKind translucency,
|
|
uint firstIndex,
|
|
int baseVertex,
|
|
int indexCount,
|
|
uint textureSlotIndex,
|
|
uint textureLayer = 0,
|
|
CullMode cullMode = CullMode.CounterClockwise) =>
|
|
new()
|
|
{
|
|
Key = new TextureKey { SurfaceId = surfaceId, IsSolid = false },
|
|
Translucency = translucency,
|
|
FirstIndex = firstIndex,
|
|
BaseVertex = (uint)baseVertex,
|
|
IndexCount = indexCount,
|
|
TextureSlot = new GpuTextureSlot(textureSlotIndex),
|
|
TextureIndex = (int)textureLayer,
|
|
};
|
|
|
|
private static ObjectRenderData MakeFlatMesh(params ObjectRenderBatch[] batches) =>
|
|
new() { Batches = new List<ObjectRenderBatch>(batches) };
|
|
|
|
private static void InjectRenderData(ObjectMeshManager manager, ulong id, ObjectRenderData data)
|
|
{
|
|
FieldInfo field = typeof(ObjectMeshManager).GetField(
|
|
"_renderData", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException(
|
|
"ObjectMeshManager._renderData field not found — test relies on this exact name.");
|
|
var dict = (ConcurrentDictionary<ulong, ObjectRenderData>)field.GetValue(manager)!;
|
|
dict[id] = data;
|
|
}
|
|
|
|
private readonly struct DrawScope : IDisposable
|
|
{
|
|
private readonly IDisposable _publication;
|
|
private readonly IGpuPassEncoder _pass;
|
|
private readonly RetailAlphaQueue? _alpha;
|
|
|
|
public DrawScope(
|
|
IGpuFrame frame,
|
|
IGpuPassEncoder pass,
|
|
IDisposable publication,
|
|
RetailAlphaQueue? alpha = null)
|
|
{
|
|
Frame = frame;
|
|
_pass = pass;
|
|
_publication = publication;
|
|
_alpha = alpha;
|
|
}
|
|
|
|
public IGpuFrame Frame { get; }
|
|
|
|
public IGpuPassEncoder Pass => _pass;
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_alpha?.IsCollecting == true)
|
|
_alpha.EndFrame();
|
|
_publication.Dispose();
|
|
_pass.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class DispatcherFixture : IDisposable
|
|
{
|
|
private readonly WbMeshAdapter _meshAdapter;
|
|
private readonly TextureCache _textures;
|
|
|
|
public DispatcherFixture()
|
|
{
|
|
Device = new RecordingGpuDevice();
|
|
FrameLifetime = new GpuDeviceFrameLifetime(Device);
|
|
Scope = new VulkanWorldPassScope(sampleCount: 1);
|
|
_textures = new TextureCache(Device, new NoopDatReaderWriter());
|
|
_meshAdapter = new WbMeshAdapter(
|
|
Device,
|
|
new NoopDatReaderWriter(),
|
|
new NullPreparedAssetSource(),
|
|
NullLogger<WbMeshAdapter>.Instance,
|
|
Device.Retirement);
|
|
var entitySpawnAdapter = new EntitySpawnAdapter(
|
|
_textures,
|
|
_ => throw new NotSupportedException("Not exercised by these tests."));
|
|
|
|
Dispatcher = new WbDrawDispatcher(
|
|
Device,
|
|
FrameLifetime,
|
|
Scope,
|
|
_textures,
|
|
_meshAdapter,
|
|
entitySpawnAdapter,
|
|
new EntityClassificationCache(),
|
|
new AcDream.Core.Rendering.TranslucencyFadeManager(),
|
|
alphaQueue: AlphaQueue);
|
|
}
|
|
|
|
public RecordingGpuDevice Device { get; }
|
|
|
|
public GpuDeviceFrameLifetime FrameLifetime { get; }
|
|
|
|
public VulkanWorldPassScope Scope { get; }
|
|
|
|
public WbDrawDispatcher Dispatcher { get; }
|
|
|
|
public RetailAlphaQueue AlphaQueue { get; } = new();
|
|
|
|
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
|
|
|
|
public DrawScope BeginDraw(bool beginAlpha = false)
|
|
{
|
|
if (beginAlpha)
|
|
{
|
|
Dispatcher.BeginFrame(frameSlot: 0);
|
|
AlphaQueue.BeginFrame();
|
|
}
|
|
FrameLifetime.BeginFrame();
|
|
IGpuFrame frame = FrameLifetime.CurrentFrame!;
|
|
IGpuPassEncoder pass = frame.BeginPass(
|
|
GpuPassDescription.BackbufferClear(
|
|
"fw3-2b-1-walk-frame-driver-test", Vector4.Zero, sampleCount: 1));
|
|
IDisposable publication = Scope.Publish(pass);
|
|
Device.Clear();
|
|
return new DrawScope(
|
|
frame,
|
|
pass,
|
|
publication,
|
|
beginAlpha ? AlphaQueue : null);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (AlphaQueue.IsCollecting)
|
|
AlphaQueue.AbortFrame();
|
|
Dispatcher.Dispose();
|
|
_meshAdapter.Dispose();
|
|
_textures.Dispose();
|
|
Device.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class NullPreparedAssetSource : IPreparedAssetSource
|
|
{
|
|
public PreparedAssetSourceStats Stats => default;
|
|
|
|
public CacheStats DecodedTextureCacheStats => default;
|
|
|
|
public PreparedAssetPresence Probe(
|
|
AcDream.Content.Pak.PakAssetType type,
|
|
uint sourceFileId) =>
|
|
PreparedAssetPresence.Missing;
|
|
|
|
public PreparedAssetReadResult Read(
|
|
in PreparedAssetRequest request,
|
|
CancellationToken cancellationToken = default) =>
|
|
PreparedAssetReadResult.Missing;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class NoopDatReaderWriter : IDatReaderWriter
|
|
{
|
|
private readonly StubDatabase _portal = new();
|
|
private readonly StubDatabase _highRes = new();
|
|
private readonly StubDatabase _language = new();
|
|
private readonly StubDatabase _cell = new();
|
|
|
|
public string SourceDirectory => string.Empty;
|
|
|
|
public IDatDatabase Portal => _portal;
|
|
|
|
public IDatDatabase Cell => _cell;
|
|
|
|
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
|
|
new(new Dictionary<uint, IDatDatabase>());
|
|
|
|
public IDatDatabase HighRes => _highRes;
|
|
|
|
public IDatDatabase Language => _language;
|
|
|
|
public IDatDatabase Local => _language;
|
|
|
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
|
new(new Dictionary<uint, uint>());
|
|
|
|
public int PortalIteration => 0;
|
|
|
|
public int CellIteration => 0;
|
|
|
|
public int HighResIteration => 0;
|
|
|
|
public int LanguageIteration => 0;
|
|
|
|
public bool TryGetFileBytes(
|
|
uint regionId,
|
|
uint fileId,
|
|
ref byte[] bytes,
|
|
out int bytesRead)
|
|
{
|
|
bytesRead = 0;
|
|
return false;
|
|
}
|
|
|
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
|
Array.Empty<uint>();
|
|
|
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
|
Array.Empty<IDatReaderWriter.IdResolution>();
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public bool TrySave<T>(
|
|
uint regionId,
|
|
T obj,
|
|
int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
[return: MaybeNull]
|
|
public T Get<T>(uint fileId) where T : IDBObj => default;
|
|
|
|
public bool TryGet<T>(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
private sealed class StubDatabase : IDatDatabase
|
|
{
|
|
public DatDatabase Db => throw new NotSupportedException();
|
|
|
|
public int Iteration => 0;
|
|
|
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
|
Array.Empty<uint>();
|
|
|
|
public bool TryGet<T>(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetFileBytes(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out byte[] value)
|
|
{
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetFileBytes(
|
|
uint fileId,
|
|
ref byte[] bytes,
|
|
out int bytesRead)
|
|
{
|
|
bytesRead = 0;
|
|
return false;
|
|
}
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|