From dcb568d4458cea72233af65ace2ac0b39a8c5bfd Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 22 Aug 2026 08:45:05 -0400 Subject: [PATCH 1/3] [Feature, Test] (SelfTest): probe the four remaining known driver bugs from the POST --- .../MG_Test/SelfTest/DriverBugProbesTest.cpp | 606 +++++++++ MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp | 1163 ++++++++++++++++- MobileGL/MG_Util/SelfTest/DriverBugProbes.h | 121 +- 3 files changed, 1876 insertions(+), 14 deletions(-) diff --git a/MobileGL/MG_Test/SelfTest/DriverBugProbesTest.cpp b/MobileGL/MG_Test/SelfTest/DriverBugProbesTest.cpp index 91ca697d..a6e1d37b 100644 --- a/MobileGL/MG_Test/SelfTest/DriverBugProbesTest.cpp +++ b/MobileGL/MG_Test/SelfTest/DriverBugProbesTest.cpp @@ -10,10 +10,21 @@ #include +#include +#include +#include +#include +#include +#include + using namespace MobileGL; using MobileGL::MG_Util::SelfTest::CollectGlesKnownDriverBugs; using MobileGL::MG_Util::SelfTest::DriverBugVerdict; +using MobileGL::MG_Util::SelfTest::ProbeCrossStageImageQualifierMergeDropsWrites; using MobileGL::MG_Util::SelfTest::ProbeGeometryStageSsboWriteAfterEmitDropped; +using MobileGL::MG_Util::SelfTest::ProbeImageLocationPerNameBudget; +using MobileGL::MG_Util::SelfTest::ProbeImageWriteReadCoherencyResidual; +using MobileGL::MG_Util::SelfTest::ProbeR32FMultisampleSwizzleCorruption; namespace { // A driver table with nothing resolved. Every probe has to treat this as "cannot tell", @@ -21,6 +32,418 @@ namespace { MG_External::GLESFunctionsTable EmptyFunctionTable() { return MG_External::GLESFunctionsTable{}; } + + // ===================== THE FAKE DRIVER ===================== + // + // Same idea as the fake GLES table BackendLoaderTest drives the gl_InstanceID probe with: + // captureless lambdas over one file-scope state, with per-test knobs that turn each defect + // on and off. It is deliberately a MODEL of the defect rather than a canned answer - the + // fake reads the shader text the probe actually submitted and reproduces what the affected + // driver does with it, so a probe that stopped building the triggering shape would stop + // detecting, which is exactly what these tests are for. + // + // These tests call the Probe* functions directly rather than through + // CollectGlesKnownDriverBugs(): the collector goes through the once-per-process memos, and a + // memo latched by one test would decide the answer for every later one. + + // The exact text an affected Adreno driver puts in the info log for this refusal. + const char* const kImageLocationLinkLog = + "Error: Image Image location or component exceeds max allowed.\nError: Linking failed."; + + struct FakeDriver { + // ---- limits the probes gate on ------------------------------------- + GLint maxColorTextureSamples = 4; + GLint maxImageUnits = 8; + GLint maxVertexImageUniforms = 8; + GLint maxFragmentImageUniforms = 8; + GLint maxGeometryImageUniforms = 3; + // The landed geometry probe reads this; zero keeps it inert so it cannot interfere. + GLint maxGeometrySsboBlocks = 0; + bool geometryImageLimitQueryRaisesError = false; + bool colorTextureSamplesQueryRaisesError = false; + + // ---- defect knobs --------------------------------------------------- + // Probe 1: a swizzled-alpha, non-zero-sample .w fetch reads garbage from the second + // sampling program onward. + bool msaaSwizzledAlphaCorrupted = false; + // Probe 1's inconclusive path: EVERY sampled read is wrong, including the controls. + bool msaaEveryReadWrong = false; + // Probe 2: the link fails once the program declares more distinct image uniform NAMES + // than this. + int distinctImageNameBudget = 1000; + // Probe 3: a same-name coherent writeonly/readonly pair loses the writing stage's store. + bool sameNameImagePairDropsWrites = false; + // Probe 3's inconclusive path: the renamed control loses it too. + bool everyVertexImageWriteDropped = false; + // Probe 4: how many texels the in-invocation dependent read misses under the STRONGEST + // shape, how many it misses under the shape MobileGL emits today, and whether the + // two-draw control misses them too. + int coherencyStrongestShapeFailedTexels = 0; + int coherencyEmittedShapeFailedTexels = 0; + int coherencyControlFailedTexels = 0; + + // ---- object bookkeeping --------------------------------------------- + GLenum pendingError = GL_NO_ERROR; + GLuint nextShaderId = 1; + GLuint nextProgramId = 1; + GLuint nextTextureId = 1; + GLuint nextFramebufferId = 1; + GLuint nextVertexArrayId = 1; + int aliveShaders = 0; + int alivePrograms = 0; + int aliveTextures = 0; + int aliveFramebuffers = 0; + int aliveVertexArrays = 0; + + std::map shaderSources; + std::map> programShaders; + std::map programLinked; + std::map programInfoLogs; + // texture id -> GL_TEXTURE_SWIZZLE_A + std::map multisampleAlphaSwizzle; + + GLuint boundMultisampleTexture = 0; + GLuint currentProgram = 0; + // How many programs that sample a multisample texture have been linked so far. The + // corruption starts at the second. + int sampledMultisampleProgramCount = 0; + // Set by glDrawArrays, consumed by glReadPixels. + GLfloat lastSampledValue = 1.0f; + int lastFailedTexelCount = 0; + }; + + FakeDriver g_fake; + + void ResetFakeDriver() { g_fake = FakeDriver{}; } + + const std::string& SourceOf(GLuint shader) { + static const std::string empty; + const auto it = g_fake.shaderSources.find(shader); + return it == g_fake.shaderSources.end() ? empty : it->second; + } + + bool Contains(const std::string& haystack, const char* needle) { + return haystack.find(needle) != std::string::npos; + } + + // Every `image2D ` the program declares, across all its stages. + std::vector DeclaredImageNames(GLuint program) { + std::vector names; + const auto attached = g_fake.programShaders.find(program); + if (attached == g_fake.programShaders.end()) return names; + for (const GLuint shader : attached->second) { + const std::string& source = SourceOf(shader); + std::size_t at = 0; + while ((at = source.find("image2D ", at)) != std::string::npos) { + at += std::strlen("image2D "); + const std::size_t end = source.find_first_of(";,)", at); + if (end == std::string::npos) break; + std::string name = source.substr(at, end - at); + while (!name.empty() && (name.back() == ' ' || name.back() == '\t')) name.pop_back(); + if (std::find(names.begin(), names.end(), name) == names.end()) { + names.push_back(name); + } + at = end; + } + } + return names; + } + + std::string StageSourceContaining(GLuint program, const char* needle) { + const auto attached = g_fake.programShaders.find(program); + if (attached == g_fake.programShaders.end()) return {}; + for (const GLuint shader : attached->second) { + const std::string& source = SourceOf(shader); + if (Contains(source, needle)) return source; + } + return {}; + } + + // The uniform name in `... image2D ;` of the first declaration in `source`. + std::string FirstImageNameIn(const std::string& source) { + const std::size_t at = source.find("image2D "); + if (at == std::string::npos) return {}; + const std::size_t start = at + std::strlen("image2D "); + const std::size_t end = source.find(';', start); + if (end == std::string::npos) return {}; + return source.substr(start, end - start); + } + + // Whatever the sampling vertex shader asked for: `texelFetch(mg_probeSampler, ivec2(0), N).C`. + void ParseSampledFetch(const std::string& source, int& sampleIndex, char& component) { + sampleIndex = -1; + component = '?'; + const std::size_t at = source.find("texelFetch(mg_probeSampler, ivec2(0), "); + if (at == std::string::npos) return; + const std::size_t start = at + std::strlen("texelFetch(mg_probeSampler, ivec2(0), "); + sampleIndex = std::atoi(source.c_str() + start); + const std::size_t dot = source.find(").", start); + if (dot != std::string::npos && dot + 2 < source.size()) component = source[dot + 2]; + } + + MG_External::GLESFunctionsTable MakeFakeGLESFunctions() { + MG_External::GLESFunctionsTable funcs{}; + + funcs.glGetError = []() -> GLenum { + const GLenum error = g_fake.pendingError; + g_fake.pendingError = GL_NO_ERROR; + return error; + }; + funcs.glGetIntegerv = [](GLenum pname, GLint* data) { + switch (pname) { + case GL_MAX_COLOR_TEXTURE_SAMPLES: + if (g_fake.colorTextureSamplesQueryRaisesError) { + g_fake.pendingError = GL_INVALID_ENUM; + } else { + *data = g_fake.maxColorTextureSamples; + } + break; + case GL_MAX_IMAGE_UNITS: + *data = g_fake.maxImageUnits; + break; + case GL_MAX_VERTEX_IMAGE_UNIFORMS: + *data = g_fake.maxVertexImageUniforms; + break; + case GL_MAX_FRAGMENT_IMAGE_UNIFORMS: + *data = g_fake.maxFragmentImageUniforms; + break; + case GL_MAX_GEOMETRY_IMAGE_UNIFORMS: + if (g_fake.geometryImageLimitQueryRaisesError) { + g_fake.pendingError = GL_INVALID_ENUM; + } else { + *data = g_fake.maxGeometryImageUniforms; + } + break; + case GL_MAX_GEOMETRY_SHADER_STORAGE_BLOCKS: + *data = g_fake.maxGeometrySsboBlocks; + break; + default: + break; + } + }; + funcs.glGetIntegeri_v = [](GLenum, GLuint, GLint* data) { *data = 0; }; + funcs.glGetFloatv = [](GLenum, GLfloat* data) { + data[0] = 0.0f; + data[1] = 0.0f; + data[2] = 0.0f; + data[3] = 0.0f; + }; + funcs.glIsEnabled = [](GLenum) -> GLboolean { return GL_FALSE; }; + funcs.glEnable = [](GLenum) {}; + funcs.glDisable = [](GLenum) {}; + funcs.glFinish = []() {}; + funcs.glMemoryBarrier = [](GLbitfield) {}; + funcs.glPixelStorei = [](GLenum, GLint) {}; + funcs.glViewport = [](GLint, GLint, GLsizei, GLsizei) {}; + funcs.glClear = [](GLbitfield) {}; + funcs.glClearColor = [](GLfloat, GLfloat, GLfloat, GLfloat) {}; + funcs.glActiveTexture = [](GLenum) {}; + + // ---- shaders and programs ------------------------------------------- + funcs.glCreateShader = [](GLenum) -> GLuint { + ++g_fake.aliveShaders; + return g_fake.nextShaderId++; + }; + funcs.glShaderSource = [](GLuint shader, GLsizei count, const GLchar* const* strings, + const GLint*) { + std::string source; + for (GLsizei i = 0; i < count; ++i) { + if (strings[i] != nullptr) source += strings[i]; + } + g_fake.shaderSources[shader] = std::move(source); + }; + funcs.glCompileShader = [](GLuint) {}; + funcs.glGetShaderiv = [](GLuint, GLenum pname, GLint* params) { + if (pname == GL_COMPILE_STATUS) *params = GL_TRUE; + }; + funcs.glGetShaderInfoLog = [](GLuint, GLsizei bufSize, GLsizei*, GLchar* infoLog) { + if (bufSize > 0) infoLog[0] = '\0'; + }; + funcs.glDeleteShader = [](GLuint shader) { + if (shader != 0) --g_fake.aliveShaders; + }; + funcs.glCreateProgram = []() -> GLuint { + ++g_fake.alivePrograms; + return g_fake.nextProgramId++; + }; + funcs.glAttachShader = [](GLuint program, GLuint shader) { + g_fake.programShaders[program].push_back(shader); + }; + funcs.glLinkProgram = [](GLuint program) { + const std::vector names = DeclaredImageNames(program); + const bool overBudget = static_cast(names.size()) > g_fake.distinctImageNameBudget; + g_fake.programLinked[program] = !overBudget; + g_fake.programInfoLogs[program] = overBudget ? kImageLocationLinkLog : ""; + if (!overBudget && !StageSourceContaining(program, "texelFetch(mg_probeSampler").empty()) { + ++g_fake.sampledMultisampleProgramCount; + } + }; + funcs.glGetProgramiv = [](GLuint program, GLenum pname, GLint* params) { + if (pname != GL_LINK_STATUS) return; + const auto it = g_fake.programLinked.find(program); + *params = (it == g_fake.programLinked.end() || it->second) ? GL_TRUE : GL_FALSE; + }; + funcs.glGetProgramInfoLog = [](GLuint program, GLsizei bufSize, GLsizei*, GLchar* infoLog) { + if (bufSize <= 0) return; + const auto it = g_fake.programInfoLogs.find(program); + const std::string& log = it == g_fake.programInfoLogs.end() ? std::string() : it->second; + const GLsizei copied = static_cast( + std::min(log.size(), static_cast(bufSize - 1))); + std::memcpy(infoLog, log.data(), static_cast(copied)); + infoLog[copied] = '\0'; + }; + funcs.glDeleteProgram = [](GLuint program) { + if (program != 0) --g_fake.alivePrograms; + }; + funcs.glUseProgram = [](GLuint program) { g_fake.currentProgram = program; }; + funcs.glGetUniformLocation = [](GLuint, const GLchar*) -> GLint { return 0; }; + funcs.glUniform1i = [](GLint, GLint) {}; + + // ---- textures, framebuffers, vertex arrays --------------------------- + funcs.glGenTextures = [](GLsizei n, GLuint* textures) { + for (GLsizei i = 0; i < n; ++i) { + textures[i] = g_fake.nextTextureId++; + ++g_fake.aliveTextures; + } + }; + funcs.glBindTexture = [](GLenum target, GLuint texture) { + if (target == GL_TEXTURE_2D_MULTISAMPLE) g_fake.boundMultisampleTexture = texture; + }; + funcs.glDeleteTextures = [](GLsizei n, const GLuint* textures) { + for (GLsizei i = 0; i < n; ++i) { + if (textures[i] != 0) --g_fake.aliveTextures; + } + }; + funcs.glTexParameteri = [](GLenum target, GLenum pname, GLint param) { + if (target == GL_TEXTURE_2D_MULTISAMPLE && pname == GL_TEXTURE_SWIZZLE_A) { + g_fake.multisampleAlphaSwizzle[g_fake.boundMultisampleTexture] = + static_cast(param); + } + }; + funcs.glTexImage2D = [](GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, + const void*) {}; + funcs.glTexSubImage2D = [](GLenum, GLint, GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, + const void*) {}; + funcs.glTexStorage2D = [](GLenum, GLsizei, GLenum, GLsizei, GLsizei) {}; + funcs.glTexStorage2DMultisample = [](GLenum, GLsizei, GLenum, GLsizei, GLsizei, GLboolean) {}; + funcs.glGenFramebuffers = [](GLsizei n, GLuint* framebuffers) { + for (GLsizei i = 0; i < n; ++i) { + framebuffers[i] = g_fake.nextFramebufferId++; + ++g_fake.aliveFramebuffers; + } + }; + funcs.glBindFramebuffer = [](GLenum, GLuint) {}; + funcs.glFramebufferTexture2D = [](GLenum, GLenum, GLenum, GLuint, GLint) {}; + funcs.glCheckFramebufferStatus = [](GLenum) -> GLenum { return GL_FRAMEBUFFER_COMPLETE; }; + funcs.glDeleteFramebuffers = [](GLsizei n, const GLuint* framebuffers) { + for (GLsizei i = 0; i < n; ++i) { + if (framebuffers[i] != 0) --g_fake.aliveFramebuffers; + } + }; + funcs.glGenVertexArrays = [](GLsizei n, GLuint* arrays) { + for (GLsizei i = 0; i < n; ++i) { + arrays[i] = g_fake.nextVertexArrayId++; + ++g_fake.aliveVertexArrays; + } + }; + funcs.glBindVertexArray = [](GLuint) {}; + funcs.glDeleteVertexArrays = [](GLsizei n, const GLuint* arrays) { + for (GLsizei i = 0; i < n; ++i) { + if (arrays[i] != 0) --g_fake.aliveVertexArrays; + } + }; + funcs.glBindImageTexture = [](GLuint, GLuint, GLint, GLboolean, GLint, GLenum, GLenum) {}; + + // ---- the draw, where the defects live -------------------------------- + funcs.glDrawArrays = [](GLenum, GLint, GLsizei) { + const GLuint program = g_fake.currentProgram; + const std::string sampling = StageSourceContaining(program, "texelFetch(mg_probeSampler"); + if (!sampling.empty()) { + int sampleIndex = -1; + char component = '?'; + ParseSampledFetch(sampling, sampleIndex, component); + const GLenum swizzle = g_fake.multisampleAlphaSwizzle.count( + g_fake.boundMultisampleTexture) != 0 + ? g_fake.multisampleAlphaSwizzle[g_fake.boundMultisampleTexture] + : GL_ALPHA; + // An R32F texel filled with (1, 0, 0, -) reads 1.0 through both the ALPHA and the + // RED swizzle sources, which is why one expected constant covers every shape. + g_fake.lastSampledValue = 1.0f; + if (g_fake.msaaEveryReadWrong) { + g_fake.lastSampledValue = 0.0f; + } else if (g_fake.msaaSwizzledAlphaCorrupted && swizzle == GL_RED && component == 'w' && + sampleIndex != 0 && g_fake.sampledMultisampleProgramCount >= 2) { + // Uninitialised memory: a value that is neither the answer nor the clear. + g_fake.lastSampledValue = -1.34954e-17f; + } + return; + } + + // Matched on the access qualifier alone, not on "coherent writeonly": the strongest + // coherency shape spells it "coherent volatile writeonly". + const std::string writeStage = StageSourceContaining(program, "writeonly"); + const std::string readStage = StageSourceContaining(program, "readonly"); + if (!writeStage.empty() && !readStage.empty() && Contains(readStage, "memoryBarrierImage")) { + // The coherency probe: one invocation stores and then reads back. `volatile` is + // what tells the strongest shape apart from the one MobileGL emits today, and + // giving them separate knobs is what lets a test pin the case where only the + // emitted shape is wrong - a fixable defect that must not be reported here. + g_fake.lastFailedTexelCount = Contains(readStage, "coherent volatile") + ? g_fake.coherencyStrongestShapeFailedTexels + : g_fake.coherencyEmittedShapeFailedTexels; + return; + } + if (!writeStage.empty() && readStage.empty()) { + // The coherency control's store half; the load half decides the result. + g_fake.lastFailedTexelCount = 0; + return; + } + if (writeStage.empty() && !readStage.empty()) { + g_fake.lastFailedTexelCount = g_fake.coherencyControlFailedTexels; + return; + } + if (!writeStage.empty() && !readStage.empty()) { + // The qualifier-merge pair: the stores are lost when the two halves share a name. + const bool sharedName = + FirstImageNameIn(writeStage) == FirstImageNameIn(readStage) && + !FirstImageNameIn(writeStage).empty(); + const bool lost = g_fake.everyVertexImageWriteDropped || + (g_fake.sameNameImagePairDropsWrites && sharedName); + g_fake.lastFailedTexelCount = lost ? 1 << 20 : 0; + return; + } + g_fake.lastFailedTexelCount = 0; + }; + funcs.glReadPixels = [](GLint, GLint, GLsizei width, GLsizei height, GLenum format, GLenum type, + void* pixels) { + const std::size_t texels = static_cast(width) * static_cast(height); + if (format == GL_RED && type == GL_FLOAT) { + GLfloat* out = static_cast(pixels); + for (std::size_t i = 0; i < texels; ++i) out[i] = g_fake.lastSampledValue; + return; + } + GLubyte* out = static_cast(pixels); + const std::size_t failed = + std::min(texels, static_cast(g_fake.lastFailedTexelCount)); + for (std::size_t i = 0; i < texels; ++i) { + const bool ok = i >= failed; + out[i * 4 + 0] = ok ? 0 : 255; + out[i * 4 + 1] = ok ? 255 : 0; + out[i * 4 + 2] = 0; + out[i * 4 + 3] = 255; + } + }; + + return funcs; + } + + void ExpectProbeReleasedEverything() { + EXPECT_EQ(g_fake.aliveShaders, 0); + EXPECT_EQ(g_fake.alivePrograms, 0); + EXPECT_EQ(g_fake.aliveTextures, 0); + EXPECT_EQ(g_fake.aliveFramebuffers, 0); + EXPECT_EQ(g_fake.aliveVertexArrays, 0); + } } // namespace // The rule the whole section depends on: a probe that cannot run reports NO bug. If an @@ -31,6 +454,10 @@ TEST(DriverBugProbes, AProbeThatCannotRunReportsNoBug) { const MG_External::GLESFunctionsTable gl = EmptyFunctionTable(); EXPECT_FALSE(ProbeGeometryStageSsboWriteAfterEmitDropped(gl)) << "a probe with no entry points to call must not claim the driver is affected"; + EXPECT_FALSE(ProbeR32FMultisampleSwizzleCorruption(gl)); + EXPECT_FALSE(ProbeImageLocationPerNameBudget(gl).detected); + EXPECT_FALSE(ProbeCrossStageImageQualifierMergeDropsWrites(gl)); + EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected); } // The section lists only bugs the device HAS, so a driver nothing could be probed on renders @@ -52,3 +479,182 @@ TEST(DriverBugProbes, EveryFindingCarriesANameAndAnExplanation) { finding.verdict == DriverBugVerdict::Unfixable); } } + +// ===================== R32F MULTISAMPLE SWIZZLE ===================== + +TEST(DriverBugProbes, R32FMultisampleSwizzleIsCleanOnAConformingDriver) { + ResetFakeDriver(); + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeR32FMultisampleSwizzleCorruption(gl)); + ExpectProbeReleasedEverything(); +} + +TEST(DriverBugProbes, R32FMultisampleSwizzleIsDetectedFromTheSecondProgramOnward) { + ResetFakeDriver(); + g_fake.msaaSwizzledAlphaCorrupted = true; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_TRUE(ProbeR32FMultisampleSwizzleCorruption(gl)); + ExpectProbeReleasedEverything(); +} + +// The control rule, made executable: a driver on which even the default-swizzle, sample-zero and +// .x reads are wrong is broken in some larger way, and the probe may not name the alpha swizzle +// as the cause. +TEST(DriverBugProbes, R32FMultisampleSwizzleReportsNothingWhenTheControlsAreWrongToo) { + ResetFakeDriver(); + g_fake.msaaSwizzledAlphaCorrupted = true; + g_fake.msaaEveryReadWrong = true; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeR32FMultisampleSwizzleCorruption(gl)) + << "with every read wrong the probe has no evidence that the alpha swizzle is the variable"; +} + +TEST(DriverBugProbes, R32FMultisampleSwizzleNeedsMoreThanOneSample) { + ResetFakeDriver(); + g_fake.msaaSwizzledAlphaCorrupted = true; + g_fake.maxColorTextureSamples = 1; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeR32FMultisampleSwizzleCorruption(gl)); +} + +// ===================== IMAGE LOCATION PER NAME ===================== + +TEST(DriverBugProbes, ImageLocationBudgetIsCleanWhenNamesDoNotCost) { + ResetFakeDriver(); + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + const auto measurement = ProbeImageLocationPerNameBudget(gl); + EXPECT_FALSE(measurement.detected); + ExpectProbeReleasedEverything(); +} + +TEST(DriverBugProbes, ImageLocationBudgetIsDetectedWhenOnlyTheSharedNamesLink) { + ResetFakeDriver(); + // Four image uniforms per stage: twelve distinct names in the subject, four in the control. + g_fake.distinctImageNameBudget = 5; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + const auto measurement = ProbeImageLocationPerNameBudget(gl); + EXPECT_TRUE(measurement.detected); + EXPECT_EQ(measurement.perStageImageUniforms, g_fake.maxGeometryImageUniforms + 1); + EXPECT_EQ(measurement.subjectDistinctNames, measurement.perStageImageUniforms * 3); + EXPECT_EQ(measurement.controlDistinctNames, measurement.perStageImageUniforms); + EXPECT_NE(measurement.driverMessage.find("exceeds max allowed"), String::npos) + << "the report quotes the driver rather than paraphrasing it"; + ExpectProbeReleasedEverything(); +} + +// The control rule again: when the shared-name program is refused too, the shape is simply too +// big for this driver and the refusal is honest. +TEST(DriverBugProbes, ImageLocationBudgetReportsNothingWhenTheControlAlsoFails) { + ResetFakeDriver(); + g_fake.distinctImageNameBudget = 2; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeImageLocationPerNameBudget(gl).detected); +} + +TEST(DriverBugProbes, ImageLocationBudgetNeedsAGeometryStageThatCanHoldImages) { + ResetFakeDriver(); + g_fake.distinctImageNameBudget = 5; + g_fake.maxGeometryImageUniforms = 0; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeImageLocationPerNameBudget(gl).detected); +} + +TEST(DriverBugProbes, ImageLocationBudgetStaysSilentOnAContextWithoutTheGeometryLimit) { + ResetFakeDriver(); + g_fake.distinctImageNameBudget = 5; + g_fake.geometryImageLimitQueryRaisesError = true; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeImageLocationPerNameBudget(gl).detected) + << "a pre-ES-3.2 context has no geometry stage to build the shape out of"; +} + +// ===================== CROSS-STAGE QUALIFIER MERGE ===================== + +TEST(DriverBugProbes, QualifierMergeIsCleanWhenTheDriverKeepsTheStore) { + ResetFakeDriver(); + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeCrossStageImageQualifierMergeDropsWrites(gl)); + ExpectProbeReleasedEverything(); +} + +TEST(DriverBugProbes, QualifierMergeIsDetectedWhenOnlyTheSharedNameLosesTheStore) { + ResetFakeDriver(); + g_fake.sameNameImagePairDropsWrites = true; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_TRUE(ProbeCrossStageImageQualifierMergeDropsWrites(gl)); + ExpectProbeReleasedEverything(); +} + +// A driver that loses the RENAMED store too cannot write images from the vertex stage at all - +// a different and much larger claim, which this probe may not make. +TEST(DriverBugProbes, QualifierMergeReportsNothingWhenTheRenamedControlAlsoFails) { + ResetFakeDriver(); + g_fake.sameNameImagePairDropsWrites = true; + g_fake.everyVertexImageWriteDropped = true; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeCrossStageImageQualifierMergeDropsWrites(gl)); +} + +TEST(DriverBugProbes, QualifierMergeNeedsVertexStageImageUniforms) { + ResetFakeDriver(); + g_fake.sameNameImagePairDropsWrites = true; + g_fake.maxVertexImageUniforms = 0; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeCrossStageImageQualifierMergeDropsWrites(gl)); +} + +// ===================== IMAGE COHERENCY RESIDUAL ===================== + +TEST(DriverBugProbes, ImageCoherencyIsCleanWhenTheDependentReadObservesTheStore) { + ResetFakeDriver(); + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + const auto measurement = ProbeImageWriteReadCoherencyResidual(gl); + EXPECT_FALSE(measurement.detected); + ExpectProbeReleasedEverything(); +} + +TEST(DriverBugProbes, ImageCoherencyResidualIsDetectedAndQuantified) { + ResetFakeDriver(); + g_fake.coherencyStrongestShapeFailedTexels = 376; + g_fake.coherencyEmittedShapeFailedTexels = 418; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + const auto measurement = ProbeImageWriteReadCoherencyResidual(gl); + EXPECT_TRUE(measurement.detected); + EXPECT_EQ(measurement.mismatchedTexels, 376); + EXPECT_EQ(measurement.emittedShapeMismatchedTexels, 418) + << "the row reports what applications get, not only what is theoretically reachable"; + EXPECT_GT(measurement.totalTexels, 418) << "the report needs a denominator to quote a rate"; + ExpectProbeReleasedEverything(); +} + +// The reason the subject is the STRONGEST shape and not the one MobileGL emits. Mesa llvmpipe +// misses every texel with `coherent` + memoryBarrierImage() and none once the pair is also +// `volatile` - a defect MobileGL could fix by emitting a different shape, which is not what +// UNFIXABLE means and does not belong in this section. +TEST(DriverBugProbes, ImageCoherencyReportsNothingWhenAStrongerShapeWouldFixIt) { + ResetFakeDriver(); + g_fake.coherencyStrongestShapeFailedTexels = 0; + g_fake.coherencyEmittedShapeFailedTexels = 4096; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected) + << "a driver the volatile shape satisfies has a fixable defect, not an unfixable one"; +} + +// The control rule once more: a driver whose glFinish-separated two-draw dependency is ALSO +// dirty has a bigger defect than an in-invocation ordering residual, and this probe must not +// dress that up as one. +TEST(DriverBugProbes, ImageCoherencyReportsNothingWhenTheFinishSeparatedControlIsDirtyToo) { + ResetFakeDriver(); + g_fake.coherencyStrongestShapeFailedTexels = 376; + g_fake.coherencyControlFailedTexels = 4096; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected); +} + +TEST(DriverBugProbes, ImageCoherencyNeedsBothHalvesOfTheSplitPairInOneStage) { + ResetFakeDriver(); + g_fake.coherencyStrongestShapeFailedTexels = 376; + g_fake.maxFragmentImageUniforms = 1; + const MG_External::GLESFunctionsTable gl = MakeFakeGLESFunctions(); + EXPECT_FALSE(ProbeImageWriteReadCoherencyResidual(gl).detected); +} diff --git a/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp b/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp index e9ca0d29..c0b051f7 100644 --- a/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp @@ -18,6 +18,8 @@ namespace MobileGL::MG_Util::SelfTest { namespace { using MG_External::GLESFunctionsTable; + constexpr const char* kGeometryProbeName = "geometry write-after-emit"; + constexpr GLuint kProbeMagic = 7u; constexpr GLsizei kProbeSize = 16; // Binding 0 carries the write issued BEFORE EmitVertex (the control), binding 1 the @@ -74,7 +76,7 @@ namespace MobileGL::MG_Util::SelfTest { } GLuint CompileStage(const GLESFunctionsTable& gl, GLenum stage, const char* source, - const char* stageName) { + const char* stageName, const char* probeName) { const GLuint shader = gl.glCreateShader(stage); if (shader == 0) return 0; gl.glShaderSource(shader, 1, &source, nullptr); @@ -87,8 +89,7 @@ namespace MobileGL::MG_Util::SelfTest { // reason that is indistinguishable from a clean driver. char log[512] = {0}; gl.glGetShaderInfoLog(shader, static_cast(sizeof(log) - 1), nullptr, log); - MGLOG_I("[driver-bug] geometry write-after-emit probe: %s stage did not compile: %s", - stageName, log); + MGLOG_I("[driver-bug] %s probe: %s stage did not compile: %s", probeName, stageName, log); gl.glDeleteShader(shader); return 0; } @@ -97,6 +98,10 @@ namespace MobileGL::MG_Util::SelfTest { // Every piece of GL state the probe disturbs, captured on the way in and put back on // the way out. It runs inside the POST context, which is not allowed to notice. + // The image unit every image-using probe binds to. One unit for all of them keeps the + // saved/restored set small, and nothing in the POST context is using it. + constexpr GLuint kProbeImageUnit = 1; + struct SavedState { GLint program = 0; GLint vertexArray = 0; @@ -109,8 +114,28 @@ namespace MobileGL::MG_Util::SelfTest { GLboolean rasterizerDiscard = GL_FALSE; GLboolean scissorTest = GL_FALSE; GLboolean cullFace = GL_FALSE; + // Everything the texture- and image-based probes disturb. Saved unconditionally + // (a query is cheaper than deciding which probe ran) and restored in the same call, + // so a probe cannot leave a binding behind for the next one to trip over. + GLboolean depthTest = GL_FALSE; + GLboolean blend = GL_FALSE; + GLint activeTexture = GL_TEXTURE0; + GLint texture2D = 0; + GLint texture2DMultisample = 0; + GLfloat clearColor[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + GLint packAlignment = 4; + GLint packRowLength = 0; + GLint imageName = 0; + GLint imageLevel = 0; + GLint imageLayered = 0; + GLint imageLayer = 0; + GLint imageAccess = GL_READ_ONLY; + GLint imageFormat = GL_R32UI; }; + // The texture and image parts of the state are only queryable when the driver resolved + // the entry points that read them; a probe that never touches them still restores the + // rest. void Save(const GLESFunctionsTable& gl, SavedState& state) { gl.glGetIntegerv(GL_CURRENT_PROGRAM, &state.program); gl.glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &state.vertexArray); @@ -125,23 +150,77 @@ namespace MobileGL::MG_Util::SelfTest { state.rasterizerDiscard = gl.glIsEnabled(GL_RASTERIZER_DISCARD); state.scissorTest = gl.glIsEnabled(GL_SCISSOR_TEST); state.cullFace = gl.glIsEnabled(GL_CULL_FACE); + state.depthTest = gl.glIsEnabled(GL_DEPTH_TEST); + state.blend = gl.glIsEnabled(GL_BLEND); + gl.glGetIntegerv(GL_ACTIVE_TEXTURE, &state.activeTexture); + gl.glGetIntegerv(GL_TEXTURE_BINDING_2D, &state.texture2D); + gl.glGetIntegerv(GL_TEXTURE_BINDING_2D_MULTISAMPLE, &state.texture2DMultisample); + gl.glGetIntegerv(GL_PACK_ALIGNMENT, &state.packAlignment); + gl.glGetIntegerv(GL_PACK_ROW_LENGTH, &state.packRowLength); + if (gl.glGetFloatv != nullptr) { + gl.glGetFloatv(GL_COLOR_CLEAR_VALUE, state.clearColor); + } + gl.glGetIntegeri_v(GL_IMAGE_BINDING_NAME, kProbeImageUnit, &state.imageName); + gl.glGetIntegeri_v(GL_IMAGE_BINDING_LEVEL, kProbeImageUnit, &state.imageLevel); + gl.glGetIntegeri_v(GL_IMAGE_BINDING_LAYERED, kProbeImageUnit, &state.imageLayered); + gl.glGetIntegeri_v(GL_IMAGE_BINDING_LAYER, kProbeImageUnit, &state.imageLayer); + gl.glGetIntegeri_v(GL_IMAGE_BINDING_ACCESS, kProbeImageUnit, &state.imageAccess); + gl.glGetIntegeri_v(GL_IMAGE_BINDING_FORMAT, kProbeImageUnit, &state.imageFormat); + // Any of the above may be rejected by a driver that does not know the pname; the + // fields keep their defaults and the restore below puts those back, which is the + // right answer for a context nobody else is sharing. + Drain(gl); } + // Null-safe throughout: the state it puts back is the superset of what any probe + // disturbs, and a probe that never needed (say) storage buffers is allowed to run on a + // driver table where they were never resolved. void Restore(const GLESFunctionsTable& gl, const SavedState& state) { - for (GLuint i = 0; i < 2; ++i) { - gl.glBindBufferBase(GL_SHADER_STORAGE_BUFFER, i, - static_cast(state.indexedStorageBuffer[i])); + if (gl.glBindBufferBase != nullptr) { + for (GLuint i = 0; i < 2; ++i) { + gl.glBindBufferBase(GL_SHADER_STORAGE_BUFFER, i, + static_cast(state.indexedStorageBuffer[i])); + } + } + if (gl.glBindBuffer != nullptr) { + gl.glBindBuffer(GL_SHADER_STORAGE_BUFFER, static_cast(state.storageBuffer)); + } + if (gl.glBindRenderbuffer != nullptr) { + gl.glBindRenderbuffer(GL_RENDERBUFFER, static_cast(state.renderbuffer)); } - gl.glBindBuffer(GL_SHADER_STORAGE_BUFFER, static_cast(state.storageBuffer)); - gl.glBindRenderbuffer(GL_RENDERBUFFER, static_cast(state.renderbuffer)); gl.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, static_cast(state.drawFramebuffer)); gl.glBindFramebuffer(GL_READ_FRAMEBUFFER, static_cast(state.readFramebuffer)); gl.glBindVertexArray(static_cast(state.vertexArray)); gl.glUseProgram(static_cast(state.program)); gl.glViewport(state.viewport[0], state.viewport[1], state.viewport[2], state.viewport[3]); + if (gl.glBindImageTexture != nullptr) { + gl.glBindImageTexture(kProbeImageUnit, static_cast(state.imageName), + state.imageLevel, state.imageLayered != 0 ? GL_TRUE : GL_FALSE, + state.imageLayer, static_cast(state.imageAccess), + static_cast(state.imageFormat)); + } + if (gl.glActiveTexture != nullptr) { + gl.glActiveTexture(GL_TEXTURE0); + if (gl.glBindTexture != nullptr) { + gl.glBindTexture(GL_TEXTURE_2D, static_cast(state.texture2D)); + gl.glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, + static_cast(state.texture2DMultisample)); + } + gl.glActiveTexture(static_cast(state.activeTexture)); + } + if (gl.glPixelStorei != nullptr) { + gl.glPixelStorei(GL_PACK_ALIGNMENT, state.packAlignment); + gl.glPixelStorei(GL_PACK_ROW_LENGTH, state.packRowLength); + } + if (gl.glClearColor != nullptr) { + gl.glClearColor(state.clearColor[0], state.clearColor[1], state.clearColor[2], + state.clearColor[3]); + } if (state.rasterizerDiscard) gl.glEnable(GL_RASTERIZER_DISCARD); if (state.scissorTest) gl.glEnable(GL_SCISSOR_TEST); if (state.cullFace) gl.glEnable(GL_CULL_FACE); + if (state.depthTest) gl.glEnable(GL_DEPTH_TEST); + if (state.blend) gl.glEnable(GL_BLEND); Drain(gl); } @@ -180,9 +259,12 @@ namespace MobileGL::MG_Util::SelfTest { GLuint vertexArray = 0, framebuffer = 0, renderbuffer = 0; do { - vertexShader = CompileStage(gl, GL_VERTEX_SHADER, kProbeVertexSource, "vertex"); - geometryShader = CompileStage(gl, GL_GEOMETRY_SHADER, kProbeGeometrySource, "geometry"); - fragmentShader = CompileStage(gl, GL_FRAGMENT_SHADER, kProbeFragmentSource, "fragment"); + vertexShader = CompileStage(gl, GL_VERTEX_SHADER, kProbeVertexSource, "vertex", + kGeometryProbeName); + geometryShader = CompileStage(gl, GL_GEOMETRY_SHADER, kProbeGeometrySource, "geometry", + kGeometryProbeName); + fragmentShader = CompileStage(gl, GL_FRAGMENT_SHADER, kProbeFragmentSource, "fragment", + kGeometryProbeName); if (vertexShader == 0 || geometryShader == 0 || fragmentShader == 0) { inconclusive = "one of the probe stages did not compile"; break; @@ -289,6 +371,982 @@ namespace MobileGL::MG_Util::SelfTest { return dropped; } + namespace { + // ===================== SHARED PROGRAM BUILDING ===================== + // The four probes below all decide their verdict from whether a program LINKS or from + // what a draw wrote, so they share one builder that keeps the two apart: a stage that + // does not COMPILE means the probe could not build its own subject (inconclusive), + // while a program that compiles and does not LINK is a result. + + struct StageSource { + GLenum stage = GL_VERTEX_SHADER; + String source; + const char* name = ""; + }; + + struct ProgramBuild { + GLuint program = 0; + Bool compiled = false; + Bool linked = false; + String infoLog; + }; + + ProgramBuild BuildProgram(const GLESFunctionsTable& gl, const Vector& stages, + const char* probeName) { + ProgramBuild build; + Vector shaders; + const auto releaseShaders = [&]() { + for (const GLuint shader : shaders) { + gl.glDeleteShader(shader); + } + }; + for (const StageSource& stage : stages) { + const GLuint shader = + CompileStage(gl, stage.stage, stage.source.c_str(), stage.name, probeName); + if (shader == 0) { + releaseShaders(); + return build; + } + shaders.push_back(shader); + } + build.compiled = true; + build.program = gl.glCreateProgram(); + if (build.program == 0) { + build.compiled = false; + releaseShaders(); + return build; + } + for (const GLuint shader : shaders) { + gl.glAttachShader(build.program, shader); + } + gl.glLinkProgram(build.program); + GLint linked = 0; + gl.glGetProgramiv(build.program, GL_LINK_STATUS, &linked); + build.linked = linked != GL_FALSE; + char log[512] = {0}; + gl.glGetProgramInfoLog(build.program, static_cast(sizeof(log) - 1), nullptr, log); + build.infoLog = log; + releaseShaders(); + return build; + } + + // The driver's own words, trimmed to the first line: a report that quotes the linker is + // far more actionable than one that paraphrases it, but the whole log is multi-line and + // this is a one-liner. + String FirstLine(const String& text) { + const SizeT end = text.find_first_of("\r\n"); + String line = end == String::npos ? text : text.substr(0, end); + while (!line.empty() && (line.back() == ' ' || line.back() == '\t')) { + line.pop_back(); + } + return line; + } + + // Every probe below rasterizes the same full-viewport quad from a vertex ID, so none of + // them needs a vertex buffer or an attribute. + const char* const kFullscreenQuadBody = + " vec2 mg_p = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0,\n" + " (gl_VertexID & 2) == 0 ? -1.0 : 1.0);\n" + " gl_Position = vec4(mg_p, 0.0, 1.0);\n"; + + const char* const kProbeQuadVertexSource = + "#version 320 es\n" + "void main()\n{\n" + " vec2 mg_p = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0,\n" + " (gl_VertexID & 2) == 0 ? -1.0 : 1.0);\n" + " gl_Position = vec4(mg_p, 0.0, 1.0);\n}\n"; + + // Puts the context into the shape every probe draw wants. The caller has already saved + // the state this disturbs. + void PrepareForProbeDraw(const GLESFunctionsTable& gl) { + gl.glDisable(GL_RASTERIZER_DISCARD); + gl.glDisable(GL_SCISSOR_TEST); + gl.glDisable(GL_CULL_FACE); + gl.glDisable(GL_DEPTH_TEST); + gl.glDisable(GL_BLEND); + gl.glPixelStorei(GL_PACK_ALIGNMENT, 4); + gl.glPixelStorei(GL_PACK_ROW_LENGTH, 0); + } + + // ===================== R32F MULTISAMPLE SWIZZLE ===================== + + constexpr const char* kMsaaProbeName = "R32F multisample swizzle"; + constexpr GLsizei kMsaaOutputSize = 8; + // The fill draw writes 1.0 to red and nothing else, so an R32F texel reads back as + // (1, 0, 0, 1) - the missing green/blue default to 0 and the missing alpha to 1. With + // GL_TEXTURE_SWIZZLE_A pointed at RED the .w a shader sees is that same 1.0, which is + // what makes a single expected constant enough for both the subject and the controls. + constexpr GLfloat kMsaaExpected = 1.0f; + // Neither 0 nor the expected value, so "the clear never landed" and "the draw never + // landed" are distinguishable from "the driver returned the wrong number". + constexpr GLfloat kMsaaClearSentinel = 7.0f; + + Bool HasMsaaEntryPoints(const GLESFunctionsTable& gl) { + return gl.glCreateShader && gl.glShaderSource && gl.glCompileShader && gl.glGetShaderiv && + gl.glGetShaderInfoLog && gl.glCreateProgram && gl.glAttachShader && gl.glLinkProgram && + gl.glGetProgramiv && gl.glGetProgramInfoLog && gl.glDeleteShader && + gl.glDeleteProgram && gl.glUseProgram && gl.glGenTextures && gl.glBindTexture && + gl.glDeleteTextures && gl.glTexParameteri && gl.glTexImage2D && + gl.glTexStorage2DMultisample && gl.glGenFramebuffers && gl.glBindFramebuffer && + gl.glFramebufferTexture2D && gl.glCheckFramebufferStatus && gl.glDeleteFramebuffers && + gl.glGenVertexArrays && gl.glBindVertexArray && gl.glDeleteVertexArrays && + gl.glActiveTexture && gl.glGetUniformLocation && gl.glUniform1i && gl.glViewport && + gl.glClear && gl.glClearColor && gl.glReadPixels && gl.glDrawArrays && + gl.glPixelStorei && gl.glGetIntegerv && gl.glGetIntegeri_v && gl.glGetError && + gl.glFinish && gl.glEnable && gl.glDisable && gl.glIsEnabled; + } + + const char* const kMsaaFillFragmentSource = + "#version 320 es\n" + "precision highp float;\n" + "layout(location = 0) out highp vec4 o_color;\n" + "void main() { o_color = vec4(1.0, 0.0, 0.0, 0.0); }\n"; + + const char* const kMsaaSampleFragmentSource = + "#version 320 es\n" + "precision highp float;\n" + "layout(location = 0) out highp float o_color;\n" + "layout(location = 0) flat in highp float v_result;\n" + "void main() { o_color = v_result; }\n"; + + // One vertex shader per round. The `round` term is multiplied by zero, so it changes + // nothing about the result and everything about the source text - which is the point: + // the corruption only appears from the SECOND separately compiled sampling program + // onward, and a driver that recognised an identical source could hand back the first + // program's binary and hide it. + String BuildMsaaSampleVertexSource(Int sampleIndex, char component, Int round) { + return format("#version 320 es\n" + "precision highp float;\n" + "precision highp int;\n" + "uniform highp sampler2DMS mg_probeSampler;\n" + "layout(location = 0) flat out float v_result;\n" + "void main()\n{{\n" + " v_result = texelFetch(mg_probeSampler, ivec2(0), {}).{} + float({}) * 0.0;\n" + "{}}}\n", + sampleIndex, component, round, kFullscreenQuadBody); + } + + // Samples one texel of `source` `rounds` times, each round through its own freshly + // linked program and its own freshly created R32F output texture, and stores what came + // back. Returns false when any round could not be set up at all. + Bool RunMsaaSampledRead(const GLESFunctionsTable& gl, GLuint source, Int sampleIndex, + char component, Int rounds, Vector& values) { + values.clear(); + for (Int round = 0; round < rounds; ++round) { + GLuint output = 0; + GLuint framebuffer = 0; + Bool ok = false; + ProgramBuild build; + do { + gl.glGenTextures(1, &output); + gl.glBindTexture(GL_TEXTURE_2D, output); + gl.glTexImage2D(GL_TEXTURE_2D, 0, GL_R32F, kMsaaOutputSize, kMsaaOutputSize, 0, + GL_RED, GL_FLOAT, nullptr); + gl.glGenFramebuffers(1, &framebuffer); + gl.glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + gl.glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, + output, 0); + if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) break; + + build = BuildProgram(gl, + {{GL_VERTEX_SHADER, + BuildMsaaSampleVertexSource(sampleIndex, component, round), + "vertex"}, + {GL_FRAGMENT_SHADER, kMsaaSampleFragmentSource, "fragment"}}, + kMsaaProbeName); + if (!build.linked) break; + + gl.glUseProgram(build.program); + gl.glActiveTexture(GL_TEXTURE0); + gl.glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, source); + gl.glUniform1i(gl.glGetUniformLocation(build.program, "mg_probeSampler"), 0); + gl.glViewport(0, 0, kMsaaOutputSize, kMsaaOutputSize); + gl.glClearColor(kMsaaClearSentinel, kMsaaClearSentinel, kMsaaClearSentinel, + kMsaaClearSentinel); + gl.glClear(GL_COLOR_BUFFER_BIT); + Drain(gl); + gl.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + if (gl.glGetError() != GL_NO_ERROR) break; + gl.glFinish(); + + Vector pixels(static_cast(kMsaaOutputSize) * kMsaaOutputSize, 0.0f); + gl.glReadPixels(0, 0, kMsaaOutputSize, kMsaaOutputSize, GL_RED, GL_FLOAT, + pixels.data()); + if (gl.glGetError() != GL_NO_ERROR) break; + values.push_back(pixels[0]); + ok = true; + } while (false); + + if (build.program != 0) gl.glDeleteProgram(build.program); + if (framebuffer != 0) gl.glDeleteFramebuffers(1, &framebuffer); + if (output != 0) gl.glDeleteTextures(1, &output); + if (!ok) return false; + } + return true; + } + + // Creates a `samples`-sample R32F multisample texture, fills it with (1, 0, 0, 0), and + // points GL_TEXTURE_SWIZZLE_R/G/B at ALPHA/BLUE/GREEN with GL_TEXTURE_SWIZZLE_A at + // `alphaSwizzle`. Every source the probe builds is identical except for that one enum. + GLuint MakeSwizzledMultisampleSource(const GLESFunctionsTable& gl, GLsizei samples, + GLenum alphaSwizzle) { + GLuint texture = 0; + gl.glGenTextures(1, &texture); + gl.glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, texture); + gl.glTexStorage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, samples, GL_R32F, 1, 1, GL_FALSE); + if (gl.glGetError() != GL_NO_ERROR) { + gl.glDeleteTextures(1, &texture); + return 0; + } + + GLuint framebuffer = 0; + gl.glGenFramebuffers(1, &framebuffer); + gl.glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + gl.glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D_MULTISAMPLE, + texture, 0); + Bool filled = false; + ProgramBuild fill; + if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE) { + fill = BuildProgram(gl, + {{GL_VERTEX_SHADER, kProbeQuadVertexSource, "vertex"}, + {GL_FRAGMENT_SHADER, kMsaaFillFragmentSource, "fragment"}}, + kMsaaProbeName); + if (fill.linked) { + gl.glUseProgram(fill.program); + gl.glViewport(0, 0, 1, 1); + gl.glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + gl.glClear(GL_COLOR_BUFFER_BIT); + Drain(gl); + gl.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + filled = gl.glGetError() == GL_NO_ERROR; + gl.glFinish(); + } + } + if (fill.program != 0) gl.glDeleteProgram(fill.program); + gl.glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (framebuffer != 0) gl.glDeleteFramebuffers(1, &framebuffer); + if (!filled) { + gl.glDeleteTextures(1, &texture); + return 0; + } + + gl.glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, texture); + gl.glTexParameteri(GL_TEXTURE_2D_MULTISAMPLE, GL_TEXTURE_SWIZZLE_R, GL_ALPHA); + gl.glTexParameteri(GL_TEXTURE_2D_MULTISAMPLE, GL_TEXTURE_SWIZZLE_G, GL_BLUE); + gl.glTexParameteri(GL_TEXTURE_2D_MULTISAMPLE, GL_TEXTURE_SWIZZLE_B, GL_GREEN); + gl.glTexParameteri(GL_TEXTURE_2D_MULTISAMPLE, GL_TEXTURE_SWIZZLE_A, alphaSwizzle); + Drain(gl); + return texture; + } + + Bool EveryRoundIsExpected(const Vector& values) { + if (values.empty()) return false; + for (const GLfloat value : values) { + if (value != kMsaaExpected) return false; + } + return true; + } + + String DescribeRounds(const Vector& values) { + String description; + for (SizeT i = 0; i < values.size(); ++i) { + if (i != 0) description += ", "; + description += format("round{}={}", i, values[i]); + } + return description; + } + + // ===================== IMAGE LOCATION PER NAME ===================== + + constexpr const char* kImageLocationProbeName = "image-location-per-name"; + + Bool HasImageProgramEntryPoints(const GLESFunctionsTable& gl) { + return gl.glCreateShader && gl.glShaderSource && gl.glCompileShader && gl.glGetShaderiv && + gl.glGetShaderInfoLog && gl.glCreateProgram && gl.glAttachShader && gl.glLinkProgram && + gl.glGetProgramiv && gl.glGetProgramInfoLog && gl.glDeleteShader && + gl.glDeleteProgram && gl.glGetIntegerv && gl.glGetIntegeri_v && gl.glGetError && + gl.glIsEnabled; + } + + // One stage declaring `count` image uniforms named `namePrefix`0..count-1, each stored to + // so the compiler cannot eliminate it. The bindings cycle through the driver's image + // units, so the subject and the control occupy exactly the same units - the names are + // the only thing that differs between them. + String BuildImageNameStageSource(GLenum stage, Int count, Int units, const char* namePrefix) { + String source = "#version 320 es\n"; + if (stage == GL_GEOMETRY_SHADER) { + source += "layout(points) in;\nlayout(points, max_vertices = 1) out;\n"; + } + source += "precision highp float;\nprecision highp int;\n"; + for (Int i = 0; i < count; ++i) { + source += format("layout(binding = {}, rgba32f) uniform writeonly highp image2D {}{};\n", + i % units, namePrefix, i); + } + if (stage == GL_FRAGMENT_SHADER) { + source += "layout(location = 0) out highp vec4 o_color;\n"; + } + source += "void main()\n{\n"; + for (Int i = 0; i < count; ++i) { + source += format(" imageStore({}{}, ivec2(0), vec4({}.0));\n", namePrefix, i, i); + } + switch (stage) { + case GL_VERTEX_SHADER: + source += " gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n"; + break; + case GL_GEOMETRY_SHADER: + source += " gl_Position = gl_in[0].gl_Position;\n EmitVertex();\n EndPrimitive();\n"; + break; + default: + source += " o_color = vec4(0.0);\n"; + break; + } + source += "}\n"; + return source; + } + + ProgramBuild BuildThreeStageImageProgram(const GLESFunctionsTable& gl, Int count, Int units, + const char* vertexPrefix, const char* geometryPrefix, + const char* fragmentPrefix) { + return BuildProgram( + gl, + {{GL_VERTEX_SHADER, BuildImageNameStageSource(GL_VERTEX_SHADER, count, units, vertexPrefix), + "vertex"}, + {GL_GEOMETRY_SHADER, + BuildImageNameStageSource(GL_GEOMETRY_SHADER, count, units, geometryPrefix), "geometry"}, + {GL_FRAGMENT_SHADER, + BuildImageNameStageSource(GL_FRAGMENT_SHADER, count, units, fragmentPrefix), "fragment"}}, + kImageLocationProbeName); + } + + // ===================== SHARED IMAGE-DRAW SCAFFOLDING ===================== + // Both remaining probes bind one rgba32f image, draw a quad into a small RGBA8 target, + // and count the texels the shader painted red. Sharing the scaffolding keeps what + // differs between them - the shader text - the only thing either probe has to explain. + + constexpr GLubyte kProbePassColor[4] = {0, 255, 0, 255}; + + struct ImageDrawTargets { + GLuint image = 0; + GLuint color = 0; + GLuint framebuffer = 0; + GLsizei size = 0; + Bool valid = false; + }; + + Bool HasImageDrawEntryPoints(const GLESFunctionsTable& gl) { + return HasImageProgramEntryPoints(gl) && gl.glUseProgram && gl.glGenTextures && + gl.glBindTexture && gl.glDeleteTextures && gl.glTexStorage2D && gl.glTexSubImage2D && + gl.glGenFramebuffers && gl.glBindFramebuffer && gl.glFramebufferTexture2D && + gl.glCheckFramebufferStatus && gl.glDeleteFramebuffers && gl.glGenVertexArrays && + gl.glBindVertexArray && gl.glDeleteVertexArrays && gl.glBindImageTexture && + gl.glViewport && gl.glClear && gl.glClearColor && gl.glReadPixels && gl.glDrawArrays && + gl.glMemoryBarrier && gl.glPixelStorei && gl.glFinish && gl.glEnable && gl.glDisable; + } + + ImageDrawTargets MakeImageDrawTargets(const GLESFunctionsTable& gl, GLsizei size) { + ImageDrawTargets targets; + targets.size = size; + gl.glGenTextures(1, &targets.image); + gl.glBindTexture(GL_TEXTURE_2D, targets.image); + gl.glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA32F, size, size); + gl.glGenTextures(1, &targets.color); + gl.glBindTexture(GL_TEXTURE_2D, targets.color); + gl.glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, size, size); + gl.glGenFramebuffers(1, &targets.framebuffer); + gl.glBindFramebuffer(GL_FRAMEBUFFER, targets.framebuffer); + gl.glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, targets.color, + 0); + targets.valid = gl.glGetError() == GL_NO_ERROR && + gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE; + Drain(gl); + return targets; + } + + void ReleaseImageDrawTargets(const GLESFunctionsTable& gl, ImageDrawTargets& targets) { + if (targets.framebuffer != 0) gl.glDeleteFramebuffers(1, &targets.framebuffer); + if (targets.color != 0) gl.glDeleteTextures(1, &targets.color); + if (targets.image != 0) gl.glDeleteTextures(1, &targets.image); + targets = ImageDrawTargets{}; + } + + // Zeroes the image so a stale value from an earlier draw can never stand in for a store + // that did not happen. + void ZeroProbeImage(const GLESFunctionsTable& gl, const ImageDrawTargets& targets) { + const SizeT texelCount = static_cast(targets.size) * targets.size * 4; + const Vector zeroes(texelCount, 0.0f); + gl.glBindTexture(GL_TEXTURE_2D, targets.image); + gl.glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, targets.size, targets.size, GL_RGBA, GL_FLOAT, + zeroes.data()); + gl.glMemoryBarrier(GL_ALL_BARRIER_BITS); + gl.glFinish(); + Drain(gl); + } + + // Reads the colour target back and counts the texels the shader did NOT paint with + // kProbePassColor. A negative result means the readback itself failed. + Int CountFailedTexels(const GLESFunctionsTable& gl, const ImageDrawTargets& targets) { + Vector pixels(static_cast(targets.size) * targets.size * 4, 0); + Drain(gl); + gl.glReadPixels(0, 0, targets.size, targets.size, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + if (gl.glGetError() != GL_NO_ERROR) return -1; + Int failed = 0; + for (SizeT texel = 0; texel < pixels.size(); texel += 4) { + if (pixels[texel + 0] != kProbePassColor[0] || pixels[texel + 1] != kProbePassColor[1] || + pixels[texel + 2] != kProbePassColor[2] || pixels[texel + 3] != kProbePassColor[3]) { + ++failed; + } + } + return failed; + } + + void BeginProbeDraw(const GLESFunctionsTable& gl, const ImageDrawTargets& targets) { + gl.glBindImageTexture(kProbeImageUnit, targets.image, 0, GL_FALSE, 0, GL_READ_WRITE, + GL_RGBA32F); + gl.glBindFramebuffer(GL_FRAMEBUFFER, targets.framebuffer); + gl.glViewport(0, 0, targets.size, targets.size); + gl.glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + gl.glClear(GL_COLOR_BUFFER_BIT); + } + + // ===================== CROSS-STAGE QUALIFIER MERGE ===================== + + constexpr const char* kQualifierMergeProbeName = "cross-stage image qualifier merge"; + constexpr GLsizei kQualifierMergeSize = 16; + // The value the vertex stage stores and the fragment stage expects to read back. + constexpr const char* kQualifierMergeValue = "2.0"; + + String BuildQualifierMergeVertexSource(const char* writeName) { + return format("#version 320 es\n" + "precision highp float;\n" + "precision highp int;\n" + "layout(binding = {}, rgba32f) uniform coherent writeonly highp image2D {};\n" + "void main()\n{{\n" + "{}" + " imageStore({}, ivec2(0), vec4({}));\n" + " memoryBarrier();\n}}\n", + kProbeImageUnit, writeName, kFullscreenQuadBody, writeName, + kQualifierMergeValue); + } + + String BuildQualifierMergeFragmentSource(const char* readName) { + return format("#version 320 es\n" + "precision highp float;\n" + "precision highp int;\n" + "layout(binding = {}, rgba32f) uniform coherent readonly highp image2D {};\n" + "layout(location = 0) out highp vec4 o_color;\n" + "void main()\n{{\n" + " o_color = vec4(0.0, 1.0, 0.0, 1.0);\n" + " if (any(notEqual(imageLoad({}, ivec2(0)), vec4({}))))\n" + " {{\n o_color = vec4(1.0, 0.0, 0.0, 1.0);\n }}\n}}\n", + kProbeImageUnit, readName, readName, kQualifierMergeValue); + } + + // Draws the pair once and reports how many fragments failed to see the vertex stage's + // store. -1 means the case could not be built or drawn at all. + Int RunQualifierMergeCase(const GLESFunctionsTable& gl, const ImageDrawTargets& targets, + const char* writeName, const char* readName) { + ProgramBuild build = + BuildProgram(gl, + {{GL_VERTEX_SHADER, BuildQualifierMergeVertexSource(writeName), "vertex"}, + {GL_FRAGMENT_SHADER, BuildQualifierMergeFragmentSource(readName), + "fragment"}}, + kQualifierMergeProbeName); + Int failed = -1; + if (build.linked) { + ZeroProbeImage(gl, targets); + BeginProbeDraw(gl, targets); + gl.glUseProgram(build.program); + Drain(gl); + gl.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + if (gl.glGetError() == GL_NO_ERROR) { + gl.glMemoryBarrier(GL_ALL_BARRIER_BITS); + gl.glFinish(); + failed = CountFailedTexels(gl, targets); + } + } + if (build.program != 0) gl.glDeleteProgram(build.program); + return failed; + } + + // ===================== IMAGE COHERENCY RESIDUAL ===================== + + constexpr const char* kCoherencyProbeName = "image write-read coherency"; + constexpr GLsizei kCoherencySize = 64; + + // A split read-write image pair on ONE binding, storing and then reading the same texel + // back inside one invocation. `qualifiers` and `barriers` are what separate the SUBJECT + // (the strongest shape ESSL offers) from the shape MobileGL emits today; everything else + // about the two is identical, so the two numbers are comparable. + String BuildCoherencyFragmentSource(const char* qualifiers, const char* barriers) { + return format( + "#version 320 es\n" + "precision highp float;\n" + "precision highp int;\n" + "layout(binding = {}, rgba32f) uniform {} readonly highp image2D mg_probeImageRead;\n" + "layout(binding = {}, rgba32f) uniform {} writeonly highp image2D mg_probeImageWrite;\n" + "layout(location = 0) out highp vec4 o_color;\n" + "void main()\n{{\n" + " ivec2 mg_c = ivec2(gl_FragCoord.xy);\n" + " o_color = vec4(0.0, 1.0, 0.0, 1.0);\n" + " for (int mg_i = 0; mg_i < 3; ++mg_i)\n {{\n" + " imageStore(mg_probeImageWrite, mg_c, vec4(float(mg_i)));\n" + " {}\n" + " highp vec4 mg_v = imageLoad(mg_probeImageRead, mg_c);\n" + " if (any(notEqual(mg_v, vec4(float(mg_i)))))\n {{\n" + " o_color = vec4(1.0, 0.0, 0.0, 1.0);\n break;\n }}\n" + " }}\n}}\n", + kProbeImageUnit, qualifiers, kProbeImageUnit, qualifiers, barriers); + } + + // Every qualifier and every barrier ESSL has for this. A driver that still misses the + // store here has nothing left to be told. + constexpr const char* kStrongestCoherencyQualifiers = "coherent volatile"; + constexpr const char* kStrongestCoherencyBarriers = "memoryBarrierImage(); memoryBarrier();"; + // What SplitReadWriteImageUniforms emits today, measured alongside so the row can report + // what applications actually get rather than only what is theoretically reachable. + constexpr const char* kEmittedCoherencyQualifiers = "coherent"; + constexpr const char* kEmittedCoherencyBarriers = "memoryBarrierImage();"; + + // The control's two halves. Same image, same binding, same qualifiers, same dependency - + // the store and the read are just in different draws, with a glMemoryBarrier and a + // glFinish between them. + String BuildCoherencyControlStoreSource() { + return format( + "#version 320 es\n" + "precision highp float;\n" + "precision highp int;\n" + "layout(binding = {}, rgba32f) uniform coherent writeonly highp image2D mg_probeImageWrite;\n" + "layout(location = 0) out highp vec4 o_color;\n" + "void main()\n{{\n" + " ivec2 mg_c = ivec2(gl_FragCoord.xy);\n" + " imageStore(mg_probeImageWrite, mg_c, vec4(float(mg_c.x * {} + mg_c.y)));\n" + " memoryBarrierImage();\n" + " o_color = vec4(0.0, 1.0, 0.0, 1.0);\n}}\n", + kProbeImageUnit, kCoherencySize); + } + + String BuildCoherencyControlLoadSource() { + return format( + "#version 320 es\n" + "precision highp float;\n" + "precision highp int;\n" + "layout(binding = {}, rgba32f) uniform coherent readonly highp image2D mg_probeImageRead;\n" + "layout(location = 0) out highp vec4 o_color;\n" + "void main()\n{{\n" + " ivec2 mg_c = ivec2(gl_FragCoord.xy);\n" + " highp vec4 mg_v = imageLoad(mg_probeImageRead, mg_c);\n" + " o_color = all(equal(mg_v, vec4(float(mg_c.x * {} + mg_c.y))))\n" + " ? vec4(0.0, 1.0, 0.0, 1.0)\n" + " : vec4(1.0, 0.0, 0.0, 1.0);\n}}\n", + kProbeImageUnit, kCoherencySize); + } + + // One draw, store and dependent read inside the same invocation, in whichever + // qualifier/barrier shape the caller asked for. + Int RunCoherencyShape(const GLESFunctionsTable& gl, const ImageDrawTargets& targets, + const char* qualifiers, const char* barriers) { + ProgramBuild build = BuildProgram( + gl, + {{GL_VERTEX_SHADER, kProbeQuadVertexSource, "vertex"}, + {GL_FRAGMENT_SHADER, BuildCoherencyFragmentSource(qualifiers, barriers), "fragment"}}, + kCoherencyProbeName); + Int failed = -1; + if (build.linked) { + ZeroProbeImage(gl, targets); + BeginProbeDraw(gl, targets); + gl.glUseProgram(build.program); + Drain(gl); + gl.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + if (gl.glGetError() == GL_NO_ERROR) { + gl.glMemoryBarrier(GL_ALL_BARRIER_BITS); + gl.glFinish(); + failed = CountFailedTexels(gl, targets); + } + } + if (build.program != 0) gl.glDeleteProgram(build.program); + return failed; + } + + // The control: the same store and the same dependent read, split across two draws with a + // glMemoryBarrier and a glFinish between them. + Int RunCoherencyControl(const GLESFunctionsTable& gl, const ImageDrawTargets& targets) { + ProgramBuild store = + BuildProgram(gl, + {{GL_VERTEX_SHADER, kProbeQuadVertexSource, "vertex"}, + {GL_FRAGMENT_SHADER, BuildCoherencyControlStoreSource(), "fragment"}}, + kCoherencyProbeName); + ProgramBuild load = + BuildProgram(gl, + {{GL_VERTEX_SHADER, kProbeQuadVertexSource, "vertex"}, + {GL_FRAGMENT_SHADER, BuildCoherencyControlLoadSource(), "fragment"}}, + kCoherencyProbeName); + Int failed = -1; + if (store.linked && load.linked) { + ZeroProbeImage(gl, targets); + BeginProbeDraw(gl, targets); + gl.glUseProgram(store.program); + Drain(gl); + gl.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + gl.glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); + gl.glFinish(); + gl.glUseProgram(load.program); + gl.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + if (gl.glGetError() == GL_NO_ERROR) { + gl.glMemoryBarrier(GL_ALL_BARRIER_BITS); + gl.glFinish(); + failed = CountFailedTexels(gl, targets); + } + } + if (store.program != 0) gl.glDeleteProgram(store.program); + if (load.program != 0) gl.glDeleteProgram(load.program); + return failed; + } + } // namespace + + Bool ProbeR32FMultisampleSwizzleCorruption(const GLESFunctionsTable& gl) { + if (!HasMsaaEntryPoints(gl)) return false; + + // A sample index other than 0 is the whole subject, so a driver that cannot host a + // multisample colour target at all has nothing to say here. + Drain(gl); + GLint maxColorTextureSamples = 0; + gl.glGetIntegerv(GL_MAX_COLOR_TEXTURE_SAMPLES, &maxColorTextureSamples); + const Bool sampleQueryAccepted = gl.glGetError() == GL_NO_ERROR; + Drain(gl); + if (!sampleQueryAccepted || maxColorTextureSamples < 2) return false; + const GLsizei samples = + maxColorTextureSamples >= 4 ? 4 : static_cast(maxColorTextureSamples); + const Int lastSample = static_cast(samples) - 1; + + SavedState saved; + Save(gl, saved); + Drain(gl); + + Bool corrupted = false; + const char* inconclusive = nullptr; + GLuint vertexArray = 0; + GLuint swizzledSource = 0; + GLuint defaultSwizzleSource = 0; + Vector subject; + Vector controlDefaultSwizzle; + Vector controlSampleZero; + Vector controlRedComponent; + + do { + gl.glGenVertexArrays(1, &vertexArray); + gl.glBindVertexArray(vertexArray); + PrepareForProbeDraw(gl); + + // Two sources, identical but for GL_TEXTURE_SWIZZLE_A. Separate textures rather than + // one re-swizzled between reads: re-pushing the swizzle is itself a variable, and it + // was measured not to be the one that matters. + swizzledSource = MakeSwizzledMultisampleSource(gl, samples, GL_RED); + defaultSwizzleSource = MakeSwizzledMultisampleSource(gl, samples, GL_ALPHA); + if (swizzledSource == 0 || defaultSwizzleSource == 0) { + inconclusive = "the driver would not host a filled R32F multisample colour target"; + break; + } + + // Two rounds each: the corruption only appears from the second separately linked + // sampling program onward, so a single read would call an affected driver clean. + constexpr Int kRounds = 2; + if (!RunMsaaSampledRead(gl, swizzledSource, lastSample, 'w', kRounds, subject) || + !RunMsaaSampledRead(gl, defaultSwizzleSource, lastSample, 'w', kRounds, + controlDefaultSwizzle) || + !RunMsaaSampledRead(gl, swizzledSource, 0, 'w', kRounds, controlSampleZero) || + !RunMsaaSampledRead(gl, swizzledSource, lastSample, 'x', kRounds, controlRedComponent)) { + inconclusive = "one of the sampling programs could not be built or drawn"; + break; + } + + // All three controls have to be right for the subject to mean anything. A driver + // that simply cannot render R32F, or cannot fetch multisample texels, would fail the + // subject too - and calling that "the alpha swizzle is corrupted" would be a claim + // the probe has no evidence for. + const Bool controlsClean = EveryRoundIsExpected(controlDefaultSwizzle) && + EveryRoundIsExpected(controlSampleZero) && + EveryRoundIsExpected(controlRedComponent); + if (!controlsClean) { + inconclusive = "a control read came back wrong too, so the subject says nothing " + "about the alpha swizzle in particular"; + break; + } + corrupted = !EveryRoundIsExpected(subject); + + MGLOG_I("[driver-bug] R32F multisample swizzle probe: %d samples; subject(swizzle_a=RED, " + "sample=%d, .w) %s; controls default-swizzle [%s] sample-0 [%s] .x [%s]; expected " + "%g everywhere%s", + static_cast(samples), static_cast(lastSample), + DescribeRounds(subject).c_str(), DescribeRounds(controlDefaultSwizzle).c_str(), + DescribeRounds(controlSampleZero).c_str(), DescribeRounds(controlRedComponent).c_str(), + static_cast(kMsaaExpected), + corrupted ? " - THE SWIZZLED ALPHA READ IS CORRUPTED" : ""); + } while (false); + + if (inconclusive != nullptr) { + MGLOG_I("[driver-bug] R32F multisample swizzle probe reached no verdict (%s)", inconclusive); + } + + if (vertexArray != 0) gl.glDeleteVertexArrays(1, &vertexArray); + if (swizzledSource != 0) gl.glDeleteTextures(1, &swizzledSource); + if (defaultSwizzleSource != 0) gl.glDeleteTextures(1, &defaultSwizzleSource); + + Restore(gl, saved); + return corrupted; + } + + Bool R32FMultisampleSwizzleCorrupted(const GLESFunctionsTable& gl) { + static const Bool corrupted = ProbeR32FMultisampleSwizzleCorruption(gl); + return corrupted; + } + + ImageLocationBudgetMeasurement ProbeImageLocationPerNameBudget(const GLESFunctionsTable& gl) { + ImageLocationBudgetMeasurement measurement; + if (!HasImageProgramEntryPoints(gl)) return measurement; + + Drain(gl); + GLint maxImageUnits = 0; + GLint maxVertexImages = 0; + GLint maxFragmentImages = 0; + GLint maxGeometryImages = 0; + gl.glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + gl.glGetIntegerv(GL_MAX_VERTEX_IMAGE_UNIFORMS, &maxVertexImages); + gl.glGetIntegerv(GL_MAX_FRAGMENT_IMAGE_UNIFORMS, &maxFragmentImages); + // ES 3.2 only; an older context raises GL_INVALID_ENUM and has no geometry stage to + // build the shape out of anyway. + gl.glGetIntegerv(GL_MAX_GEOMETRY_IMAGE_UNIFORMS, &maxGeometryImages); + const Bool limitsAccepted = gl.glGetError() == GL_NO_ERROR; + Drain(gl); + if (!limitsAccepted || maxImageUnits < 1 || maxGeometryImages < 1) { + MGLOG_I("[driver-bug] image-location-per-name probe reached no verdict (this context " + "has no geometry-stage image uniforms to build the shape out of)"); + return measurement; + } + + // One more than the smallest of the three stages' budgets. Below that, a driver with + // per-name accounting has no reason to refuse anything; above it, the CONTROL stops + // linking too and the pair stops being a comparison. + const Int perStage = static_cast(maxGeometryImages) + 1; + if (perStage > maxVertexImages || perStage > maxFragmentImages) { + MGLOG_I("[driver-bug] image-location-per-name probe reached no verdict (the geometry " + "stage's own image budget, %d, is not smaller than the vertex/fragment budgets " + "%d/%d, so no shape exceeds one stage without exceeding them all)", + static_cast(maxGeometryImages), static_cast(maxVertexImages), + static_cast(maxFragmentImages)); + return measurement; + } + + measurement.perStageImageUniforms = perStage; + measurement.subjectDistinctNames = perStage * 3; + measurement.controlDistinctNames = perStage; + + // Same stage count, same per-stage image count, same bindings, same stores. The names + // are the only difference between these two programs. + ProgramBuild subject = + BuildThreeStageImageProgram(gl, perStage, static_cast(maxImageUnits), "mg_probeVsImage", + "mg_probeGsImage", "mg_probeFsImage"); + ProgramBuild control = + BuildThreeStageImageProgram(gl, perStage, static_cast(maxImageUnits), + "mg_probeSharedImage", "mg_probeSharedImage", + "mg_probeSharedImage"); + if (subject.compiled && control.compiled) { + measurement.driverMessage = FirstLine(subject.infoLog); + // Both failing is an honest refusal of a shape that is simply too big; both linking + // is a driver that does not have this bug. + measurement.detected = !subject.linked && control.linked; + MGLOG_I("[driver-bug] image-location-per-name probe: %d image uniform(s) per stage over " + "%d image unit(s); %d distinct names %s, %d shared names %s%s%s", + static_cast(perStage), static_cast(maxImageUnits), + static_cast(measurement.subjectDistinctNames), + subject.linked ? "link" : "FAIL", static_cast(measurement.controlDistinctNames), + control.linked ? "link" : "FAIL", + measurement.driverMessage.empty() ? "" : "; driver says: ", + measurement.driverMessage.c_str()); + } else { + MGLOG_I("[driver-bug] image-location-per-name probe reached no verdict (a probe stage " + "did not compile)"); + } + + if (subject.program != 0) gl.glDeleteProgram(subject.program); + if (control.program != 0) gl.glDeleteProgram(control.program); + Drain(gl); + return measurement; + } + + const ImageLocationBudgetMeasurement& ImageLocationPerNameBudget(const GLESFunctionsTable& gl) { + static const ImageLocationBudgetMeasurement measurement = ProbeImageLocationPerNameBudget(gl); + return measurement; + } + + Bool ProbeCrossStageImageQualifierMergeDropsWrites(const GLESFunctionsTable& gl) { + if (!HasImageDrawEntryPoints(gl)) return false; + + Drain(gl); + GLint maxImageUnits = 0; + GLint maxVertexImages = 0; + GLint maxFragmentImages = 0; + gl.glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + gl.glGetIntegerv(GL_MAX_VERTEX_IMAGE_UNIFORMS, &maxVertexImages); + gl.glGetIntegerv(GL_MAX_FRAGMENT_IMAGE_UNIFORMS, &maxFragmentImages); + const Bool limitsAccepted = gl.glGetError() == GL_NO_ERROR; + Drain(gl); + if (!limitsAccepted || maxVertexImages < 1 || maxFragmentImages < 1 || + maxImageUnits <= static_cast(kProbeImageUnit)) { + return false; + } + + SavedState saved; + Save(gl, saved); + Drain(gl); + + Bool dropped = false; + const char* inconclusive = nullptr; + GLuint vertexArray = 0; + ImageDrawTargets targets; + Int subjectFailed = -1; + Int controlFailed = -1; + + do { + gl.glGenVertexArrays(1, &vertexArray); + gl.glBindVertexArray(vertexArray); + PrepareForProbeDraw(gl); + targets = MakeImageDrawTargets(gl, kQualifierMergeSize); + if (!targets.valid) { + inconclusive = "the probe's own rgba32f image and RGBA8 target would not come up"; + break; + } + + subjectFailed = RunQualifierMergeCase(gl, targets, "mg_probeImage", "mg_probeImage"); + // The control names the two halves the way MobileGL's image-uniform repair does. + controlFailed = RunQualifierMergeCase(gl, targets, "mg_imageWo_mg_probeImage", + "mg_imageRo_mg_probeImage"); + if (subjectFailed < 0 || controlFailed < 0) { + inconclusive = "one of the two programs could not be built or drawn"; + break; + } + if (controlFailed > 0) { + inconclusive = "the RENAMED control lost the store too, so this driver does not " + "perform vertex-stage image writes at all - a different and much " + "larger claim than a same-name merge"; + break; + } + dropped = subjectFailed > 0; + + MGLOG_I("[driver-bug] cross-stage image qualifier merge probe: same-name pair lost the " + "store for %d/%d texels, renamed control lost %d%s", + static_cast(subjectFailed), + static_cast(kQualifierMergeSize) * static_cast(kQualifierMergeSize), + static_cast(controlFailed), + dropped ? " - THE MERGED WRITE IS DISCARDED" : ""); + } while (false); + + if (inconclusive != nullptr) { + MGLOG_I("[driver-bug] cross-stage image qualifier merge probe reached no verdict (%s)", + inconclusive); + } + + ReleaseImageDrawTargets(gl, targets); + if (vertexArray != 0) gl.glDeleteVertexArrays(1, &vertexArray); + + Restore(gl, saved); + return dropped; + } + + Bool CrossStageImageQualifierMergeDropsWrites(const GLESFunctionsTable& gl) { + static const Bool dropped = ProbeCrossStageImageQualifierMergeDropsWrites(gl); + return dropped; + } + + ImageCoherencyResidualMeasurement ProbeImageWriteReadCoherencyResidual( + const GLESFunctionsTable& gl) { + ImageCoherencyResidualMeasurement measurement; + if (!HasImageDrawEntryPoints(gl)) return measurement; + + Drain(gl); + GLint maxImageUnits = 0; + GLint maxFragmentImages = 0; + gl.glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + gl.glGetIntegerv(GL_MAX_FRAGMENT_IMAGE_UNIFORMS, &maxFragmentImages); + const Bool limitsAccepted = gl.glGetError() == GL_NO_ERROR; + Drain(gl); + // The subject declares BOTH halves of the split pair in the fragment stage. + if (!limitsAccepted || maxFragmentImages < 2 || + maxImageUnits <= static_cast(kProbeImageUnit)) { + return measurement; + } + + SavedState saved; + Save(gl, saved); + Drain(gl); + + const char* inconclusive = nullptr; + GLuint vertexArray = 0; + ImageDrawTargets targets; + Int subjectFailed = -1; + Int emittedFailed = -1; + Int controlFailed = -1; + + do { + gl.glGenVertexArrays(1, &vertexArray); + gl.glBindVertexArray(vertexArray); + PrepareForProbeDraw(gl); + targets = MakeImageDrawTargets(gl, kCoherencySize); + if (!targets.valid) { + inconclusive = "the probe's own rgba32f image and RGBA8 target would not come up"; + break; + } + + subjectFailed = RunCoherencyShape(gl, targets, kStrongestCoherencyQualifiers, + kStrongestCoherencyBarriers); + emittedFailed = + RunCoherencyShape(gl, targets, kEmittedCoherencyQualifiers, kEmittedCoherencyBarriers); + controlFailed = RunCoherencyControl(gl, targets); + if (subjectFailed < 0 || emittedFailed < 0 || controlFailed < 0) { + inconclusive = "one of the three programs could not be built or drawn"; + break; + } + if (controlFailed > 0) { + inconclusive = "the glFinish-separated control was dirty too, so this driver does " + "not make image writes visible across draws either - a different " + "and much larger claim than an in-invocation ordering residual"; + break; + } + measurement.totalTexels = static_cast(kCoherencySize) * static_cast(kCoherencySize); + measurement.mismatchedTexels = subjectFailed; + measurement.emittedShapeMismatchedTexels = emittedFailed; + // Only the STRONGEST shape decides. A driver that gets it right there but not in the + // shape MobileGL emits has a fixable defect, not an unfixable one, and this section + // is not where that belongs. + measurement.detected = subjectFailed > 0; + + MGLOG_I("[driver-bug] image write-read coherency probe: the strongest in-shader shape " + "(coherent volatile + image and global barriers) missed its own store for " + "%d/%d texels; the shape MobileGL emits (coherent + memoryBarrierImage) missed " + "%d; the glFinish-separated two-draw control missed %d%s", + static_cast(subjectFailed), static_cast(measurement.totalTexels), + static_cast(emittedFailed), static_cast(controlFailed), + measurement.detected ? " - THE IN-INVOCATION ORDERING IS NOT HONOURED" : ""); + } while (false); + + if (inconclusive != nullptr) { + MGLOG_I("[driver-bug] image write-read coherency probe reached no verdict (%s)", + inconclusive); + } + + ReleaseImageDrawTargets(gl, targets); + if (vertexArray != 0) gl.glDeleteVertexArrays(1, &vertexArray); + + Restore(gl, saved); + return measurement; + } + + const ImageCoherencyResidualMeasurement& ImageWriteReadCoherencyResidual( + const GLESFunctionsTable& gl) { + static const ImageCoherencyResidualMeasurement measurement = + ProbeImageWriteReadCoherencyResidual(gl); + return measurement; + } + namespace { Optional ProbeGeometryWriteAfterEmitBug(const GLESFunctionsTable& gl) { if (!GeometryStageSsboWriteAfterEmitDropped(gl)) return std::nullopt; @@ -303,10 +1361,93 @@ namespace MobileGL::MG_Util::SelfTest { "A shader that must write after emitting has no substitute on this driver"}; } + Optional ProbeR32FMultisampleSwizzleBug(const GLESFunctionsTable& gl) { + if (!R32FMultisampleSwizzleCorrupted(gl)) return std::nullopt; + return DriverBugFinding{ + "R32F multisample fetch through a swizzled alpha", + DriverBugVerdict::Unfixable, + "texelFetch() on an R32F GL_TEXTURE_2D_MULTISAMPLE texture whose " + "GL_TEXTURE_SWIZZLE_A is not the default returns uninitialised memory - a " + "different value every run - for any sample index other than 0, from the SECOND " + "such program in the context onward. The identical fetch with the default alpha " + "swizzle, at sample index 0, or through .x instead of .w is correct, so neither " + "R32F multisample targets nor texture swizzles are withdrawn. No substitute was " + "adopted: widening every R32F multisample target to RG32F would sidestep it and " + "doubles multisample memory, which was declined. A shader that fetches a non-zero " + "sample through a swizzled alpha cannot be relied on here"}; + } + + Optional ProbeImageLocationPerNameBug(const GLESFunctionsTable& gl) { + const ImageLocationBudgetMeasurement& measurement = ImageLocationPerNameBudget(gl); + if (!measurement.detected) return std::nullopt; + String detail = format( + "this driver's image-location budget is charged per distinct uniform NAME, not per " + "image unit: a vertex+geometry+fragment program declaring {} image uniform(s) per " + "stage links when all three stages share one set of names ({} distinct) and is " + "rejected when each stage names its own copies ({} distinct), with the same " + "bindings, the same qualifiers and the same stores either way", + measurement.perStageImageUniforms, measurement.controlDistinctNames, + measurement.subjectDistinctNames); + if (!measurement.driverMessage.empty()) { + detail += format(" - the driver says \"{}\"", measurement.driverMessage); + } + detail += + ". MobileGL names a repaired image uniform after the REPAIR rather than after the " + "stage, so stages that use an image alike keep one shared name and stay merged; " + "applications never see the link failure"; + return DriverBugFinding{"Image locations charged per uniform name", + DriverBugVerdict::Fixed, Move(detail)}; + } + + Optional ProbeCrossStageImageQualifierMergeBug(const GLESFunctionsTable& gl) { + if (!CrossStageImageQualifierMergeDropsWrites(gl)) return std::nullopt; + return DriverBugFinding{ + "Cross-stage image qualifier merge discards writes", + DriverBugVerdict::Fixed, + "an image declared `coherent writeonly` in one stage and `coherent readonly` in " + "another under the SAME name is merged into one uniform whose writing stage's " + "imageStore()s are then silently discarded - every fragment reads the " + "pre-store value. The identical shader pair with the two halves renamed keeps " + "every store, which is what proves the stage's image writes work and only the " + "merge is at fault. MobileGL renames the two halves (mg_imageWo_ / mg_imageRo_) so " + "the driver cannot merge them, and application behaviour is correct"}; + } + + Optional ProbeImageCoherencyResidualBug(const GLESFunctionsTable& gl) { + const ImageCoherencyResidualMeasurement& measurement = ImageWriteReadCoherencyResidual(gl); + if (!measurement.detected) return std::nullopt; + const auto percentOf = [&](Int texels) { + return measurement.totalTexels > 0 ? 100.0 * texels / measurement.totalTexels : 0.0; + }; + return DriverBugFinding{ + "Image write-to-read ordering within one invocation", + DriverBugVerdict::Unfixable, + format( + "an imageLoad() does not observe the imageStore() that precedes it in the same " + "fragment invocation for {} of {} texels ({:.2f}%) under the STRONGEST shape " + "ESSL offers - a `coherent volatile` readonly/writeonly pair on one binding " + "with both memoryBarrierImage() and memoryBarrier() between the store and the " + "read - which is what leaves nothing to substitute. The shape MobileGL emits " + "today (`coherent` plus memoryBarrierImage()) misses {} ({:.2f}%) on this " + "driver. The same dependency split across two draws with a glMemoryBarrier and " + "a glFinish between them is clean, so writes do become visible; it is the " + "ordering inside one invocation that is not honoured. MobileGL keeps the " + "coherent pair and the barrier - without them every texel is wrong - and a " + "shader that reads back its own image write within one invocation cannot be " + "relied on here", + measurement.mismatchedTexels, measurement.totalTexels, + percentOf(measurement.mismatchedTexels), measurement.emittedShapeMismatchedTexels, + percentOf(measurement.emittedShapeMismatchedTexels))}; + } + // The table. One row per known driver bug; see the header for how to add a sibling. using DriverBugProbeFn = Optional (*)(const GLESFunctionsTable&); constexpr DriverBugProbeFn kGlesDriverBugProbes[] = { &ProbeGeometryWriteAfterEmitBug, + &ProbeR32FMultisampleSwizzleBug, + &ProbeImageLocationPerNameBug, + &ProbeCrossStageImageQualifierMergeBug, + &ProbeImageCoherencyResidualBug, }; } // namespace diff --git a/MobileGL/MG_Util/SelfTest/DriverBugProbes.h b/MobileGL/MG_Util/SelfTest/DriverBugProbes.h index 16315bee..f3cf4333 100644 --- a/MobileGL/MG_Util/SelfTest/DriverBugProbes.h +++ b/MobileGL/MG_Util/SelfTest/DriverBugProbes.h @@ -33,9 +33,7 @@ namespace MobileGL::MG_Util::SelfTest { // // ADDING A SIBLING IS ONE FUNCTION: write an `Optional ProbeXxx(gl)` // that returns nullopt when the driver is not affected, and add it to the table in - // CollectGlesKnownDriverBugs(). Known siblings still to be probed: the R32F-MSAA swizzle - // corruption, the image-location-per-name link limit, the cross-stage qualifier merge, and - // the coherency residual. + // CollectGlesKnownDriverBugs(). // What MobileGL can do about a bug this device HAS. There is deliberately no "not // affected" member: a driver that passes the probe produces no finding at all, so the @@ -78,6 +76,123 @@ namespace MobileGL::MG_Util::SelfTest { // ProbeGeometryStageSsboWriteAfterEmitDropped(), evaluated at most once per process. Bool GeometryStageSsboWriteAfterEmitDropped(const MG_External::GLESFunctionsTable& gl); + // Samples one R32F GL_TEXTURE_2D_MULTISAMPLE texel through a swizzled alpha channel, twice, + // with a separately linked program each time. Returns true only when the swizzled read goes + // wrong while every control read stays right. + // + // Adreno 830 returns uninitialised memory - a different value every run - for + // texelFetch(sampler2DMS, ..., sampleIndex != 0).w on an R32F multisample texture whose + // GL_TEXTURE_SWIZZLE_A is not the default, from the SECOND such program in the context + // onward. The first program reads correctly, which is why the probe links two. + // + // THREE CONTROLS, each identical to the subject but for one variable, and all three must + // read correctly for a wrong subject to count: (1) the same fetch with + // GL_TEXTURE_SWIZZLE_A left at its default, (2) the same fetch at sample index 0, and + // (3) the same swizzled texture read through .x instead of .w. Without them a driver that + // simply cannot render R32F, or cannot sample multisample textures at all, would be + // reported as having this very specific corruption. + // + // Returns false when the driver cannot host the shape (no multisample R32F colour target, + // fewer than two samples, a missing entry point, an incomplete framebuffer): an + // inconclusive probe must never be reported as a bug. Restores every piece of GL state it + // touches. + Bool ProbeR32FMultisampleSwizzleCorruption(const MG_External::GLESFunctionsTable& gl); + + // ProbeR32FMultisampleSwizzleCorruption(), evaluated at most once per process. + Bool R32FMultisampleSwizzleCorrupted(const MG_External::GLESFunctionsTable& gl); + + // What the image-location budget probe measured. `detected` is the only field the verdict + // depends on; the rest exist so the report can say what the shape was instead of asserting + // a number that was true on one device in one campaign. + struct ImageLocationBudgetMeasurement { + Bool detected = false; + // Image uniforms declared per stage in both the subject and the control - one more than + // GL_MAX_GEOMETRY_IMAGE_UNIFORMS, which is the smallest of the three stages' budgets. + Int perStageImageUniforms = 0; + // Distinct uniform NAMES in the subject (per-stage-unique) and in the control (shared). + Int subjectDistinctNames = 0; + Int controlDistinctNames = 0; + // The first line of the driver's info log for the failing link, so the report quotes the + // driver rather than paraphrasing it. + String driverMessage; + }; + + // Links the same three-stage (vertex, geometry, fragment) program twice: once with every + // stage naming its image uniforms uniquely, once with all three stages sharing one set of + // names. Both declare the same number of image uniforms per stage, on the same bindings, + // with the same qualifier and the same stores - the names are the only difference. + // + // Adreno 830 charges its image-location budget per distinct NAME, so the shared-name program + // links while the per-stage-named one is rejected with "Image location or component exceeds + // max allowed", even though nothing about the image USAGE changed. That is what makes the + // shared-name link the control: it proves the driver can host this exact amount of image + // work and that only the naming moved the answer. + // + // `detected` is false unless the subject fails AND the control links. Both failing means the + // shape is simply too large for the driver (an honest refusal); both linking means the + // driver does not have this bug. + ImageLocationBudgetMeasurement ProbeImageLocationPerNameBudget(const MG_External::GLESFunctionsTable& gl); + + // ProbeImageLocationPerNameBudget(), evaluated at most once per process. + const ImageLocationBudgetMeasurement& ImageLocationPerNameBudget(const MG_External::GLESFunctionsTable& gl); + + // Draws one quad whose vertex stage stores to a `coherent writeonly` image and whose + // fragment stage reads the same image declared `coherent readonly` under the SAME name, then + // checks every fragment saw the store. Returns true only when the same-name program loses + // the store while the different-name control keeps it. + // + // Adreno 830 merges the two declarations into one uniform and silently discards the writing + // stage's stores. The control is the identical pair of shaders with the two halves renamed - + // exactly what MobileGL's image-uniform repair emits - which keeps every store. Without it + // the probe would be indistinguishable from "this driver cannot store to images from the + // vertex stage", which is a different and much larger claim. + // + // Returns false when the driver advertises no vertex-stage image uniforms, when an entry + // point is missing, or when the setup fails. + Bool ProbeCrossStageImageQualifierMergeDropsWrites(const MG_External::GLESFunctionsTable& gl); + + // ProbeCrossStageImageQualifierMergeDropsWrites(), evaluated at most once per process. + Bool CrossStageImageQualifierMergeDropsWrites(const MG_External::GLESFunctionsTable& gl); + + // What the image coherency probe measured. The residual is reported rather than hard-coded: + // it is a rate, it differs between devices, and a report that quotes a number measured + // somewhere else is worse than no number at all. + struct ImageCoherencyResidualMeasurement { + Bool detected = false; + // Texels the STRONGEST in-shader shape missed - that is what makes the defect unfixable. + Int mismatchedTexels = 0; + // Texels the shape MobileGL emits today missed, on the same driver in the same run. It + // is what applications actually get, and it is not always the same number. + Int emittedShapeMismatchedTexels = 0; + Int totalTexels = 0; + }; + + // Counts the texels whose dependent imageLoad() did not observe the imageStore() that + // precedes it in the same fragment invocation. + // + // THE SUBJECT IS THE STRONGEST SHAPE THE LANGUAGE OFFERS - a `coherent volatile` + // readonly/writeonly pair on one binding with BOTH memoryBarrierImage() and memoryBarrier() + // between the store and the read - and that choice is the whole reason the row can say + // "unfixable". Probing only the shape MobileGL emits today (`coherent` plus + // memoryBarrierImage()) reports a bug on drivers where simply adding `volatile` makes the + // read correct, which is a defect MobileGL could fix rather than one it cannot: measured on + // Mesa llvmpipe, the emitted shape misses every texel while the `volatile` shape misses + // none. Only a driver that fails even the strongest shape has no in-shader substitute left. + // + // The control is the same dependency split across TWO draws with a glMemoryBarrier and a + // glFinish between them. It separates "this driver cannot make image writes visible at all" + // (control also dirty - a far worse defect, and the probe declines to call it this one) from + // the finding, which is about ordering inside one invocation. + // + // `detected` is false unless the strongest shape is dirty AND the control is clean. The + // shape MobileGL emits is measured either way, so the report can say what applications get. + ImageCoherencyResidualMeasurement ProbeImageWriteReadCoherencyResidual( + const MG_External::GLESFunctionsTable& gl); + + // ProbeImageWriteReadCoherencyResidual(), evaluated at most once per process. + const ImageCoherencyResidualMeasurement& ImageWriteReadCoherencyResidual( + const MG_External::GLESFunctionsTable& gl); + // Every known driver bug this GLES driver actually has. Bugs it does not have are absent, // so an unaffected device renders an empty section rather than a wall of "not affected". Vector CollectGlesKnownDriverBugs(const MG_External::GLESFunctionsTable& gl); From 2f62970dd56778bb8e75c6cdc0599b0564877b32 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 22 Aug 2026 08:59:03 -0400 Subject: [PATCH 2/3] [Refactor] (SelfTest): give every POST capability row a PASS/WARN/FAIL verdict and keep INFO for identity --- MobileGL/MG_Util/SelfTest/DriverPost.cpp | 440 +++++++++++++++-------- MobileGL/MG_Util/SelfTest/DriverPost.h | 25 +- 2 files changed, 302 insertions(+), 163 deletions(-) diff --git a/MobileGL/MG_Util/SelfTest/DriverPost.cpp b/MobileGL/MG_Util/SelfTest/DriverPost.cpp index 3dc44e16..f4770fb3 100644 --- a/MobileGL/MG_Util/SelfTest/DriverPost.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverPost.cpp @@ -37,17 +37,19 @@ namespace MobileGL::MG_Util::SelfTest { namespace { // Display ranks for PostCheck::displayRank: within one backend section, FAIL - // rows render first, then WARN, PASS, INFO, then the device-driver identity + // rows render first, then WARN, then PASS, then the device-driver identity // strings, and always last (regardless of status) the strings MobileGL itself // reports to applications. Rows are stable-sorted, so relative order within a // rank is preserved. Purely cosmetic: the verdict computation is unaffected. + // + // There is no rank between PASS and the identity blocks because there are no INFO + // capability rows any more - see the taxonomy on ReportBuilder below. enum DisplayRank : Int { RankFail = 0, RankWarn = 1, RankPass = 2, - RankInfo = 3, - RankDriverReported = 4, - RankMobileGLReported = 5, + RankDriverReported = 3, + RankMobileGLReported = 4, }; // Both backends' fp64 rows end the same way, and the sentence they end with depends on @@ -68,6 +70,28 @@ namespace MobileGL::MG_Util::SelfTest { "to advertise it anyway"; } + // ===================== THE ROW VERDICT TAXONOMY ===================== + // + // EVERY CAPABILITY ROW IS PASS, WARN OR FAIL. INFO IS FOR IDENTITY ONLY - renderer + // names, version strings, driver strings - and there is deliberately no way to emit an + // INFO capability row from here: the only INFO emitters are the two identity helpers at + // the bottom of this struct. A row that says "not supported; no impact today" tells a + // reader nothing about whether their application will work, which is the one question + // the screen exists to answer. + // + // PASS - the backend supports the capability directly. + // WARN - the backend does NOT support it directly, but a MobileGL quirk substitutes + // and the application still sees correct behaviour. The detail names the + // substitute and whatever it costs. + // FAIL - unsupported, with no substitute: an application that uses it gets wrong + // output, a failed draw, or nothing at all. The detail says what breaks. + // + // FAIL comes in two flavours, and the difference is about the BACKEND, not the row. + // Fail() is for a capability the backend cannot start without, and it drives the + // backend summary to UNSUPPORTED. FailOptional() is for a capability that is just as + // unusable but that the backend runs fine without, so the summary stays DEGRADED - a + // device with no dual-source blend still plays Minecraft, and reporting the whole + // backend as unusable because of it would be a lie in the other direction. struct ReportBuilder { BackendPostReport report; Bool fatalFailed = false; @@ -77,20 +101,27 @@ namespace MobileGL::MG_Util::SelfTest { report.checks.push_back({Move(name), "PASS", Move(detail), RankPass}); } + // FAIL on a capability the backend cannot run without: the backend summary becomes + // UNSUPPORTED. void Fail(String name, String detail) { fatalFailed = true; report.checks.push_back({Move(name), "FAIL", Move(detail), RankFail}); } + // FAIL on a capability with no substitute that the backend can nonetheless run + // without. The row is as red as any other FAIL - an application using it does not + // work - but the backend summary degrades rather than declaring the whole backend + // unusable. + void FailOptional(String name, String detail) { + warnUnmet = true; + report.checks.push_back({Move(name), "FAIL", Move(detail), RankFail}); + } + void Warn(String name, String detail) { warnUnmet = true; report.checks.push_back({Move(name), "WARN", Move(detail), RankWarn}); } - void Info(String name, String detail) { - report.checks.push_back({Move(name), "INFO", Move(detail), RankInfo}); - } - // A "Backend driver reported ..." identity string straight from the device // driver; rendered after the regular rows. void DriverReported(String name, String detail) { @@ -158,17 +189,19 @@ namespace MobileGL::MG_Util::SelfTest { // applications DO, not just what they can do: with the extension advertised, Iris // and Sodium batch their pipeline compiles and poll GL_COMPLETION_STATUS_KHR. // - // PASS when it is on (the intended configuration once the default flips), INFO when - // it is off - "off" is a supported configuration, not a degradation, so it must not - // colour the verdict. Either way the row names MOBILEGL_ASYNC_SHADER_COMPILE, so a - // user reading a POST page can tell which side of the switch they are on and how to - // change it. + // PASS when it is on (the intended configuration once the default flips), WARN when it + // is off: the capability is not advertised, and what stands in for it - compiling on + // the calling thread - produces exactly the same programs, just without the overlap. + // Either way the row names MOBILEGL_ASYNC_SHADER_COMPILE, so a user reading a POST page + // can tell which side of the switch they are on and how to change it. void AppendAsyncShaderCompileRow(ReportBuilder& builder) { constexpr const char* rowName = "Asynchronous shader compilation"; if (!MG_Util::Async::AsyncShaderCompileEnabled()) { - builder.Info(rowName, - "off; glCompileShader and glLinkProgram run on the calling thread and " - "GL_KHR_parallel_shader_compile is not advertised (set environment variable " + builder.Warn(rowName, + "off; GL_KHR_parallel_shader_compile is not advertised and " + "glCompileShader/glLinkProgram run on the calling thread instead. The " + "programs are identical - only the overlap is lost, so a shaderpack load " + "takes as long as its compiles do (set environment variable " "MOBILEGL_ASYNC_SHADER_COMPILE=1 to enable it)"); return; } @@ -301,22 +334,30 @@ namespace MobileGL::MG_Util::SelfTest { builder.Pass("Polygon mode", "glPolygonMode GL_LINE/GL_POINT available via GL_NV/ANGLE_polygon_mode"); } else { - builder.Warn("Polygon mode", - "no GL_NV/ANGLE_polygon_mode; glPolygonMode GL_LINE/GL_POINT falls back to GL_FILL"); + builder.FailOptional("Polygon mode", + "no GL_NV/ANGLE_polygon_mode; glPolygonMode GL_LINE/GL_POINT silently " + "falls back to GL_FILL. There is no substitute - wireframe and point " + "rasterization would have to be rebuilt out of line/point primitives - " + "so an application asking for either gets solid triangles instead"); } if (caps.SupportsIndexedColorMask) { builder.Pass("Indexed color mask", "per-draw-buffer glColorMaski available (ES 3.2 core or draw_buffers_indexed)"); } else { - builder.Warn("Indexed color mask", - "no indexed glColorMaski; per-draw-buffer color masks fall back to draw buffer 0"); + builder.FailOptional("Indexed color mask", + "no indexed glColorMaski; every per-draw-buffer colour mask collapses " + "onto draw buffer 0's, so an MRT pass that masks its attachments " + "differently writes the wrong channels to all but one of them, with " + "nothing to substitute"); } if (caps.SupportsDualSourceBlend) { builder.Pass("Dual-source blend", "GL_SRC1_* dual-source blend factors available via GL_EXT_blend_func_extended"); } else { - builder.Warn("Dual-source blend", - "no GL_EXT_blend_func_extended; GL_SRC1_* dual-source blend factors hard-fail at draw"); + builder.FailOptional("Dual-source blend", + "no GL_EXT_blend_func_extended; a draw using a GL_SRC1_* blend factor " + "hard-fails, and a second fragment output cannot be produced any other " + "way"); } if (es31) { @@ -328,10 +369,13 @@ namespace MobileGL::MG_Util::SelfTest { builder.Pass("Vertex shader storage blocks", format("GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS = {}", maxVertexSsboBlocks)); } else { - builder.Warn("Vertex shader storage blocks", - format("GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS = {}; the Flywheel/Create indirect draw " - "machinery cannot read indirect command buffers from the vertex stage", - maxVertexSsboBlocks)); + builder.FailOptional( + "Vertex shader storage blocks", + format("GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS = {}; the vertex stage cannot read a " + "storage buffer at all, and there is nothing to read one with instead - the " + "Flywheel/Create indirect draw machinery, which fetches its per-instance data " + "from a vertex-stage SSBO, cannot run", + maxVertexSsboBlocks)); } if (caps.MaxShaderStorageBufferBindings >= 8) { @@ -350,14 +394,15 @@ namespace MobileGL::MG_Util::SelfTest { if (caps.SupportsPersistentMapping) { builder.Pass("GL_EXT_buffer_storage", "supported (persistent buffer mapping)"); } else { - builder.Info("GL_EXT_buffer_storage", - "not supported; no impact today: the frontend fully emulates persistent " - "mapping regardless of this extension"); + builder.Warn("GL_EXT_buffer_storage", + "not supported; the frontend emulates persistent mapping with its own " + "shadow storage instead, so glBufferStorage and a GL_MAP_PERSISTENT_BIT " + "mapping behave correctly - at the cost of the shadow copy"); } if (caps.SupportsBaseInstance) { builder.Pass("GL_EXT_base_instance", "supported (native baseInstance draws)"); } else { - builder.Info("GL_EXT_base_instance", + builder.Warn("GL_EXT_base_instance", "not supported; direct baseInstance draws are emulated by shifting the " "instanced arrays' attribute offsets, and gl_BaseInstance by a uniform. " "The one gap is an INDIRECT draw whose command carries a non-zero " @@ -366,15 +411,17 @@ namespace MobileGL::MG_Util::SelfTest { // Both multi-draw rows gate on the capability flags, not the entry-point pointers: // eglGetProcAddress may hand back a non-NULL stub for these on drivers without the // extension (NVIDIA ES does, and its glMultiDrawElementsBaseVertexEXT stub silently - // drops every draw), so the pointers prove nothing. Absence is INFO in both cases - // because MobileGL falls back to an equivalent per-draw loop. + // drops every draw), so the pointers prove nothing. Absence is WARN in both cases: + // MobileGL falls back to an equivalent per-draw loop, so the output is identical and + // only the command count changes. if (caps.SupportsMultiDrawIndirect) { builder.Pass("Multi-draw indirect", "glMultiDrawArrays/ElementsIndirectEXT available via GL_EXT_multi_draw_indirect"); } else { - builder.Info("Multi-draw indirect", - "GL_EXT_multi_draw_indirect not supported; no impact today: multi-draw " - "indirect is decomposed into per-command indirect draws regardless"); + builder.Warn("Multi-draw indirect", + "GL_EXT_multi_draw_indirect not supported; MobileGL decomposes a multi-draw " + "indirect batch into per-command indirect draws, which renders the same " + "thing for one driver call per command instead of one per batch"); } if (caps.SupportsMultiDrawElementsBaseVertex) { builder.Pass("Multi-draw base vertex", @@ -382,7 +429,7 @@ namespace MobileGL::MG_Util::SelfTest { "with GL_EXT_multi_draw_arrays); glMultiDrawElementsBaseVertex batches into one " "driver call"); } else { - builder.Info("Multi-draw base vertex", + builder.Warn("Multi-draw base vertex", "glMultiDrawElementsBaseVertexEXT not supported (needs EXT/OES_" "draw_elements_base_vertex plus GL_EXT_multi_draw_arrays); the batch " "takes the next emulation tier instead, with identical output - see " @@ -407,9 +454,12 @@ namespace MobileGL::MG_Util::SelfTest { "available (ES 3.1 core); the opt-in \"compute\" multi-draw tier can flatten a " "whole batch into one draw"); } else { - builder.Info("Compute shaders", - "not available (pre-ES 3.1); no impact on the default multi-draw tiers, which " - "never use compute"); + builder.FailOptional("Compute shaders", + "not available (pre-ES 3.1); MobileGL advertises " + "GL_ARB_compute_shader on an OpenGL 4.x context and there is no way to " + "run a glDispatchCompute without the ES counterpart, so a program with " + "a compute shader cannot be built at all. The default multi-draw tiers " + "never use compute, so nothing else is lost"); } { // The same resolution the backend runs, over the capabilities probed here. @@ -420,45 +470,58 @@ namespace MobileGL::MG_Util::SelfTest { // was consulted. using MG_Backend::DirectGLES::MultiDrawImpl::ResolveTier; String resolution; - ResolveTier(caps, glesFuncs, MG_Config::Features.EsprytMultiDrawMode, &resolution); - builder.Info("Multi-draw elements tier", - "glMultiDrawElements(BaseVertex) emulation: " + resolution + - "; override with MOBILEGL_ESPRYT_MULTIDRAW_MODE"); + const MG_Config::GLESMultiDrawMode tier = + ResolveTier(caps, glesFuncs, MG_Config::Features.EsprytMultiDrawMode, &resolution); + const String detail = "glMultiDrawElements(BaseVertex) emulation: " + resolution + + "; override with MOBILEGL_ESPRYT_MULTIDRAW_MODE"; + // PASS only on the tier that hands the whole batch to the driver in one call. + // Every other tier is a MobileGL substitute: the output is identical, the + // command count is not. + if (tier == MG_Config::GLESMultiDrawMode::Ext) { + builder.Pass("Multi-draw elements tier", detail); + } else { + builder.Warn("Multi-draw elements tier", + detail + " - the batch is replayed rather than handed over whole, " + "which renders the same thing for more driver calls"); + } } if (caps.SupportsTextureBorderClamp) { builder.Pass("Texture border clamp", "supported (GL_TEXTURE_BORDER_COLOR reaches the driver, so " "GL_CLAMP_TO_BORDER samples the colour the application set)"); } else { - builder.Warn("Texture border clamp", - "not supported (pre-ES 3.2 without GL_EXT/OES_texture_border_clamp); " - "GL_TEXTURE_BORDER_COLOR is not synced to the driver at all, so anything " - "sampling outside a GL_CLAMP_TO_BORDER texture reads the driver's default " - "border instead of the requested colour"); + builder.FailOptional( + "Texture border clamp", + "not supported (pre-ES 3.2 without GL_EXT/OES_texture_border_clamp); " + "GL_TEXTURE_BORDER_COLOR is not synced to the driver at all, so anything " + "sampling outside a GL_CLAMP_TO_BORDER texture reads the driver's default " + "border instead of the requested colour, and no wrap mode substitutes for it"); } if (caps.SupportsTextureCubeMapArray) { builder.Pass("Texture cube map array", "supported (GL_TEXTURE_CUBE_MAP_ARRAY textures get real storage and can be " "attached to a framebuffer)"); } else { - builder.Warn("Texture cube map array", - "not supported (pre-ES 3.2 without GL_EXT/OES_texture_cube_map_array); a cube " - "map array texture gets no driver storage at all, so sampling one reads nothing " - "and rendering to one does not reach the screen"); + builder.FailOptional( + "Texture cube map array", + "not supported (pre-ES 3.2 without GL_EXT/OES_texture_cube_map_array); a cube " + "map array texture gets no driver storage at all, so sampling one reads nothing " + "and rendering to one does not reach the screen. Nothing substitutes: the " + "shaders that declare a samplerCubeArray do not compile either"); } - // WARN, not FAIL, and the choice is deliberate. The consequence is severe - buffer - // textures are CORE in OpenGL 3.1 and MobileGL advertises a 4.x context, so an - // application may use one without asking, and nothing degrades gracefully: the - // texture gets no driver storage, and every shader declaring a samplerBuffer fails - // to compile outright, because SPIRV-Cross emits `#extension GL_EXT_texture_buffer : - // require` for it below ESSL 320, so the program never links and every draw using it - // silently draws nothing. That is how Minecraft 26.3, whose cloud layer is built - // entirely from gl_VertexID plus texelFetch on a GL_R8I buffer texture, loses its - // clouds. But FAIL means "this backend cannot run on this driver", and that is not - // true: such a device runs everything that does not touch a buffer texture. It is - // also exactly the shape of the "Texture cube map array" row above, which loses its - // shaders to the same SPIRV-Cross `: require` mechanism and is a WARN - two adjacent - // rows with one consequence must not carry two severities. + // FAIL, and specifically FailOptional. The consequence is severe - buffer textures + // are CORE in OpenGL 3.1 and MobileGL advertises a 4.x context, so an application + // may use one without asking, and nothing degrades gracefully: the texture gets no + // driver storage, and every shader declaring a samplerBuffer fails to compile + // outright, because SPIRV-Cross emits `#extension GL_EXT_texture_buffer : require` + // for it below ESSL 320, so the program never links and every draw using it silently + // draws nothing. That is how Minecraft 26.3, whose cloud layer is built entirely + // from gl_VertexID plus texelFetch on a GL_R8I buffer texture, loses its clouds. + // There is no substitute, which is what makes the row FAIL; the backend still RUNS + // everything that does not touch a buffer texture, which is what keeps the failure + // out of the backend summary. It is exactly the shape of the "Texture cube map + // array" row above, which loses its shaders to the same SPIRV-Cross `: require` + // mechanism - two adjacent rows with one consequence must carry one severity. // The limit is stated on every tier because it is the one number an application can // read, and on the None tier it is knowingly a fiction (see below). { @@ -495,16 +558,17 @@ namespace MobileGL::MG_Util::SelfTest { break; case Tier::None: default: - builder.Warn("Buffer textures", - format("not supported (pre-ES 3.2 without GL_EXT/OES_texture_buffer); " - "glTexBuffer does not exist, so a buffer texture gets no storage, " - "and any shader declaring a samplerBuffer fails to compile and " - "leaves its program unlinked - every draw using it is a silent " - "no-op. MobileGL still reports GL_MAX_TEXTURE_BUFFER_SIZE = {}: " - "the value is a floor it cannot honour, kept because an OpenGL " - "4.x context may not answer 0 and GL has no way to say that a " - "core feature is missing", - advertisedLimit)); + builder.FailOptional( + "Buffer textures", + format("not supported (pre-ES 3.2 without GL_EXT/OES_texture_buffer); " + "glTexBuffer does not exist, so a buffer texture gets no storage, " + "and any shader declaring a samplerBuffer fails to compile and " + "leaves its program unlinked - every draw using it is a silent " + "no-op. MobileGL still reports GL_MAX_TEXTURE_BUFFER_SIZE = {}: " + "the value is a floor it cannot honour, kept because an OpenGL " + "4.x context may not answer 0 and GL has no way to say that a " + "core feature is missing", + advertisedLimit)); break; } } @@ -513,7 +577,10 @@ namespace MobileGL::MG_Util::SelfTest { // either answer, and the rows exist so the two halves of the loss are named at // startup instead of discovered as a shader that will not compile or an // unexplained GL_INVALID_OPERATION at draw setup. - builder.Pass("fp64", AppendFp64AdvertisementNote( + // WARN, not PASS: ESSL has no 64-bit float type, so this backend does not support + // fp64 directly at all. What it has is a complete substitute - the shaders build and + // run - which is exactly what WARN means. + builder.Warn("fp64", AppendFp64AdvertisementNote( "demoted to fp32 - ESSL has no 64-bit float type, so every double / " "dvec / dmat in a shader is narrowed to 32 bits before transpilation " "(DemoteFloat64Pass). Such shaders COMPILE AND RUN, at single " @@ -531,10 +598,11 @@ namespace MobileGL::MG_Util::SelfTest { builder.Pass("Tessellation patch parameters", "glPatchParameteri present (GL_PATCH_VERTICES reaches the driver)"); } else { - builder.Warn("Tessellation patch parameters", - "glPatchParameteri missing (pre-ES 3.2 without GL_EXT_tessellation_shader); " - "GL_PATCH_VERTICES stays at the driver default of 3 and a patch draw of any " - "other size renders nothing"); + builder.FailOptional("Tessellation patch parameters", + "glPatchParameteri missing (pre-ES 3.2 without " + "GL_EXT_tessellation_shader); GL_PATCH_VERTICES stays at the driver " + "default of 3 and a patch draw of any other size renders nothing - " + "the patch size cannot be communicated any other way"); } if (glesFuncs.glGenTransformFeedbacks != nullptr && glesFuncs.glBindTransformFeedback != nullptr && glesFuncs.glPauseTransformFeedback != nullptr && glesFuncs.glResumeTransformFeedback != nullptr) { @@ -550,7 +618,9 @@ namespace MobileGL::MG_Util::SelfTest { builder.Pass("GL_EXT_texture_norm16", "supported"); } else { builder.Warn("GL_EXT_texture_norm16", - "not supported; 16-bit normalized texture formats need emulation"); + "not supported; MobileGL substitutes a wider format for every 16-bit " + "normalized texture, so the texels are still readable at their declared " + "precision at the cost of the extra storage"); } if (caps.SupportsRenderSnorm) { builder.Pass("GL_EXT_render_snorm", @@ -583,23 +653,31 @@ namespace MobileGL::MG_Util::SelfTest { "render targets (Iris reports GL_FRAMEBUFFER_UNSUPPORTED and refuses to load)"); } - // INFO, never WARN: this is the HOST driver's ability to compile its own ESSL on - // its own threads, and MobileGL's asynchronous compilation does not depend on it - // in the slightest - the pool parallelises GLSL -> SPIR-V -> ESSL translation, - // which is where a shaderpack load actually spends its time, and it does that on - // a driver that has never heard of the extension. The row exists so that the day - // the driver-side half is overlapped too, the POST already says which devices can. - builder.Info("Driver GL_KHR_parallel_shader_compile", - caps.SupportsParallelShaderCompile - ? "supported; the device driver can also compile the translated ESSL off-thread" - : "not supported; the device driver compiles the translated ESSL on the calling " - "thread (MobileGL's own compile pool is unaffected)"); + // WARN and never FAIL when it is absent: this is the HOST driver's ability to + // compile its own ESSL on its own threads, and MobileGL's own compile pool stands in + // for all of it that matters - the pool parallelises GLSL -> SPIR-V -> ESSL + // translation, which is where a shaderpack load actually spends its time, and it + // does that on a driver that has never heard of the extension. The row exists so + // that the day the driver-side half is overlapped too, the POST already says which + // devices can. + if (caps.SupportsParallelShaderCompile) { + builder.Pass("Driver GL_KHR_parallel_shader_compile", + "supported; the device driver can also compile the translated ESSL off-thread"); + } else { + builder.Warn("Driver GL_KHR_parallel_shader_compile", + "not supported; the device driver compiles the translated ESSL on the calling " + "thread. MobileGL's own compile pool substitutes for the expensive half of the " + "work (GLSL -> SPIR-V -> ESSL) and is unaffected, so loads still overlap"); + } - builder.Info("Indirect gl_InstanceID semantics", - caps.IndirectDrawInstanceIdIncludesBaseInstance - ? "includes baseInstance (ANGLE-style; MobileGL's shader rewrite keeps gl_InstanceID " - "zero-based)" - : "conforming (zero-based)"); + if (caps.IndirectDrawInstanceIdIncludesBaseInstance) { + builder.Warn("Indirect gl_InstanceID semantics", + "includes baseInstance (ANGLE-style), which is not what GL promises; " + "MobileGL's shader rewrite subtracts it back out so gl_InstanceID stays " + "zero-based and instanced indirect draws index their arrays correctly"); + } else { + builder.Pass("Indirect gl_InstanceID semantics", "conforming (zero-based)"); + } builder.DriverReported("Backend driver reported GL_VENDOR", caps.GLESVendorString); builder.DriverReported("Backend driver reported GL_RENDERER", caps.GLESRendererString); @@ -614,10 +692,11 @@ namespace MobileGL::MG_Util::SelfTest { const MG_External::GLESFunctionsTable& glesFuncs) { const String disabledNote = TimerQueryDisabledNote(); if (!caps.SupportsDisjointTimerQuery) { - builder.Warn("Timer queries", - "GL_EXT_disjoint_timer_query not supported; timer queries unavailable; " - "Minecraft F3 GPU% will not show" + - disabledNote); + builder.FailOptional("Timer queries", + "GL_EXT_disjoint_timer_query not supported; there is no way to time " + "GPU work from the client, so glBeginQuery(GL_TIME_ELAPSED) has " + "nothing to stand in for it and Minecraft's F3 GPU% will not show" + + disabledNote); return; } // Every emit carries the extension-presence fact the old standalone @@ -1505,7 +1584,13 @@ namespace MobileGL::MG_Util::SelfTest { const IterationRPWitnessEligibilityResult eligibility = EvaluateIterationRPWitnessEligibility(limits); if (eligibility.eligibility == IterationRPWitnessEligibility::SkipUnsupportedNativeFeatureSet) { - builder.Info(RowName, eligibility.detail); + // WARN, not FAIL: there is nothing to witness on a device with no native + // subgroup contract, and the renderer takes its non-subgroup iteration path, + // which produces the same image. + builder.Warn(RowName, + eligibility.detail + + "; the renderer takes its non-subgroup iteration path instead, which " + "renders the same thing without the first-reduction shortcut"); return; } if (eligibility.eligibility == IterationRPWitnessEligibility::FailInadequateLimits) { @@ -2204,20 +2289,23 @@ namespace MobileGL::MG_Util::SelfTest { if (features.multiDrawIndirect == VK_TRUE) { builder.Pass("multiDrawIndirect", "indirect multi-draw batches run as single native commands"); } else { - builder.Info("multiDrawIndirect", - "unsupported; multi-draw batches fall back to one draw per command (tier " - "\"indirect\" of the multi-draw dispatch is unavailable)"); + builder.Warn("multiDrawIndirect", + "unsupported; MobileGL unrolls a multi-draw batch into one draw per command " + "(tier \"indirect\" of the multi-draw dispatch is unavailable), which renders " + "the same thing for more commands"); } if (features.drawIndirectFirstInstance == VK_TRUE) { builder.Pass("drawIndirectFirstInstance", "indirect commands may carry a non-zero firstInstance"); } else { - builder.Warn("drawIndirectFirstInstance", - "unsupported; indirect commands with a non-zero baseInstance cannot run natively"); + builder.FailOptional("drawIndirectFirstInstance", + "unsupported; an indirect command carrying a non-zero baseInstance " + "cannot run, and the offset cannot be folded into the command from the " + "CPU because the command is on the GPU"); } - // Multi-draw dispatch tiers (ext -> indirect -> unroll). INFO on the missing - // pieces: every tier has a fallback, nothing is lost, only batched into more - // commands. The renderer resolves the same chain at device creation, clamped - // by MOBILEGL_MAGMA_MULTIDRAW_MODE. + // Multi-draw dispatch tiers (ext -> indirect -> unroll). WARN on the missing + // pieces: every tier has a fallback that renders the same thing, only batched + // into more commands. The renderer resolves the same chain at device creation, + // clamped by MOBILEGL_MAGMA_MULTIDRAW_MODE. { Bool multiDrawExtUsable = false; if (HasVkExtension(deviceExtensions, VK_EXT_MULTI_DRAW_EXTENSION_NAME) && @@ -2234,8 +2322,9 @@ namespace MobileGL::MG_Util::SelfTest { builder.Pass("VK_EXT_multi_draw", "supported; a glMultiDraw* batch runs as one vkCmdDrawMulti(Indexed)EXT"); } else { - builder.Info("VK_EXT_multi_draw", - "unsupported; glMultiDraw* batches use the indirect or unrolled tier"); + builder.Warn("VK_EXT_multi_draw", + "unsupported; glMultiDraw* batches take the indirect or unrolled tier " + "instead, with identical output"); } const char* resolvedTier = multiDrawExtUsable ? "ext" : features.multiDrawIndirect == VK_TRUE ? "indirect" @@ -2248,32 +2337,46 @@ namespace MobileGL::MG_Util::SelfTest { : multiDrawMode == MG_Config::MultiDrawMode::Indirect ? "indirect" : "unroll"); } - builder.Info("Multi-draw dispatch tier", tierDetail); + // PASS only on the tier that hands the whole batch to the driver in one command. + if (multiDrawExtUsable) { + builder.Pass("Multi-draw dispatch tier", tierDetail); + } else { + builder.Warn("Multi-draw dispatch tier", + tierDetail + "; the batch is replayed rather than handed over whole, which " + "renders the same thing for more commands"); + } } if (features.vertexPipelineStoresAndAtomics == VK_TRUE) { builder.Pass("vertexPipelineStoresAndAtomics", "supported by driver (not currently enabled by the DirectVulkan backend)"); } else { - builder.Warn("vertexPipelineStoresAndAtomics", - "unsupported; shaders that write storage buffers from the vertex stage will not work"); + builder.FailOptional("vertexPipelineStoresAndAtomics", + "unsupported; a shader that writes a storage buffer or runs an atomic " + "from the vertex stage cannot build a pipeline, and the write cannot be " + "moved to another stage without changing what the shader does"); } if (features.fillModeNonSolid == VK_TRUE) { builder.Pass("fillModeNonSolid", "glPolygonMode GL_LINE/GL_POINT rasterization supported"); } else { - builder.Warn("fillModeNonSolid", - "unsupported; glPolygonMode GL_LINE/GL_POINT falls back to GL_FILL (no wireframe/point " - "rasterization)"); + builder.FailOptional("fillModeNonSolid", + "unsupported; glPolygonMode GL_LINE/GL_POINT silently falls back to " + "GL_FILL, and wireframe/point rasterization cannot be rebuilt out of " + "the triangle pipeline"); } if (features.independentBlend == VK_TRUE) { builder.Pass("independentBlend", "per-draw-buffer glColorMaski and indexed blend state supported"); } else { - builder.Warn("independentBlend", - "unsupported; per-draw-buffer glColorMaski falls back to draw buffer 0 for all attachments"); + builder.FailOptional("independentBlend", + "unsupported; every attachment takes draw buffer 0's colour mask and " + "blend state, so an MRT pass that configures them separately writes the " + "wrong channels to all but one attachment"); } if (features.dualSrcBlend == VK_TRUE) { builder.Pass("dualSrcBlend", "GL_SRC1_* dual-source blend factors supported"); } else { - builder.Warn("dualSrcBlend", "unsupported; GL_SRC1_* dual-source blend factors hard-fail at draw"); + builder.FailOptional("dualSrcBlend", + "unsupported; a draw using a GL_SRC1_* blend factor hard-fails, and a " + "second fragment output cannot be produced any other way"); } // The Magma counterpart of the GLES "Buffer textures" row, so the two sections can be // read side by side. Vulkan has no optional-feature bit here: a uniform texel buffer is @@ -2313,10 +2416,11 @@ namespace MobileGL::MG_Util::SelfTest { "back on its own; a format that refuses the flag is detected at image " "creation and declines per-slice attachment)"); } else { - builder.Warn("2D-array-compatible 3D images", - "VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT unavailable for colour attachments; " - "glFramebufferTextureLayer on a GL_TEXTURE_3D texture is declined for every " - "slice past the first"); + builder.FailOptional("2D-array-compatible 3D images", + "VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT unavailable for colour " + "attachments; glFramebufferTextureLayer on a GL_TEXTURE_3D texture " + "is declined for every slice past the first, and a 3D slice cannot " + "be rendered into any other way"); } } if (features.imageCubeArray == VK_TRUE) { @@ -2324,9 +2428,10 @@ namespace MobileGL::MG_Util::SelfTest { "GL_TEXTURE_CUBE_MAP_ARRAY textures get a Vulkan image and can be sampled and " "attached to a framebuffer per layer"); } else { - builder.Warn("imageCubeArray", - "unsupported; a GL_TEXTURE_CUBE_MAP_ARRAY texture gets no image at all, so sampling " - "one reads nothing and glFramebufferTextureLayer on one is declined"); + builder.FailOptional("imageCubeArray", + "unsupported; a GL_TEXTURE_CUBE_MAP_ARRAY texture gets no image at all, " + "so sampling one reads nothing and glFramebufferTextureLayer on one is " + "declined - there is no substitute image type"); } // MobileGL follows the device here: shaderFloat64 decides whether a module keeps its // 64-bit floats or has them narrowed before pipeline creation (DemoteFloat64Pass). Adreno @@ -2341,7 +2446,10 @@ namespace MobileGL::MG_Util::SelfTest { "FETCH here, so such a program is narrowed whole exactly as it would be on a " "device without the feature")); } else { - builder.Pass("fp64", AppendFp64AdvertisementNote( + // WARN rather than PASS: the device does not support fp64 at all here, and what + // stands in for it is a MobileGL pass that narrows the shader. It runs, at single + // precision - the definition of a substitute. + builder.Warn("fp64", AppendFp64AdvertisementNote( "demoted to fp32 (device shaderFloat64 = unsupported) - every double / dvec " "/ dmat in a shader is narrowed to 32 bits before pipeline creation, so such " "shaders BUILD AND RUN at single precision instead of failing to create a " @@ -2374,8 +2482,10 @@ namespace MobileGL::MG_Util::SelfTest { if (shaderDrawParameters) { builder.Pass("shaderDrawParameters", "gl_DrawID/gl_BaseVertex/gl_BaseInstance shaders supported"); } else { - builder.Warn("shaderDrawParameters", - "unavailable; shaders using gl_DrawID/gl_BaseInstance will not work"); + builder.FailOptional("shaderDrawParameters", + "unavailable; a shader reading gl_DrawID, gl_BaseVertex or " + "gl_BaseInstance has no SPIR-V builtin to read them from, so such " + "shaders do not work and nothing supplies the values instead"); } summary.shaderDrawParametersSupported = shaderDrawParameters; @@ -2412,10 +2522,12 @@ namespace MobileGL::MG_Util::SelfTest { "supported; flat varyings take GL's last vertex and transform feedback records " "strip/fan triangles in GL's vertex order"); } else { - builder.Warn("provokingVertexLast", - "unsupported; flat-shaded varyings take a primitive's first vertex instead of GL's " - "last, and transform feedback records TRIANGLE_STRIP/TRIANGLE_FAN triangles rotated " - "(e.g. 0,1,2 / 1,3,2 instead of 0,1,2 / 2,1,3)"); + builder.FailOptional("provokingVertexLast", + "unsupported; flat-shaded varyings take a primitive's first vertex " + "instead of GL's last, and transform feedback records " + "TRIANGLE_STRIP/TRIANGLE_FAN triangles rotated (e.g. 0,1,2 / 1,3,2 " + "instead of 0,1,2 / 2,1,3). Rewriting the convention would mean " + "reordering every index buffer, which MobileGL does not do"); } if (provokingVertexLast && !transformFeedbackPreservesProvokingVertex) { builder.Warn("transformFeedbackPreservesProvokingVertex", @@ -2444,9 +2556,10 @@ namespace MobileGL::MG_Util::SelfTest { builder.Pass("primitiveTopologyListRestart", "primitive restart supported on list topologies (GL_PRIMITIVE_RESTART)"); } else { - builder.Warn("primitiveTopologyListRestart", - "unsupported; primitive restart works on strip/fan topologies only, list-topology restart " - "hard-fails at draw"); + builder.FailOptional("primitiveTopologyListRestart", + "unsupported; primitive restart works on strip/fan topologies only, and " + "a list-topology draw with GL_PRIMITIVE_RESTART enabled hard-fails - " + "splitting the index stream on the CPU is not done"); } // Core 1.0 features the backend turns GL stages into pipeline stages with. @@ -2456,9 +2569,10 @@ namespace MobileGL::MG_Util::SelfTest { builder.Pass("tessellationShader", "supported (GL_PATCHES draws run the tessellation control/evaluation stages)"); } else { - builder.Warn("tessellationShader", - "unsupported; a program with a tessellation control/evaluation shader cannot build a " - "pipeline, so GL_PATCHES draws render nothing"); + builder.FailOptional("tessellationShader", + "unsupported; a program with a tessellation control/evaluation shader " + "cannot build a pipeline, so GL_PATCHES draws render nothing and there " + "is no stage to run the tessellation on instead"); } Bool vertexAttributeInstanceRateDivisor = false; @@ -2476,10 +2590,11 @@ namespace MobileGL::MG_Util::SelfTest { builder.Pass("vertexAttributeInstanceRateDivisor", "supported (glVertexAttribDivisor advances an attribute every N instances)"); } else { - builder.Warn("vertexAttributeInstanceRateDivisor", - "unsupported; Vulkan's instance input rate can only advance once per instance, so " - "every non-zero glVertexAttribDivisor behaves as 1 and instanced attributes meant to " - "change every N instances change every one"); + builder.FailOptional("vertexAttributeInstanceRateDivisor", + "unsupported; Vulkan's instance input rate can only advance once per " + "instance, so every non-zero glVertexAttribDivisor behaves as 1 and " + "instanced attributes meant to change every N instances change every " + "one - silently wrong geometry, with no substitute fetch rate"); } VkPhysicalDeviceSubgroupProperties subgroupProperties{}; @@ -2503,11 +2618,18 @@ namespace MobileGL::MG_Util::SelfTest { format("basic subgroup operations in compute, subgroup size {}", subgroupProperties.subgroupSize)); } else { - builder.Warn("Compute shader subgroup", - "basic subgroup operations are not usable from compute shaders"); + builder.FailOptional("Compute shader subgroup", + "basic subgroup operations are not usable from compute shaders, so " + "MobileGL withholds GL_KHR_shader_subgroup and the subgroup " + "iteration-render-pass path cannot run; there is no scalar rewrite " + "that stands in for a subgroup reduction"); } } else { - builder.Warn("Compute shader subgroup", "subgroup properties could not be queried"); + builder.FailOptional("Compute shader subgroup", + "subgroup properties could not be queried (no " + "vkGetPhysicalDeviceProperties2, or a pre-1.1 device), so MobileGL " + "withholds GL_KHR_shader_subgroup and the subgroup paths are " + "unavailable whatever the hardware can actually do"); } ProbeVulkanIterationRPWitness(builder, getInstanceProcAddr, instance, physicalDevice, computeQueueFamilyIndex, @@ -2527,9 +2649,9 @@ namespace MobileGL::MG_Util::SelfTest { if (indexTypeUint8) { builder.Pass("Index type uint8", "supported (native GL_UNSIGNED_BYTE index buffers)"); } else { - builder.Warn("Index type uint8", - "not supported; GL_UNSIGNED_BYTE index buffers cannot be drawn (the backend " - "has no conversion fallback and asserts on uint8 index draws)"); + builder.FailOptional("Index type uint8", + "not supported; a GL_UNSIGNED_BYTE index buffer cannot be drawn - the " + "backend has no widening conversion and asserts on uint8 index draws"); } builder.DriverReported("Backend driver reported device", String(properties.deviceName)); builder.DriverReported("Backend driver reported driver version", driverVersionString + " (vendor-encoded)"); @@ -2547,12 +2669,14 @@ namespace MobileGL::MG_Util::SelfTest { ProbeVulkanTimerQuery(builder, getInstanceProcAddr, instance, physicalDevice, graphicsQueueFamilyIndex, graphicsQueueTimestampValidBits, timestampPeriod); } else { - builder.Warn("Timer queries", - format("timestampValidBits = 0 on the graphics queue family; timestampPeriod = {} ns " - "per tick; timestamps unsupported on the graphics queue; timer queries " - "unavailable", - timestampPeriod) + - TimerQueryDisabledNote()); + builder.FailOptional( + "Timer queries", + format("timestampValidBits = 0 on the graphics queue family; timestampPeriod = {} ns " + "per tick; the graphics queue cannot write a timestamp at all, so there is " + "nothing to time GPU work with and glBeginQuery(GL_TIME_ELAPSED) has no " + "substitute", + timestampPeriod) + + TimerQueryDisabledNote()); } if (vkGetPhysicalDeviceFormatPropertiesFn != nullptr) { MG_External::VulkanCapabilities formatProbeCapabilities{}; diff --git a/MobileGL/MG_Util/SelfTest/DriverPost.h b/MobileGL/MG_Util/SelfTest/DriverPost.h index 2f417484..543e6d77 100644 --- a/MobileGL/MG_Util/SelfTest/DriverPost.h +++ b/MobileGL/MG_Util/SelfTest/DriverPost.h @@ -14,21 +14,36 @@ namespace MobileGL::MG_Util::SelfTest { // One row of a backend power-on self-test (POST) report. + // + // EVERY CAPABILITY ROW IS PASS, WARN OR FAIL; INFO IS FOR IDENTITY ONLY. + // PASS - the backend supports the capability directly. + // WARN - not directly, but a MobileGL quirk substitutes and the application still sees + // correct behaviour; the detail names the substitute and what it costs. + // FAIL - unsupported with no substitute; an application that uses it gets wrong output, a + // failed draw, or nothing. + // INFO - identity only: renderer name, API version, driver strings, and the strings + // MobileGL itself reports to applications. Never a capability answer. + // A FAIL row does not by itself mean the backend cannot run - see BackendPostReport::verdict. struct PostCheck { String name; String status; // "PASS" | "WARN" | "FAIL" | "INFO" String detail; // Display ordering rank within a backend section (lower renders first): FAIL, - // WARN, PASS, INFO, then the device-driver identity strings, then the strings + // WARN, PASS, then the device-driver identity strings, then the strings // MobileGL itself reports to applications. Rows are stable-sorted by this rank // before the report is returned; it is not serialized to JSON. Int displayRank = 0; }; - // Verdict for one backend's device driver. - // - UNSUPPORTED: a fatal check failed; the backend cannot run on this driver. - // - DEGRADED: every fatal check passed but at least one soft expectation is unmet. - // - OK: all expectations met. + // Verdict for one backend's device driver, derived from the rows. + // - UNSUPPORTED: a REQUIRED capability failed; the backend cannot run on this driver. + // - DEGRADED: every required capability is present, but at least one row is WARN or is a + // FAIL on an optional capability - the backend runs, and something an application might + // ask for is substituted or missing. + // - OK: every row passed. + // So a section can carry FAIL rows and still be DEGRADED rather than UNSUPPORTED: a device + // with no dual-source blend still runs. Which capabilities are required is decided at the + // row (ReportBuilder::Fail vs ReportBuilder::FailOptional in DriverPost.cpp). // available is false when no probeable driver exists at all (library missing, display // uninitializable, zero Vulkan physical devices, ...). struct BackendPostReport { From 666f150202f8eb980a0bffab97425349b6760f93 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 22 Aug 2026 09:08:13 -0400 Subject: [PATCH 3/3] [Fix] (SelfTest): stop optional-capability failures from declaring the whole backend unsupported, and log the POST report in chunks --- MobileGL/MG_Util/SelfTest/DriverPost.cpp | 22 ++++++++++++++---- .../top/mobilegl/plugin/PostActivity.java | 23 ++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/MobileGL/MG_Util/SelfTest/DriverPost.cpp b/MobileGL/MG_Util/SelfTest/DriverPost.cpp index f4770fb3..40b1ab28 100644 --- a/MobileGL/MG_Util/SelfTest/DriverPost.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverPost.cpp @@ -702,8 +702,11 @@ namespace MobileGL::MG_Util::SelfTest { // Every emit carries the extension-presence fact the old standalone // GL_EXT_disjoint_timer_query row showed, plus the probe outcome. const String extensionPresent = "GL_EXT_disjoint_timer_query extension present"; + // FailOptional: a driver that advertises the extension and then cannot serve a + // query is broken in a way nothing substitutes for, but timing GPU work is not + // something the backend needs in order to run. const auto fail = [&](const String& detail) { - builder.Fail("Timer queries", extensionPresent + "; but " + detail + disabledNote); + builder.FailOptional("Timer queries", extensionPresent + "; but " + detail + disabledNote); }; if (!glesFuncs.glGenQueries || !glesFuncs.glDeleteQueries || !glesFuncs.glBeginQuery || @@ -837,8 +840,11 @@ namespace MobileGL::MG_Util::SelfTest { const String pathNote = native ? "GL_NV_shader_noperspective_interpolation present (native path)" : "GL_NV_shader_noperspective_interpolation absent (gl_Position.w / " "gl_FragCoord.w emulation path)"; + // FailOptional: a shaderpack that declares a noperspective varying renders it wrong + // and nothing stands in for the interpolation, but everything that does not use one + // is unaffected, so the backend still runs. const auto fail = [&](const String& detail) { - builder.Fail("noperspective interpolation", pathNote + "; " + detail); + builder.FailOptional("noperspective interpolation", pathNote + "; " + detail); }; if (!g.glCreateShader || !g.glShaderSource || !g.glCompileShader || !g.glGetShaderiv || @@ -1336,8 +1342,10 @@ namespace MobileGL::MG_Util::SelfTest { const String timestampFacts = format("timestampValidBits = {} on the graphics queue family; timestampPeriod = {} ns per tick", timestampValidBits, timestampPeriod); + // FailOptional, for the same reason as the GLES row: the backend does not need to + // time GPU work in order to run. const auto fail = [&](const String& detail) { - builder.Fail("Timer queries", timestampFacts + "; but " + detail + disabledNote); + builder.FailOptional("Timer queries", timestampFacts + "; but " + detail + disabledNote); }; const auto vkCreateDeviceFn = reinterpret_cast(getInstanceProcAddr(instance, "vkCreateDevice")); @@ -1557,7 +1565,13 @@ namespace MobileGL::MG_Util::SelfTest { Bool subgroupPropertiesAvailable, const VkPhysicalDeviceSubgroupProperties& subgroupProperties) { constexpr const char* RowName = "Subgroup first-reduction witness"; - const auto fail = [&](String detail) { builder.Fail(RowName, Move(detail)); }; + // FailOptional, not Fail. The witness reports whether the NATIVE subgroup + // first-reduction works; when it does not, the renderer takes its non-subgroup + // iteration path and draws the same image. Both an Adreno 830 and Mesa lavapipe + // fail this row's topology check today while running the DirectVulkan backend + // perfectly well, so a fatal verdict here would have the screen announce that a + // backend the user is looking at through that very backend cannot run. + const auto fail = [&](String detail) { builder.FailOptional(RowName, Move(detail)); }; if (!subgroupPropertiesAvailable) { fail("vkGetPhysicalDeviceProperties2 could not provide raw Vulkan subgroup properties"); diff --git a/android-plugin/app/src/main/java/top/mobilegl/plugin/PostActivity.java b/android-plugin/app/src/main/java/top/mobilegl/plugin/PostActivity.java index 010c2cd6..c6910500 100644 --- a/android-plugin/app/src/main/java/top/mobilegl/plugin/PostActivity.java +++ b/android-plugin/app/src/main/java/top/mobilegl/plugin/PostActivity.java @@ -239,13 +239,34 @@ public final class PostActivity extends Activity { nativeLoaded = true; } + /** + * Writes the whole report to logcat. The report is the only machine-readable form of the + * POST, and logcat drops everything past roughly 4000 bytes of a single entry - which is + * less than one backend section, so a one-call log silently truncated the report to about + * the first dozen rows. Each chunk is prefixed with its index so a reader can reassemble + * them in order (concatenate the payloads after the "] " separator). + */ + private static void logReport(String json) { + if (json == null) { + Log.i(TAG, ""); + return; + } + final int chunkSize = 3000; + final int chunks = (json.length() + chunkSize - 1) / chunkSize; + for (int index = 0; index < chunks; ++index) { + final int start = index * chunkSize; + final int end = Math.min(start + chunkSize, json.length()); + Log.i(TAG, "[" + (index + 1) + "/" + chunks + "] " + json.substring(start, end)); + } + } + private static void runDriverPost() { String json = null; Throwable failure = null; try { ensureNativeLoaded(); json = nativeRunDriverPost(); - Log.i(TAG, json == null ? "" : json); + logReport(json); } catch (Throwable error) { Log.e(TAG, "Driver POST failed", error); failure = error;