[Merge] (dev): bring dev@9eae9858 into feat/disaggregated - the adopted-store respecify VAO rebind fix (d7655247) and the persistent-buffer ordering POST probe

This commit is contained in:
2026-09-08 03:15:31 -04:00
8 changed files with 916 additions and 3 deletions
+1
View File
@@ -335,6 +335,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
+4 -1
View File
@@ -1230,6 +1230,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (resource->id != 0 && CanTouchGLNow() &&
resource->contextGeneration == g_bufferContextGeneration) {
NoteBufferIdDeleted(resource->id);
// Frontend VAO bindings survive respecification; force their
// backend twins to bind the replacement buffer name.
++g_bufferBackendIdGeneration;
g_GLESFuncs.glDeleteBuffers(1, &resource->id);
resource->id = 0;
resource->immutableStorage = false;
@@ -1499,7 +1502,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
// See the declaration: re-mints of a live resource's driver id. Written only on
// the context thread (both re-mint sites run there), read only by the VAO sync.
// the context thread (all re-mint sites run there), read only by the VAO sync.
Uint64 g_bufferBackendIdGeneration = 0;
void RegisterBufferBackendOps() {
@@ -102,6 +102,12 @@ void main() { word = 0xC0FFEEu; }
// The NULL-data definition is the adoption point (and Minecraft's
// arena-creation idiom).
glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW);
ConfigureVertexArray(m_vao);
}
void ConfigureVertexArray(GLuint vao) {
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<void*>(kVertexOffset));
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
@@ -173,12 +179,12 @@ void main() { word = 0xC0FFEEu; }
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
}
void DrawQuad() {
void DrawQuad(GLuint vao = 0) {
glViewport(0, 0, Gl().Width(), Gl().Height());
glClearColor(0.f, 0.f, 0.f, 1.f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glBindVertexArray(vao != 0 ? vao : m_vao);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
@@ -222,6 +228,97 @@ void main() { word = 0xC0FFEEu; }
EXPECT_LT(px[0], 50) << "the draw still shows the previous frame's bytes";
}
// Respecifying a frontend buffer preserves its VAO attachments even when the
// backend replaces the adopted store's GL name. Keep every attribute binding
// unchanged so a stale backend VAO cannot be repaired by a frontend rebind.
TEST_F(LargeArenaAdoptionScenario, RespecifiedVertexArenaKeepsVaoBindings) {
if (!Ready() || IsSkipped()) return;
UploadQuad(1.f, 0.f, 0.f);
DrawQuad();
ASSERT_GT(CenterPixel()[0], 200);
ASSERT_EQ(FirstGLError(), 0u);
GLuint otherVao = 0;
glGenVertexArrays(1, &otherVao);
ConfigureVertexArray(otherVao);
DrawQuad(otherVao);
EXPECT_GT(CenterPixel()[0], 200);
EXPECT_EQ(FirstGLError(), 0u);
constexpr std::array<GLsizeiptr, 3> sizes = {
kArenaBytes, kArenaBytes + 4096, kArenaBytes - 4096,
};
constexpr std::array<std::array<float, 3>, 3> colors = {{
{0.f, 1.f, 0.f}, {0.f, 0.f, 1.f}, {1.f, 0.f, 0.f},
}};
for (std::size_t i = 0; i < sizes.size(); ++i) {
SCOPED_TRACE(sizes[i]);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferData(GL_ARRAY_BUFFER, sizes[i], nullptr, GL_DYNAMIC_DRAW);
UploadQuad(colors[i][0], colors[i][1], colors[i][2]);
// The unbound VAO can retain the deleted store; the current VAO's
// attachments can be cleared by deletion. Both must be repaired.
for (GLuint vao : {m_vao, otherVao}) {
SCOPED_TRACE(vao);
DrawQuad(vao);
const auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
for (std::size_t channel = 0; channel < 3; ++channel) {
if (colors[i][channel] != 0.f) {
EXPECT_GT(px[channel], 200) << "VAO did not fetch the replacement vertex store";
} else {
EXPECT_LT(px[channel], 50) << "VAO still fetched the previous vertex store";
}
}
}
}
glDeleteVertexArrays(1, &otherVao);
}
TEST_F(LargeArenaAdoptionScenario, RespecifiedIndexArenaKeepsVaoBinding) {
if (!Ready() || IsSkipped()) return;
auto vertices = QuadVertices(1.f, 0.f, 0.f);
const auto green = QuadVertices(0.f, 1.f, 0.f);
vertices.insert(vertices.end(), green.begin(), green.end());
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
GLuint indices = 0;
glGenBuffers(1, &indices);
glBindVertexArray(m_vao);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indices);
// Redefine through COPY_WRITE_BUFFER so the element binding slot never
// changes. The small final store also exercises returning to shadow storage.
glBindBuffer(GL_COPY_WRITE_BUFFER, indices);
constexpr std::array<GLsizeiptr, 4> sizes = {
kArenaBytes, kArenaBytes, kArenaBytes + 4096, 4096,
};
for (std::size_t i = 0; i < sizes.size(); ++i) {
SCOPED_TRACE(sizes[i]);
const GLuint first = (i % 2) == 0 ? 0u : 6u;
const std::array<GLuint, 6> elements = {
first, first + 1, first + 2, first + 3, first + 4, first + 5,
};
glBufferData(GL_COPY_WRITE_BUFFER, sizes[i], nullptr, GL_DYNAMIC_DRAW);
glBufferSubData(GL_COPY_WRITE_BUFFER, 0, sizeof(elements), elements.data());
glViewport(0, 0, Gl().Width(), Gl().Height());
glClearColor(0.f, 0.f, 0.f, 1.f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(m_program);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);
const auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_GT(px[first == 0 ? 0 : 1], 200) << "VAO did not fetch the replacement index store";
EXPECT_LT(px[first == 0 ? 1 : 0], 50) << "VAO still fetched the previous index store";
}
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glBindBuffer(GL_COPY_WRITE_BUFFER, 0);
glDeleteBuffers(1, &indices);
}
// The shadow IS the mapping: a readback straight after a CPU write must hand
// back exactly those bytes.
TEST_F(LargeArenaAdoptionScenario, ReadbackSeesTheLatestCpuWrite) {
+1
View File
@@ -18,6 +18,7 @@ target_link_libraries(DriverPostIterationRPWitnessTest PRIVATE
add_executable(
DriverBugProbesTest
DriverBugProbesTest.cpp
PersistentBufferOrderingProbeTest.cpp
)
target_include_directories(DriverBugProbesTest PRIVATE
@@ -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 <gtest/gtest.h>
#include <MG_Util/SelfTest/PersistentBufferOrderingProbe.h>
#include <map>
#include <set>
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<Uint8> staging;
};
struct Draw { GLuint fbo, buffer; Int channel; Bool late; };
std::map<GLuint, Buffer> buffers;
std::map<GLuint, GLuint> vaoBuffers;
std::map<GLuint, Int> colors;
Vector<Draw> draws;
std::set<GLuint> live;
std::map<GLenum, GLint> 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<GLenum, GLboolean> enabled = {{GL_BLEND, GL_TRUE}, {GL_SCISSOR_TEST, GL_TRUE},
{GL_SAMPLE_MASK, GL_TRUE}, {GL_RASTERIZER_DISCARD, GL_TRUE}};
std::array<GLint, 4> viewport = {3, 4, 5, 6};
std::array<GLfloat, 4> clear = {.25f, .5f, .75f, 0};
std::array<GLboolean, 4> 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<const Uint8*>(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<const GLubyte*>("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<Uint8*>(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);
}
@@ -7,6 +7,7 @@
// End of Source File Header
#include "DriverBugProbes.h"
#include "PersistentBufferOrderingProbe.h"
#include <Config.h>
#include <MG_Util/Debug/Log.h>
@@ -2445,6 +2446,10 @@ namespace MobileGL::MG_Util::SelfTest {
DriverBugVerdict::Unfixable, detail};
}
Optional<DriverBugFinding> 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<DriverBugFinding> (*)(const GLESFunctionsTable&);
constexpr DriverBugProbeFn kGlesDriverBugProbes[] = {
@@ -2457,6 +2462,7 @@ namespace MobileGL::MG_Util::SelfTest {
&ProbeLayeredBlitDestinationBug,
&ProbeLocatedIoBlockPayloadBug,
&ProbeCopyImagePacked16FieldOrderBug,
&ProbePersistentBufferOrderingBug,
};
} // namespace
@@ -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 <MG_Util/Debug/Log.h>
#include <array>
#include <cmath>
#include <cstring>
#include <new>
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<const char*, 3> 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<const char*>(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<GLenum, 10> 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<GLenum, 4> packNames = {
GL_PACK_ALIGNMENT, GL_PACK_ROW_LENGTH, GL_PACK_SKIP_PIXELS, GL_PACK_SKIP_ROWS};
std::array<GLboolean, enables.size()> enabled{};
std::array<GLint, packNames.size()> 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, &copyRead);
gl.glGetIntegerv(GL_COPY_WRITE_BUFFER_BINDING, &copyWrite);
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<GLuint, kSlots> 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<Vertex>& vertices, Int channel) {
constexpr std::array<Vertex, 6> 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<Uint8>& seed, Int upload, Shape shape) {
BufferOrderingSample sample;
sample.status = BufferOrderingProbeStatus::Failed;
Buffers buffers(gl);
Vector<Vertex> payload(kQuads * 6);
Vector<Uint8> 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<const void*>(kOffset));
gl.glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<const void*>(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<Uint8*>(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<Uint8> 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<DriverBugFinding> 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
@@ -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 <array>
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<BufferOrderingUploadMeasurement, 3> 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<DriverBugFinding> DescribePersistentBufferOrderingBug(
const PersistentBufferOrderingMeasurement& measurement);
} // namespace MobileGL::MG_Util::SelfTest