feat(render) Campaign FW3.2b-1: the walk frame driver
WalkFrameDriver executes one full static-content frame from the walk's turns so GPU command-buffer order equals retail's walk order. One rule does the interleaving: the accumulated OrderedDrawStream flushes through SubmitOrderedStream immediately before EVERY non-stream draw (sky, terrain slice, cell shell, punch fan, alpha barrier). Turn script, all decomp-cited and two of them corrected in review: - Interior flood: per cell IN FLOOD ORDER, shell first then contents (PView::DrawCells @0x005a4840: DrawEnvCell @0x005a4abe precedes DrawObjCellForDummies @0x005a4b0d). - Landscape: sky once, terrain per active slice, then blocks far-to-near; per cell the building turn precedes the cell's outdoor statics (DrawSortCell @0x0059f140). - Building (DrawBuilding @0x0059f2a0): the BLD probe event stays at entry, but the ENTIRE body - alpha barrier, portal passes, shell - sits inside retail's gfxobj[deg_level]!=0 gate @0x0059f2d3, and the order is FlushAlphaList @0x0059f30b -> the two-pass punch/look-in walk -> THEN the shell draw @0x0059f345. The driver review caught both the missing gate and a shell-before-punch inversion; fixed with the addresses cited. Walk seam: three additive default-implemented IWalkEventSink hooks (OnLandscapeCellTurn / OnBuildingTurn / OnBuildingShellTurn / OnPunchGeometry) - every existing sink and all FW1 conformance fixtures unchanged. WalkLandBlock gains LandblockId for the cell-id encoding. Leaf draws go through IWalkFrameLeafRenderer so FW3.2b-2 wires the real renderers and the referee suite runs on fakes + RecordingGpuDevice. Flagged for FW3.2b-2/FW4 adjudication (documented in code): FlushFartherThan(building distance) vs retail flush-all FlushAlphaList(0f); terrain-before-statics within the landscape turn. Suites: full Release build 0 warnings; Walk lane 200/1 skip; InstalledDat Walk conformance 40/1 untouched; hermetic 6,752/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
81c6531727
commit
03f63686cc
6 changed files with 1303 additions and 7 deletions
686
tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
Normal file
686
tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
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 DrawPunchFan(WalkPolygon worldPolygon)
|
||||
{
|
||||
Punches.Add(worldPolygon);
|
||||
log.Add($"PUNCH:{worldPolygon.Vertices.Length}");
|
||||
}
|
||||
|
||||
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). ──
|
||||
|
||||
[Fact]
|
||||
public void RunFrame_InteriorTwoCellFlood_EmitsShellThenContentsPerCellWithAFlushBetween()
|
||||
{
|
||||
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,
|
||||
}],
|
||||
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(
|
||||
[MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)])], 0x8C04u);
|
||||
worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
|
||||
[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);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "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(
|
||||
[MakeRecord(201, 0, Vector3.Zero, [new MeshRef((uint)shellGfxObj, Matrix4x4.Identity)])], 0x8C04u);
|
||||
worldData.CellStaticsByCell[0x104] = new WalkFrameStaticRecords(
|
||||
[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, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
walk.DrawBuilding(building, activeView, ctx, driver);
|
||||
driver.EndFrame();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "ALPHA:12.50", "PUNCH:4", "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, draw.Frame, draw.Pass, 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, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => driver.BeginFrame(
|
||||
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0));
|
||||
|
||||
driver.EndFrame();
|
||||
// EndFrame cleared the open-frame guard: BeginFrame is usable again.
|
||||
driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
driver.EndFrame();
|
||||
}
|
||||
|
||||
// ── 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(
|
||||
[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, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
|
||||
((IWalkEventSink)driver).OnLandscapeCellTurn(0x8C040005u);
|
||||
Assert.Empty(log); // accumulates in the stream; nothing flushed yet
|
||||
driver.EndFrame();
|
||||
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue