diff --git a/CMakeLists.txt b/CMakeLists.txt index eb574dc6..8d5c7805 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -313,6 +313,7 @@ set(SOURCE_FILES MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp + MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.cpp MobileGL/MG_Util/SelfTest/DriverPost.cpp MobileGL/MG_Util/SelfTest/DriverPostIterationRPWitness.cpp MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.cpp diff --git a/MobileGL/MG_Test/SelfTest/CMakeLists.txt b/MobileGL/MG_Test/SelfTest/CMakeLists.txt index cc596468..768e9179 100644 --- a/MobileGL/MG_Test/SelfTest/CMakeLists.txt +++ b/MobileGL/MG_Test/SelfTest/CMakeLists.txt @@ -18,6 +18,7 @@ target_link_libraries(DriverPostIterationRPWitnessTest PRIVATE add_executable( DriverBugProbesTest DriverBugProbesTest.cpp + PersistentBufferOrderingProbeTest.cpp ) target_include_directories(DriverBugProbesTest PRIVATE diff --git a/MobileGL/MG_Test/SelfTest/PersistentBufferOrderingProbeTest.cpp b/MobileGL/MG_Test/SelfTest/PersistentBufferOrderingProbeTest.cpp new file mode 100644 index 00000000..de414840 --- /dev/null +++ b/MobileGL/MG_Test/SelfTest/PersistentBufferOrderingProbeTest.cpp @@ -0,0 +1,326 @@ +// MobileGL - MobileGL/MG_Test/SelfTest/PersistentBufferOrderingProbeTest.cpp +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include +#include + +#include +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Util::SelfTest; + +namespace { + // Deferred vertex fetch, not canned ReadPixels answers: a broken mapped destination + // reads its current bytes at Finish instead of the bytes at DrawArrays. ReadPixels also + // drains these jobs, so inserting an early readback into the probe hides the bug here too. + struct FakeDriver { + struct Buffer { + Bool mapped = false; + Bool arena = false; + Bool copied = false; + Int channel = 0; + Vector staging; + }; + struct Draw { GLuint fbo, buffer; Int channel; Bool late; }; + std::map buffers; + std::map vaoBuffers; + std::map colors; + Vector draws; + std::set live; + std::map state = { + {GL_CURRENT_PROGRAM, 1}, {GL_VERTEX_ARRAY_BINDING, 2}, {GL_ARRAY_BUFFER, 3}, + {GL_COPY_READ_BUFFER, 4}, {GL_COPY_WRITE_BUFFER, 5}, + {GL_DRAW_FRAMEBUFFER_BINDING, 6}, {GL_READ_FRAMEBUFFER_BINDING, 7}, + {GL_TEXTURE_BINDING_2D, 8}, {GL_PIXEL_PACK_BUFFER, 9}, + {GL_PACK_ALIGNMENT, 8}, {GL_PACK_ROW_LENGTH, 31}, + {GL_PACK_SKIP_PIXELS, 4}, {GL_PACK_SKIP_ROWS, 5}}; + std::map enabled = {{GL_BLEND, GL_TRUE}, {GL_SCISSOR_TEST, GL_TRUE}, + {GL_SAMPLE_MASK, GL_TRUE}, {GL_RASTERIZER_DISCARD, GL_TRUE}}; + std::array viewport = {3, 4, 5, 6}; + std::array clear = {.25f, .5f, .75f, 0}; + std::array mask = {GL_FALSE, GL_TRUE, GL_FALSE, GL_TRUE}; + GLuint next = 100; + GLenum error = GL_NO_ERROR; + Bool extension = true, corruptSubData = false, corruptCopy = false; + Bool corruptUnmapped = false, corruptSerialized = false; + Bool failMap = false, failAllocation = false, failFramebuffer = false; + Int failReadbackAt = 0, readbacks = 0; + Uint arenaAllocations = 0; + Uint8 pointerSentinel = 0; + + GLuint Create() { live.insert(next); return next++; } + void Generate(GLsizei count, GLuint* ids) { for (Int i = 0; i < count; ++i) ids[i] = Create(); } + void Delete(GLsizei count, const GLuint* ids) { + for (Int i = 0; i < count; ++i) { + live.erase(ids[i]); + buffers.erase(ids[i]); + } + } + GLint Get(GLenum name) const { + const auto found = state.find(name); + return found == state.end() ? 0 : found->second; + } + static Int Channel(const void* data) { + GLfloat color[3]; + std::memcpy(color, static_cast(data) + 2 * sizeof(GLfloat), sizeof(color)); + return color[0] > .5f ? 0 : color[1] > .5f ? 1 : 2; + } + void Finish() { + for (const auto& draw : draws) { + const auto& buffer = buffers.at(draw.buffer); + colors[draw.fbo] = draw.late ? buffer.channel : draw.channel; + if (buffer.mapped && corruptSerialized) colors[draw.fbo] = (draw.channel + 1) % 3; + } + draws.clear(); + } + } driver; + + MG_External::GLESFunctionsTable Table() { + MG_External::GLESFunctionsTable gl{}; + gl.glGetIntegerv = [](GLenum name, GLint* out) { + if (name == GL_MAJOR_VERSION) *out = 3; + else if (name == GL_MINOR_VERSION) *out = 2; + else if (name == GL_NUM_EXTENSIONS) *out = driver.extension ? 1 : 0; + else if (name == GL_VIEWPORT) std::copy(driver.viewport.begin(), driver.viewport.end(), out); + else if (name == GL_ARRAY_BUFFER_BINDING) *out = driver.Get(GL_ARRAY_BUFFER); + else if (name == GL_PIXEL_PACK_BUFFER_BINDING) *out = driver.Get(GL_PIXEL_PACK_BUFFER); + else *out = driver.Get(name); + }; + gl.glGetBooleanv = [](GLenum, GLboolean* out) { std::copy(driver.mask.begin(), driver.mask.end(), out); }; + gl.glGetFloatv = [](GLenum, GLfloat* out) { std::copy(driver.clear.begin(), driver.clear.end(), out); }; + gl.glGetStringi = [](GLenum, GLuint) { return reinterpret_cast("GL_EXT_buffer_storage"); }; + gl.glGetError = []() { return std::exchange(driver.error, GL_NO_ERROR); }; + gl.glIsEnabled = [](GLenum name) -> GLboolean { return driver.enabled[name]; }; + gl.glEnable = [](GLenum name) { driver.enabled[name] = GL_TRUE; }; + gl.glDisable = [](GLenum name) { driver.enabled[name] = GL_FALSE; }; + gl.glCreateShader = [](GLenum) { return driver.Create(); }; + gl.glShaderSource = [](GLuint, GLsizei, const GLchar* const*, const GLint*) {}; + gl.glCompileShader = [](GLuint) {}; + gl.glGetShaderiv = [](GLuint, GLenum, GLint* out) { *out = GL_TRUE; }; + gl.glGetShaderInfoLog = [](GLuint, GLsizei, GLsizei*, GLchar* out) { *out = 0; }; + gl.glDeleteShader = [](GLuint id) { driver.Delete(1, &id); }; + gl.glCreateProgram = []() { return driver.Create(); }; + gl.glAttachShader = [](GLuint, GLuint) {}; + gl.glLinkProgram = [](GLuint) {}; + gl.glGetProgramiv = [](GLuint, GLenum, GLint* out) { *out = GL_TRUE; }; + gl.glGetProgramInfoLog = gl.glGetShaderInfoLog; + gl.glDeleteProgram = gl.glDeleteShader; + gl.glUseProgram = [](GLuint id) { driver.state[GL_CURRENT_PROGRAM] = id; }; + gl.glGenBuffers = [](GLsizei count, GLuint* ids) { driver.Generate(count, ids); }; + gl.glBindBuffer = [](GLenum target, GLuint id) { driver.state[target] = id; }; + gl.glBufferStorageEXT = [](GLenum target, GLsizeiptr size, const void*, GLbitfield) { + auto& buffer = driver.buffers[driver.Get(target)]; + buffer.arena = size >= 16 * 1024 * 1024; + if (buffer.arena) ++driver.arenaAllocations; + if (driver.failAllocation) driver.error = GL_OUT_OF_MEMORY; + if (!buffer.arena) buffer.staging.resize(size); + }; + gl.glBufferData = [](GLenum target, GLsizeiptr size, const void*, GLenum) { + driver.buffers[driver.Get(target)].staging.resize(size); + }; + gl.glMapBufferRange = [](GLenum target, GLintptr offset, GLsizeiptr, GLbitfield) -> void* { + if (driver.failMap) return nullptr; + auto& buffer = driver.buffers[driver.Get(target)]; + buffer.mapped = true; + return buffer.arena ? &driver.pointerSentinel : buffer.staging.data() + offset; + }; + gl.glUnmapBuffer = [](GLenum) -> GLboolean { return GL_TRUE; }; // Preserve allocation history. + gl.glBufferSubData = [](GLenum target, GLintptr offset, GLsizeiptr size, const void* data) { + auto& buffer = driver.buffers[driver.Get(target)]; + if (buffer.arena) { + buffer.channel = FakeDriver::Channel(data); + buffer.copied = false; + } else std::memcpy(buffer.staging.data() + offset, data, size); + }; + gl.glCopyBufferSubData = [](GLenum read, GLenum write, GLintptr offset, GLintptr, GLsizeiptr) { + auto& source = driver.buffers[driver.Get(read)]; + auto& dest = driver.buffers[driver.Get(write)]; + dest.channel = FakeDriver::Channel(source.staging.data() + offset); + dest.copied = true; + }; + gl.glDeleteBuffers = [](GLsizei count, const GLuint* ids) { driver.Delete(count, ids); }; + gl.glGenVertexArrays = gl.glGenBuffers; + gl.glBindVertexArray = [](GLuint id) { driver.state[GL_VERTEX_ARRAY_BINDING] = id; }; + gl.glVertexAttribPointer = [](GLuint, GLint, GLenum, GLboolean, GLsizei, const void*) { + driver.vaoBuffers[driver.Get(GL_VERTEX_ARRAY_BINDING)] = driver.Get(GL_ARRAY_BUFFER); + }; + gl.glEnableVertexAttribArray = [](GLuint) {}; + gl.glDeleteVertexArrays = gl.glDeleteBuffers; + gl.glGenTextures = gl.glGenBuffers; + gl.glBindTexture = [](GLenum, GLuint id) { driver.state[GL_TEXTURE_BINDING_2D] = id; }; + gl.glTexStorage2D = [](GLenum, GLsizei, GLenum, GLsizei, GLsizei) {}; + gl.glDeleteTextures = gl.glDeleteBuffers; + gl.glGenFramebuffers = gl.glGenBuffers; + gl.glBindFramebuffer = [](GLenum target, GLuint id) { + if (target != GL_READ_FRAMEBUFFER) driver.state[GL_DRAW_FRAMEBUFFER_BINDING] = id; + if (target != GL_DRAW_FRAMEBUFFER) driver.state[GL_READ_FRAMEBUFFER_BINDING] = id; + }; + gl.glFramebufferTexture2D = [](GLenum, GLenum, GLenum, GLuint, GLint) {}; + gl.glCheckFramebufferStatus = [](GLenum) -> GLenum { + return driver.failFramebuffer ? GL_FRAMEBUFFER_UNSUPPORTED : GL_FRAMEBUFFER_COMPLETE; + }; + gl.glDeleteFramebuffers = gl.glDeleteBuffers; + gl.glViewport = [](GLint x, GLint y, GLsizei w, GLsizei h) { driver.viewport = {x, y, w, h}; }; + gl.glColorMask = [](GLboolean r, GLboolean g, GLboolean b, GLboolean a) { driver.mask = {r, g, b, a}; }; + gl.glClearColor = [](GLfloat r, GLfloat g, GLfloat b, GLfloat a) { driver.clear = {r, g, b, a}; }; + gl.glClear = [](GLbitfield) {}; + gl.glDrawArrays = [](GLenum, GLint, GLsizei) { + const GLuint id = driver.vaoBuffers.at(driver.Get(GL_VERTEX_ARRAY_BINDING)); + const auto& buffer = driver.buffers.at(id); + const Bool late = buffer.mapped ? (buffer.copied ? driver.corruptCopy : driver.corruptSubData) + : driver.corruptUnmapped; + driver.draws.push_back({GLuint(driver.Get(GL_DRAW_FRAMEBUFFER_BINDING)), id, buffer.channel, late}); + }; + gl.glFinish = []() { driver.Finish(); }; + gl.glMemoryBarrier = [](GLbitfield) {}; + gl.glPixelStorei = [](GLenum name, GLint value) { driver.state[name] = value; }; + gl.glReadPixels = [](GLint, GLint, GLsizei width, GLsizei height, GLenum, GLenum, void* data) { + driver.Finish(); // Models the implicit wait that must NOT occur between subject draws. + if (++driver.readbacks == driver.failReadbackAt) { + driver.error = GL_INVALID_OPERATION; + return; + } + EXPECT_EQ(driver.Get(GL_PIXEL_PACK_BUFFER), 0); + EXPECT_EQ(driver.Get(GL_PACK_ROW_LENGTH), 0); + const Int channel = driver.colors.at(driver.Get(GL_READ_FRAMEBUFFER_BINDING)); + auto* pixels = static_cast(data); + for (Int i = 0; i < width * height; ++i) + for (Int c = 0; c < 4; ++c) pixels[4 * i + c] = c == channel || c == 3 ? 255 : 0; + }; + return gl; + } + + class PersistentBufferOrderingProbeTest : public ::testing::Test { + protected: + void SetUp() override { driver = FakeDriver{}; } + void TearDown() override { EXPECT_TRUE(driver.live.empty()); EXPECT_TRUE(driver.draws.empty()); } + }; +} + +TEST_F(PersistentBufferOrderingProbeTest, RequiresExtensionAndCompleteDispatchBeforeAllocating) { + auto gl = Table(); + driver.extension = false; + EXPECT_FALSE(ProbePersistentBufferUpdateOrdering(gl).supported); + driver.extension = true; + gl.glCopyBufferSubData = nullptr; + EXPECT_FALSE(ProbePersistentBufferUpdateOrdering(gl).supported); + EXPECT_EQ(driver.arenaAllocations, 0u); +} + +TEST_F(PersistentBufferOrderingProbeTest, OrderedDriverPassesAllUploadsAndRestoresCallerState) { + const auto saved = driver; + const auto measurement = ProbePersistentBufferUpdateOrdering(Table()); + ASSERT_TRUE(measurement.supported); + for (const auto& row : measurement.uploads) { + EXPECT_TRUE(row.unmapped.Passed()); + EXPECT_TRUE(row.mapped.Passed()); + EXPECT_EQ(row.mapped.frames, 240u); // Three fresh attempts before a negative result. + EXPECT_EQ(row.finishBoth.status, BufferOrderingProbeStatus::NotRun); + } + EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement)); + EXPECT_EQ(driver.state, saved.state); + EXPECT_EQ(driver.viewport, saved.viewport); + EXPECT_EQ(driver.clear, saved.clear); + EXPECT_EQ(driver.mask, saved.mask); + for (const auto& [cap, value] : driver.enabled) { + const auto found = saved.enabled.find(cap); + EXPECT_EQ(value, found == saved.enabled.end() ? GL_FALSE : found->second); + } +} + +TEST_F(PersistentBufferOrderingProbeTest, DeferredMappedSubDataFetchIsDetectedWithPassingControls) { + driver.corruptSubData = true; + const auto measurement = ProbePersistentBufferUpdateOrdering(Table()); + EXPECT_TRUE(measurement.uploads[0].Detected()); + EXPECT_TRUE(measurement.uploads[0].finishBefore.Passed()); + EXPECT_GT(measurement.uploads[0].mapThenUnmap.badFrames, 0u); + EXPECT_GT(measurement.uploads[0].barrierBefore.badFrames, 0u); + EXPECT_FALSE(measurement.uploads[1].Detected()); + EXPECT_FALSE(measurement.uploads[2].Detected()); + const auto finding = DescribePersistentBufferOrderingBug(measurement); + ASSERT_TRUE(finding); + EXPECT_EQ(finding->verdict, DriverBugVerdict::Unfixable); + EXPECT_NE(finding->detail.find("SubData:"), String::npos); + EXPECT_NE(finding->detail.find("MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION=1"), String::npos); +} + +TEST_F(PersistentBufferOrderingProbeTest, DeferredCopyFetchIsDetectedWithBothStagingSources) { + driver.corruptCopy = true; + const auto measurement = ProbePersistentBufferUpdateOrdering(Table()); + EXPECT_FALSE(measurement.uploads[0].Detected()); + EXPECT_TRUE(measurement.uploads[1].Detected()); + EXPECT_TRUE(measurement.uploads[2].Detected()); +} + +TEST_F(PersistentBufferOrderingProbeTest, PostCollectorIncludesTheMeasuredFinding) { + driver.corruptSubData = true; + const auto findings = CollectGlesKnownDriverBugs(Table()); + const auto found = std::find_if(findings.begin(), findings.end(), [](const auto& finding) { + return finding.name == "Persistent-mapped vertex buffers lose upload/draw ordering"; + }); + ASSERT_NE(found, findings.end()); + EXPECT_NE(found->detail.find("never-mapped 0/80"), String::npos); + EXPECT_EQ(found->verdict, DriverBugVerdict::Unfixable); +} + +TEST_F(PersistentBufferOrderingProbeTest, CorruptNeverMappedControlCannotAccusePersistentMapping) { + driver.corruptSubData = driver.corruptCopy = driver.corruptUnmapped = true; + const auto measurement = ProbePersistentBufferUpdateOrdering(Table()); + for (const auto& row : measurement.uploads) { + EXPECT_GT(row.unmapped.badFrames, 0u); + EXPECT_EQ(row.mapped.status, BufferOrderingProbeStatus::NotRun); + } + EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement)); +} + +TEST_F(PersistentBufferOrderingProbeTest, CorruptSerializedControlCannotConfirmOrderingDefect) { + driver.corruptSubData = driver.corruptCopy = driver.corruptSerialized = true; + const auto measurement = ProbePersistentBufferUpdateOrdering(Table()); + for (const auto& row : measurement.uploads) EXPECT_GT(row.finishBoth.badFrames, 0u); + EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement)); +} + +TEST_F(PersistentBufferOrderingProbeTest, FailedMappingIsInconclusiveAndReleasesResources) { + driver.failMap = true; + const auto measurement = ProbePersistentBufferUpdateOrdering(Table()); + EXPECT_EQ(measurement.uploads[0].mapped.status, BufferOrderingProbeStatus::Failed); + EXPECT_EQ(measurement.uploads[1].unmapped.status, BufferOrderingProbeStatus::Failed); + EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement)); +} + +TEST_F(PersistentBufferOrderingProbeTest, AllocationFailureIsInconclusiveAndRestoresBindings) { + driver.failAllocation = true; + const auto saved = driver.state; + const auto measurement = ProbePersistentBufferUpdateOrdering(Table()); + for (const auto& row : measurement.uploads) { + EXPECT_EQ(row.unmapped.status, BufferOrderingProbeStatus::Failed); + EXPECT_EQ(row.unmapped.error, GLenum(GL_OUT_OF_MEMORY)); + } + EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement)); + EXPECT_EQ(driver.state, saved); +} + +TEST_F(PersistentBufferOrderingProbeTest, ReadbackErrorAfterAMismatchDoesNotProduceAFinding) { + driver.corruptSubData = true; + driver.failReadbackAt = 82; // Eighty clean control readbacks, then one corrupt subject FBO. + const auto measurement = ProbePersistentBufferUpdateOrdering(Table()); + EXPECT_GT(measurement.uploads[0].mapped.badFrames, 0u); + EXPECT_EQ(measurement.uploads[0].mapped.status, BufferOrderingProbeStatus::Failed); + EXPECT_EQ(measurement.uploads[0].mapped.error, GLenum(GL_INVALID_OPERATION)); + EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement)); +} + +TEST_F(PersistentBufferOrderingProbeTest, IncompleteFramebufferIsInconclusiveAndRestoresBindings) { + driver.failFramebuffer = true; + const auto saved = driver.state; + const auto measurement = ProbePersistentBufferUpdateOrdering(Table()); + EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement)); + EXPECT_EQ(driver.arenaAllocations, 0u); + EXPECT_EQ(driver.state, saved); +} diff --git a/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp b/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp index 590f408b..8b4b01c5 100644 --- a/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp @@ -7,6 +7,7 @@ // End of Source File Header #include "DriverBugProbes.h" +#include "PersistentBufferOrderingProbe.h" #include #include @@ -2445,6 +2446,10 @@ namespace MobileGL::MG_Util::SelfTest { DriverBugVerdict::Unfixable, detail}; } + Optional ProbePersistentBufferOrderingBug(const GLESFunctionsTable& gl) { + return DescribePersistentBufferOrderingBug(ProbePersistentBufferUpdateOrdering(gl)); + } + // 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[] = { @@ -2457,6 +2462,7 @@ namespace MobileGL::MG_Util::SelfTest { &ProbeLayeredBlitDestinationBug, &ProbeLocatedIoBlockPayloadBug, &ProbeCopyImagePacked16FieldOrderBug, + &ProbePersistentBufferOrderingBug, }; } // namespace diff --git a/MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.cpp b/MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.cpp new file mode 100644 index 00000000..0b62fc57 --- /dev/null +++ b/MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.cpp @@ -0,0 +1,413 @@ +// MobileGL - MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.cpp +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "PersistentBufferOrderingProbe.h" +#include + +#include +#include +#include +#include + +namespace MobileGL::MG_Util::SelfTest { + namespace { + using MG_External::GLESFunctionsTable; + constexpr GLbitfield kPersistent = 0x0040; + constexpr GLbitfield kCoherent = 0x0080; + constexpr GLbitfield kDynamicStorage = 0x0100; + constexpr GLbitfield kMapFlags = GL_MAP_WRITE_BIT | kPersistent | kCoherent; + constexpr GLsizeiptr kArenaSize = 128 * 1024 * 1024; + constexpr GLintptr kOffset = 96 * 1024 * 1024 + 28; + constexpr GLsizei kSide = 128; + constexpr GLsizei kSlots = 8; + constexpr Int kBatches = 10; + constexpr Int kDraws = 32; + constexpr GLsizei kQuads = 64 * 32; + constexpr Int kAttempts = 3; + constexpr std::array kUploadNames = {"SubData", "Copy/persistent staging", + "Copy/SubData staging"}; + enum class Shape { Unmapped, Mapped, FinishBefore, FinishBoth, MapThenUnmap, BarrierBefore }; + struct Vertex { GLfloat x, y, r, g, b; }; + constexpr GLsizeiptr kPayloadSize = kQuads * 6 * sizeof(Vertex); + static_assert(kOffset + kPayloadSize <= kArenaSize); + + void DrainErrors(const GLESFunctionsTable& gl) { + for (Int i = 0; i < 32 && gl.glGetError() != GL_NO_ERROR; ++i) {} + } + + Bool CanProbe(const GLESFunctionsTable& gl) { + if (!(gl.glGetIntegerv && gl.glGetBooleanv && gl.glGetFloatv && gl.glGetError && + gl.glGetStringi && gl.glIsEnabled && gl.glEnable && gl.glDisable && + gl.glCreateShader && gl.glShaderSource && gl.glCompileShader && gl.glGetShaderiv && + gl.glGetShaderInfoLog && gl.glDeleteShader && gl.glCreateProgram && gl.glAttachShader && + gl.glLinkProgram && gl.glGetProgramiv && gl.glGetProgramInfoLog && gl.glDeleteProgram && + gl.glUseProgram && gl.glGenBuffers && gl.glBindBuffer && gl.glBufferStorageEXT && + gl.glMapBufferRange && gl.glUnmapBuffer && gl.glBufferData && gl.glBufferSubData && + gl.glCopyBufferSubData && gl.glDeleteBuffers && gl.glGenVertexArrays && + gl.glBindVertexArray && gl.glVertexAttribPointer && gl.glEnableVertexAttribArray && + gl.glDeleteVertexArrays && gl.glGenTextures && gl.glBindTexture && gl.glTexStorage2D && + gl.glDeleteTextures && gl.glGenFramebuffers && gl.glBindFramebuffer && + gl.glFramebufferTexture2D && gl.glCheckFramebufferStatus && gl.glDeleteFramebuffers && + gl.glViewport && gl.glColorMask && gl.glClearColor && gl.glClear && gl.glDrawArrays && + gl.glFinish && gl.glMemoryBarrier && gl.glPixelStorei && gl.glReadPixels)) return false; + DrainErrors(gl); + GLint major = 0, minor = 0, count = 0; + gl.glGetIntegerv(GL_MAJOR_VERSION, &major); + gl.glGetIntegerv(GL_MINOR_VERSION, &minor); + gl.glGetIntegerv(GL_NUM_EXTENSIONS, &count); + if (gl.glGetError() != GL_NO_ERROR || major < 3 || (major == 3 && minor < 1)) return false; + for (GLint i = 0; i < count; ++i) { + const auto* extension = gl.glGetStringi(GL_EXTENSIONS, i); + if (extension && std::strcmp(reinterpret_cast(extension), + "GL_EXT_buffer_storage") == 0) return true; + } + return false; + } + + // This probe touches no images/SSBO bindings. Keep its scope independent from the + // other POST probes, including pack state and the caller's currently active texture unit. + struct StateScope { + const GLESFunctionsTable& gl; + GLint program = 0, vao = 0, array = 0, copyRead = 0, copyWrite = 0; + GLint drawFbo = 0, readFbo = 0, texture = 0, packBuffer = 0; + GLint viewport[4]{}; + GLfloat clear[4]{}; + GLboolean colorMask[4]{}; + static constexpr std::array enables = { + GL_BLEND, GL_DEPTH_TEST, GL_STENCIL_TEST, GL_CULL_FACE, GL_SCISSOR_TEST, + GL_RASTERIZER_DISCARD, GL_DITHER, GL_SAMPLE_ALPHA_TO_COVERAGE, + GL_SAMPLE_COVERAGE, GL_SAMPLE_MASK}; + static constexpr std::array packNames = { + GL_PACK_ALIGNMENT, GL_PACK_ROW_LENGTH, GL_PACK_SKIP_PIXELS, GL_PACK_SKIP_ROWS}; + std::array enabled{}; + std::array pack{}; + + explicit StateScope(const GLESFunctionsTable& api) : gl(api) { + gl.glGetIntegerv(GL_CURRENT_PROGRAM, &program); + gl.glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &vao); + gl.glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &array); + gl.glGetIntegerv(GL_COPY_READ_BUFFER_BINDING, ©Read); + gl.glGetIntegerv(GL_COPY_WRITE_BUFFER_BINDING, ©Write); + gl.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &drawFbo); + gl.glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &readFbo); + gl.glGetIntegerv(GL_TEXTURE_BINDING_2D, &texture); + gl.glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &packBuffer); + gl.glGetIntegerv(GL_VIEWPORT, viewport); + gl.glGetFloatv(GL_COLOR_CLEAR_VALUE, clear); + gl.glGetBooleanv(GL_COLOR_WRITEMASK, colorMask); + for (SizeT i = 0; i < enables.size(); ++i) enabled[i] = gl.glIsEnabled(enables[i]); + for (SizeT i = 0; i < packNames.size(); ++i) gl.glGetIntegerv(packNames[i], &pack[i]); + } + void Prepare() { + for (auto cap : enables) gl.glDisable(cap); + gl.glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + for (auto name : packNames) gl.glPixelStorei(name, name == GL_PACK_ALIGNMENT ? 1 : 0); + gl.glViewport(0, 0, kSide, kSide); + gl.glClearColor(0, 0, 0, 1); + } + ~StateScope() { + gl.glUseProgram(program); + gl.glBindVertexArray(vao); + gl.glBindBuffer(GL_ARRAY_BUFFER, array); + gl.glBindBuffer(GL_COPY_READ_BUFFER, copyRead); + gl.glBindBuffer(GL_COPY_WRITE_BUFFER, copyWrite); + gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, packBuffer); + gl.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, drawFbo); + gl.glBindFramebuffer(GL_READ_FRAMEBUFFER, readFbo); + gl.glBindTexture(GL_TEXTURE_2D, texture); + gl.glViewport(viewport[0], viewport[1], viewport[2], viewport[3]); + gl.glClearColor(clear[0], clear[1], clear[2], clear[3]); + gl.glColorMask(colorMask[0], colorMask[1], colorMask[2], colorMask[3]); + for (SizeT i = 0; i < enables.size(); ++i) { + if (enabled[i]) gl.glEnable(enables[i]); else gl.glDisable(enables[i]); + } + for (SizeT i = 0; i < packNames.size(); ++i) gl.glPixelStorei(packNames[i], pack[i]); + } + }; + + struct Resources { + const GLESFunctionsTable& gl; + GLuint program = 0, vao = 0; + std::array fbos{}, textures{}; + explicit Resources(const GLESFunctionsTable& api) : gl(api) {} + ~Resources() { + gl.glDeleteFramebuffers(kSlots, fbos.data()); + gl.glDeleteTextures(kSlots, textures.data()); + gl.glDeleteVertexArrays(1, &vao); + if (program) gl.glDeleteProgram(program); + } + GLuint Compile(GLenum type, const char* source) { + GLuint shader = gl.glCreateShader(type); + if (!shader) return 0; + gl.glShaderSource(shader, 1, &source, nullptr); + gl.glCompileShader(shader); + GLint compiled = 0; + gl.glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (!compiled) { + char log[512]{}; + gl.glGetShaderInfoLog(shader, sizeof(log), nullptr, log); + MGLOG_I("[driver-bug] persistent buffer ordering: shader failed: %s", log); + gl.glDeleteShader(shader); + return 0; + } + return shader; + } + Bool Setup() { + const GLuint vs = Compile(GL_VERTEX_SHADER, + "#version 310 es\nlayout(location=0) in vec2 pos; layout(location=1) in vec3 color;\n" + "out highp vec3 vColor; void main(){gl_Position=vec4(pos,0,1);vColor=color;}\n"); + const GLuint fs = Compile(GL_FRAGMENT_SHADER, + "#version 310 es\nprecision highp float; in highp vec3 vColor;\n" + "layout(location=0) out vec4 outColor; void main(){outColor=vec4(vColor,1);}\n"); + if (vs && fs) { + program = gl.glCreateProgram(); + if (program) { + gl.glAttachShader(program, vs); + gl.glAttachShader(program, fs); + gl.glLinkProgram(program); + } + } + if (vs) gl.glDeleteShader(vs); + if (fs) gl.glDeleteShader(fs); + if (!program) return false; + GLint linked = 0; + gl.glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (!linked) { + char log[512]{}; + gl.glGetProgramInfoLog(program, sizeof(log), nullptr, log); + MGLOG_I("[driver-bug] persistent buffer ordering: link failed: %s", log); + return false; + } + gl.glUseProgram(program); + gl.glGenVertexArrays(1, &vao); + gl.glBindVertexArray(vao); + gl.glGenFramebuffers(kSlots, fbos.data()); + gl.glGenTextures(kSlots, textures.data()); + for (Int i = 0; i < kSlots; ++i) { + if (!vao || !fbos[i] || !textures[i]) return false; + gl.glBindTexture(GL_TEXTURE_2D, textures[i]); + gl.glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSide, kSide); + gl.glBindFramebuffer(GL_FRAMEBUFFER, fbos[i]); + gl.glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, + textures[i], 0); + if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) return false; + } + return gl.glGetError() == GL_NO_ERROR; + } + }; + + struct Buffers { + const GLESFunctionsTable& gl; + GLuint arena = 0, staging = 0; + explicit Buffers(const GLESFunctionsTable& api) : gl(api) {} + ~Buffers() { + // All normal batches finish before cleanup; also retire a partially queued + // batch on an error path before destroying a mapped staging source. + gl.glFinish(); + gl.glDeleteBuffers(1, &arena); + gl.glDeleteBuffers(1, &staging); + } + }; + + void FillVertices(Vector& vertices, Int channel) { + constexpr std::array quad = {{{-1,-1,0,0,0}, {1,-1,0,0,0}, {1,1,0,0,0}, + {-1,-1,0,0,0}, {1,1,0,0,0}, {-1,1,0,0,0}}}; + for (SizeT k = 0; k < vertices.size(); ++k) { + auto& v = vertices[k]; + v = quad[k % 6]; + const SizeT q = k / 6; + v.x = v.x / 64.f - 1.f + (2 * (q % 64) + 1) / 64.f; + v.y = v.y / 32.f - 1.f + (2 * (q / 64) + 1) / 32.f; + v.r = channel == 0 ? 1.f : 0.f; + v.g = channel == 1 ? 1.f : 0.f; + v.b = channel == 2 ? 1.f : 0.f; + } + } + + BufferOrderingSample Run(const GLESFunctionsTable& gl, const Resources& resources, + const Vector& seed, Int upload, Shape shape) { + BufferOrderingSample sample; + sample.status = BufferOrderingProbeStatus::Failed; + Buffers buffers(gl); + Vector payload(kQuads * 6); + Vector pixels(kSide * kSide * 4); + DrainErrors(gl); + do { + gl.glGenBuffers(1, &buffers.arena); + if (!buffers.arena) break; + gl.glBindBuffer(GL_ARRAY_BUFFER, buffers.arena); + gl.glBufferStorageEXT(GL_ARRAY_BUFFER, kArenaSize, seed.data(), kMapFlags | kDynamicStorage); + sample.error = gl.glGetError(); + if (sample.error != GL_NO_ERROR) break; + if (shape != Shape::Unmapped) { + // Deliberately never dereference the destination pointer. All destination + // writes below are ordered GL commands, with no client mapping accesses. + if (!gl.glMapBufferRange(GL_ARRAY_BUFFER, 0, kArenaSize, kMapFlags)) break; + if (shape == Shape::MapThenUnmap && !gl.glUnmapBuffer(GL_ARRAY_BUFFER)) break; + } + gl.glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(kOffset)); + gl.glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(kOffset + 2 * sizeof(GLfloat))); + gl.glEnableVertexAttribArray(0); + gl.glEnableVertexAttribArray(1); + void* sourceMap = nullptr; + if (upload != 0) { + gl.glGenBuffers(1, &buffers.staging); + if (!buffers.staging) break; + gl.glBindBuffer(GL_COPY_READ_BUFFER, buffers.staging); + if (upload == 1) { + gl.glBufferStorageEXT(GL_COPY_READ_BUFFER, kSlots * kPayloadSize, nullptr, kMapFlags); + sourceMap = gl.glMapBufferRange(GL_COPY_READ_BUFFER, 0, kSlots * kPayloadSize, kMapFlags); + if (!sourceMap) break; + } else { + gl.glBufferData(GL_COPY_READ_BUFFER, kSlots * kPayloadSize, nullptr, GL_STREAM_DRAW); + } + gl.glBindBuffer(GL_COPY_WRITE_BUFFER, buffers.arena); + } + sample.error = gl.glGetError(); + if (sample.error != GL_NO_ERROR) break; + for (Int batch = 0; batch < kBatches; ++batch) { + for (Int slot = 0; slot < kSlots; ++slot) { + FillVertices(payload, (batch * kSlots + slot) % 3); + if (shape == Shape::FinishBefore || shape == Shape::FinishBoth) gl.glFinish(); + if (shape == Shape::BarrierBefore) gl.glMemoryBarrier(GL_ALL_BARRIER_BITS); + if (upload == 0) { + gl.glBufferSubData(GL_ARRAY_BUFFER, kOffset, kPayloadSize, payload.data()); + } else { + // No slot is reused until the entire batch has finished on the GPU. + if (upload == 1) { + std::memcpy(static_cast(sourceMap) + slot * kPayloadSize, + payload.data(), kPayloadSize); + } else { + gl.glBufferSubData(GL_COPY_READ_BUFFER, slot * kPayloadSize, + kPayloadSize, payload.data()); + } + gl.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, + slot * kPayloadSize, kOffset, kPayloadSize); + } + if (shape == Shape::FinishBoth) gl.glFinish(); + gl.glBindFramebuffer(GL_FRAMEBUFFER, resources.fbos[slot]); + gl.glClear(GL_COLOR_BUFFER_BIT); + for (Int draw = 0; draw < kDraws; ++draw) gl.glDrawArrays(GL_TRIANGLES, 0, kQuads * 6); + } + // No readback/Finish between subject update/draw pairs. Early readback + // would hide precisely the old-reader/new-writer overlap being tested. + gl.glFinish(); + sample.error = gl.glGetError(); + if (sample.error != GL_NO_ERROR) break; + for (Int slot = 0; slot < kSlots; ++slot) { + gl.glBindFramebuffer(GL_FRAMEBUFFER, resources.fbos[slot]); + gl.glReadPixels(0, 0, kSide, kSide, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + sample.error = gl.glGetError(); + if (sample.error != GL_NO_ERROR) break; + const Int channel = (batch * kSlots + slot) % 3; + Uint bad = 0; + for (Int pixel = 0; pixel < kSide * kSide; ++pixel) { + for (Int c = 0; c < 3; ++c) { + const Int expected = c == channel ? 255 : 0; + if (std::abs(Int(pixels[pixel * 4 + c]) - expected) > 8) ++bad; + } + } + ++sample.frames; + if (bad != 0) ++sample.badFrames; + sample.badComponents += bad; + } + if (sample.error != GL_NO_ERROR) break; + } + if (sample.error == GL_NO_ERROR && sample.frames == kBatches * kSlots) + sample.status = BufferOrderingProbeStatus::Complete; + } while (false); + if (sample.error == GL_NO_ERROR) sample.error = gl.glGetError(); + return sample; + } + + String Describe(const BufferOrderingSample& sample) { + if (sample.status == BufferOrderingProbeStatus::NotRun) return "not run"; + if (sample.status == BufferOrderingProbeStatus::Failed) + return format("inconclusive (GL error 0x{:x}, {} readbacks)", sample.error, sample.frames); + return format("{}/{} bad FBOs ({} components)", sample.badFrames, sample.frames, sample.badComponents); + } + + String DescribeUpload(const BufferOrderingUploadMeasurement& row, Int upload) { + return format("{}: mapped {}, never-mapped {}, Finish-before {}, Finish-both {}, " + "map-then-unmap {}, barrier-before {}", kUploadNames[upload], Describe(row.mapped), + Describe(row.unmapped), Describe(row.finishBefore), Describe(row.finishBoth), + Describe(row.mapThenUnmap), Describe(row.barrierBefore)); + } + } // namespace + + PersistentBufferOrderingMeasurement ProbePersistentBufferUpdateOrdering(const GLESFunctionsTable& gl) try { + PersistentBufferOrderingMeasurement measurement; + if (!CanProbe(gl)) return measurement; + measurement.supported = true; + StateScope state(gl); + state.Prepare(); + Resources resources(gl); + if (gl.glGetError() != GL_NO_ERROR || !resources.Setup()) { + for (auto& row : measurement.uploads) row.unmapped.status = BufferOrderingProbeStatus::Failed; + MGLOG_I("[driver-bug] persistent buffer ordering: setup failed; inconclusive"); + return measurement; + } + Vector seed(kArenaSize, 0); + for (Int upload = 0; upload < Int(measurement.uploads.size()); ++upload) { + auto& row = measurement.uploads[upload]; + row.unmapped = Run(gl, resources, seed, upload, Shape::Unmapped); + if (row.unmapped.Passed()) { + // A single allocation can miss on Mali. Stop once a mismatch is measured, + // otherwise retry with fresh storage rather than treating one pass as proof. + for (Int attempt = 0; attempt < kAttempts; ++attempt) { + const auto sample = Run(gl, resources, seed, upload, Shape::Mapped); + row.mapped.status = sample.status; + row.mapped.error = sample.error; + row.mapped.frames += sample.frames; + row.mapped.badFrames += sample.badFrames; + row.mapped.badComponents += sample.badComponents; + if (sample.status != BufferOrderingProbeStatus::Complete || sample.badFrames) break; + } + if (row.mapped.status == BufferOrderingProbeStatus::Complete && row.mapped.badFrames) { + row.finishBoth = Run(gl, resources, seed, upload, Shape::FinishBoth); + row.finishBefore = Run(gl, resources, seed, upload, Shape::FinishBefore); + row.mapThenUnmap = Run(gl, resources, seed, upload, Shape::MapThenUnmap); + row.barrierBefore = Run(gl, resources, seed, upload, Shape::BarrierBefore); + } + } + MGLOG_I("[driver-bug] persistent buffer ordering: %s; %s", DescribeUpload(row, upload).c_str(), + row.Detected() ? "detected" : "not detected or inconclusive"); + } + return measurement; + } catch (const std::bad_alloc&) { + // The CPU initializer is arena-sized too. An allocation failure must not discard + // the rest of the POST report or turn a partially sampled case into a finding. + MGLOG_I("[driver-bug] persistent buffer ordering: host allocation failed; inconclusive"); + PersistentBufferOrderingMeasurement measurement; + measurement.supported = true; + for (auto& row : measurement.uploads) row.unmapped.status = BufferOrderingProbeStatus::Failed; + return measurement; + } + + Optional DescribePersistentBufferOrderingBug( + const PersistentBufferOrderingMeasurement& measurement) { + String detail; + for (Int upload = 0; upload < Int(measurement.uploads.size()); ++upload) { + if (!measurement.uploads[upload].Detected()) continue; + if (!detail.empty()) detail += "; "; + detail += DescribeUpload(measurement.uploads[upload], upload); + } + if (detail.empty()) return std::nullopt; + detail += ". A 128 MiB immutable vertex destination was mapped WRITE|PERSISTENT|COHERENT, " + "but never accessed through its client pointer. Queued uploads/draws corrupt vertex data; " + "identical never-mapped and Finish-before-and-after controls pass. " + "This POST does not enable a workaround. MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION=1 " + "avoids automatic arena adoption; explicit application mappings remain separate. " + "FBO counts describe this bounded stress probe, not application flicker frequency."; + return DriverBugFinding{"Persistent-mapped vertex buffers lose upload/draw ordering", + DriverBugVerdict::Unfixable, Move(detail)}; + } +} // namespace MobileGL::MG_Util::SelfTest diff --git a/MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.h b/MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.h new file mode 100644 index 00000000..b4d457e6 --- /dev/null +++ b/MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.h @@ -0,0 +1,66 @@ +// MobileGL - MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.h +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include "DriverBugProbes.h" + +#include + +namespace MobileGL::MG_Util::SelfTest { + enum class BufferOrderingProbeStatus : Uint8 { NotRun, Complete, Failed }; + + struct BufferOrderingSample { + BufferOrderingProbeStatus status = BufferOrderingProbeStatus::NotRun; + Uint frames = 0; // Independent FBO readbacks, not draw calls or application frames. + Uint badFrames = 0; + Uint badComponents = 0; + GLenum error = GL_NO_ERROR; + + Bool Passed() const { return status == BufferOrderingProbeStatus::Complete && badFrames == 0; } + }; + + struct BufferOrderingUploadMeasurement { + BufferOrderingSample unmapped; + BufferOrderingSample mapped; + BufferOrderingSample finishBefore; + BufferOrderingSample finishBoth; + BufferOrderingSample mapThenUnmap; + BufferOrderingSample barrierBefore; + + Bool Detected() const { + return unmapped.Passed() && finishBoth.Passed() && + mapped.status == BufferOrderingProbeStatus::Complete && mapped.badFrames != 0; + } + }; + + struct PersistentBufferOrderingMeasurement { + Bool supported = false; + // SubData; CopyBufferSubData from coherent persistent staging; CopyBufferSubData + // from ordinary SubData staging. Each has its OWN otherwise-identical controls. + std::array uploads; + }; + + // POST-only: native GLES calls, no MobileGL buffers, renderer-name rules or config changes. + // The Mali r54p1 finding: updating an immutable vertex arena that has been persistently + // mapped can corrupt queued draws even when the application never accesses that mapping. + // Queue eight update/draw pairs into separate FBOs BEFORE any Finish/readback, then check + // every pixel of both old and new draws. Staging slots never overlap while in flight. + // + // Each upload runs a never-mapped control with identical storage flags. Try up to three + // fresh mapped allocations to catch intermittent failures. On corruption, measure explicit + // waits, map-then-unmap and a barrier as diagnostics. Only a passing never-mapped AND + // Finish-before-and-after control permits a finding. Setup/GL failures are inconclusive. + // Explicit allocations are one 128 MiB arena, its initializer, and small staging/FBOs; + // allocations, batches and draws are bounded. Every touched GL state is restored. + PersistentBufferOrderingMeasurement ProbePersistentBufferUpdateOrdering( + const MG_External::GLESFunctionsTable& gl); + + // Used by the POST collector. A report never labels an inconclusive sample as a bug. + Optional DescribePersistentBufferOrderingBug( + const PersistentBufferOrderingMeasurement& measurement); +} // namespace MobileGL::MG_Util::SelfTest