diff --git a/.gitignore b/.gitignore index 6e3d096b..c961228b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 607efeb6..3543bacc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/MobileGL/MG_Util/SelfTest/DriverPost.cpp b/MobileGL/MG_Util/SelfTest/DriverPost.cpp new file mode 100644 index 00000000..a3625a96 --- /dev/null +++ b/MobileGL/MG_Util/SelfTest/DriverPost.cpp @@ -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 +#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 + 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(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(GetProcAddress(reinterpret_cast(library), name)); +#else + return dlsym(library, name); +#endif + } + + Bool HasVkExtension(const Vector& 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(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(getInstanceProcAddr(nullptr, "vkCreateInstance")); + const auto vkEnumerateInstanceVersionFn = reinterpret_cast( + getInstanceProcAddr(nullptr, "vkEnumerateInstanceVersion")); + const auto vkEnumerateInstanceExtensionPropertiesFn = + reinterpret_cast( + 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 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(createResult))); + builder.Finalize(); + return builder.report; + } + builder.Pass("Vulkan instance", "Vulkan 1.1 instance created"); + + const auto vkDestroyInstanceFn = + reinterpret_cast(getInstanceProcAddr(instance, "vkDestroyInstance")); + const auto vkEnumeratePhysicalDevicesFn = reinterpret_cast( + getInstanceProcAddr(instance, "vkEnumeratePhysicalDevices")); + const auto vkGetPhysicalDevicePropertiesFn = reinterpret_cast( + getInstanceProcAddr(instance, "vkGetPhysicalDeviceProperties")); + const auto vkGetPhysicalDeviceQueueFamilyPropertiesFn = + reinterpret_cast( + getInstanceProcAddr(instance, "vkGetPhysicalDeviceQueueFamilyProperties")); + const auto vkGetPhysicalDeviceFeaturesFn = reinterpret_cast( + getInstanceProcAddr(instance, "vkGetPhysicalDeviceFeatures")); + const auto vkEnumerateDeviceExtensionPropertiesFn = + reinterpret_cast( + getInstanceProcAddr(instance, "vkEnumerateDeviceExtensionProperties")); + const auto vkGetPhysicalDeviceFeatures2Fn = reinterpret_cast( + getInstanceProcAddr(instance, "vkGetPhysicalDeviceFeatures2")); + const auto vkGetPhysicalDeviceProperties2Fn = reinterpret_cast( + 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(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 devices(deviceCount); + const VkResult enumerateResult = vkEnumeratePhysicalDevicesFn(instance, &deviceCount, devices.data()); + if (enumerateResult != VK_SUCCESS) { + builder.Fail("Physical device", format("vkEnumeratePhysicalDevices failed (VkResult = {})", + static_cast(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 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 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 diff --git a/MobileGL/MG_Util/SelfTest/DriverPost.h b/MobileGL/MG_Util/SelfTest/DriverPost.h new file mode 100644 index 00000000..a28a4331 --- /dev/null +++ b/MobileGL/MG_Util/SelfTest/DriverPost.h @@ -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 + +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 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 diff --git a/MobileGL/MG_Util/SelfTest/DriverPostJni.cpp b/MobileGL/MG_Util/SelfTest/DriverPostJni.cpp new file mode 100644 index 00000000..01719159 --- /dev/null +++ b/MobileGL/MG_Util/SelfTest/DriverPostJni.cpp @@ -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 + +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(c) < 0x20 || static_cast(c) >= 0x7F) { + char buffer[8]; + std::snprintf(buffer, sizeof(buffer), "\\u%04x", + static_cast(static_cast(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__ diff --git a/android-plugin/app/src/main/AndroidManifest.xml b/android-plugin/app/src/main/AndroidManifest.xml index 78cba8ab..1734dd54 100644 --- a/android-plugin/app/src/main/AndroidManifest.xml +++ b/android-plugin/app/src/main/AndroidManifest.xml @@ -7,15 +7,21 @@ android:theme="@style/Theme.MobileGLPlugin"> + android:theme="@style/Theme.MobileGLPlugin"> + + diff --git a/android-plugin/app/src/main/java/top/mobilegl/plugin/PostActivity.java b/android-plugin/app/src/main/java/top/mobilegl/plugin/PostActivity.java new file mode 100644 index 00000000..ff1593df --- /dev/null +++ b/android-plugin/app/src/main/java/top/mobilegl/plugin/PostActivity.java @@ -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 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 ? "" : 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(); +}