[Test] (MG_IntegrationTest): pin the two shipped memo bugs with rendered pixels

Both d7976326 bugs passed every unit test while corrupting real frames -
state-level assertions cannot see them. This module renders and reads
back.

A headless EGL-pbuffer harness (no window, no GLFW) linking MobileGL_s
directly, registered once per backend under the ctest label
integration-gpu, behind the default-OFF option
MOBILEGL_BUILD_INTEGRATION_TEST. The platform pre-flight runs the ENTIRE
bring-up in a forked child first - MobileGL aborts rather than returning
errors on an unusable platform, and the child dying on any signal turns
into a clean GTEST_SKIP instead of taking the test binary down.
MOBILEGL_ITEST_REQUIRE_GPU makes the label falsifiable: with it set, an
unusable harness (or a context that lands on a software rasterizer) is a
FAILURE - without it, a CI runner whose driver pinning silently broke
reports the same green as one that rendered every frame. Configure-time
detection pins the EGL vendor and Vulkan ICD jsons, preferring hardware
vendors and never selecting llvmpipe/lavapipe.

Scenarios assert on glReadPixels with whole-region pixel counts (a
2x2 quadrant pattern whose signature distinguishes all eight square
symmetries; every region predicate reports the first offending pixel):
- OrientationScenario: default -> FBO -> default, pinning the
  transform-flags memo key. Keying GetBaseTransformFlagsRaw on the
  pre-transform alone fails exactly 3 entries.
- StreamedArenaScenario: an untouched streamed vertex buffer must
  survive transient-arena recycling. Re-enabling only the cross-frame
  vertex revalidation fails exactly this entry.
- CrossFrameBufferScenario + ResidentIndexScenario: cross-frame
  mutation matrix (SubData, map/unmap, persistent+flush, coherent
  persistent, orphan, CopyBufferSubData; vertex and index) plus six
  adversarial resident-EBO constructions. Instrumentation showed the
  cross-frame EBO memo cannot be made to serve wrong bytes from GL
  level on this stack (89 entries, 81 accepts, zero divergent slices) -
  these cases are freshness tripwires, documented as such in-file; the
  EBO half of d7976326 remains unpinned by a failing test.

At the buggy commit 72ee7c43 the suite fails 4 entries (3 orientation +
1 streamed-arena); at d7976326 all 52 pass, 5 consecutive runs, zero
flakes, and the default build is bit-for-bit unaffected (unit suite
unchanged). Adversarially verified twice, including hostile-platform
sweeps (26 configurations, all clean skips) and hand-edits of each
production hole in isolation.
This commit is contained in:
BZLZHH
2026-08-07 03:30:18 -04:00
parent d7976326fa
commit 313b75a7c0
10 changed files with 2768 additions and 0 deletions
@@ -0,0 +1,761 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CrossFrameBufferScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario B - "the draw rendered last frame's buffer".
//
// The shipped bug (DirectVulkan, TryBindResolvedVertexBindings and the EBO
// memo in UploadAndBindIndexBuffer): both memos revalidated themselves ACROSS a
// frame boundary by comparing recorded per-buffer slice epochs, and on a match
// skipped the per-frame buffer acquire. The acquire is the frame's content-sync
// point; skipping it trusted the BumpSliceEpoch call-site inventory to cover
// every way a buffer's GPU copy can go stale, and at least one path escaped it.
// Result: a draw in a later frame renders from a STALE buffer slice - random
// triangles in Minecraft/Sodium on Adreno, corrupted journeymap and
// common-mods retraces.
//
// What pins it: mutate a buffer AFTER a frame boundary and BEFORE the next
// draw, then prove the pixels show the NEW content. Every mutation API gets its
// own test case, so a failure names the culprit rather than saying "buffers".
// The index buffer is covered too: the EBO memo had exactly the same hole.
//
// The scene is deliberately trivial and entirely buffer-driven:
//
// vertices 0..3 left half of the viewport, RED
// vertices 4..7 right half of the viewport, GREEN
// indices A {0,1,2, 0,2,3} -> the left, red quad
// indices B {4,5,6, 4,6,7} -> the right, green quad
//
// A vertex-buffer test rewrites the left quad's colour red -> green and expects
// the left half to turn green. An index-buffer test rewrites the indices
// A -> B and expects the picture to jump from a red left half to a green right
// half. Either way "stale" and "fresh" are different colours in different
// places; no thresholds, no interpretation.
//
// Two families of scenario live here, and they catch different halves of the
// same rule:
//
// CrossFrameBufferScenario - one case per buffer-mutation API. Every one of
// these APIs is supposed to retire the memo; today they all do (each notify
// path bumps the slice epoch), so these pass on the buggy revision too.
// They are the standing statement of the contract: whatever a future memo
// keys on, a write through ANY of these APIs must reach the next frame's
// draw. They are also where a coherent persistent write - the one shape
// that changes a buffer with no GL call at all - is pinned.
//
// StreamedArenaScenario - the case that actually caught the shipped bug. It
// attacks the other half of the rule: a buffer nobody wrote at all, whose
// GPU-side bytes moved out from under the memo anyway.
#include <cstdio>
#include <cstring>
#include <functional>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr const char* kVertexSource = R"(#version 330 core
in vec2 aPos;
in vec3 aColor;
out vec3 vColor;
void main() {
vColor = aColor;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kFragmentSource = R"(#version 330 core
in vec3 vColor;
out vec4 oColor;
void main() {
oColor = vec4(vColor, 1.0);
}
)";
struct Vertex {
float x, y;
float r, g, b;
};
constexpr int kLeftQuadFirstVertex = 0;
constexpr int kLeftQuadVertexCount = 4;
constexpr int kIndexCount = 6;
// Enough consecutive frames drawing the same VAO that any per-(VAO, frame)
// memo is fully armed before the mutation lands.
constexpr int kWarmupFrames = 3;
std::vector<Vertex> SceneVertices(bool leftQuadIsGreen) {
const float lr = leftQuadIsGreen ? 0.0f : 1.0f;
const float lg = leftQuadIsGreen ? 1.0f : 0.0f;
return {
// 0..3: left half
{-1.0f, -1.0f, lr, lg, 0.0f},
{0.0f, -1.0f, lr, lg, 0.0f},
{0.0f, 1.0f, lr, lg, 0.0f},
{-1.0f, 1.0f, lr, lg, 0.0f},
// 4..7: right half
{0.0f, -1.0f, 0.0f, 1.0f, 0.0f},
{1.0f, -1.0f, 0.0f, 1.0f, 0.0f},
{1.0f, 1.0f, 0.0f, 1.0f, 0.0f},
{0.0f, 1.0f, 0.0f, 1.0f, 0.0f},
};
}
const GLuint kIndicesLeftQuad[kIndexCount] = {0, 1, 2, 0, 2, 3};
const GLuint kIndicesRightQuad[kIndexCount] = {4, 5, 6, 4, 6, 7};
// How far inside each half the whole-region checks start. The two quads
// meet on a pixel boundary, so a couple of pixels of margin makes "every
// single pixel in the region" an achievable demand.
constexpr int kHalfInset = 2;
// Asserts the left and right halves of the viewport, with a message that
// says what the app had asked GL to draw by then.
//
// This counts EVERY pixel in each half rather than sampling its centre.
// Sampling two pixels was demonstrably too weak: a draw in which three of
// the left quad's four vertices still carry stale data paints a centre
// pixel of exactly the expected colour and passed the old assertion. That
// case is now a standing negative control - see
// PartialStalenessIsCaughtByWholeRegionChecks below, which constructs it
// deliberately and proves the region scan reports it.
void ExpectHalves(const Image& image, const char* expectedLeft, const char* expectedRight,
const std::string& when) {
const int w = image.Width();
const int h = image.Height();
EXPECT_TRUE(RegionIsMostly(image, kHalfInset, w / 2 - kHalfInset, kHalfInset, h - kHalfInset, expectedLeft,
0.0, when + " [left half]"));
EXPECT_TRUE(RegionIsMostly(image, w / 2 + kHalfInset, w - kHalfInset, kHalfInset, h - kHalfInset,
expectedRight, 0.0, when + " [right half]"));
}
// How the app hands the new bytes to GL. Each is its own test case.
enum class Mutation {
SubData, // glBufferSubData
MapWriteUnmap, // glMapBufferRange(WRITE) + glUnmapBuffer
PersistentFlush, // write through a persistent map + glFlushMappedBufferRange
PersistentCoherent, // write through a COHERENT persistent map, no GL call at all
OrphanReupload, // glBufferData(NULL) then a full re-upload
CopySubData, // glCopyBufferSubData from a staging buffer
};
bool NeedsImmutableStorage(Mutation mutation) {
return mutation == Mutation::PersistentFlush || mutation == Mutation::PersistentCoherent;
}
// The coherent variant is the one shape in which an application changes a
// buffer's contents with NO GL call whatsoever - the write lands in the
// mapping and that is the end of it. Sodium's chunk streaming is written
// this way, and it is the case a per-buffer "has anything changed?" epoch
// cannot see on its own.
bool NeedsCoherentMapping(Mutation mutation) {
return mutation == Mutation::PersistentCoherent;
}
class CrossFrameBufferScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "program setup left a GL error behind";
}
void TearDown() override {
if (!Ready()) return;
ReleaseBuffers();
if (m_program != 0) glDeleteProgram(m_program);
}
// Builds the VAO/VBO/EBO. `immutable` switches to glBufferStorage plus a
// persistent mapping of both buffers, which is the only shape in which the
// persistent-write mutation is legal.
void BuildScene(bool immutable, bool coherent = false) {
const std::vector<Vertex> vertices = SceneVertices(/*leftQuadIsGreen=*/false);
m_vertexBytes = GLsizeiptr(vertices.size() * sizeof(Vertex));
m_indexBytes = GLsizeiptr(sizeof(kIndicesLeftQuad));
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glGenBuffers(1, &m_ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo);
if (immutable) {
const GLbitfield storageFlags = GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_DYNAMIC_STORAGE_BIT |
(coherent ? GL_MAP_COHERENT_BIT : 0);
glBufferStorage(GL_ARRAY_BUFFER, m_vertexBytes, vertices.data(), storageFlags);
glBufferStorage(GL_ELEMENT_ARRAY_BUFFER, m_indexBytes, kIndicesLeftQuad, storageFlags);
const GLenum storageError = FirstGLError();
if (storageError != GL_NO_ERROR) {
m_storageUnsupported = true;
m_storageError = storageError;
return;
}
const GLbitfield mapFlags = GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT |
(coherent ? GL_MAP_COHERENT_BIT : GL_MAP_FLUSH_EXPLICIT_BIT);
m_vertexMap =
static_cast<unsigned char*>(glMapBufferRange(GL_ARRAY_BUFFER, 0, m_vertexBytes, mapFlags));
m_indexMap = static_cast<unsigned char*>(
glMapBufferRange(GL_ELEMENT_ARRAY_BUFFER, 0, m_indexBytes, mapFlags));
if (m_vertexMap == nullptr || m_indexMap == nullptr) {
m_storageUnsupported = true;
m_storageError = FirstGLError();
return;
}
} else {
glBufferData(GL_ARRAY_BUFFER, m_vertexBytes, vertices.data(), GL_STATIC_DRAW);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, m_indexBytes, kIndicesLeftQuad, GL_STATIC_DRAW);
}
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(8));
glBindVertexArray(0);
glGenBuffers(1, &m_staging);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
}
void ReleaseBuffers() {
if (m_vertexMap != nullptr || m_indexMap != nullptr) {
glBindVertexArray(m_vao);
if (m_vertexMap != nullptr) {
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glUnmapBuffer(GL_ARRAY_BUFFER);
}
if (m_indexMap != nullptr) {
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo);
glUnmapBuffer(GL_ELEMENT_ARRAY_BUFFER);
}
glBindVertexArray(0);
m_vertexMap = nullptr;
m_indexMap = nullptr;
}
if (m_staging != 0) glDeleteBuffers(1, &m_staging);
if (m_ebo != 0) glDeleteBuffers(1, &m_ebo);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_staging = m_ebo = m_vbo = m_vao = 0;
}
void DrawScene() {
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glDrawElements(GL_TRIANGLES, kIndexCount, GL_UNSIGNED_INT, nullptr);
glBindVertexArray(0);
}
void BeginFrame() {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
}
Image ReadFrame() { return ReadPixels(Gl().Width(), Gl().Height()); }
// ---- the mutations ---------------------------------------------
// Each writes `newBytes` over the first `rangeBytes` of `buffer`;
// `wholeBytes`/`wholeSize` are the full contents an orphan+re-upload
// needs. `target` is the binding point the buffer normally lives at.
void ApplyMutation(Mutation mutation, GLenum target, GLuint buffer, unsigned char* persistentMap,
const void* newBytes, GLsizeiptr rangeBytes, const void* wholeBytes,
GLsizeiptr wholeSize) {
// The element-array binding is VAO state, so mutating the EBO happens
// with the scene's VAO bound - exactly as an application would.
glBindVertexArray(m_vao);
switch (mutation) {
case Mutation::SubData: {
glBindBuffer(target, buffer);
glBufferSubData(target, 0, rangeBytes, newBytes);
break;
}
case Mutation::MapWriteUnmap: {
glBindBuffer(target, buffer);
void* mapped =
glMapBufferRange(target, 0, rangeBytes, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_RANGE_BIT);
ASSERT_NE(mapped, nullptr) << "glMapBufferRange(WRITE) returned null";
std::memcpy(mapped, newBytes, std::size_t(rangeBytes));
ASSERT_EQ(glUnmapBuffer(target), GLboolean(GL_TRUE)) << "glUnmapBuffer reported data loss";
break;
}
case Mutation::PersistentFlush: {
ASSERT_NE(persistentMap, nullptr) << "no persistent mapping for this buffer";
std::memcpy(persistentMap, newBytes, std::size_t(rangeBytes));
glBindBuffer(target, buffer);
glFlushMappedBufferRange(target, 0, rangeBytes);
break;
}
case Mutation::PersistentCoherent: {
// Deliberately no GL call: a coherent persistent mapping is a
// promise that the write alone is enough.
ASSERT_NE(persistentMap, nullptr) << "no persistent mapping for this buffer";
std::memcpy(persistentMap, newBytes, std::size_t(rangeBytes));
break;
}
case Mutation::OrphanReupload: {
glBindBuffer(target, buffer);
glBufferData(target, wholeSize, nullptr, GL_STATIC_DRAW);
glBufferSubData(target, 0, wholeSize, wholeBytes);
break;
}
case Mutation::CopySubData: {
glBindBuffer(GL_COPY_READ_BUFFER, m_staging);
glBufferData(GL_COPY_READ_BUFFER, rangeBytes, newBytes, GL_STATIC_DRAW);
glBindBuffer(GL_COPY_WRITE_BUFFER, buffer);
glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, 0, 0, rangeBytes);
glBindBuffer(GL_COPY_WRITE_BUFFER, 0);
glBindBuffer(GL_COPY_READ_BUFFER, 0);
break;
}
}
glBindVertexArray(0);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the mutation itself raised a GL error";
}
// ---- the story -------------------------------------------------
// Steady state for a few frames, one frame boundary, then the
// mutation, then the draw that must show the new content.
void RunAcrossFrameBoundary(Mutation mutation, const std::function<void()>& mutate,
const char* expectedLeftAfter, const char* expectedRightAfter) {
ASSERT_NO_FATAL_FAILURE(BuildScene(NeedsImmutableStorage(mutation), NeedsCoherentMapping(mutation)));
if (m_storageUnsupported) {
GTEST_SKIP() << "immutable/persistent buffer storage is unavailable on this stack ("
<< GLErrorName(m_storageError) << "); the persistent-map mutation cannot "
<< "be expressed here";
}
for (int frame = 0; frame < kWarmupFrames; ++frame) {
BeginFrame();
DrawScene();
Gl().EndFrame();
}
BeginFrame();
DrawScene();
const Image before = ReadFrame();
ExpectHalves(before, "red", "black", "steady state before the mutation");
ASSERT_FALSE(::testing::Test::HasFailure())
<< "the scenario never reached its steady state, so nothing after this means anything";
// >>> a genuine frame boundary. Everything below happens in the NEXT
// frame, which is the whole point: a mutation inside one frame proves
// nothing about a memo that revalidates itself across frames.
Gl().EndFrame();
BeginFrame();
ASSERT_NO_FATAL_FAILURE(mutate());
DrawScene();
const Image after = ReadFrame();
Gl().EndFrame();
ExpectHalves(after, expectedLeftAfter, expectedRightAfter,
"the draw after the mutation drew STALE buffer content");
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// The two things a scenario mutates.
void MutateVertexColorsToGreen(Mutation mutation) {
const std::vector<Vertex> updated = SceneVertices(/*leftQuadIsGreen=*/true);
const GLsizeiptr leftQuadBytes = GLsizeiptr(kLeftQuadVertexCount * sizeof(Vertex));
ApplyMutation(mutation, GL_ARRAY_BUFFER, m_vbo, m_vertexMap, updated.data() + kLeftQuadFirstVertex,
leftQuadBytes, updated.data(), m_vertexBytes);
}
void MutateIndicesToRightQuad(Mutation mutation) {
ApplyMutation(mutation, GL_ELEMENT_ARRAY_BUFFER, m_ebo, m_indexMap, kIndicesRightQuad, m_indexBytes,
kIndicesRightQuad, m_indexBytes);
}
unsigned int m_program = 0;
unsigned int m_vao = 0;
unsigned int m_vbo = 0;
unsigned int m_ebo = 0;
unsigned int m_staging = 0;
GLsizeiptr m_vertexBytes = 0;
GLsizeiptr m_indexBytes = 0;
unsigned char* m_vertexMap = nullptr;
unsigned char* m_indexMap = nullptr;
bool m_storageUnsupported = false;
unsigned int m_storageError = 0;
};
// ---- vertex buffer: the left quad must turn green ------------------
TEST_F(CrossFrameBufferScenario, VertexBufferSubData) {
RunAcrossFrameBoundary(
Mutation::SubData, [&] { MutateVertexColorsToGreen(Mutation::SubData); }, "green", "black");
}
TEST_F(CrossFrameBufferScenario, VertexMapWriteUnmap) {
RunAcrossFrameBoundary(
Mutation::MapWriteUnmap, [&] { MutateVertexColorsToGreen(Mutation::MapWriteUnmap); }, "green", "black");
}
TEST_F(CrossFrameBufferScenario, VertexPersistentMapFlush) {
RunAcrossFrameBoundary(
Mutation::PersistentFlush, [&] { MutateVertexColorsToGreen(Mutation::PersistentFlush); }, "green",
"black");
}
TEST_F(CrossFrameBufferScenario, VertexPersistentCoherentWrite) {
RunAcrossFrameBoundary(
Mutation::PersistentCoherent, [&] { MutateVertexColorsToGreen(Mutation::PersistentCoherent); }, "green",
"black");
}
TEST_F(CrossFrameBufferScenario, VertexOrphanAndReupload) {
RunAcrossFrameBoundary(
Mutation::OrphanReupload, [&] { MutateVertexColorsToGreen(Mutation::OrphanReupload); }, "green",
"black");
}
TEST_F(CrossFrameBufferScenario, VertexCopyBufferSubData) {
RunAcrossFrameBoundary(
Mutation::CopySubData, [&] { MutateVertexColorsToGreen(Mutation::CopySubData); }, "green", "black");
}
// ---- index buffer: the picture must jump to the right, green quad --
// The EBO memo had the same cross-frame hole as the vertex one, and no
// vertex-only test can see it.
TEST_F(CrossFrameBufferScenario, IndexBufferSubData) {
RunAcrossFrameBoundary(
Mutation::SubData, [&] { MutateIndicesToRightQuad(Mutation::SubData); }, "black", "green");
}
TEST_F(CrossFrameBufferScenario, IndexMapWriteUnmap) {
RunAcrossFrameBoundary(
Mutation::MapWriteUnmap, [&] { MutateIndicesToRightQuad(Mutation::MapWriteUnmap); }, "black", "green");
}
TEST_F(CrossFrameBufferScenario, IndexPersistentMapFlush) {
RunAcrossFrameBoundary(
Mutation::PersistentFlush, [&] { MutateIndicesToRightQuad(Mutation::PersistentFlush); }, "black",
"green");
}
// Kept, with its coverage stated exactly, because it is the one case in
// this file that is served a stale slice by the buggy revision and passes
// anyway - and a test that reads as coverage without being coverage is
// worse than no test.
//
// COVERS: the coherent-persistent index contract - a write into a coherent
// persistent mapping, with no GL call at all, must reach the next frame's
// draw. That is a real contract and this is the only case that states it
// for indices.
//
// DOES NOT COVER: the EBO cross-frame memo. Instrumented against the
// re-enabled buggy path, it enters the cross-frame branch 4 times and is
// served its recorded slice all 4 times - and still passes, because the
// backend adopted the persistent map into that very storage
// (AcquirePersistentMap succeeded), so the application's writes landed in
// the bytes the "stale" slice names. It would only discriminate on a stack
// where that adoption is declined and the CPU shadow stays authoritative;
// measured over this whole module, 50 of 50 coherent persistent write maps
// were adopted. See ResidentIndexScenario.cpp for the full account.
TEST_F(CrossFrameBufferScenario, IndexPersistentCoherentWrite) {
RunAcrossFrameBoundary(
Mutation::PersistentCoherent, [&] { MutateIndicesToRightQuad(Mutation::PersistentCoherent); }, "black",
"green");
}
TEST_F(CrossFrameBufferScenario, IndexOrphanAndReupload) {
RunAcrossFrameBoundary(
Mutation::OrphanReupload, [&] { MutateIndicesToRightQuad(Mutation::OrphanReupload); }, "black",
"green");
}
TEST_F(CrossFrameBufferScenario, IndexCopyBufferSubData) {
RunAcrossFrameBoundary(
Mutation::CopySubData, [&] { MutateIndicesToRightQuad(Mutation::CopySubData); }, "black", "green");
}
// ---- a self-test of the assertions, not of MobileGL ------------------
//
// Every case above leans on ExpectHalves. ExpectHalves used to sample the
// centre pixel of each half - two pixels for a 12288-pixel readback - and
// that is measurably too weak to stand behind a claim about buffer
// freshness: a quad whose four vertices are only PARTLY updated still
// paints a sampled centre the expected colour, because the centre is a
// barycentric blend dominated by the vertices that DID update.
//
// So construct that case on purpose. Update the left quad's colour to
// green in the buffer but leave exactly one of its four vertices holding
// the old red, once for each vertex, and check two things:
//
// - the whole-region scan reports every one of the four (the tightening
// is real, and this test fails the moment someone loosens it back to
// sampling);
// - at least one of the four is invisible to a single centre sample
// (the blind spot was real, and this records which vertices it hid).
//
// Nothing here calls a memo path; it is the assertion itself under test.
TEST_F(CrossFrameBufferScenario, PartialStalenessIsCaughtByWholeRegionChecks) {
ASSERT_NO_FATAL_FAILURE(BuildScene(/*immutable=*/false));
const std::vector<Vertex> allGreen = SceneVertices(/*leftQuadIsGreen=*/true);
const std::vector<Vertex> allRed = SceneVertices(/*leftQuadIsGreen=*/false);
const GLsizeiptr leftQuadBytes = GLsizeiptr(kLeftQuadVertexCount * sizeof(Vertex));
int centreSampleMissed = 0;
std::string missedVertices;
for (int staleVertex = 0; staleVertex < kLeftQuadVertexCount; ++staleVertex) {
// Every left-quad vertex turns green except this one.
std::vector<Vertex> partial(allGreen.begin(), allGreen.begin() + kLeftQuadVertexCount);
partial[std::size_t(staleVertex)] = allRed[std::size_t(staleVertex)];
glBindVertexArray(m_vao);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferSubData(GL_ARRAY_BUFFER, 0, leftQuadBytes, partial.data());
glBindVertexArray(0);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the partial update itself raised a GL error";
BeginFrame();
DrawScene();
const Image image = ReadFrame();
Gl().EndFrame();
const int w = image.Width();
const int h = image.Height();
const RegionScan scan =
ScanRegion(image, kHalfInset, w / 2 - kHalfInset, kHalfInset, h - kHalfInset, "green");
EXPECT_GT(scan.offenders, 0)
<< "vertex " << staleVertex << " of the left quad kept its stale red colour and the "
<< "whole-region scan saw nothing wrong across " << scan.total << " pixels - the assertion "
<< "is not tight enough to stand behind any freshness claim in this file";
// What the old two-pixel form of ExpectHalves would have concluded.
if (std::strcmp(image.ColorName(w / 4, h / 2), "green") == 0) {
++centreSampleMissed;
if (!missedVertices.empty()) missedVertices += ",";
missedVertices += std::to_string(staleVertex);
}
}
EXPECT_GT(centreSampleMissed, 0)
<< "no single-vertex staleness was invisible to a centre sample, so this negative control "
<< "is no longer demonstrating anything - re-derive it before trusting it";
if (centreSampleMissed > 0) {
RecordProperty("centre_sample_blind_to_stale_vertices", missedVertices);
std::fprintf(stderr,
"[itest] whole-region scan caught all %d single-stale-vertex cases; a centre "
"sample alone was blind to %d of them (vertices %s)\n",
kLeftQuadVertexCount, centreSampleMissed, missedVertices.c_str());
}
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// ---- the same bug, seen from the other side --------------------------
//
// The mutation cases above ask "did the new bytes reach the GPU?". This
// one asks the question a STREAMED buffer forces: "do the old bytes even
// still exist?".
//
// A GL_STREAM_DRAW / GL_DYNAMIC_DRAW buffer is not given permanent GPU
// storage. Every frame its contents are copied into that frame's
// transient upload arena, which is a bump allocator reset at the start of
// each frame slot - so a slice handed out in frame N names bytes that
// frame N+frames-in-flight hands to whoever uploads first. A memo that
// revalidates across a frame boundary and skips the acquire never
// re-uploads, so it keeps binding an offset the arena has since given
// away: the draw reads whatever the next tenant put there. That is the
// "random triangles" shape of this bug - the buffer nobody touched is the
// one that renders wrong.
//
// The scene makes the next tenant deterministic instead of arbitrary: a
// second streamed object of exactly the same size is uploaded and drawn
// FIRST in every frame, so it lands on precisely the bytes the memo still
// points at. A draw that renders the decoy's geometry instead of its own
// is unmissable.
class StreamedArenaScenario : public ScenarioTest {
protected:
static constexpr int kQuietFrames = 2; // frames in which only the subject draws
static constexpr int kChurnFrames = 8; // > frames-in-flight, so the ring wraps
struct StreamedObject {
unsigned int vao = 0;
unsigned int vbo = 0;
unsigned int ebo = 0;
};
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
void TearDown() override {
if (!Ready()) return;
for (StreamedObject* object : {&m_subject, &m_decoy}) {
if (object->ebo != 0) glDeleteBuffers(1, &object->ebo);
if (object->vbo != 0) glDeleteBuffers(1, &object->vbo);
if (object->vao != 0) glDeleteVertexArrays(1, &object->vao);
*object = StreamedObject{};
}
if (m_program != 0) glDeleteProgram(m_program);
}
// GL_STREAM_DRAW is what puts a buffer on the transient arena
// (ShouldUseTransientVertexIndexBuffer) - and what Minecraft uses for
// exactly this kind of geometry.
void BuildStreamedObject(StreamedObject& object, const std::vector<Vertex>& vertices,
const GLuint (&indices)[kIndexCount]) {
glGenVertexArrays(1, &object.vao);
glBindVertexArray(object.vao);
glGenBuffers(1, &object.vbo);
glBindBuffer(GL_ARRAY_BUFFER, object.vbo);
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data(),
GL_STREAM_DRAW);
glGenBuffers(1, &object.ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, object.ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(indices)), indices, GL_STREAM_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(8));
glBindVertexArray(0);
}
void Draw(const StreamedObject& object) {
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(m_program);
glBindVertexArray(object.vao);
glDrawElements(GL_TRIANGLES, kIndexCount, GL_UNSIGNED_INT, nullptr);
glBindVertexArray(0);
}
// Re-uploading the decoy is what forces it onto a fresh arena slice
// this frame - i.e. what makes it the arena's next tenant.
void RestreamDecoy(const std::vector<Vertex>& vertices, const GLuint (&indices)[kIndexCount]) {
glBindVertexArray(m_decoy.vao);
glBindBuffer(GL_ARRAY_BUFFER, m_decoy.vbo);
glBufferSubData(GL_ARRAY_BUFFER, 0, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_decoy.ebo);
glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(indices)), indices);
glBindVertexArray(0);
}
unsigned int m_program = 0;
StreamedObject m_subject;
StreamedObject m_decoy;
};
// Vertex data. Subject and decoy differ in geometry AND colour, so a
// subject draw that reads the decoy's arena bytes paints the decoy's quad.
TEST_F(StreamedArenaScenario, StreamedVertexDataSurvivesArenaRecycling) {
const std::vector<Vertex> full = SceneVertices(/*leftQuadIsGreen=*/false);
const std::vector<Vertex> subjectVertices(full.begin(), full.begin() + 4); // left, red
const std::vector<Vertex> decoyVertices(full.begin() + 4, full.begin() + 8); // right, green
ASSERT_EQ(subjectVertices.size(), decoyVertices.size()); // same arena footprint
BuildStreamedObject(m_subject, subjectVertices, kIndicesLeftQuad);
BuildStreamedObject(m_decoy, decoyVertices, kIndicesLeftQuad);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
// Quiet frames: the subject is the only thing uploading, so its data
// sits at the head of the arena and its memo records that offset.
for (int frame = 0; frame < kQuietFrames; ++frame) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
Draw(m_subject);
Gl().EndFrame();
}
// Churn frames: the decoy re-streams and draws first every frame. The
// subject is never touched again - it must still render itself.
for (int frame = 0; frame < kChurnFrames; ++frame) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
RestreamDecoy(decoyVertices, kIndicesLeftQuad);
Draw(m_decoy);
Draw(m_subject);
const Image image = ReadPixels(Gl().Width(), Gl().Height());
ExpectHalves(image, "red", "green",
"churn frame " + std::to_string(frame) +
": the untouched streamed vertex buffer rendered someone else's arena bytes");
Gl().EndFrame();
}
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// Index data. Both objects carry the SAME eight vertices, so only the
// element buffer can decide which half is drawn - this isolates the EBO
// memo, which had its own copy of the cross-frame hole.
//
// COVERS: that an untouched streamed index buffer still renders its own
// geometry after the arena it lives in has been recycled by another
// object - the index-side statement of the invariant the vertex case
// above actually catches.
//
// DOES NOT COVER: the EBO cross-frame memo. Instrumented against the
// re-enabled buggy path this case reaches that branch ZERO times: the memo
// is recorded only on the RESIDENT index path (UploadAndBindIndexBuffer
// stores it in the arm after AcquireResidentSlice), and a streamed EBO
// never gets there. So it passes on the buggy revision exactly as it does
// on the fixed one, and it is not evidence about the fix.
//
// It stays because it is the tripwire for the change that would make the
// EBO memo dangerous: memoise the streamed index path - the obvious next
// step for the same optimisation - and the reach stops being zero and this
// test fails on the first churn frame. See ResidentIndexScenario.cpp.
TEST_F(StreamedArenaScenario, StreamedIndexDataSurvivesArenaRecycling) {
const std::vector<Vertex> shared = SceneVertices(/*leftQuadIsGreen=*/false);
BuildStreamedObject(m_subject, shared, kIndicesLeftQuad); // draws the left, red quad
BuildStreamedObject(m_decoy, shared, kIndicesRightQuad); // draws the right, green quad
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
for (int frame = 0; frame < kQuietFrames; ++frame) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
Draw(m_subject);
Gl().EndFrame();
}
for (int frame = 0; frame < kChurnFrames; ++frame) {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
RestreamDecoy(shared, kIndicesRightQuad);
Draw(m_decoy);
Draw(m_subject);
const Image image = ReadPixels(Gl().Width(), Gl().Height());
ExpectHalves(image, "red", "green",
"churn frame " + std::to_string(frame) +
": the untouched streamed index buffer rendered someone else's arena bytes");
Gl().EndFrame();
}
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,381 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/OrientationScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario A - "the frame came out upside down".
//
// The shipped bug (DirectVulkan, GetBaseTransformFlagsRaw): the shader
// transform flags - the Y-flip and surface-rotation bits that apply ONLY when
// the bound draw framebuffer is the default one - were memoized on the
// swapchain pre-transform alone. The is-default-framebuffer input was not part
// of the key, so whichever kind of pass evaluated the memo first decided the
// orientation of every pass after it. In a real frame that meant: after any
// render-to-texture pass, the next default-framebuffer pass inherited the FBO's
// unflipped flags and the whole frame rendered upside down (retrace SSIM 0.052,
// deterministic; flickering clouds on device).
//
// What pins it: a pattern asymmetric in BOTH axes - four quadrants, coloured
//
// top-left RED | WHITE top-right
// bottom-left BLUE | GREEN bottom-right
//
// - drawn to a target, read back with glReadPixels, and reduced to the four
// quadrant-centre colours in the fixed order bottom-left, bottom-right,
// top-left, top-right.
//
// Four quadrants rather than the three horizontal stripes this scenario used to
// draw, because stripes only pin ONE axis. Stripes read down the centre line
// are unchanged by an X flip, by a transpose, and by a 180 rotation composed
// with a Y flip: all three of those bugs would have rendered a green stripe
// between a blue one and a red one and passed. Every one of the eight
// symmetries of the square now produces a different string:
//
// identity blue,green,red,white <- correct
// Y flip red,white,blue,green <- the shipped bug
// X flip green,blue,white,red
// 180 rotation white,red,green,blue
// transpose blue,red,green,white
// anti-transpose white,green,red,blue
// rotate 90 CCW red,blue,white,green
// rotate 90 CW green,white,blue,red
//
// The assertions then go further than the signature: every quadrant is checked
// pixel by pixel over its whole area (RegionIsMostly), so a partial or torn
// draw cannot pass by having the four sampled centres come out right.
//
// Both orderings are covered, because the memo is poisoned by whichever pass
// runs first and these tests share one process:
// - default -> FBO -> default (the FBO pass inherits the default's flip)
// - FBO -> default (the shipped symptom: the default pass
// inherits the FBO's lack of flip)
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr const char* kVertexSource = R"(#version 330 core
in vec2 aPos;
in vec3 aColor;
out vec3 vColor;
void main() {
vColor = aColor;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kFragmentSource = R"(#version 330 core
in vec3 vColor;
out vec4 oColor;
void main() {
oColor = vec4(vColor, 1.0);
}
)";
// The correctly-oriented answer, in glReadPixels order (row 0 is the
// bottom row) and in QuadrantSignature's order: bottom-left, bottom-right,
// top-left, top-right. Plain GL semantics; holds for every framebuffer,
// default or not.
constexpr const char* kUprightSignature = "blue,green,red,white";
// How far inside each quadrant the whole-region checks start. The quadrant
// seam sits on a pixel boundary, so one pixel of margin is enough to make
// "every single pixel" an achievable (and therefore useful) demand.
constexpr int kQuadrantInset = 2;
struct Vertex {
float x, y;
float r, g, b;
};
void AppendQuad(std::vector<Vertex>& out, float x0, float x1, float y0, float y1, float r, float g, float b) {
const Vertex bl{x0, y0, r, g, b};
const Vertex br{x1, y0, r, g, b};
const Vertex tr{x1, y1, r, g, b};
const Vertex tl{x0, y1, r, g, b};
out.insert(out.end(), {bl, br, tr, bl, tr, tl});
}
std::vector<Vertex> QuadrantGeometry() {
std::vector<Vertex> vertices;
vertices.reserve(24);
AppendQuad(vertices, -1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f); // bottom-left: blue
AppendQuad(vertices, 0.0f, 1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f); // bottom-right: green
AppendQuad(vertices, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f); // top-left: red
AppendQuad(vertices, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f); // top-right: white
return vertices;
}
class OrientationScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
const std::vector<Vertex> vertices = QuadrantGeometry();
m_vertexCount = static_cast<int>(vertices.size());
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data(),
GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(8));
glBindVertexArray(0);
m_offscreen = MakeColorFbo(Gl().Width(), Gl().Height());
ASSERT_NE(m_offscreen.fbo, 0u) << "offscreen FBO is not framebuffer-complete";
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "setup left a GL error behind";
}
void TearDown() override {
if (!Ready()) return;
DestroyColorFbo(m_offscreen);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_program != 0) glDeleteProgram(m_program);
}
void DrawQuadrants() {
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, m_vertexCount);
glBindVertexArray(0);
}
// One pass to the default (presentable) framebuffer.
Image DefaultFramebufferPass() {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawQuadrants();
return ReadPixels(Gl().Width(), Gl().Height());
}
// One render-to-texture pass. Real frames do this constantly
// (shadow maps, post-processing, Minecraft's main render target).
Image OffscreenPass() {
BindFbo(m_offscreen);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
DrawQuadrants();
return ReadPixels(m_offscreen.width, m_offscreen.height);
}
// The signature says WHICH transform went wrong; this says the whole
// image is right, not merely its four sampled centres.
void ExpectUprightQuadrants(const Image& image, const std::string& when) {
const int w = image.Width();
const int h = image.Height();
const int inset = kQuadrantInset;
EXPECT_TRUE(RegionIsMostly(image, inset, w / 2 - inset, inset, h / 2 - inset, "blue", 0.0, when));
EXPECT_TRUE(RegionIsMostly(image, w / 2 + inset, w - inset, inset, h / 2 - inset, "green", 0.0, when));
EXPECT_TRUE(RegionIsMostly(image, inset, w / 2 - inset, h / 2 + inset, h - inset, "red", 0.0, when));
EXPECT_TRUE(RegionIsMostly(image, w / 2 + inset, w - inset, h / 2 + inset, h - inset, "white", 0.0,
when));
}
unsigned int m_program = 0;
unsigned int m_vao = 0;
unsigned int m_vbo = 0;
int m_vertexCount = 0;
ColorFbo m_offscreen;
};
// The plain statement of GL semantics that everything else leans on: an
// FBO pass is never flipped.
TEST_F(OrientationScenario, OffscreenPassRendersUpright) {
const Image offscreen = OffscreenPass();
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
<< "a render-to-texture pass must render unflipped";
ExpectUprightQuadrants(offscreen, "render-to-texture pass");
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// The same for the default framebuffer: whatever the backend does with
// the swapchain internally, glReadPixels owes the caller GL orientation.
TEST_F(OrientationScenario, DefaultFramebufferPassRendersUpright) {
const Image presented = DefaultFramebufferPass();
EXPECT_EQ(presented.QuadrantSignature(), kUprightSignature)
<< "a default-framebuffer pass must read back in GL orientation";
ExpectUprightQuadrants(presented, "default-framebuffer pass");
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// Scenario A proper: default -> FBO -> default in one frame. The third
// pass must be pixel-identical to the first; the FBO pass in between
// must not have moved anything.
TEST_F(OrientationScenario, DefaultFramebufferSurvivesAnOffscreenPass) {
const Image before = DefaultFramebufferPass();
const Image offscreen = OffscreenPass();
const Image after = DefaultFramebufferPass();
EXPECT_EQ(before.QuadrantSignature(), kUprightSignature)
<< "first default-framebuffer pass is already misoriented";
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
<< "the render-to-texture pass in the middle rendered flipped - the "
"default framebuffer's transform flags leaked into it";
EXPECT_EQ(after.QuadrantSignature(), kUprightSignature)
<< "the default-framebuffer pass AFTER a render-to-texture pass is "
"misoriented - it inherited the FBO's transform flags";
ExpectUprightQuadrants(after, "default-framebuffer pass after a render-to-texture pass");
EXPECT_TRUE(after == before) << "the third pass differs from the first in " << after.ByteDiffCount(before)
<< " bytes; first=" << before.QuadrantSignature()
<< " third=" << after.QuadrantSignature();
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// The shipped symptom, in its shipped order: an FBO pass, then the
// default framebuffer. This is the one that flipped whole Minecraft
// frames.
TEST_F(OrientationScenario, DefaultFramebufferAfterOffscreenIsNotFlipped) {
const Image offscreen = OffscreenPass();
const Image presented = DefaultFramebufferPass();
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
<< "render-to-texture pass rendered flipped";
EXPECT_EQ(presented.QuadrantSignature(), kUprightSignature)
<< "the default-framebuffer pass that follows a render-to-texture pass "
"rendered upside down";
ExpectUprightQuadrants(presented, "default-framebuffer pass following a render-to-texture pass");
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// And across a real frame boundary, which is how a game actually
// alternates the two kinds of pass.
TEST_F(OrientationScenario, OrientationIsStableAcrossFrames) {
const Image firstFrame = DefaultFramebufferPass();
ExpectUprightQuadrants(firstFrame, "frame 0");
Gl().EndFrame();
for (int frame = 0; frame < 3; ++frame) {
const Image offscreen = OffscreenPass();
EXPECT_EQ(offscreen.QuadrantSignature(), kUprightSignature)
<< "frame " << frame + 1 << "'s render-to-texture pass is misoriented";
const Image presented = DefaultFramebufferPass();
EXPECT_EQ(presented.QuadrantSignature(), kUprightSignature)
<< "frame " << frame + 1 << " of the alternating FBO/default loop is misoriented";
ExpectUprightQuadrants(presented, "frame " + std::to_string(frame + 1));
EXPECT_TRUE(presented == firstFrame) << "frame " << frame + 1 << " differs from frame 0 in "
<< presented.ByteDiffCount(firstFrame) << " bytes";
Gl().EndFrame();
}
EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
}
// A standing self-test of the signature, not of MobileGL: it proves the
// four-quadrant reduction really does separate all eight symmetries of
// the square, so a future "simplify the pattern" change cannot quietly
// reintroduce the blind spot the three-stripe version had (X flip,
// transpose and 180+Y-flip all left the stripe signature alone).
TEST_F(OrientationScenario, QuadrantSignatureSeparatesEverySquareSymmetry) {
const Image upright = OffscreenPass();
ASSERT_EQ(upright.QuadrantSignature(), kUprightSignature) << "the reference image is not upright";
const int w = upright.Width();
const int h = upright.Height();
// Transposes are expressed on the largest centred square the readback
// contains, which is enough for the four quadrant centres to move.
const int side = std::min(w, h);
const int ox = (w - side) / 2;
const int oy = (h - side) / 2;
struct Symmetry {
const char* name;
const char* expected;
int (*mapX)(int x, int y, int w, int h);
int (*mapY)(int x, int y, int w, int h);
};
const Symmetry symmetries[] = {
{"Y flip", "red,white,blue,green", [](int x, int, int, int) { return x; },
[](int, int y, int, int hh) { return hh - 1 - y; }},
{"X flip", "green,blue,white,red", [](int x, int, int ww, int) { return ww - 1 - x; },
[](int, int y, int, int) { return y; }},
{"180 rotation", "white,red,green,blue", [](int x, int, int ww, int) { return ww - 1 - x; },
[](int, int y, int, int hh) { return hh - 1 - y; }},
};
for (const Symmetry& symmetry : symmetries) {
Image transformed(w, h);
for (int y = 0; y < h; ++y) {
for (int x = 0; x < w; ++x) {
const Rgba8 source = upright.At(symmetry.mapX(x, y, w, h), symmetry.mapY(x, y, w, h));
std::uint8_t* out = transformed.Data() + (std::size_t(y) * w + x) * 4;
out[0] = source.r;
out[1] = source.g;
out[2] = source.b;
out[3] = source.a;
}
}
EXPECT_EQ(transformed.QuadrantSignature(), symmetry.expected)
<< symmetry.name << " must produce its own signature, or the pattern cannot see it";
EXPECT_NE(transformed.QuadrantSignature(), kUprightSignature)
<< symmetry.name << " is INDISTINGUISHABLE from an upright frame - the pattern is too symmetric";
}
// The four symmetries that move the axes into each other. They only
// make sense on a square, so they run on the largest centred one.
struct SquareSymmetry {
const char* name;
const char* expected;
int (*sourceX)(int x, int y, int side);
int (*sourceY)(int x, int y, int side);
};
const SquareSymmetry squareSymmetries[] = {
{"transpose", "blue,red,green,white", [](int, int y, int) { return y; },
[](int x, int, int) { return x; }},
{"anti-transpose", "white,green,red,blue", [](int, int y, int s) { return s - 1 - y; },
[](int x, int, int s) { return s - 1 - x; }},
{"rotate 90 CCW", "red,blue,white,green", [](int, int y, int) { return y; },
[](int x, int, int s) { return s - 1 - x; }},
{"rotate 90 CW", "green,white,blue,red", [](int, int y, int s) { return s - 1 - y; },
[](int x, int, int) { return x; }},
};
for (const SquareSymmetry& symmetry : squareSymmetries) {
Image square(side, side);
for (int y = 0; y < side; ++y) {
for (int x = 0; x < side; ++x) {
const Rgba8 source =
upright.At(ox + symmetry.sourceX(x, y, side), oy + symmetry.sourceY(x, y, side));
std::uint8_t* out = square.Data() + (std::size_t(y) * side + x) * 4;
out[0] = source.r;
out[1] = source.g;
out[2] = source.b;
out[3] = source.a;
}
}
EXPECT_EQ(square.QuadrantSignature(), symmetry.expected)
<< symmetry.name << " must produce its own signature, or the pattern cannot see it";
EXPECT_NE(square.QuadrantSignature(), kUprightSignature)
<< symmetry.name << " is INDISTINGUISHABLE from an upright frame";
}
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,383 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ResidentIndexScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario C - RESIDENT index buffers across frame boundaries.
//
// WHAT THIS FILE DOES AND DOES NOT COVER, stated plainly because the answer is
// not the one it was written to find.
//
// The shipped fix (d7976326) removed cross-frame slice trust from TWO memos: the
// vertex-binding one and the EBO one. StreamedArenaScenario pins the vertex
// half - re-enable that half alone and it fails. Nothing pinned the EBO half,
// and these cases are the result of trying to build something that does.
//
// The EBO memo lives in UploadAndBindIndexBuffer and is recorded ONLY on the
// resident branch, keyed on (BufferObject*, VkBufferResource::sliceEpoch,
// frame serial). To fail with only the EBO revalidation re-enabled, a scenario
// needs a RESIDENT index buffer whose recorded slice stops describing the right
// bytes while the pointer and the epoch still match. Every case below is an
// attempt at that, run against the re-enabled buggy path with the branch
// instrumented to count reaches, acceptances, and - critically - what the
// skipped AcquireResidentSlice WOULD have done. The measurement, over this file
// plus every other scenario in the module:
//
// reached=89 accepted=81 sliceMoved=0 bytesChanged=0 epochBumped=0
//
// The buggy branch is entered 89 times and serves its recorded slice 81 times,
// and in NOT ONE of those 81 would the acquire have moved the slice, changed a
// byte of it, or bumped the epoch. The skipped work was a no-op every time.
//
// That is not luck, it is the shape of the code. A resident slice is
// `resource->buffer.GetSlice(0, size)` of a dedicated VkBuffer, so it can only
// move when CreateResidentStorage mints new storage - which bumps the epoch. Its
// bytes can only change through Respecify / SubData / FlushMappedRange - each of
// which bumps the epoch as its first act - or through
// BufferObject::SyncPersistentMappedRange, which the acquire calls and the memo
// skips. That last one is the real escape, and it is dead here: it early-outs
// when the backend has adopted the map into coherent GPU storage, and
// AcquirePersistentMap only declines when a host-visible coherent allocation
// FAILS. Instrumented across the whole module: 50 persistent coherent write
// maps, 50 adopted, 0 dispatches. A 96 MiB EBO did not change that either.
//
// So on DirectVulkan as it stands, the EBO half of the fix is not reachable from
// a GL-level test - not because the guard is sound in principle (it is the same
// unsound idea the vertex half shipped corruption with) but because the two
// mechanisms that made the vertex half observable are both absent for indices:
//
// 1. ARENA RELOCATION. The vertex memo records STREAMED slices too, and a
// streamed slice moves to a new arena block every frame BY DESIGN - the
// epoch that catches it is bumped inside the very acquire the memo skips.
// That is what StreamedVertexDataSurvivesArenaRecycling exploits. The index
// memo is never recorded on the streamed branch, so no index memo ever
// names an arena offset. Measured: StreamedIndexDataSurvivesArenaRecycling
// reaches the branch 0 times, and so does PromotedDynamicEbo below (a
// promoted DYNAMIC_DRAW buffer is SERVED by AcquireResidentSlice but still
// ROUTED as streamed, so it is not memoised either).
// 2. HOST-MAP SYNC. Dead, as above.
//
// These cases therefore stay as what they honestly are: end-to-end regression
// tests for resident index-buffer freshness across frame boundaries, and the
// standing tripwire for change (1). The moment anyone memoises the streamed or
// promoted index path - the natural next step for the same optimisation - these
// stop being redundant and start failing. Each case says below what it covers.
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
in vec3 aColor;
out vec3 vColor;
void main() { vColor = aColor; gl_Position = vec4(aPos, 0.0, 1.0); }
)";
constexpr const char* kFS = R"(#version 330 core
in vec3 vColor;
out vec4 oColor;
void main() { oColor = vec4(vColor, 1.0); }
)";
struct V {
float x, y, r, g, b;
};
constexpr int kIdx = 6;
const GLuint kLeft[kIdx] = {0, 1, 2, 0, 2, 3};
const GLuint kRight[kIdx] = {4, 5, 6, 4, 6, 7};
std::vector<V> Scene() {
return {{-1, -1, 1, 0, 0}, {0, -1, 1, 0, 0}, {0, 1, 1, 0, 0}, {-1, 1, 1, 0, 0},
{0, -1, 0, 1, 0}, {1, -1, 0, 1, 0}, {1, 1, 0, 1, 0}, {0, 1, 0, 1, 0}};
}
class ResidentIndexScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string err;
m_program = CompileProgram(kVS, kFS, &err);
ASSERT_NE(m_program, 0u) << err;
}
void TearDown() override {
if (!Ready()) return;
if (m_program != 0) glDeleteProgram(m_program);
}
// A VAO whose VBO is STATIC_DRAW (so it resolves resident and the
// vertex memo is recorded) and whose EBO is `eboName`.
unsigned int MakeVao(unsigned int vbo, unsigned int ebo) {
unsigned int vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(8));
glBindVertexArray(0);
return vao;
}
unsigned int MakeStaticVbo() {
const std::vector<V> vertices = Scene();
unsigned int vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(vertices.size() * sizeof(V)), vertices.data(),
GL_STATIC_DRAW);
return vbo;
}
void Draw(unsigned int vao) {
glDisable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
glUseProgram(m_program);
glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, kIdx, GL_UNSIGNED_INT, nullptr);
glBindVertexArray(0);
}
void Begin() {
BindDefaultFramebuffer();
ClearTo(0, 0, 0, 1);
}
Image Read() { return ReadPixels(Gl().Width(), Gl().Height()); }
void Halves(const Image& image, const char* left, const char* right, const std::string& when) {
const int w = image.Width(), h = image.Height();
EXPECT_TRUE(RegionIsMostly(image, 2, w / 2 - 2, 2, h - 2, left, 0.0, when + " [left]"));
EXPECT_TRUE(RegionIsMostly(image, w / 2 + 2, w - 2, 2, h - 2, right, 0.0, when + " [right]"));
}
unsigned int m_program = 0;
};
// A: a coherent persistent EBO rewritten on EVERY frame, with no GL call
// between the write and the draw. This is the only shape in which an
// application changes index data with nothing for the backend to notice.
//
// COVERS: the coherent-persistent index contract end to end.
// DOES NOT COVER: the EBO memo. Instrumented it reaches the cross-frame
// branch 11 times and is served its recorded slice all 11 - but the
// backend adopted the map into that same storage, so the "stale" slice IS
// where the application's writes landed. It would only discriminate on a
// stack where AcquirePersistentMap declines (see the file header). A
// 96 MiB variant was tried to force that and did not: it cost 40s and
// measured the same zero, so it is not kept.
TEST_F(ResidentIndexScenario, PersistentCoherentEboWrittenEveryFrame) {
const unsigned int vbo = MakeStaticVbo();
unsigned int ebo = 0;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
const GLbitfield storageFlags =
GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT | GL_DYNAMIC_STORAGE_BIT;
glBufferStorage(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, storageFlags);
if (FirstGLError() != GL_NO_ERROR) GTEST_SKIP() << "no immutable storage";
auto* map = static_cast<unsigned char*>(glMapBufferRange(
GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(kLeft)),
GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT));
ASSERT_NE(map, nullptr);
const unsigned int vao = MakeVao(vbo, ebo);
for (int frame = 0; frame < 12; ++frame) {
Begin();
const bool wantRight = (frame % 2) == 1;
std::memcpy(map, wantRight ? kRight : kLeft, sizeof(kLeft));
Draw(vao);
const Image image = Read();
Halves(image, wantRight ? "black" : "red", wantRight ? "green" : "black",
"frame " + std::to_string(frame) + " of a per-frame coherent EBO rewrite");
Gl().EndFrame();
}
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glUnmapBuffer(GL_ELEMENT_ARRAY_BUFFER);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &ebo);
glDeleteBuffers(1, &vbo);
}
// B: usage escalation. The EBO is memoised as an index buffer, then bound
// as a VERTEX buffer in a later frame, which forces the backend to
// recreate its resident storage carrying the extra usage bit. A memo that
// survived that recreate would name a destroyed VkBuffer.
//
// COVERS: that a storage recreate driven by a DIFFERENT binding point
// retires the index memo. Reaches the branch 5 times.
TEST_F(ResidentIndexScenario, EboAlsoBoundAsVertexBufferLater) {
const unsigned int vbo = MakeStaticVbo();
unsigned int ebo = 0;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
// Big enough to be a legal (if nonsensical) vertex source too.
std::vector<GLuint> indices(64, 0);
std::memcpy(indices.data(), kLeft, sizeof(kLeft));
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(indices.size() * 4), indices.data(), GL_STATIC_DRAW);
const unsigned int vao = MakeVao(vbo, ebo);
unsigned int vertexUseVao = 0;
glGenVertexArrays(1, &vertexUseVao);
glBindVertexArray(vertexUseVao);
glBindBuffer(GL_ARRAY_BUFFER, ebo); // the EBO, as a vertex source
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(0));
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast<void*>(8));
glBindVertexArray(0);
for (int frame = 0; frame < 6; ++frame) {
Begin();
Draw(vao);
if (frame == 2) Draw(vertexUseVao); // forces the usage escalation
const Image image = Read();
if (frame != 2) {
Halves(image, "red", "black", "frame " + std::to_string(frame) + " around a usage escalation");
}
Gl().EndFrame();
}
glDeleteVertexArrays(1, &vertexUseVao);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &ebo);
glDeleteBuffers(1, &vbo);
}
// C: delete the EBO and immediately recreate it, so the frontend
// BufferObject may well land at the same address - which is all the memo's
// identity check compares. What stops it is that a fresh resource cannot
// reproduce an epoch from the process-lifetime counter; this is the test
// that says so out loud.
//
// COVERS: address reuse of a deleted index buffer. Reaches 7, accepts 6 -
// the one decline is the post-recreate draw.
TEST_F(ResidentIndexScenario, EboDeletedAndRecreatedAtTheSameName) {
const unsigned int vbo = MakeStaticVbo();
unsigned int ebo = 0;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, GL_STATIC_DRAW);
unsigned int vao = MakeVao(vbo, ebo);
for (int frame = 0; frame < 4; ++frame) {
Begin();
Draw(vao);
Halves(Read(), "red", "black", "warmup frame " + std::to_string(frame));
Gl().EndFrame();
}
// Same VAO, same GL name, different contents.
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &ebo);
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kRight)), kRight, GL_STATIC_DRAW);
vao = MakeVao(vbo, ebo);
for (int frame = 0; frame < 4; ++frame) {
Begin();
Draw(vao);
Halves(Read(), "black", "green", "post-recreate frame " + std::to_string(frame));
Gl().EndFrame();
}
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &ebo);
glDeleteBuffers(1, &vbo);
}
// D: one resident EBO shared by two VAOs, so two independent memo entries
// hold the same recorded slice, mutated through one of them and drawn
// through both across frames.
//
// COVERS: that a mutation retires EVERY memo naming the buffer, not just
// the one whose VAO issued it. Reaches 8, accepts 6.
TEST_F(ResidentIndexScenario, OneEboTwoVaosMutatedAcrossFrames) {
const unsigned int vbo = MakeStaticVbo();
unsigned int ebo = 0;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, GL_STATIC_DRAW);
const unsigned int vaoA = MakeVao(vbo, ebo);
const unsigned int vaoB = MakeVao(vbo, ebo);
for (int frame = 0; frame < 10; ++frame) {
Begin();
const bool wantRight = frame >= 5;
if (frame == 5) {
glBindVertexArray(vaoA);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(kRight)), kRight);
glBindVertexArray(0);
}
Draw((frame % 2) == 0 ? vaoA : vaoB);
Halves(Read(), wantRight ? "black" : "red", wantRight ? "green" : "black",
"shared-EBO frame " + std::to_string(frame));
Gl().EndFrame();
}
glDeleteVertexArrays(1, &vaoB);
glDeleteVertexArrays(1, &vaoA);
glDeleteBuffers(1, &ebo);
glDeleteBuffers(1, &vbo);
}
// E: a DYNAMIC_DRAW EBO left untouched long enough for the streaming path
// to PROMOTE it onto resident storage, then mutated.
//
// COVERS: promoted-buffer index freshness across a frame boundary.
// DOES NOT COVER: the EBO memo, and this is the useful part - instrumented,
// it reaches the cross-frame branch ZERO times. A promoted buffer is SERVED
// by AcquireResidentSlice but still ROUTED through the streamed branch of
// UploadAndBindIndexBuffer, which never records a memo. That asymmetry is
// exactly what makes the EBO half of the shipped fix unobservable, and this
// case is the tripwire: memoise the streamed/promoted index path and the
// reach stops being zero.
TEST_F(ResidentIndexScenario, PromotedDynamicEbo) {
const unsigned int vbo = MakeStaticVbo();
unsigned int ebo = 0;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kLeft)), kLeft, GL_DYNAMIC_DRAW);
const unsigned int vao = MakeVao(vbo, ebo);
for (int frame = 0; frame < 10; ++frame) {
Begin();
Draw(vao);
Halves(Read(), "red", "black", "promotion warmup frame " + std::to_string(frame));
Gl().EndFrame();
}
for (int frame = 0; frame < 6; ++frame) {
Begin();
if (frame == 0) {
glBindVertexArray(vao);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, GLsizeiptr(sizeof(kRight)), kRight);
glBindVertexArray(0);
}
Draw(vao);
Halves(Read(), "black", "green", "post-promotion frame " + std::to_string(frame));
Gl().EndFrame();
}
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &ebo);
glDeleteBuffers(1, &vbo);
}
} // namespace
} // namespace MGITest