From e65644cb337177d090fafd1f3b5647f839d57014 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 12:31:31 +0200 Subject: [PATCH] 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 --- .../2026-08-30-campaign-fw-frame-walk.md | 25 + .../Rendering/DirectionalSunShadowRenderer.cs | 8 +- .../Rendering/ParticleRenderer.Rhi.cs | 28 +- ...dernRenderer.DirectionalShadowReceivers.cs | 5 +- .../Rendering/TerrainModernRenderer.Rhi.cs | 14 +- .../Rendering/Walk/OrderedDrawStream.cs | 166 +++++ .../Rendering/Wb/EnvCellRenderer.Rhi.cs | 12 +- .../Wb/WbDrawDispatcher.OrderedStream.cs | 371 ++++++++++ .../Rendering/Wb/WbDrawDispatcher.Rhi.cs | 13 +- .../Rendering/WorldDepthContract.cs | 47 ++ .../Walk/OrderPreservingSubmitterTests.cs | 634 ++++++++++++++++++ .../Rendering/Walk/OrderedDrawStreamTests.cs | 169 +++++ 12 files changed, 1461 insertions(+), 31 deletions(-) create mode 100644 src/AcDream.App/Rendering/Walk/OrderedDrawStream.cs create mode 100644 src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs create mode 100644 src/AcDream.App/Rendering/WorldDepthContract.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/OrderedDrawStreamTests.cs 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]); + } +}