diff --git a/.github/workflows/app-package.yml b/.github/workflows/app-package.yml index c925d6cb..25ec2774 100644 --- a/.github/workflows/app-package.yml +++ b/.github/workflows/app-package.yml @@ -349,6 +349,8 @@ jobs: mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake mingw-w64-x86_64-ninja + mingw-w64-x86_64-mesa + mingw-w64-x86_64-python cmake_extra: '' - arch: arm64 os: windows-11-arm @@ -358,6 +360,8 @@ jobs: mingw-w64-clang-aarch64-clang mingw-w64-clang-aarch64-cmake mingw-w64-clang-aarch64-ninja + mingw-w64-clang-aarch64-mesa + mingw-w64-clang-aarch64-python cmake_extra: '-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++' defaults: run: @@ -419,7 +423,9 @@ jobs: if [ "$ARCH" = arm64 ]; then grep -qiE 'aarch64|arm64' machine.txt; else grep -qiE 'x86-64|i386:x86-64' machine.txt; fi - name: Install tree - run: cmake --install build --prefix "$PWD/stage" + run: | + cmake --install build --prefix "$PWD/stage" + python app/packaging/windows/stage-software-gl.py stage # ---- SignPath (stub): runs only with the `sign` input and SIGNPATH_API_TOKEN; the SignPath # Foundation application is not approved yet, so this is dormant. ----------------------- diff --git a/.github/workflows/windows-gl-test.yml b/.github/workflows/windows-gl-test.yml new file mode 100644 index 00000000..a6ca2e8d --- /dev/null +++ b/.github/workflows/windows-gl-test.yml @@ -0,0 +1,111 @@ +name: Windows ARM OpenGL qualification +on: + pull_request: + # Any pull request that touches the Windows GL path, whatever its base: the + # qualification must also run for a PR stacked on another PR's branch. + paths: + - 'app/src/gl_context.*' + - 'app/src/software_gl.h' + - 'app/src/platform/software_gl_windows.cpp' + - 'app/src/main.cpp' + - 'app/tests/software_gl_test.cpp' + - 'app/packaging/windows/stage-software-gl.py' + - 'app/src/app.cpp' + - 'app/tests/gl_context*' + - 'app/cmake/VJShaders.cmake' + - 'app/cmake/VJPlatformTests.cmake' + - '.github/workflows/windows-gl-test.yml' + workflow_dispatch: +concurrency: + group: windows-gl-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + software-gl: + runs-on: windows-11-arm + timeout-minutes: 45 + defaults: + run: + shell: 'msys2 {0}' + steps: + - uses: actions/checkout@v7 + - uses: msys2/setup-msys2@v2 + with: + msystem: CLANGARM64 + update: false + install: >- + zip + mingw-w64-clang-aarch64-clang + mingw-w64-clang-aarch64-cmake + mingw-w64-clang-aarch64-ninja + mingw-w64-clang-aarch64-mesa + mingw-w64-clang-aarch64-python + - name: Configure and build the ARM64 test candidate + run: | + set -eu + cmake -S app -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ + -DVJ_APP_WIN32_GUI=ON -DVJ_APP_WERROR=ON -DVJ_BUILD_CHANNEL=nightly + cmake --build build --target virtualjaguar vj_gl_context_test vj_gl_context_integration vj_software_gl_test -j4 + build/vj_gl_context_test.exe + build/vj_software_gl_test.exe + cmake --install build --prefix "$PWD/stage" + - name: Stage a separate software OpenGL runtime and its notices + run: | + set -eu + mkdir -p diagnostics + python app/packaging/windows/stage-software-gl.py stage + command cp -f build/vj_gl_context_integration.exe stage/bin/software-opengl/ + + - name: Replay the old loader and qualify real ARM64 software GL rendering + run: | + set -eu + set +e + build/vj_gl_context_integration.exe legacy >diagnostics/native-driver.txt 2>&1 + set -e + timeout 90 stage/bin/virtualjaguar.exe --data-dir "$PWD/automatic fallback data" \ + --screenshot "$PWD/diagnostics/automatic-fallback.png" --screenshot-frame 20 \ + >diagnostics/automatic-stdout.txt 2>diagnostics/automatic-stderr.txt + test -s diagnostics/automatic-fallback.png + grep -q 'automatically retrying with bundled Mesa OpenGL' diagnostics/automatic-stderr.txt + # The launcher picks d3d12 only for a hardware D3D12 adapter and llvmpipe + # otherwise; the hosted runner has only the Basic Render Driver (WARP), so + # it shows llvmpipe. Either is the bundled Mesa and not the GDI Generic + # 1.1 driver the native attempt reported. + grep -E 'renderer=(llvmpipe|D3D12)' diagnostics/automatic-stderr.txt + grep -E '\[gl\] (bundled Mesa will render through Direct3D 12|no hardware Direct3D 12 adapter)' diagnostics/automatic-stderr.txt + test -f 'automatic fallback data/config.ini' + test ! -e 'automatic fallback data/saves/instance-2' + export PATH="$PWD/stage/bin/software-opengl:$PATH" + export SDL_OPENGL_LIBRARY="$(cygpath -w "$PWD/stage/bin/software-opengl/opengl32.dll")" + export GALLIUM_DRIVER=llvmpipe + export LIBGL_ALWAYS_SOFTWARE=true + set +e + stage/bin/software-opengl/vj_gl_context_integration.exe legacy >diagnostics/legacy-loader.txt 2>&1 + legacy_result=$? + set -e + echo "Legacy loader exit: $legacy_result" | tee -a diagnostics/legacy-loader.txt + stage/bin/software-opengl/vj_gl_context_integration.exe >diagnostics/sdl-loader.txt 2>&1 + cat diagnostics/sdl-loader.txt + timeout 90 stage/bin/software-opengl/virtualjaguar.exe --data-dir "$PWD/test-data" \ + --screenshot "$PWD/diagnostics/arm64-software-gl.png" --screenshot-frame 20 \ + >diagnostics/app-stdout.txt 2>diagnostics/app-stderr.txt + test -s diagnostics/arm64-software-gl.png + command rm -f stage/bin/software-opengl/vj_gl_context_integration.exe + cat diagnostics/app-stderr.txt + - name: Package the portable test build + run: | + set -eu + printf '%s\n' 'Open virtualjaguar.exe normally. If its graphics driver cannot initialize, it automatically retries with bundled Mesa OpenGL: hardware rendering through Direct3D 12 when the machine has it, CPU rendering (slower) otherwise. No driver installation or special launcher is needed.' >stage/bin/TEST-BUILD.txt + bash app/packaging/windows/package.sh stage gl-test out --arch arm64 + printf '%s\n' "$GITHUB_SHA" >out/SOURCE_COMMIT.txt + sha256sum out/*.zip >out/SHA256SUMS.txt + - uses: actions/upload-artifact@v7 + if: always() + with: + name: windows-arm64-opengl-test + path: | + out/* + diagnostics/* + if-no-files-found: error diff --git a/.github/workflows/windows-store.yml b/.github/workflows/windows-store.yml index 8fa48fd2..dd5ec10d 100644 --- a/.github/workflows/windows-store.yml +++ b/.github/workflows/windows-store.yml @@ -10,7 +10,14 @@ on: - 'app/packaging/icons/**' - 'app/packaging/check-payload.sh' - 'test/roms/yarc.j64' - - 'app/packaging/windows/msix/**' + - 'app/packaging/windows/**' + - 'app/src/software_gl.h' + - 'app/src/gl_context.*' + - 'app/src/app.h' + - 'app/src/platform/software_gl_windows.cpp' + - 'app/src/main.cpp' + - 'app/src/app.cpp' + - 'app/CMakeLists.txt' - 'app/src/platform/platform_windows.cpp' - '.github/workflows/windows-store.yml' workflow_dispatch: @@ -40,12 +47,12 @@ jobs: - arch: x64 os: windows-latest msystem: MINGW64 - packages: mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake mingw-w64-x86_64-ninja + packages: mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake mingw-w64-x86_64-ninja mingw-w64-x86_64-mesa mingw-w64-x86_64-python cmake_extra: '' - arch: arm64 os: windows-11-arm msystem: CLANGARM64 - packages: mingw-w64-clang-aarch64-clang mingw-w64-clang-aarch64-cmake mingw-w64-clang-aarch64-ninja + packages: mingw-w64-clang-aarch64-clang mingw-w64-clang-aarch64-cmake mingw-w64-clang-aarch64-ninja mingw-w64-clang-aarch64-mesa mingw-w64-clang-aarch64-python cmake_extra: '-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++' steps: - uses: actions/checkout@v7 @@ -75,6 +82,7 @@ jobs: cmake --install build-store --prefix "$PWD/stage-store" stage-store/bin/virtualjaguar.exe --headless --frames 300 --expect-hash f4c30ea3 test/roms/yarc.j64 bash app/packaging/check-payload.sh build-store stage-store/bin virtualjaguar.exe + python app/packaging/windows/stage-software-gl.py stage-store - name: Pack and validate Store MSIX with the Windows SDK shell: pwsh run: | diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 1c2c7edc..5b5a95c0 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -322,6 +322,7 @@ endif() set(VJ_APP_SOURCES src/main.cpp src/app.cpp + src/gl_context.cpp src/paths.cpp src/instance.cpp src/config.cpp @@ -443,7 +444,7 @@ if(APPLE) set_source_files_properties(src/platform/platform_macos.mm PROPERTIES COMPILE_OPTIONS "-fobjc-arc;-Wno-deprecated-declarations") elseif(WIN32) - list(APPEND VJ_APP_SOURCES src/platform/platform_windows.cpp) + list(APPEND VJ_APP_SOURCES src/platform/platform_windows.cpp src/platform/software_gl_windows.cpp) else() list(APPEND VJ_APP_SOURCES src/platform/platform_other.cpp) endif() diff --git a/app/cmake/VJPlatformTests.cmake b/app/cmake/VJPlatformTests.cmake index 330ea0b4..696704ca 100644 --- a/app/cmake/VJPlatformTests.cmake +++ b/app/cmake/VJPlatformTests.cmake @@ -44,3 +44,12 @@ target_include_directories(vj_window_chrome_test PRIVATE src) target_link_libraries(vj_window_chrome_test PRIVATE vj_imgui) add_test(NAME window_chrome_unit COMMAND vj_window_chrome_test) set_tests_properties(window_chrome_unit PROPERTIES TIMEOUT 60 FAIL_REGULAR_EXPRESSION "FAIL ") + +add_executable(vj_software_gl_test tests/software_gl_test.cpp) +target_include_directories(vj_software_gl_test PRIVATE src) +if(WIN32) + target_sources(vj_software_gl_test PRIVATE src/platform/software_gl_windows.cpp) + target_link_libraries(vj_software_gl_test PRIVATE shell32) +endif() +add_test(NAME software_gl_unit COMMAND vj_software_gl_test) +set_tests_properties(software_gl_unit PROPERTIES TIMEOUT 60) diff --git a/app/cmake/VJShaders.cmake b/app/cmake/VJShaders.cmake index a8bd4d9f..8d30fb10 100644 --- a/app/cmake/VJShaders.cmake +++ b/app/cmake/VJShaders.cmake @@ -311,8 +311,17 @@ endfunction() # fallback. SKIPPED (exit 77) without the library or a display. if(VJ_APP_BUILD_TESTS) enable_testing() + add_executable(vj_gl_context_test tests/gl_context_test.cpp) + target_include_directories(vj_gl_context_test PRIVATE src) + add_test(NAME gl_context_unit COMMAND vj_gl_context_test) + add_executable(vj_gl_context_integration tests/gl_context_integration.cpp src/gl_context.cpp) + target_include_directories(vj_gl_context_integration PRIVATE src) + target_link_libraries(vj_gl_context_integration PRIVATE vj_imgui ${VJ_SDL3_TARGET}) + add_test(NAME gl_context_integration COMMAND vj_gl_context_integration) + set_tests_properties(gl_context_integration PROPERTIES TIMEOUT 60 SKIP_RETURN_CODE 77) add_executable(vj_shader_tests test/shader_tests.cpp + src/gl_context.cpp src/shaders.cpp src/present_util.cpp src/paths.cpp diff --git a/app/packaging/README.md b/app/packaging/README.md index 78bb20e8..7add1d13 100644 --- a/app/packaging/README.md +++ b/app/packaging/README.md @@ -423,3 +423,21 @@ PNG, `.ico` and `.icns` files. Gatekeeper first-run on a freshly downloaded signed zip, SmartScreen on the installer, Intel Mac behavior (CI may not run the x86_64 slice), Wayland/X11/PipeWire sound on real Linux desktops, and the AppImage on distributions older than Ubuntu 22.04. + +Windows packages also stage a bundled Mesa OpenGL through +`windows/stage-software-gl.py` (MSYS2 Mesa, Python and objdump required) in +the `software-opengl/` folder (the name predates the Direct3D 12 path below). +The normal executable automatically retries once with the bundled app if +graphics startup fails, which on a stock Windows 10/11 install without a vendor +OpenGL driver (Windows on ARM, VMs, Remote Desktop, the basic display adapter) +means WGL handed us the GDI Generic OpenGL 1.1 driver. The retry preserves game +arguments, working directory and the original config/save root; users do not +need a separate launcher. The launcher picks Mesa's driver: `d3d12` (OpenGL on +the machine's Direct3D 12 driver, hardware rendering, the same layer as +Microsoft's OpenGL Compatibility Pack) when a hardware D3D12 adapter exists, +pinned to that adapter; `llvmpipe` (CPU rendering, slower) otherwise, including +when the only D3D12 adapter is the Microsoft Basic Render Driver (WARP), which +is software too and slower than llvmpipe. A `GALLIUM_DRIVER` already in the +environment wins. The `[gl]` log lines name the choice and the renderer that +came up. Keep the dependency notices and matching architecture alongside the +bundled executable. diff --git a/app/packaging/windows/msix/README.md b/app/packaging/windows/msix/README.md index f68c88c7..1200312b 100644 --- a/app/packaging/windows/msix/README.md +++ b/app/packaging/windows/msix/README.md @@ -80,3 +80,13 @@ upload multiple different builds under an already published version. References: [manual packaging](https://learn.microsoft.com/en-us/windows/msix/desktop/desktop-to-uwp-manual-conversion), [version rules](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/package-version-numbering), [signing](https://learn.microsoft.com/en-us/windows/msix/package/signing-package-overview). + +Windows packages include a `software-opengl/` app copy and Mesa dependency notices. +Startup tries the normal driver first. If window/context/renderer initialization +fails, it releases the first instance and retries once with the bundled Mesa, +preserving command-line arguments and the original data root. Mesa renders on +the machine's hardware Direct3D 12 adapter when there is one (`d3d12`) and on +the CPU otherwise (`llvmpipe`; the Basic Render Driver does not count as +hardware). Only the child's environment selects Mesa; system drivers and +settings are unchanged. +Qualify both the normal and fallback launch paths for each Store architecture. diff --git a/app/packaging/windows/stage-software-gl.py b/app/packaging/windows/stage-software-gl.py new file mode 100644 index 00000000..fbc8fc9b --- /dev/null +++ b/app/packaging/windows/stage-software-gl.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# Stage the MSYS2 Mesa dependency closure and matching app copy for automatic fallback. +import os, pathlib, re, shutil, subprocess, sys +def windows_path(path): + return pathlib.Path(subprocess.check_output(['cygpath', '-m', str(path)], text=True).strip()) +source = windows_path(os.environ['MINGW_PREFIX'] + '/bin') +bindir = pathlib.Path(sys.argv[1]) / 'bin' +target = bindir / 'software-opengl' +if target.exists(): + raise SystemExit('Choose a fresh install prefix: software OpenGL is already staged') +target.mkdir() +objdump = shutil.which('llvm-objdump') or shutil.which('objdump') +if not objdump: + raise SystemExit('MSYS2 objdump is required to stage the software renderer') +pending = ['opengl32.dll'] +copied = set() +owners = set() +while pending: + name = pending.pop() + if name.lower() in copied: + continue + path = source / name + if not path.exists(): + continue # system DLL, not an MSYS2 dependency + copied.add(name.lower()) + shutil.copy2(path, target / path.name) + imports = subprocess.check_output([objdump, '-p', str(path)], text=True) + pending.extend(re.findall(r'DLL Name:\s*(\S+)', imports)) + unix_path = subprocess.check_output(['cygpath', '-u', str(path)], text=True).strip() + owners.add(subprocess.check_output(['pacman', '-Qqo', unix_path], text=True).strip()) +assert (target / 'libgallium_wgl.dll').exists(), 'Mesa WGL driver missing' +notices = target / 'licenses' +for owner in sorted(owners): + for line in subprocess.check_output(['pacman', '-Ql', owner], text=True).splitlines(): + unix_path = line.split(' ', 1)[1] + if '/share/licenses/' not in unix_path or unix_path.endswith('/'): + continue + path = windows_path(unix_path) + if path.is_file(): + dest = notices / owner / path.name + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, dest) +(target / 'PACKAGES.txt').write_text(subprocess.check_output(['pacman', '-Q', *sorted(owners)], text=True)) +# WGL/GDI must find the Mesa frontend by its standard name next to the +# executable. Keep a separate full app copy so normal graphics stays clean. +for path in list(bindir.iterdir()): + if path == target: + continue + if path.is_dir(): + shutil.copytree(path, target / path.name, dirs_exist_ok=True) + else: + shutil.copy2(path, target / path.name) + diff --git a/app/src/app.cpp b/app/src/app.cpp index 43a37da5..630e98a5 100644 --- a/app/src/app.cpp +++ b/app/src/app.cpp @@ -1,6 +1,7 @@ #include "platform/file_access.h" #include "app.h" #include "build_channel.h" +#include "gl_context.h" #include "devices_ui.h" #include "fonts_imgui.h" #include "ui_scale.h" @@ -435,7 +436,11 @@ bool App::initWindow() { std::fprintf(stderr, "cannot create an OpenGL 3.3 core context: %s\n", SDL_GetError()); return false; } - SDL_GL_MakeCurrent(window_, gl_); + if (!SDL_GL_MakeCurrent(window_, gl_)) { + std::fprintf(stderr, "cannot make OpenGL context current: %s\n", SDL_GetError()); + return false; + } + if (!initOpenGLLoader()) return false; swapGuard_.contextCreated(platform::displayAsleep(window_)); if (swapGuard_.deadLink()) std::fprintf(stderr, "[gl] the display is asleep: vsync is off for this run (a swap would wait for a refresh that never comes)\n"); @@ -2574,7 +2579,11 @@ int App::runInteractive(const CliArgs& a) { lowDpi_ = a.lowDpi || a.pixelDensity > 1; // --pixel-density builds its own density on a 1x window simDensity_ = a.pixelDensity > 1 ? std::min(4u, a.pixelDensity) : 0; g_simDensity = simDensity_ ? (float)simDensity_ : 1.0f; - if (!initWindow() || !initImGui()) return 1; + if (!initWindow() || !initImGui()) { + graphicsStartupError = SDL_GetError(); + if (graphicsStartupError.empty()) graphicsStartupError = "Could not initialize the graphics renderer."; + return 1; + } desktopInit(); if (!a.font.empty()) { if (!fonts::applySpec(a.font, fontSettings)) std::fprintf(stderr, "--font: cannot read '%s'\n", a.font.c_str()); diff --git a/app/src/app.h b/app/src/app.h index e3ccdae0..cac69757 100644 --- a/app/src/app.h +++ b/app/src/app.h @@ -164,6 +164,7 @@ public: int run(const CliArgs& args); // returns the process exit code // ---- services (public: the UI drives them) ------------------------------- + std::string graphicsStartupError; // nonempty only when window/context/UI initialization fails Paths paths; Config cfg; CoreHost& host; diff --git a/app/src/gl_context.cpp b/app/src/gl_context.cpp new file mode 100644 index 00000000..68e97899 --- /dev/null +++ b/app/src/gl_context.cpp @@ -0,0 +1,39 @@ +#include "gl_context.h" +#include +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include "imgui_impl_opengl3_loader.h" + +namespace vj { +bool initOpenGLLoader() { + auto getString = reinterpret_cast(SDL_GL_GetProcAddress("glGetString")); + const char* version = getString ? reinterpret_cast(getString(GL_VERSION)) : nullptr; + const char* vendor = getString ? reinterpret_cast(getString(GL_VENDOR)) : nullptr; + const char* renderer = getString ? reinterpret_cast(getString(GL_RENDERER)) : nullptr; + std::fprintf(stderr, "[gl] version=%s; vendor=%s; renderer=%s\n", + version ? version : "unavailable", vendor ? vendor : "unavailable", renderer ? renderer : "unavailable"); + if (!supportsDesktopGL33(version)) { + SDL_SetError("Virtual Jaguar requires desktop OpenGL 3.3 or newer; this driver provides %s. " + "Use a compatible graphics driver or a software OpenGL implementation.", version ? version : "no current OpenGL context"); + std::fprintf(stderr, "[gl] %s\n", SDL_GetError()); + return false; + } +#ifdef _WIN32 + // Use the same library that created the context, including SDL_OPENGL_LIBRARY. + // ImGui's default loader independently opens opengl32.dll, which can be a + // different (legacy system) implementation on Windows ARM / software-GL setups. + const int result = imgl3wInit2([](const char* name) -> GL3WglProc { + return reinterpret_cast(SDL_GL_GetProcAddress(name)); + }); +#else + const int result = imgl3wInit(); +#endif + if (result != 0) { + SDL_SetError("Could not initialize the OpenGL function loader (error %d).", result); + std::fprintf(stderr, "[gl] %s\n", SDL_GetError()); + return false; + } + return true; +} +} diff --git a/app/src/gl_context.h b/app/src/gl_context.h new file mode 100644 index 00000000..c9f85bb4 --- /dev/null +++ b/app/src/gl_context.h @@ -0,0 +1,13 @@ +#pragma once +#include + +namespace vj { +// SDL's WGL backend can return a legacy context even when 3.3 core was requested. +inline bool supportsDesktopGL33(const char* version) { + int major = 0, minor = 0; + return version && std::sscanf(version, "%d.%d", &major, &minor) == 2 && + (major > 3 || (major == 3 && minor >= 3)); +} +// Requires a current SDL context. Reports the actual driver and sets SDL's error on failure. +bool initOpenGLLoader(); +} diff --git a/app/src/main.cpp b/app/src/main.cpp index 549dbf05..c5cdb395 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -6,6 +6,7 @@ #include #include "app.h" +#include "software_gl.h" #include "platform/commands.h" #include "platform/platform.h" @@ -327,6 +328,17 @@ int main(int argc, char** argv) { return 0; } if (a.dumpNativeMenu) return vj::platform::dumpNativeMenu(); - vj::App app; - return app.run(a); + int result = 1; + std::string graphicsError, dataDir; + { + vj::App app; + result = app.run(a); + graphicsError = app.graphicsStartupError; + dataDir = app.paths.root; + } // Release workers, streams and the instance lock before the software retry. + if (!graphicsError.empty()) { + if (vj::retrySoftwareOpenGL(argc, argv, dataDir, result)) return result; + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Virtual Jaguar graphics initialization", graphicsError.c_str(), nullptr); + } + return result; } diff --git a/app/src/platform/software_gl_windows.cpp b/app/src/platform/software_gl_windows.cpp new file mode 100644 index 00000000..c6366ec8 --- /dev/null +++ b/app/src/platform/software_gl_windows.cpp @@ -0,0 +1,162 @@ +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include "software_gl.h" +#include +#include +#include +#include + +namespace vj { +namespace { +// The description of the first hardware adapter that can create a Direct3D 12 +// device at feature level 11_0, or empty when the machine has none. The +// "Microsoft Basic Render Driver" (WARP) is a software adapter and is skipped: +// GL on WARP is slower than llvmpipe and the app did not survive it on the +// hosted ARM64 runner. dxgi.dll / d3d12.dll are loaded by hand so the +// executable keeps running on a machine without either (no link dependency). +std::wstring hardwareD3D12Adapter() { + std::wstring found; + HMODULE dxgi = LoadLibraryW(L"dxgi.dll"); + HMODULE d3d12 = LoadLibraryW(L"d3d12.dll"); + if (!dxgi || !d3d12) { + if (dxgi) FreeLibrary(dxgi); + if (d3d12) FreeLibrary(d3d12); + return found; + } + using CreateFactory1 = HRESULT(WINAPI*)(REFIID, void**); + using CreateDevice = HRESULT(WINAPI*)(IUnknown*, D3D_FEATURE_LEVEL, REFIID, void**); + auto createFactory = reinterpret_cast(GetProcAddress(dxgi, "CreateDXGIFactory1")); + auto createDevice = reinterpret_cast(GetProcAddress(d3d12, "D3D12CreateDevice")); + IDXGIFactory1* factory = nullptr; + if (createFactory && createDevice && SUCCEEDED(createFactory(__uuidof(IDXGIFactory1), reinterpret_cast(&factory)))) { + IDXGIAdapter1* adapter = nullptr; + for (UINT i = 0; found.empty() && factory->EnumAdapters1(i, &adapter) != DXGI_ERROR_NOT_FOUND; ++i) { + DXGI_ADAPTER_DESC1 desc = {}; + // DXGI_ADAPTER_FLAG_SOFTWARE is not reliably set on the Basic Render + // Driver (the hosted ARM64 runner enumerates it without the flag), so + // also reject Microsoft's vendor id 0x1414 and the name itself. + if (SUCCEEDED(adapter->GetDesc1(&desc))) { + const bool software = (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) || desc.VendorId == 0x1414 || + std::wcsstr(desc.Description, L"Basic Render") != nullptr; + if (!software && SUCCEEDED(createDevice(adapter, D3D_FEATURE_LEVEL_11_0, __uuidof(ID3D12Device), nullptr))) + found = desc.Description; + } + adapter->Release(); + adapter = nullptr; + } + factory->Release(); + } + FreeLibrary(d3d12); + FreeLibrary(dxgi); + return found; +} +std::wstring wide(const std::string& text) { + if (text.empty()) return {}; + int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), (int)text.size(), nullptr, 0); + if (!size) return {}; + std::wstring result((size_t)size, L'\0'); + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), (int)text.size(), result.data(), size); + return result; +} +bool fileExists(const std::wstring& path) { + DWORD attr = GetFileAttributesW(path.c_str()); + return attr != INVALID_FILE_ATTRIBUTES && !(attr & FILE_ATTRIBUTE_DIRECTORY); +} +} + +bool retrySoftwareOpenGL(int argc, char** argv, const std::string& dataDir, int& exitCode) { + if (GetEnvironmentVariableW(L"VJ_SOFTWARE_OPENGL_RETRY", nullptr, 0)) return false; + wchar_t path[32768]; + DWORD size = GetModuleFileNameW(nullptr, path, (DWORD)(sizeof path / sizeof path[0])); + if (!size || size >= sizeof path / sizeof path[0]) return false; + std::wstring exe(path, size); + size_t slash = exe.find_last_of(L"\\/"); + if (slash == std::wstring::npos) return false; + std::wstring folder = exe.substr(0, slash + 1) + L"software-opengl\\"; + std::wstring child = folder + L"virtualjaguar.exe"; + std::wstring dll = folder + L"opengl32.dll"; + if (!fileExists(child) || !fileExists(dll) || !fileExists(folder + L"libgallium_wgl.dll")) { + std::fprintf(stderr, "[gl] bundled software OpenGL is unavailable\n"); + return false; + } + std::wstring command = quoteSoftwareGLArgument(child); + for (int i = 1; i < argc; ++i) command += L" " + quoteSoftwareGLArgument(wide(argv[i])); + // The executable lives beside Mesa, but config/saves and the instance number + // must keep using the original app's data root (including portable installs). + command += L" --data-dir " + quoteSoftwareGLArgument(wide(dataDir)); + std::vector entries; + wchar_t* block = GetEnvironmentStringsW(); + if (!block) return false; + for (const wchar_t* p = block; *p; p += std::wcslen(p) + 1) { + if (!_wcsnicmp(p, L"SDL_OPENGL_LIBRARY=", 19) || !_wcsnicmp(p, L"VJ_SOFTWARE_OPENGL_RETRY=", 25)) continue; + entries.emplace_back(p); + } + FreeEnvironmentStringsW(block); + entries.push_back(L"SDL_OPENGL_LIBRARY=" + dll); + entries.push_back(L"VJ_SOFTWARE_OPENGL_RETRY=1"); + // Which Gallium driver the bundled Mesa uses. It carries d3d12 (OpenGL on + // the Direct3D 12 driver every stock Windows 10/11 install has, the same + // layer as Microsoft's OpenGL Compatibility Pack) and llvmpipe (pure + // software). With a hardware D3D12 adapter present, d3d12 renders on the + // GPU, pinned to that adapter so Mesa cannot drift onto WARP; without one, + // llvmpipe: Mesa left alone takes the Microsoft Basic Render Driver, which + // is software D3D12 under a GL translation, slower than llvmpipe and not + // something the app survived on the hosted ARM64 runner. A GALLIUM_DRIVER + // the user already set wins over this probe. + if (!GetEnvironmentVariableW(L"GALLIUM_DRIVER", nullptr, 0)) { + std::wstring adapter = hardwareD3D12Adapter(); + if (!adapter.empty()) { + entries.push_back(L"GALLIUM_DRIVER=d3d12"); + if (!GetEnvironmentVariableW(L"MESA_D3D12_DEFAULT_ADAPTER_NAME", nullptr, 0)) + entries.push_back(L"MESA_D3D12_DEFAULT_ADAPTER_NAME=" + adapter); + std::fprintf(stderr, "[gl] bundled Mesa will render through Direct3D 12 on %ls\n", adapter.c_str()); + } else { + entries.push_back(L"GALLIUM_DRIVER=llvmpipe"); + entries.push_back(L"LIBGL_ALWAYS_SOFTWARE=true"); + std::fprintf(stderr, "[gl] no hardware Direct3D 12 adapter; bundled Mesa will render in software (llvmpipe)\n"); + } + } + std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) { return _wcsicmp(a.c_str(), b.c_str()) < 0; }); + std::vector environment; + for (const auto& entry : entries) environment.insert(environment.end(), entry.c_str(), entry.c_str() + entry.size() + 1); + environment.push_back(L'\0'); + STARTUPINFOW startup = {}; + startup.cb = sizeof startup; + // Preserve redirected diagnostics without inheriting an instance lock. + HANDLE copies[3] = {}; + const DWORD ids[] = {STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE}; + for (int i = 0; i < 3; ++i) { + HANDLE h = GetStdHandle(ids[i]); + if (h && h != INVALID_HANDLE_VALUE) + DuplicateHandle(GetCurrentProcess(), h, GetCurrentProcess(), &copies[i], 0, TRUE, DUPLICATE_SAME_ACCESS); + } + if (copies[1] || copies[2]) { + startup.dwFlags = STARTF_USESTDHANDLES; + startup.hStdInput = copies[0]; startup.hStdOutput = copies[1]; startup.hStdError = copies[2]; + } + PROCESS_INFORMATION process = {}; + std::fprintf(stderr, "[gl] automatically retrying with bundled Mesa OpenGL (Direct3D 12 when available, software otherwise)\n"); + std::fflush(nullptr); + BOOL created = CreateProcessW(child.c_str(), command.data(), nullptr, nullptr, TRUE, + CREATE_UNICODE_ENVIRONMENT, environment.data(), nullptr, &startup, &process); + DWORD error = GetLastError(); + for (HANDLE h : copies) if (h) CloseHandle(h); + if (!created) { + std::fprintf(stderr, "[gl] software renderer could not start (Windows error %lu)\n", (unsigned long)error); + return false; + } + CloseHandle(process.hThread); + DWORD result = 1; + if (WaitForSingleObject(process.hProcess, INFINITE) == WAIT_OBJECT_0) GetExitCodeProcess(process.hProcess, &result); + CloseHandle(process.hProcess); + exitCode = (int)result; + return true; +} +} diff --git a/app/src/software_gl.h b/app/src/software_gl.h new file mode 100644 index 00000000..4c5682cb --- /dev/null +++ b/app/src/software_gl.h @@ -0,0 +1,26 @@ +#pragma once +#include + +namespace vj { +// Windows command-line quoting, including empty arguments and trailing backslashes. +inline std::wstring quoteSoftwareGLArgument(const std::wstring& arg) { + std::wstring out = L"\""; + size_t slashes = 0; + for (wchar_t c : arg) { + if (c == L'\\') { ++slashes; continue; } + out.append(slashes * (c == L'"' ? 2 : 1), L'\\'); + slashes = 0; + if (c == L'"') out += L'\\'; + out += c; + } + out.append(slashes * 2, L'\\'); + return out + L'"'; +} +#ifdef _WIN32 +// Called only after the failed App is destroyed and its instance lock is released. +// Runs at most once and returns the child exit status, preserving CLI/wait semantics. +bool retrySoftwareOpenGL(int argc, char** argv, const std::string& dataDir, int& exitCode); +#else +inline bool retrySoftwareOpenGL(int, char**, const std::string&, int&) { return false; } +#endif +} diff --git a/app/test/shader_tests.cpp b/app/test/shader_tests.cpp index 15d65238..c79929ff 100644 --- a/app/test/shader_tests.cpp +++ b/app/test/shader_tests.cpp @@ -21,6 +21,7 @@ #include "imgui_impl_opengl3_loader.h" #include "config.h" +#include "gl_context.h" #include "paths.h" #include "present_util.h" #include "shaders.h" @@ -480,7 +481,7 @@ static int runGl() { return 77; } SDL_GL_MakeCurrent(win, ctx); - if (imgl3wInit() != 0) { + if (!vj::initOpenGLLoader()) { std::printf("shader_tests gl: FAIL cannot load GL entry points\n"); return 1; } diff --git a/app/tests/gl_context_integration.cpp b/app/tests/gl_context_integration.cpp new file mode 100644 index 00000000..0425daa0 --- /dev/null +++ b/app/tests/gl_context_integration.cpp @@ -0,0 +1,54 @@ +// Real-context regression: SDL_OPENGL_LIBRARY must also supply the UI's GL entry points. +#include "gl_context.h" +#include +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include "imgui_impl_opengl3_loader.h" +#include + +int main(int argc, char** argv) { + if (!SDL_Init(SDL_INIT_VIDEO)) return 77; + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); +#ifdef __APPLE__ + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG); +#endif + SDL_Window* window = SDL_CreateWindow("GL loader regression", 32, 32, SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + SDL_GLContext context = window ? SDL_GL_CreateContext(window) : nullptr; + if (!context) { + std::fprintf(stderr, "SKIP no test GL context: %s\n", SDL_GetError()); + if (window) SDL_DestroyWindow(window); + SDL_Quit(); + return 77; + } + auto getString = reinterpret_cast(SDL_GL_GetProcAddress("glGetString")); + const char* version = getString ? reinterpret_cast(getString(GL_VERSION)) : nullptr; + const bool legacy = argc > 1 && !std::strcmp(argv[1], "legacy"); + std::printf("SDL context version=%s\n", version ? version : "unavailable"); + if (!legacy && !vj::supportsDesktopGL33(version)) { + std::fprintf(stderr, "SKIP driver has no desktop GL 3.3: %s\n", version ? version : "unavailable"); + SDL_GL_DestroyContext(context); + SDL_DestroyWindow(window); + SDL_Quit(); + return 77; + } + // 'legacy' replays the pre-fix independent OS-library loader for before/after evidence. + bool loaded = legacy ? imgl3wInit() == 0 : vj::initOpenGLLoader(); + bool passed = false; + if (loaded && glClearColor && glClear && glReadPixels && glGetString) { + unsigned char pixel[4] = {}; + glClearColor(1.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + passed = pixel[0] > 240 && pixel[1] < 15 && pixel[2] < 15; + std::printf("GL pixel=%u,%u,%u; version=%s\n", pixel[0], pixel[1], pixel[2], glGetString(GL_VERSION)); + } + if (loaded) imgl3wShutdown(); + SDL_GL_DestroyContext(context); + SDL_DestroyWindow(window); + SDL_Quit(); + std::puts(passed ? "PASS GL loader and real framebuffer readback" : "FAIL GL loader or framebuffer readback"); + return passed ? 0 : 1; +} diff --git a/app/tests/gl_context_test.cpp b/app/tests/gl_context_test.cpp new file mode 100644 index 00000000..4ba5fc8f --- /dev/null +++ b/app/tests/gl_context_test.cpp @@ -0,0 +1,16 @@ +#include "gl_context.h" +#include + +int main() { + struct Case { const char* version; bool supported; }; + const Case cases[] = {{nullptr, false}, {"", false}, {"1.1.0", false}, {"2.1 Mesa", false}, + {"3.2", false}, {"3.3 (Core Profile) Mesa", true}, {"4.6.0", true}, + {"OpenGL ES 3.3", false}, {"garbage", false}}; + for (const auto& c : cases) { + if (vj::supportsDesktopGL33(c.version) != c.supported) { + std::fprintf(stderr, "FAIL GL version gate: %s\n", c.version ? c.version : "null"); + return 1; + } + } + std::puts("PASS desktop GL 3.3 gate (including SDL's legacy-context fallback)"); +} diff --git a/app/tests/software_gl_test.cpp b/app/tests/software_gl_test.cpp new file mode 100644 index 00000000..c92877a0 --- /dev/null +++ b/app/tests/software_gl_test.cpp @@ -0,0 +1,36 @@ +#include "software_gl.h" +#include +#include +#ifdef _WIN32 +#include +#include +#endif + +int main() { + const std::vector args = {L"", L"plain", L"path with spaces", L"C:\\", L"C:\\folder\\", L"quoted \"value\"", L"slashes\\\\\"quote", L"Unicode \u2603"}; +#ifdef _WIN32 + std::wstring command = L"program.exe"; + for (const auto& arg : args) command += L" " + vj::quoteSoftwareGLArgument(arg); + int count = 0; + wchar_t** parsed = CommandLineToArgvW(command.c_str(), &count); + bool ok = parsed && count == (int)args.size() + 1; + if (ok) for (size_t i = 0; i < args.size(); ++i) ok = ok && args[i] == parsed[i + 1]; + if (parsed) LocalFree(parsed); + if (!ok) { std::puts("FAIL Windows argument roundtrip"); return 1; } + int exitCode = 123; + SetEnvironmentVariableW(L"VJ_SOFTWARE_OPENGL_RETRY", L"1"); + if (vj::retrySoftwareOpenGL(0, nullptr, "", exitCode) || exitCode != 123) { + std::puts("FAIL software retry loop guard"); return 1; + } + SetEnvironmentVariableW(L"VJ_SOFTWARE_OPENGL_RETRY", nullptr); + // This unit binary's build directory has no staged software-opengl app. + if (vj::retrySoftwareOpenGL(0, nullptr, "", exitCode) || exitCode != 123) { + std::puts("FAIL missing runtime should not launch a child"); return 1; + } +#else + if (vj::quoteSoftwareGLArgument(L"C:\\") != L"\"C:\\\\\"" || vj::quoteSoftwareGLArgument(L"") != L"\"\"") { + std::puts("FAIL Windows trailing-backslash quoting"); return 1; + } +#endif + std::puts("PASS software graphics arguments and retry guards"); +}