feat(render) Campaign FW2: OrderedDrawStream + walk-order submitter

The walk-order submission layer over the existing RHI (plan section FW2):

- OrderedDrawStream: append-only walk-ordered draw commands
  (GroupKey + transform + per-instance data + WalkDrawStage + cell
  provenance), struct-of-arrays with one lockstep Reset (#193 shape).
  The PortalPunch stage exists but has no FW2 submission path - the
  submitter throws on it; punch emission lands with FW3 wiring.
- WbDrawDispatcher.OrderedStream partial: per-instance-first emission
  (the deferred-alpha shape - command i owns instance i, walk order
  survives into the indirect array), each SSBO section written once,
  then one DrawIndirectRangeRhi call per maximal merge run. Runs are
  built by pure-CPU BuildOrderedMergeRuns and may never span a stage,
  pipeline-bucket, or cull boundary; ValidateMergeRun re-checks every
  emitted run and throws (the campaign fail-loud rule). Nothing is
  sorted, reordered, or dropped: N commands in, N indirect commands
  out, covered exactly once.
- WorldDepthContract: retail world depth verified verbatim from the
  decomp - Render::zfuncVal @0x00820e1c = 0x2, SetDepthBufferMode
  @0x005a2d10 writes the enum directly as D3DRS_ZFUNC so the value IS
  D3DCMP_LESS, applied by the surface-state applier @0x0059c80a with
  Z-write toggled by blend; the LESSEQUAL sites are GameSky::Draw-local.
  Seven world pipeline sites now cite the named constant (no value
  changes).
- Plan updated: FW1 status block + gate amendment (the ten pose-stamped
  retail traces supersede re-expressing the old-builder replay
  fixtures; those retire with the old builder at FW4 and their
  scenario classes re-verify at the FW3/FW4 connected gates).

Known FW2 scope notes recorded in the code: the building-detail
overlay replay is production wiring (FW3); the _drawCullModes scratch
may not interleave with a mid-flight RetailAlphaQueue scope (FW3
sequencing constraint). The pixel A/B equivalence proof rides FW3's
cutover toggle where a walk-driven scene first exists.

Suites: full Release build 0 warnings; Walk lane 154/1 skip;
hermetic 6,714/0 (+27 new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-30 12:31:31 +02:00
parent 77f5342b62
commit e65644cb33
12 changed files with 1461 additions and 31 deletions

View file

@ -0,0 +1,634 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Walk;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using AcDream.Core.Meshing;
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 FW2: <see cref="WbDrawDispatcher.BuildOrderedMergeRuns"/>
/// (pure CPU merge-run legality) and <see cref="WbDrawDispatcher.SubmitOrderedStream"/>
/// (the same legality proven through actual recorded RHI calls against
/// <see cref="RecordingGpuDevice"/>).
/// </summary>
public sealed class OrderPreservingSubmitterTests
{
private static OrderedDrawCommand MakeCommand(
int index,
WalkDrawStage stage = WalkDrawStage.Terrain,
TranslucencyKind translucency = TranslucencyKind.Opaque,
CullMode cullMode = CullMode.CounterClockwise,
uint detailCategory = 0) =>
new(
Key: new GroupKey(
FirstIndex: (uint)index * 3,
BaseVertex: index * 4,
IndexCount: 3,
TextureSlot: new GpuTextureSlot((uint)index),
TextureLayer: 0,
Translucency: translucency,
FoliageFlags: 0,
CullMode: cullMode),
Transform: Matrix4x4.CreateTranslation(index, index * 2, index * 3),
Stage: stage,
CellId: 0x8C040100u + (uint)index,
ClipSlot: 0,
Lights: WbDrawDispatcher.InstanceLightSet.Disabled,
IndoorFlag: 0,
Alpha: 1f,
SelectionLighting: Vector2.Zero,
DetailCategory: detailCategory);
private static OrderedDrawStream StreamOf(params OrderedDrawCommand[] commands)
{
var stream = new OrderedDrawStream();
foreach (OrderedDrawCommand command in commands)
stream.Append(command);
return stream;
}
// ── Pure BuildOrderedMergeRuns — no GPU device ─────────────────────────
[Fact]
public void BuildOrderedMergeRuns_MergesThreeAdjacentSameStateCommandsIntoOneRun()
{
OrderedDrawStream stream = StreamOf(
MakeCommand(0), MakeCommand(1), MakeCommand(2));
List<WbDrawDispatcher.OrderedMergeRun> runs =
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
WbDrawDispatcher.OrderedMergeRun run = Assert.Single(runs);
Assert.Equal(0, run.FirstCommand);
Assert.Equal(3, run.CommandCount);
}
[Fact]
public void BuildOrderedMergeRuns_SplitsOnAPipelineBucketChange()
{
OrderedDrawStream stream = StreamOf(
MakeCommand(0, translucency: TranslucencyKind.Opaque),
MakeCommand(1, translucency: TranslucencyKind.Opaque),
MakeCommand(2, translucency: TranslucencyKind.AlphaBlend));
List<WbDrawDispatcher.OrderedMergeRun> runs =
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
Assert.Equal(
[
new WbDrawDispatcher.OrderedMergeRun(0, 2),
new WbDrawDispatcher.OrderedMergeRun(2, 1),
],
runs);
}
[Fact]
public void BuildOrderedMergeRuns_SplitsOnACullModeChange()
{
OrderedDrawStream stream = StreamOf(
MakeCommand(0, cullMode: CullMode.None),
MakeCommand(1, cullMode: CullMode.None),
MakeCommand(2, cullMode: CullMode.Clockwise));
List<WbDrawDispatcher.OrderedMergeRun> runs =
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
Assert.Equal(
[
new WbDrawDispatcher.OrderedMergeRun(0, 2),
new WbDrawDispatcher.OrderedMergeRun(2, 1),
],
runs);
}
/// <summary>
/// The load-bearing new assertion: two commands whose material state
/// (bucket, cull mode, detail category) is IDENTICAL still split into two
/// runs when their <see cref="WalkDrawStage"/> differs. Nothing about the
/// deferred-alpha template this submitter borrows from ever had to
/// consider stage — walk order introduces it.
/// </summary>
[Fact]
public void BuildOrderedMergeRuns_SplitsOnAStageChangeEvenWithIdenticalMaterialState()
{
OrderedDrawStream stream = StreamOf(
MakeCommand(0, stage: WalkDrawStage.Terrain),
MakeCommand(1, stage: WalkDrawStage.CellStatic));
List<WbDrawDispatcher.OrderedMergeRun> runs =
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
Assert.Equal(
[
new WbDrawDispatcher.OrderedMergeRun(0, 1),
new WbDrawDispatcher.OrderedMergeRun(1, 1),
],
runs);
}
[Fact]
public void BuildOrderedMergeRuns_ADetailCategoryCommandIsAlwaysSolo()
{
OrderedDrawStream stream = StreamOf(
MakeCommand(0),
MakeCommand(1, detailCategory: 1),
MakeCommand(2));
List<WbDrawDispatcher.OrderedMergeRun> runs =
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
Assert.Equal(
[
new WbDrawDispatcher.OrderedMergeRun(0, 1),
new WbDrawDispatcher.OrderedMergeRun(1, 1),
new WbDrawDispatcher.OrderedMergeRun(2, 1),
],
runs);
}
[Fact]
public void BuildOrderedMergeRuns_ThrowsNotSupportedForAPortalPunchCommand()
{
OrderedDrawStream stream = StreamOf(
MakeCommand(0, stage: WalkDrawStage.Terrain),
MakeCommand(1, stage: WalkDrawStage.PortalPunch));
Assert.Throws<NotSupportedException>(
() => WbDrawDispatcher.BuildOrderedMergeRuns(stream));
}
[Fact]
public void BuildOrderedMergeRuns_EveryCommandBelongsToExactlyOneRunInOrderWithNoGaps()
{
OrderedDrawStream stream = StreamOf(
MakeCommand(0, stage: WalkDrawStage.Terrain, translucency: TranslucencyKind.Opaque, cullMode: CullMode.None),
MakeCommand(1, stage: WalkDrawStage.Terrain, translucency: TranslucencyKind.Opaque, cullMode: CullMode.None),
MakeCommand(2, stage: WalkDrawStage.Terrain, translucency: TranslucencyKind.AlphaBlend, cullMode: CullMode.None),
MakeCommand(3, stage: WalkDrawStage.Terrain, translucency: TranslucencyKind.AlphaBlend, cullMode: CullMode.Clockwise),
MakeCommand(4, stage: WalkDrawStage.CellStatic, translucency: TranslucencyKind.AlphaBlend, cullMode: CullMode.Clockwise),
MakeCommand(5, stage: WalkDrawStage.CellStatic, translucency: TranslucencyKind.AlphaBlend, cullMode: CullMode.Clockwise, detailCategory: 1),
MakeCommand(6, stage: WalkDrawStage.CellStatic, translucency: TranslucencyKind.AlphaBlend, cullMode: CullMode.Clockwise));
List<WbDrawDispatcher.OrderedMergeRun> runs =
WbDrawDispatcher.BuildOrderedMergeRuns(stream);
int coveredThrough = 0;
int totalCommands = 0;
foreach (WbDrawDispatcher.OrderedMergeRun run in runs)
{
Assert.Equal(coveredThrough, run.FirstCommand);
Assert.True(run.CommandCount > 0);
coveredThrough = run.FirstCommand + run.CommandCount;
totalCommands += run.CommandCount;
}
Assert.Equal(stream.Count, coveredThrough);
Assert.Equal(stream.Count, totalCommands);
}
// ── SubmitOrderedStream — recorded RHI calls against RecordingGpuDevice ─
[Fact]
public void SubmitOrderedStream_AlternatingStateCommandsRecordOneDrawEachInOrder()
{
using var fx = new DispatcherFixture();
using DrawScope draw = fx.BeginDraw();
OrderedDrawStream stream = StreamOf(
MakeCommand(0, translucency: TranslucencyKind.Opaque),
MakeCommand(1, translucency: TranslucencyKind.AlphaBlend),
MakeCommand(2, translucency: TranslucencyKind.Opaque),
MakeCommand(3, translucency: TranslucencyKind.AlphaBlend));
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
List<(int Start, int Count)> ranges = DecodeDrawRanges(fx.Device);
Assert.Equal([(0, 1), (1, 1), (2, 1), (3, 1)], ranges);
}
[Fact]
public void SubmitOrderedStream_MergesAdjacentSameStateCommandsIntoOneMultiDrawIndirect()
{
using var fx = new DispatcherFixture();
using DrawScope draw = fx.BeginDraw();
OrderedDrawStream stream = StreamOf(
MakeCommand(0), MakeCommand(1), MakeCommand(2));
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
List<(int Start, int Count)> ranges = DecodeDrawRanges(fx.Device);
Assert.Equal([(0, 3)], ranges);
}
[Fact]
public void SubmitOrderedStream_CullModeChangeRecordsSeparateCullCallsAndSplitsTheDraw()
{
using var fx = new DispatcherFixture();
using DrawScope draw = fx.BeginDraw();
OrderedDrawStream stream = StreamOf(
MakeCommand(0, cullMode: CullMode.None),
MakeCommand(1, cullMode: CullMode.None),
MakeCommand(2, cullMode: CullMode.Clockwise));
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
Assert.Equal([(0, 2), (2, 1)], DecodeDrawRanges(fx.Device));
List<GpuCullMode> cullCalls =
[.. fx.Device.Calls.OfType<GpuRecordedCullMode>().Select(c => c.CullMode)];
// ApplyCullModeRhi: CullMode.None -> GpuCullMode.None, CullMode.Clockwise -> GpuCullMode.Front.
Assert.Equal([GpuCullMode.None, GpuCullMode.Front], cullCalls);
}
[Fact]
public void SubmitOrderedStream_StageChangeSplitsTheDrawEvenWithIdenticalMaterialState()
{
using var fx = new DispatcherFixture();
using DrawScope draw = fx.BeginDraw();
OrderedDrawStream stream = StreamOf(
MakeCommand(0, stage: WalkDrawStage.Terrain),
MakeCommand(1, stage: WalkDrawStage.CellStatic));
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
Assert.Equal([(0, 1), (1, 1)], DecodeDrawRanges(fx.Device));
}
[Fact]
public void SubmitOrderedStream_ADetailCategoryCommandRecordsItsOwnSoloDraw()
{
using var fx = new DispatcherFixture();
using DrawScope draw = fx.BeginDraw();
OrderedDrawStream stream = StreamOf(
MakeCommand(0),
MakeCommand(1, detailCategory: 1),
MakeCommand(2));
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
Assert.Equal([(0, 1), (1, 1), (2, 1)], DecodeDrawRanges(fx.Device));
}
[Fact]
public void SubmitOrderedStream_OpaqueRunUsesRenderPassZeroAndAlphaBlendRunUsesRenderPassOne()
{
using var fx = new DispatcherFixture();
using DrawScope draw = fx.BeginDraw();
OrderedDrawStream stream = StreamOf(
MakeCommand(0, translucency: TranslucencyKind.Opaque),
MakeCommand(1, translucency: TranslucencyKind.AlphaBlend));
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
List<(GpuPushConstants Constants, int Start, int Count)> runs = DecodeRuns(fx.Device);
Assert.Equal(2, runs.Count);
Assert.Equal(0, runs[0].Constants.RenderPass);
Assert.Equal(1, runs[1].Constants.RenderPass);
}
[Fact]
public void SubmitOrderedStream_ThrowsNotSupportedForAPortalPunchCommandBeforeAnyDraw()
{
using var fx = new DispatcherFixture();
using DrawScope draw = fx.BeginDraw();
OrderedDrawStream stream = StreamOf(
MakeCommand(0, stage: WalkDrawStage.PortalPunch));
Assert.Throws<NotSupportedException>(
() => fx.Dispatcher.SubmitOrderedStream(
draw.Frame, draw.Pass, stream, Matrix4x4.Identity));
Assert.Empty(fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
Assert.Empty(fx.Device.Calls.OfType<GpuRecordedStorageBind>());
}
[Fact]
public void SubmitOrderedStream_EmptyStreamRecordsNoDraws()
{
using var fx = new DispatcherFixture();
using DrawScope draw = fx.BeginDraw();
fx.Dispatcher.SubmitOrderedStream(
draw.Frame, draw.Pass, new OrderedDrawStream(), Matrix4x4.Identity);
Assert.Empty(fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
}
/// <summary>
/// Fail-loud invariant: whatever the state pattern, the recorded
/// MultiDrawIndirect calls' DrawCounts always sum to the stream's Count —
/// no command is ever silently skipped, and none is drawn twice.
/// </summary>
[Fact]
public void SubmitOrderedStream_TotalRecordedDrawCountAlwaysEqualsTheStreamCount()
{
using var fx = new DispatcherFixture();
using DrawScope draw = fx.BeginDraw();
var stream = new OrderedDrawStream();
var stages = new[] { WalkDrawStage.Terrain, WalkDrawStage.CellStatic, WalkDrawStage.BuildingShell };
var blends = new[]
{
TranslucencyKind.Opaque, TranslucencyKind.AlphaBlend,
TranslucencyKind.Additive, TranslucencyKind.InvAlpha,
};
var culls = new[] { CullMode.None, CullMode.Clockwise, CullMode.CounterClockwise };
const int commandCount = 11;
for (int i = 0; i < commandCount; i++)
{
stream.Append(MakeCommand(
i,
stage: stages[i % stages.Length],
translucency: blends[i % blends.Length],
cullMode: culls[i % culls.Length],
detailCategory: i == 5 ? 1u : 0u));
}
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
List<(int Start, int Count)> ranges = DecodeDrawRanges(fx.Device);
int sum = ranges.Sum(r => r.Count);
Assert.Equal(commandCount, sum);
int coveredThrough = 0;
foreach ((int start, int count) in ranges)
{
Assert.Equal(coveredThrough, start);
coveredThrough += count;
}
Assert.Equal(commandCount, coveredThrough);
}
// ── Decode helpers ──────────────────────────────────────────────────────
private static List<(int Start, int Count)> DecodeDrawRanges(RecordingGpuDevice device) =>
[.. DecodeRuns(device).Select(r => (r.Start, r.Count))];
private static List<(GpuPushConstants Constants, int Start, int Count)> DecodeRuns(
RecordingGpuDevice device)
{
GpuPushConstants? lastConstants = null;
uint? commandBase = null;
var result = new List<(GpuPushConstants, int, int)>();
foreach (var call in device.Calls)
{
if (call is GpuRecordedPushConstants pc)
{
lastConstants = pc.Constants;
}
else if (call is GpuRecordedMultiDrawIndirect mdi)
{
Assert.Equal((uint)WbDrawDispatcher.DrawCommandStride, mdi.StrideBytes);
commandBase ??= mdi.OffsetBytes;
int start = (int)((mdi.OffsetBytes - commandBase.Value) / mdi.StrideBytes);
Assert.NotNull(lastConstants);
result.Add((lastConstants!.Value, start, (int)mdi.DrawCount));
}
}
return result;
}
// ── Fixture: a real WbDrawDispatcher against RecordingGpuDevice ─────────
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 SubmitOrderedStream 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; }
/// <summary>Opens a frame and a backbuffer pass, publishes it on
/// <see cref="Scope"/>, then clears the recorded calls so a test only
/// sees what its own <c>SubmitOrderedStream</c> call produced.</summary>
public DrawScope BeginDraw()
{
FrameLifetime.BeginFrame();
IGpuFrame frame = FrameLifetime.CurrentFrame!;
IGpuPassEncoder pass = frame.BeginPass(
GpuPassDescription.BackbufferClear(
"fw2-ordered-stream-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()
{
}
}
}
}

View file

@ -0,0 +1,169 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Walk;
using AcDream.Core.Meshing;
using DatReaderWriter.Enums;
namespace AcDream.App.Tests.Rendering.Walk;
/// <summary>
/// Campaign FW stage FW2: pure data-structure tests for
/// <see cref="OrderedDrawStream"/>. No GPU device, no dispatcher — this is the
/// append-only struct-of-arrays storage on its own.
/// </summary>
public sealed class OrderedDrawStreamTests
{
private static OrderedDrawCommand MakeCommand(
int index,
WalkDrawStage stage = WalkDrawStage.Terrain,
TranslucencyKind translucency = TranslucencyKind.Opaque,
CullMode cullMode = CullMode.CounterClockwise,
uint detailCategory = 0) =>
new(
Key: new GroupKey(
FirstIndex: (uint)index * 3,
BaseVertex: index * 4,
IndexCount: 3,
TextureSlot: new GpuTextureSlot((uint)index),
TextureLayer: 0,
Translucency: translucency,
FoliageFlags: 0,
CullMode: cullMode),
Transform: Matrix4x4.CreateTranslation(index, index * 2, index * 3),
Stage: stage,
CellId: 0x8C040100u + (uint)index,
ClipSlot: (uint)index + 1,
Lights: WbDrawDispatcher.InstanceLightSet.Disabled,
IndoorFlag: (uint)(index % 2),
Alpha: 1f - index * 0.01f,
SelectionLighting: new Vector2(index, index + 1),
DetailCategory: detailCategory);
[Fact]
public void EmptyStream_HasZeroCount()
{
var stream = new OrderedDrawStream();
Assert.Equal(0, stream.Count);
Assert.Empty(stream.Keys);
Assert.Empty(stream.Transforms);
Assert.Empty(stream.Stages);
Assert.Empty(stream.CellIds);
Assert.Empty(stream.ClipSlots);
Assert.Empty(stream.Lights);
Assert.Empty(stream.IndoorFlags);
Assert.Empty(stream.Alphas);
Assert.Empty(stream.SelectionLighting);
Assert.Empty(stream.DetailCategories);
}
[Fact]
public void Append_GrowsCountAndEveryParallelListInLockstep()
{
var stream = new OrderedDrawStream();
for (int i = 0; i < 5; i++)
stream.Append(MakeCommand(i));
Assert.Equal(5, stream.Count);
Assert.Equal(5, stream.Keys.Count);
Assert.Equal(5, stream.Transforms.Count);
Assert.Equal(5, stream.Stages.Count);
Assert.Equal(5, stream.CellIds.Count);
Assert.Equal(5, stream.ClipSlots.Count);
Assert.Equal(5, stream.Lights.Count);
Assert.Equal(5, stream.IndoorFlags.Count);
Assert.Equal(5, stream.Alphas.Count);
Assert.Equal(5, stream.SelectionLighting.Count);
Assert.Equal(5, stream.DetailCategories.Count);
}
[Fact]
public void Append_PreservesEveryFieldAtItsIndex()
{
var stream = new OrderedDrawStream();
OrderedDrawCommand[] commands =
[
MakeCommand(0, WalkDrawStage.Terrain),
MakeCommand(1, WalkDrawStage.CellStatic),
MakeCommand(2, WalkDrawStage.BuildingShell),
];
foreach (OrderedDrawCommand command in commands)
stream.Append(command);
for (int i = 0; i < commands.Length; i++)
{
Assert.Equal(commands[i].Key, stream.Keys[i]);
Assert.Equal(commands[i].Transform, stream.Transforms[i]);
Assert.Equal(commands[i].Stage, stream.Stages[i]);
Assert.Equal(commands[i].CellId, stream.CellIds[i]);
Assert.Equal(commands[i].ClipSlot, stream.ClipSlots[i]);
Assert.Equal(commands[i].Lights, stream.Lights[i]);
Assert.Equal(commands[i].IndoorFlag, stream.IndoorFlags[i]);
Assert.Equal(commands[i].Alpha, stream.Alphas[i]);
Assert.Equal(commands[i].SelectionLighting, stream.SelectionLighting[i]);
Assert.Equal(commands[i].DetailCategory, stream.DetailCategories[i]);
}
}
[Theory]
[InlineData(WalkDrawStage.Terrain)]
[InlineData(WalkDrawStage.CellStatic)]
[InlineData(WalkDrawStage.BuildingShell)]
[InlineData(WalkDrawStage.PortalPunch)]
[InlineData(WalkDrawStage.LookInStatic)]
[InlineData(WalkDrawStage.Dynamic)]
internal void Append_AcceptsEveryStageIncludingPortalPunch(WalkDrawStage stage)
{
// The stream itself is a dumb data structure — it accepts every stage
// unconditionally. Only the SUBMITTER rejects PortalPunch (see
// OrderPreservingSubmitterTests), so stage-separation gates can
// exercise the boundary at the stream level too.
var stream = new OrderedDrawStream();
stream.Append(MakeCommand(0, stage));
Assert.Equal(1, stream.Count);
Assert.Equal(stage, stream.Stages[0]);
}
[Fact]
public void Reset_ClearsEveryParallelListToZeroCount()
{
var stream = new OrderedDrawStream();
for (int i = 0; i < 7; i++)
stream.Append(MakeCommand(i));
Assert.Equal(7, stream.Count);
stream.Reset();
Assert.Equal(0, stream.Count);
Assert.Empty(stream.Keys);
Assert.Empty(stream.Transforms);
Assert.Empty(stream.Stages);
Assert.Empty(stream.CellIds);
Assert.Empty(stream.ClipSlots);
Assert.Empty(stream.Lights);
Assert.Empty(stream.IndoorFlags);
Assert.Empty(stream.Alphas);
Assert.Empty(stream.SelectionLighting);
Assert.Empty(stream.DetailCategories);
}
[Fact]
public void Reset_ThenAppend_StartsAFreshInOrderSequence()
{
var stream = new OrderedDrawStream();
stream.Append(MakeCommand(0, WalkDrawStage.Terrain));
stream.Append(MakeCommand(1, WalkDrawStage.Terrain));
stream.Reset();
stream.Append(MakeCommand(9, WalkDrawStage.Dynamic));
Assert.Equal(1, stream.Count);
Assert.Equal(WalkDrawStage.Dynamic, stream.Stages[0]);
Assert.Equal(0x8C040100u + 9u, stream.CellIds[0]);
}
}