From ad1238bd6f5f940b2fe924f48c6ed81259c3e208 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:00:04 -0400 Subject: [PATCH] [Test] (Pipe): require a named pipeline member to be WHOLLY pipeline, drive every indexed setter at index 0, and walk the incremental chunk path - ChunkTablePartitionsTheBlock asserted only that a named pipeline member is TOUCHED by the pipeline half. A boundary that demotes part of one - four bytes at the head of BlendStates, i.e. the glEnablei(GL_BLEND, 0) bit - left the case green except for the two byte-count constants, which P3 will legitimately move; after that a partial demotion would have been invisible, and its consequence is one CSO handle serving two different pipeline states. Now IsWhollyPipeline for every named member, with StencilStates the one documented straddler. - Every indexed setter is driven at index 0 as well as at a middle index. Index 0 is the element both backends consume and the one a head-of-array boundary demotes first; with it, the demotion above also fails SetterConsistency, naming the setter. - The round trip D2 rests on is asserted: the assembled block is memcmp-equal to the live one. That is the only cover for the ~25 members with no derived field at all - SampleCoverage*, SampleMaskValue, PolygonModeBack, the hints, ScissorBoxes[1..15], ClipDistanceEnabledMask and the raw capability bools - which is exactly the set Espryt's SyncRenderState reads through its span memcmp. - IncrementalChunksKeepEveryDerivedFieldInStep: the shape the tracker actually emits. A create_render_state naming only the pipeline chunks that moved against a BaseCso (D7 step 2's miss path) and a set_dynamic_state naming only the dynamic chunks that moved (D8's suppressor), ten steps plus all 35 capabilities one at a time, each followed by the full derived-field comparison. It is the first caller of the base-inherit branch, of MGPipeScatterPipelineChunks, MGPipePipelineChunkBlobBytes, MGPipe{Dynamic,Pipeline}ChunksThatMoved and MGPipeHashPipelineBytes, and it is the oracle for the applier's chunk-scoped derivation - a whole-block apply asks for every chunk and so cannot tell a correct guard from one that is too narrow. - The 25 kDraw comparisons move into ExpectDerivedDrawFieldsMatch, shared by the whole-block walk and the incremental one. --- .../MG_Test/Pipe/RenderStateSpansTest.cpp | 350 +++++++++++++++--- 1 file changed, 293 insertions(+), 57 deletions(-) diff --git a/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp index 8afe544c..7295f46d 100644 --- a/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp +++ b/MobileGL/MG_Test/Pipe/RenderStateSpansTest.cpp @@ -90,6 +90,91 @@ namespace { } Bool IsWhollyPipeline(SizeT offset, SizeT size) { return PipelineBytesOf(offset, size) == size; } + + // --------------------------------------------------------------------------------- + // D5's 25 kDraw derivations against the frontend getters they were transcribed from. + // Factored out because two cases need exactly this comparison: the whole-block walk + // below, and the INCREMENTAL walk that drives the applier's chunk-scoped derivation one + // family at a time. Everything here is a kDraw fill point (MG_Pipe/FillPoints.def), so + // the caller must be inside a kDraw verb; the three clear values and GetClampReadColor + // belong to kClear and kReadback and are checked in their own phases. + // --------------------------------------------------------------------------------- + void ExpectDerivedDrawFieldsMatch(GLContext& ctx, const char* tag) { + SCOPED_TRACE(tag); + EXPECT_EQ(gPipeInputs.GetBlendColor(), ctx.GetBlendColor()); + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + BlendEquation gotColor{}, gotAlpha{}, wantColor{}, wantAlpha{}; + gPipeInputs.GetBlendEquationIndexed(i, gotColor, gotAlpha); + ctx.GetBlendEquationIndexed(i, wantColor, wantAlpha); + EXPECT_EQ(gotColor, wantColor) << "blend equation " << i; + EXPECT_EQ(gotAlpha, wantAlpha) << "blend equation " << i; + + BlendFactor gotSrcRGB{}, gotDstRGB{}, gotSrcA{}, gotDstA{}; + BlendFactor wantSrcRGB{}, wantDstRGB{}, wantSrcA{}, wantDstA{}; + gPipeInputs.GetBlendFuncIndexed(i, gotSrcRGB, gotDstRGB, gotSrcA, gotDstA); + ctx.GetBlendFuncIndexed(i, wantSrcRGB, wantDstRGB, wantSrcA, wantDstA); + EXPECT_EQ(gotSrcRGB, wantSrcRGB) << "blend func " << i; + EXPECT_EQ(gotDstRGB, wantDstRGB) << "blend func " << i; + EXPECT_EQ(gotSrcA, wantSrcA) << "blend func " << i; + EXPECT_EQ(gotDstA, wantDstA) << "blend func " << i; + + EXPECT_EQ(gPipeInputs.GetColorMaskIndexed(i), ctx.GetColorMaskIndexed(i)) << "colour mask " << i; + EXPECT_EQ(gPipeInputs.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i), + ctx.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i)) + << "indexed blend enable " << i; + } + EXPECT_EQ(gPipeInputs.GetCullFaceMode(), ctx.GetCullFaceMode()); + EXPECT_EQ(gPipeInputs.GetDepthFunc(), ctx.GetDepthFunc()); + EXPECT_EQ(gPipeInputs.GetDepthMask(), ctx.GetDepthMask()); + for (Uint i = 0; i < RenderStateParameters::MAX_VIEWPORTS; ++i) { + EXPECT_EQ(gPipeInputs.GetDepthRangeIndexed(i), ctx.GetDepthRangeIndexed(i)) << "depth range " << i; + EXPECT_EQ(gPipeInputs.GetViewportIndexed(i), ctx.GetViewportIndexed(i)) << "viewport " << i; + EXPECT_EQ(gPipeInputs.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i), + ctx.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i)) + << "indexed scissor enable " << i; + } + EXPECT_EQ(gPipeInputs.GetLineWidth(), ctx.GetLineWidth()); + EXPECT_EQ(gPipeInputs.GetLogicOp(), ctx.GetLogicOp()); + EXPECT_EQ(gPipeInputs.GetMinSampleShadingValue(), ctx.GetMinSampleShadingValue()); + EXPECT_EQ(gPipeInputs.GetPatchDefaultInnerLevel(), ctx.GetPatchDefaultInnerLevel()); + EXPECT_EQ(gPipeInputs.GetPatchDefaultOuterLevel(), ctx.GetPatchDefaultOuterLevel()); + EXPECT_EQ(gPipeInputs.GetPatchVertices(), ctx.GetPatchVertices()); + EXPECT_EQ(gPipeInputs.GetPolygonModeFront(), ctx.GetPolygonModeFront()); + EXPECT_EQ(gPipeInputs.GetPolygonOffsetFactor(), ctx.GetPolygonOffsetFactor()); + EXPECT_EQ(gPipeInputs.GetPolygonOffsetUnits(), ctx.GetPolygonOffsetUnits()); + EXPECT_EQ(gPipeInputs.GetPrimitiveRestartIndex(), ctx.GetPrimitiveRestartIndex()); + EXPECT_EQ(gPipeInputs.GetProvokingVertexMode(), ctx.GetProvokingVertexMode()); + EXPECT_EQ(gPipeInputs.GetScissorBox(), ctx.GetScissorBox()); + EXPECT_EQ(gPipeInputs.GetViewport(), ctx.GetViewport()); + for (const StencilFace face : {StencilFace::Front, StencilFace::Back}) { + const StencilFaceState& got = gPipeInputs.GetStencilState(face); + const StencilFaceState& want = ctx.GetStencilState(face); + EXPECT_EQ(std::memcmp(&got, &want, sizeof(StencilFaceState)), 0) + << "stencil face " << static_cast(face); + } + for (SizeT i = 0; i < static_cast(CapabilityInput::CapabilityInputCount); ++i) { + const CapabilityInput cap = static_cast(i); + EXPECT_EQ(gPipeInputs.IsCapabilityEnabled(cap), ctx.IsCapabilityEnabled(cap)) << "capability " << i; + } + } + + // THE ROUND TRIP D2's whole argument rests on: "the block they read IS the assembled + // block" (ARCHITECTURE.md 5.3), which is what lets Espryt's SyncRenderState stay + // untouched. The 29 derived fields cover barely half of RenderStateParameters; the other + // ~25 members - SampleCoverageValue/Invert, SampleMaskValue, PolygonModeBack, PointSize, + // PointFadeThresholdSize, PointSpriteCoordOrigin, the four hints, ClipOrigin, + // ClipDepthMode, PolygonOffsetClamp, FrontFaceModeSetting, ScissorBoxes[1..15], + // ScissorBoxWrittenMask, ClipDistanceEnabledMask and the raw capability bools - have no + // derived field at all and are read RAW, through Espryt's span memcmp. This one line is + // the only thing in the suite that covers them. + void ExpectAssembledBlockIsTheLiveBlock(GLContext& ctx, const char* tag) { + SCOPED_TRACE(tag); + EXPECT_EQ(std::memcmp(&gPipeInputs.GetRenderStateParameters(), &ctx.GetRenderStateParameters(), + sizeof(RenderStateParameters)), + 0) + << "the assembled working block is not byte-identical to the live one - a chunk of " + "RenderStateParameters is not being carried, and Espryt reads those bytes raw"; + } #endif // MOBILEGL_PIPE_PUSH // ------------------------------------------------------------------------------------- @@ -136,8 +221,26 @@ namespace { const SizeT pipelineBytes = PipelineBytesOf(member.Offset, member.Size); if (pipelineNames.count(member.Name) != 0) { ++namedFound; - EXPECT_GT(pipelineBytes, SizeT{0}) - << member.Name << " is named as pipeline state but no pipeline chunk covers it"; + // WHOLLY pipeline, not merely touched by a pipeline chunk. A named member + // with only SOME of its bytes in the pipeline half is the silent form of the + // bug this suite exists to prevent: the demoted bytes drop out of the CSO's + // content-addressed identity, so one handle serves two different pipeline + // states and the same cached VkPipeline draws with, say, draw buffer 0's + // blend enable set both ways. The subset hash cannot see it - a hash over + // the wrong bytes is still a hash - and the setter walk cannot see it either + // as long as SOME byte of the member stayed pipeline, because the version + // and the hash then still move together. + // + // StencilStates is the ONE member allowed to straddle, by design and at + // sub-member granularity; the loop below pins its split face by face. + if (std::strcmp(member.Name, "StencilStates") == 0) { + EXPECT_GT(pipelineBytes, SizeT{0}) << "StencilStates has no pipeline bytes at all"; + continue; + } + EXPECT_TRUE(IsWhollyPipeline(member.Offset, member.Size)) + << member.Name << " is named as pipeline state but only " << pipelineBytes << " of its " + << member.Size << " bytes are in the pipeline half - the rest have silently left the " + "CSO's identity"; } else { EXPECT_EQ(pipelineBytes, SizeT{0}) << member.Name << " is not named as pipeline state but " << pipelineBytes @@ -198,6 +301,14 @@ namespace { // ---- Rasterization ---- check("SetViewport", [](RenderState& s) { s.SetViewport(IntVec4(1, 2, 30, 40)); }); check("SetViewportIndexed", [](RenderState& s) { s.SetViewportIndexed(3, FloatVec4(4.f, 5.f, 60.f, 70.f)); }); + // EVERY indexed setter is driven at INDEX 0 as well as at a middle index, and index 0 + // is the one that matters most: it is the element both backends actually consume + // (IsCapabilityEnabled(Blend) is BlendStates[0].Enabled, GetScissorBox/GetViewport + // answer for rectangle 0) and it is the element a chunk boundary landing at the HEAD + // of an array demotes first. A walk that only ever touches index 3 cannot tell a + // boundary that swallowed index 0 from a correct table. + check("SetViewportIndexed(0)", + [](RenderState& s) { s.SetViewportIndexed(0, FloatVec4(0.5f, 1.5f, 31.5f, 41.5f)); }); check("SetLineWidth", [](RenderState& s) { s.SetLineWidth(3.5f); }); check("SetPointSize", [](RenderState& s) { s.SetPointSize(7.25f); }); check("SetPatchVertices", [](RenderState& s) { s.SetPatchVertices(4); }); @@ -279,6 +390,13 @@ namespace { [](RenderState& s) { s.SetCapabilityIndexed(CapabilityInput::Blend, 3, false); }); check("SetCapabilityIndexed(ScissorTest, 5)", [](RenderState& s) { s.SetCapabilityIndexed(CapabilityInput::ScissorTest, 5, false); }); + // Index 0 of both: BlendStates[0].Enabled is the single bit glEnable(GL_BLEND) + // answers for and the first four bytes of chunk P1, and ScissorTestEnabledMask bit 0 + // is what DynamicTailKey::scissorEnabled reads. + check("SetCapabilityIndexed(Blend, 0)", + [](RenderState& s) { s.SetCapabilityIndexed(CapabilityInput::Blend, 0, false); }); + check("SetCapabilityIndexed(ScissorTest, 0)", + [](RenderState& s) { s.SetCapabilityIndexed(CapabilityInput::ScissorTest, 0, false); }); // ---- Blending ---- check("SetBlendFunc", [](RenderState& s) { @@ -288,10 +406,16 @@ namespace { s.SetBlendFuncIndexed(2, BlendFactor::DstColor, BlendFactor::SrcColor, BlendFactor::DstAlpha, BlendFactor::SrcAlpha); }); + check("SetBlendFuncIndexed(0)", [](RenderState& s) { + s.SetBlendFuncIndexed(0, BlendFactor::ConstantColor, BlendFactor::ConstantAlpha, BlendFactor::OneMinusDstColor, + BlendFactor::OneMinusDstAlpha); + }); check("SetBlendEquation", [](RenderState& s) { s.SetBlendEquation(BlendEquation::Subtract, BlendEquation::Min); }); check("SetBlendEquationIndexed", [](RenderState& s) { s.SetBlendEquationIndexed(4, BlendEquation::ReverseSubtract, BlendEquation::Max); }); + check("SetBlendEquationIndexed(0)", + [](RenderState& s) { s.SetBlendEquationIndexed(0, BlendEquation::Max, BlendEquation::Add); }); check("SetLogicOp", [](RenderState& s) { s.SetLogicOp(LogicOperation::Xor); }); // ---- Depth and stencil ---- @@ -325,12 +449,15 @@ namespace { // ---- Colour mask, clear state, sampling ---- check("SetColorMask", [](RenderState& s) { s.SetColorMask(BoolVec4(true, false, true, false)); }); check("SetColorMaskIndexed", [](RenderState& s) { s.SetColorMaskIndexed(6, BoolVec4(false, false, true, true)); }); + check("SetColorMaskIndexed(0)", + [](RenderState& s) { s.SetColorMaskIndexed(0, BoolVec4(false, true, false, true)); }); check("SetClearColor", [](RenderState& s) { s.SetClearColor(FloatVec4(0.1f, 0.2f, 0.3f, 0.4f)); }); check("SetClearDepth", [](RenderState& s) { s.SetClearDepth(0.75f); }); check("SetClearStencil", [](RenderState& s) { s.SetClearStencil(9); }); check("SetBlendColor", [](RenderState& s) { s.SetBlendColor(FloatVec4(0.5f, 0.6f, 0.7f, 0.8f)); }); check("SetDepthRange", [](RenderState& s) { s.SetDepthRange(FloatVec2(0.25f, 0.75f)); }); check("SetDepthRangeIndexed", [](RenderState& s) { s.SetDepthRangeIndexed(9, FloatVec2(0.1f, 0.9f)); }); + check("SetDepthRangeIndexed(0)", [](RenderState& s) { s.SetDepthRangeIndexed(0, FloatVec2(0.3f, 0.6f)); }); // SetSampleCoverage calls BumpVersions(), so under the rule it is PIPELINE state - // which is why MGPipeTypes.h's MGPDynamicState comment no longer claims otherwise. check("SetSampleCoverage", [](RenderState& s) { s.SetSampleCoverage(0.375f, true); }); @@ -347,6 +474,7 @@ namespace { check("SetScissorBox(first write)", [](RenderState& s) { s.SetScissorBox(IntVec4(1, 2, 3, 4)); }); check("SetScissorBox(again)", [](RenderState& s) { s.SetScissorBox(IntVec4(5, 6, 7, 8)); }); check("SetScissorBoxIndexed", [](RenderState& s) { s.SetScissorBoxIndexed(11, IntVec4(9, 10, 11, 12)); }); + check("SetScissorBoxIndexed(0)", [](RenderState& s) { s.SetScissorBoxIndexed(0, IntVec4(13, 14, 15, 16)); }); // SetPixelStoreParam moves NEITHER counter and touches no byte of // RenderStateParameters: the pixel store lives in its own two structs and travels as @@ -479,67 +607,16 @@ namespace { applyWholeBlock(); // ---- the 25 kDraw fields ---- - EXPECT_EQ(gPipeInputs.GetBlendColor(), ctx.GetBlendColor()); - for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { - BlendEquation gotColor{}, gotAlpha{}, wantColor{}, wantAlpha{}; - gPipeInputs.GetBlendEquationIndexed(i, gotColor, gotAlpha); - ctx.GetBlendEquationIndexed(i, wantColor, wantAlpha); - EXPECT_EQ(gotColor, wantColor) << "blend equation " << i; - EXPECT_EQ(gotAlpha, wantAlpha) << "blend equation " << i; - - BlendFactor gotSrcRGB{}, gotDstRGB{}, gotSrcA{}, gotDstA{}; - BlendFactor wantSrcRGB{}, wantDstRGB{}, wantSrcA{}, wantDstA{}; - gPipeInputs.GetBlendFuncIndexed(i, gotSrcRGB, gotDstRGB, gotSrcA, gotDstA); - ctx.GetBlendFuncIndexed(i, wantSrcRGB, wantDstRGB, wantSrcA, wantDstA); - EXPECT_EQ(gotSrcRGB, wantSrcRGB) << "blend func " << i; - EXPECT_EQ(gotDstRGB, wantDstRGB) << "blend func " << i; - EXPECT_EQ(gotSrcA, wantSrcA) << "blend func " << i; - EXPECT_EQ(gotDstA, wantDstA) << "blend func " << i; - - EXPECT_EQ(gPipeInputs.GetColorMaskIndexed(i), ctx.GetColorMaskIndexed(i)) << "colour mask " << i; - EXPECT_EQ(gPipeInputs.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i), - ctx.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i)) - << "indexed blend enable " << i; - } - EXPECT_EQ(gPipeInputs.GetCullFaceMode(), ctx.GetCullFaceMode()); - EXPECT_EQ(gPipeInputs.GetDepthFunc(), ctx.GetDepthFunc()); - EXPECT_EQ(gPipeInputs.GetDepthMask(), ctx.GetDepthMask()); - for (Uint i = 0; i < RenderStateParameters::MAX_VIEWPORTS; ++i) { - EXPECT_EQ(gPipeInputs.GetDepthRangeIndexed(i), ctx.GetDepthRangeIndexed(i)) << "depth range " << i; - EXPECT_EQ(gPipeInputs.GetViewportIndexed(i), ctx.GetViewportIndexed(i)) << "viewport " << i; - EXPECT_EQ(gPipeInputs.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i), - ctx.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i)) - << "indexed scissor enable " << i; - } - EXPECT_EQ(gPipeInputs.GetLineWidth(), ctx.GetLineWidth()); - EXPECT_EQ(gPipeInputs.GetLogicOp(), ctx.GetLogicOp()); - EXPECT_EQ(gPipeInputs.GetMinSampleShadingValue(), ctx.GetMinSampleShadingValue()); - EXPECT_EQ(gPipeInputs.GetPatchDefaultInnerLevel(), ctx.GetPatchDefaultInnerLevel()); - EXPECT_EQ(gPipeInputs.GetPatchDefaultOuterLevel(), ctx.GetPatchDefaultOuterLevel()); - EXPECT_EQ(gPipeInputs.GetPatchVertices(), ctx.GetPatchVertices()); - EXPECT_EQ(gPipeInputs.GetPolygonModeFront(), ctx.GetPolygonModeFront()); - EXPECT_EQ(gPipeInputs.GetPolygonOffsetFactor(), ctx.GetPolygonOffsetFactor()); - EXPECT_EQ(gPipeInputs.GetPolygonOffsetUnits(), ctx.GetPolygonOffsetUnits()); - EXPECT_EQ(gPipeInputs.GetPrimitiveRestartIndex(), ctx.GetPrimitiveRestartIndex()); - EXPECT_EQ(gPipeInputs.GetProvokingVertexMode(), ctx.GetProvokingVertexMode()); - EXPECT_EQ(gPipeInputs.GetScissorBox(), ctx.GetScissorBox()); - for (const StencilFace face : {StencilFace::Front, StencilFace::Back}) { - const StencilFaceState& got = gPipeInputs.GetStencilState(face); - const StencilFaceState& want = ctx.GetStencilState(face); - EXPECT_EQ(std::memcmp(&got, &want, sizeof(StencilFaceState)), 0) - << "stencil face " << static_cast(face); - } + ExpectDerivedDrawFieldsMatch(ctx, "whole block, kDraw"); + // ...and the ~25 members that have NO derived field, which only a byte compare of the + // whole block reaches. + ExpectAssembledBlockIsTheLiveBlock(ctx, "whole block, kDraw"); // The rounding half of GetViewport, exercised on purpose: viewport 0 is // (1.5, 2.5, 63.5, 32.25), so a transcription that truncated instead of rounding // would hand the backends a 63-wide rectangle where 64 was asked for. The literal // is std::lround's answer - round half AWAY FROM ZERO, so 1.5 -> 2 and 2.5 -> 3, // not the banker's rounding a nearbyint() transcription would give. - EXPECT_EQ(gPipeInputs.GetViewport(), ctx.GetViewport()); EXPECT_EQ(gPipeInputs.GetViewport(), IntVec4(2, 3, 64, 32)); - for (SizeT i = 0; i < static_cast(CapabilityInput::CapabilityInputCount); ++i) { - const CapabilityInput cap = static_cast(i); - EXPECT_EQ(gPipeInputs.IsCapabilityEnabled(cap), ctx.IsCapabilityEnabled(cap)) << "capability " << i; - } } // phase 1, kDraw // Phase 2, kClear: the three clear values. The verb's own fill runs FIRST and copies @@ -626,6 +703,165 @@ namespace { "scissorEnabled input and this expectation both need re-reading"; EXPECT_TRUE(IsWhollyPipeline(offsetof(RenderStateParameters, ScissorTestEnabledMask), sizeof(RenderStateParameters::ScissorTestEnabledMask))); +#endif + } + + // ------------------------------------------------------------------------------------- + // 5. The INCREMENTAL path, which is the shape the tracker actually emits: a + // create_render_state naming only the pipeline chunks that moved against a BaseCso + // (D7 step 2's miss path), and a set_dynamic_state naming only the dynamic chunks that + // moved (D8's chunk-level suppressor). Nothing but this case enters + // MGPipeApplyCreateRenderState's base-inherit branch, and nothing but this case drives + // the applier's CHUNK-SCOPED derivation - a whole-block apply asks for every chunk and + // so cannot tell a correctly scoped guard from one that is too narrow. + // ------------------------------------------------------------------------------------- + TEST(RenderStateSpans, IncrementalChunksKeepEveryDerivedFieldInStep) { +#if !MOBILEGL_PIPE_PUSH + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +#else + struct ContextGuard { + UniquePtr Previous; + ContextGuard() : Previous(Move(MG_State::pGLContext)) { + MG_State::pGLContext = MakeUnique(); + MGPipeApplierReset(); + } + ~ContextGuard() { + MGPipeApplierReset(); + MG_State::pGLContext = Move(Previous); + } + } guard; + GLContext& ctx = *MG_State::pGLContext; + + // Every read below is a kDraw fill point, so one verb covers the whole case. The fill + // runs at construction against the DEFAULT context, which is what makes the first + // step's comparison meaningful rather than a comparison of the filler with itself. + MG_Test::ScopedPipeVerb verb(MGPipeVerb::DrawArrays); + + // `staged` is the tracker's "what the server has" mirror (D8). The masks are computed + // from it exactly as the tracker will compute them. + RenderStateParameters staged{}; + MGPipeHandle previousCso = kMGPipeNullHandle; + Uint32 nextSlot = kMGPipeFirstAllocatableSlot; + Bool firstPush = true; + + const auto push = [&](const char* tag) { + const RenderStateParameters& live = ctx.GetRenderStateParameters(); + const Uint32 movedPipeline = + firstPush ? static_cast((Uint64{1} << kMGPipePipelineChunkCount) - 1) + : MGPipePipelineChunksThatMoved(staged, live); + const Uint32 movedDynamic = + firstPush ? static_cast((Uint64{1} << kMGPipeDynamicChunkCount) - 1) + : MGPipeDynamicChunksThatMoved(staged, live); + + if (movedPipeline != 0) { + Vector blob(MGPipePipelineChunkBlobBytes(movedPipeline)); + MGPipeGatherPipelineChunks(live, movedPipeline, blob.data()); + MGPRenderStateDesc desc{}; + desc.Cso = MGPipeHandle{nextSlot++, 0}; + // The base-inherit branch: everything this desc does NOT name has to come + // from the record the client is pointing at. + desc.BaseCso = previousCso; + desc.ChunkMask = movedPipeline; + MGPipeApplyCreateRenderState(desc, blob.data()); + + MGPBindRenderState bind{}; + bind.Cso = desc.Cso; + bind.Version = static_cast(ctx.GetRenderStateParametersVersion()); + bind.PipelineVersion = static_cast(ctx.GetPipelineStateVersion()); + MGPipeApplyBindRenderState(bind); + previousCso = desc.Cso; + + // The reconstructed record must be the WHOLE pipeline half of the live block, + // byte for byte: an incremental create that inherited the wrong chunk would + // otherwise only show up as a wrong pixel much later, on the first bind that + // scatters it. This is also MGPipeHashPipelineBytes' only caller - and its + // agreement with the from-scratch hash is what lets CsoCache hash the bytes + // it already holds instead of re-gathering them. + Array gathered{}; + MGPipeGatherPipelineBytes(live, gathered.data()); + const MGPipeRenderStateCsoRecord& record = MGPipeApplier().RenderStateCsos[desc.Cso.Slot]; + EXPECT_EQ(std::memcmp(record.PipelineBytes.data(), gathered.data(), kMGPipePipelineChunkBytes), 0) + << tag << ": the incrementally created CSO is not the live pipeline half"; + EXPECT_EQ(MGPipeHashPipelineBytes(gathered.data()), MGPipeComputePipelineSubsetHash(live)) + << tag << ": hashing the gathered bytes disagrees with hashing the block"; + } + + if (movedDynamic != 0) { + Vector blob(MGPipeDynamicChunkBlobBytes(movedDynamic)); + MGPipeGatherDynamicChunks(live, movedDynamic, blob.data()); + MGPDynamicState dyn{}; + dyn.ChunkMask = movedDynamic; + dyn.Version = static_cast(ctx.GetRenderStateParametersVersion()); + MGPipeApplySetDynamicState(dyn, blob.data()); + } + + staged = live; + firstPush = false; + // Every derived field, after a scatter that named only the chunks that moved. A + // derivation guard that is too narrow leaves the previous step's value standing + // and this is where it shows. + ExpectDerivedDrawFieldsMatch(ctx, tag); + ExpectAssembledBlockIsTheLiveBlock(ctx, tag); + }; + + // Step 0: the whole block, so every later step is a genuine delta. + ctx.SetViewportIndexed(0, FloatVec4(1.5f, 2.5f, 63.5f, 32.25f)); + push("step 0: the whole block"); + + // One step per derivation guard, each moving as few chunks as the setter allows. + ctx.SetViewportIndexed(0, FloatVec4(4.f, 5.f, 60.f, 70.f)); + ctx.SetViewportIndexed(7, FloatVec4(8.f, 9.f, 10.f, 11.f)); + push("step 1: viewports only (dynamic chunk D0)"); + + ctx.SetDepthRangeIndexed(3, FloatVec2(0.2f, 0.8f)); + push("step 2: depth ranges only (dynamic chunk D2)"); + + ctx.SetBlendFuncIndexed(0, BlendFactor::DstColor, BlendFactor::SrcColor, BlendFactor::DstAlpha, + BlendFactor::SrcAlpha); + ctx.SetBlendEquationIndexed(5, BlendEquation::ReverseSubtract, BlendEquation::Max); + ctx.SetColorMaskIndexed(0, BoolVec4(false, true, false, true)); + push("step 3: blend and colour mask (pipeline chunk P1)"); + + ctx.SetCapabilityIndexed(CapabilityInput::Blend, 0, true); + push("step 4: indexed blend enable at index 0"); + + ctx.SetCapabilityIndexed(CapabilityInput::ScissorTest, 2, true); + push("step 5: indexed scissor enable (pipeline chunk P6)"); + + ctx.SetScissorBox(IntVec4(3, 4, 5, 6)); + push("step 6: scissor rectangles (dynamic chunk D7)"); + + ctx.SetStencilFunc(StencilFace::Front, DepthTestFunc::Equal, 7, 0xf0u); + ctx.SetStencilOp(StencilFace::Back, StencilOperation::Replace, StencilOperation::IncrementClamp, + StencilOperation::DecrementWrap); + ctx.SetStencilMask(StencilFace::Back, 0x0fu); + push("step 7: the stencil faces, which straddle four chunks"); + + ctx.SetLineWidth(3.5f); + ctx.SetPolygonOffsetClamped(1.5f, 2.5f, 0.25f); + ctx.SetLogicOp(LogicOperation::Xor); + ctx.SetDepthFunc(DepthTestFunc::GreaterEqual); + ctx.SetDepthMask(false); + ctx.SetCullFaceMode(CullFaceMode::Front); + ctx.SetProvokingVertexMode(ProvokingVertexMode::FirstVertex); + ctx.SetPrimitiveRestartIndex(0xabcdu); + ctx.SetMinSampleShadingValue(0.625f); + ctx.SetPatchVertices(4); + ctx.SetPatchDefaultOuterLevel(FloatVec4(2.f, 3.f, 4.f, 5.f)); + ctx.SetPatchDefaultInnerLevel(FloatVec2(6.f, 7.f)); + ctx.SetPolygonMode(GL_LINE, GL_POINT); + push("step 8: the unguarded scalars"); + + // EVERY capability, one at a time. This is what pins the capability walk's guard + // exhaustively: the 25 plain bools sit in three different pipeline chunks, Blend is + // BlendStates[0].Enabled, ScissorTest is a pipeline mask and the eight ClipDistances + // are a DYNAMIC mask - so a guard that named only "the capability chunk" would leave + // one of those families stale, and the flip that reaches it fails here by name. + for (SizeT i = 0; i < static_cast(CapabilityInput::CapabilityInputCount); ++i) { + const CapabilityInput cap = static_cast(i); + ctx.SetCapability(cap, !ctx.IsCapabilityEnabled(cap)); + push(("step 9: capability " + std::to_string(i)).c_str()); + } #endif } } // namespace