Reconstruct one combined static/dynamic object-part stream for each ordinary outdoor or interior cell, compute authored SortCenter CYpt keys, and stable-sort far to near before projecting opaque and delayed subsets. Prepare real cell-particle records at the leaf, preserve every S4-c2 router outcome, and merge object and particle delayed records by retained key before either source appends to the unchanged CLIP/ALPHA FIFO lists. Cell turns remain cell-major; equal cross-source ties are deterministically object-first. File AP-241 and AP-242 for the remaining separate opaque/row-5 channels and unrepresented equal-key common ordinal. File AP-243 for the paired-binary correction: retail shares the cell CYpt/heading beyond 50 m while this bounded port always uses the more exact authored per-part center. Pin 162 active AP rows and correct world-alpha and AlphaFlushCounts prose. Lead-approved scope clarification: RetailPViewPassExecutor.WalkLeaf.cs and RetailPViewPassExecutor.cs are the minimum existing production leaf adapter and thin particle-prepare forwarder omitted by the literal Walk/Wb file list. They contain no router, queue, mask, state, depth, or flush behavior; relocating them would create an artificial seam. Gates: Release solution build 0W/0E; shader/manifest 32/32; focused production 210/210; real allocation 3/3 at 0 B; one-shot hermetic 16743/0/0 across 14 assemblies; InstalledDat 385 pass/10 documented fail/1 skip with all six AlphaFlushSites passing; git diff --check PASS. Initial no-restore solution build failed NETSDK1004 for 42 missing scratch assets; one solution restore preceded the official build. Mutation proof, each restored before final gates: 1. Reverse comparator: authored-center order expected [202,101], actual [101,202]. 2. Move ties left: multipart/subset order expected [11,12,21,22], actual [22,21,12,11]. 3. Restore static/dynamic blocks: expected [2,3,1], actual [3,1,2]. 4. Use entity origin: authored-center order expected [202,101], actual [101,202]. 5. Restore particle tail: expected [Wb,Particle,Wb,Particle], actual [Wb,Wb,Particle,Particle]. 6. Scope-global sort: first cell model X expected 5, actual 50. 7. Restore dead camera parameter: SubmitWalkAlphaInstance parameter count expected 2, actual 3. 8. Restore stale global-queue prose: exact Assert.DoesNotContain failure on distance-sorts one shared queue. 9. Remove AP-241 identity: Assert.Single found no matching row. 10. Allocate in real merge: expected 0 B, actual 3072 B. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1272 lines
52 KiB
C#
1272 lines
52 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 the ordered
|
|
/// submitter (<c>PrepareOrderedStream</c>/<c>DrawOrderedRange</c> as of
|
|
/// Campaign FW stage FW3.4a).
|
|
/// </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));
|
|
}
|
|
|
|
private sealed class FixedWalkViews(params uint[] slots) : IWalkLookInViewSource
|
|
{
|
|
public IReadOnlyList<uint> LookInCellTurns { get; } = [0x8C040112u];
|
|
|
|
public bool SphereVisibleInLookInTurn(
|
|
int routeIndex,
|
|
in Vector3 center,
|
|
float radius,
|
|
bool testSphere = true) => slots.Length != 0;
|
|
}
|
|
|
|
// ── Synthetic RenderProjectionRecord construction ──────────────────────
|
|
|
|
private static RenderProjectionRecord MakeRecord(
|
|
uint localEntityId,
|
|
uint serverGuid,
|
|
Vector3 position,
|
|
IReadOnlyList<MeshRef> meshRefs,
|
|
bool isBuildingShell = false,
|
|
uint parentCellId = 0u,
|
|
RenderCasterIdentityKind casterIdentity =
|
|
RenderCasterIdentityKind.Unclassified) =>
|
|
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,
|
|
CasterIdentity: casterIdentity));
|
|
|
|
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 ObjectRenderData MakeSortedMesh(
|
|
Vector3 sortCenter,
|
|
params ObjectRenderBatch[] batches) =>
|
|
new() { SortCenter = sortCenter, 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;
|
|
}
|
|
|
|
[Fact]
|
|
public void PopulateCellObjects_UsesAuthoredSortCenterAndStableFarToNearOrder()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong nearOrigin = 0x0100_0C01UL;
|
|
const ulong farAuthoredCenter = 0x0100_0C02UL;
|
|
InjectRenderData(fx.Manager, nearOrigin, MakeSortedMesh(
|
|
Vector3.Zero,
|
|
MakeBatch(101, TranslucencyKind.AlphaBlend, 101, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, farAuthoredCenter, MakeSortedMesh(
|
|
new Vector3(10, 0, 0),
|
|
MakeBatch(202, TranslucencyKind.AlphaBlend, 202, 0, 3, 2)));
|
|
|
|
RenderProjectionRecord[] records =
|
|
[
|
|
MakeRecord(1, 0, new Vector3(5, 0, 0), [new MeshRef((uint)nearOrigin, Matrix4x4.Identity)]),
|
|
MakeRecord(2, 0, Vector3.Zero, [new MeshRef((uint)farAuthoredCenter, Matrix4x4.Identity)]),
|
|
];
|
|
var alpha = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var stream = new OrderedDrawStream();
|
|
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
|
|
|
|
populator.PopulateCellObjects(
|
|
stream,
|
|
WalkDrawStage.CellStatic,
|
|
0x8C040112u,
|
|
records,
|
|
0x8C04u,
|
|
Vector3.Zero,
|
|
Matrix4x4.Identity,
|
|
views: null,
|
|
viewRouteIndex: -1,
|
|
alpha);
|
|
|
|
Assert.Empty(stream.Keys);
|
|
Assert.Equal([202u, 101u], alpha.Select(static batch => batch.Key.FirstIndex));
|
|
Assert.Equal([100f, 25f], alpha.Select(static batch => batch.SortDistanceSq));
|
|
}
|
|
|
|
[Fact]
|
|
public void PopulateCellObjects_EqualCyptRetainsEntityPartAndSubsetOrder()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong setup = 0x1000_0C10UL;
|
|
const ulong firstPart = 0x0100_0C11UL;
|
|
const ulong secondPart = 0x0100_0C12UL;
|
|
InjectRenderData(fx.Manager, firstPart, MakeSortedMesh(
|
|
Vector3.Zero,
|
|
MakeBatch(11, TranslucencyKind.AlphaBlend, 11, 0, 3, 1),
|
|
MakeBatch(12, TranslucencyKind.AlphaBlend, 12, 0, 3, 2)));
|
|
InjectRenderData(fx.Manager, secondPart, MakeSortedMesh(
|
|
Vector3.Zero,
|
|
MakeBatch(21, TranslucencyKind.AlphaBlend, 21, 0, 3, 3),
|
|
MakeBatch(22, TranslucencyKind.AlphaBlend, 22, 0, 3, 4)));
|
|
InjectRenderData(fx.Manager, setup, new ObjectRenderData
|
|
{
|
|
IsSetup = true,
|
|
SetupParts =
|
|
[
|
|
(firstPart, Matrix4x4.CreateTranslation(10, 0, 0)),
|
|
(secondPart, Matrix4x4.CreateTranslation(0, 10, 0)),
|
|
],
|
|
});
|
|
RenderProjectionRecord record = MakeRecord(
|
|
10, 0, Vector3.Zero, [new MeshRef((uint)setup, Matrix4x4.Identity)]);
|
|
var alpha = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
|
|
new WalkStaticStreamPopulator(fx.Dispatcher).PopulateCellObjects(
|
|
new OrderedDrawStream(),
|
|
WalkDrawStage.CellStatic,
|
|
0x8C040112u,
|
|
[record],
|
|
0x8C04u,
|
|
Vector3.Zero,
|
|
Matrix4x4.Identity,
|
|
views: null,
|
|
viewRouteIndex: -1,
|
|
alpha);
|
|
|
|
Assert.Equal([11u, 12u, 21u, 22u], alpha.Select(static batch => batch.Key.FirstIndex));
|
|
Assert.All(alpha, static batch => Assert.Equal(100f, batch.SortDistanceSq));
|
|
}
|
|
|
|
[Fact]
|
|
public void PopulateCellObjects_InterleavesStaticAndDynamicOpaquePartsByCypt()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong nearStatic = 0x0100_0C21UL;
|
|
const ulong farDynamic = 0x0100_0C22UL;
|
|
const ulong middleStatic = 0x0100_0C23UL;
|
|
InjectRenderData(fx.Manager, nearStatic, MakeFlatMesh(
|
|
MakeBatch(1, TranslucencyKind.Opaque, 1, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, farDynamic, MakeFlatMesh(
|
|
MakeBatch(2, TranslucencyKind.Opaque, 2, 0, 3, 2)));
|
|
InjectRenderData(fx.Manager, middleStatic, MakeFlatMesh(
|
|
MakeBatch(3, TranslucencyKind.Opaque, 3, 0, 3, 3)));
|
|
RenderProjectionRecord dynamic = MakeRecord(
|
|
2, 0, new Vector3(30, 0, 0), [new MeshRef((uint)farDynamic, Matrix4x4.Identity)])
|
|
with { ProjectionClass = RenderProjectionClass.LiveDynamicRoot };
|
|
RenderProjectionRecord[] records =
|
|
[
|
|
MakeRecord(1, 0, new Vector3(10, 0, 0), [new MeshRef((uint)nearStatic, Matrix4x4.Identity)]),
|
|
dynamic,
|
|
MakeRecord(3, 0, new Vector3(20, 0, 0), [new MeshRef((uint)middleStatic, Matrix4x4.Identity)]),
|
|
];
|
|
var stream = new OrderedDrawStream();
|
|
|
|
new WalkStaticStreamPopulator(fx.Dispatcher).PopulateCellObjects(
|
|
stream,
|
|
WalkDrawStage.CellStatic,
|
|
0x8C040112u,
|
|
records,
|
|
0x8C04u,
|
|
Vector3.Zero,
|
|
Matrix4x4.Identity,
|
|
views: null,
|
|
viewRouteIndex: -1,
|
|
[]);
|
|
|
|
Assert.Equal([2u, 3u, 1u], stream.Keys.Select(static key => key.FirstIndex));
|
|
Assert.Equal(
|
|
[WalkDrawStage.Dynamic, WalkDrawStage.CellStatic, WalkDrawStage.CellStatic],
|
|
stream.Stages);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldAlphaCyptContract_RetainsTheKeyWithoutDeadCameraSubmitThreading()
|
|
{
|
|
Type batchType = typeof(WbDrawDispatcher).GetNestedType(
|
|
"WalkClassifiedBatch", BindingFlags.NonPublic)
|
|
?? throw new InvalidOperationException("WalkClassifiedBatch was not found.");
|
|
PropertyInfo sortDistance = batchType.GetProperty("SortDistanceSq")
|
|
?? throw new InvalidOperationException("SortDistanceSq was not retained.");
|
|
Assert.Equal(typeof(float), sortDistance.PropertyType);
|
|
|
|
MethodInfo submit = typeof(WbDrawDispatcher).GetMethod(
|
|
"SubmitWalkAlphaInstance", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException("SubmitWalkAlphaInstance was not found.");
|
|
ParameterInfo[] parameters = submit.GetParameters();
|
|
Assert.Equal(2, parameters.Length);
|
|
Assert.Equal("batch", parameters[0].Name);
|
|
Assert.Equal(typeof(Matrix4x4), parameters[1].ParameterType);
|
|
Assert.DoesNotContain(parameters, static parameter => parameter.ParameterType == typeof(Vector3));
|
|
|
|
string root = FindRepoRoot();
|
|
string populator = File.ReadAllText(Path.Combine(
|
|
root, "src", "AcDream.App", "Rendering", "Walk", "WalkStaticStreamPopulator.cs"));
|
|
Assert.Contains("batch.LocalSortCenter, batch.Transform", populator);
|
|
Assert.Contains("SortDistanceSq = Vector3.DistanceSquared", populator);
|
|
Assert.Contains("value.Batch.SortDistanceSq >", populator);
|
|
Assert.Contains("AP-243", populator);
|
|
Assert.Contains("greater-than-50 m", populator);
|
|
Assert.Contains("0x005A06B7..0x005A0720", populator);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldAlphaCyptDocumentationAndRegister_PinPerCellTruthAndThreeResiduals()
|
|
{
|
|
string root = FindRepoRoot();
|
|
string inventory = File.ReadAllText(Path.Combine(
|
|
root, "docs", "architecture", "worldbuilder-inventory.md"));
|
|
string architecture = File.ReadAllText(Path.Combine(
|
|
root, "docs", "architecture", "acdream-architecture.md"));
|
|
string registerPath = Path.Combine(
|
|
root, "docs", "architecture", "retail-divergence-register.md");
|
|
string register = File.ReadAllText(registerPath);
|
|
|
|
Assert.Contains("per-cell", inventory, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("two FIFO", inventory, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(
|
|
"distance-sorts one shared queue", inventory, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("per-cell", architecture, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("two FIFO", architecture, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(
|
|
"one stable far-to-near stream keyed", architecture, StringComparison.OrdinalIgnoreCase);
|
|
|
|
Assert.Contains("162 active rows", register, StringComparison.Ordinal);
|
|
int apSectionStart = register.IndexOf(
|
|
"## 3. Documented approximation (AP)", StringComparison.Ordinal);
|
|
int apSectionEnd = register.IndexOf(
|
|
"## 4. Temporary stopgap (TS)", StringComparison.Ordinal);
|
|
Assert.True(apSectionStart >= 0, "AP section heading must exist.");
|
|
Assert.True(apSectionEnd > apSectionStart, "TS heading must follow the AP section.");
|
|
foreach (string id in new[] { "AP-241", "AP-242", "AP-243" })
|
|
{
|
|
int rowIndex = register.IndexOf($"| {id} |", StringComparison.Ordinal);
|
|
Assert.True(
|
|
rowIndex > apSectionStart && rowIndex < apSectionEnd,
|
|
$"{id} row must be inside the AP table before the TS section; "
|
|
+ $"rowIndex={rowIndex}, AP=[{apSectionStart},{apSectionEnd}).");
|
|
}
|
|
Assert.Single(File.ReadLines(registerPath), static line =>
|
|
line.StartsWith("| AP-241 |", StringComparison.Ordinal));
|
|
Assert.Single(File.ReadLines(registerPath), static line =>
|
|
line.StartsWith("| AP-242 |", StringComparison.Ordinal));
|
|
Assert.Single(File.ReadLines(registerPath), static line =>
|
|
line.StartsWith("| AP-243 |", StringComparison.Ordinal));
|
|
Assert.Equal(
|
|
162,
|
|
File.ReadLines(registerPath).Count(static line =>
|
|
line.StartsWith("| AP-", StringComparison.Ordinal)));
|
|
Assert.Contains("greater than 50 m", register, StringComparison.Ordinal);
|
|
Assert.Contains("0x005A06B7..0x005A0720", register, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
string? directory = AppContext.BaseDirectory;
|
|
while (directory is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(directory, "AcDream.slnx")))
|
|
return directory;
|
|
directory = Directory.GetParent(directory)?.FullName;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("Could not locate AcDream.slnx.");
|
|
}
|
|
|
|
// ── 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_NoDrawProjectileRecord_SubmitsNeitherMeshNorSelection()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong projectileGfxObj = 0x0100_0003UL;
|
|
InjectRenderData(fx.Manager, projectileGfxObj, MakeFlatMesh(
|
|
MakeBatch(
|
|
0x08000003u,
|
|
TranslucencyKind.Opaque,
|
|
firstIndex: 0,
|
|
baseVertex: 0,
|
|
indexCount: 3,
|
|
textureSlotIndex: 1)));
|
|
|
|
RenderProjectionRecord projectile = MakeRecord(
|
|
localEntityId: 101,
|
|
serverGuid: 0x7000_0101u,
|
|
position: Vector3.Zero,
|
|
meshRefs: [new MeshRef((uint)projectileGfxObj, Matrix4x4.Identity)])
|
|
with
|
|
{
|
|
// ACE's projectile-impact SetState sets NoDraw immediately;
|
|
// the later DeleteObject intentionally arrives five seconds
|
|
// afterward. The journal projects that state as resident and
|
|
// hidden, with Draw cleared.
|
|
Flags = RenderProjectionFlags.SpatiallyResident
|
|
| RenderProjectionFlags.Selectable
|
|
| RenderProjectionFlags.Hidden,
|
|
};
|
|
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts =
|
|
new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in projectile,
|
|
tupleLandblockId: 0x8C04u,
|
|
batches,
|
|
selectionParts,
|
|
liveDynamic: true);
|
|
|
|
Assert.Empty(batches);
|
|
Assert.Empty(selectionParts);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_FrameScopeStampsEachAdmittedSetupPartOncePerRetailPass()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong setupGfxObj = 0x1000_0020UL;
|
|
const ulong headGfxObj = 0x0100_0021UL;
|
|
const ulong torsoGfxObj = 0x0100_0022UL;
|
|
|
|
InjectRenderData(fx.Manager, headGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000021u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, torsoGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000022u, TranslucencyKind.Opaque, 3, 4, 6, 2)));
|
|
InjectRenderData(fx.Manager, setupGfxObj, new ObjectRenderData
|
|
{
|
|
IsSetup = true,
|
|
SetupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>
|
|
{
|
|
(headGfxObj, Matrix4x4.CreateTranslation(0, 0, 2)),
|
|
(torsoGfxObj, Matrix4x4.CreateTranslation(0, 0, 1)),
|
|
},
|
|
});
|
|
|
|
RenderProjectionRecord record = MakeRecord(
|
|
220,
|
|
0,
|
|
Vector3.Zero,
|
|
[new MeshRef((uint)setupGfxObj, Matrix4x4.Identity)]);
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
|
|
fx.Dispatcher.BeginWalkPartFrame();
|
|
try
|
|
{
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record, 0x8C04u, batches, selectionParts);
|
|
Assert.Equal(2, batches.Count);
|
|
Assert.Equal(2, selectionParts.Count);
|
|
|
|
batches.Clear();
|
|
selectionParts.Clear();
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record, 0x8C04u, batches, selectionParts);
|
|
Assert.Empty(batches);
|
|
Assert.Empty(selectionParts);
|
|
|
|
// PView::DrawCells @0x005A4886 increments m_nFrameStamp after
|
|
// the landscape pass and before its interior-cell repaint. The
|
|
// same parts may therefore submit once again in the second pass.
|
|
fx.Dispatcher.AdvanceWalkPartPassStamp();
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record, 0x8C04u, batches, selectionParts);
|
|
Assert.Equal(2, batches.Count);
|
|
Assert.Equal(2, selectionParts.Count);
|
|
}
|
|
finally
|
|
{
|
|
fx.Dispatcher.EndWalkPartFrame();
|
|
}
|
|
|
|
// Direct classifier calls outside a production walk frame remain
|
|
// independent, as the conformance/referee tests require.
|
|
batches.Clear();
|
|
selectionParts.Clear();
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record, 0x8C04u, batches, selectionParts);
|
|
Assert.Equal(2, batches.Count);
|
|
Assert.Equal(2, selectionParts.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_FrameScopeDoesNotStampPortalRejectedPart()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0100_0023UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000023u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
RenderProjectionRecord record = MakeRecord(
|
|
221,
|
|
0,
|
|
Vector3.Zero,
|
|
[new MeshRef((uint)gfxObj, Matrix4x4.Identity)]);
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
|
|
fx.Dispatcher.BeginWalkPartFrame();
|
|
try
|
|
{
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record,
|
|
0x8C04u,
|
|
batches,
|
|
selectionParts,
|
|
liveDynamic: true,
|
|
lookInViews: new FixedWalkViews(),
|
|
lookInRouteIndex: 0);
|
|
Assert.Empty(batches);
|
|
Assert.Empty(selectionParts);
|
|
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record,
|
|
0x8C04u,
|
|
batches,
|
|
selectionParts,
|
|
liveDynamic: true,
|
|
lookInViews: new FixedWalkViews(7u),
|
|
lookInRouteIndex: 1);
|
|
Assert.Single(batches);
|
|
Assert.Single(selectionParts);
|
|
}
|
|
finally
|
|
{
|
|
fx.Dispatcher.EndWalkPartFrame();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_LocalPlayerBypassesDrawnPartStampLikeRetail()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0100_0024UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000024u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
RenderProjectionRecord player = MakeRecord(
|
|
222,
|
|
0x5000_0001u,
|
|
Vector3.Zero,
|
|
[new MeshRef((uint)gfxObj, Matrix4x4.Identity)],
|
|
casterIdentity: RenderCasterIdentityKind.LocalPlayer);
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
|
|
fx.Dispatcher.BeginWalkPartFrame();
|
|
try
|
|
{
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in player, 0xF418u, batches, selectionParts,
|
|
liveDynamic: true);
|
|
Assert.Single(batches);
|
|
Assert.Single(selectionParts);
|
|
|
|
batches.Clear();
|
|
selectionParts.Clear();
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in player, 0xF418u, batches, selectionParts,
|
|
liveDynamic: true);
|
|
Assert.Single(batches);
|
|
Assert.Single(selectionParts);
|
|
}
|
|
finally
|
|
{
|
|
fx.Dispatcher.EndWalkPartFrame();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyEntityForWalk_PortalViewsAdmitOneCompleteUnclippedMesh()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0100_0013UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08000013u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
RenderProjectionRecord record = MakeRecord(
|
|
201, 0, Vector3.Zero,
|
|
[new MeshRef((uint)gfxObj, Matrix4x4.Identity)],
|
|
parentCellId: 0x8C040112u);
|
|
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
|
|
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
|
|
var views = new FixedWalkViews(7u, 9u);
|
|
|
|
fx.Dispatcher.ClassifyEntityForWalk(
|
|
in record,
|
|
0x8C04u,
|
|
batches,
|
|
selectionParts,
|
|
liveDynamic: true,
|
|
views,
|
|
lookInRouteIndex: 0,
|
|
lookInCellId: 0x8C040112u);
|
|
|
|
WbDrawDispatcher.WalkClassifiedBatch batch = Assert.Single(batches);
|
|
Assert.Equal(0u, batch.ClipSlot);
|
|
Assert.Single(selectionParts);
|
|
}
|
|
|
|
// ── 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 per-instance data and router decision
|
|
// as DeferTransparentGroups, through the REAL RetailAlphaQueue — S4-c2
|
|
// fix round 1 (A5): this banner's stale "same viewer distance" text is
|
|
// corrected; the FIFO cutover deleted viewer distance from the alpha
|
|
// path entirely. ────────────────────────────────────────────────────
|
|
|
|
/// <summary>S4-c2: retail's queues are FIFO, not distance-sorted — this
|
|
/// pins the routing decision instead (an AlphaBlend batch's constructed
|
|
/// mask is 0x02, which has no ClipMap bit, so
|
|
/// <see cref="RetailAlphaMeshRouter.Route"/> appends it to ALPHA, never
|
|
/// CLIP), plus the exact token. Mutation check: routing AlphaBlend to
|
|
/// CLIP instead makes <c>ClipCount</c> assert fail (1 instead of the
|
|
/// expected 0) and <c>AlphaCount</c> fail (0 instead of 1).</summary>
|
|
[Fact]
|
|
public void SubmitWalkAlphaInstance_RoutesAlphaBlendBatchIntoTheAlphaList()
|
|
{
|
|
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 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, Matrix4x4.Identity);
|
|
|
|
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
|
Assert.Equal(1, fx.AlphaQueue.AlphaCount);
|
|
Assert.Equal(0, fx.AlphaQueue.ClipCount);
|
|
|
|
FieldInfo alphaListField = typeof(RetailAlphaQueue).GetField(
|
|
"_alpha", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
var alphaList = (List<RetailAlphaEntry>)alphaListField.GetValue(fx.AlphaQueue)!;
|
|
RetailAlphaEntry entry = Assert.Single(alphaList);
|
|
Assert.Equal(0, entry.Token);
|
|
|
|
fx.AlphaQueue.AbortFrame();
|
|
}
|
|
|
|
/// <summary>
|
|
/// S4-c2 final M3 production pin for <c>SubmitWalkAlphaInstance</c>:
|
|
/// building shell + detail ON executes the immediate base+detail draws at
|
|
/// the submit mark and queues nothing; detail OFF queues, then drains the
|
|
/// base draw without a detail pass; an ordinary batch remains deferred
|
|
/// even while the global detail option is on. Mutation check: hardcoding
|
|
/// <c>detailSurfaceActive=false</c> at the production submit makes the
|
|
/// first case's pending-count assertion fail with 1.</summary>
|
|
[Fact]
|
|
public void SubmitWalkAlphaInstance_DetailOnOffAndOrdinaryUseProductionOutcomes()
|
|
{
|
|
using (var on = new DispatcherFixture(
|
|
withAlphaQueue: true,
|
|
detailAvailable: true,
|
|
detailEnabled: true))
|
|
using (DrawScope draw = on.BeginDraw(beginAlpha: true))
|
|
{
|
|
WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch(detailCategory: 1u);
|
|
on.Dispatcher.SubmitWalkAlphaInstance(
|
|
in building,
|
|
Matrix4x4.Identity);
|
|
|
|
Assert.Equal(0, on.AlphaQueue!.PendingCount);
|
|
Assert.Contains(
|
|
on.Device.Calls.OfType<GpuRecordedPipelineBind>(),
|
|
call => call.PipelineName.Contains("alpha", StringComparison.Ordinal)
|
|
&& !call.PipelineName.Contains("detail", StringComparison.Ordinal));
|
|
Assert.Contains(
|
|
on.Device.Calls.OfType<GpuRecordedPipelineBind>(),
|
|
call => call.PipelineName.Contains("retail-detail-alpha", StringComparison.Ordinal));
|
|
}
|
|
|
|
using (var off = new DispatcherFixture(
|
|
withAlphaQueue: true,
|
|
detailAvailable: true,
|
|
detailEnabled: false))
|
|
using (DrawScope draw = off.BeginDraw(beginAlpha: true))
|
|
{
|
|
WbDrawDispatcher.WalkClassifiedBatch building = AlphaWalkBatch(detailCategory: 1u);
|
|
off.Dispatcher.SubmitWalkAlphaInstance(
|
|
in building,
|
|
Matrix4x4.Identity);
|
|
|
|
Assert.Equal(1, off.AlphaQueue!.PendingCount);
|
|
Assert.Empty(off.Device.Calls.OfType<GpuRecordedPipelineBind>());
|
|
off.AlphaQueue.EndFrame();
|
|
Assert.DoesNotContain(
|
|
off.Device.Calls.OfType<GpuRecordedPipelineBind>(),
|
|
call => call.PipelineName.Contains("retail-detail", StringComparison.Ordinal));
|
|
}
|
|
|
|
using (var ordinary = new DispatcherFixture(
|
|
withAlphaQueue: true,
|
|
detailAvailable: true,
|
|
detailEnabled: true))
|
|
using (DrawScope draw = ordinary.BeginDraw(beginAlpha: true))
|
|
{
|
|
WbDrawDispatcher.WalkClassifiedBatch batch = AlphaWalkBatch(detailCategory: 0u);
|
|
ordinary.Dispatcher.SubmitWalkAlphaInstance(
|
|
in batch,
|
|
Matrix4x4.Identity);
|
|
|
|
Assert.Equal(1, ordinary.AlphaQueue!.PendingCount);
|
|
Assert.Empty(ordinary.Device.Calls.OfType<GpuRecordedPipelineBind>());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The sibling grouped-production site executes the same detail-on row 1,
|
|
/// not merely the walk submit site. This invokes the real private
|
|
/// <c>DeferTransparentGroups</c> after seeding its real retained group
|
|
/// scratch. The same hardcoded-false mutation leaves one queued token and
|
|
/// makes the pending-count assertion fail.</summary>
|
|
[Fact]
|
|
public void DeferTransparentGroups_BuildingDetailOnDrawsImmediateWithDetail()
|
|
{
|
|
using var fx = new DispatcherFixture(
|
|
withAlphaQueue: true,
|
|
detailAvailable: true,
|
|
detailEnabled: true);
|
|
using DrawScope draw = fx.BeginDraw(beginAlpha: true);
|
|
WbDrawDispatcher.InstanceGroup group = AlphaInstanceGroup(detailCategory: 1u);
|
|
FieldInfo field = typeof(WbDrawDispatcher).GetField(
|
|
"_translucentDraws",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
var groups = (List<WbDrawDispatcher.InstanceGroup>)field.GetValue(fx.Dispatcher)!;
|
|
groups.Add(group);
|
|
MethodInfo defer = typeof(WbDrawDispatcher).GetMethod(
|
|
"DeferTransparentGroups",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
|
|
defer.Invoke(fx.Dispatcher, [Vector3.Zero, Matrix4x4.Identity]);
|
|
|
|
Assert.Equal(0, fx.AlphaQueue!.PendingCount);
|
|
Assert.Contains(
|
|
fx.Device.Calls.OfType<GpuRecordedPipelineBind>(),
|
|
call => call.PipelineName.Contains("retail-detail-alpha", StringComparison.Ordinal));
|
|
}
|
|
|
|
private static WbDrawDispatcher.WalkClassifiedBatch AlphaWalkBatch(uint detailCategory) =>
|
|
new(
|
|
new GroupKey(
|
|
0,
|
|
0,
|
|
3,
|
|
new GpuTextureSlot(1),
|
|
0,
|
|
TranslucencyKind.AlphaBlend,
|
|
FoliageFlags: 0),
|
|
Matrix4x4.Identity,
|
|
ClipSlot: 0,
|
|
WbDrawDispatcher.InstanceLightSet.Disabled,
|
|
IndoorFlag: 0,
|
|
Alpha: 1f,
|
|
SelectionLighting: Vector2.Zero,
|
|
DetailCategory: detailCategory,
|
|
IsOpaque: false,
|
|
LocalSortCenter: Vector3.Zero);
|
|
|
|
private static WbDrawDispatcher.InstanceGroup AlphaInstanceGroup(uint detailCategory)
|
|
{
|
|
var group = new WbDrawDispatcher.InstanceGroup
|
|
{
|
|
FirstIndex = 0,
|
|
BaseVertex = 0,
|
|
IndexCount = 3,
|
|
TextureSlot = new GpuTextureSlot(1),
|
|
TextureLayer = 0,
|
|
Translucency = TranslucencyKind.AlphaBlend,
|
|
};
|
|
group.Matrices.Add(Matrix4x4.Identity);
|
|
group.LocalSortCenters.Add(Vector3.Zero);
|
|
group.SubmissionOrders.Add(0);
|
|
group.Slots.Add(0);
|
|
group.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled);
|
|
group.IndoorFlags.Add(0);
|
|
group.DetailCategories.Add(detailCategory);
|
|
group.Opacities.Add(1f);
|
|
group.SelectionLighting.Add(Vector2.Zero);
|
|
return group;
|
|
}
|
|
|
|
[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, Matrix4x4.Identity);
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fx.Dispatcher.SubmitWalkAlphaInstance(
|
|
in batch, Matrix4x4.CreateTranslation(1, 0, 0)));
|
|
|
|
fx.AlphaQueue.AbortFrame();
|
|
}
|
|
|
|
// ── Deliverable 3: the ordered submitter's own cull scratch ────────────
|
|
|
|
[Fact]
|
|
public void PrepareThenDrawOrderedStream_DoesNotReadOrCorruptTheSharedAlphaCullScratch()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
using DrawScope draw = fx.BeginDraw();
|
|
|
|
// Poison the SHARED _drawCullModes scratch the alpha path owns — the
|
|
// exact array the ordered submitter used to write into before FW3.2a.
|
|
// Under the OLD shared-scratch behavior this test's second assertion
|
|
// fails: the ordered path'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.PrepareOrderedStream(draw.Frame, stream, Matrix4x4.Identity);
|
|
fx.Dispatcher.DrawOrderedRange(draw.Pass, 0, stream.Count);
|
|
|
|
// (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,
|
|
bool detailAvailable = false,
|
|
bool detailEnabled = false)
|
|
{
|
|
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,
|
|
buildingDetail: detailAvailable
|
|
? new TerrainAtlas.RetailDetailTextureBinding(
|
|
new GpuTextureSlot(99),
|
|
Tiling: 2f,
|
|
SurfaceTextureId: 1,
|
|
RenderSurfaceId: 2,
|
|
Width: 4,
|
|
Height: 4)
|
|
: default,
|
|
buildingDetailEnabled: detailEnabled ? DetailOn : DetailOff);
|
|
}
|
|
|
|
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(bool beginAlpha = false)
|
|
{
|
|
if (beginAlpha)
|
|
{
|
|
Dispatcher.BeginFrame(frameSlot: 0);
|
|
AlphaQueue!.BeginFrame();
|
|
}
|
|
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);
|
|
}
|
|
|
|
private static bool DetailOn() => true;
|
|
|
|
private static bool DetailOff() => false;
|
|
|
|
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()
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|