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

@ -218,6 +218,31 @@ oracle traces exactly.
vs the old builder are adjudicated against the ORACLE, not against the vs the old builder are adjudicated against the ORACLE, not against the
old builder. No production wiring; hermetic suites green. old builder. No production wiring; hermetic suites green.
**FW1 STATUS (2026-08-30, @`77f5342b`):** NINE of the ten pose-stamped
fixtures reproduce retail frame-exactly (foundry-deep all 39 frames,
doorway-still, street-outdoor, terrace-center, terrace-edge — the #456
acceptance pose — cathedral-arrival, holtburg-walkout, -transitions,
-walkabout); foundry-entry is exact through F66 with the F67F79
standing segment parked on ONE live number (building 0036's root-plane
viewpoint — probe `tools/walk-oracle/fw1-f67-viewpoint-probe.cdb`,
goal-sanctioned retail-session stop filed with the user). Load-bearing
adjudications, all decomp-cited: the two-arm `get_degrade` threshold
rule (ideal→max at the live `deg_mul≈+0.99`), deg_mul's DYNAMIC swing
under capture load (doorway-still pins mul=0 — an environment pin like
the viewport), znear=0.1 confirmed, and the Ghidra-arbitrated portal
walker truth table (BN FPU pseudo-C mis-renders branch sense — three
separate misreads this stage; Ghidra first, always).
**Gate amendment:** the old-replay-fixture re-expression is retired as
an FW1 gate — the ten traces are direct retail evidence and strictly
supersede fixtures that encode the OLD builder's behavior; the old
suite's scenario classes (doorway flap, dungeon seams, tower ascent,
corner flood) are covered by the traces and re-verified live at the
FW3/FW4 connected gates, where the old fixtures retire with the old
builder. Production classes: `RetailFrameWalk`, `WalkPView` (the
PViewSet role), `WalkBuildingPortals`, `WalkLandscape`,
`WalkVisibilityMath`, `WalkScreenClip`, `WalkCopyView` under
`src/AcDream.App/Rendering/Walk/`.
### FW2 — `OrderedDrawStream` + `OrderPreservingSubmitter` ### FW2 — `OrderedDrawStream` + `OrderPreservingSubmitter`
**Goal:** walk-order submission through the existing RHI, proven **Goal:** walk-order submission through the existing RHI, proven

View file

@ -1073,6 +1073,12 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
}); });
} }
/// <summary>
/// The caster's own depth-only pipeline. Depth compare is
/// <see cref="WorldDepthContract.WorldCompare"/> — see that type for the
/// world-space <c>GL_LESS</c> citation this shares with every other world
/// pipeline.
/// </summary>
private static IGpuPipeline CreatePipeline( private static IGpuPipeline CreatePipeline(
IGpuDevice device, IGpuDevice device,
string name, string name,
@ -1087,7 +1093,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
VertexLayout = layout, VertexLayout = layout,
Topology = GpuPrimitiveTopology.TriangleList, Topology = GpuPrimitiveTopology.TriangleList,
Blend = GpuBlendMode.None, Blend = GpuBlendMode.None,
Depth = new GpuDepthState(true, true, GpuCompareOp.Less), Depth = new GpuDepthState(true, true, WorldDepthContract.WorldCompare),
Cull = GpuCullMode.Back, Cull = GpuCullMode.Back,
FrontFace = frontFace, FrontFace = frontFace,
AlphaToCoverage = false, AlphaToCoverage = false,

View file

@ -232,12 +232,14 @@ public sealed unsafe partial class ParticleRenderer
/// which is the GL arm's bracket verbatim /// which is the GL arm's bracket verbatim
/// (<c>Enable(DepthTest)</c>/<c>DepthMask(false)</c>/<c>Disable(CullFace)</c>). /// (<c>Enable(DepthTest)</c>/<c>DepthMask(false)</c>/<c>Disable(CullFace)</c>).
/// ///
/// <para>Depth compare is <c>Less</c>, not the contract's <c>LessOrEqual</c> /// <para>Depth compare is <see cref="AcDream.App.Rendering.WorldDepthContract.WorldCompare"/>
/// default: the world frame runs under <c>GL_LESS</c> and this renderer never /// (<c>Less</c>), not the contract's <c>LessOrEqual</c> default — see that
/// called <c>glDepthFunc</c>, so it inherited it. Alpha-to-coverage is off /// type for the full citation. The world frame runs under <c>GL_LESS</c>
/// for the same kind of reason and the opposite way round — the frame-global /// and this renderer never called <c>glDepthFunc</c>, so it inherited it.
/// state controller disables it and only <c>WbDrawDispatcher</c>'s opaque /// Alpha-to-coverage is off for the same kind of reason and the opposite
/// bracket turns it on, so particles have never drawn with it.</para> /// way round — the frame-global state controller disables it and only
/// <c>WbDrawDispatcher</c>'s opaque bracket turns it on, so particles have
/// never drawn with it.</para>
/// </summary> /// </summary>
private static IGpuPipeline CreateBillboardPipeline( private static IGpuPipeline CreateBillboardPipeline(
IGpuDevice device, IGpuDevice device,
@ -251,7 +253,7 @@ public sealed unsafe partial class ParticleRenderer
VertexLayout = BillboardVertexLayout, VertexLayout = BillboardVertexLayout,
Topology = GpuPrimitiveTopology.TriangleList, Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend, Blend = blend,
Depth = new GpuDepthState(Test: true, Write: false, GpuCompareOp.Less), Depth = new GpuDepthState(Test: true, Write: false, WorldDepthContract.WorldCompare),
Cull = GpuCullMode.None, Cull = GpuCullMode.None,
FrontFace = GpuFrontFace.CounterClockwise, FrontFace = GpuFrontFace.CounterClockwise,
AlphaToCoverage = false, AlphaToCoverage = false,
@ -260,10 +262,12 @@ public sealed unsafe partial class ParticleRenderer
}); });
/// <summary> /// <summary>
/// One mesh-particle pipeline. Same depth bracket as the billboards; the /// One mesh-particle pipeline. Same depth bracket as the billboards (see
/// winding is CW because <c>PrepareMeshPipeline</c> sets /// <see cref="AcDream.App.Rendering.WorldDepthContract"/> for the world
/// <c>glFrontFace(GL_CW)</c>, and the cull mode stays DYNAMIC because it is /// <c>GL_LESS</c> citation); the winding is CW because
/// resolved per sub-batch from the DAT's own <c>CullMode</c>. /// <c>PrepareMeshPipeline</c> sets <c>glFrontFace(GL_CW)</c>, and the cull
/// mode stays DYNAMIC because it is resolved per sub-batch from the DAT's
/// own <c>CullMode</c>.
/// </summary> /// </summary>
private static IGpuPipeline CreateMeshParticlePipeline( private static IGpuPipeline CreateMeshParticlePipeline(
IGpuDevice device, IGpuDevice device,
@ -277,7 +281,7 @@ public sealed unsafe partial class ParticleRenderer
VertexLayout = MeshVertexLayout, VertexLayout = MeshVertexLayout,
Topology = GpuPrimitiveTopology.TriangleList, Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend, Blend = blend,
Depth = new GpuDepthState(Test: true, Write: false, GpuCompareOp.Less), Depth = new GpuDepthState(Test: true, Write: false, WorldDepthContract.WorldCompare),
Cull = GpuCullMode.None, Cull = GpuCullMode.None,
FrontFace = GpuFrontFace.Clockwise, FrontFace = GpuFrontFace.Clockwise,
AlphaToCoverage = false, AlphaToCoverage = false,

View file

@ -41,7 +41,10 @@ public sealed partial class TerrainModernRenderer
VertexLayout = TerrainVertexLayout, VertexLayout = TerrainVertexLayout,
Topology = GpuPrimitiveTopology.TriangleList, Topology = GpuPrimitiveTopology.TriangleList,
Blend = GpuBlendMode.None, Blend = GpuBlendMode.None,
Depth = new GpuDepthState(true, true, GpuCompareOp.Less), // WorldDepthContract.WorldCompare — see that type for the
// world-space GL_LESS citation this receiver pipeline shares
// with terrain's own base pass.
Depth = new GpuDepthState(true, true, WorldDepthContract.WorldCompare),
Cull = GpuCullMode.Back, Cull = GpuCullMode.Back,
FrontFace = GpuFrontFace.CounterClockwise, FrontFace = GpuFrontFace.CounterClockwise,
AlphaToCoverage = false, AlphaToCoverage = false,

View file

@ -85,12 +85,14 @@ public sealed unsafe partial class TerrainModernRenderer
VertexLayout = TerrainVertexLayout, VertexLayout = TerrainVertexLayout,
Topology = GpuPrimitiveTopology.TriangleList, Topology = GpuPrimitiveTopology.TriangleList,
Blend = GpuBlendMode.None, Blend = GpuBlendMode.None,
// GL_LESS, not the contract's LessOrEqual default: the world frame // WorldDepthContract.WorldCompare (GL_LESS), not the contract's
// runs under GL_LESS and terrain never called glDepthFunc, so it // LessOrEqual default: the world frame runs under GL_LESS and
// inherited it. LessOrEqual would change which of two coplanar retail // terrain never called glDepthFunc, so it inherited it. LessOrEqual
// surfaces wins — visible exactly where terrain meets roads and // would change which of two coplanar retail surfaces wins — visible
// building footings, which is what zFightTerrainAdjust is about. // exactly where terrain meets roads and building footings, which is
Depth = new GpuDepthState(Test: true, Write: true, GpuCompareOp.Less), // what zFightTerrainAdjust is about. See WorldDepthContract for the
// full citation.
Depth = new GpuDepthState(Test: true, Write: true, WorldDepthContract.WorldCompare),
// #108-residual: retail terrain is SINGLE-SIDED. See the GL arm's // #108-residual: retail terrain is SINGLE-SIDED. See the GL arm's
// Draw for the full reasoning; this bakes the same triple. // Draw for the full reasoning; this bakes the same triple.
Cull = GpuCullMode.Back, Cull = GpuCullMode.Back,

View file

@ -0,0 +1,166 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering.Walk;
/// <summary>
/// Campaign FW stage FW2 — retail's frame phases, in the order the walk
/// visits them. <c>RetailPViewPassExecutor</c>'s packed route contract
/// (<c>RenderFrameCandidateRoute</c>: LandscapeOutdoorStatic →
/// LandscapeBuildingShell → LookInObject → LandscapeOutsideDynamic →
/// CellStatic → DynamicLast — <c>WbDrawDispatcher.PackedOracle.cs:108/171</c>
/// enforces in-order consumption today) is the closest existing analogue;
/// this enum is the walk submitter's OWN phase vocabulary, and a merge run
/// built by <see cref="WbDrawDispatcher.BuildOrderedMergeRuns"/> may never
/// span two different stages — the submitter treats a stage boundary exactly
/// like a material-state boundary (see
/// docs/plans/2026-08-30-campaign-fw-frame-walk.md §FW2).
/// </summary>
internal enum WalkDrawStage : byte
{
/// <summary><c>LScape::draw</c> @0x00506330 /
/// <c>LScape::grab_visible_cells</c> @0x00504EC0 — outdoor terrain.</summary>
Terrain,
/// <summary>An indoor <c>PView::DrawCells</c> @0x005A4840 flood's static
/// geometry: EnvCell shells plus the static meshes they contain.</summary>
CellStatic,
/// <summary><c>DrawBuilding</c>'s (<c>RenderDeviceD3D::DrawBuilding</c>
/// @0x0059f2a0) exterior shell pass.</summary>
BuildingShell,
/// <summary>
/// <c>DrawPortalPolyInternal</c>'s depth-only invisible portal-polygon
/// panel — punch far-Z / seal own-depth. FW0 confirmed retail actually
/// draws these every frame (not an acdream invention); the AD-117 stamps
/// re-invented the mechanism at the wrong site.
///
/// <para><b>No FW2 submission path exists yet.</b>
/// <see cref="WbDrawDispatcher.SubmitOrderedStream"/> throws
/// <see cref="System.NotSupportedException"/> if a command carries this
/// stage — punch geometry emission lands in FW3 with the world wiring.
/// The stage exists now purely so stage-separation gates can exercise the
/// boundary before the real emission path is built.</para>
/// </summary>
PortalPunch,
/// <summary><c>ConstructView(CBldPortal)</c> look-in static geometry —
/// what an exterior building's window or doorway reveals of its own
/// interior.</summary>
LookInStatic,
/// <summary>Every non-static draw the walk visits last: entities,
/// monsters, items, and the meshes particles ride.</summary>
Dynamic,
}
/// <summary>
/// One walk-ordered draw command. The nine fields after <see cref="Key"/> and
/// <see cref="Transform"/> are exactly the per-instance data
/// <c>WbDrawDispatcher.PrepareDeferredAlphaDraws</c>'s
/// <c>DeferredAlphaInstance</c> carries — that method is the per-instance-first
/// SSBO-layout template FW2's submitter follows — plus the walk provenance
/// (<see cref="Stage"/>, <see cref="CellId"/>) the submitter needs to know
/// where a merge run may and may not cross a boundary.
/// </summary>
/// <param name="Key">Mesh-subset and material identity: index range, texture
/// slot/layer, translucency, foliage flags, cull mode. The same
/// <see cref="GroupKey"/> the classic material-bucketed path groups instances
/// by — FW2 does not bucket by it, only reads its fields per instance.</param>
/// <param name="Transform">World transform. Storage binding 0
/// (<c>StorageInstances</c>).</param>
/// <param name="Stage">The retail frame phase this command belongs to. A
/// merge run may never span two different stages.</param>
/// <param name="CellId">Walk-order provenance: which cell's traversal emitted
/// this command. Not bound to any GPU storage section today — carried for
/// FW3's portal-punch wiring and for diagnostics.</param>
/// <param name="ClipSlot">Storage binding 3 (<c>StorageClipSlots</c>).</param>
/// <param name="Lights">Storage binding 5 (<c>StorageInstanceLightSets</c>).</param>
/// <param name="IndoorFlag">Storage binding 6 (<c>StorageInstanceIndoor</c>).</param>
/// <param name="Alpha">Storage binding 7 (<c>StorageInstanceAlpha</c>).</param>
/// <param name="SelectionLighting">Storage binding 8
/// (<c>StorageInstanceSelectionLighting</c>).</param>
/// <param name="DetailCategory">Storage binding 9
/// (<c>StorageInstanceDetailCategory</c>). A nonzero value forces this
/// command into a solo merge run — mirrors the deferred-alpha detail break in
/// <c>WbDrawDispatcher.DrawPreparedAlphaBatchRhi</c>.</param>
internal readonly record struct OrderedDrawCommand(
GroupKey Key,
Matrix4x4 Transform,
WalkDrawStage Stage,
uint CellId,
uint ClipSlot,
WbDrawDispatcher.InstanceLightSet Lights,
uint IndoorFlag,
float Alpha,
Vector2 SelectionLighting,
uint DetailCategory);
/// <summary>
/// Append-only, walk-ordered draw-command stream. Struct-of-arrays storage —
/// one parallel list per <see cref="OrderedDrawCommand"/> field, the same
/// shape as <see cref="WbDrawDispatcher.InstanceGroup"/>'s per-instance lists
/// — so <see cref="WbDrawDispatcher.SubmitOrderedStream"/> can walk the stream
/// by index instead of allocating one boxed command per instance.
///
/// <para>The stream carries no ordering logic of its own: reading it back is
/// exactly the sequence <see cref="Append"/> was called in, unconditionally.
/// That is the campaign invariant this type exists to make impossible to
/// silently regress — see
/// docs/plans/2026-08-30-campaign-fw-frame-walk.md §FW2's "a merge across a
/// state or stage boundary is forbidden by construction (assert it)" rule.</para>
/// </summary>
internal sealed class OrderedDrawStream
{
public readonly List<GroupKey> Keys = new();
public readonly List<Matrix4x4> Transforms = new();
public readonly List<WalkDrawStage> Stages = new();
public readonly List<uint> CellIds = new();
public readonly List<uint> ClipSlots = new();
public readonly List<WbDrawDispatcher.InstanceLightSet> Lights = new();
public readonly List<uint> IndoorFlags = new();
public readonly List<float> Alphas = new();
public readonly List<Vector2> SelectionLighting = new();
public readonly List<uint> DetailCategories = new();
/// <summary>Number of commands appended since the last <see cref="Reset"/>.</summary>
public int Count => Keys.Count;
public void Append(in OrderedDrawCommand command)
{
Keys.Add(command.Key);
Transforms.Add(command.Transform);
Stages.Add(command.Stage);
CellIds.Add(command.CellId);
ClipSlots.Add(command.ClipSlot);
Lights.Add(command.Lights);
IndoorFlags.Add(command.IndoorFlag);
Alphas.Add(command.Alpha);
SelectionLighting.Add(command.SelectionLighting);
DetailCategories.Add(command.DetailCategory);
}
/// <summary>
/// Clears every parallel list together, in one method. The established
/// #193 lesson (<c>WbDrawDispatcher.InstanceGroup.ClearPerInstanceData</c>
/// carries the same remark): a stream with N parallel lists that resets
/// them independently can leave one list stale relative to the others
/// after a future field is added and its clear call forgotten — and a
/// stale list that only ever grows leaks unboundedly. Ten lists, one
/// reset call, so a new eleventh list has nowhere to hide from it.
/// </summary>
public void Reset()
{
Keys.Clear();
Transforms.Clear();
Stages.Clear();
CellIds.Clear();
ClipSlots.Clear();
Lights.Clear();
IndoorFlags.Clear();
Alphas.Clear();
SelectionLighting.Clear();
DetailCategories.Clear();
}
}

View file

@ -88,10 +88,12 @@ public sealed unsafe partial class EnvCellRenderer
/// shared: <c>mesh_modern</c>, the 32-byte world-mesh vertex, triangle lists, /// shared: <c>mesh_modern</c>, the 32-byte world-mesh vertex, triangle lists,
/// back-face culling with clockwise front faces. /// back-face culling with clockwise front faces.
/// ///
/// <para>Depth compare is <c>Less</c>, not the contract's <c>LessOrEqual</c> /// <para>Depth compare is <see cref="AcDream.App.Rendering.WorldDepthContract.WorldCompare"/>
/// default. The world frame runs under <c>GL_LESS</c> and this renderer never /// (<c>Less</c>), not the contract's <c>LessOrEqual</c> default — see that
/// called <c>glDepthFunc</c>, so it inherited it; baking <c>LessOrEqual</c> /// type for the full citation. The world frame runs under <c>GL_LESS</c>
/// would change which of two coplanar retail surfaces wins.</para> /// and this renderer never called <c>glDepthFunc</c>, so it inherited it;
/// baking <c>LessOrEqual</c> would change which of two coplanar retail
/// surfaces wins.</para>
/// </summary> /// </summary>
private static IGpuPipeline CreateShellPipeline( private static IGpuPipeline CreateShellPipeline(
IGpuDevice device, IGpuDevice device,
@ -100,7 +102,7 @@ public sealed unsafe partial class EnvCellRenderer
bool depthWrite, bool depthWrite,
int sampleCount, int sampleCount,
string shaderName = "mesh_modern", string shaderName = "mesh_modern",
GpuCompareOp depthCompare = GpuCompareOp.Less) => GpuCompareOp depthCompare = AcDream.App.Rendering.WorldDepthContract.WorldCompare) =>
device.CreatePipeline(new GpuPipelineDescription device.CreatePipeline(new GpuPipelineDescription
{ {
Name = name, Name = name,

View file

@ -0,0 +1,371 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Walk;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Campaign FW stage FW2: <see cref="OrderedDrawStream"/>'s submitter.
///
/// <para>Walk-order submission through the SAME RHI machinery
/// <c>WbDrawDispatcher.Rhi.cs</c> already owns — the ring-section writers,
/// <see cref="MeshPipelineSet"/>, and <see cref="DrawIndirectRangeRhi"/> — is
/// why this is a partial of <see cref="WbDrawDispatcher"/> rather than a
/// standalone class. Two shapes are reused directly:
/// <see cref="PrepareDeferredAlphaDraws"/>'s per-instance-first emission
/// (command <c>i</c> owns exactly one instance, <c>BaseInstance = i</c>, so
/// walk order — never material bucketing — survives into the indirect array)
/// and <see cref="PrepareRhiAlphaSections"/>'s "write every section once"
/// shape (locals here, not the persisted <c>_alpha*</c> fields: those belong
/// to <see cref="RetailAlphaQueue"/>, which can still be mid-flight in the
/// same frame, and overwriting them would corrupt that replay).</para>
///
/// <para>Scope: this stage proves walk-order submission through the existing
/// RHI on static content (plan §FW2). It does NOT wire the retail
/// building-detail overlay replay (<c>DrawBuildingDetailRangeRhi</c>'s second
/// pass through <c>RetailDetail</c>/<c>RetailDetailTransparent</c>) — a
/// detail-category command still forces a solo merge run (mirroring the
/// deferred-alpha detail break), but <see cref="SubmitOrderedStream"/> issues
/// only the base-pipeline draw for it. The overlay replay is production
/// wiring, deferred to whichever stage cuts the walk over for real content.</para>
/// </summary>
public sealed unsafe partial class WbDrawDispatcher
{
/// <summary>
/// One walk-order merge run: a maximal, in-order span of commands that
/// share a <see cref="WalkDrawStage"/>, a resolved pipeline, and a cull
/// mode, built by <see cref="BuildOrderedMergeRuns"/>.
/// </summary>
internal readonly record struct OrderedMergeRun(int FirstCommand, int CommandCount);
/// <summary>
/// The four pipeline buckets a translucency kind resolves to, independent
/// of any live <see cref="MeshPipelineSet"/> instance. <see cref="AlphaToCoverage"/>
/// selects between <c>Opaque</c> and <c>OpaqueAlphaToCoverage</c> uniformly
/// for the whole submission — it never varies per command — so merge-run
/// legality only needs to know WHICH bucket a command falls in, not which
/// concrete <see cref="IGpuPipeline"/> that resolves to. That is what lets
/// <see cref="BuildOrderedMergeRuns"/> stay pure CPU logic, testable
/// without a live GPU device — the same separation
/// <see cref="BuildIndirectArrays"/> already draws between layout and RHI
/// glue.
/// </summary>
private enum PipelineBucket
{
Opaque,
AlphaBlend,
AlphaAdditive,
AlphaInverse,
}
private static PipelineBucket BucketFor(TranslucencyKind kind)
{
if (IsOpaque(kind))
return PipelineBucket.Opaque;
return kind switch
{
TranslucencyKind.Additive => PipelineBucket.AlphaAdditive,
TranslucencyKind.InvAlpha => PipelineBucket.AlphaInverse,
_ => PipelineBucket.AlphaBlend,
};
}
private IGpuPipeline PipelineForBucket(MeshPipelineSet pipelines, PipelineBucket bucket) =>
bucket switch
{
PipelineBucket.Opaque => AlphaToCoverage ? pipelines.OpaqueAlphaToCoverage : pipelines.Opaque,
PipelineBucket.AlphaAdditive => pipelines.AlphaAdditive,
PipelineBucket.AlphaInverse => pipelines.AlphaInverse,
_ => pipelines.AlphaBlend,
};
/// <summary>
/// Builds the maximal in-order merge runs for <paramref name="stream"/>.
/// Pure CPU: no GPU device, no live pipeline, no encoder — every legality
/// decision is a comparison over the stream's own parallel arrays, which
/// is what lets this be unit-tested directly.
///
/// <para>A run extends from command <c>i</c> to <c>j</c> while every
/// command in <c>[i, j)</c> shares the same <see cref="WalkDrawStage"/>,
/// the same <see cref="PipelineBucket"/>, the same <see cref="CullMode"/>,
/// and none carries a nonzero <c>DetailCategory</c> — a detail-category
/// command always emits alone, mirroring
/// <c>DrawPreparedAlphaBatchRhi</c>'s <c>hasDetail</c> break. Never
/// reorders or drops anything: every command in <paramref name="stream"/>
/// belongs to exactly one returned run, in stream order.</para>
///
/// <para>Fails loud before building any run: <see cref="WalkDrawStage.PortalPunch"/>
/// has no FW2 submission path (see that value's own documentation), so a
/// stream carrying one throws immediately rather than silently degrading
/// to some other stage's handling.</para>
/// </summary>
internal static List<OrderedMergeRun> BuildOrderedMergeRuns(OrderedDrawStream stream)
{
ArgumentNullException.ThrowIfNull(stream);
int count = stream.Count;
for (int i = 0; i < count; i++)
{
if (stream.Stages[i] == WalkDrawStage.PortalPunch)
{
throw new NotSupportedException(
$"OrderedDrawStream command {i} carries WalkDrawStage.PortalPunch, "
+ "which has no FW2 submission path — punch geometry emission lands "
+ "in FW3 with the world wiring "
+ "(docs/plans/2026-08-30-campaign-fw-frame-walk.md §FW2/§FW3). The "
+ "stage exists now purely so stage-separation gates can exercise the "
+ "boundary before the real emission path exists.");
}
}
var runs = new List<OrderedMergeRun>();
int cursor = 0;
while (cursor < count)
{
WalkDrawStage stage = stream.Stages[cursor];
PipelineBucket bucket = BucketFor(stream.Keys[cursor].Translucency);
CullMode cull = stream.Keys[cursor].CullMode;
bool detail = stream.DetailCategories[cursor] != 0;
int end = cursor + 1;
if (!detail)
{
while (end < count
&& stream.Stages[end] == stage
&& stream.DetailCategories[end] == 0
&& BucketFor(stream.Keys[end].Translucency) == bucket
&& stream.Keys[end].CullMode == cull)
{
end++;
}
}
runs.Add(new OrderedMergeRun(cursor, end - cursor));
cursor = end;
}
return runs;
}
/// <summary>
/// The campaign's "assert it" rule (plan §FW2: "a merge across a state or
/// stage boundary is forbidden by construction"). <see cref="BuildOrderedMergeRuns"/>
/// only ever EXTENDS a run while stage/bucket/cull/detail all match, so
/// this should never fire — it exists so a future edit to that method's
/// loop condition fails a test immediately instead of silently drawing
/// the wrong material state for part of a run.
/// </summary>
private static void ValidateMergeRun(OrderedDrawStream stream, OrderedMergeRun run)
{
int firstCommand = run.FirstCommand;
WalkDrawStage stage = stream.Stages[firstCommand];
PipelineBucket bucket = BucketFor(stream.Keys[firstCommand].Translucency);
CullMode cull = stream.Keys[firstCommand].CullMode;
bool detail = stream.DetailCategories[firstCommand] != 0;
int end = firstCommand + run.CommandCount;
if (detail && run.CommandCount != 1)
{
throw new InvalidOperationException(
$"Merge run [{firstCommand}, {end}) carries a nonzero DetailCategory but "
+ $"contains {run.CommandCount} commands — a detail-category command must "
+ "emit alone.");
}
for (int i = firstCommand + 1; i < end; i++)
{
if (stream.Stages[i] != stage)
{
throw new InvalidOperationException(
$"Merge run [{firstCommand}, {end}) crosses a WalkDrawStage boundary "
+ $"at command {i} ({stream.Stages[i]} != {stage}) — a merge across a "
+ "stage boundary is forbidden by construction (Campaign FW §FW2).");
}
if (BucketFor(stream.Keys[i].Translucency) != bucket)
{
throw new InvalidOperationException(
$"Merge run [{firstCommand}, {end}) crosses a pipeline boundary at "
+ $"command {i} — a merge across a material-state boundary is "
+ "forbidden by construction (Campaign FW §FW2).");
}
if (stream.Keys[i].CullMode != cull)
{
throw new InvalidOperationException(
$"Merge run [{firstCommand}, {end}) crosses a cull-mode boundary at "
+ $"command {i} — a merge across a material-state boundary is "
+ "forbidden by construction (Campaign FW §FW2).");
}
if (stream.DetailCategories[i] != 0)
{
throw new InvalidOperationException(
$"Merge run [{firstCommand}, {end}) contains a detail-category "
+ $"command at {i} outside a solo run — a detail-category command "
+ "must emit alone (Campaign FW §FW2).");
}
}
}
/// <summary>
/// Submits <paramref name="stream"/> in walk order through the existing
/// RHI: per-instance-first emission (see the type doc comment), one
/// section write per per-instance array, then one
/// <see cref="DrawIndirectRangeRhi"/> call per maximal merge run from
/// <see cref="BuildOrderedMergeRuns"/>. N commands in yield indirect
/// commands <c>[0, N)</c> in stream order, each covered by exactly one
/// emitted run — nothing is reordered, sorted, or dropped.
///
/// <para><paramref name="frame"/> and <paramref name="encoder"/> are
/// caller-supplied rather than pulled from <c>_frames</c>/<c>_scope</c>
/// (contrast <see cref="SubmitRhi"/>'s <c>RequireRhiFrame</c>/
/// <c>scope.RequireEncoder()</c>): the walk submitter draws into whatever
/// pass its caller has open, including an offscreen diagnostic target
/// that never touches the dispatcher's own world-pass scope. The frame's
/// shared clip-region/scene-lighting sections still come from
/// <c>_scope.Sections</c> — those are canonical per-frame published
/// state, not something this submitter owns.</para>
/// </summary>
internal void SubmitOrderedStream(
IGpuFrame frame,
IGpuPassEncoder encoder,
OrderedDrawStream stream,
in Matrix4x4 viewProjection)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(encoder);
ArgumentNullException.ThrowIfNull(stream);
int count = stream.Count;
if (count == 0)
return;
// Fail loud before any GPU work: a PortalPunch command has no FW2
// submission path.
List<OrderedMergeRun> runs = BuildOrderedMergeRuns(stream);
GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer;
if (global is null || !MeshSourceReady())
return;
// Per-instance-first emission — the PrepareDeferredAlphaDraws shape,
// into the SAME per-frame scratch arrays PrepareDeferredAlphaDraws/
// SubmitRhi write. Most are consumed immediately by the ring uploads
// below, but _drawCullModes is NOT write-then-consume: the deferred-
// alpha path reads it at FLUSH time (DrawIndirectRangeRhi's internal
// cull split), so an ordered submission may never interleave between
// RetailAlphaQueue prepare and flush. FW2 has no production caller;
// the FW3 wiring must either sequence around the alpha scope or give
// this path its own cull scratch.
EnsureDeferredAlphaCapacity(count);
for (int i = 0; i < count; i++)
{
GroupKey key = stream.Keys[i];
WriteMatrix(_instanceData, i * 16, stream.Transforms[i]);
_clipSlotData[i] = stream.ClipSlots[i];
_indoorData[i] = stream.IndoorFlags[i];
_detailCategoryData[i] = stream.DetailCategories[i];
_alphaData[i] = stream.Alphas[i];
_selectionLightingData[i] = stream.SelectionLighting[i];
stream.Lights[i].CopyTo(_lightSetData, i * LightManager.MaxLightsPerObject);
_batchData[i] = new BatchData
{
TextureIndex = key.TextureSlot.Index,
TextureLayer = key.TextureLayer,
Flags = 1u | key.FoliageFlags,
};
_indirectCommands[i] = new DrawElementsIndirectCommand
{
Count = (uint)key.IndexCount,
InstanceCount = 1,
FirstIndex = key.FirstIndex,
BaseVertex = key.BaseVertex,
BaseInstance = (uint)i,
};
_drawCullModes[i] = key.CullMode;
}
// Write every section ONCE — the PrepareRhiAlphaSections shape, but
// into locals rather than the persisted _alpha* fields (see the type
// doc comment for why those must stay untouched here).
RhiSection instances = WriteWorldTransformSection(
frame, _instanceData.AsSpan(0, count * 16), out uint transformBaseInstance);
RhiSection batches = WriteRingSection<BatchData>(frame, _batchData.AsSpan(0, count));
RhiSection clipSlots = WriteRingSection<uint>(frame, _clipSlotData.AsSpan(0, count));
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int uploadCount = lightCount > 0 ? lightCount : 1;
RhiSection globalLights = WriteRingSection<float>(
frame,
_globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
RhiSection lightSets = WriteRingSection<int>(
frame, _lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject));
RhiSection indoor = WriteRingSection<uint>(frame, _indoorData.AsSpan(0, count));
RhiSection alpha = WriteRingSection<float>(frame, _alphaData.AsSpan(0, count));
RhiSection selectionLighting = WriteRingSection<Vector2>(
frame, _selectionLightingData.AsSpan(0, count));
RhiSection detailCategory = WriteRingSection<uint>(frame, _detailCategoryData.AsSpan(0, count));
GpuRingAllocation commandsAllocation = WriteIndirectCommands(
frame, _indirectCommands.AsSpan(0, count), transformBaseInstance);
IGpuBuffer commandBuffer = commandsAllocation.Buffer;
uint commandBase = commandsAllocation.OffsetBytes;
MeshPipelineSet pipelines = PipelinesFor(encoder);
var pushConstants = new GpuPushConstants
{
ViewProjection = viewProjection,
DrawIdOffset = 0,
LightingMode = 0,
RenderPass = 0,
LightDebug = RenderingDiagnostics.LightDebugMode,
TextureIndexA = 0,
TextureIndexB = transformBaseInstance,
ParamA = 0f,
ParamB = 0f,
};
// Bind the opaque variant first so the storage/uniform binds below
// land on a live program (SubmitRhi's own rationale) — every mesh
// pipeline shares one layout, so these bindings survive the per-run
// pipeline switches in the loop below.
BindPipelineWithMesh(encoder, pipelines.Opaque, global);
encoder.SetPushConstants(in pushConstants);
BindSection(encoder, GpuBindingModel.StorageInstances, instances);
BindSection(encoder, GpuBindingModel.StorageBatches, batches);
BindSection(encoder, GpuBindingModel.StorageClipSlots, clipSlots);
BindSection(encoder, GpuBindingModel.StorageGlobalLights, globalLights);
BindSection(encoder, GpuBindingModel.StorageInstanceLightSets, lightSets);
BindSection(encoder, GpuBindingModel.StorageInstanceIndoor, indoor);
BindSection(encoder, GpuBindingModel.StorageInstanceAlpha, alpha);
BindSection(encoder, GpuBindingModel.StorageInstanceSelectionLighting, selectionLighting);
BindSection(encoder, GpuBindingModel.StorageInstanceDetailCategory, detailCategory);
AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions(
encoder, _scope!.Sections, frame);
AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting(
encoder, _scope!.Sections, frame);
// One in-order pass over the pre-built merge runs: bind the run's
// pipeline, set RenderPass, draw. DrawIndirectRangeRhi still splits
// internally on _drawCullModes (issue #52's absolute DrawIdOffset per
// sub-call) — every run here already shares one cull mode by
// construction, so that inner split is a no-op here, never a second
// boundary this loop failed to expect.
foreach (OrderedMergeRun run in runs)
{
ValidateMergeRun(stream, run);
PipelineBucket bucket = BucketFor(stream.Keys[run.FirstCommand].Translucency);
IGpuPipeline pipeline = PipelineForBucket(pipelines, bucket);
pushConstants.RenderPass = bucket == PipelineBucket.Opaque ? 0 : 1;
BindPipelineWithMesh(encoder, pipeline, global);
DrawIndirectRangeRhi(
encoder, ref pushConstants, commandBuffer, commandBase,
run.FirstCommand, run.CommandCount);
}
}
}

View file

@ -315,11 +315,12 @@ public sealed unsafe partial class WbDrawDispatcher
/// where <c>ApplyCullMode</c> sets them, because core Vulkan 1.3 makes those /// where <c>ApplyCullMode</c> sets them, because core Vulkan 1.3 makes those
/// dynamic and blend and alpha-to-coverage not. /// dynamic and blend and alpha-to-coverage not.
/// ///
/// <para>Depth compare is <c>Less</c>, not the contract's /// <para>Depth compare is <see cref="AcDream.App.Rendering.WorldDepthContract.WorldCompare"/>
/// <c>LessOrEqual</c> default: the world frame runs under <c>GL_LESS</c> and /// (<c>Less</c>), not the contract's <c>LessOrEqual</c> default — see that
/// this renderer never called <c>glDepthFunc</c>, so it inherited it. Baking /// type for the full citation. The short version: the world frame runs
/// <c>LessOrEqual</c> would change which of two coplanar retail surfaces /// under <c>GL_LESS</c> and this renderer never called <c>glDepthFunc</c>,
/// wins.</para> /// so it inherited it. Baking <c>LessOrEqual</c> would change which of two
/// coplanar retail surfaces wins.</para>
/// </summary> /// </summary>
private static IGpuPipeline CreateMeshPipeline( private static IGpuPipeline CreateMeshPipeline(
IGpuDevice device, IGpuDevice device,
@ -330,7 +331,7 @@ public sealed unsafe partial class WbDrawDispatcher
int sampleCount, int sampleCount,
string shaderName = "mesh_modern", string shaderName = "mesh_modern",
GpuShaderSet? shaders = null, GpuShaderSet? shaders = null,
GpuCompareOp depthCompare = GpuCompareOp.Less, GpuCompareOp depthCompare = AcDream.App.Rendering.WorldDepthContract.WorldCompare,
bool usesRenderPackShaderAbi = false) => bool usesRenderPackShaderAbi = false) =>
device.CreatePipeline(new GpuPipelineDescription device.CreatePipeline(new GpuPipelineDescription
{ {

View file

@ -0,0 +1,47 @@
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering;
/// <summary>
/// Campaign FW stage FW2: the one depth-compare operator every world-space
/// pipeline uses, named instead of repeated as a literal at each of the
/// world's own pipeline-creation sites.
///
/// <para><b>Why <see cref="GpuCompareOp.Less"/>, not
/// <see cref="GpuPipelineDescription"/>'s/<c>GpuDepthState</c>'s own
/// <c>LessOrEqual</c> convention default:</b> the GL-era world frame ran under
/// <c>GL_LESS</c> and never called <c>glDepthFunc</c> to change it, so every
/// world-space renderer inherited <c>GL_LESS</c> by omission rather than by
/// design. <c>LessOrEqual</c> would flip which of two exactly-coplanar retail
/// surfaces wins the depth test — visible wherever terrain meets a road or a
/// building footing, or wherever two retail-authored polygons share a plane.
/// FW2's decomp verification (plan §FW2, read 2026-08-30) confirms retail's
/// own world raster state is <c>D3DCMP_LESS</c>: the .data default
/// <c>Render::zfuncVal</c> @0x00820e1c is <c>0x2</c>, and
/// <c>RenderDeviceD3D::SetDepthBufferMode</c> @0x005a2d10 writes that enum
/// value DIRECTLY as <c>D3DRS_ZFUNC</c> (render state 0x17) — the enum IS
/// <c>D3DCMPFUNC</c>, so <c>0x2 = D3DCMP_LESS</c>. The surface-state applier
/// @0x0059c80a0x0059c866 applies it to all world geometry with Z-write
/// toggled by blend state (on for opaque, off for blended), exactly this
/// pipeline set's per-variant <c>depthWrite</c>. The <c>DEPTHTEST_LESSEQUAL</c>
/// sites in the decomp are SKY-local (<c>GameSky::Draw</c> @0x00506ff0, drawn
/// at 4× zfar) — never world state. So <c>Less</c> is not merely "what the
/// port happened to inherit" — it is retail's actual world depth-compare
/// operator, now named as a citable contract instead of a bare literal
/// repeated at each call site.</para>
///
/// <para>Scope: WORLD-SPACE geometry only (terrain, EnvCell shells, entity
/// meshes, particles, portal punches, the directional shadow caster/receiver
/// pair). The two <c>RetailDetail</c> pipelines
/// (<see cref="RetailDetailTextureContract"/>'s <c>Equal</c>/<c>LessOrEqual</c>
/// pair, used by the building-detail overlay replay) are a documented
/// exception with their own citation and are untouched by this contract.</para>
/// </summary>
internal static class WorldDepthContract
{
/// <summary>Retail's world-space depth-compare operator. See the type
/// doc comment for the full citation; every world pipeline site should
/// reference this constant rather than spelling <c>GpuCompareOp.Less</c>
/// again.</summary>
public const GpuCompareOp WorldCompare = GpuCompareOp.Less;
}

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]);
}
}