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

@ -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(
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,

View file

@ -232,12 +232,14 @@ public sealed unsafe partial class ParticleRenderer
/// which is the GL arm's bracket verbatim
/// (<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>
/// default: the world frame runs under <c>GL_LESS</c> and this renderer never
/// called <c>glDepthFunc</c>, 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 <c>WbDrawDispatcher</c>'s opaque
/// bracket turns it on, so particles have never drawn with it.</para>
/// <para>Depth compare is <see cref="AcDream.App.Rendering.WorldDepthContract.WorldCompare"/>
/// (<c>Less</c>), not the contract's <c>LessOrEqual</c> default — see that
/// type for the full citation. The world frame runs under <c>GL_LESS</c>
/// and this renderer never called <c>glDepthFunc</c>, 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
/// <c>WbDrawDispatcher</c>'s opaque bracket turns it on, so particles have
/// never drawn with it.</para>
/// </summary>
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
});
/// <summary>
/// One mesh-particle pipeline. Same depth bracket as the billboards; the
/// winding is CW because <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>.
/// One mesh-particle pipeline. Same depth bracket as the billboards (see
/// <see cref="AcDream.App.Rendering.WorldDepthContract"/> for the world
/// <c>GL_LESS</c> citation); the winding is CW because
/// <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>
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,

View file

@ -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,

View file

@ -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,

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,
/// back-face culling with clockwise front faces.
///
/// <para>Depth compare is <c>Less</c>, not the contract's <c>LessOrEqual</c>
/// default. The world frame runs under <c>GL_LESS</c> 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>
/// <para>Depth compare is <see cref="AcDream.App.Rendering.WorldDepthContract.WorldCompare"/>
/// (<c>Less</c>), not the contract's <c>LessOrEqual</c> default — see that
/// type for the full citation. The world frame runs under <c>GL_LESS</c>
/// 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>
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,

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
/// dynamic and blend and alpha-to-coverage not.
///
/// <para>Depth compare is <c>Less</c>, not the contract's
/// <c>LessOrEqual</c> default: the world frame runs under <c>GL_LESS</c> 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>
/// <para>Depth compare is <see cref="AcDream.App.Rendering.WorldDepthContract.WorldCompare"/>
/// (<c>Less</c>), not the contract's <c>LessOrEqual</c> default — see that
/// type for the full citation. The short version: the world frame runs
/// under <c>GL_LESS</c> 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>
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
{

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