Files
MobileGL/MobileGL/MG_IntegrationTest/Scenarios/TriangleScenario.cpp
T

289 lines
15 KiB
C++

// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/TriangleScenario.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
//
// Scenario - THE SMALLEST THING THAT DRAWS: one VBO, one program, one VAO, a clear, a
// VBO-backed glDrawArrays and a glReadPixels.
//
// This is target B of P5's reduced path (BRIEF-P5 4). It is an ORDINARY GL scenario and runs
// in every lane; the DirectGLES.Split. entries run the same two cases with
// MOBILEGL_TRANSPORT=inproc, where the same body is the smallest workload that crosses a real
// ring. Four things about its shape are decisions rather than defaults, and all four come from
// the measured verb census (~/w7/notes/p5/verb-census.md):
//
// 1. THE DRAW IS VBO-BACKED AND HAS NO CLIENT-ARRAY INDICES. glDrawArrays against a buffer
// bound to GL_ARRAY_BUFFER, never glDrawElements with a client pointer. That keeps
// kDrawHasUserIndices' MGHostSpan out of the first IPC frame entirely - the split filling of
// a host span is P8 - and it is why table 0 can pin kCapNeedsHostIndexBytes and
// kCapNeedsHostUboBytes at 0 for the whole of P5.
//
// 2. THERE IS NO glFlush, AND ITS ABSENCE IS THE POINT. `Flush` is not a GLFunctionsTable slot
// at all, and MG_Impl/GLImpl/Exporting/Definitions.cpp:111-112 makes glFlush() and glFinish()
// LITERALLY EMPTY BODIES - one MGLOG_D and a return. A scenario that called glFlush to order
// its readback would be ordering nothing and would still pass, which makes the ordering it
// believes in unfalsifiable. glReadPixels IS the ordering point: it is a blocking readback on
// both backends today and a SEG_REPLY round trip under split, so the pixels it returns are
// the pixels the draw produced or the case fails.
//
// 3. THE FIRST CASE ENDS WITH EndFrame(), DELIBERATELY. `Present` has ZERO call sites in
// MG_Impl - it is reached only through EGLImpl.cpp:178 -> BackendObject.cpp:396 - so a
// scenario that never swaps never touches it, and B would then exercise a STRICT SUBSET of
// what ClearThenReadPixelsScenario already covers. BRIEF-P5 4.B lists Present(67) among the
// catalogue rows this target needs, so the frame boundary is here on purpose: B is A's slot
// set plus DrawArrays, not minus Present.
//
// 4. THE SECOND CASE REDRAWS ACROSS A FRAME BOUNDARY WITHOUT REBUILDING ANYTHING. The VBO, the
// program and the VAO outlive the swap and only the clear colour changes. Under split that
// is the difference between "the client re-declares every object every frame" (which would
// pass a single-frame test and be the whole cost of the design) and a steady state; under
// monolith it is the backend's per-frame retire/aging path, which is exactly where P3a's
// respecify bug lived.
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#include "../Harness/SplitLane.h"
#include "../Harness/WireLedgerChecks.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 {
// #version 330 core, because that is what the retrace lane's
// MESA_GLSL_VERSION_OVERRIDE pins and what every other scenario in this module that does
// not need a later feature uses.
constexpr const char* kVertexSource = R"(#version 330 core
layout(location = 0) in vec2 aPos;
layout(location = 1) 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;
};
// A single triangle with its base at y = -0.8 and its apex at y = +0.8, so the
// interior box the cases assert on (the middle 10% of the width, a fifth of the way up)
// is far from every edge and the corner box they assert the CLEAR on is far outside it.
// Flat green: one colour over the whole primitive means an offender pixel is a real
// disagreement rather than an interpolation rounding difference between two drivers.
constexpr Vertex kTriangle[3] = {
{-0.8f, -0.8f, 0.0f, 1.0f, 0.0f},
{0.8f, -0.8f, 0.0f, 1.0f, 0.0f},
{0.0f, 0.8f, 0.0f, 1.0f, 0.0f},
};
class TriangleScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
// Ready() is false both when there is no GPU and when the base SetUp skipped a
// Split lane that has no client to assert against (ScenarioFixture.h).
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kVertexSource, kFragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(sizeof(kTriangle)), kTriangle, GL_STATIC_DRAW);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), nullptr);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<void*>(2 * sizeof(float)));
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
ASSERT_EQ(FirstGLError(), 0u) << "building the one VBO and one VAO this scenario has";
}
void TearDown() override {
if (!Ready() || IsSkipped()) return;
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_program != 0) glDeleteProgram(m_program);
m_vbo = m_vao = m_program = 0;
}
// Clear, draw, read back. No glFlush between the draw and the readback: see the
// file header, point 2.
Image ClearThenDrawThenRead(float clearR, float clearG, float clearB) {
HeadlessGL& gl = Gl();
BindDefaultFramebuffer();
glViewport(0, 0, gl.Width(), gl.Height());
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(clearR, clearG, clearB, 1.0f);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
return ReadPixels(gl.Width(), gl.Height());
}
// The box inside the triangle: the middle tenth of the width, a fifth of the way up
// from the base, which is interior for the vertex set above at any surface size the
// harness uses.
void ExpectTriangleInterior(const Image& image, const char* color, const std::string& when) {
const int w = image.Width();
const int h = image.Height();
EXPECT_TRUE(RegionIsMostly(image, (w * 45) / 100, (w * 55) / 100, (h * 20) / 100,
(h * 30) / 100, color, 0.0, when));
}
// The bottom-left corner, which is below the triangle's base and left of its left
// edge, so it carries the clear and nothing else.
void ExpectClearedCorner(const Image& image, const char* color, const std::string& when) {
const int w = image.Width();
const int h = image.Height();
EXPECT_TRUE(RegionIsMostly(image, 0, (w * 5) / 100, 0, (h * 5) / 100, color, 0.0, when));
}
// EXIT GATE E3(e)'s DRIVE LOOP. Ordinary GL through this scenario's own objects -
// a clear and a VBO-backed draw per iteration, no readback (a readback is a
// SEG_REPLY round trip per iteration and would make this cost seconds rather than
// milliseconds) - repeated until the producer has written more bytes into SEG_CMD
// than the lane's ring holds. Returns the bytes this loop drove.
//
// NOTHING HERE TOUCHES THE RING DIRECTLY. The loop's only input is the producer's
// own head cursor, read through Harness/SplitRuntimePeek, and its only output is
// GL calls the scenario already makes. R-16: an assertion may not construct the
// state it observes, and "the workload makes the ring wrap" is a different claim
// from "a test can make the ring wrap".
unsigned long long DriveUntilSmallRingOverruns() {
const unsigned long long before = WireLedger::CmdBytesWritten();
unsigned long long driven = 0;
for (unsigned int i = 0; i < WireLedger::kSmallRingLaneMaxIterations; ++i) {
ClearTo(0.0f, 0.0f, (i & 1u) ? 1.0f : 0.0f, 1.0f);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
// Cheap: this is a member read on the encoder, not a wire round trip.
driven = WireLedger::CmdBytesWritten() - before;
if (driven > WireLedger::kSmallRingLaneCmdByteTarget) break;
}
return driven;
}
unsigned int m_program = 0;
unsigned int m_vao = 0;
unsigned int m_vbo = 0;
};
} // namespace
// The reduced path's target B, in one case: GetCaps (reached by the first glCompileShader of
// the context, not by any verb - verb-census trap 2), Clear, DrawArrays, ReadPixels, Present.
TEST_F(TriangleScenario, AVboBackedTriangleReachesReadPixels) {
if (!Ready() || IsSkipped()) return;
const Image image = ClearThenDrawThenRead(0.0f, 0.0f, 1.0f);
EXPECT_EQ(FirstGLError(), 0u);
ExpectTriangleInterior(image, "green", "the interior of a VBO-backed glDrawArrays triangle");
ExpectClearedCorner(image, "blue", "the corner outside the triangle, which carries the clear");
// The frame boundary, deliberately (file header, point 3): this is the only thing in the
// scenario that reaches the Present slot.
Gl().EndFrame();
}
// Steady state: the same VBO, program and VAO across a swap, with only the clear colour
// changing. Nothing is re-created, so a client that re-declared its objects every frame and
// a backend that lost them at the frame boundary both show up here and in no single-frame
// case.
TEST_F(TriangleScenario, TheSameVboAndVaoRedrawAcrossAFrameBoundary) {
if (!Ready() || IsSkipped()) return;
const Image first = ClearThenDrawThenRead(0.0f, 0.0f, 1.0f);
ExpectTriangleInterior(first, "green", "frame 0's triangle");
ExpectClearedCorner(first, "blue", "frame 0's clear");
Gl().EndFrame();
// Second frame: black clear, nothing else touched.
const Image second = ClearThenDrawThenRead(0.0f, 0.0f, 0.0f);
EXPECT_EQ(FirstGLError(), 0u);
ExpectTriangleInterior(second, "green",
"frame 1's triangle, drawn from the SAME VBO and VAO with no "
"re-specification of either");
ExpectClearedCorner(second, "black", "frame 1's clear, which is the only thing that changed");
Gl().EndFrame();
// ---- the split lanes' two readings of the wire producer's ledger --------------------
//
// They are HERE, at the end of the steady-state case, and not in a case of their own,
// for a reason that is about the gate and not about tidiness: `integration-split` is a
// NAMED census (19 ran / 2 skipped by design) and a new entry moves it, so the phase
// would have to re-baseline a number the joint gate just pinned. The measurement wants
// this workload anyway - BRIEF 8 item 3 names this case - and a reading taken after the
// case's own pixel assertions is a reading over a run that is known to have been
// correct.
//
// Both are skipped, loudly and by the same predicate every other split-only assertion
// uses, in the monolith lanes: there is no encoder there, and every field of the
// ledger reads 0.
const std::string skip = SplitLane::SkipReasonForSplitOnlyAssertions();
if (!skip.empty()) {
RecordProperty("wire_ledger_skip_reason", skip);
return;
}
// R-10's proof obligation over target B. Published in every split lane, small ring
// included - the cap moves with MOBILEGL_IPC_RING_MB, so the SmallRing lane is also the
// arm where a record closest to its cap would show up first.
WireLedger::ExpectMaxRecordBytesUnderCap(
"TriangleScenario.TheSameVboAndVaoRedrawAcrossAFrameBoundary");
// Exit gate E3(e). Only the small-ring lane drives the overrun: at the default 8 MiB
// the same loop would take eight times as long to say the same thing, and the point of
// the lane is that IT is the arm with a ring the workload can fill.
if (SplitLane::IsSmallRingLane()) {
const unsigned long long driven = DriveUntilSmallRingOverruns();
// Ordinary uploads, each fitting by itself, jointly exceed the lane's 1 MiB
// staging segment. Delay only scheduling between applied and retired: the
// production allocator, not this test, must observe capacity and wait.
GLuint pressureBuffer = 0;
glGenBuffers(1, &pressureBuffer);
glBindBuffer(GL_COPY_WRITE_BUFFER, pressureBuffer);
std::vector<unsigned char> upload(768 * 1024, 0x5a);
glBufferData(GL_COPY_WRITE_BUFFER, upload.size(), nullptr, GL_DYNAMIC_DRAW);
DelaySplitRetirementForTesting(true);
glBufferSubData(GL_COPY_WRITE_BUFFER, 0, upload.size(), upload.data());
upload[0] = 0xa5;
glBufferSubData(GL_COPY_WRITE_BUFFER, 0, upload.size(), upload.data());
DelaySplitRetirementForTesting(false);
glBindBuffer(GL_COPY_WRITE_BUFFER, 0);
glDeleteBuffers(1, &pressureBuffer);
Gl().EndFrame();
WireLedger::ExpectSmallRingWrappedAtLeastOnce(
"TriangleScenario.TheSameVboAndVaoRedrawAcrossAFrameBoundary", driven);
}
}
} // namespace MGITest