diff --git a/MobileGL/MG_Test/Pipe/CMakeLists.txt b/MobileGL/MG_Test/Pipe/CMakeLists.txt index 19dc5e4b..1bd8f0ef 100644 --- a/MobileGL/MG_Test/Pipe/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipe/CMakeLists.txt @@ -24,5 +24,35 @@ if (MSVC) target_compile_options(PipeCatalogueTest PRIVATE /Zc:preprocessor) endif() +# The P1 poison and verify shapes at the block level: a fake GLContext, the real filler and +# accessors out of the static library. Links gtest (not gtest_main): the suite needs its own +# main() to point MOBILEGL_LOG_FILE_PATH at a temp file before anything logs, because its +# abort cases read the Fatal line back out of that file. Every case is a visible SKIP in a +# pull build. +add_executable( + PipeInputsTest + PipeInputsTest.cpp +) + +target_include_directories(PipeInputsTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/MobileGL/MG_Pipe + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries( + PipeInputsTest PRIVATE + GTest::gtest + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(PipeInputsTest PRIVATE /Zc:preprocessor) +endif() + include(GoogleTest) gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +gtest_discover_tests(PipeInputsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp index 42578b3a..c5c720e0 100644 --- a/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp +++ b/MobileGL/MG_Test/Pipe/PipeCatalogueTest.cpp @@ -13,6 +13,7 @@ #include #include +#include #include "Includes.h" #include @@ -233,6 +234,116 @@ TEST(PipeCatalogue, PipeInputFieldsStartUnfilled) { EXPECT_FALSE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters)); } +// G5b: the verb enum is GLFunctionsTable's member list (69 entries), every class has verbs, +// and the seven sticky fields ride in every class mask (P1 brief D7). +TEST(PipeCatalogue, VerbTableIsTheFunctionTable) { + EXPECT_EQ(kMGPipeVerbCount, 69u); + EXPECT_EQ(kMGPipeVerbClassCount, 9u); + SizeT perClass[kMGPipeVerbClassCount] = {}; + for (SizeT v = 0; v < kMGPipeVerbCount; ++v) { + ++perClass[static_cast(kMGPipeVerbClass[v])]; + } + for (SizeT c = 0; c < kMGPipeVerbClassCount; ++c) { + EXPECT_GT(perClass[c], 0u) << kMGPipeVerbClassNames[c]; + for (SizeT f = 0; f < kMGPipeInputFieldCount; ++f) { + if (kMGPipeInputFieldSticky[f]) { + EXPECT_TRUE(MGPipeFieldMaskHas(kMGPipeClassFieldMask[c], static_cast(f))) + << kMGPipeInputFieldNames[f] << " in " << kMGPipeVerbClassNames[c]; + } + } + } + // The class table of D7, spot-checked at its edges: a draw reads the render state, a + // query reads only the paused-primitive counter, and GenerateMipmap is a texture op. + const auto& draw = kMGPipeClassFieldMask[static_cast(MGPipeVerbClass::kDraw)]; + const auto& query = kMGPipeClassFieldMask[static_cast(MGPipeVerbClass::kQuery)]; + EXPECT_TRUE(MGPipeFieldMaskHas(draw, MGPipeInputField::GetRenderStateParameters)); + EXPECT_FALSE(MGPipeFieldMaskHas(query, MGPipeInputField::GetRenderStateParameters)); + EXPECT_TRUE(MGPipeFieldMaskHas(query, MGPipeInputField::GetTransformFeedbackPausedPrimitiveCounter)); + EXPECT_EQ(kMGPipeVerbClass[static_cast(MGPipeVerb::GenerateMipmap)], MGPipeVerbClass::kTextureOp); + EXPECT_STREQ(kMGPipeVerbNames[static_cast(MGPipeVerb::GetGpuTimestampNs)], "GetGpuTimestampNs"); +} + +// The sticky set is exactly the seven forwarded, argument-keyed accessors (P1 brief D6); no +// version or generation accessor is among them. +TEST(PipeCatalogue, StickyFieldsAreExactlyTheSeven) { + const char* const expected[] = {"GetBufferBindingPointCount", "GetProgramObject", "GetTextureObject", + "HasOpenTransformFeedbackSpan", "InvalidateCompileEnv", "ValidateProgramName", + "RecordError"}; + SizeT count = 0; + for (SizeT f = 0; f < kMGPipeInputFieldCount; ++f) { + Bool listed = false; + for (const char* name : expected) { + if (std::strcmp(kMGPipeInputFieldNames[f], name) == 0) listed = true; + } + EXPECT_EQ(kMGPipeInputFieldSticky[f], listed) << kMGPipeInputFieldNames[f]; + if (kMGPipeInputFieldSticky[f]) ++count; + } + EXPECT_EQ(count, 7u); + EXPECT_EQ(kMGPipeInputStickyFieldCount, 7u); + EXPECT_FALSE(kMGPipeInputFieldSticky[static_cast(MGPipeInputField::GetTextureContextId)]); + EXPECT_FALSE(kMGPipeInputFieldSticky[static_cast(MGPipeInputField::GetSamplingResolutionGeneration)]); + EXPECT_FALSE(kMGPipeInputFieldSticky[static_cast(MGPipeInputField::GetPipelineStateVersion)]); +} + +// G4 compares floating point BY BITS (P1 brief D8): a NaN equals itself, a negative zero +// does not equal a positive one, and a vector type inside an Array inside a value struct is +// reached field by field - the differing member of the residual block is named. +TEST(PipeCatalogue, FloatVectorsCompareBitwise) { + const Float nan = std::numeric_limits::quiet_NaN(); + const FloatVec4 a{nan, 1.f, 2.f, 3.f}; + const FloatVec4 b{nan, 1.f, 2.f, 3.f}; + EXPECT_TRUE(MGPipeFieldEqual(a, b)); + EXPECT_FALSE(a == b); // IEEE ==, the comparison the comparator must NOT use + const FloatVec4 zero{0.f, 0.f, 0.f, 0.f}; + const FloatVec4 negativeZero{-0.f, 0.f, 0.f, 0.f}; + EXPECT_FALSE(MGPipeFieldEqual(zero, negativeZero)); + EXPECT_TRUE(zero == negativeZero); + EXPECT_TRUE(MGPipeFieldEqual(1.5f, 1.5f)); + EXPECT_FALSE(MGPipeFieldEqual(-0.f, 0.f)); + + ResidualValueBlock left{}; + ResidualValueBlock right{}; + const char* field = nullptr; + EXPECT_TRUE(MGPipeVerify(left, right, &field)); + right.RenderState.BlendStates[3].SrcFactorRGB = BlendFactor::DstColor; + EXPECT_FALSE(MGPipeVerify(left, right, &field)); + EXPECT_STREQ(field, "RenderState"); + const char* inner = nullptr; + EXPECT_FALSE(MGPipeVerify(left.RenderState, right.RenderState, &inner)); + EXPECT_STREQ(inner, "BlendStates"); + // A NaN patch level in the render state equals itself too. + right = left; + left.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; + right.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f}; + EXPECT_TRUE(MGPipeVerify(left, right, &field)); +} + +// The six value structs have field lists of their own (P1 brief D8): 63 + 6 payloads, and +// the struct that used to memcmp is compared member by member. +TEST(PipeCatalogue, SixValueStructsHaveFieldLists) { + EXPECT_EQ(kMGPipeVerifiedPayloadCount, 69u); + static_assert(MGPipeHasFieldVerifier::value); + static_assert(MGPipeHasFieldVerifier::value); + static_assert(MGPipeHasFieldVerifier::value); + static_assert(MGPipeHasFieldVerifier::value); + static_assert(MGPipeHasFieldVerifier::value); + static_assert(MGPipeHasFieldVerifier::value); + PixelStoreParameters p{}; + PixelStoreParameters q{}; + const char* field = nullptr; + EXPECT_TRUE(MGPipeVerify(p, q, &field)); + q.SkipRows = 2; + EXPECT_FALSE(MGPipeVerify(p, q, &field)); + EXPECT_STREQ(field, "SkipRows"); + MGHostSpan s{}; + MGHostSpan t{}; + t.Pad0 = 0x5A; // padding is not a field + EXPECT_TRUE(MGPipeVerify(s, t, &field)); + t.Offset = 8; + EXPECT_FALSE(MGPipeVerify(s, t, &field)); + EXPECT_STREQ(field, "Offset"); +} + // G7 pins the member list the pipeline/dynamic split is derived from. TEST(PipeCatalogue, PipelineSubsetMembersArePinned) { EXPECT_EQ(kMGPipePipelineStateMemberCount, 24u); diff --git a/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp new file mode 100755 index 00000000..2d6266eb --- /dev/null +++ b/MobileGL/MG_Test/Pipe/PipeInputsTest.cpp @@ -0,0 +1,270 @@ +// MobileGL - MobileGL/MG_Test/Pipe/PipeInputsTest.cpp +// 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 + +// The P1 poison and verify shapes at the block level (P1 brief C.1): a fake GLContext, the +// real filler, the real accessors. Needs the push sources, so every case is a visible SKIP +// in a pull build rather than a vanishing test. The abort cases fork (HeadlessGL.cpp's +// pre-flight shape): the child performs the read that must be Fatal{UnmigratedPipeInput} +// and the parent reads SIGABRT out of waitpid and the exact line out of the log file that +// main() below points MOBILEGL_LOG_FILE_PATH at (LogLevelTest's shape). Never EXPECT_DEATH. + +#include + +#include +#include +#include +#include +#include +#include + +#include "Includes.h" +#include + +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#endif + +#if !defined(_WIN32) +#include +#include +#include +#define MGTEST_HAVE_FORK 1 +#else +#define MGTEST_HAVE_FORK 0 +#endif + +using namespace MobileGL; +using namespace MobileGL::MG_Pipe; + +namespace { + std::string g_logPath; + + std::string ReadLog() { + std::ifstream in(g_logPath, std::ios::binary); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); + } + +#if MOBILEGL_PIPE_PUSH + using GLContext = MG_State::GLState::GLContext; + + // A live frontend context for the filler to read (SanityTest's idiom), restored on the + // way out so the cases stay independent. + class PipeInputsTest : public ::testing::Test { + protected: + void SetUp() override { + m_previous = Move(MG_State::pGLContext); + MG_State::pGLContext = MakeUnique(); + MGPipeSetPoisonOmission(nullptr, nullptr); + } + void TearDown() override { + MGPipeSetPoisonOmission(nullptr, nullptr); + MG_State::pGLContext = Move(m_previous); + } + UniquePtr m_previous; + }; + +#if MOBILEGL_PIPE_POISON + Bool Fresh(MGPipeInputField field) { return MGPipeInputFieldIsFresh(gPipeInputs.FilledState(), field); } +#endif + + [[maybe_unused]] constexpr const char* kOmittedFatal ="Fatal{UnmigratedPipeInput, \"GetActiveTextureUnit@GenerateMipmap\"}"; +#endif // MOBILEGL_PIPE_PUSH +} // namespace + +#if !MOBILEGL_PIPE_PUSH + +TEST(PipeInputsTest,OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,ReadingAnOmittedFieldAbortsNamingTheVerb) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,ReadingAFilledFieldCompletes) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,CorruptedSnapshotFieldIsNamedWithItsSerial) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} +TEST(PipeInputsTest,EveryVerbFillsItsClassAndNothingElse) { + GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)"; +} + +#else // MOBILEGL_PIPE_PUSH + +// Negative control B, layer 1 (P1 brief D6 / G5): the omitted (verb, field) pair is the +// ONLY thing that goes stale - a sibling field of the same verb is fresh, and the verb after +// it neither heals the field (not in kDraw's mask) nor loses one of its own. +TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) { +#if !MOBILEGL_PIPE_POISON + GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)"; +#else + MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + EXPECT_TRUE(Fresh(MGPipeInputField::GetActiveTextureUnit)); + EXPECT_TRUE(Fresh(MGPipeInputField::GetTextureUnitObject)); + + MGPipeSetPoisonOmission("GenerateMipmap", "GetActiveTextureUnit"); + MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + EXPECT_TRUE(Fresh(MGPipeInputField::GetTextureUnitObject)); + EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit)); + // The value was still copied: only the stamp is withheld. + EXPECT_EQ(gPipeInputs.CurrentVerb(), MGPipeVerb::GenerateMipmap); + + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit)); + EXPECT_TRUE(Fresh(MGPipeInputField::GetBoundVertexArray)); + EXPECT_TRUE(Fresh(MGPipeInputField::GetRenderStateParameters)); + // A sticky field stays fresh across every verb. + EXPECT_TRUE(Fresh(MGPipeInputField::RecordError)); + + // And the omission is scoped to its verb: a different verb of the same class keeps it. + MGPipeFillForVerb(MGPipeVerb::BindImageTexture); + EXPECT_TRUE(Fresh(MGPipeInputField::GetActiveTextureUnit)); +#endif +} + +// Negative control B, layer 2 (G5): the read itself. The child fills GenerateMipmap with the +// omission and reads gPipeInputs.GetActiveTextureUnit(); the parent expects SIGABRT and the +// exact Fatal line, and that nothing else was fatal. +TEST_F(PipeInputsTest, ReadingAnOmittedFieldAbortsNamingTheVerb) { +#if !MOBILEGL_PIPE_POISON + GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)"; +#elif !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + const std::string before = ReadLog(); + std::fflush(nullptr); + const pid_t pid = ::fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + // Child: no gtest assertions, _exit never exit. + MGPipeSetPoisonOmission("GenerateMipmap", "GetActiveTextureUnit"); + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + (void)gPipeInputs.GetRenderStateParameters(); // a filled field of the preceding draw: must not abort + MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + (void)gPipeInputs.GetTextureUnitObject(0); // the sibling field: filled, must not abort + (void)gPipeInputs.GetActiveTextureUnit(); // the omitted field: Fatal + ::_exit(3); // reached only if the poison failed + } + int status = 0; + ASSERT_EQ(::waitpid(pid, &status, 0), pid); + ASSERT_TRUE(WIFSIGNALED(status)) << "child exited normally with " << (WIFEXITED(status) ? WEXITSTATUS(status) : -1); + EXPECT_EQ(WTERMSIG(status), SIGABRT); + const std::string log = ReadLog().substr(before.size()); + EXPECT_NE(log.find(kOmittedFatal), std::string::npos) << log; + EXPECT_EQ(log.find("@DrawArrays"), std::string::npos) << log; + EXPECT_EQ(log.find("GetTextureUnitObject@"), std::string::npos) << log; +#endif +} + +// The sibling: the same reads without the omission complete, and no Fatal is logged. +TEST_F(PipeInputsTest, ReadingAFilledFieldCompletes) { +#if !MGTEST_HAVE_FORK + GTEST_SKIP() << "no fork() on this platform"; +#else + ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH"; + const std::string before = ReadLog(); + std::fflush(nullptr); + const pid_t pid = ::fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + (void)gPipeInputs.GetRenderStateParameters(); + MGPipeFillForVerb(MGPipeVerb::GenerateMipmap); + (void)gPipeInputs.GetTextureUnitObject(0); + (void)gPipeInputs.GetActiveTextureUnit(); + ::_exit(0); + } + int status = 0; + ASSERT_EQ(::waitpid(pid, &status, 0), pid); + ASSERT_TRUE(WIFEXITED(status)) << "child died on signal " << (WIFSIGNALED(status) ? WTERMSIG(status) : -1); + EXPECT_EQ(WEXITSTATUS(status), 0); + const std::string log = ReadLog().substr(before.size()); + EXPECT_EQ(log.find("Fatal{"), std::string::npos) << log; +#endif +} + +// Negative control A at the block level (G4): a clean snapshot compares equal to the pushed +// block over the verb's mask; corrupting one field of the snapshot makes the compare name +// exactly that field, at the serial of the fill it belongs to. +TEST_F(PipeInputsTest, CorruptedSnapshotFieldIsNamedWithItsSerial) { +#if !MOBILEGL_PIPE_VERIFY + GTEST_SKIP() << "verify not compiled in (MOBILEGL_PIPE_VERIFY=OFF)"; +#else + const Uint64 serialBefore = gPipeInputs.FilledState().CurrentVerbSerial; + MGPipeFillForVerb(MGPipeVerb::DrawArrays); + const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial; + EXPECT_EQ(serial, serialBefore + 1); + const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast(MGPipeVerbClass::kDraw)]; + + static PipeInputs snapshot{}; + SnapshotFromGLContext(snapshot, mask); + MGPipeInputField field = MGPipeInputField::kFieldCount; + EXPECT_TRUE(MGPipeVerifyInputs(gPipeInputs, snapshot, mask, &field)); + EXPECT_EQ(field, MGPipeInputField::kFieldCount); + + ASSERT_TRUE(MGPipeApplyVerifyCorruption(snapshot, MGPipeInputField::GetRenderStateParameters)); + EXPECT_FALSE(MGPipeVerifyInputs(gPipeInputs, snapshot, mask, &field)); + EXPECT_EQ(field, MGPipeInputField::GetRenderStateParameters); + EXPECT_STREQ(kMGPipeInputFieldNames[static_cast(field)], "GetRenderStateParameters"); + // The serial the report would print is the fill's, and it stamped that field. + EXPECT_EQ(gPipeInputs.FilledState().FilledGen[static_cast(field)], serial); + + // A forwarded field has nothing to corrupt, and the corruption of a field outside the + // mask is not seen by a compare over that mask. + EXPECT_FALSE(MGPipeApplyVerifyCorruption(snapshot, MGPipeInputField::RecordError)); + SnapshotFromGLContext(snapshot, mask); + ASSERT_TRUE(MGPipeApplyVerifyCorruption(snapshot, MGPipeInputField::GetPixelStoreParameters)); + EXPECT_FALSE(MGPipeFieldMaskHas(mask, MGPipeInputField::GetPixelStoreParameters)); + EXPECT_TRUE(MGPipeVerifyInputs(gPipeInputs, snapshot, mask, &field)); +#endif +} + +// Every verb fills exactly its class mask: after a fill, a field is fresh iff its bit is set +// (sticky fields included, since every class mask carries them). +TEST_F(PipeInputsTest, EveryVerbFillsItsClassAndNothingElse) { +#if !MOBILEGL_PIPE_POISON + GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)"; +#else + for (SizeT v = 0; v < kMGPipeVerbCount; ++v) { + const auto verb = static_cast(v); + MGPipeFillForVerb(verb); + const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast(kMGPipeVerbClass[v])]; + for (SizeT f = 0; f < kMGPipeInputFieldCount; ++f) { + const auto field = static_cast(f); + EXPECT_EQ(Fresh(field), MGPipeFieldMaskHas(mask, field)) + << kMGPipeInputFieldNames[f] << " after " << kMGPipeVerbNames[v]; + } + } + EXPECT_EQ(gPipeInputs.ContextIdentity(), static_cast(MG_State::pGLContext.get())); + EXPECT_TRUE(gPipeInputs.IsLive()); +#endif +} + +#endif // MOBILEGL_PIPE_PUSH + +int main(int argc, char** argv) { + // Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first + // write, and caches the FILE*. + namespace fs = std::filesystem; + const fs::path path = fs::temp_directory_path() / "mobilegl-pipeinputs-test.log"; + std::error_code ec; + fs::remove(path, ec); + g_logPath = path.string(); +#if defined(_WIN32) + _putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str()); +#else + setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1); +#endif + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}