[Feat] (MG_Util/SelfTest, android-plugin): driver POST self-test screen

Opening a MobileGL plugin APK now shows a POST screen that probes the
device's GLES and Vulkan drivers independently against MobileGL's
expectations - a device may satisfy only one backend - and reports a
per-backend verdict (OK / DEGRADED / UNSUPPORTED) with per-check rows.

The GLES probe builds its own ES3 pbuffer context on the system driver
and reuses FillInGLESCapabilities, including the indirect-draw
gl_InstanceID semantics probe; the Vulkan probe checks instance/device
requirements and the optional features each DirectVulkan path degrades
without. Results serialize as ASCII-safe JSON through a JNI entry in
libMobileGL.so; PostActivity renders them and caches the run per
process (single-flight, rotation-safe). PluginActivity keeps its
NoDisplay stub but the launcher entry moves to the POST screen; FCL
plugin discovery reads application meta-data and is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 13:44:14 +00:00
co-authored by Claude Fable 5
parent 28facc1c3f
commit d16b7ccd6a
7 changed files with 1078 additions and 2 deletions
+1
View File
@@ -23,4 +23,5 @@ MobileGL/MG*/cmake-build*
/android-plugin/build
/android-plugin/app/build
/android-plugin/app/src/trace/jniLibs
/android-plugin/local.properties
tools/trace_replay/work/
+8
View File
@@ -193,6 +193,8 @@ set(SOURCE_FILES
MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp
MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp
MobileGL/MG_Util/SelfTest/DriverPost.cpp
MobileGL/MG_Util/Texture/PixelStoreProcessor.cpp
MobileGL/MG_Util/Texture/TextureFormatProcessor.cpp
@@ -288,6 +290,12 @@ if (APPLE AND NOT MOBILEGL_IOS)
)
endif()
if (ANDROID)
list(APPEND SOURCE_FILES
MobileGL/MG_Util/SelfTest/DriverPostJni.cpp
)
endif()
set(MOBILEGL_LINK_LIBRARIES
glslang::glslang
spirv-cross-c
+582
View File
@@ -0,0 +1,582 @@
// MobileGL - MobileGL/MG_Util/SelfTest/DriverPost.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 "DriverPost.h"
#include "MG_Util/BackendLoaders/OpenGL/Loader.h"
#if !defined(_WIN32)
#include <dlfcn.h>
#endif
namespace MobileGL::MG_Util::SelfTest {
namespace {
struct ReportBuilder {
BackendPostReport report;
Bool fatalFailed = false;
Bool warnUnmet = false;
void Pass(String name, String detail) { report.checks.push_back({Move(name), "PASS", Move(detail)}); }
void Fail(String name, String detail) {
fatalFailed = true;
report.checks.push_back({Move(name), "FAIL", Move(detail)});
}
void Warn(String name, String detail) {
warnUnmet = true;
report.checks.push_back({Move(name), "WARN", Move(detail)});
}
void Info(String name, String detail) { report.checks.push_back({Move(name), "INFO", Move(detail)}); }
void Finalize() { report.verdict = fatalFailed ? "UNSUPPORTED" : (warnUnmet ? "DEGRADED" : "OK"); }
};
// Runs a callable when the enclosing scope exits, so driver teardown still happens
// even if a String/format allocation throws while report rows are being built.
template <typename Callable>
struct ScopeGuard {
explicit ScopeGuard(Callable callable) : onExit(Move(callable)) {}
ScopeGuard(const ScopeGuard&) = delete;
ScopeGuard& operator=(const ScopeGuard&) = delete;
~ScopeGuard() { onExit(); }
private:
Callable onExit;
};
String EGLErrorSuffix(const MG_External::EGLFunctionsTable& eglFuncs) {
if (!eglFuncs.eglGetError) {
return "";
}
return format(" (EGL error 0x{:x})", eglFuncs.eglGetError());
}
void EvaluateGlesChecklist(ReportBuilder& builder, const MG_External::GLESCapabilities& caps,
const MG_External::GLESFunctionsTable& glesFuncs) {
const Int major = caps.GLESVersion.Major;
const Int minor = caps.GLESVersion.Minor;
const Bool es31 = major > 3 || (major == 3 && minor >= 1);
const Bool es32 = major > 3 || (major == 3 && minor >= 2);
const String versionLabel = format("OpenGL ES {}.{}", major, minor);
if (es32) {
builder.Pass("OpenGL ES version", versionLabel + " (>= 3.2, full native feature set)");
} else if (es31) {
builder.Warn("OpenGL ES version",
versionLabel +
" (compute shaders and native indirect draws available; ES 3.2 is recommended)");
} else {
builder.Fail("OpenGL ES version",
versionLabel + " (< 3.1: no compute shaders or native indirect draws)");
}
if (es31) {
GLint maxVertexSsboBlocks = 0;
glesFuncs.glGetIntegerv(GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, &maxVertexSsboBlocks);
while (glesFuncs.glGetError && glesFuncs.glGetError() != GL_NO_ERROR) {
}
if (maxVertexSsboBlocks >= 1) {
builder.Pass("Vertex shader storage blocks",
format("GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS = {}", maxVertexSsboBlocks));
} else {
builder.Warn("Vertex shader storage blocks",
format("GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS = {}; the Flywheel/Create indirect draw "
"machinery cannot read indirect command buffers from the vertex stage",
maxVertexSsboBlocks));
}
if (caps.MaxShaderStorageBufferBindings >= 8) {
builder.Pass("Shader storage buffer bindings",
format("GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS = {} (the last binding is reserved "
"for mg_IndirectParams)",
caps.MaxShaderStorageBufferBindings));
} else {
builder.Warn("Shader storage buffer bindings",
format("GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS = {} (< 8); reserving the last "
"binding for mg_IndirectParams leaves little room for app SSBOs",
caps.MaxShaderStorageBufferBindings));
}
}
builder.Info("GL_EXT_buffer_storage",
caps.SupportsPersistentMapping ? "supported (persistent buffer mapping)" : "not supported");
builder.Info("GL_EXT_base_instance",
caps.SupportsBaseInstance ? "supported (native baseInstance draws)" : "not supported");
if (caps.SupportsNorm16Texture) {
builder.Pass("GL_EXT_texture_norm16", "supported");
} else {
builder.Warn("GL_EXT_texture_norm16",
"not supported; 16-bit normalized texture formats need emulation");
}
builder.Info("Indirect gl_InstanceID semantics",
caps.IndirectDrawInstanceIdIncludesBaseInstance
? "includes baseInstance (ANGLE-style; MobileGL's shader rewrite keeps gl_InstanceID "
"zero-based)"
: "conforming (zero-based)");
builder.Info("GL_VENDOR", caps.GLESVendorString);
builder.Info("GL_RENDERER", caps.GLESRendererString);
builder.Info("GL_VERSION", caps.GLESVersionString);
}
} // namespace
BackendPostReport RunGlesDriverPost() {
MGLOG_I("Driver POST: probing the device GLES driver");
ReportBuilder builder;
MG_External::EGLFunctionsTable eglFuncs{};
BackendLoader::AcquireEGLFunctions(eglFuncs);
const Bool eglLoaded = eglFuncs.eglGetDisplay && eglFuncs.eglInitialize && eglFuncs.eglBindAPI &&
eglFuncs.eglChooseConfig && eglFuncs.eglCreatePbufferSurface &&
eglFuncs.eglCreateContext && eglFuncs.eglMakeCurrent && eglFuncs.eglDestroySurface &&
eglFuncs.eglDestroyContext && eglFuncs.eglTerminate && eglFuncs.eglGetProcAddress;
if (!eglLoaded) {
builder.Fail("EGL library", "libEGL.so or one of its required entry points is missing");
builder.Finalize();
return builder.report;
}
builder.Pass("EGL library", "libEGL.so loaded with all required entry points");
EGLDisplay display = eglFuncs.eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (display == EGL_NO_DISPLAY) {
builder.Fail("EGL display", "eglGetDisplay returned EGL_NO_DISPLAY");
builder.Finalize();
return builder.report;
}
EGLint eglMajor = 0;
EGLint eglMinor = 0;
if (!eglFuncs.eglInitialize(display, &eglMajor, &eglMinor)) {
builder.Fail("EGL display",
"eglInitialize failed on the default display" + EGLErrorSuffix(eglFuncs));
builder.Finalize();
return builder.report;
}
builder.Pass("EGL display", format("EGL {}.{} initialized on the default display", eglMajor, eglMinor));
builder.report.available = true;
EGLSurface surface = EGL_NO_SURFACE;
EGLContext context = EGL_NO_CONTEXT;
const ScopeGuard eglTeardown([&]() {
eglFuncs.eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (surface != EGL_NO_SURFACE) {
eglFuncs.eglDestroySurface(display, surface);
}
if (context != EGL_NO_CONTEXT) {
eglFuncs.eglDestroyContext(display, context);
}
// eglTerminate is deliberately not called: the probe shares EGL_DEFAULT_DISPLAY with
// the process UI renderer (HWUI), and terminating it can invalidate the UI's EGL
// objects on pre-refcounting Android builds. Unbinding and destroying our own
// surface/context is sufficient cleanup.
});
do {
if (!eglFuncs.eglBindAPI(EGL_OPENGL_ES_API)) {
builder.Fail("OpenGL ES API bind", "eglBindAPI(EGL_OPENGL_ES_API) failed" + EGLErrorSuffix(eglFuncs));
break;
}
builder.Pass("OpenGL ES API bind", "eglBindAPI(EGL_OPENGL_ES_API) succeeded");
const EGLint configAttribs[] = {EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8,
EGL_NONE};
EGLConfig config = nullptr;
EGLint numConfigs = 0;
if (!eglFuncs.eglChooseConfig(display, configAttribs, &config, 1, &numConfigs)) {
builder.Fail("ES3 RGBA8888 pbuffer config", "eglChooseConfig failed" + EGLErrorSuffix(eglFuncs));
break;
}
if (numConfigs < 1) {
// No EGL error suffix here: eglChooseConfig succeeded, so it would read EGL_SUCCESS.
builder.Fail("ES3 RGBA8888 pbuffer config", "no ES3-capable RGBA8888 pbuffer config");
break;
}
builder.Pass("ES3 RGBA8888 pbuffer config", "ES3-renderable RGBA8888 pbuffer config found");
const EGLint surfaceAttribs[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
surface = eglFuncs.eglCreatePbufferSurface(display, config, surfaceAttribs);
if (surface == EGL_NO_SURFACE) {
builder.Fail("1x1 pbuffer surface",
"eglCreatePbufferSurface failed" + EGLErrorSuffix(eglFuncs));
break;
}
builder.Pass("1x1 pbuffer surface", "probe surface created");
const EGLint contextAttribs[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
context = eglFuncs.eglCreateContext(display, config, EGL_NO_CONTEXT, contextAttribs);
if (context == EGL_NO_CONTEXT) {
builder.Fail("OpenGL ES 3 context", "eglCreateContext failed" + EGLErrorSuffix(eglFuncs));
break;
}
if (!eglFuncs.eglMakeCurrent(display, surface, surface, context)) {
builder.Fail("OpenGL ES 3 context", "eglMakeCurrent failed" + EGLErrorSuffix(eglFuncs));
break;
}
builder.Pass("OpenGL ES 3 context", "context created and made current");
MG_External::GLESFunctionsTable glesFuncs{};
BackendLoader::AcquireGLESFunctions(glesFuncs, eglFuncs.eglGetProcAddress);
MG_External::GLESCapabilities caps{};
if (!BackendLoader::FillInGLESCapabilities(caps, glesFuncs)) {
builder.Fail("GLES capability query",
"required GLES entry points could not be resolved through eglGetProcAddress");
break;
}
builder.report.rendererInfo = format("{} ({})", caps.GLESRendererString, caps.GLESVersionString);
EvaluateGlesChecklist(builder, caps, glesFuncs);
} while (false);
builder.Finalize();
MGLOG_I("Driver POST: GLES verdict = %s", builder.report.verdict.c_str());
return builder.report;
}
namespace {
// The Vulkan loader is bootstrapped through dlopen + vkGetInstanceProcAddr instead of
// static linking so the POST also works in build configurations that do not link a
// Vulkan loader (and degrades gracefully when the device ships none). The library
// handle is intentionally never closed: Android Vulkan ICDs may register threads and
// state that do not survive unloading, and the loader stays resident for the real
// backend anyway.
void* OpenVulkanLoaderLibrary() {
#if defined(_WIN32)
return reinterpret_cast<void*>(LoadLibraryA("vulkan-1.dll"));
#else
static const char* const LoaderNames[] = {
#if defined(__APPLE__)
"libvulkan.dylib",
"libvulkan.1.dylib",
"libMoltenVK.dylib",
#else
"libvulkan.so.1",
"libvulkan.so",
#endif
};
for (const char* name : LoaderNames) {
if (void* library = dlopen(name, RTLD_LOCAL | RTLD_NOW)) {
MGLOG_I("Driver POST: loaded Vulkan loader library: %s", name);
return library;
}
}
return nullptr;
#endif
}
void* VulkanLoaderSymbol(void* library, const char* name) {
#if defined(_WIN32)
return reinterpret_cast<void*>(GetProcAddress(reinterpret_cast<HMODULE>(library), name));
#else
return dlsym(library, name);
#endif
}
Bool HasVkExtension(const Vector<VkExtensionProperties>& extensions, const char* name) {
return std::any_of(extensions.begin(), extensions.end(), [name](const VkExtensionProperties& extension) {
return std::strcmp(extension.extensionName, name) == 0;
});
}
String VkApiVersionToString(Uint32 version) {
return format("{}.{}.{}", VK_VERSION_MAJOR(version), VK_VERSION_MINOR(version),
VK_VERSION_PATCH(version));
}
} // namespace
BackendPostReport RunVulkanDriverPost() {
MGLOG_I("Driver POST: probing the device Vulkan driver");
ReportBuilder builder;
void* loaderLibrary = OpenVulkanLoaderLibrary();
if (loaderLibrary == nullptr) {
builder.Fail("Vulkan loader", "libvulkan.so could not be loaded; no Vulkan loader on this device");
builder.Finalize();
return builder.report;
}
const auto getInstanceProcAddr =
reinterpret_cast<PFN_vkGetInstanceProcAddr>(VulkanLoaderSymbol(loaderLibrary, "vkGetInstanceProcAddr"));
if (getInstanceProcAddr == nullptr) {
builder.Fail("Vulkan loader", "vkGetInstanceProcAddr is missing from the Vulkan loader library");
builder.Finalize();
return builder.report;
}
builder.Pass("Vulkan loader", "Vulkan loader library loaded and vkGetInstanceProcAddr resolved");
const auto vkCreateInstanceFn =
reinterpret_cast<PFN_vkCreateInstance>(getInstanceProcAddr(nullptr, "vkCreateInstance"));
const auto vkEnumerateInstanceVersionFn = reinterpret_cast<PFN_vkEnumerateInstanceVersion>(
getInstanceProcAddr(nullptr, "vkEnumerateInstanceVersion"));
const auto vkEnumerateInstanceExtensionPropertiesFn =
reinterpret_cast<PFN_vkEnumerateInstanceExtensionProperties>(
getInstanceProcAddr(nullptr, "vkEnumerateInstanceExtensionProperties"));
Uint32 instanceApiVersion = VK_API_VERSION_1_0;
if (vkEnumerateInstanceVersionFn != nullptr) {
vkEnumerateInstanceVersionFn(&instanceApiVersion);
}
if (vkCreateInstanceFn == nullptr || vkEnumerateInstanceVersionFn == nullptr ||
instanceApiVersion < VK_API_VERSION_1_1) {
builder.Fail("Instance API version",
format("instance API {} (< 1.1); the DirectVulkan backend requires a Vulkan 1.1 instance",
VkApiVersionToString(instanceApiVersion)));
builder.Finalize();
return builder.report;
}
builder.Pass("Instance API version", format("instance API {}", VkApiVersionToString(instanceApiVersion)));
Vector<VkExtensionProperties> instanceExtensions;
if (vkEnumerateInstanceExtensionPropertiesFn != nullptr) {
Uint32 extensionCount = 0;
if (vkEnumerateInstanceExtensionPropertiesFn(nullptr, &extensionCount, nullptr) == VK_SUCCESS &&
extensionCount > 0) {
instanceExtensions.resize(extensionCount);
if (vkEnumerateInstanceExtensionPropertiesFn(nullptr, &extensionCount, instanceExtensions.data()) ==
VK_SUCCESS) {
instanceExtensions.resize(extensionCount);
} else {
instanceExtensions.clear();
}
}
}
if (HasVkExtension(instanceExtensions, VK_KHR_SURFACE_EXTENSION_NAME)) {
builder.Pass("VK_KHR_surface", "instance extension present");
} else {
builder.Fail("VK_KHR_surface", "required instance extension missing; on-screen rendering is impossible");
}
#if defined(VK_USE_PLATFORM_ANDROID_KHR)
if (HasVkExtension(instanceExtensions, VK_KHR_ANDROID_SURFACE_EXTENSION_NAME)) {
builder.Pass("VK_KHR_android_surface", "instance extension present");
} else {
builder.Fail("VK_KHR_android_surface",
"required instance extension missing; ANativeWindow surfaces cannot be created");
}
#endif
// The probe never creates a surface, so the instance is created without extensions.
VkApplicationInfo appInfo{};
appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
appInfo.pApplicationName = "MobileGL Driver POST";
appInfo.pEngineName = "MobileGL";
appInfo.apiVersion = VK_API_VERSION_1_1;
VkInstanceCreateInfo instanceInfo{};
instanceInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
instanceInfo.pApplicationInfo = &appInfo;
VkInstance instance = VK_NULL_HANDLE;
const VkResult createResult = vkCreateInstanceFn(&instanceInfo, nullptr, &instance);
if (createResult != VK_SUCCESS || instance == VK_NULL_HANDLE) {
builder.Fail("Vulkan instance",
format("vkCreateInstance failed (VkResult = {})", static_cast<Int>(createResult)));
builder.Finalize();
return builder.report;
}
builder.Pass("Vulkan instance", "Vulkan 1.1 instance created");
const auto vkDestroyInstanceFn =
reinterpret_cast<PFN_vkDestroyInstance>(getInstanceProcAddr(instance, "vkDestroyInstance"));
const auto vkEnumeratePhysicalDevicesFn = reinterpret_cast<PFN_vkEnumeratePhysicalDevices>(
getInstanceProcAddr(instance, "vkEnumeratePhysicalDevices"));
const auto vkGetPhysicalDevicePropertiesFn = reinterpret_cast<PFN_vkGetPhysicalDeviceProperties>(
getInstanceProcAddr(instance, "vkGetPhysicalDeviceProperties"));
const auto vkGetPhysicalDeviceQueueFamilyPropertiesFn =
reinterpret_cast<PFN_vkGetPhysicalDeviceQueueFamilyProperties>(
getInstanceProcAddr(instance, "vkGetPhysicalDeviceQueueFamilyProperties"));
const auto vkGetPhysicalDeviceFeaturesFn = reinterpret_cast<PFN_vkGetPhysicalDeviceFeatures>(
getInstanceProcAddr(instance, "vkGetPhysicalDeviceFeatures"));
const auto vkEnumerateDeviceExtensionPropertiesFn =
reinterpret_cast<PFN_vkEnumerateDeviceExtensionProperties>(
getInstanceProcAddr(instance, "vkEnumerateDeviceExtensionProperties"));
const auto vkGetPhysicalDeviceFeatures2Fn = reinterpret_cast<PFN_vkGetPhysicalDeviceFeatures2>(
getInstanceProcAddr(instance, "vkGetPhysicalDeviceFeatures2"));
const auto vkGetPhysicalDeviceProperties2Fn = reinterpret_cast<PFN_vkGetPhysicalDeviceProperties2>(
getInstanceProcAddr(instance, "vkGetPhysicalDeviceProperties2"));
// The instance is destroyed from a scope guard so it is released on every early-return
// path and even if a String/format allocation throws while report rows are being built.
const ScopeGuard destroyInstance([&]() {
if (vkDestroyInstanceFn != nullptr) {
vkDestroyInstanceFn(instance, nullptr);
}
});
if (vkEnumeratePhysicalDevicesFn == nullptr || vkGetPhysicalDevicePropertiesFn == nullptr ||
vkGetPhysicalDeviceQueueFamilyPropertiesFn == nullptr || vkGetPhysicalDeviceFeaturesFn == nullptr ||
vkEnumerateDeviceExtensionPropertiesFn == nullptr) {
builder.Fail("Vulkan core entry points",
"vkGetInstanceProcAddr could not resolve required Vulkan 1.0 functions");
builder.Finalize();
return builder.report;
}
Uint32 deviceCount = 0;
const VkResult countResult = vkEnumeratePhysicalDevicesFn(instance, &deviceCount, nullptr);
if (countResult != VK_SUCCESS) {
builder.Fail("Physical device", format("vkEnumeratePhysicalDevices failed (VkResult = {})",
static_cast<Int>(countResult)));
builder.Finalize();
return builder.report;
}
if (deviceCount == 0) {
builder.Fail("Physical device", "no Vulkan physical devices found");
builder.Finalize();
return builder.report;
}
builder.report.available = true;
Vector<VkPhysicalDevice> devices(deviceCount);
const VkResult enumerateResult = vkEnumeratePhysicalDevicesFn(instance, &deviceCount, devices.data());
if (enumerateResult != VK_SUCCESS) {
builder.Fail("Physical device", format("vkEnumeratePhysicalDevices failed (VkResult = {})",
static_cast<Int>(enumerateResult)));
builder.Finalize();
return builder.report;
}
devices.resize(deviceCount);
VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;
for (VkPhysicalDevice candidate : devices) {
Uint32 queueFamilyCount = 0;
vkGetPhysicalDeviceQueueFamilyPropertiesFn(candidate, &queueFamilyCount, nullptr);
Vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
vkGetPhysicalDeviceQueueFamilyPropertiesFn(candidate, &queueFamilyCount, queueFamilies.data());
const Bool hasGraphicsQueue =
std::any_of(queueFamilies.begin(), queueFamilies.end(), [](const VkQueueFamilyProperties& family) {
return family.queueCount > 0 && (family.queueFlags & VK_QUEUE_GRAPHICS_BIT) != 0;
});
if (hasGraphicsQueue) {
physicalDevice = candidate;
break;
}
}
if (physicalDevice == VK_NULL_HANDLE) {
builder.Fail("Graphics queue",
format("none of the {} physical device(s) exposes a graphics queue family", deviceCount));
builder.Finalize();
return builder.report;
}
VkPhysicalDeviceProperties properties{};
vkGetPhysicalDevicePropertiesFn(physicalDevice, &properties);
builder.Pass("Physical device",
format("{} ({} device(s) enumerated, picked the first with a graphics queue)",
String(properties.deviceName), deviceCount));
builder.Pass("Graphics queue", "graphics queue family present");
// driverVersion is vendor-encoded (each vendor packs its own bit layout), so it is
// reported as raw hex instead of being decoded with the VK_VERSION_* macros.
const String driverVersionString = format("0x{:08x}", properties.driverVersion);
builder.report.rendererInfo = format("{} (Vulkan {}, driver {})", String(properties.deviceName),
VkApiVersionToString(properties.apiVersion), driverVersionString);
if (properties.apiVersion >= VK_API_VERSION_1_1) {
builder.Pass("Device API version", format("Vulkan {}", VkApiVersionToString(properties.apiVersion)));
} else {
builder.Fail("Device API version",
format("Vulkan {} (< 1.1); the DirectVulkan backend requires a Vulkan 1.1 device",
VkApiVersionToString(properties.apiVersion)));
}
Vector<VkExtensionProperties> deviceExtensions;
Uint32 deviceExtensionCount = 0;
if (vkEnumerateDeviceExtensionPropertiesFn(physicalDevice, nullptr, &deviceExtensionCount, nullptr) ==
VK_SUCCESS &&
deviceExtensionCount > 0) {
deviceExtensions.resize(deviceExtensionCount);
if (vkEnumerateDeviceExtensionPropertiesFn(physicalDevice, nullptr, &deviceExtensionCount,
deviceExtensions.data()) == VK_SUCCESS) {
deviceExtensions.resize(deviceExtensionCount);
} else {
deviceExtensions.clear();
}
}
if (HasVkExtension(deviceExtensions, VK_KHR_SWAPCHAIN_EXTENSION_NAME)) {
builder.Pass("VK_KHR_swapchain", "device extension present");
} else {
builder.Fail("VK_KHR_swapchain", "required device extension missing; presentation is impossible");
}
VkPhysicalDeviceFeatures features{};
vkGetPhysicalDeviceFeaturesFn(physicalDevice, &features);
if (features.multiDrawIndirect == VK_TRUE) {
builder.Pass("multiDrawIndirect", "indirect multi-draw batches run as single native commands");
} else {
builder.Warn("multiDrawIndirect",
"unsupported; indirect multi-draw batches fall back to one draw per command");
}
if (features.drawIndirectFirstInstance == VK_TRUE) {
builder.Pass("drawIndirectFirstInstance", "indirect commands may carry a non-zero firstInstance");
} else {
builder.Warn("drawIndirectFirstInstance",
"unsupported; indirect commands with a non-zero baseInstance cannot run natively");
}
if (features.vertexPipelineStoresAndAtomics == VK_TRUE) {
builder.Pass("vertexPipelineStoresAndAtomics",
"supported by driver (not currently enabled by the DirectVulkan backend)");
} else {
builder.Warn("vertexPipelineStoresAndAtomics",
"unsupported; shaders that write storage buffers from the vertex stage will not work");
}
Bool shaderDrawParameters = false;
if (vkGetPhysicalDeviceFeatures2Fn != nullptr && properties.apiVersion >= VK_API_VERSION_1_1) {
VkPhysicalDeviceShaderDrawParametersFeatures drawParametersFeatures{};
drawParametersFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_DRAW_PARAMETERS_FEATURES;
VkPhysicalDeviceFeatures2 features2{};
features2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
features2.pNext = &drawParametersFeatures;
vkGetPhysicalDeviceFeatures2Fn(physicalDevice, &features2);
shaderDrawParameters = drawParametersFeatures.shaderDrawParameters == VK_TRUE;
} else if (HasVkExtension(deviceExtensions, VK_KHR_SHADER_DRAW_PARAMETERS_EXTENSION_NAME)) {
// Vulkan 1.0 device: the extension alone exposes the SPIR-V DrawParameters capability.
shaderDrawParameters = true;
}
if (shaderDrawParameters) {
builder.Pass("shaderDrawParameters", "gl_DrawID/gl_BaseVertex/gl_BaseInstance shaders supported");
} else {
builder.Warn("shaderDrawParameters",
"unavailable; shaders using gl_DrawID/gl_BaseInstance will not work");
}
if (vkGetPhysicalDeviceProperties2Fn != nullptr && properties.apiVersion >= VK_API_VERSION_1_1) {
VkPhysicalDeviceSubgroupProperties subgroupProperties{};
subgroupProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SUBGROUP_PROPERTIES;
VkPhysicalDeviceProperties2 properties2{};
properties2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2;
properties2.pNext = &subgroupProperties;
vkGetPhysicalDeviceProperties2Fn(physicalDevice, &properties2);
const Bool subgroupUsable = subgroupProperties.subgroupSize > 0 &&
(subgroupProperties.supportedStages & VK_SHADER_STAGE_COMPUTE_BIT) != 0 &&
(subgroupProperties.supportedOperations & VK_SUBGROUP_FEATURE_BASIC_BIT) != 0;
if (subgroupUsable) {
builder.Pass("Compute shader subgroup",
format("basic subgroup operations in compute, subgroup size {}",
subgroupProperties.subgroupSize));
} else {
builder.Warn("Compute shader subgroup",
"basic subgroup operations are not usable from compute shaders");
}
} else {
builder.Warn("Compute shader subgroup", "subgroup properties could not be queried");
}
builder.Info("VK_KHR_draw_indirect_count",
HasVkExtension(deviceExtensions, VK_KHR_DRAW_INDIRECT_COUNT_EXTENSION_NAME)
? "supported (GPU-driven indirect draw counts)"
: "not supported");
const Bool indexTypeUint8 = HasVkExtension(deviceExtensions, VK_KHR_INDEX_TYPE_UINT8_EXTENSION_NAME) ||
HasVkExtension(deviceExtensions, VK_EXT_INDEX_TYPE_UINT8_EXTENSION_NAME);
builder.Info("Index type uint8",
indexTypeUint8 ? "supported (native GL_UNSIGNED_BYTE index buffers)" : "not supported");
builder.Info("Device", String(properties.deviceName));
builder.Info("Driver version", driverVersionString + " (vendor-encoded)");
builder.Finalize();
MGLOG_I("Driver POST: Vulkan verdict = %s", builder.report.verdict.c_str());
return builder.report;
}
} // namespace MobileGL::MG_Util::SelfTest
+42
View File
@@ -0,0 +1,42 @@
// MobileGL - MobileGL/MG_Util/SelfTest/DriverPost.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 <Includes.h>
namespace MobileGL::MG_Util::SelfTest {
// One row of a backend power-on self-test (POST) report.
struct PostCheck {
String name;
String status; // "PASS" | "WARN" | "FAIL" | "INFO"
String detail;
};
// Verdict for one backend's device driver.
// - UNSUPPORTED: a fatal check failed; the backend cannot run on this driver.
// - DEGRADED: every fatal check passed but at least one soft expectation is unmet.
// - OK: all expectations met.
// available is false when no probeable driver exists at all (library missing, display
// uninitializable, zero Vulkan physical devices, ...).
struct BackendPostReport {
Bool available = false;
String verdict = "UNSUPPORTED"; // "OK" | "DEGRADED" | "UNSUPPORTED"
String rendererInfo;
Vector<PostCheck> checks;
};
// Probes the DEVICE GLES driver with self-contained EGL boilerplate (1x1 pbuffer
// context on the default display). Runs in the plugin APK's own process before
// MobileGL::Initialize(); it only touches MG_External tables and BackendLoader
// functions, never MG_State.
BackendPostReport RunGlesDriverPost();
// Probes the DEVICE Vulkan driver through a dlopen'd loader and a throwaway
// Vulkan 1.1 instance. Same standalone constraints as RunGlesDriverPost().
BackendPostReport RunVulkanDriverPost();
} // namespace MobileGL::MG_Util::SelfTest
+117
View File
@@ -0,0 +1,117 @@
// MobileGL - MobileGL/MG_Util/SelfTest/DriverPostJni.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
// JNI surface for the plugin APK's driver POST screen. The CMake source list only compiles
// this file on Android; the guard keeps it inert if it ever ends up in another build.
#ifdef __ANDROID__
#include "DriverPost.h"
#include <jni.h>
namespace {
using MobileGL::Bool;
using MobileGL::SizeT;
using MobileGL::String;
using MobileGL::StringStream;
using MobileGL::MG_Util::SelfTest::BackendPostReport;
using MobileGL::MG_Util::SelfTest::PostCheck;
String EscapeJsonString(const String& value) {
String out;
out.reserve(value.size() + 8);
for (const char c : value) {
switch (c) {
case '"':
out += "\\\"";
break;
case '\\':
out += "\\\\";
break;
case '\b':
out += "\\b";
break;
case '\f':
out += "\\f";
break;
case '\n':
out += "\\n";
break;
case '\r':
out += "\\r";
break;
case '\t':
out += "\\t";
break;
default:
// Bytes outside printable ASCII are \u00XX-escaped too: driver strings can carry
// arbitrary bytes, and NewStringUTF aborts under CheckJNI when the payload is not
// valid Modified UTF-8. Escaping keeps the JSON pure ASCII.
if (static_cast<unsigned char>(c) < 0x20 || static_cast<unsigned char>(c) >= 0x7F) {
char buffer[8];
std::snprintf(buffer, sizeof(buffer), "\\u%04x",
static_cast<unsigned>(static_cast<unsigned char>(c)));
out += buffer;
} else {
out += c;
}
break;
}
}
return out;
}
void AppendJsonString(StringStream& out, const String& value) {
out << '"' << EscapeJsonString(value) << '"';
}
void AppendBackendReportJson(StringStream& out, const BackendPostReport& report) {
out << "{\"available\":" << (report.available ? "true" : "false");
out << ",\"verdict\":";
AppendJsonString(out, report.verdict);
out << ",\"renderer\":";
AppendJsonString(out, report.rendererInfo);
out << ",\"checks\":[";
for (SizeT i = 0; i < report.checks.size(); ++i) {
const PostCheck& check = report.checks[i];
if (i != 0) {
out << ',';
}
out << "{\"name\":";
AppendJsonString(out, check.name);
out << ",\"status\":";
AppendJsonString(out, check.status);
out << ",\"detail\":";
AppendJsonString(out, check.detail);
out << '}';
}
out << "]}";
}
} // namespace
extern "C" JNIEXPORT jstring JNICALL Java_top_mobilegl_plugin_PostActivity_nativeRunDriverPost(JNIEnv* env, jclass) {
String json;
try {
const BackendPostReport glesReport = MobileGL::MG_Util::SelfTest::RunGlesDriverPost();
const BackendPostReport vulkanReport = MobileGL::MG_Util::SelfTest::RunVulkanDriverPost();
StringStream out;
out << "{\"gles\":";
AppendBackendReportJson(out, glesReport);
out << ",\"vulkan\":";
AppendBackendReportJson(out, vulkanReport);
out << '}';
json = out.str();
} catch (const std::exception& exception) {
json = "{\"error\":\"" + EscapeJsonString(exception.what()) + "\"}";
} catch (...) {
json = "{\"error\":\"driver POST failed with an unknown native exception\"}";
}
return env->NewStringUTF(json.c_str());
}
#endif // __ANDROID__
@@ -7,15 +7,21 @@
android:theme="@style/Theme.MobileGLPlugin">
<activity
android:name=".PluginActivity"
android:name=".PostActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
android:exported="true"
android:theme="@style/Theme.MobileGLPlugin.NoDisplay">
android:theme="@style/Theme.MobileGLPlugin">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".PluginActivity"
android:exported="true"
android:theme="@style/Theme.MobileGLPlugin.NoDisplay" />
<meta-data
android:name="fclPlugin"
android:value="true" />
@@ -0,0 +1,320 @@
package top.mobilegl.plugin;
import android.app.Activity;
import android.graphics.Typeface;
import android.os.Bundle;
import android.util.Log;
import android.util.TypedValue;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.lang.ref.WeakReference;
import java.util.Locale;
public final class PostActivity extends Activity {
private static final String TAG = "MobileGLPost";
private static final int COLOR_BACKGROUND = 0xFF121212;
private static final int COLOR_TEXT = 0xFFEEEEEE;
private static final int COLOR_PASS = 0xFF4CAF50;
private static final int COLOR_WARN = 0xFFFF9800;
private static final int COLOR_FAIL = 0xFFF44336;
private static final int COLOR_INFO = 0xFF9E9E9E;
/**
* Single-flight state: the driver POST runs at most once per process.
* All fields below are guarded by {@link #POST_LOCK}. Once {@code postCompleted}
* is set, {@code cachedReportJson}/{@code cachedFailure} never change again.
*/
private static final Object POST_LOCK = new Object();
private static boolean postInFlight = false;
private static boolean postCompleted = false;
private static String cachedReportJson;
private static Throwable cachedFailure;
private static WeakReference<PostActivity> deliveryTarget = new WeakReference<>(null);
private static boolean nativeLoaded = false;
private LinearLayout contentLayout;
private TextView statusView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
contentLayout = new LinearLayout(this);
contentLayout.setOrientation(LinearLayout.VERTICAL);
int padding = dp(16);
contentLayout.setPadding(padding, padding, padding, padding);
ScrollView scrollView = new ScrollView(this);
scrollView.setBackgroundColor(COLOR_BACKGROUND);
scrollView.setFillViewport(true);
scrollView.addView(contentLayout);
setContentView(scrollView);
addText("MobileGL Driver POST", 20, COLOR_TEXT, true, 0);
statusView = addText("Running self-test...", 14, COLOR_INFO, false, dp(8));
boolean startWorker = false;
boolean renderNow = false;
synchronized (POST_LOCK) {
deliveryTarget = new WeakReference<>(this);
if (postCompleted) {
renderNow = true;
} else if (!postInFlight) {
postInFlight = true;
startWorker = true;
}
// else: a probe is already in flight; leave "Running self-test..."
// showing and wait for delivery to this (the current) activity.
}
if (renderNow) {
renderCachedResult();
return;
}
if (startWorker) {
new Thread(PostActivity::runDriverPost, "MobileGLDriverPost").start();
}
}
@Override
protected void onDestroy() {
synchronized (POST_LOCK) {
if (deliveryTarget.get() == this) {
deliveryTarget = new WeakReference<>(null);
}
}
super.onDestroy();
}
/**
* Loads libMobileGL.so on first use, off the UI thread, so the first frame
* renders immediately. Any failure (including errors thrown by static
* initializers of the library) is captured and rethrown so it surfaces
* through the normal error path.
*/
private static synchronized void ensureNativeLoaded() {
if (nativeLoaded) {
return;
}
try {
System.loadLibrary("MobileGL");
} catch (Throwable error) {
throw new IllegalStateException("Failed to load libMobileGL.so: " + error, error);
}
nativeLoaded = true;
}
private static void runDriverPost() {
String json = null;
Throwable failure = null;
try {
ensureNativeLoaded();
json = nativeRunDriverPost();
Log.i(TAG, json == null ? "<null report>" : json);
} catch (Throwable error) {
Log.e(TAG, "Driver POST failed", error);
failure = error;
}
synchronized (POST_LOCK) {
cachedReportJson = json;
cachedFailure = failure;
postCompleted = true;
postInFlight = false;
}
deliverResult();
}
/** Delivers the cached result to whichever activity instance is current, if any. */
private static void deliverResult() {
final PostActivity target;
synchronized (POST_LOCK) {
target = deliveryTarget.get();
}
if (target == null) {
return;
}
target.runOnUiThread(() -> {
if (target.isFinishing() || target.isDestroyed()) {
return;
}
target.renderCachedResult();
});
}
private void renderCachedResult() {
String json;
Throwable failure;
synchronized (POST_LOCK) {
json = cachedReportJson;
failure = cachedFailure;
}
if (failure != null) {
showError("Driver POST failed: " + failure);
return;
}
renderReport(json);
}
private void renderReport(String json) {
if (json == null || json.isEmpty()) {
showError("Driver POST returned an empty report.");
return;
}
JSONObject root;
try {
root = new JSONObject(json);
} catch (JSONException error) {
showError("Failed to parse POST report: " + error.getMessage() + "\n\n" + json);
return;
}
String nativeError = root.optString("error", "");
if (!nativeError.isEmpty()) {
showError(nativeError);
addText(json, 10, COLOR_INFO, false, dp(8));
return;
}
JSONArray backends = extractBackends(root);
if (backends.length() == 0) {
showError("POST report contains no backend sections:\n\n" + json);
return;
}
statusView.setText("Self-test complete.");
for (int i = 0; i < backends.length(); ++i) {
JSONObject backend = backends.optJSONObject(i);
if (backend != null) {
renderBackendSection(backend);
}
}
}
private static JSONArray extractBackends(JSONObject root) {
JSONArray backends = root.optJSONArray("backends");
if (backends != null) {
return backends;
}
backends = new JSONArray();
String[] keys = {"gles", "DirectGLES", "vulkan", "DirectVulkan"};
for (String key : keys) {
JSONObject backend = root.optJSONObject(key);
if (backend == null) {
continue;
}
if (!backend.has("backend") && !backend.has("name")) {
try {
backend.put("backend", key);
} catch (JSONException ignored) {
}
}
backends.put(backend);
}
return backends;
}
private void renderBackendSection(JSONObject backend) {
String name = backend.optString("backend", backend.optString("name", "Unknown backend"));
addText(sectionTitle(name), 16, COLOR_TEXT, true, dp(20));
if (!backend.optBoolean("available", true)) {
addText("Driver not present / not probeable", 12, COLOR_INFO, false, dp(4));
return;
}
String verdict = backend.optString("verdict", "UNKNOWN");
addText("Verdict: " + verdict, 14, verdictColor(verdict), true, dp(4));
String renderer = backend.optString("renderer", "");
if (!renderer.isEmpty()) {
addText(renderer, 12, COLOR_INFO, false, dp(2));
}
JSONArray checks = backend.optJSONArray("checks");
if (checks == null) {
return;
}
for (int i = 0; i < checks.length(); ++i) {
JSONObject check = checks.optJSONObject(i);
if (check == null) {
continue;
}
String status = check.optString("status", "INFO");
String checkName = check.optString("name", "unnamed check");
String detail = check.optString("detail", check.optString("message", ""));
String row = "[" + status + "] " + checkName + (detail.isEmpty() ? "" : " - " + detail);
addText(row, 12, statusColor(status), false, dp(4));
}
}
private static String sectionTitle(String backendName) {
String lower = backendName.toLowerCase(Locale.ROOT);
if (lower.contains("gles") || lower.contains("espryt")) {
return "DirectGLES (Espryt)";
}
if (lower.contains("vulkan") || lower.contains("magma")) {
return "DirectVulkan (Magma)";
}
return backendName;
}
private static int verdictColor(String verdict) {
switch (verdict.toUpperCase(Locale.ROOT)) {
case "OK":
return COLOR_PASS;
case "DEGRADED":
return COLOR_WARN;
case "UNSUPPORTED":
return COLOR_FAIL;
default:
return COLOR_INFO;
}
}
private static int statusColor(String status) {
switch (status.toUpperCase(Locale.ROOT)) {
case "PASS":
return COLOR_PASS;
case "WARN":
return COLOR_WARN;
case "FAIL":
return COLOR_FAIL;
case "INFO":
return COLOR_INFO;
default:
return COLOR_TEXT;
}
}
private void showError(String message) {
Log.e(TAG, message);
statusView.setText("Self-test failed.");
statusView.setTextColor(COLOR_FAIL);
addText(message, 12, COLOR_FAIL, false, dp(8));
}
private TextView addText(String text, int sizeSp, int color, boolean bold, int topMarginPx) {
TextView view = new TextView(this);
view.setText(text);
view.setTextColor(color);
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sizeSp);
view.setTypeface(Typeface.MONOSPACE, bold ? Typeface.BOLD : Typeface.NORMAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.topMargin = topMarginPx;
contentLayout.addView(view, params);
return view;
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
private static native String nativeRunDriverPost();
}