diff --git a/docs/plans/2026-08-30-campaign-fw-frame-walk.md b/docs/plans/2026-08-30-campaign-fw-frame-walk.md
index 5ff39160..78870782 100644
--- a/docs/plans/2026-08-30-campaign-fw-frame-walk.md
+++ b/docs/plans/2026-08-30-campaign-fw-frame-walk.md
@@ -218,6 +218,31 @@ oracle traces exactly.
vs the old builder are adjudicated against the ORACLE, not against the
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 F67–F79
+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`
**Goal:** walk-order submission through the existing RHI, proven
diff --git a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
index 75e9518c..b43d745f 100644
--- a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
+++ b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
@@ -1073,6 +1073,12 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
});
}
+ ///
+ /// The caster's own depth-only pipeline. Depth compare is
+ /// — see that type for the
+ /// world-space GL_LESS citation this shares with every other world
+ /// pipeline.
+ ///
private static IGpuPipeline CreatePipeline(
IGpuDevice device,
string name,
@@ -1087,7 +1093,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
VertexLayout = layout,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = GpuBlendMode.None,
- Depth = new GpuDepthState(true, true, GpuCompareOp.Less),
+ Depth = new GpuDepthState(true, true, WorldDepthContract.WorldCompare),
Cull = GpuCullMode.Back,
FrontFace = frontFace,
AlphaToCoverage = false,
diff --git a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
index cd68e206..f4870ff9 100644
--- a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
+++ b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs
@@ -232,12 +232,14 @@ public sealed unsafe partial class ParticleRenderer
/// which is the GL arm's bracket verbatim
/// (Enable(DepthTest)/DepthMask(false)/Disable(CullFace)).
///
- /// Depth compare is Less, not the contract's LessOrEqual
- /// default: the world frame runs under GL_LESS and this renderer never
- /// called glDepthFunc, so it inherited it. Alpha-to-coverage is off
- /// for the same kind of reason and the opposite way round — the frame-global
- /// state controller disables it and only WbDrawDispatcher's opaque
- /// bracket turns it on, so particles have never drawn with it.
+ /// Depth compare is
+ /// (Less), not the contract's LessOrEqual default — see that
+ /// type for the full citation. The world frame runs under GL_LESS
+ /// and this renderer never called glDepthFunc, so it inherited it.
+ /// Alpha-to-coverage is off for the same kind of reason and the opposite
+ /// way round — the frame-global state controller disables it and only
+ /// WbDrawDispatcher's opaque bracket turns it on, so particles have
+ /// never drawn with it.
///
private static IGpuPipeline CreateBillboardPipeline(
IGpuDevice device,
@@ -251,7 +253,7 @@ public sealed unsafe partial class ParticleRenderer
VertexLayout = BillboardVertexLayout,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend,
- Depth = new GpuDepthState(Test: true, Write: false, GpuCompareOp.Less),
+ Depth = new GpuDepthState(Test: true, Write: false, WorldDepthContract.WorldCompare),
Cull = GpuCullMode.None,
FrontFace = GpuFrontFace.CounterClockwise,
AlphaToCoverage = false,
@@ -260,10 +262,12 @@ public sealed unsafe partial class ParticleRenderer
});
///
- /// One mesh-particle pipeline. Same depth bracket as the billboards; the
- /// winding is CW because PrepareMeshPipeline sets
- /// glFrontFace(GL_CW), and the cull mode stays DYNAMIC because it is
- /// resolved per sub-batch from the DAT's own CullMode.
+ /// One mesh-particle pipeline. Same depth bracket as the billboards (see
+ /// for the world
+ /// GL_LESS citation); the winding is CW because
+ /// PrepareMeshPipeline sets glFrontFace(GL_CW), and the cull
+ /// mode stays DYNAMIC because it is resolved per sub-batch from the DAT's
+ /// own CullMode.
///
private static IGpuPipeline CreateMeshParticlePipeline(
IGpuDevice device,
@@ -277,7 +281,7 @@ public sealed unsafe partial class ParticleRenderer
VertexLayout = MeshVertexLayout,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend,
- Depth = new GpuDepthState(Test: true, Write: false, GpuCompareOp.Less),
+ Depth = new GpuDepthState(Test: true, Write: false, WorldDepthContract.WorldCompare),
Cull = GpuCullMode.None,
FrontFace = GpuFrontFace.Clockwise,
AlphaToCoverage = false,
diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadowReceivers.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadowReceivers.cs
index 8ec70656..97a10be4 100644
--- a/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadowReceivers.cs
+++ b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadowReceivers.cs
@@ -41,7 +41,10 @@ public sealed partial class TerrainModernRenderer
VertexLayout = TerrainVertexLayout,
Topology = GpuPrimitiveTopology.TriangleList,
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,
FrontFace = GpuFrontFace.CounterClockwise,
AlphaToCoverage = false,
diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs
index 0d9a0f0d..4ca62c12 100644
--- a/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs
+++ b/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs
@@ -85,12 +85,14 @@ public sealed unsafe partial class TerrainModernRenderer
VertexLayout = TerrainVertexLayout,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = GpuBlendMode.None,
- // GL_LESS, not the contract's LessOrEqual default: the world frame
- // runs under GL_LESS and terrain never called glDepthFunc, so it
- // inherited it. LessOrEqual would change which of two coplanar retail
- // surfaces wins — visible exactly where terrain meets roads and
- // building footings, which is what zFightTerrainAdjust is about.
- Depth = new GpuDepthState(Test: true, Write: true, GpuCompareOp.Less),
+ // WorldDepthContract.WorldCompare (GL_LESS), not the contract's
+ // LessOrEqual default: the world frame runs under GL_LESS and
+ // terrain never called glDepthFunc, so it inherited it. LessOrEqual
+ // would change which of two coplanar retail surfaces wins — visible
+ // exactly where terrain meets roads and building footings, which is
+ // 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
// Draw for the full reasoning; this bakes the same triple.
Cull = GpuCullMode.Back,
diff --git a/src/AcDream.App/Rendering/Walk/OrderedDrawStream.cs b/src/AcDream.App/Rendering/Walk/OrderedDrawStream.cs
new file mode 100644
index 00000000..97939e41
--- /dev/null
+++ b/src/AcDream.App/Rendering/Walk/OrderedDrawStream.cs
@@ -0,0 +1,166 @@
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+
+namespace AcDream.App.Rendering.Walk;
+
+///
+/// Campaign FW stage FW2 — retail's frame phases, in the order the walk
+/// visits them. RetailPViewPassExecutor's packed route contract
+/// (RenderFrameCandidateRoute: LandscapeOutdoorStatic →
+/// LandscapeBuildingShell → LookInObject → LandscapeOutsideDynamic →
+/// CellStatic → DynamicLast — WbDrawDispatcher.PackedOracle.cs:108/171
+/// 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 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).
+///
+internal enum WalkDrawStage : byte
+{
+ /// LScape::draw @0x00506330 /
+ /// LScape::grab_visible_cells @0x00504EC0 — outdoor terrain.
+ Terrain,
+
+ /// An indoor PView::DrawCells @0x005A4840 flood's static
+ /// geometry: EnvCell shells plus the static meshes they contain.
+ CellStatic,
+
+ /// DrawBuilding's (RenderDeviceD3D::DrawBuilding
+ /// @0x0059f2a0) exterior shell pass.
+ BuildingShell,
+
+ ///
+ /// DrawPortalPolyInternal'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.
+ ///
+ /// No FW2 submission path exists yet.
+ /// throws
+ /// 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.
+ ///
+ PortalPunch,
+
+ /// ConstructView(CBldPortal) look-in static geometry —
+ /// what an exterior building's window or doorway reveals of its own
+ /// interior.
+ LookInStatic,
+
+ /// Every non-static draw the walk visits last: entities,
+ /// monsters, items, and the meshes particles ride.
+ Dynamic,
+}
+
+///
+/// One walk-ordered draw command. The nine fields after and
+/// are exactly the per-instance data
+/// WbDrawDispatcher.PrepareDeferredAlphaDraws's
+/// DeferredAlphaInstance carries — that method is the per-instance-first
+/// SSBO-layout template FW2's submitter follows — plus the walk provenance
+/// (, ) the submitter needs to know
+/// where a merge run may and may not cross a boundary.
+///
+/// Mesh-subset and material identity: index range, texture
+/// slot/layer, translucency, foliage flags, cull mode. The same
+/// the classic material-bucketed path groups instances
+/// by — FW2 does not bucket by it, only reads its fields per instance.
+/// World transform. Storage binding 0
+/// (StorageInstances).
+/// The retail frame phase this command belongs to. A
+/// merge run may never span two different stages.
+/// 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.
+/// Storage binding 3 (StorageClipSlots).
+/// Storage binding 5 (StorageInstanceLightSets).
+/// Storage binding 6 (StorageInstanceIndoor).
+/// Storage binding 7 (StorageInstanceAlpha).
+/// Storage binding 8
+/// (StorageInstanceSelectionLighting).
+/// Storage binding 9
+/// (StorageInstanceDetailCategory). A nonzero value forces this
+/// command into a solo merge run — mirrors the deferred-alpha detail break in
+/// WbDrawDispatcher.DrawPreparedAlphaBatchRhi.
+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);
+
+///
+/// Append-only, walk-ordered draw-command stream. Struct-of-arrays storage —
+/// one parallel list per field, the same
+/// shape as 's per-instance lists
+/// — so can walk the stream
+/// by index instead of allocating one boxed command per instance.
+///
+/// The stream carries no ordering logic of its own: reading it back is
+/// exactly the sequence 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.
+///
+internal sealed class OrderedDrawStream
+{
+ public readonly List Keys = new();
+ public readonly List Transforms = new();
+ public readonly List Stages = new();
+ public readonly List CellIds = new();
+ public readonly List ClipSlots = new();
+ public readonly List Lights = new();
+ public readonly List IndoorFlags = new();
+ public readonly List Alphas = new();
+ public readonly List SelectionLighting = new();
+ public readonly List DetailCategories = new();
+
+ /// Number of commands appended since the last .
+ 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);
+ }
+
+ ///
+ /// Clears every parallel list together, in one method. The established
+ /// #193 lesson (WbDrawDispatcher.InstanceGroup.ClearPerInstanceData
+ /// 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.
+ ///
+ public void Reset()
+ {
+ Keys.Clear();
+ Transforms.Clear();
+ Stages.Clear();
+ CellIds.Clear();
+ ClipSlots.Clear();
+ Lights.Clear();
+ IndoorFlags.Clear();
+ Alphas.Clear();
+ SelectionLighting.Clear();
+ DetailCategories.Clear();
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs
index bf51a132..8154724e 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs
@@ -88,10 +88,12 @@ public sealed unsafe partial class EnvCellRenderer
/// shared: mesh_modern, the 32-byte world-mesh vertex, triangle lists,
/// back-face culling with clockwise front faces.
///
- /// Depth compare is Less, not the contract's LessOrEqual
- /// default. The world frame runs under GL_LESS and this renderer never
- /// called glDepthFunc, so it inherited it; baking LessOrEqual
- /// would change which of two coplanar retail surfaces wins.
+ /// Depth compare is
+ /// (Less), not the contract's LessOrEqual default — see that
+ /// type for the full citation. The world frame runs under GL_LESS
+ /// and this renderer never called glDepthFunc, so it inherited it;
+ /// baking LessOrEqual would change which of two coplanar retail
+ /// surfaces wins.
///
private static IGpuPipeline CreateShellPipeline(
IGpuDevice device,
@@ -100,7 +102,7 @@ public sealed unsafe partial class EnvCellRenderer
bool depthWrite,
int sampleCount,
string shaderName = "mesh_modern",
- GpuCompareOp depthCompare = GpuCompareOp.Less) =>
+ GpuCompareOp depthCompare = AcDream.App.Rendering.WorldDepthContract.WorldCompare) =>
device.CreatePipeline(new GpuPipelineDescription
{
Name = name,
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs
new file mode 100644
index 00000000..37c8a9fa
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs
@@ -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;
+
+///
+/// Campaign FW stage FW2: 's submitter.
+///
+/// Walk-order submission through the SAME RHI machinery
+/// WbDrawDispatcher.Rhi.cs already owns — the ring-section writers,
+/// , and — is
+/// why this is a partial of rather than a
+/// standalone class. Two shapes are reused directly:
+/// 's per-instance-first emission
+/// (command i owns exactly one instance, BaseInstance = i, so
+/// walk order — never material bucketing — survives into the indirect array)
+/// and 's "write every section once"
+/// shape (locals here, not the persisted _alpha* fields: those belong
+/// to , which can still be mid-flight in the
+/// same frame, and overwriting them would corrupt that replay).
+///
+/// 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 (DrawBuildingDetailRangeRhi's second
+/// pass through RetailDetail/RetailDetailTransparent) — a
+/// detail-category command still forces a solo merge run (mirroring the
+/// deferred-alpha detail break), but 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.
+///
+public sealed unsafe partial class WbDrawDispatcher
+{
+ ///
+ /// One walk-order merge run: a maximal, in-order span of commands that
+ /// share a , a resolved pipeline, and a cull
+ /// mode, built by .
+ ///
+ internal readonly record struct OrderedMergeRun(int FirstCommand, int CommandCount);
+
+ ///
+ /// The four pipeline buckets a translucency kind resolves to, independent
+ /// of any live instance.
+ /// selects between Opaque and OpaqueAlphaToCoverage 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 that resolves to. That is what lets
+ /// stay pure CPU logic, testable
+ /// without a live GPU device — the same separation
+ /// already draws between layout and RHI
+ /// glue.
+ ///
+ 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,
+ };
+
+ ///
+ /// Builds the maximal in-order merge runs for .
+ /// 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.
+ ///
+ /// A run extends from command i to j while every
+ /// command in [i, j) shares the same ,
+ /// the same , the same ,
+ /// and none carries a nonzero DetailCategory — a detail-category
+ /// command always emits alone, mirroring
+ /// DrawPreparedAlphaBatchRhi's hasDetail break. Never
+ /// reorders or drops anything: every command in
+ /// belongs to exactly one returned run, in stream order.
+ ///
+ /// Fails loud before building any run:
+ /// 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.
+ ///
+ internal static List 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();
+ 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;
+ }
+
+ ///
+ /// The campaign's "assert it" rule (plan §FW2: "a merge across a state or
+ /// stage boundary is forbidden by construction").
+ /// 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.
+ ///
+ 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).");
+ }
+ }
+ }
+
+ ///
+ /// Submits 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
+ /// call per maximal merge run from
+ /// . N commands in yield indirect
+ /// commands [0, N) in stream order, each covered by exactly one
+ /// emitted run — nothing is reordered, sorted, or dropped.
+ ///
+ /// and are
+ /// caller-supplied rather than pulled from _frames/_scope
+ /// (contrast 's RequireRhiFrame/
+ /// scope.RequireEncoder()): 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
+ /// _scope.Sections — those are canonical per-frame published
+ /// state, not something this submitter owns.
+ ///
+ 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 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(frame, _batchData.AsSpan(0, count));
+ RhiSection clipSlots = WriteRingSection(frame, _clipSlotData.AsSpan(0, count));
+ int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
+ int uploadCount = lightCount > 0 ? lightCount : 1;
+ RhiSection globalLights = WriteRingSection(
+ frame,
+ _globalLightData.AsSpan(0, uploadCount * GlobalLightPacker.FloatsPerLight));
+ RhiSection lightSets = WriteRingSection(
+ frame, _lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject));
+ RhiSection indoor = WriteRingSection(frame, _indoorData.AsSpan(0, count));
+ RhiSection alpha = WriteRingSection(frame, _alphaData.AsSpan(0, count));
+ RhiSection selectionLighting = WriteRingSection(
+ frame, _selectionLightingData.AsSpan(0, count));
+ RhiSection detailCategory = WriteRingSection(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);
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
index 2d9fac4b..4aabfa8d 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
@@ -315,11 +315,12 @@ public sealed unsafe partial class WbDrawDispatcher
/// where ApplyCullMode sets them, because core Vulkan 1.3 makes those
/// dynamic and blend and alpha-to-coverage not.
///
- /// Depth compare is Less, not the contract's
- /// LessOrEqual default: the world frame runs under GL_LESS and
- /// this renderer never called glDepthFunc, so it inherited it. Baking
- /// LessOrEqual would change which of two coplanar retail surfaces
- /// wins.
+ /// Depth compare is
+ /// (Less), not the contract's LessOrEqual default — see that
+ /// type for the full citation. The short version: the world frame runs
+ /// under GL_LESS and this renderer never called glDepthFunc,
+ /// so it inherited it. Baking LessOrEqual would change which of two
+ /// coplanar retail surfaces wins.
///
private static IGpuPipeline CreateMeshPipeline(
IGpuDevice device,
@@ -330,7 +331,7 @@ public sealed unsafe partial class WbDrawDispatcher
int sampleCount,
string shaderName = "mesh_modern",
GpuShaderSet? shaders = null,
- GpuCompareOp depthCompare = GpuCompareOp.Less,
+ GpuCompareOp depthCompare = AcDream.App.Rendering.WorldDepthContract.WorldCompare,
bool usesRenderPackShaderAbi = false) =>
device.CreatePipeline(new GpuPipelineDescription
{
diff --git a/src/AcDream.App/Rendering/WorldDepthContract.cs b/src/AcDream.App/Rendering/WorldDepthContract.cs
new file mode 100644
index 00000000..e0c260be
--- /dev/null
+++ b/src/AcDream.App/Rendering/WorldDepthContract.cs
@@ -0,0 +1,47 @@
+using AcDream.App.Rendering.Gpu;
+
+namespace AcDream.App.Rendering;
+
+///
+/// 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.
+///
+/// Why , not
+/// 's/GpuDepthState's own
+/// LessOrEqual convention default: the GL-era world frame ran under
+/// GL_LESS and never called glDepthFunc to change it, so every
+/// world-space renderer inherited GL_LESS by omission rather than by
+/// design. LessOrEqual 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 D3DCMP_LESS: the .data default
+/// Render::zfuncVal @0x00820e1c is 0x2, and
+/// RenderDeviceD3D::SetDepthBufferMode @0x005a2d10 writes that enum
+/// value DIRECTLY as D3DRS_ZFUNC (render state 0x17) — the enum IS
+/// D3DCMPFUNC, so 0x2 = D3DCMP_LESS. The surface-state applier
+/// @0x0059c80a–0x0059c866 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 depthWrite. The DEPTHTEST_LESSEQUAL
+/// sites in the decomp are SKY-local (GameSky::Draw @0x00506ff0, drawn
+/// at 4× zfar) — never world state. So Less 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.
+///
+/// Scope: WORLD-SPACE geometry only (terrain, EnvCell shells, entity
+/// meshes, particles, portal punches, the directional shadow caster/receiver
+/// pair). The two RetailDetail pipelines
+/// ('s Equal/LessOrEqual
+/// pair, used by the building-detail overlay replay) are a documented
+/// exception with their own citation and are untouched by this contract.
+///
+internal static class WorldDepthContract
+{
+ /// 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 GpuCompareOp.Less
+ /// again.
+ public const GpuCompareOp WorldCompare = GpuCompareOp.Less;
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs
new file mode 100644
index 00000000..605f1b61
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs
@@ -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;
+
+///
+/// Campaign FW stage FW2:
+/// (pure CPU merge-run legality) and
+/// (the same legality proven through actual recorded RHI calls against
+/// ).
+///
+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 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 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 runs =
+ WbDrawDispatcher.BuildOrderedMergeRuns(stream);
+
+ Assert.Equal(
+ [
+ new WbDrawDispatcher.OrderedMergeRun(0, 2),
+ new WbDrawDispatcher.OrderedMergeRun(2, 1),
+ ],
+ runs);
+ }
+
+ ///
+ /// The load-bearing new assertion: two commands whose material state
+ /// (bucket, cull mode, detail category) is IDENTICAL still split into two
+ /// runs when their differs. Nothing about the
+ /// deferred-alpha template this submitter borrows from ever had to
+ /// consider stage — walk order introduces it.
+ ///
+ [Fact]
+ public void BuildOrderedMergeRuns_SplitsOnAStageChangeEvenWithIdenticalMaterialState()
+ {
+ OrderedDrawStream stream = StreamOf(
+ MakeCommand(0, stage: WalkDrawStage.Terrain),
+ MakeCommand(1, stage: WalkDrawStage.CellStatic));
+
+ List 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 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(
+ () => 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 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 cullCalls =
+ [.. fx.Device.Calls.OfType().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(
+ () => fx.Dispatcher.SubmitOrderedStream(
+ draw.Frame, draw.Pass, stream, Matrix4x4.Identity));
+
+ Assert.Empty(fx.Device.Calls.OfType());
+ Assert.Empty(fx.Device.Calls.OfType());
+ }
+
+ [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());
+ }
+
+ ///
+ /// 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.
+ ///
+ [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.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; }
+
+ /// Opens a frame and a backbuffer pass, publishes it on
+ /// , then clears the recorded calls so a test only
+ /// sees what its own SubmitOrderedStream call produced.
+ 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 CellRegions { get; } =
+ new(new Dictionary());
+
+ public IDatDatabase HighRes => _highRes;
+
+ public IDatDatabase Language => _language;
+
+ public IDatDatabase Local => _language;
+
+ public ReadOnlyDictionary RegionFileMap { get; } =
+ new(new Dictionary());
+
+ 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 GetAllIdsOfType() where T : IDBObj =>
+ Array.Empty();
+
+ public IEnumerable ResolveId(uint id) =>
+ Array.Empty();
+
+ public bool TrySave(T obj, int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ public bool TrySave(
+ uint regionId,
+ T obj,
+ int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ [return: MaybeNull]
+ public T Get(uint fileId) where T : IDBObj => default;
+
+ public bool TryGet(
+ 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 GetAllIdsOfType() where T : IDBObj =>
+ Array.Empty();
+
+ public bool TryGet(
+ 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 obj, int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ public void Dispose()
+ {
+ }
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/OrderedDrawStreamTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/OrderedDrawStreamTests.cs
new file mode 100644
index 00000000..b79158a3
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Walk/OrderedDrawStreamTests.cs
@@ -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;
+
+///
+/// Campaign FW stage FW2: pure data-structure tests for
+/// . No GPU device, no dispatcher — this is the
+/// append-only struct-of-arrays storage on its own.
+///
+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]);
+ }
+}