diff --git a/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp b/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp index 96a072a3..77ea1623 100644 --- a/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp +++ b/MobileGL/MG_Test/Framebuffer/FramebufferTest.cpp @@ -22,6 +22,7 @@ #include #include #include +#include using namespace MobileGL; @@ -1229,6 +1230,11 @@ TEST_F(FramebufferTest, DrawIntoAWidenedDrawBufferReachesTheDriverWithAlphaWrite // What the application asked for: write every channel of every draw buffer. MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + // SyncRenderState is reached from a verb, never on its own: a test that calls it directly + // has to say which verb it stands in, or the block it reads is unfilled and unstamped and + // its first read is Fatal{UnmigratedPipeInput} in a push build. forColorClear=false is the + // draw arm. + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); // What the driver was told. Draw buffer 0 is untouched; draw buffer 1 loses alpha. @@ -1260,14 +1266,22 @@ TEST_F(FramebufferTest, ClearIntoAWidenedDrawBufferKeepsAlphaWritableAndSubstitu MG_Impl::GLImpl::ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); // A draw first, so the mask really is doctored when the clear arrives... - MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); + { + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); + MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); + } ASSERT_EQ(g_driverIndexedColorMasks[1].a, GL_FALSE); // ...and now the clear, with NOTHING changed in the frontend parameter block. The frontend's // render-state version has not moved, so only the purpose-aware memo can force this push - // without it the clear would inherit the draw's alpha-off mask and never write the 1.0. ResetRecordedColorMasks(); - MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true); + { + // A different verb CLASS, so a scope of its own: kClear's fill set is what a clear may + // read, and this half has to go through on that set alone. + MG_Test::ScopedPipeVerb clear(MG_Pipe::MGPipeVerb::Clear); + MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true); + } ASSERT_TRUE(g_driverIndexedColorMasks[1].seen) << "the clear must re-push the colour mask"; EXPECT_EQ(g_driverIndexedColorMasks[1].a, GL_TRUE) << "a clear is what puts the 1.0 in the stored alpha"; @@ -1305,6 +1319,7 @@ TEST_F(FramebufferTest, ApplicationAlphaMaskOffIsStillHonouredOnANativeDrawBuffe MG_Impl::GLImpl::ColorMaski(0, GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE); MG_Impl::GLImpl::ColorMaski(1, GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); MG_Impl::GLImpl::ColorMaski(2, GL_FALSE, GL_TRUE, GL_FALSE, GL_TRUE); + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); EXPECT_EQ(g_driverIndexedColorMasks[0].a, GL_FALSE) << "the application's own alpha mask survives"; @@ -1337,6 +1352,7 @@ TEST_F(FramebufferTest, DualSourceBlendFactorsReachTheDriverWhenTheExtensionIsTh MG_Impl::GLImpl::BlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR); ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR) << "GL_SRC1_* is core since 3.3; glBlendFunc must take it"; ResetRecordedBlend(); + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); ASSERT_TRUE(g_driverBlend[0].factorsSeen); @@ -1357,6 +1373,7 @@ TEST_F(FramebufferTest, DualSourceBlendIsDeclinedRatherThanThrownWhenTheExtensio ResetRecordedBlend(); // The whole point: this used to be `throw std::runtime_error` straight through the GL ABI. + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false)); ASSERT_TRUE(g_driverBlend[0].enableSeen) << "the blend enable still has to be pushed"; @@ -1374,6 +1391,7 @@ TEST_F(FramebufferTest, DualSourceBlendIsDeclinedRatherThanThrownWhenTheExtensio // is what has to push it. MG_Impl::GLImpl::BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); ResetRecordedBlend(); + draw.Renew(); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); ASSERT_TRUE(g_driverBlend[0].factorsSeen); EXPECT_TRUE(g_driverBlend[0].enabled); @@ -1400,6 +1418,7 @@ TEST_F(FramebufferTest, DualSourceFactorsAreDeclinedEvenWithBlendingDisabled) { ASSERT_EQ(MG_Impl::GLImpl::GetError(), GL_NO_ERROR); ResetRecordedBlend(); + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false)); for (Uint i = 0; i < kRecordedDrawBuffers; ++i) { @@ -1416,7 +1435,10 @@ TEST_F(FramebufferTest, DualSourceFactorsAreDeclinedEvenWithBlendingDisabled) { // the flag only steers the alpha-widen colour mask, so it must not reopen this either. MG_Impl::GLImpl::BlendFunc(GL_SRC1_COLOR, GL_ONE_MINUS_SRC1_COLOR); ResetRecordedBlend(); - ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true)); + { + MG_Test::ScopedPipeVerb clear(MG_Pipe::MGPipeVerb::Clear); + ASSERT_NO_THROW(MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/true)); + } for (Uint i = 0; i < kRecordedDrawBuffers; ++i) { EXPECT_NE(g_driverBlend[i].srcRGB, static_cast(GL_SRC1_COLOR)) << "draw buffer " << i; EXPECT_NE(g_driverBlend[i].dstRGB, static_cast(GL_ONE_MINUS_SRC1_COLOR)) << "draw buffer " << i; @@ -1427,6 +1449,7 @@ TEST_F(FramebufferTest, DualSourceFactorsAreDeclinedEvenWithBlendingDisabled) { MG_Impl::GLImpl::Enable(GL_BLEND); MG_Impl::GLImpl::BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); ResetRecordedBlend(); + draw.Renew(); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); ASSERT_TRUE(g_driverBlend[0].factorsSeen); EXPECT_TRUE(g_driverBlend[0].enabled) << "the enable has to be pushed - the shadow said 'off' because it was"; @@ -1444,6 +1467,7 @@ TEST_F(FramebufferTest, DualSourceFactorsWithBlendingDisabledStillReachACapableD MG_Impl::GLImpl::Disable(GL_BLEND); MG_Impl::GLImpl::BlendFunc(GL_SRC1_ALPHA, GL_ONE_MINUS_SRC1_ALPHA); ResetRecordedBlend(); + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); MG_Backend::DirectGLES::RenderStateImpl::SyncRenderState(/*forColorClear=*/false); ASSERT_TRUE(g_driverBlend[0].factorsSeen); diff --git a/MobileGL/MG_Test/SanityTest.cpp b/MobileGL/MG_Test/SanityTest.cpp index 7af54ec0..7fd2a8a2 100644 --- a/MobileGL/MG_Test/SanityTest.cpp +++ b/MobileGL/MG_Test/SanityTest.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -321,7 +322,10 @@ TEST(DirectGLESSanity, BindsAMultisampleTextureDespiteTheDefaultMipmapFilter) { backendTexture = MakeShared(); const GLuint backendTextureId = backendTexture->GetBackendTextureId(); - // The symptom itself: the per-unit walk has to actually bind it. + // The symptom itself: the per-unit walk has to actually bind it. BindCurrentTextures() is + // the per-draw walk - it reads GetProgramForDraw - so the block it reads is the one a draw + // fills; without saying so, its first read is Fatal{UnmigratedPipeInput} in a push build. + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); DirectGLES::BindCurrentTextures(); ASSERT_EQ(state.bindCalls.size(), 1u) << "the multisample texture was not bound; every texelFetch against it reads zero"; @@ -362,6 +366,9 @@ TEST(DirectGLESSanity, BindingZeroClearsPreviousNativeTextureBinding) { backendTexture = MakeShared(); const GLuint backendTextureId = backendTexture->GetBackendTextureId(); + // Each walk below is the texture half of one draw, so each gets its own verb (the second + // and third stand after frontend state moved, exactly as a second entry point's fill would). + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); DirectGLES::BindCurrentTextures(); ASSERT_EQ(state.bindCalls.size(), 1u); EXPECT_EQ(state.bindCalls[0].target, GL_TEXTURE_2D); @@ -369,6 +376,7 @@ TEST(DirectGLESSanity, BindingZeroClearsPreviousNativeTextureBinding) { // The default 1D slot maps to the same native ES target as 2D. It must not clear and force a // redundant rebind while the real 2D frontend object remains current. + draw.Renew(); DirectGLES::BindCurrentTextures(); EXPECT_EQ(state.bindCalls.size(), 1u); @@ -379,6 +387,7 @@ TEST(DirectGLESSanity, BindingZeroClearsPreviousNativeTextureBinding) { .GetBoundObject() .get())); + draw.Renew(); DirectGLES::BindCurrentTextures(); ASSERT_EQ(state.bindCalls.size(), 2u); @@ -3015,7 +3024,9 @@ TEST(DirectGLESTextureSync, UnitMemoRefusesToDriveATwinFromAnotherTexture) { ASSERT_NE(resident, nullptr); // First sync: builds the memo with unit 0 -> `resident`, and gives `resident`'s twin its - // 16x16 backend storage. + // 16x16 backend storage. The unit walk is the per-draw one, so both syncs below stand in a + // draw - the fill is what a real glDraw* would have done before reaching this helper. + MG_Test::ScopedPipeVerb draw(MG_Pipe::MGPipeVerb::DrawArrays); MG_Backend::DirectGLES::TextureImpl::SyncNeccessaryTextures(); auto* residentSlot = MG_Backend::DirectGLES::TextureImpl::g_backendTextureObjects.Find(resident.get()); ASSERT_NE(residentSlot, nullptr); @@ -3031,6 +3042,9 @@ TEST(DirectGLESTextureSync, UnitMemoRefusesToDriveATwinFromAnotherTexture) { MG_State::pGLContext->GetTextureUnitObject(0).GetBindingSlot(TextureTarget::Texture2D).Bind(foreign); const SizeT specsBeforeReplay = specs.size(); + // The second draw. Its fill re-reads the memo's keys off the live context, which is the + // premise being tested: the silent slot swap moved none of them. + draw.Renew(); MG_Backend::DirectGLES::TextureImpl::SyncNeccessaryTextures(); // `foreign` must have been synced through its OWN twin... diff --git a/MobileGL/MG_Test/ScopedPipeVerb.h b/MobileGL/MG_Test/ScopedPipeVerb.h new file mode 100644 index 00000000..1172cf90 --- /dev/null +++ b/MobileGL/MG_Test/ScopedPipeVerb.h @@ -0,0 +1,78 @@ +// MobileGL - MobileGL/MG_Test/ScopedPipeVerb.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include +#if MOBILEGL_PIPE_PUSH +#include +#endif + +namespace MobileGL::MG_Test { + // "This test is standing inside verb X." + // + // A unit test that constructs a GLContext by hand and then calls a BACKEND helper + // directly enters through no GL entry point, so no MGP_FILL ever fires (P1 brief D7) and + // in a push build the PipeInputs block the helper's MGB_CTX reads is empty and unstamped: + // its first accessor read is Fatal{UnmigratedPipeInput, "Field@"}. The test is + // right and the poison is right - what was missing is the verb, and this object is how a + // test states it. Constructing it runs the real filler for `verb`, exactly the call + // MG_Impl makes before that verb reaches a backend; destroying it leaves the verb again. + // + // It is not an escape hatch and it weakens nothing: + // - it fills exactly kMGPipeClassFieldMask[class of verb] out of the live GLContext, so + // a read of a field that verb does not fill is still Fatal, naming the field and this + // verb - the fill table stays the only thing that says what a verb may read; + // - leaving the scope re-arms the poison. The exit fill is a kQuery verb, whose class + // mask is a single field, so every field this scope stamped goes stale the moment the + // scope ends. That matters when the suite runs as one process (a developer running + // the test binary directly, rather than one ctest entry per case): without it, one + // case's declaration would cover a later case that forgot to make one; + // - it is a no-op in the pull build, where MGB_CTX is the live context and there is + // nothing to fill, so the pull build stays byte-identical. + // + // Place it the way MGP_FILL is placed in production (P1 brief D7): immediately BEFORE the + // backend call, after every frontend mutation that call is meant to see. A test that + // drives the helper again with frontend state changed in between issues a second verb - + // Renew() - because that is what a second GL entry point would have done. A test that + // drives a helper of a DIFFERENT verb class opens a nested scope for it. + class ScopedPipeVerb { + public: + explicit ScopedPipeVerb([[maybe_unused]] MG_Pipe::MGPipeVerb verb) +#if MOBILEGL_PIPE_PUSH + : m_verb(verb) { + MG_Pipe::MGPipeFillForVerb(m_verb); + } +#else + { + } +#endif + + ScopedPipeVerb(const ScopedPipeVerb&) = delete; + ScopedPipeVerb& operator=(const ScopedPipeVerb&) = delete; + + // A second verb of the same kind begins: re-fill and re-stamp, so a helper driven + // again after the test moved frontend state sees the new values, the way the next GL + // entry point's MGP_FILL would. + void Renew() { +#if MOBILEGL_PIPE_PUSH + MG_Pipe::MGPipeFillForVerb(m_verb); +#endif + } + +#if MOBILEGL_PIPE_PUSH + ~ScopedPipeVerb() { MG_Pipe::MGPipeFillForVerb(kLeaveVerb); } + + private: + // Leaving is a fill of the narrowest verb class there is: kQuery names one field, so + // the serial bump lands and every field this scope stamped falls behind it. The one + // field is a transform-feedback counter read - no side effect, and nothing to undo. + static constexpr MG_Pipe::MGPipeVerb kLeaveVerb = MG_Pipe::MGPipeVerb::GetGpuTimestampNs; + MG_Pipe::MGPipeVerb m_verb; +#endif + }; +} // namespace MobileGL::MG_Test diff --git a/MobileGL/MG_Test/Texture/TextureTest.cpp b/MobileGL/MG_Test/Texture/TextureTest.cpp index e1aa29f8..7bd51045 100644 --- a/MobileGL/MG_Test/Texture/TextureTest.cpp +++ b/MobileGL/MG_Test/Texture/TextureTest.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -4477,6 +4478,10 @@ TEST_F(TextureTest, StorePackedWordsToClientCopiesWordsVerbatimUnderPackParams) MG_Impl::GLImpl::PixelStorei(GL_PACK_ALIGNMENT, 8); // rows of 3 words (12 B) pad to 16 B MG_Impl::GLImpl::PixelStorei(GL_PACK_SKIP_ROWS, 1); MG_Impl::GLImpl::PixelStorei(GL_PACK_SKIP_PIXELS, 1); + // The store reads the PACK block out of the frontend, and it is only ever reached from a + // readback verb: a test that calls it directly says so, or the block is unfilled and the + // first read is Fatal{UnmigratedPipeInput} in a push build. + MG_Test::ScopedPipeVerb readback(MG_Pipe::MGPipeVerb::ReadPixels); ASSERT_TRUE(ReadbackImpl::StorePackedWordsToClient(reinterpret_cast(source), /*width=*/3, /*sliceHeight=*/2, /*sliceCount=*/1, GL_UNSIGNED_INT_5_9_9_9_REV, destination, @@ -4499,6 +4504,8 @@ TEST_F(TextureTest, StorePackedWordsToClientCopiesWordsVerbatimUnderPackParams) MG_Impl::GLImpl::PixelStorei(GL_PACK_SKIP_PIXELS, 0); MG_Impl::GLImpl::PixelStorei(GL_PACK_ALIGNMENT, 1); MG_Impl::GLImpl::PixelStorei(GL_PACK_SWAP_BYTES, GL_TRUE); + // A second readback, after the PACK parameters moved. + readback.Renew(); ASSERT_TRUE(ReadbackImpl::StorePackedWordsToClient(reinterpret_cast(source), /*width=*/3, /*sliceHeight=*/1, /*sliceCount=*/1, GL_UNSIGNED_INT_5_9_9_9_REV, destination,