The FW3.4 dense-Arwic pair triggered the +/-20% stop rule (+33.5% CPU p50, 14x frame allocation). This slice removes the three measured costs without changing GPU command order (the referee suites assert identical recorded call sequences): - WalkFrameDriver: Collect (ONE walk per frame - no GPU work; leaf calls and flush points become a recorded event list; the driver absorbed the renderer collection pass and exposes the visited sets) + Replay (prepare the whole stream once, then replay events, interleaving DrawOrderedRange with leaf calls in the exact recorded order). RunFrame = Collect+Replay for existing callers. - WbDrawDispatcher: SubmitOrderedStream split into PrepareOrderedStream (all sections + commands + merge runs uploaded once per frame) and DrawOrderedRange (bind-once latch; per-run pipeline + DrawIdOffset + DrawIndirectRangeRhi). Load-bearing correctness catch from the implementation round: merge runs take FORCED BREAKS at the recorded event marks - whole-stream merging must not fuse two segments that retail separates with a leaf GPU call (shell, punch); the straddle assert stays as a dead-code safety net. - WalkProductionWorldData: WalkFrameStaticRecords carries an ArraySegment into a per-frame grow-only arena; the per-cell fresh-array copies (the 1.9 MB/frame alloc p50) are gone - zero steady-state allocation after warmup. Suites (lead-verified): full Release build 0 warnings; hermetic 6,758/0; Walk lane 209/1; InstalledDat Walk conformance 40/1 untouched. Next: the dense-Arwic re-measure against the same-session baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
858 lines
35 KiB
C#
858 lines
35 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 (shell-then-contents per cell, 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) : IWalkFrameLeafRenderer
|
|
{
|
|
public readonly List<WalkPolygon> Punches = new();
|
|
|
|
public void DrawSky() => log.Add("SKY");
|
|
|
|
public void DrawTerrainSlice(int sliceIndex) => log.Add($"TERRAIN:{sliceIndex}");
|
|
|
|
public void DrawCellShell(uint cellId) => log.Add($"SHELL:{cellId:x8}");
|
|
|
|
public void ClearInteriorDepth() => log.Add("CLEAR");
|
|
|
|
public void DrawExitSeals() => log.Add("SEALS");
|
|
|
|
public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex)
|
|
{
|
|
Punches.Add(worldPolygon);
|
|
log.Add($"PUNCH:{worldPolygon.Vertices.Length}@v{activeViewIndex}");
|
|
}
|
|
|
|
public void AlphaBarrier(float viewerDistance) => log.Add($"ALPHA:{viewerDistance:F2}");
|
|
}
|
|
|
|
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> OutdoorStaticsByCell = 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 GetOutdoorStatics(uint cellId) =>
|
|
OutdoorStaticsByCell.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; shell
|
|
// precedes contents per cell, and a flush happens exactly at the point
|
|
// the NEXT cell's shell needs the stream clear (never before, never
|
|
// batched across cells within this stage's turn-by-turn discipline). ──
|
|
|
|
// ── Deliverable (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 depth clear (pc:432731-432732), 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 (flush no-op, sky, terrain), THEN
|
|
// clear, seals, and the two flood cells shell-then-contents. ──────────
|
|
|
|
[Fact]
|
|
public void RunFrame_InteriorFloodWithExitView_DrawsLandscapeThenClearSealsThenFloodCells()
|
|
{
|
|
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);
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, trace);
|
|
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,
|
|
activeTerrainSliceCount: 1);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"SKY", "TERRAIN:0", "CLEAR", "SEALS",
|
|
"SHELL:00000100", "FLUSH:1:CellStatic", "SHELL:00000101", "FLUSH:1:CellStatic",
|
|
},
|
|
log);
|
|
|
|
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));
|
|
}
|
|
|
|
// ── Deliverable: the ov==0 interior case — no exit view survives, so
|
|
// DrawInside never runs the landscape turn at all; retail's clear+seals
|
|
// still run unconditionally for the interior root's own flood, straight
|
|
// after the (draw-nothing) DC event. ───────────────────────────────────
|
|
|
|
[Fact]
|
|
public void RunFrame_InteriorFloodWithNoExitView_SkipsLandscapeButStillClearsAndSeals()
|
|
{
|
|
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,
|
|
activeTerrainSliceCount: 0);
|
|
|
|
// No SKY/TERRAIN — ov==0 means DrawInside never calls DrawLandscape
|
|
// at all — but CLEAR/SEALS still fire unconditionally.
|
|
Assert.Equal(
|
|
new[] { "CLEAR", "SEALS", "SHELL:00000100", "FLUSH:1:CellStatic", "SHELL:00000101", "FLUSH:1:CellStatic" },
|
|
log);
|
|
|
|
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));
|
|
}
|
|
|
|
// ── 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;
|
|
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)));
|
|
|
|
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);
|
|
Matrix4x4 buildingWorld = Matrix4x4.CreateTranslation(10f, 0f, 0f);
|
|
worldData.WorldTransformByBuilding[building] = buildingWorld;
|
|
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var trace = new RecordingTrace(log);
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, trace);
|
|
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, activeTerrainSliceCount: 0);
|
|
walk.DrawBuilding(building, activeView, ctx, driver);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
new[] { "ALPHA:12.50", "PUNCH:4@v0", "SHELL:00000104", "FLUSH:1:LookInStatic", "FLUSH:1:BuildingShell" },
|
|
log);
|
|
|
|
// 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(2, mdiCalls.Count);
|
|
Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount));
|
|
}
|
|
|
|
// ── 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, activeTerrainSliceCount: 0);
|
|
|
|
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, activeTerrainSliceCount: 0);
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => driver.BeginFrame(
|
|
ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0));
|
|
|
|
driver.EndFrame();
|
|
// EndFrame cleared the open-frame guard: BeginFrame is usable again.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
|
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, activeTerrainSliceCount: 0);
|
|
|
|
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, activeTerrainSliceCount: 1);
|
|
|
|
// 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", "TERRAIN:0" }, 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);
|
|
|
|
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, activeTerrainSliceCount: 0);
|
|
((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);
|
|
|
|
Assert.Equal(new[] { "FLUSH:1:OutdoorStatic" }, log);
|
|
GpuRecordedMultiDrawIndirect mdi = Assert.Single(fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
|
|
Assert.Equal(1u, mdi.DrawCount);
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
public DrawScope(IGpuFrame frame, IGpuPassEncoder pass, IDisposable publication)
|
|
{
|
|
Frame = frame;
|
|
_pass = pass;
|
|
_publication = publication;
|
|
}
|
|
|
|
public IGpuFrame Frame { get; }
|
|
|
|
public IGpuPassEncoder Pass => _pass;
|
|
|
|
public void Dispose()
|
|
{
|
|
_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());
|
|
}
|
|
|
|
public RecordingGpuDevice Device { get; }
|
|
|
|
public GpuDeviceFrameLifetime FrameLifetime { get; }
|
|
|
|
public VulkanWorldPassScope Scope { get; }
|
|
|
|
public WbDrawDispatcher Dispatcher { get; }
|
|
|
|
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
|
|
|
|
public DrawScope BeginDraw()
|
|
{
|
|
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);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
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()
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|