diff --git a/CMakeLists.txt b/CMakeLists.txt index 8b56fa98..92e64c91 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,11 @@ project("MobileGL") option(MOBILEGL_BUILD_TEST "Build MobileGL tests" ON ) option(MOBILEGL_BUILD_BENCHMARK "Build MobileGL benchmarks" ON ) +# Headless end-to-end GPU scenarios (MobileGL/MG_IntegrationTest). They need a +# real GPU/ICD to do anything, so they are off by default for CI; every scenario +# skips cleanly where there is none. Registered under the `integration-gpu` +# ctest label so a run can select or exclude them. +option(MOBILEGL_BUILD_INTEGRATION_TEST "Build MobileGL headless GPU integration tests" OFF) option(MOBILEGL_FORCE_RELEASE_OPT "Enable Release optimization flags in Debug build" ON ) option(MOBILEGL_ENABLE_TRACY "Enable tracy for profiling" OFF) option(MOBILEGL_BUILD_TRACE_REPLAY "Build desktop apitrace replay runner" OFF) @@ -538,6 +543,12 @@ if (NOT ANDROID) add_subdirectory(MobileGL/MG_Test) endif() + # After MG_Test so googletest is already available when the unit tests are + # built; the module fetches its own copy when they are not. + if (MOBILEGL_BUILD_INTEGRATION_TEST) + add_subdirectory(MobileGL/MG_IntegrationTest) + endif() + if (MOBILEGL_BUILD_BENCHMARK) add_subdirectory(MobileGL/MG_Benchmark) endif() diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt new file mode 100644 index 00000000..b4b2459a --- /dev/null +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -0,0 +1,243 @@ +cmake_minimum_required(VERSION 3.24) + +# MobileGL headless GPU integration tests. +# +# These are not unit tests: each scenario brings up a real EGL context on a +# pbuffer, renders real frames through a real backend and asserts on +# glReadPixels output. They need a GPU, so the module is OFF by default +# (MOBILEGL_BUILD_INTEGRATION_TEST) and every scenario skips cleanly - never +# fails, never hangs - on a machine without one. "Cleanly" is not a hope: the +# harness runs the whole bring-up in a forked child first, because MobileGL +# ABORTS rather than returning an error on an unusable platform (HeadlessGL.cpp). +# +# A clean skip is also indistinguishable from a pass, so set +# MOBILEGL_ITEST_REQUIRE_GPU wherever the machine is supposed to have a GPU. +# +# Backend selection is latched at initialization from MOBILEGL_BACKEND_TYPE, so +# one process is one backend: the same binary is registered twice, once per +# backend, under the `integration-gpu` label. + +message(STATUS "Generating build files for MobileGL Integration Test...") + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(MGL_ITEST_ROOT ${CMAKE_CURRENT_LIST_DIR}/../..) + +# Only meaningful where MobileGL_s exists (i.e. not Android). +if (NOT TARGET MobileGL_s) + message(STATUS "MobileGL_s is not available; skipping the integration test module") + return() +endif() + +# MG_Test already pulls googletest in when MOBILEGL_BUILD_TEST is ON. Stand on +# our own feet when it is not, so this module can be built by itself. +if (NOT TARGET GTest::gtest) + include(FetchContent) + FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.17.0 + ) + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(googletest) +endif() + +add_executable(MobileGLIntegrationTest + Main.cpp + Harness/HeadlessGL.cpp + Scenarios/OrientationScenario.cpp + Scenarios/CrossFrameBufferScenario.cpp + Scenarios/ResidentIndexScenario.cpp +) + +target_include_directories(MobileGLIntegrationTest PRIVATE + ${MGL_ITEST_ROOT}/include + ${MGL_ITEST_ROOT}/MobileGL +) + +# gtest, not gtest_main: Main.cpp installs the harness banner itself. +target_link_libraries(MobileGLIntegrationTest PRIVATE + GTest::gtest + MobileGL_s +) + +if (MSVC) + # Same reason as MG_Test/Backend/DirectVulkan: the GLES headers declare gl* + # as dllimport on Windows, so the in-library GL entry-point definitions only + # resolve if the whole static library is part of the link. + target_link_options(MobileGLIntegrationTest PRIVATE /WHOLEARCHIVE:MobileGL_s) +endif() +target_compile_definitions(MobileGLIntegrationTest PRIVATE -DNOMINMAX) + +# --- ctest wiring -------------------------------------------------------- +# A bare libEGL on a glvnd box resolves to whatever vendor comes first, which is +# usually Mesa/llvmpipe - a software rasteriser silently replacing the GPU under +# a GPU test. Pin the vendor/ICD json the same way MG_Benchmark's +# run_driver_bench.sh does. +# +# Leaving these empty is not a neutral default, it is the failure mode: an +# unpinned libEGL lands on llvmpipe and the suite goes green having tested a +# software rasteriser. So they are DETECTED here rather than defaulted to empty, +# and an empty result is a loud warning. +# +# mgl_itest_find_driver_json( [...]) +# Picks the first json a real hardware vendor owns, in preference order, and +# never picks a software rasteriser (llvmpipe / lavapipe / swrast) - landing on +# one of those silently is the exact accident this pinning exists to prevent. +function(mgl_itest_find_driver_json outVar) + set(candidates "") + foreach(pattern IN LISTS ARGN) + file(GLOB matches "${pattern}") + list(APPEND candidates ${matches}) + endforeach() + list(SORT candidates) + # Vendors ship an i686 json beside the x86_64 one and it sorts first. Pinning + # the wrong word size is worse than not pinning at all - the loader finds no + # driver and the whole suite skips - so drop the mismatched ones outright. + if (CMAKE_SIZEOF_VOID_P EQUAL 8) + list(FILTER candidates EXCLUDE REGEX "i686|i386") + else() + list(FILTER candidates EXCLUDE REGEX "x86_64|aarch64") + endif() + set(software "") + foreach(vendor IN ITEMS nvidia amdgpu amd radeon intel_hasvk intel broadcom freedreno panfrost) + foreach(candidate IN LISTS candidates) + get_filename_component(leaf "${candidate}" NAME) + string(TOLOWER "${leaf}" leaf) + if (leaf MATCHES "${vendor}") + set(${outVar} "${candidate}" PARENT_SCOPE) + return() + endif() + endforeach() + endforeach() + # Nothing recognised as hardware. Report the first non-software entry if there + # is one; otherwise report nothing, so the warning below fires. + foreach(candidate IN LISTS candidates) + get_filename_component(leaf "${candidate}" NAME) + string(TOLOWER "${leaf}" leaf) + if (NOT leaf MATCHES "lvp|llvmpipe|lavapipe|swrast|softpipe") + set(${outVar} "${candidate}" PARENT_SCOPE) + return() + endif() + set(software "${candidate}") + endforeach() + set(${outVar} "" PARENT_SCOPE) +endfunction() + +set(MGL_ITEST_DETECTED_EGL_VENDOR "") +set(MGL_ITEST_DETECTED_VK_ICD "") +if (UNIX AND NOT APPLE AND NOT ANDROID) + mgl_itest_find_driver_json(MGL_ITEST_DETECTED_EGL_VENDOR + "/usr/share/glvnd/egl_vendor.d/*.json" + "/etc/glvnd/egl_vendor.d/*.json") + mgl_itest_find_driver_json(MGL_ITEST_DETECTED_VK_ICD + "/usr/share/vulkan/icd.d/*.json" + "/etc/vulkan/icd.d/*.json") +endif() + +set(MOBILEGL_ITEST_EGL_VENDOR "${MGL_ITEST_DETECTED_EGL_VENDOR}" CACHE FILEPATH + "glvnd EGL vendor json to pin for the integration tests (empty: leave the loader alone)") +set(MOBILEGL_ITEST_VK_ICD "${MGL_ITEST_DETECTED_VK_ICD}" CACHE FILEPATH + "Vulkan ICD json to pin for the DirectVulkan integration tests (empty: leave the loader alone)") + +if (MOBILEGL_ITEST_EGL_VENDOR) + message(STATUS "Integration tests: pinning EGL vendor ${MOBILEGL_ITEST_EGL_VENDOR}") +else() + message(WARNING + "Integration tests: no EGL vendor json found or configured (MOBILEGL_ITEST_EGL_VENDOR is empty). " + "An unpinned libEGL on a glvnd system resolves to whichever vendor comes first, which is usually " + "Mesa/llvmpipe - the scenarios would then go green against a software rasteriser instead of the GPU. " + "Set -DMOBILEGL_ITEST_EGL_VENDOR=/usr/share/glvnd/egl_vendor.d/.json.") +endif() +if (MOBILEGL_ITEST_VK_ICD) + message(STATUS "Integration tests: pinning Vulkan ICD ${MOBILEGL_ITEST_VK_ICD}") +else() + message(WARNING + "Integration tests: no Vulkan ICD json found or configured (MOBILEGL_ITEST_VK_ICD is empty). " + "DirectVulkan would then load whichever ICD the loader enumerates first, quite possibly lavapipe. " + "Set -DMOBILEGL_ITEST_VK_ICD=/usr/share/vulkan/icd.d/.json.") +endif() + +# Turns "no usable GPU" from a clean skip into a failure - see ScenarioFixture.h. +# Without it the integration-gpu label is unfalsifiable: a run that skipped every +# scenario and a run that passed every scenario are the same green in ctest. +option(MOBILEGL_ITEST_REQUIRE_GPU + "Fail (rather than skip) the integration scenarios when the headless harness is unusable" OFF) + +# DirectGLES asks the system EGL for a pbuffer config, and on Mesa the default +# platform is not X11 unless it is said out loud (run_driver_bench.sh sets the +# same variable). Wrong platform here is not a soft failure: eglCreatePbuffer +# fails and every scenario skips. +if (UNIX AND NOT APPLE AND NOT ANDROID) + set(MOBILEGL_ITEST_EGL_PLATFORM "x11" CACHE STRING + "EGL_PLATFORM for the integration tests (empty: leave the loader alone)") +else() + set(MOBILEGL_ITEST_EGL_PLATFORM "" CACHE STRING + "EGL_PLATFORM for the integration tests (empty: leave the loader alone)") +endif() + +set(MGL_ITEST_COMMON_ENV "") +if (MOBILEGL_ITEST_EGL_VENDOR) + list(APPEND MGL_ITEST_COMMON_ENV "__EGL_VENDOR_LIBRARY_FILENAMES=${MOBILEGL_ITEST_EGL_VENDOR}") +endif() +if (MOBILEGL_ITEST_EGL_PLATFORM) + list(APPEND MGL_ITEST_COMMON_ENV "EGL_PLATFORM=${MOBILEGL_ITEST_EGL_PLATFORM}") +endif() +if (MOBILEGL_ITEST_REQUIRE_GPU) + list(APPEND MGL_ITEST_COMMON_ENV "MOBILEGL_ITEST_REQUIRE_GPU=1") +endif() + +set(MGL_ITEST_VULKAN_ENV ${MGL_ITEST_COMMON_ENV}) +if (MOBILEGL_ITEST_VK_ICD) + list(APPEND MGL_ITEST_VULKAN_ENV "VK_ICD_FILENAMES=${MOBILEGL_ITEST_VK_ICD}") +endif() + +# The ENVIRONMENT test property is itself a `;`-list, and gtest_discover_tests +# forwards PROPERTIES as a flat list - so a plain `;`-joined value arrives as +# four separate arguments and everything after the first is silently read as +# another property name. Escaping the separators keeps the whole thing one list +# element until set_tests_properties expands it back. Without this only +# MOBILEGL_BACKEND_TYPE reaches the test and the vendor/ICD pinning is lost. +function(mgl_itest_join_environment outVar) + set(joined "") + foreach(entry IN LISTS ARGN) + if (joined) + string(APPEND joined "\\;${entry}") + else() + set(joined "${entry}") + endif() + endforeach() + set(${outVar} "${joined}" PARENT_SCOPE) +endfunction() + +mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" ${MGL_ITEST_VULKAN_ENV}) + +# TIMEOUT on every entry: a GPU test that wedges must fail the run, not hang it. +set(MGL_ITEST_TIMEOUT 120) + +include(GoogleTest) + +# Discovery runs `--gtest_list_tests`, which does not construct the harness and +# so needs no GPU. One registration per backend; TEST_PREFIX keeps the two sets +# of ctest names apart. +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES." + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_ENVIRONMENT}" +) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan." + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_ENVIRONMENT}" +) diff --git a/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.cpp b/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.cpp new file mode 100644 index 00000000..07af1950 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.cpp @@ -0,0 +1,587 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/HeadlessGL.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 + +#include "HeadlessGL.h" + +#include +#include +#include +#include +#include +#include + +// MobileGL's own headers, in the order MobileGL/Includes.h uses them: GL/gl.h +// first, then glcorearb.h for the 3.x+ entry points. This binary links +// MobileGL_s, so every gl*/egl* below binds to MobileGL's implementation, not +// to a system loader. +#ifdef GLAPI +#undef GLAPI +#endif +#include +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +// The pre-flight below runs the whole EGL bring-up in a forked child, which is +// the only construction that is actually predictive here: MobileGL ABORTS +// (MOBILEGL_ASSERT -> SIGTRAP) rather than returning an error on an unusable +// platform, so nothing the parent can call in-process is allowed to be wrong. +#if !defined(_WIN32) && !defined(__APPLE__) && __has_include() +#define MGITEST_HAVE_FORK_PREFLIGHT 1 +#include +#include +#include +#include +#include +#include +#else +#define MGITEST_HAVE_FORK_PREFLIGHT 0 +#endif + +namespace MGITest { + + namespace { + // Small enough that a readback is cheap, big enough that "top third" and + // "bottom third" are unambiguous. Non-square on purpose: a transposing + // bug cannot hide behind a square. + constexpr int kSurfaceWidth = 128; + constexpr int kSurfaceHeight = 96; + + std::string EnvOr(const char* name, const char* fallback) { + const char* value = std::getenv(name); + return (value != nullptr && value[0] != '\0') ? std::string(value) : std::string(fallback); + } + + // A skip reason is only useful if it says which call failed AND why, so + // every bring-up step reports the EGL error it left behind. + std::string WithEglError(const char* what) { + std::ostringstream out; + out << what << " (eglGetError=0x" << std::hex << eglGetError() << ")"; + return out.str(); + } + + // The EGL objects one bring-up produces. + struct EglBringUp { + void* display = nullptr; + void* surface = nullptr; + void* context = nullptr; + std::string renderer; + }; + + // THE bring-up, in one function so the pre-flight child and the parent run + // literally the same sequence - a pre-flight that tests something narrower + // than what the parent will do is exactly the kind of "predictive" check + // that is not. + // + // Returns 0 on success, or the 1-based index of the step that failed, and + // fills outReason either way. + int RunEglBringUp(EglBringUp& out, std::string& outReason) { + EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (display == EGL_NO_DISPLAY) { + outReason = WithEglError("eglGetDisplay(EGL_DEFAULT_DISPLAY) returned EGL_NO_DISPLAY"); + return 1; + } + EGLint major = 0, minor = 0; + if (eglInitialize(display, &major, &minor) != EGL_TRUE) { + outReason = WithEglError("eglInitialize failed: no usable display/driver on this machine"); + return 2; + } + if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) { + outReason = WithEglError("eglBindAPI(EGL_OPENGL_API) failed"); + return 3; + } + + const EGLint configAttribs[] = {EGL_SURFACE_TYPE, + EGL_PBUFFER_BIT, + EGL_RED_SIZE, + 8, + EGL_GREEN_SIZE, + 8, + EGL_BLUE_SIZE, + 8, + EGL_ALPHA_SIZE, + 8, + EGL_DEPTH_SIZE, + 24, + EGL_RENDERABLE_TYPE, + EGL_OPENGL_BIT, + EGL_NONE}; + EGLConfig config = nullptr; + EGLint configCount = 0; + if (eglChooseConfig(display, configAttribs, &config, 1, &configCount) != EGL_TRUE || configCount < 1) { + outReason = WithEglError("eglChooseConfig found no pbuffer-capable RGBA8/D24 config"); + return 4; + } + + const EGLint contextAttribs[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_CONTEXT_MINOR_VERSION, 3, EGL_NONE}; + EGLContext context = eglCreateContext(display, config, EGL_NO_CONTEXT, contextAttribs); + if (context == EGL_NO_CONTEXT) { + context = eglCreateContext(display, config, EGL_NO_CONTEXT, nullptr); + } + if (context == EGL_NO_CONTEXT) { + outReason = WithEglError("eglCreateContext failed: no desktop-GL context available"); + return 5; + } + + const EGLint pbufferAttribs[] = {EGL_WIDTH, kSurfaceWidth, EGL_HEIGHT, kSurfaceHeight, EGL_NONE}; + EGLSurface surface = eglCreatePbufferSurface(display, config, pbufferAttribs); + if (surface == EGL_NO_SURFACE) { + outReason = WithEglError("eglCreatePbufferSurface failed"); + return 6; + } + // The step that brings the whole backend up (DirectVulkan creates its + // instance, device and surface in here) and therefore the step that + // aborts instead of returning an error on an unusable platform. + if (eglMakeCurrent(display, surface, surface, context) != EGL_TRUE) { + outReason = WithEglError("eglMakeCurrent failed"); + return 7; + } + + const GLubyte* renderer = glGetString(GL_RENDERER); + if (renderer == nullptr) { + outReason = "glGetString(GL_RENDERER) returned null after eglMakeCurrent"; + return 8; + } + + out.display = display; + out.surface = surface; + out.context = context; + out.renderer = reinterpret_cast(renderer); + outReason.clear(); + return 0; + } + + // Platform pre-flight, and the reason this module can claim to skip + // cleanly rather than merely hope to. + // + // MobileGL does not return errors when the platform is unusable - it + // ABORTS. MOBILEGL_ASSERT raises SIGTRAP, and the DirectVulkan bring-up + // asserts its way through instance, physical-device and surface creation + // inside eglMakeCurrent. So there is no in-process question the harness + // can ask that is guaranteed to be survivable, and the old form (dlopen + // the Vulkan loader, count physical devices, look for + // VK_EXT_headless_surface) was a guess at the abort conditions rather + // than a test of them: it named three of the ways bring-up can die and + // was silent about every other one, including every DirectGLES one. + // + // What is actually predictive is to run the bring-up itself somewhere a + // SIGTRAP is a datum instead of a crash. fork() gives exactly that: the + // child performs the identical sequence and _exit(0)s on success, and + // ANY non-zero exit or ANY signal in the parent's waitpid() means "this + // platform is unusable" - whatever the reason, including reasons nobody + // has thought of. Only then does the parent do the real bring-up. + // + // Returns an empty string when the platform survived a full bring-up. + std::string PreflightBringUp() { +#if !MGITEST_HAVE_FORK_PREFLIGHT + // No fork(): let the in-process bring-up speak for itself, which is + // what this module did before. Windows/macOS are not CI targets for + // the headless scenarios. + return {}; +#else + int channel[2] = {-1, -1}; + if (pipe(channel) != 0) { + return {}; // cannot pre-flight; fall through to the in-process attempt + } + // The child inherits our stdio buffers; flush so nothing is printed twice. + std::fflush(nullptr); + const pid_t child = fork(); + if (child < 0) { + close(channel[0]); + close(channel[1]); + return {}; + } + if (child == 0) { + close(channel[0]); + // The child is EXPECTED to die on a signal on an unusable + // platform; that is the measurement. Do not let each such + // measurement drop a core file next to the test binary. + const rlimit noCore{0, 0}; + setrlimit(RLIMIT_CORE, &noCore); + std::fprintf(stderr, "[itest] pre-flight child: attempting a full EGL bring-up\n"); + EglBringUp local; + std::string reason; + const int step = RunEglBringUp(local, reason); + if (!reason.empty()) { + const std::size_t bytes = std::min(reason.size(), 480); + const ssize_t written = write(channel[1], reason.data(), bytes); + (void)written; + } + close(channel[1]); + // _exit, never exit(): every atexit handler and static destructor + // in this address space belongs to the parent's copy of the world, + // and the child is holding a live context it must not tear down. + _exit(step); + } + + close(channel[1]); + // Reap first, read after: the message is bounded well below the pipe + // buffer so the child can never block writing it, and polling the exit + // status is what lets a wedged child be killed instead of hanging the + // parent on a read that will never return. + constexpr int kPreflightTimeoutMs = 30000; + int status = 0; + int waitedMs = 0; + for (;;) { + const pid_t reaped = waitpid(child, &status, WNOHANG); + if (reaped == child) break; + if (reaped < 0) { + close(channel[0]); + return "waitpid on the EGL bring-up pre-flight child failed"; + } + if (waitedMs >= kPreflightTimeoutMs) { + kill(child, SIGKILL); + (void)waitpid(child, &status, 0); + close(channel[0]); + std::ostringstream out; + out << "the EGL bring-up wedged: a forked pre-flight child made no progress in " + << kPreflightTimeoutMs / 1000 << "s and was killed"; + return out.str(); + } + timespec nap{0, 10 * 1000 * 1000}; + nanosleep(&nap, nullptr); + waitedMs += 10; + } + + std::string childSays; + char buffer[512]; + for (;;) { + const ssize_t got = read(channel[0], buffer, sizeof(buffer)); + if (got <= 0) break; + childSays.append(buffer, static_cast(got)); + } + close(channel[0]); + + if (WIFSIGNALED(status)) { + const int signalNumber = WTERMSIG(status); + const char* signalName = strsignal(signalNumber); + std::ostringstream out; + out << "the EGL bring-up ABORTS on this platform: a forked pre-flight child died on signal " + << signalNumber << " (" << (signalName != nullptr ? signalName : "?") << ")"; + if (!childSays.empty()) out << " after: " << childSays; + out << ". MobileGL asserts rather than returning an error here, so the scenarios would " + "have taken the whole test binary down with them"; + return out.str(); + } + if (!WIFEXITED(status)) { + return "the EGL bring-up pre-flight child neither exited nor was signalled"; + } + const int exitStatus = WEXITSTATUS(status); + if (exitStatus != 0) { + std::ostringstream out; + out << (childSays.empty() ? "the EGL bring-up failed" : childSays) + << " (forked pre-flight child exit status " << exitStatus << ")"; + return out.str(); + } + return {}; +#endif + } + } // namespace + + bool RequireGpu() { + const char* value = std::getenv("MOBILEGL_ITEST_REQUIRE_GPU"); + return value != nullptr && value[0] != '\0' && std::strcmp(value, "0") != 0; + } + + std::ostream& operator<<(std::ostream& os, const Rgba8& c) { + os << "rgba(" << int(c.r) << "," << int(c.g) << "," << int(c.b) << "," << int(c.a) << ")"; + return os; + } + + Rgba8 Image::At(int x, int y) const { + if (x < 0 || y < 0 || x >= m_width || y >= m_height) { + return Rgba8{}; + } + const std::size_t index = (static_cast(y) * m_width + x) * 4; + return Rgba8{m_pixels[index], m_pixels[index + 1], m_pixels[index + 2], m_pixels[index + 3]}; + } + + const char* Image::ColorName(int x, int y) const { + const Rgba8 c = At(x, y); + const bool r = c.r > 160, g = c.g > 160, b = c.b > 160; + const bool nr = c.r < 96, ng = c.g < 96, nb = c.b < 96; + if (nr && ng && nb) return "black"; + if (r && g && b) return "white"; + if (r && ng && nb) return "red"; + if (nr && g && nb) return "green"; + if (nr && ng && b) return "blue"; + if (r && g && nb) return "yellow"; + return "other"; + } + + std::size_t Image::ByteDiffCount(const Image& other) const { + if (m_width != other.m_width || m_height != other.m_height) { + return std::max(m_pixels.size(), other.m_pixels.size()); + } + std::size_t differing = 0; + for (std::size_t i = 0; i < m_pixels.size(); ++i) { + if (m_pixels[i] != other.m_pixels[i]) ++differing; + } + return differing; + } + + std::string Image::QuadrantSignature() const { + if (m_width < 2 || m_height < 2) return ""; + // Quadrant CENTRES, so a one-pixel rounding difference at a quadrant edge + // never decides the answer. Order is fixed and load-bearing: bottom-left, + // bottom-right, top-left, top-right. + const int leftX = m_width / 4; + const int rightX = m_width * 3 / 4; + const int bottomY = m_height / 4; + const int topY = m_height * 3 / 4; + std::ostringstream out; + out << ColorName(leftX, bottomY) << "," << ColorName(rightX, bottomY) << "," << ColorName(leftX, topY) << "," + << ColorName(rightX, topY); + return out.str(); + } + + RegionScan ScanRegion(const Image& image, int x0, int x1, int y0, int y1, const char* expectedColor) { + RegionScan scan; + x0 = std::max(x0, 0); + y0 = std::max(y0, 0); + x1 = std::min(x1, image.Width() - 1); + y1 = std::min(y1, image.Height() - 1); + for (int y = y0; y <= y1; ++y) { + for (int x = x0; x <= x1; ++x) { + ++scan.total; + const char* name = image.ColorName(x, y); + if (std::strcmp(name, expectedColor) == 0) continue; + ++scan.offenders; + if (scan.firstX < 0) { + scan.firstX = x; + scan.firstY = y; + scan.firstColor = image.At(x, y); + scan.firstColorName = name; + } + } + } + return scan; + } + + ::testing::AssertionResult RegionIsMostly(const Image& image, int x0, int x1, int y0, int y1, + const char* expectedColor, double tolerance, + const std::string& when) { + const RegionScan scan = ScanRegion(image, x0, x1, y0, y1, expectedColor); + if (scan.total == 0) { + return ::testing::AssertionFailure() + << when << ": region x[" << x0 << "," << x1 << "] y[" << y0 << "," << y1 + << "] is empty against a " << image.Width() << "x" << image.Height() << " readback"; + } + const double offendingFraction = static_cast(scan.offenders) / scan.total; + if (offendingFraction <= tolerance) { + return ::testing::AssertionSuccess(); + } + return ::testing::AssertionFailure() + << when << ": region x[" << x0 << "," << x1 << "] y[" << y0 << "," << y1 << "] should be all " + << expectedColor << ", but " << scan.offenders << " of " << scan.total << " pixels (" + << static_cast(offendingFraction * 100.0 + 0.5) << "%) are not; first offender at (" << scan.firstX + << "," << scan.firstY << ") is " << scan.firstColorName << " " << scan.firstColor; + } + + HeadlessGL& HeadlessGL::Get() { + static HeadlessGL instance; + return instance; + } + + HeadlessGL::HeadlessGL() { + m_backendName = EnvOr("MOBILEGL_BACKEND_TYPE", ""); + m_usable = BringUp(); + } + + bool HeadlessGL::BringUp() { + // Ask a disposable copy of this process first. Only if it survived does + // the real one try - see PreflightBringUp for why nothing weaker is + // predictive against a stack that aborts instead of returning errors. + const std::string preflightProblem = PreflightBringUp(); + if (!preflightProblem.empty()) { + m_skipReason = preflightProblem; + return false; + } + + // Same shape as DriverBench's boot_egl(), minus the dlopen: the provider + // is this binary. A pbuffer needs no window system, but MobileGL's own + // loader still has to reach a real driver underneath - and the child + // above just proved it can. + EglBringUp brought; + std::string reason; + if (RunEglBringUp(brought, reason) != 0) { + // The pre-flight passed and the parent's identical attempt did not. + // That is a real result, not a machine without a GPU, so say so: it + // means something is different between the two attempts (a leaked + // exclusive device, an environment the child did not have). + m_skipReason = reason + " - although an identical bring-up in a forked pre-flight child succeeded"; + return false; + } + + m_display = brought.display; + m_surface = brought.surface; + m_context = brought.context; + m_width = kSurfaceWidth; + m_height = kSurfaceHeight; + m_renderer = std::move(brought.renderer); + return true; + } + + void HeadlessGL::EndFrame() { + if (!m_usable) return; + eglSwapBuffers(static_cast(m_display), static_cast(m_surface)); + ++m_frameIndex; + } + + void HeadlessGL::ShutDown() { + if (!m_usable) return; + EGLDisplay display = static_cast(m_display); + eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (m_context != nullptr) eglDestroyContext(display, static_cast(m_context)); + if (m_surface != nullptr) eglDestroySurface(display, static_cast(m_surface)); + eglTerminate(display); + m_context = nullptr; + m_surface = nullptr; + m_display = nullptr; + m_usable = false; + m_skipReason = "the headless context has already been torn down"; + } + + // ---- scenario vocabulary ------------------------------------------------ + + namespace { + unsigned int CompileStage(GLenum stage, const char* source, std::string* outError) { + const GLuint shader = glCreateShader(stage); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint compiled = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == GL_FALSE) { + char log[2048] = {}; + GLsizei length = 0; + glGetShaderInfoLog(shader, sizeof(log) - 1, &length, log); + if (outError != nullptr) { + *outError = std::string(stage == GL_VERTEX_SHADER ? "vertex" : "fragment") + + " shader failed to compile: " + log; + } + glDeleteShader(shader); + return 0; + } + return shader; + } + } // namespace + + unsigned int CompileProgram(const char* vertexSource, const char* fragmentSource, std::string* outError) { + const GLuint vs = CompileStage(GL_VERTEX_SHADER, vertexSource, outError); + if (vs == 0) return 0; + const GLuint fs = CompileStage(GL_FRAGMENT_SHADER, fragmentSource, outError); + if (fs == 0) { + glDeleteShader(vs); + return 0; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, vs); + glAttachShader(program, fs); + // Pinned rather than queried so the scenarios can set up a VAO without a + // round trip, and so a driver that reorders attributes cannot change what + // the test means. + glBindAttribLocation(program, 0, "aPos"); + glBindAttribLocation(program, 1, "aColor"); + glLinkProgram(program); + glDeleteShader(vs); + glDeleteShader(fs); + GLint linked = 0; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_FALSE) { + char log[2048] = {}; + GLsizei length = 0; + glGetProgramInfoLog(program, sizeof(log) - 1, &length, log); + if (outError != nullptr) *outError = std::string("program failed to link: ") + log; + glDeleteProgram(program); + return 0; + } + return program; + } + + ColorFbo MakeColorFbo(int width, int height) { + ColorFbo target; + target.width = width; + target.height = height; + glGenTextures(1, &target.texture); + glBindTexture(GL_TEXTURE_2D, target.texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glBindTexture(GL_TEXTURE_2D, 0); + + glGenFramebuffers(1, &target.fbo); + glBindFramebuffer(GL_FRAMEBUFFER, target.fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target.texture, 0); + const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (status != GL_FRAMEBUFFER_COMPLETE) { + DestroyColorFbo(target); + } + return target; + } + + void DestroyColorFbo(ColorFbo& target) { + if (target.fbo != 0) glDeleteFramebuffers(1, &target.fbo); + if (target.texture != 0) glDeleteTextures(1, &target.texture); + target.fbo = 0; + target.texture = 0; + } + + void BindDefaultFramebuffer() { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, HeadlessGL::Get().Width(), HeadlessGL::Get().Height()); + } + + void BindFbo(const ColorFbo& target) { + glBindFramebuffer(GL_FRAMEBUFFER, target.fbo); + glViewport(0, 0, target.width, target.height); + } + + void ClearTo(float r, float g, float b, float a) { + glClearColor(r, g, b, a); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + } + + Image ReadPixels(int width, int height) { + Image image(width, height); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.Data()); + return image; + } + + unsigned int FirstGLError() { + const GLenum first = glGetError(); + if (first == GL_NO_ERROR) return GL_NO_ERROR; + // Drain, bounded: a broken stack must not turn an error check into a hang. + for (int i = 0; i < 64 && glGetError() != GL_NO_ERROR; ++i) {} + return first; + } + + const char* GLErrorName(unsigned int error) { + switch (error) { + case GL_NO_ERROR: + return "GL_NO_ERROR"; + case GL_INVALID_ENUM: + return "GL_INVALID_ENUM"; + case GL_INVALID_VALUE: + return "GL_INVALID_VALUE"; + case GL_INVALID_OPERATION: + return "GL_INVALID_OPERATION"; + case GL_OUT_OF_MEMORY: + return "GL_OUT_OF_MEMORY"; + case GL_INVALID_FRAMEBUFFER_OPERATION: + return "GL_INVALID_FRAMEBUFFER_OPERATION"; + default: + return "GL_"; + } + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.h b/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.h new file mode 100644 index 00000000..8e1234dc --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/HeadlessGL.h @@ -0,0 +1,218 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/HeadlessGL.h +// 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 +// +// A headless GL context and the small vocabulary the scenarios are written in. +// +// The scenarios in this module are end-to-end: they drive MobileGL's own GL and +// EGL entry points (this binary links MobileGL_s, so gl*/egl* resolve straight +// into the implementation) and assert on glReadPixels output. Nothing here +// inspects backend state - both bugs this module pins were invisible to +// state-level assertions and visible only in pixels. +// +// Headless by construction, following MG_Benchmark/Driver/DriverBench.c: an EGL +// context on a PBUFFER surface. No window, no window manager, no human. Unlike +// DriverBench the scenarios do draw to the DEFAULT framebuffer (that is where +// the Y-flip lives) and do call eglSwapBuffers (that is the frame boundary the +// cross-frame scenarios need to be real). +// +// One process is one backend: MOBILEGL_BACKEND_TYPE is latched at +// initialization, so the CMake wiring runs this binary once per backend rather +// than trying to switch in-process. + +#pragma once + +#include + +#include +#include +#include + +namespace MGITest { + + // True when MOBILEGL_ITEST_REQUIRE_GPU is set in the environment: the runner + // is asserting that this machine HAS a usable GPU, so "no GPU" stops being a + // clean skip and becomes a failure. Without it the integration-gpu label is + // unfalsifiable - a CI job that ran nothing reports exactly the same green as + // a job that ran everything. + bool RequireGpu(); + + struct Rgba8 { + std::uint8_t r = 0, g = 0, b = 0, a = 0; + + bool operator==(const Rgba8& other) const { + return r == other.r && g == other.g && b == other.b && a == other.a; + } + bool operator!=(const Rgba8& other) const { return !(*this == other); } + }; + + // Prints as "rgba(255,0,0,255)" so a gtest failure names the colour it saw. + std::ostream& operator<<(std::ostream& os, const Rgba8& c); + + // An RGBA8 readback. Row 0 is the BOTTOM row: that is GL's convention for + // glReadPixels and it is what "correctly oriented" means everywhere below. + class Image { + public: + Image() = default; + Image(int width, int height) + : m_width(width), m_height(height), m_pixels(static_cast(width) * height * 4, 0) {} + + int Width() const { return m_width; } + int Height() const { return m_height; } + bool Empty() const { return m_pixels.empty(); } + std::uint8_t* Data() { return m_pixels.data(); } + const std::uint8_t* Data() const { return m_pixels.data(); } + + Rgba8 At(int x, int y) const; + // Nearest of {black, red, green, blue, white, other} - the scenarios only + // ever draw those, so this turns a pixel into something readable. + const char* ColorName(int x, int y) const; + + bool operator==(const Image& other) const { + return m_width == other.m_width && m_height == other.m_height && m_pixels == other.m_pixels; + } + + // Count of differing bytes, for a failure message that says how wrong. + std::size_t ByteDiffCount(const Image& other) const; + + // The four quadrant centres, in the fixed order + // bottom-left, bottom-right, top-left, top-right. + // + // This replaces the old VerticalSignature(bandCount), which read three + // full-width horizontal stripes down the centre line and was therefore + // blind to an X flip, to a transpose, and to a 180 rotation composed with + // a Y flip - all of those left the stripe order alone. Four quadrant + // colours are asymmetric in BOTH axes, so each of the eight square + // symmetries produces a different string (see OrientationScenario, which + // spells all eight out). + std::string QuadrantSignature() const; + + private: + int m_width = 0; + int m_height = 0; + std::vector m_pixels; + }; + + // The process-wide headless context. Brought up lazily on the first Get() so + // that `--gtest_list_tests` (which CMake runs at build time to discover the + // cases) never touches a GPU. + class HeadlessGL { + public: + static HeadlessGL& Get(); + + // False on a machine with no usable GPU/display/ICD. SkipReason() then + // says which step failed; every fixture turns that into GTEST_SKIP(). + bool Usable() const { return m_usable; } + const std::string& SkipReason() const { return m_skipReason; } + + // Backend actually in use, as reported by MOBILEGL_BACKEND_TYPE. + const std::string& BackendName() const { return m_backendName; } + const std::string& RendererString() const { return m_renderer; } + + int Width() const { return m_width; } + int Height() const { return m_height; } + + // THE frame boundary. eglSwapBuffers is what retires a frame in the + // renderer, and the cross-frame scenarios are meaningless without it. + void EndFrame(); + + // Frames completed so far, for failure messages. + int FrameIndex() const { return m_frameIndex; } + + // Releases the context and surface and terminates the display. Called + // once, after the last scenario: MobileGL frees its backend objects + // through eglTerminate, and letting a process simply exit on top of a + // live context leaves those objects to be torn down from a static + // destructor with no driver left underneath. + void ShutDown(); + + private: + HeadlessGL(); + HeadlessGL(const HeadlessGL&) = delete; + HeadlessGL& operator=(const HeadlessGL&) = delete; + + bool BringUp(); + + bool m_usable = false; + std::string m_skipReason; + std::string m_backendName; + std::string m_renderer; + int m_width = 0; + int m_height = 0; + int m_frameIndex = 0; + void* m_display = nullptr; + void* m_surface = nullptr; + void* m_context = nullptr; + }; + + // ---- the scenario vocabulary ------------------------------------------- + // Deliberately tiny. A scenario should read like a story; anything that + // needs a comment about GL mechanics belongs here instead. + + // Compiles and links vs+fs, pinning attribute 0 to "aPos" and 1 to "aColor". + // Returns 0 and fills outError on failure. + unsigned int CompileProgram(const char* vertexSource, const char* fragmentSource, std::string* outError); + + struct ColorFbo { + unsigned int fbo = 0; + unsigned int texture = 0; + int width = 0; + int height = 0; + }; + + // A complete RGBA8 render target. Returns fbo==0 on failure. + ColorFbo MakeColorFbo(int width, int height); + void DestroyColorFbo(ColorFbo& target); + + // Binds a target and sets the viewport to match. Passing fbo 0 means the + // default (presentable) framebuffer. + void BindDefaultFramebuffer(); + void BindFbo(const ColorFbo& target); + + void ClearTo(float r, float g, float b, float a); + + // Reads back the whole currently bound READ framebuffer. width/height must + // be the target's full size - DirectVulkan's default-framebuffer readback + // only re-orients a full-extent read. + Image ReadPixels(int width, int height); + + // Drains any GL error queue and returns the first error, or 0. + unsigned int FirstGLError(); + const char* GLErrorName(unsigned int error); + + // ---- whole-region readback predicates ---------------------------------- + // The scenarios used to assert on two or three individual pixels, which is + // provably too weak: a draw in which 3 of a quad's 4 vertices carry stale + // data still paints the sampled centre the expected colour (that exact case + // is a standing negative-control test - see CrossFrameBufferScenario). The + // readback is already fully in memory, so counting every pixel in a region + // costs nothing and turns "the middle looks right" into "all of it is right". + + // Everything a caller needs to say what was wrong and where. + struct RegionScan { + int total = 0; // pixels examined + int offenders = 0; // pixels whose ColorName() != expected + int firstX = -1; // first offender in bottom-to-top, left-to-right order + int firstY = -1; + Rgba8 firstColor{}; + std::string firstColorName; + }; + + // Inclusive pixel bounds, clamped to the image. Row 0 is the bottom row. + RegionScan ScanRegion(const Image& image, int x0, int x1, int y0, int y1, const char* expectedColor); + + // gtest predicate wrapper: EXPECT_TRUE(RegionIsMostly(...)) reports the + // offender count, the offender fraction and the FIRST offending pixel's + // coordinates and colour. `tolerance` is the fraction of the region allowed + // to disagree; pass 0.0 to demand every pixel (which is what the scenarios + // do - they inset their regions away from primitive edges so exactness is + // achievable). + ::testing::AssertionResult RegionIsMostly(const Image& image, int x0, int x1, int y0, int y1, + const char* expectedColor, double tolerance, + const std::string& when); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h b/MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h new file mode 100644 index 00000000..21a3942c --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h @@ -0,0 +1,84 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h +// 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 +// +// The base fixture every scenario derives from. Its only jobs are to bring the +// headless context up once per process and to decide what "this machine has no +// usable GPU" means. +// +// By default it means a clean GTEST_SKIP() - never a failure, never a hang - +// because a developer box or a container without a GPU should not fail a run it +// was never able to perform. But a skip is indistinguishable from a pass in +// every CI summary, so the `integration-gpu` label on its own is unfalsifiable: +// a runner whose driver pinning silently broke reports the same green as one +// that rendered every frame. MOBILEGL_ITEST_REQUIRE_GPU is the caller saying +// "this machine HAS a GPU and I am relying on these scenarios actually running"; +// with it set, an unusable harness is a FAILURE carrying the pre-flight's reason. + +#pragma once + +#include + +#include "HeadlessGL.h" + +namespace MGITest { + + class ScenarioTest : public ::testing::Test { + protected: + void SetUp() override { + m_ready = false; + HeadlessGL& gl = HeadlessGL::Get(); + if (!gl.Usable()) { + if (RequireGpu()) { + // FAIL() is a FATAL failure but does NOT mark the test skipped, + // so a derived SetUp that guards on IsSkipped() alone would run + // straight into GL calls with no current context and SIGSEGV - + // that exact crash shipped from the first version of this guard. + // Derived fixtures must gate on Ready() (below), which is false + // on BOTH the skip path and this failure path. + FAIL() << "MOBILEGL_ITEST_REQUIRE_GPU is set, so an unusable harness is a failure, not a skip. " + << "Backend " << gl.BackendName() << " could not be brought up: " << gl.SkipReason(); + } + GTEST_SKIP() << "no usable GPU/display/ICD for backend " << gl.BackendName() << ": " << gl.SkipReason(); + } + if (RequireGpu() && LooksLikeSoftwareRasterizer(gl.RendererString())) { + // "Ran on llvmpipe" must not be able to pass as "ran on the GPU": + // a misconfigured vendor pin silently lands on the software + // rasterizer, and REQUIRE_GPU exists precisely to make that loud. + FAIL() << "MOBILEGL_ITEST_REQUIRE_GPU is set but the context landed on a software rasterizer: " + << gl.RendererString(); + } + // A scenario starts from a clean slate but shares the context (and so + // the renderer's memos) with every other scenario in this process - + // which is exactly the situation both shipped bugs needed. + RecordProperty("backend", gl.BackendName()); + RecordProperty("renderer", gl.RendererString()); + m_ready = true; + } + + // The ONLY gate a derived SetUp/TearDown may use: `if (!Ready()) return;`. + // True only when the base SetUp brought the context up and neither skipped + // nor failed. IsSkipped() alone is WRONG here (see the comment at FAIL()). + bool Ready() const { return m_ready; } + + static HeadlessGL& Gl() { return HeadlessGL::Get(); } + + private: + static bool LooksLikeSoftwareRasterizer(const std::string& renderer) { + static const char* kNames[] = {"llvmpipe", "lavapipe", "softpipe", "SwiftShader", "swrast"}; + for (const char* name : kNames) { + if (renderer.find(name) != std::string::npos) { + return true; + } + } + return false; + } + + bool m_ready = false; + }; + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Main.cpp b/MobileGL/MG_IntegrationTest/Main.cpp new file mode 100644 index 00000000..69c5f407 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Main.cpp @@ -0,0 +1,53 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Main.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 +// +// Entry point for the headless GPU integration scenarios. +// +// The banner lives in a gtest Environment rather than in main() on purpose: +// Environment::SetUp does not run for `--gtest_list_tests`, which is what CMake +// invokes at build time to discover the cases. Discovery therefore never brings +// up EGL, never needs a GPU and cannot hang. + +#include +#include + +#include "Harness/HeadlessGL.h" + +namespace { + + class HarnessBanner : public ::testing::Environment { + public: + void SetUp() override { + const MGITest::HeadlessGL& gl = MGITest::HeadlessGL::Get(); + std::fprintf(stderr, "MobileGL integration scenarios: backend=%s\n", gl.BackendName().c_str()); + if (gl.Usable()) { + std::fprintf(stderr, " renderer: %s\n surface: %dx%d pbuffer (headless)\n", + gl.RendererString().c_str(), gl.Width(), gl.Height()); + } else if (MGITest::RequireGpu()) { + std::fprintf(stderr, + " FAILING every scenario (MOBILEGL_ITEST_REQUIRE_GPU is set): %s\n", + gl.SkipReason().c_str()); + } else { + std::fprintf(stderr, + " SKIPPING every scenario: %s\n" + " (set MOBILEGL_ITEST_REQUIRE_GPU=1 to make this a failure instead - a run that\n" + " skipped everything is otherwise indistinguishable from one that passed)\n", + gl.SkipReason().c_str()); + } + } + + void TearDown() override { MGITest::HeadlessGL::Get().ShutDown(); } + }; + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + ::testing::AddGlobalTestEnvironment(new HarnessBanner()); + return RUN_ALL_TESTS(); +} diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CrossFrameBufferScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CrossFrameBufferScenario.cpp new file mode 100644 index 00000000..a82710a0 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/CrossFrameBufferScenario.cpp @@ -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 +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#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 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 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(glMapBufferRange(GL_ARRAY_BUFFER, 0, m_vertexBytes, mapFlags)); + m_indexMap = static_cast( + 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(0)); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast(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& 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 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 allGreen = SceneVertices(/*leftQuadIsGreen=*/true); + const std::vector 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 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& 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(0)); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast(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& 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 full = SceneVertices(/*leftQuadIsGreen=*/false); + const std::vector subjectVertices(full.begin(), full.begin() + 4); // left, red + const std::vector 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 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 diff --git a/MobileGL/MG_IntegrationTest/Scenarios/OrientationScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/OrientationScenario.cpp new file mode 100644 index 00000000..5181b9d1 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/OrientationScenario.cpp @@ -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 +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#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& 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 QuadrantGeometry() { + std::vector 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 vertices = QuadrantGeometry(); + m_vertexCount = static_cast(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(0)); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast(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 diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ResidentIndexScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ResidentIndexScenario.cpp new file mode 100644 index 00000000..f07033db --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ResidentIndexScenario.cpp @@ -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 +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#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 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(0)); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast(8)); + glBindVertexArray(0); + return vao; + } + + unsigned int MakeStaticVbo() { + const std::vector 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(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 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(0)); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), reinterpret_cast(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 diff --git a/MobileGL/MG_IntegrationTest/run_integration_test.sh b/MobileGL/MG_IntegrationTest/run_integration_test.sh new file mode 100644 index 00000000..4e539cff --- /dev/null +++ b/MobileGL/MG_IntegrationTest/run_integration_test.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Run the headless MobileGL integration scenarios on one backend: +# ./run_integration_test.sh espryt [gtest args...] -> DirectGLES +# ./run_integration_test.sh magma [gtest args...] -> DirectVulkan +# +# The backend is latched at initialization from MOBILEGL_BACKEND_TYPE, so one +# process is one backend; this script is the dev-box equivalent of the two ctest +# registrations in CMakeLists.txt. +# +# Pin the vendor libraries explicitly, for the same reason +# MG_Benchmark/Driver/run_driver_bench.sh does: a bare libEGL on a glvnd system +# resolves to whatever vendor comes first, which is usually Mesa/llvmpipe - a +# software rasteriser silently replacing the GPU under a GPU test. Override +# MGL_EGL_VENDOR / MGL_VK_ICD to test another driver. +# +# Set MOBILEGL_ITEST_REQUIRE_GPU=1 to turn "the harness is unusable" from a clean +# skip into a failure. Do that anywhere the machine is supposed to have a GPU: a +# run that skipped everything and a run that passed everything are otherwise the +# same green, so without it a broken driver pinning is invisible. +set -eu +HERE=$(cd "$(dirname "$0")" && pwd) +BIN=${MOBILEGL_ITEST_BIN:-$HERE/MobileGLIntegrationTest} +EGL_VENDOR=${MGL_EGL_VENDOR:-/usr/share/glvnd/egl_vendor.d/10_nvidia.json} +VK_ICD=${MGL_VK_ICD:-/usr/share/vulkan/icd.d/nvidia_icd.x86_64.json} +MODE=$1; shift + +if [ ! -x "$BIN" ]; then + echo "MobileGLIntegrationTest not found at $BIN" + echo "configure with -DMOBILEGL_BUILD_INTEGRATION_TEST=ON and set MOBILEGL_ITEST_BIN" + exit 1 +fi + +[ -r "$EGL_VENDOR" ] && export __EGL_VENDOR_LIBRARY_FILENAMES=$EGL_VENDOR +export EGL_PLATFORM=${EGL_PLATFORM:-x11} + +case "$MODE" in + espryt|DirectGLES) + export MOBILEGL_BACKEND_TYPE=DirectGLES + ;; + magma|DirectVulkan) + export MOBILEGL_BACKEND_TYPE=DirectVulkan + [ -r "$VK_ICD" ] && export VK_ICD_FILENAMES=$VK_ICD + ;; + *) echo "unknown mode: $MODE (espryt|magma)"; exit 1 ;; +esac +export MOBILEGL_ITEST_REQUIRE_GPU=${MOBILEGL_ITEST_REQUIRE_GPU:-} +exec "$BIN" "$@"