Compare commits

..
10 Commits
Author SHA1 Message Date
swung0x48 efa0345c36 [Fix] (MG_Backend/DirectVulkan): offscreen surfaces never touch a window system - delete the hidden-Xlib fallback, make surface and device-enumeration failures loud, guard zero devices 2026-08-11 00:58:24 -04:00
swung0x48 ce0f18969c [Fix] (MG_Util): dlopen the versioned libEGL.so.1 first - the unversioned name is a dev-package symlink absent on runtime-only systems, and its silent failure nulled every EGL entry point 2026-08-11 00:58:23 -04:00
swung0x48 7ce0966e7d [Fix, Test] (MG_IntegrationTest): the harness is surfaceless by construction - never binds a window system, hardware demand split out of REQUIRE_GPU, pre-flight crashes keep their cores 2026-08-11 00:58:22 -04:00
swung0x48 1c6ca2753f [Test] (CI): wire the integration-gpu lane into the Test workflow - lavapipe ICD pinned at configure time, REQUIRE_GPU armed, failure-only core-dump artifacts in every native lane 2026-08-11 00:58:21 -04:00
swung0x48 61b0532865 [Fix] (MG_Backend/DirectVulkan): replay client-memory multi-draw through the single-draw path - the batched shared index view cannot express per-draw client pointers and dropped the whole batch 2026-08-10 21:58:06 -04:00
swung0x48 f5b8a505ed [Fix] (MG_Backend/DirectVulkan): keep the transient arena's outgrown buffers alive until frame rewind - the transient drain freed them while frame-serial memos still bound them 2026-08-10 21:47:28 -04:00
swung0x48 8371365db5 [Fix] (MG_Backend/DirectVulkan): include the texture enum converter the WARN-level log line needs - VulkanRenderer.cpp did not compile below INFO log level 2026-08-10 21:47:27 -04:00
swung0x48 b219992ee3 [Feat, Perf, Test] (MG_Util, MG_Backend, MG_Test): test-lane SPIR-V validation latch; entry-local Private rewrite, in-chain rect lowering and uniform-Location strip 2026-08-10 13:00:20 -04:00
swung0x48 94233ef928 [Fix] (MG_Backend/DirectVulkan): drop draws and dispatches when a program has no optimized SPIR-V - a phase-B failure left raw glslang words in GetGeneratedSpirv 2026-08-10 13:00:11 -04:00
swung0x48 0827d7a539 [Fix] (MobileGL): define the numeric log levels in Defines.h - the MOBILEGL_ASSERT gate compared undefined tokens in TUs that never include Log.h 2026-08-10 13:00:10 -04:00
27 changed files with 1486 additions and 175 deletions
+116 -2
View File
@@ -1,4 +1,4 @@
name: Test
name: Test
on:
push:
@@ -83,6 +83,8 @@ jobs:
-DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \
-DMOBILEGL_BUILD_TEST=ON \
-DMOBILEGL_BUILD_BENCHMARK=ON \
-DMOBILEGL_BUILD_INTEGRATION_TEST=ON \
-DMOBILEGL_ITEST_VK_ICD=/usr/share/vulkan/icd.d/lvp_icd.json \
-DMOBILEGL_BUILD_TRACE_REPLAY=OFF \
-DBENCHMARK_DOWNLOAD_DEPENDENCIES=ON \
-DBENCHMARK_ENABLE_TESTING=OFF \
@@ -110,6 +112,7 @@ jobs:
"${BUILD_DIR}/CTestTestfile.cmake" \
"${BUILD_DIR}/MobileGL/MG_Test" \
"${BUILD_DIR}/MobileGL/MG_Benchmark" \
"${BUILD_DIR}/MobileGL/MG_IntegrationTest" \
"${SHARED_LIBS[@]}"
- name: Upload Linux runtime
@@ -159,12 +162,102 @@ jobs:
- name: Test
working-directory: build-linux
run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then
ctest -V -L unit --no-tests=error
else
ctest --output-on-failure -L unit --no-tests=error
fi
- name: Upload core dumps
if: failure()
uses: actions/upload-artifact@v7
with:
name: unit-core-dumps
path: /tmp/core.*
if-no-files-found: ignore
integration:
runs-on: ubuntu-latest
needs: build-linux
steps:
- name: Checkout repo
uses: actions/checkout@v6
- name: Get CMake
uses: lukka/get-cmake@v4.3.3
- name: Install runtime dependencies
# Same set as the benchmark job, for the same reason: the scenarios bring
# up real headless EGL (llvmpipe) and Vulkan (lavapipe) contexts, and
# libegl-mesa0 - the EGL vendor library behind glvnd's libegl1 dispatch -
# only arrives as a Recommends.
run: |
sudo apt-get update
sudo apt-get install -y libvulkan1 libegl1 libegl-mesa0 libgles2 libgl1-mesa-dri mesa-vulkan-drivers
- name: Download Linux runtime
uses: actions/download-artifact@v8
with:
name: mobilegl-linux-runtime
path: .
- name: Unpack Linux runtime
run: tar -xzf mobilegl-linux-runtime.tgz
- name: Normalize CTest command paths
run: |
python - <<'PY'
from pathlib import Path
import re
for path in Path('build-linux').rglob('CTestTestfile.cmake'):
text = path.read_text()
text = re.sub(r'"[^"]*/cmake-[^"]*/bin/cmake"', '"cmake"', text)
path.write_text(text)
PY
- name: Integration scenarios
working-directory: build-linux
# REQUIRE_GPU makes a driverless runner FAIL instead of skipping every
# scenario - an all-skip run is otherwise indistinguishable from a pass,
# which is how a five-month-old draw-dropping bug survived unseen until
# this lane existed.
#
# The lavapipe ICD pin lives in the build-linux configure
# (-DMOBILEGL_ITEST_VK_ICD), NOT here: the configure bakes it into each
# test's ctest ENVIRONMENT property, and a property entry OVERRIDES the
# job environment - a VK_ICD_FILENAMES exported here would be silently
# ignored while looking like it works. This lane runs on lavapipe
# deterministically, not on whichever of the eight Mesa ICDs a GPU-less
# runner enumerates first.
#
# Cores are armed so that any crash - the harness pre-flight child's
# included - leaves /tmp/core.*, which the failure-only step below ships
# as an artifact. Analyzing a downloaded core against the runtime
# artifact's binary in an ubuntu-24.04 userspace reproduces the exact
# crash stack without burning a CI round on an in-workflow debugger.
env:
MOBILEGL_ITEST_REQUIRE_GPU: "1"
run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then
ctest -V -L integration-gpu --no-tests=error
else
ctest --output-on-failure -L integration-gpu --no-tests=error
fi
- name: Upload core dumps
if: failure()
uses: actions/upload-artifact@v7
with:
name: integration-core-dumps
path: /tmp/core.*
if-no-files-found: ignore
benchmark:
runs-on: ubuntu-latest
needs: build-linux
@@ -208,7 +301,18 @@ jobs:
- name: Benchmark
working-directory: build-linux
run: ctest -V -C Release -L benchmark --no-tests=error
run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
ctest -V -C Release -L benchmark --no-tests=error
- name: Upload core dumps
if: failure()
uses: actions/upload-artifact@v7
with:
name: benchmark-core-dumps
path: /tmp/core.*
if-no-files-found: ignore
build-retrace:
runs-on: ubuntu-latest
@@ -456,6 +560,8 @@ jobs:
- name: Retrace and validate
working-directory: build-retrace/tools/trace_replay
run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
if [ '${{ matrix.backend }}' = 'DirectVulkan' ]; then
export MOBILEGL_MAGMA_R11G11B10F_FALLBACK=1
fi
@@ -470,6 +576,14 @@ jobs:
fi
ctest -V --no-tests=error -R '^MobileGLTraceReplay\.${{ matrix.case }}\.${{ matrix.backend }}$'
- name: Upload core dumps
if: failure()
uses: actions/upload-artifact@v7
with:
name: retrace-core-dumps-${{ matrix.backend }}-${{ matrix.case }}
path: /tmp/core.*
if-no-files-found: ignore
- name: Upload actual image
if: always()
uses: actions/upload-artifact@v7
+2
View File
@@ -205,6 +205,8 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUboMemberRelaxedPrecisionPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.cpp
+5
View File
@@ -66,6 +66,11 @@ namespace MobileGL::MG_Config {
// - DISPLAY: X11 session variable, not MobileGL configuration.
// - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init
// (see MG_Util/Debug/Log.cpp).
// - MOBILEGL_VALIDATE_SPIRV: test suites like SpirvPassTest exercise
// ShaderCompiler without ever running MobileGL::Initialize(), and every
// Initialize() re-runs MG_ConfigLoader::Init, which would clobber a
// programmatic override stored here (see ShaderCompiler.cpp,
// SpirvValidationEnabled).
struct FeaturesTable {
// MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries.
Bool DisableTimerQuery = false;
+13
View File
@@ -37,6 +37,19 @@
#define MOBILEGL_WGL_API MOBILEGL_API
// ====================== MobileGL configurations ======================= //
// The numeric log levels live here, not only in Log.h: MOBILEGL_ASSERT below compares
// MOBILEGL_LOG_ACTIVE_LEVEL against MOBILEGL_LOG_LEVEL_DEBUG, and in a translation unit
// that includes Defines.h without Log.h both tokens would silently evaluate to 0 in the
// preprocessor conditional - enabling the assert in exactly the INFO-level builds it is
// documented to be compiled out of. Log.h redefines them identically, which is legal.
#ifndef MOBILEGL_LOG_LEVEL_DEBUG
#define MOBILEGL_LOG_LEVEL_DEBUG 0
#define MOBILEGL_LOG_LEVEL_WARN 1
#define MOBILEGL_LOG_LEVEL_ERROR 2
#define MOBILEGL_LOG_LEVEL_INFO 3
#define MOBILEGL_LOG_LEVEL_FATAL 4
#endif
#ifndef MOBILEGL_LOG_ACTIVE_LEVEL
#define MOBILEGL_LOG_ACTIVE_LEVEL MOBILEGL_LOG_LEVEL_INFO
#endif
@@ -966,6 +966,29 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (drawcount <= 0) {
return;
}
// With no element-array buffer bound, every indices[i] is a client pointer into a
// separate CPU allocation, not an offset into one shared buffer. The batched payload
// below cannot express that: it carries ONE index-buffer view for the whole batch and
// turns each pointer into a firstIndex relative to it. Replay the sub-draws through
// the single-draw entry point instead - it snapshots each client range into its own
// transient slice, which is exactly what the unrolled draws this must match do.
// (The batch used to be built this way; the shared-view rewrite that added
// MultiDrawIndexedCmd left the client-memory shape addressing a view whose byte
// offset is a hardcoded 0, so UploadAndBindIndexBuffer saw a null client pointer,
// declined the whole batch and painted nothing.)
const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();
if (vao.GetIndexBufferBindingSlot().GetBoundObject() == nullptr) {
for (GLsizei i = 0; i < drawcount; ++i) {
if (count[i] <= 0) {
continue;
}
DrawElementsBaseVertex(mode, count[i], type, indices[i],
basevertex != nullptr ? basevertex[i] : 0);
}
return;
}
MultiDrawIndexedCmd payload{};
payload.mode = mode;
payload.indexBufferView.indexType = type;
@@ -111,6 +111,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
if (buffer.IsValid()) {
// Outgrown, not dead: every BufferSlice handed out from this frame's arena so far
// still names it, and those slices stay in service until the frame slot is rewound
// (VkBufferResource::transientSlice, the converted-vertex-stream cache, the draw
// memos). The release therefore has to survive every mid-frame reclaim and land on
// the next ResetFrame of this slot - see VkBufferManager::CollectAllDeferredReleases.
m_deferredReleases[frameIndex].push_back(std::move(buffer));
}
@@ -372,6 +372,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spv_diagnostic diagnostic = nullptr;
const spv_result_t result = spvValidateWithOptions(context, options, &binary, &diagnostic);
if (result != SPV_SUCCESS) {
// MGLOG_I, not E: at the INFO compile level of the CI/test lanes that arm
// the validation switch, MGLOG_E is compiled out (Log.h orders
// DEBUG < WARN < ERROR < INFO) and the VUID would never reach a log. The
// latch is what a test harness asserts on.
MG_Util::ShaderTranspiler::ShaderCompiler::NoteSpirvValidationFailure();
MGLOG_I(
"ProgramFactory::ValidateTransformedSpirv: validation failed for stage=%d program=%u result=%d index=%zu msg=%s",
static_cast<Int>(shaderStage),
programExternalIndex,
static_cast<Int>(result),
diagnostic != nullptr ? diagnostic->position.index : 0,
diagnostic != nullptr && diagnostic->error != nullptr ? diagnostic->error : "<null>");
}
MOBILEGL_ASSERT(
result == SPV_SUCCESS,
"ProgramFactory::ValidateTransformedSpirv: validation failed for stage=%d program=%u result=%d line=%zu column=%zu index=%zu msg=%s",
@@ -1263,8 +1277,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options;
// Matches the position-fix pass: this build of spirv-tools asserts rather than
// reporting, so validation stays off in the shipping path.
// Always off: the optimizer's input validator conflates "input invalid" with
// "transform failed", and this call site fails open. Validating lanes check the
// FINAL module via ValidateTransformedSpirv, which latches instead of rerouting
// control flow.
options.set_run_validator(false);
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
const char* message) {
@@ -1304,7 +1320,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options;
options.set_run_validator(false);
options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
const char* message) {
MGLOG_E("Vulkan: xfb capture pass: %s", message != nullptr ? message : "");
@@ -1334,7 +1350,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options;
options.set_run_validator(false);
options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
const char* message) {
MGLOG_E("Vulkan: position fix pass: %s", message != nullptr ? message : "");
});
optimizer.RegisterPass(CreateGlToVulkanPositionFixPass(transformFlags));
const Bool success = optimizer.Run(input.data(), input.size(), &output, options);
@@ -2525,6 +2545,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateTransformedSpirv(moduleSpv, shaders[i]->GetShaderStage(), program.GetExternalIndex());
#else
// Final module the driver receives; also checked in the INFO-level CI/test
// lanes, where the DEBUG gate above is compiled out.
if (MG_Util::ShaderTranspiler::ShaderCompiler::SpirvValidationEnabled()) {
ValidateTransformedSpirv(moduleSpv, shaders[i]->GetShaderStage(), program.GetExternalIndex());
}
#endif
VkShaderModuleCreateInfo smci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
@@ -161,12 +161,23 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
void VkBufferManager::CollectAllDeferredReleases() {
// Per-resource releases only. Every one of them was deferred behind a BumpSliceEpoch,
// so no memo can still name the handle, and the caller has proved the GPU is idle.
//
// The transient arena's releases are deliberately NOT collected here. A buffer lands
// there when the arena outgrows it mid-frame (BufferArena::EnsureCapacity), and at
// that moment every slice already handed out from this frame's arena still names it -
// VkBufferResource::transientSlice above all, which AcquireStreamedSlice keeps
// serving for the whole frame serial on the strength of transientFrameSerial alone.
// Nothing bumps the slice epoch for those other resources, so freeing the buffer
// here left the streamed memo handing a destroyed VkBuffer to vkCmdBindIndexBuffer
// (llvmpipe then faulted inside the draw; the Create/Flywheel indirect retrace died
// exactly this way). Mid-frame drains do not advance m_frameSerial, so they must not
// free arena storage either: the arena's own ResetFrame/BeginFrame is the point where
// the slot's slices stop being reachable, and that is where these releases land.
for (Uint32 frameIndex = 0; frameIndex < m_deferredBufferReleases.size(); ++frameIndex) {
CollectDeferredReleases(frameIndex);
}
for (Uint32 frameIndex = 0; frameIndex < m_transientUploadArena.GetFrameCount(); ++frameIndex) {
m_transientUploadArena.CollectDeferredReleases(frameIndex);
}
}
void VkBufferManager::NotifyDeviceIdle() {
@@ -102,10 +102,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Recreate all per-frame transient arenas
Bool RecreateTransientArenas(Uint32 frameCount);
void BeginFrame(Uint32 frameIndex);
// Drains every frame slot's deferred buffer/resource releases (and the
// transient arena's parked superseded blocks). Only valid when the
// caller has proven every queue submission complete; used by the
// present-less frame-boundary drain.
// Drains every frame slot's deferred buffer/resource releases. Only valid when
// the caller has proven every queue submission complete; used by the present-less
// frame-boundary drain. Deliberately does NOT touch the transient arena's parked
// superseded blocks: those are still named by this frame's slices (see the
// definition), and only a frame rewind retires them.
void CollectAllDeferredReleases();
// All previously submitted GPU work has completed (vkDeviceWaitIdle).
void NotifyDeviceIdle();
@@ -19,6 +19,9 @@
#include "MG_State/GLState/TextureState/TextureObject.h"
#include "MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h"
#include "MG_Util/Converters/GLToMG/TextureEnumConverter.h"
// Only reached from an MGLOG_W, which the shipping INFO log level compiles out - so the
// missing include never broke a default build and did break every WARN/DEBUG-level one.
#include "MG_Util/Converters/MGToStr/TextureEnumConverter.h"
#include "MG_Util/Converters/MGToVk/RenderStateEnumConverter.h"
#include "MG_Util/Converters/MGToVk/TextureEnumConverter.h"
#include "MG_Util/Math/HalfFloat.h"
@@ -3009,15 +3012,10 @@ void main() {
#if defined(VK_USE_PLATFORM_XLIB_KHR)
if (m_platformDisplay != nullptr) {
if (m_ownsFallbackXlibWindow && m_window != 0 && m_platformLibrary != nullptr) {
using XDestroyWindowFn = int (*)(Display*, Window);
auto* destroyWindow = reinterpret_cast<XDestroyWindowFn>(dlsym(m_platformLibrary, "XDestroyWindow"));
if (destroyWindow) {
destroyWindow(static_cast<Display*>(m_platformDisplay), static_cast<Window>(m_window));
}
m_window = 0;
m_ownsFallbackXlibWindow = false;
}
// No fallback window to destroy any more: the display here is only ever
// one this renderer opened for a REAL window surface, and that window is
// the caller's to own. The hidden-window pbuffer fallback that used to be
// cleaned up here is gone (see CreateSurface).
using XCloseDisplayFn = int (*)(Display*);
auto* closeDisplay = reinterpret_cast<XCloseDisplayFn>(m_platformCloseDisplay);
if (closeDisplay) {
@@ -5568,6 +5566,19 @@ void main() {
MakeXfbWritesVisible();
VkTextureManager::DrawSyncScope drawSyncScope(*m_textureManager);
m_textureManager->CollectGarbage();
{
// Mirror DirectGLES's SyncToBackend gate: a program whose phase-B job failed or
// was cancelled has no usable optimized module - and on an in-place
// SanitizeAndOptimizeBinary failure GetGeneratedSpirv() still holds the RAW
// glslang words, which must never reach vkCreateShaderModule. Drop the draw.
const auto& drawProgram = *MG_State::pGLContext->GetProgramForDraw();
if (!drawProgram.GetLinkStatus() || !drawProgram.GetSpirvStatus()) {
MGLOG_D("SetupDraw skipped: program=%u is linked=%d spirv=%d",
drawProgram.GetExternalIndex(), static_cast<int>(drawProgram.GetLinkStatus()),
static_cast<int>(drawProgram.GetSpirvStatus()));
return false;
}
}
if (TrySetupDrawFastPath(frame, mode, aspects, drawParams, pIndexBufferView)) {
return true;
}
@@ -6018,6 +6029,11 @@ void main() {
m_textureManager->CollectGarbage();
auto& frame = m_frameContext.GetCurrent();
const auto& program = *MG_State::pGLContext->GetProgramForDraw();
if (!program.GetLinkStatus() || !program.GetSpirvStatus()) {
MGLOG_E("DispatchCompute skipped: program=%u has no optimized SPIR-V",
program.GetExternalIndex());
return;
}
ProgramFactory::CompileOptionFlags transformFlags = 0;
const auto& programObj = m_programFactory->GetOrCreateProgram(program, transformFlags);
@@ -6058,6 +6074,11 @@ void main() {
m_textureManager->CollectGarbage();
auto& frame = m_frameContext.GetCurrent();
const auto& program = *MG_State::pGLContext->GetProgramForDraw();
if (!program.GetLinkStatus() || !program.GetSpirvStatus()) {
MGLOG_E("DispatchComputeIndirect skipped: program=%u has no optimized SPIR-V",
program.GetExternalIndex());
return;
}
ProgramFactory::CompileOptionFlags transformFlags = 0;
const auto& programObj = m_programFactory->GetOrCreateProgram(program, transformFlags);
@@ -10904,17 +10925,24 @@ void main() {
exts.push_back(VK_KHR_ANDROID_SURFACE_EXTENSION_NAME);
}
#elif defined VK_USE_PLATFORM_XLIB_KHR
// An offscreen surface has ZERO window-system dependence, by design and on
// every machine - including ones that do have a display. There used to be a
// fallback here that requested VK_KHR_xlib_surface and had CreateSurface()
// open a hidden, never-mapped X window; it is gone. A pbuffer that quietly
// needs an X server is a pbuffer that works on a workstation and dies on a
// headless runner, which is exactly what it did: with no DISPLAY, XOpenDisplay
// returned null and the next Xlib call segfaulted. If the loader genuinely has
// no VK_EXT_headless_surface, that is an honest bring-up failure and is
// reported as one below - never papered over with a window.
m_headlessSurfaceSupported = IsExtensionSupported(m_extensions, VK_EXT_HEADLESS_SURFACE_EXTENSION_NAME);
if (m_headlessSurfaceSupported) {
exts.push_back(VK_EXT_HEADLESS_SURFACE_EXTENSION_NAME);
} else {
// Real ICDs (e.g. NVIDIA's proprietary Linux driver) may not implement
// VK_EXT_headless_surface at all. CreateSurface() falls back to a
// hidden Xlib window in that case, so request that extension instead.
MGLOG_I("%s not available; falling back to a hidden %s surface for the pbuffer context.",
VK_EXT_HEADLESS_SURFACE_EXTENSION_NAME, VK_KHR_XLIB_SURFACE_EXTENSION_NAME);
exts.push_back(VK_KHR_XLIB_SURFACE_EXTENSION_NAME);
if (!m_headlessSurfaceSupported) {
MGLOG_F("%s is not available from this Vulkan loader, so an offscreen (pbuffer) DirectVulkan "
"surface cannot be created. Refusing to substitute a window: offscreen surfaces must not "
"depend on a window system. Install an ICD that implements it (lavapipe does).",
VK_EXT_HEADLESS_SURFACE_EXTENSION_NAME);
throw RuntimeError("VK_EXT_headless_surface is unavailable for an offscreen DirectVulkan surface");
}
exts.push_back(VK_EXT_HEADLESS_SURFACE_EXTENSION_NAME);
#else
exts.push_back(VK_EXT_HEADLESS_SURFACE_EXTENSION_NAME);
#endif
@@ -11053,17 +11081,38 @@ void main() {
void VulkanRenderer::PickPhysicalDevice() {
Uint32 deviceCount = 0;
vkEnumeratePhysicalDevices(m_instance, &deviceCount, nullptr);
VK_VERIFY(vkEnumeratePhysicalDevices(m_instance, &deviceCount, nullptr));
if (deviceCount == 0) {
MGLOG_E("No physical devices supporting Vulkan found.");
} else {
MGLOG_I("Found %d physical device(s).", deviceCount);
// A real, reachable configuration, not a broken invariant: an instance can be
// created from ICDs that load perfectly and then expose no device at all - a
// GPU-less machine with the vendor ICDs installed (RADV/ANV/NVK on a CI runner)
// is exactly that. It has to be a bring-up failure the caller can report.
//
// It used to be MGLOG_E + MOBILEGL_ASSERT, and BOTH are compiled out at the INFO
// log level every shipping and CI build uses (Log.h orders DEBUG < WARN < ERROR
// < INFO), so the count-zero case fell through in silence to `devices[0]` on an
// EMPTY vector below and segfaulted in vkGetPhysicalDeviceProperties.
MGLOG_F("No Vulkan physical devices found: the instance loaded ICDs but none of them exposes a "
"device. Cannot bring up DirectVulkan. (A software ICD such as lavapipe provides one; "
"pin it with VK_ICD_FILENAMES if the machine has no GPU.)");
throw RuntimeError("No Vulkan physical devices available for DirectVulkan");
}
MOBILEGL_ASSERT(deviceCount > 0, "No physical devices found.");
MGLOG_I("Found %d physical device(s).", deviceCount);
Vector<VkPhysicalDevice> devices(deviceCount);
vkEnumeratePhysicalDevices(m_instance, &deviceCount, devices.data());
// Same truncation hazard as the instance-extension enumeration: a VK_INCOMPLETE here
// leaves the tail of `devices` default-constructed (VK_NULL_HANDLE), and every one of
// those is a null handle waiting to be passed to the driver. Take only what was
// actually written.
const VkResult enumerateResult = vkEnumeratePhysicalDevices(m_instance, &deviceCount, devices.data());
if (enumerateResult != VK_SUCCESS && enumerateResult != VK_INCOMPLETE) {
VK_VERIFY(enumerateResult, "vkEnumeratePhysicalDevices failed");
}
devices.resize(deviceCount);
if (devices.empty()) {
MGLOG_F("vkEnumeratePhysicalDevices reported devices and then wrote none");
throw RuntimeError("No Vulkan physical devices available for DirectVulkan");
}
for (Int i = 0; i < deviceCount; i++) {
if (GetMoreCapablePhysicalDevice(devices[i], m_surface, m_physicalDevice, m_physicalDevice))
MGLOG_I("Picked physical device %d.", i);
@@ -11898,26 +11947,36 @@ void main() {
m_window = reinterpret_cast<NativeWindowType>(nativeWindow);
}
#elif defined VK_USE_PLATFORM_XLIB_KHR
if (m_headlessSurfaceSupported) {
auto* createHeadlessSurface =
reinterpret_cast<PFN_vkCreateHeadlessSurfaceEXT>(
vkGetInstanceProcAddr(m_instance, "vkCreateHeadlessSurfaceEXT"));
MOBILEGL_ASSERT(createHeadlessSurface != nullptr,
"VK_EXT_headless_surface is not available for DirectVulkan pbuffer surface");
VkHeadlessSurfaceCreateInfoEXT sci{VK_STRUCTURE_TYPE_HEADLESS_SURFACE_CREATE_INFO_EXT};
VK_VERIFY(createHeadlessSurface(m_instance, &sci, nullptr, &m_surface),
"vkCreateHeadlessSurfaceEXT failed");
return;
// No fall-through to Xlib: an offscreen surface never touches a window
// system. CreateInstance() has already refused the bring-up if the loader
// lacks the extension, so reaching here without it is a broken invariant
// rather than a platform limitation - report it and fail, do not continue.
auto* createHeadlessSurface =
reinterpret_cast<PFN_vkCreateHeadlessSurfaceEXT>(
vkGetInstanceProcAddr(m_instance, "vkCreateHeadlessSurfaceEXT"));
if (!m_headlessSurfaceSupported || createHeadlessSurface == nullptr) {
MGLOG_F("vkCreateHeadlessSurfaceEXT is unavailable (%s reported as %s) while creating an "
"offscreen DirectVulkan surface",
VK_EXT_HEADLESS_SURFACE_EXTENSION_NAME,
m_headlessSurfaceSupported ? "supported" : "unsupported");
throw RuntimeError("vkCreateHeadlessSurfaceEXT is unavailable for an offscreen DirectVulkan surface");
}
// VK_EXT_headless_surface unavailable on this ICD: fall through to the
// Xlib branch below, which creates a hidden window since m_window is
// still null here.
VkHeadlessSurfaceCreateInfoEXT sci{VK_STRUCTURE_TYPE_HEADLESS_SURFACE_CREATE_INFO_EXT};
VK_VERIFY(createHeadlessSurface(m_instance, &sci, nullptr, &m_surface),
"vkCreateHeadlessSurfaceEXT failed");
return;
#else
auto* createHeadlessSurface =
reinterpret_cast<PFN_vkCreateHeadlessSurfaceEXT>(
vkGetInstanceProcAddr(m_instance, "vkCreateHeadlessSurfaceEXT"));
MOBILEGL_ASSERT(createHeadlessSurface != nullptr,
"VK_EXT_headless_surface is not available for DirectVulkan pbuffer surface");
if (createHeadlessSurface == nullptr) {
// Same class as the Xlib branch above: a null entry point behind
// MOBILEGL_ASSERT is a segv on the next line in every INFO-level build.
MGLOG_F("vkCreateHeadlessSurfaceEXT is unavailable while creating an offscreen DirectVulkan "
"surface (%s missing from this loader)",
VK_EXT_HEADLESS_SURFACE_EXTENSION_NAME);
throw RuntimeError("vkCreateHeadlessSurfaceEXT is unavailable for an offscreen DirectVulkan surface");
}
VkHeadlessSurfaceCreateInfoEXT sci{VK_STRUCTURE_TYPE_HEADLESS_SURFACE_CREATE_INFO_EXT};
VK_VERIFY(createHeadlessSurface(m_instance, &sci, nullptr, &m_surface),
"vkCreateHeadlessSurfaceEXT failed");
@@ -11946,59 +12005,52 @@ void main() {
sci.pLayer = reinterpret_cast<const void*>(m_window);
VK_VERIFY(vkCreateMetalSurfaceEXT(m_instance, &sci, nullptr, &m_surface), "vkCreateMetalSurfaceEXT failed");
#elif defined VK_USE_PLATFORM_XLIB_KHR
// m_window may legitimately still be null here: the pbuffer/headless-fallback
// path below creates its own window when the caller didn't provide one.
// Reached only for a REAL on-screen window surface (a windowed desktop app,
// retrace in window mode). Presentation to a window legitimately needs a
// window system; offscreen requests returned above and never come here, so
// there is no longer any path that opens a display on a caller's behalf.
//
// Every failure below is a real error return, not MOBILEGL_ASSERT: that macro
// is compiled out at the INFO log level every shipping and CI build uses, so
// asserting here meant a null Display sailed straight into the next Xlib call
// and segfaulted - which is exactly how this presented in CI.
if (!m_window) {
MGLOG_F("CreateSurface: a window surface was requested with no native window");
throw RuntimeError("CreateSurface: no native window for the Vulkan Xlib surface");
}
void* x11Lib = dlopen("libX11.so.6", RTLD_LOCAL | RTLD_NOW);
if (!x11Lib) {
x11Lib = dlopen("libX11.so", RTLD_LOCAL | RTLD_NOW);
}
MOBILEGL_ASSERT(x11Lib != nullptr, "Failed to open libX11 while creating Vulkan Xlib surface");
if (x11Lib == nullptr) {
MGLOG_F("Failed to open libX11 (.so.6 and .so) while creating a Vulkan Xlib window surface: %s",
dlerror());
throw RuntimeError("libX11 is unavailable for the Vulkan Xlib window surface");
}
using XOpenDisplayFn = Display* (*)(const char*);
using XCloseDisplayFn = int (*)(Display*);
auto* xOpenDisplay = reinterpret_cast<XOpenDisplayFn>(dlsym(x11Lib, "XOpenDisplay"));
auto* xCloseDisplay = reinterpret_cast<XCloseDisplayFn>(dlsym(x11Lib, "XCloseDisplay"));
MOBILEGL_ASSERT(xOpenDisplay != nullptr && xCloseDisplay != nullptr,
"Failed to resolve XOpenDisplay/XCloseDisplay while creating Vulkan Xlib surface");
if (xOpenDisplay == nullptr || xCloseDisplay == nullptr) {
MGLOG_F("Failed to resolve XOpenDisplay/XCloseDisplay while creating a Vulkan Xlib window surface");
dlclose(x11Lib);
throw RuntimeError("libX11 is missing XOpenDisplay/XCloseDisplay");
}
auto* display = xOpenDisplay(std::getenv("DISPLAY"));
MOBILEGL_ASSERT(display != nullptr, "XOpenDisplay failed while creating Vulkan Xlib surface");
const char* displayName = std::getenv("DISPLAY");
auto* display = xOpenDisplay(displayName);
if (display == nullptr) {
MGLOG_F("XOpenDisplay(%s) failed while creating a Vulkan Xlib window surface; there is no usable X "
"display for the requested window surface",
displayName != nullptr ? displayName : "<DISPLAY unset>");
dlclose(x11Lib);
throw RuntimeError("XOpenDisplay failed for the Vulkan Xlib window surface");
}
m_platformDisplay = display;
m_platformLibrary = x11Lib;
m_platformCloseDisplay = reinterpret_cast<void*>(xCloseDisplay);
if (!m_window) {
// Pbuffer/headless fallback: the ICD didn't implement
// VK_EXT_headless_surface (e.g. NVIDIA's proprietary Linux driver), so
// CreateInstance() requested VK_KHR_xlib_surface instead and left
// m_window null for us to fill in here. This window is never mapped -
// it exists only to give the WSI a valid drawable - so nothing is ever
// shown on screen; the swapchain image backs the GL default framebuffer
// exactly like the headless-surface path does.
using XDefaultRootWindowFn = Window (*)(Display*);
using XDefaultScreenFn = int (*)(Display*);
using XBlackPixelFn = unsigned long (*)(Display*, int);
using XCreateSimpleWindowFn = Window (*)(Display*, Window, int, int, unsigned int, unsigned int,
unsigned int, unsigned long, unsigned long);
auto* xDefaultRootWindow = reinterpret_cast<XDefaultRootWindowFn>(dlsym(x11Lib, "XDefaultRootWindow"));
auto* xDefaultScreen = reinterpret_cast<XDefaultScreenFn>(dlsym(x11Lib, "XDefaultScreen"));
auto* xBlackPixel = reinterpret_cast<XBlackPixelFn>(dlsym(x11Lib, "XBlackPixel"));
auto* xCreateSimpleWindow =
reinterpret_cast<XCreateSimpleWindowFn>(dlsym(x11Lib, "XCreateSimpleWindow"));
MOBILEGL_ASSERT(xDefaultRootWindow && xDefaultScreen && xBlackPixel && xCreateSimpleWindow,
"Failed to resolve XCreateSimpleWindow dependencies for the Xlib pbuffer fallback");
const int screen = xDefaultScreen(display);
const Uint32 width = std::max<Uint32>(m_config.SurfaceWidth, 1);
const Uint32 height = std::max<Uint32>(m_config.SurfaceHeight, 1);
const Window fallbackWindow = xCreateSimpleWindow(display, xDefaultRootWindow(display), 0, 0, width,
height, 0, xBlackPixel(display, screen),
xBlackPixel(display, screen));
MOBILEGL_ASSERT(fallbackWindow != 0, "XCreateSimpleWindow failed for the Xlib pbuffer fallback");
m_window = static_cast<NativeWindowType>(fallbackWindow);
m_ownsFallbackXlibWindow = true;
}
VkXlibSurfaceCreateInfoKHR sci{VK_STRUCTURE_TYPE_XLIB_SURFACE_CREATE_INFO_KHR};
sci.dpy = display;
sci.window = static_cast<Window>(m_window);
@@ -12048,10 +12100,40 @@ void main() {
}
Vector<VkExtensionProperties> VulkanRenderer::EnumerateInstanceExtensions() {
Uint32 extensionCount = 0;
VK_VERIFY(vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount, nullptr));
Vector<VkExtensionProperties> extensions(extensionCount);
vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount, extensions.data());
// The two-call idiom has a race the spec explicitly allows for: the loader
// re-scans ICDs, so the property count can GROW between the sizing call and
// the fill call, and the fill then returns VK_INCOMPLETE having written only
// as many entries as the caller asked for. The result is a silently TRUNCATED
// extension list - and which extensions fall off the end is exactly as stable
// as the loader's scan order, i.e. not at all. That is how a headless CI
// runner could decide VK_EXT_headless_surface did not exist on one run and
// did on the next, sending the pbuffer path into the Xlib fallback with no
// X server to open. The sibling EnumerateDeviceExtensions below already
// checked its second call; this one dropped the result on the floor.
// Loop until a fill call agrees with its own sizing call.
Vector<VkExtensionProperties> extensions;
for (Uint32 attempt = 0; attempt < 8; ++attempt) {
Uint32 extensionCount = 0;
VK_VERIFY(vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount, nullptr));
extensions.resize(extensionCount);
if (extensionCount == 0) {
return extensions;
}
const VkResult result =
vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount, extensions.data());
if (result == VK_SUCCESS) {
extensions.resize(extensionCount);
return extensions;
}
if (result != VK_INCOMPLETE) {
VK_VERIFY(result, "vkEnumerateInstanceExtensionProperties failed");
return extensions;
}
MGLOG_I("vkEnumerateInstanceExtensionProperties returned VK_INCOMPLETE (the loader's list grew "
"mid-enumeration); re-enumerating");
}
MGLOG_F("vkEnumerateInstanceExtensionProperties never settled; the instance extension list may be "
"truncated and surface-extension selection is about to be made on incomplete information");
return extensions;
}
@@ -445,15 +445,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
void* m_platformDisplay = nullptr;
void* m_platformLibrary = nullptr;
void* m_platformCloseDisplay = nullptr;
// Some real ICDs (e.g. NVIDIA's proprietary Linux driver) don't implement
// VK_EXT_headless_surface at all. Detected once in CreateInstance() from the
// enumerated instance extensions; when false, CreateSurface() falls back to a
// hidden Xlib window instead of vkCreateHeadlessSurfaceEXT.
// Whether the loader exposes VK_EXT_headless_surface, detected once in
// CreateInstance() from the enumerated instance extensions. On desktop an
// offscreen surface REQUIRES it: false is a clean, loud bring-up failure, never
// a substituted window. (Android is the one exception and has its own path -
// no Mali/Adreno driver seen so far exposes the extension, so a windowless
// context is given an AImageReader ANativeWindow that is never displayed.)
Bool m_headlessSurfaceSupported = true;
// Set when CreateSurface() had to create its own Xlib window for the fallback
// above (rather than being handed one by the caller), so Shutdown() knows it
// owns that window and must destroy it.
Bool m_ownsFallbackXlibWindow = false;
// Android has the same shortfall: no Mali/Adreno driver seen so far exposes
// VK_EXT_headless_surface, so a windowless (EGL pbuffer) context gets an
// AImageReader's ANativeWindow to hand the WSI instead. Nothing is ever
+13 -14
View File
@@ -169,25 +169,24 @@ endif()
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()
# No EGL_PLATFORM knob here on purpose. The harness pins EGL_PLATFORM=surfaceless
# itself before its first EGL call (HeadlessGL.cpp, EnsureHeadlessPlatform) so a
# developer's machine and a CI runner take the SAME path whether or not a window
# system happens to be running. This used to inject "x11", which is how the lane
# came up green on a workstation with WSLg and died on a runner with no X server.
#
# A build-system knob would not just be redundant, it would be a trap: `set(...
# CACHE ...)` does not rewrite an existing cache, so every build directory
# configured before this change would keep injecting EGL_PLATFORM=x11 and go on
# binding to a window system - silently, and only on the machines that have one.
# Someone reproducing a platform-specific bug sets EGL_PLATFORM in their own
# environment, which the harness still honours.
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()
unset(MOBILEGL_ITEST_EGL_PLATFORM CACHE) # see above: an old cache must not resurrect x11
if (MOBILEGL_ITEST_REQUIRE_GPU)
list(APPEND MGL_ITEST_COMMON_ENV "MOBILEGL_ITEST_REQUIRE_GPU=1")
endif()
@@ -74,6 +74,40 @@ namespace MGITest {
std::string renderer;
};
// The harness is headless BY CONSTRUCTION, on every machine: it must never
// reach a window system, not even where one happens to be running. This is
// not a CI accommodation - it is what keeps a developer's run and a CI run
// the same run. The lane was wired up green on a workstation and immediately
// died on the runner precisely because the workstation had a DISPLAY (WSLg)
// and took Mesa's x11 platform, while the runner has none; that divergence
// is the bug, and pinning the platform here is the fix for it.
//
// Mesa selects its EGL platform from EGL_PLATFORM at loader time, so this
// has to run before the first EGL call in the process (see EnsureHeadless
// callers). surfaceless is the platform with no window-system dependency at
// all; the surface this file then creates is still a pbuffer, which every
// platform supports and which the amendment to this rule requires as the
// fallback shape. DISPLAY/WAYLAND_DISPLAY are cleared as well so that a
// driver that consults them directly cannot reintroduce the dependency
// behind EGL's back. Desktop-only file: MG_IntegrationTest never builds
// for Android, so no device path is affected.
void EnsureHeadlessPlatform() {
#if defined(__linux__) && !defined(__ANDROID__)
static bool done = false;
if (done) {
return;
}
done = true;
// An explicit EGL_PLATFORM from the operator still wins: pinning a
// platform is exactly how someone reproduces a platform-specific bug.
if (std::getenv("EGL_PLATFORM") == nullptr) {
setenv("EGL_PLATFORM", "surfaceless", 1);
}
unsetenv("DISPLAY");
unsetenv("WAYLAND_DISPLAY");
#endif
}
// 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
@@ -82,6 +116,9 @@ namespace MGITest {
// 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) {
// Belt and braces: the pre-flight child and the parent both enter here,
// and neither may be the first to touch EGL without this having run.
EnsureHeadlessPlatform();
EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (display == EGL_NO_DISPLAY) {
outReason = WithEglError("eglGetDisplay(EGL_DEFAULT_DISPLAY) returned EGL_NO_DISPLAY");
@@ -199,11 +236,10 @@ namespace MGITest {
}
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);
// No core suppression here, deliberately: when the child dies on a
// signal, the core IS the diagnosis (an rlimit that used to sit here
// made a CI-only crash undebuggable). Machines that do not want
// cores control that with the usual ulimit/core_pattern knobs.
std::fprintf(stderr, "[itest] pre-flight child: attempting a full EGL bring-up\n");
EglBringUp local;
std::string reason;
@@ -284,9 +320,19 @@ namespace MGITest {
}
} // namespace
namespace {
bool EnvFlag(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && value[0] != '\0' && std::strcmp(value, "0") != 0;
}
} // namespace
bool RequireGpu() {
const char* value = std::getenv("MOBILEGL_ITEST_REQUIRE_GPU");
return value != nullptr && value[0] != '\0' && std::strcmp(value, "0") != 0;
return EnvFlag("MOBILEGL_ITEST_REQUIRE_GPU");
}
bool RequireHardwareGpu() {
return EnvFlag("MOBILEGL_ITEST_REQUIRE_HARDWARE_GPU");
}
std::ostream& operator<<(std::ostream& os, const Rgba8& c) {
@@ -390,6 +436,10 @@ namespace MGITest {
}
HeadlessGL::HeadlessGL() {
// Before anything else in this process can reach EGL, and in particular
// before the pre-flight forks - the child must measure the same platform
// the parent will use.
EnsureHeadlessPlatform();
m_backendName = EnvOr("MOBILEGL_BACKEND_TYPE", "<unset>");
m_usable = BringUp();
}
@@ -41,6 +41,15 @@ namespace MGITest {
// a job that ran everything.
bool RequireGpu();
// True when MOBILEGL_ITEST_REQUIRE_HARDWARE_GPU is set: additionally asserts
// that the context did NOT land on a software rasterizer. Deliberately a
// SEPARATE switch from RequireGpu - a GPU-less CI runner is a supported and
// intended configuration for these scenarios (they pin backend draw logic,
// which llvmpipe/lavapipe execute faithfully), so CI wants the falsifiability
// of REQUIRE_GPU without the hardware demand. Use this one only where a vendor
// pin silently degrading to software would invalidate the measurement.
bool RequireHardwareGpu();
struct Rgba8 {
std::uint8_t r = 0, g = 0, b = 0, a = 0;
@@ -45,12 +45,18 @@ namespace MGITest {
}
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();
if (RequireHardwareGpu() && LooksLikeSoftwareRasterizer(gl.RendererString())) {
// Only when hardware was asked for BY NAME. REQUIRE_GPU means "an
// unusable harness is a failure, not a silent skip" - it is the
// falsifiability switch, and CI is exactly where it belongs. But CI
// runners have no GPU, so folding "must not be llvmpipe" into the
// same switch made the CI lane unpassable by construction: the
// scenarios pin backend draw logic, which a software rasterizer
// executes just as faithfully. Landing on llvmpipe/lavapipe there is
// the intended configuration, not a misconfiguration. A vendor pin
// that must not silently degrade sets REQUIRE_HARDWARE_GPU.
FAIL() << "MOBILEGL_ITEST_REQUIRE_HARDWARE_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 -
+8 -2
View File
@@ -26,8 +26,14 @@ namespace {
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());
// EGL_PLATFORM is echoed because it is the invariant this harness
// rests on: the run is headless on every machine, so a run that
// silently bound to a workstation's window system is a different
// run from CI's and must be visible as one in the log.
const char* eglPlatform = std::getenv("EGL_PLATFORM");
std::fprintf(stderr, " renderer: %s\n surface: %dx%d pbuffer (headless, EGL_PLATFORM=%s)\n",
gl.RendererString().c_str(), gl.Width(), gl.Height(),
eglPlatform != nullptr ? eglPlatform : "<unset>");
} else if (MGITest::RequireGpu()) {
std::fprintf(stderr,
" FAILING every scenario (MOBILEGL_ITEST_REQUIRE_GPU is set): %s\n",
@@ -2759,3 +2759,323 @@ void main() {
}
EXPECT_GE(checked, 7u) << "expected all seven declared inputs to be present in the raw module";
}
namespace {
// Storage-class census of module-scope OpVariables plus an OpFunctionCall count -
// everything the dead-interface-elimination tests need to see, nothing more.
struct SpirvVariableCensus {
SizeT inputCount = 0;
SizeT outputCount = 0;
SizeT privateCount = 0;
SizeT functionCallCount = 0;
};
SpirvVariableCensus TakeVariableCensus(const Vector<Uint32>& spirv) {
constexpr unsigned kOpVariable = 59, kOpFunctionCall = 57;
constexpr unsigned kStorageClassInput = 1, kStorageClassPrivate = 6, kStorageClassOutput = 3;
SpirvVariableCensus census;
for (SizeT i = 5; i < spirv.size();) { // 5-word header
const unsigned wordCount = spirv[i] >> 16;
const unsigned opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
if (opcode == kOpVariable && wordCount >= 4) {
switch (spirv[i + 3]) {
case kStorageClassInput: ++census.inputCount; break;
case kStorageClassOutput: ++census.outputCount; break;
case kStorageClassPrivate: ++census.privateCount; break;
default: break;
}
} else if (opcode == kOpFunctionCall) {
++census.functionCallCount;
}
i += wordCount;
}
return census;
}
// The exact Iris shim shape that shipped an invalid module for a month: a declared
// vertex input whose only use is the initializer of a file-scope global nothing ever
// reads, in a shader whose main() still contains calls (which is what used to make
// ADCE keep the whole chain alive).
constexpr const char* kDeadPrivateChainVertexSource = R"(#version 460 core
in vec3 a_Position;
in vec2 mc_midTexCoord;
out vec4 v_Color;
vec4 iris_MidTex = vec4(mc_midTexCoord * (1.0 / 32768.0), 0.0, 1.0);
vec4 helperTint();
void main() {
v_Color = helperTint();
gl_Position = vec4(a_Position, 1.0);
}
vec4 helperTint() { return vec4(1.0); }
)";
Vector<Uint32> CompileVertexToRawSpirv(const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
if (!shaderResult) {
ADD_FAILURE() << shaderResult.error().log;
return {};
}
ProgramAttrib programAttrib{.shaders = {shaderResult.value()}};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
if (!programResult) {
ADD_FAILURE() << programResult.error().log;
return {};
}
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_VERTEX_SHADER},
.program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
if (!binaryResult || binaryResult->size() != 1u) {
ADD_FAILURE() << (binaryResult ? "unexpected module count"
: binaryResult.error().log);
return {};
}
return binaryResult->front();
}
struct SpirvValidationScope {
bool previous;
explicit SpirvValidationScope(bool enabled)
: previous(MG_Util::ShaderTranspiler::ShaderCompiler::SpirvValidationEnabled()) {
MG_Util::ShaderTranspiler::ShaderCompiler::SetSpirvValidationEnabled(enabled);
}
~SpirvValidationScope() {
MG_Util::ShaderTranspiler::ShaderCompiler::SetSpirvValidationEnabled(previous);
}
};
} // namespace
TEST_F(ProgramUtilTest, DeadPrivateChainVertexInputIsEliminatedFromOptimizedBinary) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = CompileVertexToRawSpirv(kDeadPrivateChainVertexSource);
ASSERT_FALSE(raw.empty());
const SpirvVariableCensus before = TakeVariableCensus(raw);
// Preconditions that make this module exercise the ADCE conservatism gate: the dead
// input is present, its Private sink is present, and main() still contains a call.
// Four Inputs, not two: the frontend always emits gl_VertexIndex/gl_InstanceIndex
// built-ins alongside a_Position and mc_midTexCoord.
ASSERT_EQ(before.inputCount, 4u)
<< "expected a_Position, mc_midTexCoord, gl_VertexIndex and gl_InstanceIndex in the raw module";
ASSERT_GE(before.privateCount, 1u);
ASSERT_GE(before.functionCallCount, 1u)
<< "helperTint() was inlined by the frontend; this test no longer covers the "
<< "entry-point-with-calls shape it exists for";
Vector<Uint32> optimized;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
const SpirvVariableCensus after = TakeVariableCensus(optimized);
EXPECT_EQ(after.inputCount, 1u)
<< "mc_midTexCoord feeds only a never-read Private global and must not reach the driver";
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String validatorMessages;
tools.SetMessageConsumer([&validatorMessages](spv_message_level_t, const char*,
const spv_position_t&, const char* message) {
if (message != nullptr) validatorMessages += String(message) + "\n";
});
EXPECT_TRUE(tools.Validate(optimized)) << validatorMessages;
}
TEST_F(ProgramUtilTest, DeclaredButUnwrittenOutputSurvivesOptimization) {
using namespace MG_Util::ShaderTranspiler;
// Chocapic-class packs declare varyings some variants never write while the paired
// fragment shader still reads them. The OpVariable (and its Location) must survive the
// chain on both backends: Espryt's ESSL link would otherwise fail with "varying not
// declared in vertex shader", and Magma's stage-interface contract breaks the same way.
// ADCE guarantees this only while remove_outputs stays false - this test freezes that.
const Vector<Uint32> raw = CompileVertexToRawSpirv(R"(#version 460 core
in vec3 a_Position;
out vec4 v_Written;
out vec4 v_NeverWritten;
void main() {
v_Written = vec4(1.0);
gl_Position = vec4(a_Position, 1.0);
}
)");
ASSERT_FALSE(raw.empty());
// v_Written, v_NeverWritten, and the gl_PerVertex block are all Output-storage variables.
const SpirvVariableCensus before = TakeVariableCensus(raw);
ASSERT_GE(before.outputCount, 3u);
Vector<Uint32> optimized;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
EXPECT_EQ(TakeVariableCensus(optimized).outputCount, before.outputCount)
<< "a declared-but-unwritten output was deleted; a fragment stage reading it now "
<< "fails to link (ES) or breaks the Vulkan stage interface";
}
TEST_F(ProgramUtilTest, ValidationLatchFlagsInvalidModuleWithoutChangingResults) {
using namespace MG_Util::ShaderTranspiler;
// Only LIVE inputs, so the chain cannot heal the module by deleting them: both
// survive to the output, undecorated, and the output is invalid SPIR-V.
Vector<Uint32> raw = CompileVertexToRawSpirv(R"(#version 460 core
in vec3 a_Position;
in vec4 a_Color;
out vec4 v_Color;
void main() {
v_Color = a_Color;
gl_Position = vec4(a_Position, 1.0);
}
)");
ASSERT_FALSE(raw.empty());
// Strip every Input Location decoration - the exact defect class the
// TMglGlslIoResolver used to ship ([VUID-StandaloneSpirv-Location-04916]).
constexpr unsigned kOpDecorate = 71, kOpVariable = 59;
constexpr unsigned kDecorationLocation = 30, kStorageClassInput = 1;
std::set<unsigned> inputIds;
for (SizeT i = 5; i < raw.size();) {
const unsigned wordCount = raw[i] >> 16;
const unsigned opcode = raw[i] & 0xFFFFu;
ASSERT_GT(wordCount, 0u);
if (i + wordCount > raw.size()) break;
if (opcode == kOpVariable && wordCount >= 4 && raw[i + 3] == kStorageClassInput) {
inputIds.insert(raw[i + 2]);
}
i += wordCount;
}
SizeT strippedCount = 0;
for (SizeT i = 5; i < raw.size();) {
const unsigned wordCount = raw[i] >> 16;
const unsigned opcode = raw[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > raw.size()) break;
if (opcode == kOpDecorate && wordCount >= 4 && raw[i + 2] == kDecorationLocation &&
inputIds.count(raw[i + 1]) != 0) {
raw.erase(raw.begin() + static_cast<std::ptrdiff_t>(i),
raw.begin() + static_cast<std::ptrdiff_t>(i + wordCount));
++strippedCount;
continue; // do not advance: the next instruction moved into place
}
i += wordCount;
}
ASSERT_GE(strippedCount, 2u) << "expected to strip both live inputs' Location decorations";
Vector<Uint32> optimized;
{
// The armed lane: control flow is IDENTICAL to shipping (the wrapper still
// succeeds - fail-open call sites downstream must not see a different world),
// and the failure latch is the signal. This is the catch that took a device
// bisect to find when the validator was off everywhere.
SpirvValidationScope validationOn(true);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
EXPECT_GT(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "an invalid optimized module must bump the validation-failure latch";
}
{
// The shipping configuration: same result, no validation, latch untouched.
SpirvValidationScope validationOff(false);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore);
}
}
namespace {
// OpTypeImage: result id (+1), sampled type (+2), dim (+3). Dim::Rect == 4.
SizeT CountRectImageTypes(const Vector<Uint32>& spirv) {
constexpr unsigned kOpTypeImage = 25, kDimRect = 4;
SizeT count = 0;
for (SizeT i = 5; i < spirv.size();) {
const unsigned wordCount = spirv[i] >> 16;
const unsigned opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
if (opcode == kOpTypeImage && wordCount >= 4 && spirv[i + 3] == kDimRect) {
++count;
}
i += wordCount;
}
return count;
}
// True when any OpDecorate Location targets a UniformConstant/Uniform-storage
// variable ([VUID-StandaloneSpirv-Location-06672]).
bool AnyLocationOnUniformStorage(const Vector<Uint32>& spirv) {
constexpr unsigned kOpDecorate = 71, kOpVariable = 59, kDecorationLocation = 30;
constexpr unsigned kStorageUniformConstant = 0, kStorageUniform = 2;
std::set<unsigned> locatedIds;
for (SizeT i = 5; i < spirv.size();) {
const unsigned wordCount = spirv[i] >> 16;
const unsigned opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
if (opcode == kOpDecorate && wordCount >= 4 && spirv[i + 2] == kDecorationLocation) {
locatedIds.insert(spirv[i + 1]);
}
i += wordCount;
}
for (SizeT i = 5; i < spirv.size();) {
const unsigned wordCount = spirv[i] >> 16;
const unsigned opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
if (opcode == kOpVariable && wordCount >= 4 &&
(spirv[i + 3] == kStorageUniformConstant || spirv[i + 3] == kStorageUniform) &&
locatedIds.count(spirv[i + 2]) != 0) {
return true;
}
i += wordCount;
}
return false;
}
} // namespace
TEST_F(ProgramUtilTest, RectangleSamplerModuleLeavesTheChainVulkanLegal) {
using namespace MG_Util::ShaderTranspiler;
// Dim::Rect is invalid under every Vulkan environment; the lowering used to run
// only in the backends, i.e. AFTER the chain whose output the validating lanes
// check. It now runs inside the chain, so the driver-bound bytes are rect-free.
const Vector<Uint32> raw = CompileVertexToRawSpirv(R"(#version 460 core
in vec3 a_Position;
uniform sampler2DRect uRect;
out vec4 v_Color;
void main() {
v_Color = texture(uRect, a_Position.xy);
gl_Position = vec4(a_Position, 1.0);
}
)");
ASSERT_FALSE(raw.empty());
ASSERT_GE(CountRectImageTypes(raw), 1u) << "glslang no longer emits Dim::Rect for sampler2DRect";
SpirvValidationScope validationOn(true);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> optimized;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
EXPECT_EQ(CountRectImageTypes(optimized), 0u);
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "a rectangle module must leave the chain valid, not latched as a failure";
}
TEST_F(ProgramUtilTest, ExplicitSamplerLocationIsStrippedFromTheOptimizedBinary) {
using namespace MG_Util::ShaderTranspiler;
// glslang's relaxed GL path keeps layout(location=N) on the UniformConstant
// variable, which Vulkan forbids; nothing downstream reads it (GL locations come
// from phase-A reflection, Vulkan bindings go by name).
const Vector<Uint32> raw = CompileVertexToRawSpirv(R"(#version 460 core
in vec3 a_Position;
layout(location = 5) uniform sampler2D uTex;
out vec4 v_Color;
void main() {
v_Color = texture(uTex, a_Position.xy);
gl_Position = vec4(a_Position, 1.0);
}
)");
ASSERT_FALSE(raw.empty());
ASSERT_TRUE(AnyLocationOnUniformStorage(raw))
<< "glslang no longer keeps the explicit uniform location; the strip pass may be obsolete";
SpirvValidationScope validationOn(true);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> optimized;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
EXPECT_FALSE(AnyLocationOnUniformStorage(optimized));
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "the stripped module must validate clean";
}
+7 -3
View File
@@ -54,9 +54,7 @@ namespace MobileGL::MG_Util::Async {
// calling eglTerminate, which is the norm for a test binary and legal
// for an application. Registered here, during main, so it runs before
// the destructors of statics constructed at load time.
if (ShaderCompilePool* pool = g_processPool.load(std::memory_order_acquire)) {
pool->StopAndDrain();
}
ShaderCompilePool::StopAndDrainProcessPoolAtExit();
});
});
}
@@ -345,4 +343,10 @@ namespace MobileGL::MG_Util::Async {
// eglInitialize to get its worker threads back, the re-arm belongs in
// MobileGL::Initialize(), next to glslang::InitializeProcess().
}
void ShaderCompilePool::StopAndDrainProcessPoolAtExit() {
if (ShaderCompilePool* pool = g_processPool.load(std::memory_order_acquire)) {
pool->StopAndDrain();
}
}
} // namespace MobileGL::MG_Util::Async
@@ -109,6 +109,13 @@ namespace MobileGL::MG_Util::Async {
// but they share glslang's process globals, which teardown is about to free.
void StopAndDrain();
// StopAndDrain() on the process-wide pool if one was ever created; never creates
// one. For extra atexit sentinels owned by other subsystems (the SPIR-V validation
// switch registers one after forcing spirv-tools' lazy function-local tables into
// existence, so the drain is sequenced before those tables' destructors - a worker
// mid-Validate would otherwise touch freed memory during process exit).
static void StopAndDrainProcessPoolAtExit();
Uint GetThreadCount() const;
Uint GetMaxConcurrency() const;
@@ -568,13 +568,26 @@ namespace MobileGL::MG_Util::BackendLoader {
#if defined(MOBILEGL_IOS)
eglLib = OpenLib({"libtinygl4angle.dylib"});
#else
eglLib = OpenLib({"libEGL.so"});
// Versioned SONAME first. The unversioned "libEGL.so" is a development
// symlink: it ships in libegl-dev/mesa-libEGL-devel, NOT in the runtime
// package, so a machine that can run GL perfectly well may not have it -
// every stock Ubuntu/Debian runtime image, the GitHub Actions runners
// included. Asking only for the unversioned name there makes dlopen fail,
// which used to leave the whole EGL function table null and take the next
// call through a null pointer (SIGSEGV inside InitDisplayAndContext).
// Developer machines have both names, which is exactly why this only ever
// showed up in CI.
eglLib = OpenLib({"libEGL.so.1", "libEGL.so"});
#endif
}
#endif // !_WIN32
if (!eglLib) {
MGLOG_E("Failed to open EGL library");
// MGLOG_F, not MGLOG_E: at the INFO log level every shipping and CI build
// uses, MGLOG_E is compiled out (Log.h orders DEBUG < WARN < ERROR < INFO),
// so this diagnosis was invisible in precisely the builds that needed it.
MGLOG_F("Failed to open EGL library: none of libEGL.so.1 / libEGL.so could be "
"dlopened; every EGL entry point will be null");
return;
}
@@ -595,7 +608,10 @@ namespace MobileGL::MG_Util::BackendLoader {
do { \
funcs.name = (MG_External::EGL::name##_PTR)resolveEGLProc(#name); \
if (!funcs.name) { \
MGLOG_E("Failed to load EGL function: %s", #name); \
/* MGLOG_F for the same reason as the open failure above: a null entry */ \
/* point is a crash waiting for its first caller, and MGLOG_E is compiled */ \
/* out at the INFO level every shipping and CI build uses. */ \
MGLOG_F("Failed to load EGL function: %s", #name); \
} \
} while (0);
@@ -22,6 +22,8 @@
#include "SpirvPasses/PackDoubleVertexInputsPass.h"
#include "SpirvPasses/RebaseInstanceIndexPass.h"
#include "SpirvPasses/NormalizeRectCoordinatesPass.h"
#include "SpirvPasses/PrivateToEntryLocalPass.h"
#include "SpirvPasses/StripUniformLocationsPass.h"
#include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h"
#include "SpirvPasses/StripNoPerspectivePass.h"
#include "SpirvPasses/EmulateNoPerspectivePass.h"
@@ -30,9 +32,13 @@
#include "ShaderSourceProcessor.h"
#include <MG_Backend/BackendObjects.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/Converters/GLToGlslang/ProgramEnumConverter.h>
#include <atomic>
#include <cctype>
#include <cstdlib>
#include <mutex>
namespace MobileGL {
namespace MG_Util {
@@ -354,16 +360,215 @@ namespace MobileGL {
return allSpirv;
}
// -1 unresolved, 0 off, 1 on. Resolved once from MOBILEGL_VALIDATE_SPIRV on first
// use. A live getenv rather than an MG_Config::Features field, for the same reason
// Config.h already exempts MOBILEGL_LOG_FILE_PATH: suites like SpirvPassTest never
// run MobileGL::Initialize(), and every Initialize() re-runs MG_ConfigLoader::Init,
// which would clobber a programmatic override stored in the feature table.
static std::atomic<int> g_validateSpirv{-1};
// Total validation failures observed this process. This latch - not the wrappers'
// return values - is the test-lane signal: validation must never change what a
// wrapper returns, or the validating lanes would render differently from the
// shipping configuration (fail-open call sites would silently substitute an
// earlier-stage module).
static std::atomic<Uint64> g_spirvValidationFailures{0};
namespace {
// Test lanes (desktop/CI/WSL) validate by default; device builds do not -
// validation costs real time per module, and on device the driver is the
// final validator anyway. MOBILEGL_VALIDATE_SPIRV overrides in either
// direction, using the ConfigLoader truthy rule.
constexpr bool kValidateSpirvDefault =
#if defined(__ANDROID__)
false;
#else
true;
#endif
bool IsTruthySpirvEnvValue(const char* value) {
if (value == nullptr || value[0] == '\0') {
return false;
}
String lowered(value);
for (auto& c : lowered) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
return lowered != "0" && lowered != "false";
}
// spirv-tools' validator lazily constructs function-local static tables on
// its first run, which on this codebase happens on a ShaderCompilePool
// worker. Function-local statics are destroyed in reverse construction
// order, so those tables would die BEFORE the pool's own atexit sentinel
// (registered at first pool use) gets to drain the workers - and a worker
// mid-Validate would then read freed memory during process exit. Pin the
// order instead: force the tables into existence now, then register a
// second drain handler; being registered after the tables' destructors, it
// runs before them.
void PinValidatorTablesForProcessExit() {
static std::once_flag pinnedOnce;
std::call_once(pinnedOnce, [] {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
Vector<Uint32> warmup;
// The module is shaped to reach BOTH lazily-constructed tables in
// the vendored validate_id.cpp: a type-generating operand pins
// InstructionCanHaveTypeOperand's allow-set, and the OpExtInst use
// of the TYPELESS %glsl import is the one path into
// InstructionRequiresTypeOperand's deny-set (its call site is
// guarded on a referenced def with no result type). A straight-line
// module without it leaves the deny-set to be built later on a pool
// worker, re-creating the exit-order hazard for that one table.
if (tools.Assemble("OpCapability Shader\n"
"%glsl = OpExtInstImport \"GLSL.std.450\"\n"
"OpMemoryModel Logical GLSL450\n"
"OpEntryPoint GLCompute %main \"main\"\n"
"OpExecutionMode %main LocalSize 1 1 1\n"
"%void = OpTypeVoid\n"
"%fn = OpTypeFunction %void\n"
"%float = OpTypeFloat 32\n"
"%c = OpConstant %float 1\n"
"%main = OpFunction %void None %fn\n"
"%entry = OpLabel\n"
"%abs = OpExtInst %float %glsl FAbs %c\n"
"OpReturn\n"
"OpFunctionEnd\n",
&warmup)) {
tools.Validate(warmup);
}
std::atexit(+[] {
// Flip validation off first: a validator table this warmup does
// not know about (a future spirv-tools bump) would still be
// destroyed before this handler, and workers must stop entering
// Validate before the drain waits for them.
g_validateSpirv.store(0, std::memory_order_release);
Async::ShaderCompilePool::StopAndDrainProcessPoolAtExit();
});
});
}
spvtools::MessageConsumer MakeSpirvMessageConsumer(const char* site) {
return [site](spv_message_level_t level, const char* /*source*/,
const spv_position_t& position, const char* message) {
const char* text = message ? message : "";
switch (level) {
case SPV_MSG_FATAL:
case SPV_MSG_INTERNAL_ERROR:
case SPV_MSG_ERROR:
// MGLOG_I, deliberately: at the INFO compile level of every
// CI/WSL/retrace build, MGLOG_E and MGLOG_W are compiled out
// (Log.h orders DEBUG < WARN < ERROR < INFO) and the VUID
// would never reach a log.
MGLOG_I("[spirv] %s: %s (word index %zu)", site, text, position.index);
break;
default:
MGLOG_D("[spirv] %s: %s", site, text);
break;
}
};
}
// Validation is decoupled from control flow on purpose: a failure logs and
// bumps the latch, and the caller proceeds exactly as the shipping (non-
// validating) configuration would. Tests assert on the latch delta.
void ValidateOrLatch(const char* site, const Vector<Uint32>& binary) {
if (!ShaderCompiler::SpirvValidationEnabled()) {
return;
}
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
tools.SetMessageConsumer(MakeSpirvMessageConsumer(site));
if (!tools.Validate(binary)) {
MGLOG_I("[spirv] %s: produced a module that fails validation (failure #%llu)",
site,
static_cast<unsigned long long>(
ShaderCompiler::NoteSpirvValidationFailure()));
}
}
// Shared tail for every Optimizer wrapper in this file. The optimizer's own
// input validator stays off even in validating lanes, for two reasons: its
// failure is indistinguishable from a transform failure (Optimizer::Run
// returns false before BuildModule), and the FIRST wrapper's input is
// glslang output that is legitimately not Vulkan-clean yet. What gets
// validated is each wrapper's OUTPUT - the only bytes a driver can ever
// receive. The message consumer is installed unconditionally: without one,
// spirv-tools drops pass diagnostics on the floor.
bool RunOptimizerChecked(const char* site, spvtools::Optimizer& optimizer,
const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
spvtools::OptimizerOptions options;
options.set_run_validator(false);
optimizer.SetMessageConsumer(MakeSpirvMessageConsumer(site));
if (!optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options)) {
return false;
}
ValidateOrLatch(site, outputBinary);
return true;
}
} // namespace
bool ShaderCompiler::SpirvValidationEnabled() {
int state = g_validateSpirv.load(std::memory_order_acquire);
if (state < 0) {
const char* env = std::getenv("MOBILEGL_VALIDATE_SPIRV");
const bool resolved = env != nullptr ? IsTruthySpirvEnvValue(env) : kValidateSpirvDefault;
int expected = -1;
g_validateSpirv.compare_exchange_strong(expected, resolved ? 1 : 0,
std::memory_order_acq_rel);
state = g_validateSpirv.load(std::memory_order_acquire);
if (state == 1) {
PinValidatorTablesForProcessExit();
}
}
return state == 1;
}
void ShaderCompiler::SetSpirvValidationEnabled(bool enabled) {
g_validateSpirv.store(enabled ? 1 : 0, std::memory_order_release);
if (enabled) {
PinValidatorTablesForProcessExit();
}
}
Uint64 ShaderCompiler::NoteSpirvValidationFailure() {
return g_spirvValidationFailures.fetch_add(1, std::memory_order_relaxed) + 1;
}
Uint64 ShaderCompiler::SpirvValidationFailureCount() {
return g_spirvValidationFailures.load(std::memory_order_relaxed);
}
bool ShaderCompiler::SanitizeAndOptimizeBinary(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
// ADCE refuses to treat a Private global as deletable while the entry point
// still contains any OpFunctionCall (IsLocalVar -> IsEntryPointWithNoCalls), so
// a dead vertex input feeding a never-read Private shim used to survive the
// whole chain (the Chocapic13 shadow.vsh mc_midTexCoord/iris_MidTex case).
// Rewriting entry-point-owned Private variables to Function storage first
// satisfies ADCE without inlining: over 521 real Iris modules the rewrite
// captured 17 of the 21 extra dead interface variables exhaustive inlining
// would, while shrinking the corpus 8% - inlining grew it 20% with a 5.3x
// worst-case module and no additional GPU-side benefit.
optimizer.RegisterPass(PrivateToEntryLocalPass::CreatePrivateToEntryLocalPass());
// Keep the one-arg overload: remove_outputs must stay false, forever. Output
// variables on the entry-point interface are ADCE's only unconditional live
// roots; XFB capture resolves varyings by OpName after this chain, and the
// VS-out/FS-in interface contract on both backends depends on declared outputs
// surviving even when never stored.
optimizer.RegisterPass(CreateAggressiveDCEPass(false));
// Complementary to ADCE, not redundant: ADCE can never delete or delist an
// Output (see above), so never-written outputs are trimmed from the
// OpEntryPoint operand list here.
optimizer.RegisterPass(CreateRemoveUnusedInterfaceVariablesPass());
// The two module-legality repairs, so the chain's output - the bytes every
// consumer downstream sees - is valid Vulkan SPIR-V. Rect lowering used to
// live only in the backends; a validating lane would flag every rectangle
// module long before the backend got the chance to fix it, and the backend
// calls remain as no-ops on the now rect-free modules.
optimizer.RegisterPass(NormalizeRectCoordinatesPass::CreateNormalizeRectCoordinatesPass());
optimizer.RegisterPass(StripUniformLocationsPass::CreateStripUniformLocationsPass());
optimizer.RegisterPass(FlattenInterfaceStructPass::CreateFlattenInterfaceStructPass());
optimizer.RegisterPass(RenameSamplerFunctionParameterPass::CreateRenameSamplerFunctionParameterPass());
optimizer.RegisterPass(
@@ -371,104 +576,88 @@ namespace MobileGL {
optimizer.RegisterPass(EliminateFloatEqualsZeroPass::CreateEliminateFloatEqualsZeroPass());
optimizer.RegisterPass(DecomposeWorkgroupVec3Pass::CreateDecomposeWorkgroupVec3Pass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("SanitizeAndOptimizeBinary", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::LowerDrawParametersForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(LowerDrawParametersPass::CreateLowerDrawParametersPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("LowerDrawParametersForEssl", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::PackDoubleVertexInputsForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(PackDoubleVertexInputsPass::CreatePackDoubleVertexInputsPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("PackDoubleVertexInputsForVulkan", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::StripUboMemberRelaxedPrecisionForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(
StripUboMemberRelaxedPrecisionPass::CreateStripUboMemberRelaxedPrecisionPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("StripUboMemberRelaxedPrecisionForEssl", optimizer,
inputBinary, outputBinary);
}
bool ShaderCompiler::StripNoPerspectiveForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(StripNoPerspectivePass::CreateStripNoPerspectivePass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("StripNoPerspectiveForEssl", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::EmulateNoPerspectiveForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(EmulateNoPerspectivePass::CreateEmulateNoPerspectivePass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("EmulateNoPerspectiveForEssl", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::LowerRectImages(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(NormalizeRectCoordinatesPass::CreateNormalizeRectCoordinatesPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("LowerRectImages", optimizer, inputBinary, outputBinary);
}
bool ShaderCompiler::RebaseInstanceIndexForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(RebaseInstanceIndexPass::CreateRebaseInstanceIndexPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("RebaseInstanceIndexForVulkan", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::DecoratePositionInvariantForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(DecoratePositionInvariantPass::CreateDecoratePositionInvariantPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("DecoratePositionInvariantForVulkan", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::UseUnformattedFloatStorageImagesForVulkan(
@@ -598,6 +787,9 @@ namespace MobileGL {
}
outputBinary.insert(outputBinary.begin() + static_cast<std::ptrdiff_t>(capabilityInsertOffset),
addedCapabilities.begin(), addedCapabilities.end());
// Hand-rolled word walk, so no Optimizer wrapper ever sees this rewrite;
// check the modified module explicitly in validating lanes.
ValidateOrLatch("UseUnformattedFloatStorageImagesForVulkan", outputBinary);
return true;
}
@@ -101,6 +101,27 @@ namespace MobileGL {
// it, the second eglInitialize of a process comes back up unwarmed and with
// no way left to warm it.
static void ResetPrewarmLatch();
// Test-environment SPIR-V validation. When enabled, every Optimizer wrapper
// in this file validates its OUTPUT binary - the bytes a driver can actually
// receive - and a failure logs the VUID (via MGLOG_I; see the consumer for
// why not MGLOG_E) and bumps the failure latch below WITHOUT changing the
// wrapper's return value: control flow must stay identical between the
// validating and shipping configurations, or fail-open call sites would make
// the two render differently. Resolved lazily from MOBILEGL_VALIDATE_SPIRV;
// defaults on for desktop/CI/WSL builds and off for device (__ANDROID__)
// builds. The setter wins over the environment and is safe to call from test
// fixtures at any time.
static bool SpirvValidationEnabled();
static void SetSpirvValidationEnabled(bool enabled);
// The test-lane enforcement signal: total validation failures observed this
// process. Tests snapshot it, run the operation under scrutiny, and assert
// on the delta. NoteSpirvValidationFailure is for validation done outside
// this file (ProgramFactory::ValidateTransformedSpirv); it returns the new
// total.
static Uint64 SpirvValidationFailureCount();
static Uint64 NoteSpirvValidationFailure();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
@@ -0,0 +1,250 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.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
//
// Derived from SPIRV-Tools' PrivateToLocalPass (source/opt/private_to_local_pass.cpp,
// Copyright (c) 2017 Google Inc., Apache License 2.0). The one behavioral difference is
// the entry-point restriction in FindEntryLocalFunction; see the header for why.
#include "PrivateToEntryLocalPass.h"
#include "source/opt/ir_context.h"
#include "source/opt/type_manager.h"
#include "source/spirv_constant.h"
#include "source/util/make_unique.h"
#include <cassert>
#include <utility>
#include <vector>
#include <unordered_set>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::BasicBlock;
using spvtools::opt::Function;
using spvtools::opt::Instruction;
using spvtools::opt::Operand;
constexpr uint32_t kVariableStorageClassInIdx = 0;
constexpr uint32_t kSpvTypePointerTypeIdInIdx = 1;
} // namespace
spvtools::opt::Pass::Status PrivateToEntryLocalPass::Process() {
// Private variables require the Shader capability; with Addresses the
// rewrite below is not guaranteed sound (variable pointers may escape).
if (context()->get_feature_mgr()->HasCapability(spv::Capability::Addresses)) {
return Status::SuccessWithoutChange;
}
std::vector<std::pair<Instruction*, Function*>> variablesToMove;
std::unordered_set<uint32_t> localizedVariables;
for (auto& inst : context()->types_values()) {
if (inst.opcode() != spv::Op::OpVariable) {
continue;
}
if (spv::StorageClass(inst.GetSingleWordInOperand(kVariableStorageClassInIdx)) !=
spv::StorageClass::Private) {
continue;
}
Function* targetFunction = FindEntryLocalFunction(inst);
if (targetFunction != nullptr) {
variablesToMove.push_back({&inst, targetFunction});
}
}
const bool modified = !variablesToMove.empty();
for (auto& p : variablesToMove) {
if (!MoveVariable(p.first, p.second)) {
return Status::Failure;
}
localizedVariables.insert(p.first->result_id());
}
if (get_module()->version() >= SPV_SPIRV_VERSION_WORD(1, 4)) {
// SPIR-V 1.4+ lists statically-used Private variables on OpEntryPoint;
// drop the ones that just stopped being Private. Dead code for the 1.3
// modules MobileGL emits, kept for robustness.
for (auto& entry : get_module()->entry_points()) {
std::vector<Operand> newOperands;
for (uint32_t i = 0; i < entry.NumInOperands(); ++i) {
// Execution model, function id and name are always kept.
if (i < 3 || !localizedVariables.count(entry.GetSingleWordInOperand(i))) {
newOperands.push_back(entry.GetInOperand(i));
}
}
if (newOperands.size() != entry.NumInOperands()) {
entry.SetInOperands(std::move(newOperands));
context()->AnalyzeUses(&entry);
}
}
}
return modified ? Status::SuccessWithChange : Status::SuccessWithoutChange;
}
Function* PrivateToEntryLocalPass::FindEntryLocalFunction(const Instruction& inst) const {
bool foundFirstUse = false;
Function* targetFunction = nullptr;
const uint32_t variableId = inst.result_id();
context()->get_def_use_mgr()->ForEachUser(
variableId, [&targetFunction, &foundFirstUse, variableId, this](Instruction* use) {
BasicBlock* currentBlock = context()->get_instr_block(use);
if (currentBlock == nullptr) {
// Module-scope users: OpName, decorations, the OpEntryPoint
// interface list. None of them pins the variable to a function,
// but a debug-info extended instruction would go stale after the
// move, so treat it as disqualifying.
if (use->opcode() == spv::Op::OpExtInst) {
foundFirstUse = true;
targetFunction = nullptr;
}
return;
}
if (!IsValidUse(use, variableId)) {
foundFirstUse = true;
targetFunction = nullptr;
return;
}
Function* currentFunction = currentBlock->GetParent();
if (!foundFirstUse) {
foundFirstUse = true;
targetFunction = currentFunction;
} else if (targetFunction != currentFunction) {
targetFunction = nullptr;
}
});
if (targetFunction != nullptr && !IsEntryPointFunction(targetFunction)) {
// The whole point of this derivative: a helper can be called more than
// once per invocation, and Function storage would reset the variable at
// every call.
return nullptr;
}
return targetFunction;
}
bool PrivateToEntryLocalPass::IsEntryPointFunction(Function* function) const {
for (auto& entry : get_module()->entry_points()) {
if (entry.GetSingleWordInOperand(1) == function->result_id()) {
return true;
}
}
return false;
}
bool PrivateToEntryLocalPass::IsValidUse(const Instruction* inst, uint32_t variableId) const {
// The cases here have to match the cases in UpdateUse: a use the rewrite
// does not know how to update disqualifies the variable.
switch (inst->opcode()) {
case spv::Op::OpLoad:
case spv::Op::OpImageTexelPointer: // treat like a load
return true;
case spv::Op::OpStore:
// Storing the variable's ADDRESS somewhere else escapes it.
return inst->GetOperand(1).AsId() != variableId;
case spv::Op::OpAccessChain:
return context()->get_def_use_mgr()->WhileEachUser(
inst, [this, inst](const Instruction* user) {
return IsValidUse(user, inst->result_id());
});
case spv::Op::OpName:
return true;
default:
return spvOpcodeIsDecoration(inst->opcode());
}
}
bool PrivateToEntryLocalPass::MoveVariable(Instruction* variable, Function* function) {
// Remove from the global section and re-insert at the head of the entry
// function's first block, Function-storage variables' one legal position.
variable->RemoveFromList();
std::unique_ptr<Instruction> var(variable); // take ownership
context()->ForgetUses(variable);
variable->SetInOperand(kVariableStorageClassInIdx,
{uint32_t(spv::StorageClass::Function)});
const uint32_t newTypeId = GetNewType(variable->type_id());
if (newTypeId == 0) {
return false;
}
variable->SetResultType(newTypeId);
context()->AnalyzeUses(variable);
context()->set_instr_block(variable, &*function->begin());
function->begin()->begin()->InsertBefore(std::move(var));
return UpdateUses(variable);
}
uint32_t PrivateToEntryLocalPass::GetNewType(uint32_t oldTypeId) {
auto* typeMgr = context()->get_type_mgr();
Instruction* oldTypeInst = get_def_use_mgr()->GetDef(oldTypeId);
const uint32_t pointeeTypeId =
oldTypeInst->GetSingleWordInOperand(kSpvTypePointerTypeIdInIdx);
const uint32_t newTypeId =
typeMgr->FindPointerToType(pointeeTypeId, spv::StorageClass::Function);
if (newTypeId != 0) {
context()->UpdateDefUse(context()->get_def_use_mgr()->GetDef(newTypeId));
}
return newTypeId;
}
bool PrivateToEntryLocalPass::UpdateUse(Instruction* inst, Instruction* user) {
// The cases here have to match the cases in IsValidUse.
switch (inst->opcode()) {
case spv::Op::OpLoad:
case spv::Op::OpStore:
case spv::Op::OpImageTexelPointer: // treat like a load
// Fine as-is: their type is the pointed-to type, which is unchanged.
break;
case spv::Op::OpAccessChain: {
context()->ForgetUses(inst);
const uint32_t newTypeId = GetNewType(inst->type_id());
if (newTypeId == 0) {
return false;
}
inst->SetResultType(newTypeId);
context()->AnalyzeUses(inst);
if (!UpdateUses(inst)) {
return false;
}
break;
}
case spv::Op::OpName:
case spv::Op::OpEntryPoint: // handled separately in Process()
break;
default:
assert(spvOpcodeIsDecoration(inst->opcode()) &&
"PrivateToEntryLocalPass: unexpected use opcode");
break;
}
(void)user;
return true;
}
bool PrivateToEntryLocalPass::UpdateUses(Instruction* inst) {
const uint32_t id = inst->result_id();
std::vector<Instruction*> uses;
context()->get_def_use_mgr()->ForEachUser(id,
[&uses](Instruction* use) { uses.push_back(use); });
for (Instruction* use : uses) {
if (!UpdateUse(use, inst)) {
return false;
}
}
return true;
}
spvtools::Optimizer::PassToken PrivateToEntryLocalPass::CreatePrivateToEntryLocalPass() {
return spvtools::Optimizer::PassToken(
spvtools::MakeUnique<PrivateToEntryLocalPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,55 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.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
#pragma once
#include "spirv-tools/optimizer.hpp"
#include "source/opt/pass.h"
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// AggressiveDCE treats every store to a Private global as an observable side
// effect while the entry point still contains any OpFunctionCall, so a dead
// vertex-input -> Private-shim chain (Iris rewrites unused legacy attributes
// into exactly this shape) survives the whole optimizer chain. Rewriting such
// a variable to Function storage unlocks ADCE without inlining anything.
//
// Upstream's PrivateToLocalPass does that rewrite for a Private variable used
// in ANY single function - which is unsound here: a Function-storage variable
// is recreated on every call, so a Private global that carries state across
// repeated calls of one helper (a memoized init flag, LCG rand state) would
// silently lose it. This derivative applies the same rewrite restricted to
// variables whose only using function is an entry point: an entry point runs
// once per invocation, so the two lifetimes are indistinguishable there.
//
// Derived from SPIRV-Tools' PrivateToLocalPass
// (source/opt/private_to_local_pass.cpp, Copyright (c) 2017 Google Inc.,
// Apache License 2.0).
class PrivateToEntryLocalPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-private-to-entry-local"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreatePrivateToEntryLocalPass();
private:
// The single entry-point function every block-level use of the variable
// lives in, or nullptr when the uses span functions, include an opcode the
// rewrite cannot update, or belong to a non-entry function.
spvtools::opt::Function* FindEntryLocalFunction(const spvtools::opt::Instruction& inst) const;
bool IsEntryPointFunction(spvtools::opt::Function* function) const;
bool IsValidUse(const spvtools::opt::Instruction* inst, uint32_t variableId) const;
bool MoveVariable(spvtools::opt::Instruction* variable, spvtools::opt::Function* function);
uint32_t GetNewType(uint32_t oldTypeId);
bool UpdateUse(spvtools::opt::Instruction* inst, spvtools::opt::Instruction* user);
bool UpdateUses(spvtools::opt::Instruction* inst);
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,59 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.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 "StripUniformLocationsPass.h"
#include "source/opt/ir_context.h"
#include "source/util/make_unique.h"
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
spvtools::opt::Pass::Status StripUniformLocationsPass::Process() {
using spvtools::opt::Instruction;
std::vector<Instruction*> toKill;
for (auto& annotation : get_module()->annotations()) {
if (annotation.opcode() != spv::Op::OpDecorate) {
continue;
}
if (annotation.GetSingleWordInOperand(1) !=
static_cast<uint32_t>(spv::Decoration::Location)) {
continue;
}
Instruction* target =
get_def_use_mgr()->GetDef(annotation.GetSingleWordInOperand(0));
if (target == nullptr || target->opcode() != spv::Op::OpVariable) {
continue;
}
switch (spv::StorageClass(target->GetSingleWordInOperand(0))) {
case spv::StorageClass::UniformConstant:
case spv::StorageClass::Uniform:
case spv::StorageClass::StorageBuffer:
toKill.push_back(&annotation);
break;
default:
break;
}
}
for (Instruction* inst : toKill) {
context()->KillInst(inst);
}
return toKill.empty() ? Status::SuccessWithoutChange : Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken StripUniformLocationsPass::CreateStripUniformLocationsPass() {
return spvtools::Optimizer::PassToken(
spvtools::MakeUnique<StripUniformLocationsPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,32 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.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
#pragma once
#include "spirv-tools/optimizer.hpp"
#include "source/opt/pass.h"
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// glslang's relaxed GL path keeps `layout(location = N) uniform ...` as a
// Location decoration on the UniformConstant/Uniform variable, which Vulkan
// forbids ([VUID-StandaloneSpirv-Location-06672]). Nothing downstream reads
// it: GL-side uniform locations come from the phase-A glslang reflection,
// Vulkan binding assignment goes by (kind, name), and SPIRV-Cross's ESSL
// resolves uniforms by name. Strip it so the driver-bound module is valid.
class StripUniformLocationsPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-strip-uniform-locations"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateStripUniformLocationsPass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -131,6 +131,11 @@ bool LoadMobileGL(const Request& request, std::string& error) {
setenv("MOBILEGL_BACKEND_TYPE", request.backend.c_str(), 1);
setenv("MOBILEGL_TRACE_LIBRARY", request.mobileGlLibrary.c_str(), 1);
setenv("MOBILEGL_TRACE_SKIP_AUTODESTROY", "1", 1);
// Retrace is a test lane on every platform, including the Android AVD one where
// MobileGL's __ANDROID__ default would leave validation off. No overwrite: an outer
// MOBILEGL_VALIDATE_SPIRV=0 must keep working as the escape hatch, and retracing the
// exact shipping pipeline must stay possible.
setenv("MOBILEGL_VALIDATE_SPIRV", "1", 0);
setenv("MOBILEGL_TRACE_SURFACE", request.usePbuffer ? "pbuffer" : "window", 1);
if (request.backend == "DirectVulkan") {
setenv("MOBILEGL_MAGMA_R11G11B10F_FALLBACK", "1", 1);