diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index ad8718c0..ffd79a3c 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -6722,6 +6722,15 @@ namespace MobileGL::MG_Backend::DirectGLES { strippedAny, strippedIoBlockLocationSpirv, enableSpirvValidation) && !strippedIoBlockLocationSpirv.empty() && strippedAny) { effectiveSpirv = &strippedIoBlockLocationSpirv; + // THE ARMING SIGNAL, and it is INFO on purpose: the per-stage line below is + // MGLOG_D, which is compiled out of every build CI and the device runs, so + // nothing outside a debug build could tell an armed repair from a silently + // un-armed one. Latched, so it costs one line per process rather than one + // per stage of every program. The integration lane that pins the emulation + // on asserts on exactly this line - see UnlocatedIoBlockScenario. + MGLOG_I_ONCE("DirectGLES is emitting inter-stage interface blocks WITHOUT their " + "layout(location) qualifier, because this driver loses a located " + "block's payload across a tessellation or geometry boundary."); MGLOG_D("Program %u stage %s: interface-block location qualifiers dropped " "(%s), because this driver loses a located block's payload across a " "tessellation or geometry boundary.", diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index ef204087..99996c6a 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -353,8 +353,15 @@ mgl_itest_join_environment(MGL_ITEST_VULKAN_OPTIMISTIC_ENVIRONMENT "MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS=1" ${MGL_ITEST_VULKAN_ENV}) mgl_itest_join_environment(MGL_ITEST_GLES_NO_VIEWPORT_EMULATION_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0" ${MGL_ITEST_COMMON_ENV}) +# MOBILEGL_LOG_FILE_PATH alongside the pin, because the arming assertion needs somewhere to +# read the library's own report from. The strip's arming signal is a latched MGLOG_I and there +# is no other way for a test process to learn that it fired - MG_Config is not reachable from +# this module on Android, where it links the shipping library. The path is per-lane so nothing +# else appends to it, and the case only trusts the bytes written after it started. mgl_itest_join_environment(MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT - "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1" ${MGL_ITEST_COMMON_ENV}) + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/unlocated-io-blocks.log" + ${MGL_ITEST_COMMON_ENV}) # TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it. set(MGL_ITEST_TIMEOUT 120) diff --git a/MobileGL/MG_IntegrationTest/Scenarios/UnlocatedIoBlockScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/UnlocatedIoBlockScenario.cpp index c3cc5804..0da0321e 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/UnlocatedIoBlockScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/UnlocatedIoBlockScenario.cpp @@ -41,6 +41,11 @@ // black - the pipeline ran, the plain varying arrived, and the BLOCK payload came back // zeroed. That is what an interface whose two ends stopped matching looks like. +#include +#include +#include +#include +#include #include #include @@ -345,6 +350,37 @@ void main() const std::string& BuildLog() const { return m_buildLog; } + // The library log this process is writing, or an empty path when none was + // configured. MOBILEGL_LOG_FILE_PATH is read at log-init, before anything this + // fixture can reach, so the ctest entry sets it and this only reads it back. + static std::filesystem::path LibraryLogPath() { + const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH"); + return (path != nullptr && *path != '\0') ? std::filesystem::path(path) + : std::filesystem::path(); + } + + // How many bytes the library log already holds. Everything this fixture asserts on + // is searched from here forward, because the file is APPENDED to by every process + // in the lane and a line left behind by an earlier one would otherwise satisfy the + // assertion without this process having done anything at all. + static std::uintmax_t LibraryLogSize() { + std::error_code ec; + const std::filesystem::path path = LibraryLogPath(); + if (path.empty()) return 0; + const std::uintmax_t size = std::filesystem::file_size(path, ec); + return ec ? 0 : size; + } + + static std::string LibraryLogSince(std::uintmax_t offset) { + const std::filesystem::path path = LibraryLogPath(); + if (path.empty()) return {}; + std::ifstream file(path, std::ios::binary); + if (!file.good()) return {}; + file.seekg(static_cast(offset)); + return std::string((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + } + static GLenum FirstGLError() { const GLenum first = glGetError(); while (glGetError() != GL_NO_ERROR) { @@ -417,5 +453,74 @@ void main() << "the renamed-and-unlocated interface chain lost its payload: " << centre; } + // THE ONE CASE THAT CAN FAIL WHEN THE REPAIR SILENTLY STOPS BEING ARMED. + // + // Everything above renders green on llvmpipe whether the blocks were stripped or not - + // this machine carries a located block correctly - so those cases pin that the strip + // does no HARM and can say nothing about whether it happened. That leaves the arming + // itself untested, and the arming is where the cheap mistake lives: Loader.cpp maps + // MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS onto the capability INVERTED (forcing the + // emulation on means declaring located blocks UNSUPPORTED), and a one-line swap of + // those two arms would disable the device repair with every test here still green. + // + // So this case asserts a LIBRARY OBSERVABLE against the environment, the shape + // AsyncCompileScenario::ExtensionStringMatchesTheConfiguration uses: the environment + // says the emulation is pinned on, therefore the library must SAY it stripped + // something. The observable is the latched MGLOG_I DirectGLES emits the first time the + // pass fires (Managers.cpp); it is INFO rather than DEBUG precisely so that this + // assertion is possible in the builds CI runs. + // + // Two things it deliberately does NOT do: it does not read MG_Config (on Android this + // module links the shipping library, which exports nothing internal - the reason + // ViewportArrayScenario's control moved to the environment), and it does not trust the + // whole log file, only the bytes appended after this test started. + TEST_F(UnlocatedIoBlockScenario, TheEmulationIsActuallyArmedWhenTheEnvironmentPinsItOn) { + if (!Ready()) return; + + if (AmbientQuirkFromEnvironment("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS") != AmbientQuirk::On) { + GTEST_SKIP() << "this case needs the emulation pinned ON for the whole process, which " + "is what the UnlocatedIoBlocks. ctest entry does with " + "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1; with the variable unset the " + "driver POST decides, and on this machine it decides the blocks are " + "fine - so there would be nothing to observe"; + } + if (LibraryLogPath().empty()) { + GTEST_SKIP() << "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS is pinned on but " + "MOBILEGL_LOG_FILE_PATH is not set, so the library has nowhere to " + "record that it stripped anything; the UnlocatedIoBlocks. ctest entry " + "sets both"; + } + if (Gl().BackendName() != std::string("DirectGLES")) { + GTEST_SKIP() << "the strip is DirectGLES's; " << Gl().BackendName() + << " hands the module to the driver as SPIR-V, where Location is how " + "interfaces are matched"; + } + + // Taken BEFORE the program is built, so the line this looks for can only be one + // this process wrote. The latch means it is emitted at the FIRST stage of the + // FIRST affected program, which is inside the build below. + const std::uintmax_t before = LibraryLogSize(); + + const GLuint program = BuildPipeline(kDistinctTessEvalSource, kDistinctGeometrySource); + if (program == 0) { + GTEST_SKIP() << "this stack cannot build a five-stage tessellation+geometry program on " + << Gl().BackendName() << ", so nothing would arm the strip: " << BuildLog(); + } + // Drawn as well as built, so a stack that defers its backend program to first use + // still reaches the transpile this is asserting about. + const Rgba8 centre = DrawAndReadCentre(program); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_TRUE(IsGreen(centre)) << "the pinned-on lane did not even render correctly: " << centre; + + const std::string appended = LibraryLogSince(before); + EXPECT_NE(appended.find("WITHOUT their layout(location) qualifier"), std::string::npos) + << "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS is pinned ON, a five-stage program with four " + "interface-block boundaries was built and drawn, and DirectGLES never reported " + "stripping a single location. The emulation is not armed - check the override " + "mapping in Loader.cpp (it is inverted on purpose) and the arming gate in " + "Managers.cpp. Log appended by this test:\n" + << appended; + } + } // namespace } // namespace MGITest diff --git a/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp b/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp index 80046586..48ac8797 100644 --- a/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp +++ b/MobileGL/MG_Test/BackendLoader/BackendLoaderTest.cpp @@ -7,6 +7,7 @@ // End of Source File Header #include +#include #include #include #include @@ -14,9 +15,11 @@ #include +#include #include #include #include +#include // ProbeIndirectInstanceIdIncludesBaseInstance is driven against a fake GLES driver: // a GLESFunctionsTable populated with captureless lambdas backed by the file-scope @@ -25,6 +28,13 @@ // ANGLE-style baseInstance-leaking driver, or a failing one. namespace { struct FakeDriverState { + // What each of the located-interface-block probe's draws reads back, in the order the + // probe makes them: unlocated control, located subject, located vertex-to-fragment + // control. Empty means "conforming driver" - every read returns the payload - which is + // what keeps this probe invisible to every other test in this file. + std::vector ioBlockPayloadArrives; + std::size_t ioBlockReads = 0; + std::size_t ioBlockDraws = 0; // Behavior knobs, configured per test before running the probe. GLint maxVertexSsboBlocks = 4; GLint glesMajorVersion = 3; @@ -433,6 +443,52 @@ namespace { }; funcs.glBindFramebuffer = [](GLenum, GLuint) {}; funcs.glBindRenderbuffer = [](GLenum, GLuint) {}; + // ---- what the located-interface-block probe draws with ------------------------- + // Enough of a rasterizer for ProbeLocatedIoBlocksLosePayload to reach a verdict: it + // builds three programs, draws each to a 1x1 target and reads the pixel back, and the + // fake decides what each read returns. Default behaviour is a CONFORMING driver, so + // every test that predates this one sees the probe reach "not affected" and no + // capability it asserts on moves. + funcs.glCheckFramebufferStatus = [](GLenum) -> GLenum { return GL_FRAMEBUFFER_COMPLETE; }; + funcs.glViewport = [](GLint, GLint, GLsizei, GLsizei) {}; + funcs.glClearColor = [](GLfloat, GLfloat, GLfloat, GLfloat) {}; + funcs.glClear = [](GLbitfield) {}; + funcs.glPixelStorei = [](GLenum, GLint) {}; + funcs.glColorMask = [](GLboolean, GLboolean, GLboolean, GLboolean) {}; + funcs.glIsEnabled = [](GLenum) -> GLboolean { return GL_FALSE; }; + funcs.glGetBooleanv = [](GLenum, GLboolean* data) { + if (data == nullptr) return; + for (int i = 0; i < 4; ++i) data[i] = GL_TRUE; + }; + funcs.glGetIntegeri_v = [](GLenum, GLuint, GLint* data) { + if (data != nullptr) *data = 0; + }; + funcs.glGetProgramInfoLog = [](GLuint, GLsizei bufSize, GLsizei* length, GLchar* infoLog) { + if (infoLog != nullptr && bufSize > 0) infoLog[0] = '\0'; + if (length != nullptr) *length = 0; + }; + funcs.glGetShaderInfoLog = [](GLuint, GLsizei bufSize, GLsizei* length, GLchar* infoLog) { + if (infoLog != nullptr && bufSize > 0) infoLog[0] = '\0'; + if (length != nullptr) *length = 0; + }; + funcs.glDrawArrays = [](GLenum, GLint, GLsizei) { ++g_fake.ioBlockDraws; }; + // One entry of ioBlockPayloadArrives is consumed per draw, in the order the probe makes + // them: the unlocated CONTROL, then the located SUBJECT, then the located + // vertex-to-fragment second control. Past the end of the list the driver is conforming. + funcs.glReadPixels = [](GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void* pixels) { + auto* out = static_cast(pixels); + if (out == nullptr) return; + const std::size_t index = g_fake.ioBlockReads++; + const bool arrives = index < g_fake.ioBlockPayloadArrives.size() + ? g_fake.ioBlockPayloadArrives[index] + : true; + // 0.25 and 0.5 as the probe's vertex stage wrote them; zeroes are what a stage that + // received nothing reads. + out[0] = arrives ? 0x40 : 0x00; + out[1] = arrives ? 0x80 : 0x00; + out[2] = 0x00; + out[3] = 0xff; + }; funcs.glRenderbufferStorage = [](GLenum, GLenum, GLsizei, GLsizei) {}; funcs.glFramebufferRenderbuffer = [](GLenum, GLenum, GLenum, GLuint) {}; funcs.glDeleteFramebuffers = [](GLsizei n, const GLuint* framebuffers) { @@ -1347,3 +1403,151 @@ TEST(BaseInstanceCapabilities, RequiresTheExtensionAndAllThreeEntryPoints) { MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(missingEntryPointCaps, funcs)); EXPECT_FALSE(missingEntryPointCaps.SupportsBaseInstance); } + +// ===================== LOCATED INTER-STAGE INTERFACE BLOCKS ===================== +// +// The capability that decides whether DirectGLES strips the layout(location) qualifier off a +// tessellation/geometry program's interface blocks, and the environment override that forces +// it either way. +// +// THE MAPPING IS INVERTED ON PURPOSE and that is exactly why it is pinned here: the variable +// is named for the EMULATION ("emit them unlocated"), the capability is named for the DRIVER +// ("located blocks work"), so forcing the emulation ON must set the capability to FALSE. A +// one-line swap of those two arms would leave every other test in the tree green - the unit +// tests drive the pass directly, and the integration lane runs on llvmpipe, which carries a +// located block correctly either way - while silently disabling the repair on the only device +// that needs it. + +namespace { + void SetEnvVarForTest(const char* name, const char* value) { +#if defined(_WIN32) + _putenv_s(name, value); +#else + setenv(name, value, 1); +#endif + } + + void UnsetEnvVarForTest(const char* name) { +#if defined(_WIN32) + _putenv_s(name, ""); +#else + unsetenv(name); +#endif + } + + // Sets MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS (or clears it), re-reads the configuration the + // way process start would, and runs the capability fill against the fake driver. + MobileGL::MG_External::GLESCapabilities CapabilitiesWithOverride( + const MobileGL::MG_External::GLESFunctionsTable& funcs, const char* value) { + if (value == nullptr) { + UnsetEnvVarForTest("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS"); + } else { + SetEnvVarForTest("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS", value); + } + MobileGL::MG_ConfigLoader::Init(); + MobileGL::MG_External::GLESCapabilities caps; + EXPECT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs)); + return caps; + } +} // namespace + +TEST(LocatedIoBlockCapability, TheOverrideMapsOntoTheCapabilityInverted) { + const auto funcs = MakeFakeGLESFunctions(); + + // ONE TEST, THREE ARMS, IN THIS ORDER, because the Auto arm consults a probe that is + // memoized for the lifetime of the process - splitting them into three test cases would + // make the answer depend on which one gtest happened to run first. + ResetFakeDriver(); + g_fake.glesMinorVersion = 2; + + // ForceOn - "emit the blocks unlocated". The driver is NOT probed, and the capability must + // come out FALSE. This is the assertion the inversion swap breaks. + { + const auto caps = CapabilitiesWithOverride(funcs, "1"); + EXPECT_FALSE(caps.SupportsLocatedInterStageIoBlocks) + << "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1 forces the emulation ON, which means " + "declaring that this driver's located interface blocks do NOT work. A true here " + "means the strip is disabled in the one configuration that exists to enable it."; + } + + // ForceOff - the negative control. Also unprobed, and the capability must come out TRUE so + // the strip stays off. + { + const auto caps = CapabilitiesWithOverride(funcs, "0"); + EXPECT_TRUE(caps.SupportsLocatedInterStageIoBlocks) + << "MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=0 forces located blocks ON, i.e. the " + "emulation off; a false here would strip on every driver regardless of the probe."; + } + + // Auto - the setting every real run uses. The capability is the probe's verdict, negated: + // "the blocks lose their payload" is the same statement as "located blocks are not + // supported". On this fake the probe finds a conforming driver, so the capability is true. + { + const auto caps = CapabilitiesWithOverride(funcs, nullptr); + EXPECT_EQ(caps.SupportsLocatedInterStageIoBlocks, + !MobileGL::MG_Util::SelfTest::LocatedIoBlocksLosePayload(funcs).detected) + << "with the variable unset the capability must follow the driver probe and nothing " + "else"; + EXPECT_TRUE(caps.SupportsLocatedInterStageIoBlocks) + << "the fake driver carries the probe's payload, so Auto must leave the strip off"; + } + + UnsetEnvVarForTest("MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS"); + MobileGL::MG_ConfigLoader::Init(); +} + +// The probe's own verdict logic, driven directly rather than through the memoized accessor so +// each shape gets its own answer. Its two controls are the whole design: without them a driver +// that cannot run the shape at all, or one whose interface blocks are broken generally, would +// be reported as having this very specific defect - and would have its locations stripped for +// nothing. +TEST(LocatedIoBlockProbe, ReportsTheDefectOnlyWhenTheUnlocatedControlCarriesThePayload) { + const auto funcs = MakeFakeGLESFunctions(); + using MobileGL::MG_Util::SelfTest::ProbeLocatedIoBlocksLosePayload; + + // A CONFORMING driver: every draw delivers. No finding. + ResetFakeDriver(); + g_fake.glesMinorVersion = 2; + g_fake.ioBlockPayloadArrives = {true, true, true}; + EXPECT_FALSE(ProbeLocatedIoBlocksLosePayload(funcs).detected); + + // THE AFFECTED DRIVER: the unlocated control delivers, the located subject does not, and + // the located vertex-to-fragment control does. That last one is what scopes the repair to + // tessellation/geometry programs. + ResetFakeDriver(); + g_fake.glesMinorVersion = 2; + g_fake.ioBlockPayloadArrives = {true, false, true}; + { + const auto measurement = ProbeLocatedIoBlocksLosePayload(funcs); + EXPECT_TRUE(measurement.detected); + EXPECT_FALSE(measurement.alsoAffectsVertexToFragment); + } + + // ...and a driver that loses the payload even without a geometry stage says so, because the + // repair does not reach that shape and the report must not imply it does. + ResetFakeDriver(); + g_fake.glesMinorVersion = 2; + g_fake.ioBlockPayloadArrives = {true, false, false}; + { + const auto measurement = ProbeLocatedIoBlocksLosePayload(funcs); + EXPECT_TRUE(measurement.detected); + EXPECT_TRUE(measurement.alsoAffectsVertexToFragment); + } + + // THE CONTROL FAILING IS NOT A FINDING. A driver that cannot carry an UNLOCATED block + // either has something else wrong with it, and stripping locations would repair nothing + // while changing every tessellation and geometry program on it. + ResetFakeDriver(); + g_fake.glesMinorVersion = 2; + g_fake.ioBlockPayloadArrives = {false, false, false}; + EXPECT_FALSE(ProbeLocatedIoBlocksLosePayload(funcs).detected); + + // Neither is a driver the probe cannot even draw on: an inconclusive probe must leave the + // capability exactly as it was before the probe existed. + ResetFakeDriver(); + g_fake.glesMinorVersion = 2; + auto crippled = MakeFakeGLESFunctions(); + crippled.glReadPixels = nullptr; + EXPECT_FALSE(ProbeLocatedIoBlocksLosePayload(crippled).detected); + EXPECT_EQ(g_fake.ioBlockDraws, 0u) << "an entry-point-gated probe must not draw at all"; +}