From 5ac4db438dae5ee28e5619e62cf8d6647beecd52 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:47:13 +0300 Subject: [PATCH] Fix Windows Vulkan driver discovery and add fast graphics-data checks --- .github/workflows/ci.yml | 40 +++++++- .github/workflows/windows-graphics.yml | 15 ++- tests/runtime_player_tests.cpp | 2 + tools/ci/prepare_windows_vulkan.py | 52 +++++++++- tools/ci/probe_windows_vulkan.py | 127 +++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 tools/ci/probe_windows_vulkan.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 930d3a6..2b4a209 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,32 @@ jobs: - name: Windows compiler environment if: runner.os == 'Windows' uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 + - name: Identify pinned Windows Vulkan SDK cache + if: runner.os == 'Windows' + id: vulkan-identity + run: python tools/ci/prepare_windows_vulkan.py --cache-key + - name: Restore Windows Vulkan SDK + if: runner.os == 'Windows' + id: vulkan-sdk-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + key: ${{ steps.vulkan-identity.outputs.key }}-loader-only + restore-keys: | + ${{ steps.vulkan-identity.outputs.key }} + ${{ steps.vulkan-identity.outputs.legacy }} + path: .cache/windows-graphics/sdk + - name: Build Windows Vulkan headers and loader without a GPU driver + if: runner.os == 'Windows' + run: python tools/ci/prepare_windows_vulkan.py --loader-only + - name: Save successfully built Windows Vulkan SDK + if: runner.os == 'Windows' && steps.vulkan-sdk-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + key: ${{ steps.vulkan-sdk-cache.outputs.cache-primary-key }} + path: .cache/windows-graphics/sdk + - name: Fetch Windows Slang compiler + if: runner.os == 'Windows' + run: python tools/fetch_slang.py - name: Linux build tools if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y clang ninja-build @@ -27,7 +53,7 @@ jobs: run: cmake --preset linux-debug -DFASET_BUILD_RENDERER=OFF -DFASET_BUILD_EDITOR=OFF - name: Configure Windows if: runner.os == 'Windows' - run: cmake --preset windows-debug -DFASET_BUILD_RENDERER=OFF -DFASET_BUILD_EDITOR=OFF + run: cmake --preset windows-debug -DFASET_BUILD_RENDERER=ON -DFASET_BUILD_EDITOR=OFF - name: Build Linux if: runner.os == 'Linux' run: cmake --build --preset linux-debug --parallel 2 @@ -39,7 +65,17 @@ jobs: run: ctest --preset linux-debug - name: Test Windows if: runner.os == 'Windows' - run: ctest --preset windows-debug + run: ctest --preset windows-debug -LE gpu + - name: Preserve Windows CPU test evidence + if: always() && runner.os == 'Windows' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: windows-cpu-evidence + if-no-files-found: warn + include-hidden-files: true + path: | + .cache/windows-graphics/toolchain.json + build/windows-debug/Testing/Temporary/LastTest.log manual: runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/windows-graphics.yml b/.github/workflows/windows-graphics.yml index 83a3c5f..2685f8a 100644 --- a/.github/workflows/windows-graphics.yml +++ b/.github/workflows/windows-graphics.yml @@ -21,11 +21,15 @@ jobs: uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 with: arch: x64 + - name: Identify pinned Vulkan source cache + id: vulkan-identity + run: python tools/ci/prepare_windows_vulkan.py --cache-key - name: Restore pinned Vulkan test tools id: vulkan-cache uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 with: - key: windows-2025-vulkan-${{ hashFiles('tools/ci/prepare_windows_vulkan.py') }} + key: ${{ steps.vulkan-identity.outputs.key }} + restore-keys: ${{ steps.vulkan-identity.outputs.legacy }} path: | .cache/windows-graphics/sdk .cache/windows-graphics/driver @@ -39,6 +43,10 @@ jobs: path: | .cache/windows-graphics/sdk .cache/windows-graphics/driver + - name: Probe Vulkan loader and SwiftShader before compiling the engine + env: + VK_LOADER_DEBUG: all + run: python tools/ci/probe_windows_vulkan.py - name: Fetch checksum-verified Slang compiler run: python tools/fetch_slang.py - name: Configure full editor and Player @@ -46,6 +54,8 @@ jobs: - name: Build full editor and tests run: cmake --build --preset windows-debug --parallel 2 - name: CPU contracts and software GPU pixel tests + env: + VK_LOADER_DEBUG: error,warn,driver run: ctest --preset windows-debug --timeout 180 - name: Real Release exports, 2D and 3D execution, incremental Debug rebuild run: build/windows-debug/faset_build_service_tests.exe --integration .cache/windows-export-e2e @@ -61,6 +71,9 @@ jobs: include-hidden-files: true path: | .cache/windows-graphics/toolchain.json + .cache/windows-graphics/probe.json + .cache/windows-graphics/driver/*.json + .cache/windows-graphics/sdk/source-identity.json build/windows-debug/Testing/Temporary/LastTest.log build/windows-debug/*test.ppm .cache/windows-export-e2e/result-*.json diff --git a/tests/runtime_player_tests.cpp b/tests/runtime_player_tests.cpp index ae467eb..d1a5328 100644 --- a/tests/runtime_player_tests.cpp +++ b/tests/runtime_player_tests.cpp @@ -264,6 +264,8 @@ void run() { auto importedScene = scene; importedScene["entities"][1]["components"][1]["fields"]["asset"] = imported.asset_id; snapshot = view.build(importedScene, 1); + for (const auto& diagnostic : view.diagnostics()) + std::cerr << "Cooked fixture: " << diagnostic << '\n'; check(view.diagnostics().empty(), "valid cooked texture/material produces no error"); check(snapshot.draws.size() == 1 && snapshot.draws[0].mesh->vertices.size() == 3, "cooked mesh reaches render snapshot"); diff --git a/tools/ci/prepare_windows_vulkan.py b/tools/ci/prepare_windows_vulkan.py index 9bbd047..16fe1eb 100644 --- a/tools/ci/prepare_windows_vulkan.py +++ b/tools/ci/prepare_windows_vulkan.py @@ -25,6 +25,23 @@ SOURCES = { "swiftshader": ("google/swiftshader", "1e80438d2b93ef36a7c05f8d2b81233bac0e3d16", "1c3a1afc397c7aa4d3275790bc9b451e80b144a1db665135d5519838bacf44a8"), } +# This exact previous helper cache used the same verified source pins. Allow a +# one-time migration without rebuilding LLVM, but never restore it after pin changes. +LEGACY_SOURCE_IDENTITY = "38d3f6735736a2d0cf25cec2d9edef8b1220f6d10d13a694efb633aa85c3b8fc" +LEGACY_CACHE_KEY = "windows-2025-vulkan-0ca023e20dd35e447b4e13537e95c70859cc7604367979f32c45705b3f370568" + + +def cache_identity() -> str: + return hashlib.sha256(json.dumps(SOURCES, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def stamp(directory: Path, names: tuple[str, ...]) -> None: + identity = {name: list(SOURCES[name]) for name in names} + path = directory / "source-identity.json" + if path.exists() and json.loads(path.read_text(encoding="utf-8")) != identity: + raise RuntimeError(f"Cached tool source identity differs from the pins: {path}") + path.write_text(json.dumps(identity, indent=2) + "\n", encoding="utf-8") + def run(*arguments: str | Path) -> None: command = [str(argument) for argument in arguments] @@ -67,7 +84,18 @@ def configure(source_path: Path, build: Path, *arguments: str) -> None: def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--cache", type=Path, default=Path(".cache/windows-graphics")) + parser.add_argument("--loader-only", action="store_true", help="Build headers/loader only for CPU renderer-linked tests") + parser.add_argument("--cache-key", action="store_true", help="Print source identity and expose Actions cache outputs without building") args = parser.parse_args() + if args.cache_key: + identity = cache_identity() + key = "windows-2025-vulkan-v2-" + identity + legacy = LEGACY_CACHE_KEY if identity == LEGACY_SOURCE_IDENTITY else "" + print(key) + if "GITHUB_OUTPUT" in os.environ: + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"key={key}\nlegacy={legacy}\n") + return 0 if sys.platform != "win32": parser.error("Run this helper on Windows in a Visual Studio x64 developer environment") for program in ("cmake", "ninja", "cl"): @@ -83,7 +111,8 @@ def main() -> int: configure(loader, cache / "loader-build", f"-DCMAKE_INSTALL_PREFIX={sdk}", f"-DCMAKE_PREFIX_PATH={sdk}", "-DBUILD_TESTS=OFF", "-DBUILD_WERROR=OFF") run("cmake", "--build", cache / "loader-build", "--parallel", "2") run("cmake", "--install", cache / "loader-build") - if not (driver / "vk_swiftshader_icd.json").is_file(): + stamp(sdk, ("headers", "loader")) + if not args.loader_only and not (driver / "vk_swiftshader_icd.json").is_file(): swift = source(cache, "swiftshader") build = cache / "swiftshader-build" configure(swift, build, "-DSWIFTSHADER_BUILD_TESTS=OFF", "-DSWIFTSHADER_BUILD_BENCHMARKS=OFF", "-DSWIFTSHADER_BUILD_PVR=OFF", "-DSWIFTSHADER_WARNINGS_AS_ERRORS=OFF") @@ -93,14 +122,29 @@ def main() -> int: library = (manifest_directory / manifest["ICD"]["library_path"]).resolve() driver.mkdir(parents=True, exist_ok=True) shutil.copy2(library, driver / library.name) - manifest["ICD"]["library_path"] = "./" + library.name + # Khronos' Windows loader identifies relative paths by a backslash, not + # a forward slash. './name.dll' otherwise remains relative to the cwd. + manifest["ICD"]["library_path"] = ".\\" + library.name (driver / "vk_swiftshader_icd.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") - report = {"sources": {name: {"repository": repository, "commit": commit, "sha256": digest} for name, (repository, commit, digest) in SOURCES.items()}, "validation_layer": False, "vulkan_sdk": str(sdk), "driver": str(driver / "vk_swiftshader_icd.json")} + if not args.loader_only: + # Repair the old separator on cache hits as well as newly built bundles. + manifest_path = driver / "vk_swiftshader_icd.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + library_name = Path(manifest["ICD"]["library_path"]).name + if not (driver / library_name).is_file(): + raise RuntimeError("Cached SwiftShader manifest points to a missing DLL") + manifest["ICD"]["library_path"] = ".\\" + library_name + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + stamp(driver, ("swiftshader",)) + used_sources = {name: source for name, source in SOURCES.items() if not args.loader_only or name != "swiftshader"} + report = {"sources": {name: {"repository": repository, "commit": commit, "sha256": digest} for name, (repository, commit, digest) in used_sources.items()}, "validation_layer": False, "vulkan_sdk": str(sdk), "driver": None if args.loader_only else str(driver / "vk_swiftshader_icd.json"), "loader_only": args.loader_only} cache.mkdir(parents=True, exist_ok=True) (cache / "toolchain.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") if "GITHUB_ENV" in os.environ: with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output: - output.write(f"VULKAN_SDK={sdk}\nVK_DRIVER_FILES={report['driver']}\nVK_ICD_FILENAMES={report['driver']}\n") + output.write(f"VULKAN_SDK={sdk}\n") + if report["driver"]: + output.write(f"VK_DRIVER_FILES={report['driver']}\nVK_ICD_FILENAMES={report['driver']}\n") with open(os.environ["GITHUB_PATH"], "a", encoding="utf-8") as output: output.write(str(sdk / "bin") + "\n") print(json.dumps(report, indent=2)) diff --git a/tools/ci/probe_windows_vulkan.py b/tools/ci/probe_windows_vulkan.py new file mode 100644 index 0000000..f8140a6 --- /dev/null +++ b/tools/ci/probe_windows_vulkan.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Verify the prepared Windows Vulkan DLL/ICD before spending time on engine builds. + +Use VK_LOADER_DEBUG=all to retain the loader's own driver-discovery diagnostics. +This probe uses ctypes and the Vulkan ABI, and needs neither the engine nor Slang. +""" +from __future__ import annotations + +import argparse +import ctypes as ct +import json +import os +from pathlib import Path +import sys + + +class Application(ct.Structure): + _fields_ = [("sType", ct.c_uint32), ("pNext", ct.c_void_p), ("pApplicationName", ct.c_char_p), + ("applicationVersion", ct.c_uint32), ("pEngineName", ct.c_char_p), + ("engineVersion", ct.c_uint32), ("apiVersion", ct.c_uint32)] + + +class InstanceCreate(ct.Structure): + _fields_ = [("sType", ct.c_uint32), ("pNext", ct.c_void_p), ("flags", ct.c_uint32), + ("pApplicationInfo", ct.POINTER(Application)), ("enabledLayerCount", ct.c_uint32), + ("ppEnabledLayerNames", ct.POINTER(ct.c_char_p)), ("enabledExtensionCount", ct.c_uint32), + ("ppEnabledExtensionNames", ct.POINTER(ct.c_char_p))] + + +def version(value: int) -> str: + return f"{value >> 22}.{(value >> 12) & 1023}.{value & 4095}" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cache", type=Path, default=Path(".cache/windows-graphics")) + args = parser.parse_args() + if sys.platform != "win32": + parser.error("This probe verifies the native Windows DLL loader") + cache = args.cache.resolve() + manifest_path = cache / "driver/vk_swiftshader_icd.json" + loader_path = cache / "sdk/bin/vulkan-1.dll" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + library = (manifest_path.parent / manifest["ICD"]["library_path"]).resolve() + report = {"format": "faset.windows-vulkan-probe", "version": 1, "status": "running", + "loader": str(loader_path), "driver_manifest": str(manifest_path), + "driver_library": str(library), "manifest": manifest, + "loader_debug": os.environ.get("VK_LOADER_DEBUG"), "pointer_bits": ct.sizeof(ct.c_void_p) * 8} + instance = ct.c_void_p() + loader = None + try: + if not loader_path.is_file() or not library.is_file(): + raise RuntimeError("Prepared Vulkan loader or driver DLL is missing") + if not manifest["ICD"]["library_path"].startswith(".\\"): + raise RuntimeError("Windows ICD relative library path must use a native backslash") + os.environ["VK_DRIVER_FILES"] = str(manifest_path) + os.environ["VK_ICD_FILENAMES"] = str(manifest_path) + # Direct DLL loading separates Windows dependency/architecture errors from + # manifest discovery and Vulkan API compatibility errors in the next step. + driver = ct.WinDLL(str(library)) + negotiate = driver.vk_icdNegotiateLoaderICDInterfaceVersion + negotiate.argtypes = [ct.POINTER(ct.c_uint32)] + negotiate.restype = ct.c_int32 + interface_version = ct.c_uint32(7) + report["icd_negotiate_result"] = negotiate(ct.byref(interface_version)) + report["icd_interface_version"] = interface_version.value + if report["icd_negotiate_result"] != 0: + raise RuntimeError("SwiftShader rejected the loader interface negotiation") + loader = ct.WinDLL(str(loader_path)) + enumerate_version = loader.vkEnumerateInstanceVersion + enumerate_version.argtypes = [ct.POINTER(ct.c_uint32)] + enumerate_version.restype = ct.c_int32 + api = ct.c_uint32() + report["enumerate_version_result"] = enumerate_version(ct.byref(api)) + report["loader_api_version"] = version(api.value) + create = loader.vkCreateInstance + create.argtypes = [ct.POINTER(InstanceCreate), ct.c_void_p, ct.POINTER(ct.c_void_p)] + create.restype = ct.c_int32 + app = Application(0, None, b"Faset Windows Vulkan probe", 1, b"Faset", 1, (1 << 22) | (3 << 12)) + info = InstanceCreate(1, None, 0, ct.pointer(app), 0, None, 0, None) + result = create(ct.byref(info), None, ct.byref(instance)) + report["create_instance_result"] = result + if result != 0: + raise RuntimeError(f"vkCreateInstance for Vulkan 1.3 failed: {result}") + enumerate_devices = loader.vkEnumeratePhysicalDevices + enumerate_devices.argtypes = [ct.c_void_p, ct.POINTER(ct.c_uint32), ct.POINTER(ct.c_void_p)] + enumerate_devices.restype = ct.c_int32 + count = ct.c_uint32() + if enumerate_devices(instance, ct.byref(count), None) != 0 or count.value == 0: + raise RuntimeError("The loader found no usable Vulkan physical device") + devices = (ct.c_void_p * count.value)() + if enumerate_devices(instance, ct.byref(count), devices) != 0: + raise RuntimeError("Cannot enumerate Vulkan physical devices") + properties = loader.vkGetPhysicalDeviceProperties + properties.argtypes = [ct.c_void_p, ct.c_void_p] + properties.restype = None + report["devices"] = [] + for device in devices: + # VkPhysicalDeviceProperties starts with five uint32 values and a + # 256-byte deviceName. Reserve ample room for the limits that follow. + storage = (ct.c_uint64 * 512)() + properties(device, ct.byref(storage)) + raw = bytes(storage) + values = [int.from_bytes(raw[index:index + 4], "little") for index in range(0, 20, 4)] + name = raw[20:276].split(b"\0", 1)[0].decode("utf-8") + report["devices"].append({"name": name, "api_version": version(values[0]), + "driver_version": values[1], "vendor_id": values[2], + "device_id": values[3], "device_type": values[4]}) + if not any("SwiftShader" in device["name"] for device in report["devices"]): + raise RuntimeError("Pinned SwiftShader was not among the enumerated devices") + report["status"] = "passed" + except Exception as error: + report.update({"status": "failed", "error": str(error)}) + raise + finally: + if loader is not None and instance.value: + destroy = loader.vkDestroyInstance + destroy.argtypes = [ct.c_void_p, ct.c_void_p] + destroy.restype = None + destroy(instance, None) + (cache / "probe.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(json.dumps(report, indent=2), flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())