The piece that turns walk-visited static content into draws, with no production frame wiring (FW3.2b roots the frame): - TryClassifyBatch: ONE shared per-batch classify core (the #426 untextured gate, #188 opacity promotion, texture resolve, foliage classification, in the exact original order) extracted from ClassifyBatches; the classic and packed classifiers now call it - behavior-identical, proven by the full hermetic + InstalledDat + Core Wb suites. - ClassifyEntityForWalk / WalkClassifiedBatch: the per-entity seam yielding per-batch keys + instance data WITHOUT InstanceGroup bucketing, plus the per-part selection data (picking stays alive on the walk path - the survey's unlisted-consumer fix). - WalkStaticStreamPopulator: per-entity walk-ordered opaque appends (under depth Less, opaque order is pixel-relevant only for coplanar surfaces, which retail resolves first-drawn-wins in ITS order - never material-grouped), translucent instances to the SAME RetailAlphaQueue via SubmitWalkAlphaInstance (identical viewer distances; walk-order submission improves retail's tie fidelity), selection parts published per entity. - SubmitOrderedStream now owns _orderedDrawCullModes, retiring the FW2-recorded alpha-scope interleaving constraint; DrawIndirectRangeRhi takes an optional cull array (all existing call sites unchanged). The referee test was verified to FAIL against the old shared-scratch behavior. - WalkDrawStage.OutdoorStatic added for the landscape turn. Suites: full Release build 0 warnings; Walk lane 195/1 skip; hermetic 6,747/0 (the two failures the implementation round reported were transient - both pass in isolation and in the full run). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
636 lines
26 KiB
C#
636 lines
26 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.Selection;
|
|
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.2a: the walk→draw population layer's data-
|
|
/// equivalence referee. Covers <see cref="WbDrawDispatcher.ClassifyEntityForWalk"/>
|
|
/// (the shared per-entity classify seam), <see cref="WalkStaticStreamPopulator"/>
|
|
/// (opaque → <see cref="OrderedDrawStream"/>, translucent → the alpha queue,
|
|
/// selection publish), and the FW3.2a own-cull-scratch fix to
|
|
/// <c>SubmitOrderedStream</c>.
|
|
/// </summary>
|
|
public sealed class WalkStaticStreamPopulatorTests
|
|
{
|
|
// ── Test doubles ────────────────────────────────────────────────────────
|
|
|
|
private sealed class RecordingSelectionSink : IRetailSelectionRenderSink
|
|
{
|
|
public readonly List<(uint ServerGuid, uint LocalEntityId, int PartIndex, uint GfxObjId, Matrix4x4 LocalToWorld)>
|
|
Calls = new();
|
|
|
|
public void AddVisiblePart(
|
|
uint serverGuid, uint localEntityId, int partIndex, uint gfxObjId, Matrix4x4 partWorld) =>
|
|
Calls.Add((serverGuid, localEntityId, partIndex, gfxObjId, partWorld));
|
|
}
|
|
|
|
// ── Synthetic RenderProjectionRecord construction ──────────────────────
|
|
|
|
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) };
|
|
|
|
// ── Reflection seam: ObjectMeshManager owns no test-injection API, and
|
|
// driving real GPU/GfxObj upload for a unit test is out of this stage's
|
|
// scope — ObjectRenderData/ObjectRenderBatch are plain settable classes,
|
|
// so this seeds the manager's private cache directly. ──────────────────
|
|
|
|
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;
|
|
}
|
|
|
|
// ── Deliverable 1: ClassifyEntityForWalk data equivalence ─────────────
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_OneOpaqueAndOneTranslucentPart_YieldsBatchesInRecordOrderWithCorrectIsOpaque()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong opaqueGfxObj = 0x0100_0001UL;
|
|
const ulong alphaGfxObj = 0x0100_0002UL;
|
|
InjectRenderData(fx.Manager, opaqueGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000001u, TranslucencyKind.Opaque, firstIndex: 0, baseVertex: 0, indexCount: 3, textureSlotIndex: 1)));
|
|
InjectRenderData(fx.Manager, alphaGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000002u, TranslucencyKind.AlphaBlend, firstIndex: 3, baseVertex: 4, indexCount: 6, textureSlotIndex: 2)));
|
|
|
|
var meshRefs = new[]
|
|
{
|
|
new MeshRef((uint)opaqueGfxObj, Matrix4x4.CreateTranslation(1, 0, 0)),
|
|
new MeshRef((uint)alphaGfxObj, Matrix4x4.CreateTranslation(0, 1, 0)),
|
|
};
|
|
RenderProjectionRecord record = MakeRecord(
|
|
localEntityId: 100, serverGuid: 0, position: new Vector3(5, 6, 7), meshRefs);
|
|
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
fx.Dispatcher.ClassifyEntityForWalk(in record, tupleLandblockId: 0x8C04u, batches, selectionParts);
|
|
|
|
Assert.Equal(2, batches.Count);
|
|
|
|
WbDrawDispatcher.WalkClassifiedBatch opaque = batches[0];
|
|
Assert.True(opaque.IsOpaque);
|
|
Assert.Equal(TranslucencyKind.Opaque, opaque.Key.Translucency);
|
|
Assert.Equal(0u, opaque.Key.FirstIndex);
|
|
Assert.Equal(3, opaque.Key.IndexCount);
|
|
Assert.Equal(1u, opaque.Key.TextureSlot.Index);
|
|
Assert.Equal(1f, opaque.Alpha);
|
|
Assert.Equal(meshRefs[0].PartTransform * record.Transform.LocalToWorld, opaque.Transform);
|
|
|
|
WbDrawDispatcher.WalkClassifiedBatch translucent = batches[1];
|
|
Assert.False(translucent.IsOpaque);
|
|
Assert.Equal(TranslucencyKind.AlphaBlend, translucent.Key.Translucency);
|
|
Assert.Equal(3u, translucent.Key.FirstIndex);
|
|
Assert.Equal(6, translucent.Key.IndexCount);
|
|
Assert.Equal(2u, translucent.Key.TextureSlot.Index);
|
|
Assert.Equal(meshRefs[1].PartTransform * record.Transform.LocalToWorld, translucent.Transform);
|
|
|
|
Assert.Equal(2, selectionParts.Count);
|
|
Assert.Equal(100u, selectionParts[0].LocalEntityId);
|
|
Assert.Equal(0, selectionParts[0].PartIndex);
|
|
Assert.Equal((uint)opaqueGfxObj, selectionParts[0].GfxObjId);
|
|
Assert.Equal(opaque.Transform, selectionParts[0].LocalToWorld);
|
|
Assert.Equal(1, selectionParts[1].PartIndex);
|
|
Assert.Equal((uint)alphaGfxObj, selectionParts[1].GfxObjId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_SetupComposite_EncodesPartAndSetupPartIndexLikePackedRoute()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong setupGfxObj = 0x1000_0010UL;
|
|
const ulong trunkGfxObj = 0x0100_0011UL;
|
|
const ulong leavesGfxObj = 0x0100_0012UL;
|
|
|
|
InjectRenderData(fx.Manager, trunkGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000011u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, leavesGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000012u, TranslucencyKind.ClipMap, 3, 4, 6, 2)));
|
|
InjectRenderData(fx.Manager, setupGfxObj, new ObjectRenderData
|
|
{
|
|
IsSetup = true,
|
|
SetupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>
|
|
{
|
|
(trunkGfxObj, Matrix4x4.CreateTranslation(0, 0, 1)),
|
|
(leavesGfxObj, Matrix4x4.CreateTranslation(0, 0, 2)),
|
|
},
|
|
});
|
|
|
|
var meshRefs = new[] { new MeshRef((uint)setupGfxObj, Matrix4x4.Identity) };
|
|
RenderProjectionRecord record = MakeRecord(200, 0, Vector3.Zero, meshRefs);
|
|
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
fx.Dispatcher.ClassifyEntityForWalk(in record, 0x8C04u, batches, selectionParts);
|
|
|
|
Assert.Equal(2, batches.Count);
|
|
Assert.Equal(2, selectionParts.Count);
|
|
// partIndex=0 (the entity's single top-level MeshRef) << 16 | setupPartIndex.
|
|
Assert.Equal(0, selectionParts[0].PartIndex);
|
|
Assert.Equal(1, selectionParts[1].PartIndex);
|
|
Assert.Equal((uint)trunkGfxObj, selectionParts[0].GfxObjId);
|
|
Assert.Equal((uint)leavesGfxObj, selectionParts[1].GfxObjId);
|
|
}
|
|
|
|
// ── Deliverable 2: WalkStaticStreamPopulator routing ───────────────────
|
|
|
|
[Fact]
|
|
public void PopulateCell_OpaqueBatchAppendsOrderedDrawCommandInRecordOrderWithStageAndCellProvenance()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0100_0003UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000003u, TranslucencyKind.Opaque, 10, 2, 12, 5)));
|
|
|
|
var record = MakeRecord(300, 0, new Vector3(1, 2, 3), new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
|
var stream = new OrderedDrawStream();
|
|
|
|
populator.PopulateCell(
|
|
stream, WalkDrawStage.CellStatic, cellId: 0x8C040100u,
|
|
new[] { record }, tupleLandblockId: 0x8C04u,
|
|
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
|
|
|
|
Assert.Equal(1, stream.Count);
|
|
Assert.Equal(WalkDrawStage.CellStatic, stream.Stages[0]);
|
|
Assert.Equal(0x8C040100u, stream.CellIds[0]);
|
|
Assert.Equal(10u, stream.Keys[0].FirstIndex);
|
|
Assert.Equal(12, stream.Keys[0].IndexCount);
|
|
Assert.Equal(1f, stream.Alphas[0]);
|
|
Assert.Equal(record.Transform.LocalToWorld, stream.Transforms[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void PopulateOutdoorStatics_UsesTheOutdoorStaticStage()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0100_0004UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000004u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var record = MakeRecord(400, 0, Vector3.Zero, new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
|
var stream = new OrderedDrawStream();
|
|
|
|
populator.PopulateOutdoorStatics(
|
|
stream, cellId: 0x8C040000u, new[] { record }, tupleLandblockId: 0x8C04u,
|
|
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
|
|
|
|
Assert.Equal(1, stream.Count);
|
|
Assert.Equal(WalkDrawStage.OutdoorStatic, stream.Stages[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void PopulateCell_TranslucentBatchDoesNotAppendToTheStreamAndReachesTheAlphaQueue()
|
|
{
|
|
using var fx = new DispatcherFixture(withAlphaQueue: true);
|
|
const ulong gfxObj = 0x0100_0005UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000005u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1)));
|
|
|
|
var record = MakeRecord(500, 0, new Vector3(0, 0, 10), new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
|
var stream = new OrderedDrawStream();
|
|
|
|
fx.AlphaQueue!.BeginFrame();
|
|
populator.PopulateCell(
|
|
stream, WalkDrawStage.CellStatic, 0x8C040100u, new[] { record }, 0x8C04u,
|
|
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
|
|
|
|
Assert.Equal(0, stream.Count);
|
|
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
|
fx.AlphaQueue.AbortFrame();
|
|
}
|
|
|
|
[Fact]
|
|
public void PopulateCell_PublishesSelectionPartsForEveryClassifiedEntity()
|
|
{
|
|
var sink = new RecordingSelectionSink();
|
|
using var fx = new DispatcherFixture(selectionSink: sink);
|
|
const ulong gfxObj = 0x0100_0006UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000006u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var record = MakeRecord(600, serverGuid: 0x8000_0060u, new Vector3(1, 1, 1),
|
|
new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
|
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
|
var stream = new OrderedDrawStream();
|
|
|
|
populator.PopulateCell(
|
|
stream, WalkDrawStage.CellStatic, 0x8C040100u, new[] { record }, 0x8C04u,
|
|
Vector3.Zero, Matrix4x4.Identity);
|
|
|
|
var call = Assert.Single(sink.Calls);
|
|
Assert.Equal(0x8000_0060u, call.ServerGuid);
|
|
Assert.Equal(600u, call.LocalEntityId);
|
|
Assert.Equal(0, call.PartIndex);
|
|
Assert.Equal((uint)gfxObj, call.GfxObjId);
|
|
Assert.Equal(record.Transform.LocalToWorld, call.LocalToWorld);
|
|
}
|
|
|
|
// ── SubmitWalkAlphaInstance: same viewer distance + per-instance data as
|
|
// DeferTransparentGroups, through the REAL RetailAlphaQueue. ───────────
|
|
|
|
[Fact]
|
|
public void SubmitWalkAlphaInstance_SubmitsTheSameViewerDistanceComputeViewerDistanceWouldProduce()
|
|
{
|
|
using var fx = new DispatcherFixture(withAlphaQueue: true);
|
|
fx.AlphaQueue!.BeginFrame();
|
|
|
|
var key = new GroupKey(10, 2, 6, new GpuTextureSlot(3), 1, TranslucencyKind.AlphaBlend, FoliageFlags: 0);
|
|
Vector3 localSortCenter = new(1, 2, 3);
|
|
Matrix4x4 model = Matrix4x4.CreateTranslation(4, 5, 6);
|
|
var cameraWorldPosition = Vector3.Zero;
|
|
var batch = new WbDrawDispatcher.WalkClassifiedBatch(
|
|
key, model, ClipSlot: 7, WbDrawDispatcher.InstanceLightSet.Disabled, IndoorFlag: 1,
|
|
Alpha: 0.5f, SelectionLighting: new Vector2(0.25f, 0.75f), DetailCategory: 1,
|
|
IsOpaque: false, LocalSortCenter: localSortCenter);
|
|
|
|
fx.Dispatcher.SubmitWalkAlphaInstance(in batch, cameraWorldPosition, Matrix4x4.Identity);
|
|
|
|
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
|
|
|
// Same call DeferTransparentGroups makes per instance — independently
|
|
// computed here so the assertion cannot pass by construction.
|
|
float expectedDistance = RetailAlphaOrdering.ComputeViewerDistance(
|
|
localSortCenter, model, cameraWorldPosition);
|
|
|
|
FieldInfo submissionsField = typeof(RetailAlphaQueue).GetField(
|
|
"_submissions", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
var submissions = (List<RetailAlphaSubmission>)submissionsField.GetValue(fx.AlphaQueue)!;
|
|
RetailAlphaSubmission submission = Assert.Single(submissions);
|
|
Assert.Equal(expectedDistance, submission.ViewerDistance, precision: 4);
|
|
Assert.Equal(0, submission.Token);
|
|
|
|
fx.AlphaQueue.AbortFrame();
|
|
}
|
|
|
|
[Fact]
|
|
public void SubmitWalkAlphaInstance_RejectsAMismatchedViewProjectionInTheSameScope()
|
|
{
|
|
using var fx = new DispatcherFixture(withAlphaQueue: true);
|
|
fx.AlphaQueue!.BeginFrame();
|
|
|
|
var key = new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.AlphaBlend, FoliageFlags: 0);
|
|
var batch = new WbDrawDispatcher.WalkClassifiedBatch(
|
|
key, Matrix4x4.Identity, 0, WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f,
|
|
Vector2.Zero, 0, IsOpaque: false, LocalSortCenter: new Vector3(0, 0, 10));
|
|
|
|
fx.Dispatcher.SubmitWalkAlphaInstance(in batch, Vector3.Zero, Matrix4x4.Identity);
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fx.Dispatcher.SubmitWalkAlphaInstance(
|
|
in batch, Vector3.Zero, Matrix4x4.CreateTranslation(1, 0, 0)));
|
|
|
|
fx.AlphaQueue.AbortFrame();
|
|
}
|
|
|
|
// ── Deliverable 3: SubmitOrderedStream's own cull scratch ──────────────
|
|
|
|
[Fact]
|
|
public void SubmitOrderedStream_DoesNotReadOrCorruptTheSharedAlphaCullScratch()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
using DrawScope draw = fx.BeginDraw();
|
|
|
|
// Poison the SHARED _drawCullModes scratch the alpha path owns — the
|
|
// exact array SubmitOrderedStream used to write into before FW3.2a.
|
|
// Under the OLD shared-scratch behavior this test's second assertion
|
|
// fails: SubmitOrderedStream's own command overwrites index 0 with
|
|
// its own cull mode (Clockwise), destroying the alpha path's poison.
|
|
FieldInfo field = typeof(WbDrawDispatcher).GetField(
|
|
"_drawCullModes", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
var poisonModes = (CullMode[])field.GetValue(fx.Dispatcher)!;
|
|
poisonModes[0] = CullMode.None;
|
|
|
|
var stream = new OrderedDrawStream();
|
|
stream.Append(new OrderedDrawCommand(
|
|
new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.Opaque, FoliageFlags: 0, CullMode: CullMode.Clockwise),
|
|
Matrix4x4.Identity, WalkDrawStage.Terrain, 0, 0,
|
|
WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f, Vector2.Zero, 0));
|
|
|
|
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
|
|
|
|
// (1) The ordered submission's OWN recorded cull call reflects the
|
|
// STREAM's cull mode (Clockwise -> GpuCullMode.Front), not the
|
|
// poisoned shared array's (None).
|
|
List<GpuCullMode> cullCalls = [.. fx.Device.Calls.OfType<GpuRecordedCullMode>().Select(c => c.CullMode)];
|
|
Assert.Equal([GpuCullMode.Front], cullCalls);
|
|
|
|
// (2) The shared _drawCullModes scratch is UNTOUCHED — the ordered
|
|
// path never wrote through it.
|
|
var afterModes = (CullMode[])field.GetValue(fx.Dispatcher)!;
|
|
Assert.Equal(CullMode.None, afterModes[0]);
|
|
}
|
|
|
|
// ── Fixture ─────────────────────────────────────────────────────────────
|
|
|
|
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(
|
|
bool withAlphaQueue = false,
|
|
IRetailSelectionRenderSink? selectionSink = null)
|
|
{
|
|
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."));
|
|
AlphaQueue = withAlphaQueue ? new RetailAlphaQueue() : null;
|
|
|
|
Dispatcher = new WbDrawDispatcher(
|
|
Device,
|
|
FrameLifetime,
|
|
Scope,
|
|
_textures,
|
|
_meshAdapter,
|
|
entitySpawnAdapter,
|
|
new EntityClassificationCache(),
|
|
new AcDream.Core.Rendering.TranslucencyFadeManager(),
|
|
selectionSink: selectionSink,
|
|
alphaQueue: AlphaQueue);
|
|
}
|
|
|
|
public RecordingGpuDevice Device { get; }
|
|
|
|
public GpuDeviceFrameLifetime FrameLifetime { get; }
|
|
|
|
public VulkanWorldPassScope Scope { get; }
|
|
|
|
public WbDrawDispatcher Dispatcher { get; }
|
|
|
|
public RetailAlphaQueue? AlphaQueue { get; }
|
|
|
|
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
|
|
|
|
public DrawScope BeginDraw()
|
|
{
|
|
FrameLifetime.BeginFrame();
|
|
IGpuFrame frame = FrameLifetime.CurrentFrame!;
|
|
IGpuPassEncoder pass = frame.BeginPass(
|
|
GpuPassDescription.BackbufferClear(
|
|
"fw3-2a-walk-populator-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()
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|