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 index 6b4cab4b..28f27bcb 100644 --- a/.github/workflows/windows-gl-test.yml +++ b/.github/workflows/windows-gl-test.yml @@ -4,9 +4,15 @@ on: branches: [develop] 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: @@ -40,66 +46,31 @@ jobs: 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 -j4 + 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 stage/bin/software-opengl diagnostics - python - <<'PY' - import pathlib, re, shutil, subprocess - def windows_path(path): - return pathlib.Path(subprocess.check_output(['cygpath', '-m', str(path)], text=True).strip()) - source = windows_path('/clangarm64/bin') - target = pathlib.Path('stage/bin/software-opengl') - 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(['llvm-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(pathlib.Path('stage/bin').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) - shutil.copy2('build/vj_gl_context_integration.exe', target) - (target / 'portable.txt').write_text('Keep software-test settings beside this executable.\n') - PY + 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 software OpenGL' diagnostics/automatic-stderr.txt + grep -q 'renderer=llvmpipe' 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 @@ -120,15 +91,7 @@ jobs: - name: Package the portable test build run: | set -eu - python - <<'PY' - import pathlib - root = pathlib.Path('stage/bin') - normal = '@echo off\ncd /d "%~dp0"\nvirtualjaguar.exe >test-stdout.txt 2>test-stderr.txt\necho Exit code: %errorlevel%>>test-stderr.txt\nnotepad test-stderr.txt\n' - software = '@echo off\nsetlocal\ncd /d "%~dp0software-opengl"\nset "PATH=%~dp0software-opengl;%PATH%"\nset "SDL_OPENGL_LIBRARY=%~dp0software-opengl\\opengl32.dll"\nset "GALLIUM_DRIVER=llvmpipe"\nset "LIBGL_ALWAYS_SOFTWARE=true"\nvirtualjaguar.exe >"%~dp0software-stdout.txt" 2>"%~dp0software-stderr.txt"\necho Exit code: %errorlevel%>>"%~dp0software-stderr.txt"\nnotepad "%~dp0software-stderr.txt"\n' - (root / 'Test-Normal-Graphics.cmd').write_bytes(normal.replace('\n','\r\n').encode()) - (root / 'Test-Software-Graphics.cmd').write_bytes(software.replace('\n','\r\n').encode()) - (root / 'TEST-BUILD.txt').write_text('ARM64 diagnostic candidate. Try Test-Normal-Graphics.cmd first, then Test-Software-Graphics.cmd. Close the app to open its log. Software mode uses CPU rendering and changes no system settings. Not a Store package.\n') - PY + printf '%s\n' 'Open virtualjaguar.exe normally. If its graphics driver cannot initialize, it automatically retries with bundled software OpenGL. CPU rendering can be slower. 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 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 e72d8b1b..5b5a95c0 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -444,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/packaging/README.md b/app/packaging/README.md index 78bb20e8..b08f8d57 100644 --- a/app/packaging/README.md +++ b/app/packaging/README.md @@ -423,3 +423,11 @@ 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 Mesa software OpenGL through +`windows/stage-software-gl.py` (MSYS2 Mesa, Python and objdump required). +The normal executable automatically retries once with the bundled software app +if graphics startup fails. The retry preserves game arguments, working directory +and the original config/save root; users do not need a separate launcher. +Software rendering uses the CPU and can be slower. Keep its dependency notices +and matching architecture alongside the software executable. diff --git a/app/packaging/windows/msix/README.md b/app/packaging/windows/msix/README.md index f68c88c7..4efe9934 100644 --- a/app/packaging/windows/msix/README.md +++ b/app/packaging/windows/msix/README.md @@ -80,3 +80,10 @@ 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 CPU +renderer, preserving command-line arguments and the original data root. 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 c484ea67..630e98a5 100644 --- a/app/src/app.cpp +++ b/app/src/app.cpp @@ -440,10 +440,7 @@ bool App::initWindow() { std::fprintf(stderr, "cannot make OpenGL context current: %s\n", SDL_GetError()); return false; } - if (!initOpenGLLoader()) { - SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Virtual Jaguar graphics initialization", SDL_GetError(), window_); - 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"); @@ -2582,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/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..e307889d --- /dev/null +++ b/app/src/platform/software_gl_windows.cpp @@ -0,0 +1,99 @@ +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include "software_gl.h" +#include +#include +#include +#include + +namespace vj { +namespace { +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"GALLIUM_DRIVER=", 15) || + !_wcsnicmp(p, L"LIBGL_ALWAYS_SOFTWARE=", 22) || !_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"GALLIUM_DRIVER=llvmpipe"); + entries.push_back(L"LIBGL_ALWAYS_SOFTWARE=true"); + entries.push_back(L"VJ_SOFTWARE_OPENGL_RETRY=1"); + 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 software OpenGL\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/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"); +}