From 95650a942431cb9ffeaa050178a29006bfd43c72 Mon Sep 17 00:00:00 2001 From: Jesse Talavera Date: Tue, 14 Jul 2026 21:10:59 -0400 Subject: [PATCH 1/6] Release 0.8.2 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19eaadf..344da65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > breaking changes may be introduced > at any time without warning. -## [Unreleased] +## [0.8.2] - 2026-07-14 ### Fixed From 4e02f0e5117c8343ee02a80c051dcac652054f1b Mon Sep 17 00:00:00 2001 From: Eric Warmenhoven Date: Thu, 23 Jul 2026 16:15:33 -0400 Subject: [PATCH 2/6] vulkan api support --- CHANGELOG.md | 21 + cmake/LibretroSamples.cmake | 24 + cmake/SampleCore.cmake | 6 + docs/guide/vulkan.rst | 59 + docs/index.rst | 1 + pyproject.toml | 4 +- src/libretro/api/video/__init__.py | 1 + src/libretro/api/video/vulkan.py | 346 +++++ src/libretro/drivers/environment/composite.py | 43 +- src/libretro/drivers/video/__init__.py | 5 + src/libretro/drivers/video/driver.py | 31 + src/libretro/drivers/video/multi.py | 48 + src/libretro/drivers/video/opengl/moderngl.py | 16 + src/libretro/drivers/video/software/base.py | 20 + src/libretro/drivers/video/vulkan/__init__.py | 24 + src/libretro/drivers/video/vulkan/driver.py | 1180 +++++++++++++++++ src/libretro/samples/video/__init__.py | 8 +- tests/integration/test_vulkan_driver.py | 42 + tests/unit/api/test_video_vulkan.py | 95 ++ tests/unit/drivers/test_video_negotiation.py | 105 ++ tests/unit/drivers/test_vulkan_driver.py | 330 +++++ 21 files changed, 2402 insertions(+), 7 deletions(-) create mode 100644 docs/guide/vulkan.rst create mode 100644 src/libretro/api/video/vulkan.py create mode 100644 src/libretro/drivers/video/vulkan/__init__.py create mode 100644 src/libretro/drivers/video/vulkan/driver.py create mode 100644 tests/integration/test_vulkan_driver.py create mode 100644 tests/unit/api/test_video_vulkan.py create mode 100644 tests/unit/drivers/test_video_negotiation.py create mode 100644 tests/unit/drivers/test_vulkan_driver.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 344da65..d11bed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > breaking changes may be introduced > at any time without warning. +## [Unreleased] + +### Added + +- Added `VulkanVideoDriver`, a headless Vulkan video driver + implementing the full `retro_hw_render_interface_vulkan` (version 5) + and the Vulkan context negotiation interface, + enabling Vulkan-only cores (e.g. Azahar) to run under libretro.py. + Requires the new `libretro.py[vulkan]` extra + and a Vulkan loader (MoltenVK on macOS). +- Added ctypes bindings for the types in `libretro_vulkan.h` + to `libretro.api.video`. +- Implemented `RETRO_ENVIRONMENT_SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE` + and `RETRO_ENVIRONMENT_GET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_SUPPORT`, + and added a `context_negotiation_interface` property to `VideoDriver`. + +### Fixed + +- Fixed `RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE` writing the interface struct + by value instead of writing a pointer to it. + ## [0.8.2] - 2026-07-14 ### Fixed diff --git a/cmake/LibretroSamples.cmake b/cmake/LibretroSamples.cmake index 22b6ed7..9b6515e 100644 --- a/cmake/LibretroSamples.cmake +++ b/cmake/LibretroSamples.cmake @@ -169,3 +169,27 @@ else() "skipping GL sample cores.") endif() endif() + +# --- video/vulkan/ -------------------------------------------------------- +# +# The Vulkan core resolves every entry point at runtime through +# ``get_instance_proc_addr`` (via the upstream ``vulkan_symbol_wrapper``), +# so only the Vulkan *headers* are needed at build time — no loader library +# is linked. Skip the core when the headers aren't installed. +find_package(Vulkan QUIET) + +if(Vulkan_INCLUDE_DIRS) + add_sample_core( + NAME vulkan_rendering + CATEGORY video + SOURCES + "${_lrs_src}/video/vulkan/vk_rendering/libretro-test.c" + "${_lrs_src}/video/vulkan/vk_rendering/vulkan_symbol_wrapper.c" + INCLUDES + "${_lrs_src}/video/vulkan/vk_rendering" + ${Vulkan_INCLUDE_DIRS} + COMPILE_DEFINITIONS ${_lrs_common_defs} VK_NO_PROTOTYPES + ) +else() + message(STATUS "libretro.py: Vulkan headers not found; skipping the Vulkan sample core.") +endif() diff --git a/cmake/SampleCore.cmake b/cmake/SampleCore.cmake index 33c8542..69e4738 100644 --- a/cmake/SampleCore.cmake +++ b/cmake/SampleCore.cmake @@ -54,6 +54,12 @@ function(add_sample_core) OUTPUT_NAME "${SC_NAME}_libretro" ) + if(APPLE) + # MODULE libraries default to ".so" on macOS, + # but frontends (and libretro.samples._loader) expect ".dylib" + set_target_properties(${SC_NAME} PROPERTIES SUFFIX ".dylib") + endif() + if(WIN32) # libretro.h declares the public entry points with __declspec(dllexport), # so explicit per-symbol exports already cover the API. Setting this diff --git a/docs/guide/vulkan.rst b/docs/guide/vulkan.rst new file mode 100644 index 0000000..999305b --- /dev/null +++ b/docs/guide/vulkan.rst @@ -0,0 +1,59 @@ +Running Vulkan Cores +==================== + +libretro.py includes :class:`.VulkanVideoDriver`, +a headless video driver for cores that render with Vulkan +(``RETRO_HW_CONTEXT_VULKAN``), +such as Azahar or other 3D-heavy emulators +whose OpenGL renderers are unavailable or too slow on some platforms. + +The driver implements the full version 5 +:class:`.retro_hw_render_interface_vulkan` +and the Vulkan context negotiation interface; +each hardware frame is copied into host memory, +so :meth:`.VideoDriver.screenshot` works the same way it does +with the OpenGL and software drivers. +There is no window or swapchain. + +Installation +------------ + +Install the ``vulkan`` extra alongside libretro.py:: + + pip install libretro.py[vulkan] + +The driver also needs a Vulkan loader library at runtime: + +Linux + Install the Vulkan loader from your package manager + (e.g. ``libvulkan1`` on Debian/Ubuntu). + +Windows + ``vulkan-1.dll`` ships with your graphics driver. + +macOS + Install MoltenVK_ and the Vulkan loader, + e.g. with Homebrew:: + + brew install molten-vk vulkan-loader + + A Homebrew-installed Python will find ``libvulkan.dylib`` automatically. + Other Python builds may need the library path set at launch:: + + DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib python my_script.py + +Usage +----- + +When the ``vulkan`` extra is installed, +:data:`.DEFAULT_DRIVER_MAP` automatically maps +:attr:`.HardwareContext.VULKAN` to :class:`.VulkanVideoDriver`, +so a default :class:`.Session` will use it +whenever a core requests a Vulkan context. +To use it explicitly:: + + from libretro.drivers.video.vulkan import VulkanVideoDriver + + driver = VulkanVideoDriver() + +.. _MoltenVK: https://github.com/KhronosGroup/MoltenVK diff --git a/docs/index.rst b/docs/index.rst index 48e557a..ba0c4fc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,6 +19,7 @@ Ease of use, flexibility, and complete API support are top priorities. :caption: Guides guide/env + guide/vulkan guide/renderdoc guide/glossary diff --git a/pyproject.toml b/pyproject.toml index cfd5467..72a3a61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,8 @@ dev = [ cli = ['typer == 0.25.1'] opengl = ['moderngl[headless] >= 5.12', 'PyOpenGL == 3.1.*'] opengl-window = ['moderngl-window == 3.*', "libretro.py[opengl]"] -all = ["libretro.py[cli,opengl,opengl-window]"] +vulkan = ['vulkan == 1.3.*'] +all = ["libretro.py[cli,opengl,opengl-window,vulkan]"] [project.urls] Homepage = "https://github.com/JesseTG/libretro.py" @@ -166,6 +167,7 @@ testpaths = ["tests", "src"] markers = [ "isolated: run the test in a fresh subprocess (provided by pytest-isolated)", "opengl: requires the libretro.py[opengl] extra and a working GL context", + "vulkan: requires the libretro.py[vulkan] extra and a working Vulkan installation", "slow: long-running; excluded from PR CI by default", ] required_plugins = ["pytest-assert-type", "hypothesis", "pytest-isolated"] diff --git a/src/libretro/api/video/__init__.py b/src/libretro/api/video/__init__.py index 6d5eb66..6d75c7f 100644 --- a/src/libretro/api/video/__init__.py +++ b/src/libretro/api/video/__init__.py @@ -14,3 +14,4 @@ from .frame import * from .negotiate import * from .render import * +from .vulkan import * diff --git a/src/libretro/api/video/vulkan.py b/src/libretro/api/video/vulkan.py new file mode 100644 index 0000000..7b44d63 --- /dev/null +++ b/src/libretro/api/video/vulkan.py @@ -0,0 +1,346 @@ +""" +Vulkan hardware rendering types. + +Corresponds to the types in ``libretro_vulkan.h``, +including the mirrored Vulkan API structs +that appear in the libretro ABI by value. + +Vulkan handles are represented as plain integers at this layer: +dispatchable handles (``VkInstance``, ``VkDevice``, ...) as :class:`~ctypes.c_void_p` +and non-dispatchable handles (``VkImage``, ``VkSemaphore``, ...) as :class:`~ctypes.c_uint64`. +Only 64-bit platforms are supported. +""" + +from ctypes import ( + CFUNCTYPE, + POINTER, + Structure, + c_bool, + c_char_p, + c_int, + c_uint, + c_uint32, + c_uint64, + c_void_p, +) + +from .negotiate import retro_hw_render_context_negotiation_interface +from .render import retro_hw_render_interface + +RETRO_HW_RENDER_INTERFACE_VULKAN_VERSION = 5 +RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION = 2 + +# Dispatchable Vulkan handles (pointers to opaque driver objects) +VkInstance = c_void_p +VkPhysicalDevice = c_void_p +VkDevice = c_void_p +VkQueue = c_void_p +VkCommandBuffer = c_void_p + +# Non-dispatchable Vulkan handles (64-bit integers) +VkImage = c_uint64 +VkImageView = c_uint64 +VkSemaphore = c_uint64 +VkSurfaceKHR = c_uint64 + +VkBool32 = c_uint32 +VkImageLayout = c_int +VkFormat = c_int +VkStructureType = c_int + +PFN_vkGetInstanceProcAddr = c_void_p +"""Treated as an opaque function pointer at the ABI boundary.""" + +PFN_vkGetDeviceProcAddr = c_void_p +"""Treated as an opaque function pointer at the ABI boundary.""" + + +class VkApplicationInfo(Structure): + """Corresponds to :c:type:`VkApplicationInfo` in ``vulkan_core.h``.""" + + _fields_ = ( + ("sType", VkStructureType), + ("pNext", c_void_p), + ("pApplicationName", c_char_p), + ("applicationVersion", c_uint32), + ("pEngineName", c_char_p), + ("engineVersion", c_uint32), + ("apiVersion", c_uint32), + ) + + +class VkComponentMapping(Structure): + """Corresponds to :c:type:`VkComponentMapping` in ``vulkan_core.h``.""" + + _fields_ = ( + ("r", c_int), + ("g", c_int), + ("b", c_int), + ("a", c_int), + ) + + +class VkImageSubresourceRange(Structure): + """Corresponds to :c:type:`VkImageSubresourceRange` in ``vulkan_core.h``.""" + + _fields_ = ( + ("aspectMask", c_uint32), + ("baseMipLevel", c_uint32), + ("levelCount", c_uint32), + ("baseArrayLayer", c_uint32), + ("layerCount", c_uint32), + ) + + +class VkImageViewCreateInfo(Structure): + """Corresponds to :c:type:`VkImageViewCreateInfo` in ``vulkan_core.h``.""" + + _fields_ = ( + ("sType", VkStructureType), + ("pNext", c_void_p), + ("flags", c_uint32), + ("image", VkImage), + ("viewType", c_int), + ("format", VkFormat), + ("components", VkComponentMapping), + ("subresourceRange", VkImageSubresourceRange), + ) + + +class VkPhysicalDeviceFeatures(Structure): + """Corresponds to :c:type:`VkPhysicalDeviceFeatures` in ``vulkan_core.h``.""" + + _fields_ = tuple( + (name, VkBool32) + for name in ( + "robustBufferAccess", + "fullDrawIndexUint32", + "imageCubeArray", + "independentBlend", + "geometryShader", + "tessellationShader", + "sampleRateShading", + "dualSrcBlend", + "logicOp", + "multiDrawIndirect", + "drawIndirectFirstInstance", + "depthClamp", + "depthBiasClamp", + "fillModeNonSolid", + "depthBounds", + "wideLines", + "largePoints", + "alphaToOne", + "multiViewport", + "samplerAnisotropy", + "textureCompressionETC2", + "textureCompressionASTC_LDR", + "textureCompressionBC", + "occlusionQueryPrecise", + "pipelineStatisticsQuery", + "vertexPipelineStoresAndAtomics", + "fragmentStoresAndAtomics", + "shaderTessellationAndGeometryPointSize", + "shaderImageGatherExtended", + "shaderStorageImageExtendedFormats", + "shaderStorageImageMultisample", + "shaderStorageImageReadWithoutFormat", + "shaderStorageImageWriteWithoutFormat", + "shaderUniformBufferArrayDynamicIndexing", + "shaderSampledImageArrayDynamicIndexing", + "shaderStorageBufferArrayDynamicIndexing", + "shaderStorageImageArrayDynamicIndexing", + "shaderClipDistance", + "shaderCullDistance", + "shaderFloat64", + "shaderInt64", + "shaderInt16", + "shaderResourceResidency", + "shaderResourceMinLod", + "sparseBinding", + "sparseResidencyBuffer", + "sparseResidencyImage2D", + "sparseResidencyImage3D", + "sparseResidency2Samples", + "sparseResidency4Samples", + "sparseResidency8Samples", + "sparseResidency16Samples", + "sparseResidencyAliased", + "variableMultisampleRate", + "inheritedQueries", + ) + ) + + +class retro_vulkan_image(Structure): + """Corresponds to :c:type:`retro_vulkan_image` in ``libretro_vulkan.h``.""" + + _fields_ = ( + ("image_view", VkImageView), + ("image_layout", VkImageLayout), + ("create_info", VkImageViewCreateInfo), + ) + + +class retro_vulkan_context(Structure): + """Corresponds to :c:type:`retro_vulkan_context` in ``libretro_vulkan.h``.""" + + _fields_ = ( + ("gpu", VkPhysicalDevice), + ("device", VkDevice), + ("queue", VkQueue), + ("queue_family_index", c_uint32), + ("presentation_queue", VkQueue), + ("presentation_queue_family_index", c_uint32), + ) + + +retro_vulkan_set_image_t = CFUNCTYPE( + None, c_void_p, POINTER(retro_vulkan_image), c_uint32, POINTER(VkSemaphore), c_uint32 +) +retro_vulkan_get_sync_index_t = CFUNCTYPE(c_uint32, c_void_p) +retro_vulkan_get_sync_index_mask_t = CFUNCTYPE(c_uint32, c_void_p) +retro_vulkan_set_command_buffers_t = CFUNCTYPE(None, c_void_p, c_uint32, POINTER(VkCommandBuffer)) +retro_vulkan_wait_sync_index_t = CFUNCTYPE(None, c_void_p) +retro_vulkan_lock_queue_t = CFUNCTYPE(None, c_void_p) +retro_vulkan_unlock_queue_t = CFUNCTYPE(None, c_void_p) +retro_vulkan_set_signal_semaphore_t = CFUNCTYPE(None, c_void_p, VkSemaphore) + +retro_vulkan_get_application_info_t = CFUNCTYPE(POINTER(VkApplicationInfo)) +retro_vulkan_create_device_t = CFUNCTYPE( + c_bool, + POINTER(retro_vulkan_context), + VkInstance, + VkPhysicalDevice, + VkSurfaceKHR, + PFN_vkGetInstanceProcAddr, + POINTER(c_char_p), + c_uint, + POINTER(c_char_p), + c_uint, + POINTER(VkPhysicalDeviceFeatures), +) +retro_vulkan_destroy_device_t = CFUNCTYPE(None) + +retro_vulkan_create_instance_wrapper_t = CFUNCTYPE(VkInstance, c_void_p, c_void_p) +"""The second parameter is a ``const VkInstanceCreateInfo *``, opaque at this layer.""" + +retro_vulkan_create_instance_t = CFUNCTYPE( + VkInstance, + PFN_vkGetInstanceProcAddr, + POINTER(VkApplicationInfo), + retro_vulkan_create_instance_wrapper_t, + c_void_p, +) + +retro_vulkan_create_device_wrapper_t = CFUNCTYPE(VkDevice, VkPhysicalDevice, c_void_p, c_void_p) +"""The third parameter is a ``const VkDeviceCreateInfo *``, opaque at this layer.""" + +retro_vulkan_create_device2_t = CFUNCTYPE( + c_bool, + POINTER(retro_vulkan_context), + VkInstance, + VkPhysicalDevice, + VkSurfaceKHR, + PFN_vkGetInstanceProcAddr, + retro_vulkan_create_device_wrapper_t, + c_void_p, +) + + +class retro_hw_render_interface_vulkan(retro_hw_render_interface): + """ + Corresponds to :c:type:`retro_hw_render_interface_vulkan` in ``libretro_vulkan.h``. + + Filled by the frontend and fetched by the core + through :attr:`.EnvironmentCall.GET_HW_RENDER_INTERFACE`. + Extends :class:`.retro_hw_render_interface`, + so a pointer to this struct may be reinterpreted as its base. + """ + + _fields_ = ( + ("handle", c_void_p), + ("instance", VkInstance), + ("gpu", VkPhysicalDevice), + ("device", VkDevice), + ("get_device_proc_addr", PFN_vkGetDeviceProcAddr), + ("get_instance_proc_addr", PFN_vkGetInstanceProcAddr), + ("queue", VkQueue), + ("queue_index", c_uint), + ("set_image", retro_vulkan_set_image_t), + ("get_sync_index", retro_vulkan_get_sync_index_t), + ("get_sync_index_mask", retro_vulkan_get_sync_index_mask_t), + ("set_command_buffers", retro_vulkan_set_command_buffers_t), + ("wait_sync_index", retro_vulkan_wait_sync_index_t), + ("lock_queue", retro_vulkan_lock_queue_t), + ("unlock_queue", retro_vulkan_unlock_queue_t), + ("set_signal_semaphore", retro_vulkan_set_signal_semaphore_t), + ) + + +class retro_hw_render_context_negotiation_interface_vulkan( + retro_hw_render_context_negotiation_interface +): + """ + Corresponds to :c:type:`retro_hw_render_context_negotiation_interface_vulkan` + in ``libretro_vulkan.h``. + + Provided by the core + through :attr:`.EnvironmentCall.SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE`. + Extends :class:`.retro_hw_render_context_negotiation_interface`. + This is the version 2 layout; + cores that only know version 1 leave the trailing fields unset. + """ + + _fields_ = ( + ("get_application_info", retro_vulkan_get_application_info_t), + ("create_device", retro_vulkan_create_device_t), + ("destroy_device", retro_vulkan_destroy_device_t), + ("create_instance", retro_vulkan_create_instance_t), + ("create_device2", retro_vulkan_create_device2_t), + ) + + +__all__ = [ + "RETRO_HW_RENDER_INTERFACE_VULKAN_VERSION", + "RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION", + "VkInstance", + "VkPhysicalDevice", + "VkDevice", + "VkQueue", + "VkCommandBuffer", + "VkImage", + "VkImageView", + "VkSemaphore", + "VkSurfaceKHR", + "VkBool32", + "VkImageLayout", + "VkFormat", + "VkStructureType", + "PFN_vkGetInstanceProcAddr", + "PFN_vkGetDeviceProcAddr", + "VkApplicationInfo", + "VkComponentMapping", + "VkImageSubresourceRange", + "VkImageViewCreateInfo", + "VkPhysicalDeviceFeatures", + "retro_vulkan_image", + "retro_vulkan_context", + "retro_vulkan_set_image_t", + "retro_vulkan_get_sync_index_t", + "retro_vulkan_get_sync_index_mask_t", + "retro_vulkan_set_command_buffers_t", + "retro_vulkan_wait_sync_index_t", + "retro_vulkan_lock_queue_t", + "retro_vulkan_unlock_queue_t", + "retro_vulkan_set_signal_semaphore_t", + "retro_vulkan_get_application_info_t", + "retro_vulkan_create_device_t", + "retro_vulkan_destroy_device_t", + "retro_vulkan_create_instance_wrapper_t", + "retro_vulkan_create_instance_t", + "retro_vulkan_create_device_wrapper_t", + "retro_vulkan_create_device2_t", + "retro_hw_render_interface_vulkan", + "retro_hw_render_context_negotiation_interface_vulkan", +] diff --git a/src/libretro/drivers/environment/composite.py b/src/libretro/drivers/environment/composite.py index 758c57b..2dea4b6 100644 --- a/src/libretro/drivers/environment/composite.py +++ b/src/libretro/drivers/environment/composite.py @@ -16,6 +16,8 @@ from collections.abc import Sequence from copy import deepcopy from ctypes import ( + POINTER, + addressof, c_bool, c_char_p, c_double, @@ -1261,7 +1263,9 @@ def _get_hw_render_interface(self, interface: TypedPointer[retro_hw_render_inter # This video driver doesn't provide (or need) a hardware render interface return False - interface[0] = driver_interface + # The data is a retro_hw_render_interface **; + # write the address of the driver's (long-lived) interface struct into it + cast(interface, POINTER(c_void_p))[0] = addressof(driver_interface) return True @property @@ -1288,7 +1292,25 @@ def _set_support_achievements(self, support: TypedPointer[c_bool]) -> bool: def _set_hw_render_context_negotiation_interface( self, interface: TypedPointer[retro_hw_render_context_negotiation_interface] ) -> bool: - return False # TODO: Implement + if not interface: + raise ValueError( + "RETRO_ENVIRONMENT_SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE doesn't accept NULL" + ) + + iface: retro_hw_render_context_negotiation_interface = interface[0] + if iface.interface_type == ContextNegotiationInterfaceType.VULKAN: + # Reinterpret the core's struct as the full Vulkan negotiation interface + iface = cast(interface, POINTER(retro_hw_render_context_negotiation_interface_vulkan))[ + 0 + ] + + try: + self._video.context_negotiation_interface = iface + except NotImplementedError: + # The video driver doesn't support context negotiation + return False + + return True @property def serialization_quirks(self) -> SerializationQuirks | None: @@ -2038,7 +2060,22 @@ def _get_savestate_context(self, context: TypedPointer[retro_savestate_context]) def _get_hw_render_context_negotiation_interface_support( self, support: TypedPointer[retro_hw_render_context_negotiation_interface] ) -> bool: - return False # TODO: Implement + if not support: + raise ValueError( + "RETRO_ENVIRONMENT_GET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_SUPPORT " + "doesn't accept NULL" + ) + + if ( + support[0].interface_type == ContextNegotiationInterfaceType.VULKAN + and HardwareContext.VULKAN in self._video.supported_contexts + ): + support[ + 0 + ].interface_version = RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION + return True + + return False @property def jit_capable(self) -> bool | None: diff --git a/src/libretro/drivers/video/__init__.py b/src/libretro/drivers/video/__init__.py index e8c4861..c0fadb2 100644 --- a/src/libretro/drivers/video/__init__.py +++ b/src/libretro/drivers/video/__init__.py @@ -17,3 +17,8 @@ from .opengl import * except ImportError: pass + +try: + from .vulkan import * +except (ImportError, OSError): + pass diff --git a/src/libretro/drivers/video/driver.py b/src/libretro/drivers/video/driver.py index 812da4a..a107b5b 100644 --- a/src/libretro/drivers/video/driver.py +++ b/src/libretro/drivers/video/driver.py @@ -17,6 +17,7 @@ Rotation, retro_framebuffer, retro_hw_render_callback, + retro_hw_render_context_negotiation_interface, retro_hw_render_interface, ) @@ -431,6 +432,36 @@ def hw_render_interface(self) -> retro_hw_render_interface | None: """ ... + @property + @abstractmethod + def context_negotiation_interface( + self, + ) -> retro_hw_render_context_negotiation_interface | None: + """ + The context negotiation interface most recently registered by the core, + or :obj:`None` if the core hasn't registered one + (or if this driver doesn't support negotiation). + + Drivers that support negotiation should keep the registered interface + and honor it the next time the context is (re)initialized. + + :raise NotImplementedError: If setting this property + on a :class:`.VideoDriver` that doesn't support context negotiation. + :raise TypeError: If setting a value that isn't a + :class:`.retro_hw_render_context_negotiation_interface` or :obj:`None`. + + .. note:: + + Corresponds to ``RETRO_ENVIRONMENT_SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE``. + """ + ... + + @context_negotiation_interface.setter + @abstractmethod + def context_negotiation_interface( + self, interface: retro_hw_render_context_negotiation_interface | None + ) -> None: ... + @property @abstractmethod def shared_context(self) -> bool: diff --git a/src/libretro/drivers/video/multi.py b/src/libretro/drivers/video/multi.py index c5094c4..e5cb7e2 100644 --- a/src/libretro/drivers/video/multi.py +++ b/src/libretro/drivers/video/multi.py @@ -18,6 +18,7 @@ Rotation, retro_framebuffer, retro_hw_render_callback, + retro_hw_render_context_negotiation_interface, retro_hw_render_interface, ) @@ -38,6 +39,13 @@ except ImportError: ModernGlVideoDriver = None +try: + from libretro.drivers.video.vulkan import VulkanVideoDriver + + _default_driver_map[HardwareContext.VULKAN] = VulkanVideoDriver +except (ImportError, OSError): + VulkanVideoDriver = None + DEFAULT_DRIVER_MAP: DriverMap = MappingProxyType(_default_driver_map) """ The default mapping from context types to :class:`.VideoDriver` constructors. @@ -50,6 +58,11 @@ :attr:`~.HardwareContext.OPENGL_CORE`, :attr:`~.HardwareContext.OPENGL` Mapped to :class:`.ModernGlVideoDriver` if :py:mod:`moderngl` is installed, absent if not. +:attr:`~.HardwareContext.VULKAN` + Mapped to :class:`~libretro.drivers.video.vulkan.driver.VulkanVideoDriver` + if the :py:mod:`vulkan` package + and a Vulkan loader library are installed, absent if not. + :class:`.VideoDriver` s for other graphics APIs have not yet been implemented. """ @@ -109,6 +122,7 @@ def __init__( self._callback = retro_hw_render_callback(context_type=HardwareContext.NONE) self._shared_context = False self._next_hw_context: HardwareContext | None = HardwareContext.NONE + self._context_negotiation: retro_hw_render_context_negotiation_interface | None = None @override def refresh( @@ -191,6 +205,12 @@ def reinit(self) -> None: old_driver = self._current self._current = driver + if self._context_negotiation is not None: + try: + driver.context_negotiation_interface = self._context_negotiation + except NotImplementedError: + pass # This driver doesn't negotiate contexts; that's okay + # Must set the callback before setting the system AV info, # as setting the system AV info reinitializes the video driver immediately # and that requires calling the core-provided callbacks @@ -366,6 +386,34 @@ def hw_render_interface(self) -> retro_hw_render_interface | None: return None + @property + @override + def context_negotiation_interface( + self, + ) -> retro_hw_render_context_negotiation_interface | None: + return self._context_negotiation + + @context_negotiation_interface.setter + @override + def context_negotiation_interface( + self, interface: retro_hw_render_context_negotiation_interface | None + ) -> None: + if interface is not None and not isinstance( + interface, retro_hw_render_context_negotiation_interface + ): + raise TypeError( + "Expected a retro_hw_render_context_negotiation_interface or None, " + f"got {type(interface).__name__}" + ) + + self._context_negotiation = interface + + if self._current is not None: + try: + self._current.context_negotiation_interface = interface + except NotImplementedError: + pass # The active driver doesn't negotiate contexts; that's okay + @property @override def shared_context(self) -> bool: diff --git a/src/libretro/drivers/video/opengl/moderngl.py b/src/libretro/drivers/video/opengl/moderngl.py index c99d2b4..b6e3578 100644 --- a/src/libretro/drivers/video/opengl/moderngl.py +++ b/src/libretro/drivers/video/opengl/moderngl.py @@ -62,6 +62,7 @@ Rotation, retro_framebuffer, retro_hw_render_callback, + retro_hw_render_context_negotiation_interface, retro_hw_render_interface, ) @@ -759,6 +760,21 @@ def hw_render_interface(self) -> retro_hw_render_interface | None: # libretro doesn't define one of these for OpenGL, so no need return None + @property + @override + def context_negotiation_interface( + self, + ) -> retro_hw_render_context_negotiation_interface | None: + return None + + @context_negotiation_interface.setter + @override + def context_negotiation_interface( + self, interface: retro_hw_render_context_negotiation_interface | None + ) -> None: + # libretro doesn't define a negotiation interface for OpenGL, so no need + raise NotImplementedError("Context negotiation is not supported by the OpenGL driver") + def __get_framebuffer_size(self) -> tuple[int, int]: assert self._context is not None assert self._system_av_info is not None diff --git a/src/libretro/drivers/video/software/base.py b/src/libretro/drivers/video/software/base.py index 3fad1ce..2f63774 100644 --- a/src/libretro/drivers/video/software/base.py +++ b/src/libretro/drivers/video/software/base.py @@ -12,6 +12,7 @@ from typing import Literal, final, override from libretro.api.video.context import HardwareContext, retro_hw_render_callback +from libretro.api.video.negotiate import retro_hw_render_context_negotiation_interface from libretro.api.video.render import retro_hw_render_interface from ..driver import UnsupportedContextError, VideoDriver @@ -86,6 +87,25 @@ def can_dupe(self) -> bool | None: def hw_render_interface(self) -> retro_hw_render_interface | None: return None + @property + @override + @final + def context_negotiation_interface( + self, + ) -> retro_hw_render_context_negotiation_interface | None: + return None + + @context_negotiation_interface.setter + @override + @final + def context_negotiation_interface( + self, interface: retro_hw_render_context_negotiation_interface | None + ) -> None: + # Software-rendered drivers don't create a hardware context to negotiate over + raise NotImplementedError( + "Context negotiation is not supported by software-rendered drivers" + ) + @property @override @final diff --git a/src/libretro/drivers/video/vulkan/__init__.py b/src/libretro/drivers/video/vulkan/__init__.py new file mode 100644 index 0000000..6a7832a --- /dev/null +++ b/src/libretro/drivers/video/vulkan/__init__.py @@ -0,0 +1,24 @@ +""" +Vulkan-backed :class:`.VideoDriver` implementation. + +Imports lazily so the rest of :mod:`libretro.drivers.video` +remains usable when the :mod:`vulkan` package +or a Vulkan loader library is not installed. +(The :mod:`vulkan` package loads the Vulkan loader at import time, +which raises :class:`OSError` when no loader is available.) + +.. seealso:: + + :mod:`libretro.api.video.vulkan` + The :mod:`ctypes` types from ``libretro_vulkan.h`` this driver implements. +""" + +try: + from .driver import VulkanVideoDriver as VulkanVideoDriver +except (ImportError, OSError): + __all__ = [] +else: + # An explicit __all__ keeps ``from .vulkan import *`` in the parent package + # from re-exporting this package's ``driver`` submodule attribute, + # which would shadow ``libretro.drivers.video.driver`` + __all__ = ["VulkanVideoDriver"] diff --git a/src/libretro/drivers/video/vulkan/driver.py b/src/libretro/drivers/video/vulkan/driver.py new file mode 100644 index 0000000..399fd5d --- /dev/null +++ b/src/libretro/drivers/video/vulkan/driver.py @@ -0,0 +1,1180 @@ +""" +Headless Vulkan :class:`.VideoDriver` built on the :mod:`vulkan` package (CFFI bindings). + +Implements the frontend side of ``libretro_vulkan.h``: +the context negotiation interface +and the version 5 :class:`.retro_hw_render_interface_vulkan`. +There is no window or swapchain; +each hardware frame is copied from the core's image +into host memory so that :meth:`.VulkanVideoDriver.screenshot` works, +mirroring the offscreen default of :class:`.ModernGlVideoDriver`. +""" + +# The vulkan package is untyped CFFI, so the "unknown type" family of checks +# reports every use of it; relax those (and only those) for this module. +# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false +# pyright: reportUnknownVariableType=false, reportUnknownParameterType=false +# pyright: reportMissingParameterType=false, reportMissingTypeStubs=false + +from __future__ import annotations + +import ctypes +import threading +from collections.abc import Set +from contextlib import suppress +from copy import deepcopy +from ctypes import ( + CFUNCTYPE, + POINTER, + Structure, + byref, + c_char_p, + c_int, + c_uint32, + c_void_p, +) +from typing import Any, final, override +from warnings import warn + +import vulkan as vk +from vulkan import ffi + +from libretro.api.av import retro_game_geometry, retro_system_av_info +from libretro.api.proc import retro_proc_address_t +from libretro.api.video import ( + RETRO_HW_RENDER_INTERFACE_VULKAN_VERSION, + HardwareContext, + HardwareRenderInterfaceType, + MemoryAccess, + PixelFormat, + Rotation, + VkApplicationInfo, + VkPhysicalDeviceFeatures, + retro_framebuffer, + retro_hw_render_callback, + retro_hw_render_context_negotiation_interface, + retro_hw_render_context_negotiation_interface_vulkan, + retro_hw_render_interface_vulkan, + retro_vulkan_context, + retro_vulkan_create_device_wrapper_t, + retro_vulkan_create_instance_wrapper_t, + retro_vulkan_get_sync_index_mask_t, + retro_vulkan_get_sync_index_t, + retro_vulkan_image, + retro_vulkan_lock_queue_t, + retro_vulkan_set_command_buffers_t, + retro_vulkan_set_image_t, + retro_vulkan_set_signal_semaphore_t, + retro_vulkan_unlock_queue_t, + retro_vulkan_wait_sync_index_t, +) + +from ..driver import FrameBufferSpecial, Screenshot, UnsupportedContextError, VideoDriver +from ..software import ArrayVideoDriver + +_CONTEXTS = frozenset((HardwareContext.NONE, HardwareContext.VULKAN)) + +_VK_API_VERSION_1_1 = (1 << 22) | (1 << 12) +_VK_FORMAT_R8G8B8A8_UNORM = 37 +_VK_FORMAT_R8G8B8A8_SRGB = 43 +_VK_FORMAT_B8G8R8A8_UNORM = 44 +_VK_FORMAT_B8G8R8A8_SRGB = 50 + +# Formats whose texels are four bytes and can be captured with a plain buffer copy, +# mapped to True if the byte order is R, G, B, A (False for B, G, R, A). +_CAPTURABLE_FORMATS: dict[int, bool] = { + _VK_FORMAT_R8G8B8A8_UNORM: True, + _VK_FORMAT_R8G8B8A8_SRGB: True, + _VK_FORMAT_B8G8R8A8_UNORM: False, + _VK_FORMAT_B8G8R8A8_SRGB: False, +} + +_LOADER_NAMES = ("libvulkan.so.1", "vulkan-1.dll", "libvulkan.dylib", "libvulkan.1.dylib") + +_PFN_GetInstanceProcAddr = CFUNCTYPE(c_void_p, c_void_p, c_char_p) + + +class _VkInstanceCreateInfo(Structure): + # Only used to read the create info a core passes to the create_instance wrapper + _fields_ = ( + ("sType", c_int), + ("pNext", c_void_p), + ("flags", c_uint32), + ("pApplicationInfo", POINTER(VkApplicationInfo)), + ("enabledLayerCount", c_uint32), + ("ppEnabledLayerNames", POINTER(c_char_p)), + ("enabledExtensionCount", c_uint32), + ("ppEnabledExtensionNames", POINTER(c_char_p)), + ) + + +class _VkDeviceCreateInfo(Structure): + # Only used to read the create info a core passes to the create_device wrapper + _fields_ = ( + ("sType", c_int), + ("pNext", c_void_p), + ("flags", c_uint32), + ("queueCreateInfoCount", c_uint32), + ("pQueueCreateInfos", c_void_p), + ("enabledLayerCount", c_uint32), + ("ppEnabledLayerNames", POINTER(c_char_p)), + ("enabledExtensionCount", c_uint32), + ("ppEnabledExtensionNames", POINTER(c_char_p)), + ("pEnabledFeatures", POINTER(VkPhysicalDeviceFeatures)), + ) + + +def _raw(handle) -> int: + """Return the integer value of a CFFI Vulkan handle (0 for None).""" + if handle is None: + return 0 + + return int(ffi.cast("uintptr_t", handle)) + + +def _load_loader() -> ctypes.CDLL: + for name in _LOADER_NAMES: + try: + return ctypes.CDLL(name) + except OSError: + continue + + raise RuntimeError( + "Couldn't load the Vulkan loader library; " + "install the Vulkan SDK or (on macOS) MoltenVK, " + "and ensure it's on the dynamic library search path" + ) + + +def _rotate_rgba32( + src: bytearray, width: int, height: int, rotation: Rotation +) -> tuple[bytearray, int, int]: + """Rotate a tightly-packed 4-byte-per-pixel buffer, returning it with its new dimensions.""" + if rotation == Rotation.NONE: + return src, width, height + + out = bytearray(len(src)) + match rotation: + case Rotation.NINETY: + start_y = (width - 1) * height * 4 + delta_x = height * -4 + delta_y = 4 + sideways = True + case Rotation.ONE_EIGHTY: + start_y = width * height * 4 - 4 + delta_x = -4 + delta_y = width * -4 + sideways = False + case Rotation.TWO_SEVENTY: + start_y = (height - 1) * 4 + delta_x = height * 4 + delta_y = -4 + sideways = True + case _: + raise ValueError(f"Invalid rotation: {rotation}") + + i = 0 + for y in range(height): + o = start_y + y * delta_y + for _ in range(width): + out[o : o + 4] = src[i : i + 4] + i += 4 + o += delta_x + + if sideways: + return out, height, width + + return out, width, height + + +@final +class VulkanVideoDriver(VideoDriver): + """ + A headless Vulkan video driver for hardware-rendered cores. + + Cores render into their own :c:type:`VkImage` + and pass it to the driver with ``set_image``; + the driver copies the visible region into host memory each frame, + which backs :meth:`~.VulkanVideoDriver.screenshot`. + Software-rendered frames are supported as well, + with the same semantics as :class:`.ArrayVideoDriver`. + """ + + def __init__(self, *, sync_indices: int = 2, gpu_index: int = 0): + """ + Initialize the driver without creating any Vulkan objects; + those are created in :meth:`~.VulkanVideoDriver.reinit`. + + :param sync_indices: The number of frame-in-flight indices + reported through ``get_sync_index_mask``. + :param gpu_index: The index of the physical device to use, + in the order reported by :c:func:`vkEnumeratePhysicalDevices`. + + :raises ValueError: If ``sync_indices`` is not between 1 and 32, + or if ``gpu_index`` is negative. + """ + if not (1 <= sync_indices <= 32): + raise ValueError(f"Expected 1 <= sync_indices <= 32, got {sync_indices}") + + if gpu_index < 0: + raise ValueError(f"Expected a non-negative gpu_index, got {gpu_index}") + + self._sync_index_count = sync_indices + self._gpu_index = gpu_index + + self._software = ArrayVideoDriver() + self._callback = retro_hw_render_callback(context_type=HardwareContext.NONE) + self._active = HardwareContext.NONE + self._needs_reinit = True + self._negotiation: retro_hw_render_context_negotiation_interface_vulkan | None = None + + self._queue_lock = threading.RLock() + self._loader: ctypes.CDLL | None = None + + # CFFI handles for the live context (None when no Vulkan context is active) + self._instance = None + self._gpu = None + self._device = None + self._queue = None + self._queue_family = 0 + self._core_created_device = False + self._negotiation_used = False + + # Frontend capture resources + self._command_pool = None + self._command_buffer = None + self._fence = None + self._staging_buffer = None + self._staging_memory = None + self._staging_map = None + self._staging_dims: tuple[int, int] | None = None + + # Per-frame state provided by the core through the render interface + self._sync_index = 0 + self._hw_image: tuple[int, int, int, int, int] | None = None + self._hw_semaphores: list[int] = [] + self._hw_src_queue_family: int = vk.VK_QUEUE_FAMILY_IGNORED + self._core_command_buffers: list[int] = [] + self._signal_semaphore: int = 0 + + # The most recent captured hardware frame: (pixels, width, height, vk_format) + self._hw_frame: tuple[bytearray, int, int, int] | None = None + self._last_frame_hw = False + self._warned_no_image = False + self._warned_format: int | None = None + + self._interface: retro_hw_render_interface_vulkan | None = None + # ctypes callback objects must outlive the interface struct + self._interface_refs: tuple[Any, ...] | None = None + + def __del__(self): + """Release all Vulkan resources; never raises.""" + with suppress(Exception): + self.__destroy_vulkan() + + @override + def refresh( + self, data: memoryview | FrameBufferSpecial, width: int, height: int, pitch: int + ) -> None: + match data: + case FrameBufferSpecial.HARDWARE: + self.__refresh_hardware(width, height) + + case FrameBufferSpecial.DUPE: + if self._last_frame_hw: + self.__finish_frame() + else: + self._software.refresh(data, width, height, pitch) + + case memoryview(): + self._software.refresh(data, width, height, pitch) + self._last_frame_hw = False + + case _: + raise TypeError( + f"Expected a memoryview or a FrameBufferSpecial, got {type(data).__name__}" + ) + + @property + @override + def needs_reinit(self) -> bool: + return self._needs_reinit + + @override + def reinit(self) -> None: + if self._software.system_av_info is None: + raise RuntimeError("Cannot reinitialize video driver without system AV info from core") + + had_context = self._interface is not None + if had_context and self._callback.context_destroy: + self._callback.context_destroy() + + self.__destroy_vulkan() + + if self._active == HardwareContext.VULKAN: + self.__init_vulkan() + self._needs_reinit = False + if self._callback.context_reset: + self._callback.context_reset() + else: + self._needs_reinit = False + + @property + @override + def supported_contexts(self) -> Set[HardwareContext]: + return _CONTEXTS + + @property + @override + def active_context(self) -> HardwareContext: + return self._active + + @property + @override + def preferred_context(self) -> HardwareContext | None: + return HardwareContext.VULKAN + + @override + def set_context(self, callback: retro_hw_render_callback) -> None: + if not isinstance(callback, retro_hw_render_callback): + raise TypeError(f"Expected a retro_hw_render_callback, got {type(callback).__name__}") + + context_type = HardwareContext(callback.context_type) + if context_type not in _CONTEXTS: + raise UnsupportedContextError( + f"VulkanVideoDriver only supports NONE and VULKAN contexts, got {context_type}" + ) + + self._callback = deepcopy(callback) + self._active = context_type + self._needs_reinit = True + + @property + @override + def current_framebuffer(self) -> int | None: + return None # Only meaningful for OpenGL + + @override + def get_proc_address(self, sym: bytes) -> retro_proc_address_t | None: + return None # Only meaningful for OpenGL; Vulkan cores use get_instance_proc_addr + + @property + @override + def rotation(self) -> Rotation: + return self._software.rotation + + @rotation.setter + @override + def rotation(self, rotation: Rotation) -> None: + self._software.rotation = rotation + + @property + @override + def can_dupe(self) -> bool | None: + return True + + @property + @override + def pixel_format(self) -> PixelFormat: + return self._software.pixel_format + + @pixel_format.setter + @override + def pixel_format(self, format: PixelFormat) -> None: + self._software.pixel_format = format + + @property + @override + def system_av_info(self) -> retro_system_av_info | None: + return self._software.system_av_info + + @system_av_info.setter + @override + def system_av_info(self, av_info: retro_system_av_info) -> None: + self._software.system_av_info = av_info + self.reinit() + + @property + @override + def geometry(self) -> retro_game_geometry | None: + return self._software.geometry + + @geometry.setter + @override + def geometry(self, geometry: retro_game_geometry) -> None: + self._software.geometry = geometry + + @override + def get_software_framebuffer( + self, width: int, height: int, flags: MemoryAccess + ) -> retro_framebuffer | None: + return self._software.get_software_framebuffer(width, height, flags) + + @property + @override + def hw_render_interface(self) -> retro_hw_render_interface_vulkan | None: + return self._interface + + @property + @override + def context_negotiation_interface( + self, + ) -> retro_hw_render_context_negotiation_interface | None: + return self._negotiation + + @context_negotiation_interface.setter + @override + def context_negotiation_interface( + self, interface: retro_hw_render_context_negotiation_interface | None + ) -> None: + if interface is None: + self._negotiation = None + return + + if not isinstance(interface, retro_hw_render_context_negotiation_interface): + raise TypeError( + "Expected a retro_hw_render_context_negotiation_interface or None, " + f"got {type(interface).__name__}" + ) + + if not isinstance(interface, retro_hw_render_context_negotiation_interface_vulkan): + # Reinterpret the base struct as the full Vulkan layout; + # the underlying memory is the core's full struct + interface = ctypes.cast( + byref(interface), POINTER(retro_hw_render_context_negotiation_interface_vulkan) + )[0] + + assert isinstance(interface, retro_hw_render_context_negotiation_interface_vulkan) + self._negotiation = interface + + @property + @override + def shared_context(self) -> bool: + return False + + @shared_context.setter + @override + def shared_context(self, value: bool) -> None: + raise NotImplementedError("Shared contexts are only meaningful for OpenGL") + + @property + def sync_indices(self) -> int: + """The number of frame-in-flight indices reported through ``get_sync_index_mask``.""" + return self._sync_index_count + + @override + def screenshot(self, prerotate: bool = True) -> Screenshot | None: + if not self._last_frame_hw: + return self._software.screenshot(prerotate) + + if self._hw_frame is None: + return None + + pixels, width, height, vk_format = self._hw_frame + rgba = bytearray(pixels) + if not _CAPTURABLE_FORMATS.get(vk_format, True): + # B, G, R, A: swap the red and blue channels + rgba[0::4], rgba[2::4] = rgba[2::4], rgba[0::4] + + rgba[3::4] = b"\xff" * (width * height) # Screenshots are opaque + + rotation = self._software.rotation + rot = rotation if prerotate else Rotation.NONE + rgba, out_width, out_height = _rotate_rgba32(rgba, width, height, rot) + + return Screenshot( + memoryview(rgba), + out_width, + out_height, + rotation, + self._software.pixel_format, + ) + + def __init_vulkan(self) -> None: + self._loader = _load_loader() + gipa_addr = ctypes.cast(self._loader.vkGetInstanceProcAddr, c_void_p).value + assert gipa_addr is not None + + self.__create_instance(gipa_addr) + self.__select_gpu() + self.__create_device(gipa_addr) + self.__create_capture_resources() + self.__build_interface(gipa_addr) + + def __create_instance(self, gipa_addr: int) -> None: + negotiation = self._negotiation + app_name = b"libretro.py" + engine_name = b"libretro.py" + # Vulkan cores put a full VK_MAKE_VERSION value in version_major + # (both Azahar and libretro-samples do this); + # plain major/minor pairs appear only in older or GL-minded cores. + requested = self._callback.version_major + if requested and requested < (1 << 22): + requested = (requested << 22) | (self._callback.version_minor << 12) + + api_version = max(_VK_API_VERSION_1_1, requested) + + if negotiation is not None and negotiation.get_application_info: + app_info_ptr = negotiation.get_application_info() + if app_info_ptr: + app_info = app_info_ptr[0] + app_name = app_info.pApplicationName or app_name + engine_name = app_info.pEngineName or engine_name + api_version = max(api_version, app_info.apiVersion) + + available = {ext.extensionName for ext in vk.vkEnumerateInstanceExtensionProperties(None)} + extensions: list[str] = [] + flags = 0 + if vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME in available: + extensions.append(vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME) + flags |= vk.VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR + + if vk.VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME in available: + extensions.append(vk.VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME) + + if ( + negotiation is not None + and negotiation.interface_version >= 2 + and negotiation.create_instance + ): + instance_raw = self.__negotiate_instance(negotiation, gipa_addr, api_version) + if instance_raw: + self._instance = ffi.cast("VkInstance", instance_raw) + return + + warn("The core's create_instance failed; creating a VkInstance without it") + + app_info = vk.VkApplicationInfo( + pApplicationName=app_name.decode(), + applicationVersion=0, + pEngineName=engine_name.decode(), + engineVersion=0, + apiVersion=api_version, + ) + create_info = vk.VkInstanceCreateInfo( + flags=flags, + pApplicationInfo=app_info, + enabledExtensionCount=len(extensions), + ppEnabledExtensionNames=extensions, + ) + self._instance = vk.vkCreateInstance(create_info, None) + + def __negotiate_instance( + self, + negotiation: retro_hw_render_context_negotiation_interface_vulkan, + gipa_addr: int, + api_version: int, + ) -> int: + self._negotiation_used = True + + available = {ext.extensionName for ext in vk.vkEnumerateInstanceExtensionProperties(None)} + + def _create_instance_wrapper(_opaque: int | None, create_info_ptr: int | None) -> int: + try: + if not create_info_ptr: + return 0 + + info = ctypes.cast(create_info_ptr, POINTER(_VkInstanceCreateInfo))[0] + extensions = [ + info.ppEnabledExtensionNames[i].decode() + for i in range(info.enabledExtensionCount) + ] + layers = [ + info.ppEnabledLayerNames[i].decode() for i in range(info.enabledLayerCount) + ] + flags = info.flags + if vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME in available: + if vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME not in extensions: + extensions.append(vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME) + flags |= vk.VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR + + app_info = ( + ffi.cast("VkApplicationInfo *", ctypes.addressof(info.pApplicationInfo[0])) + if info.pApplicationInfo + else ffi.NULL + ) + create_info = vk.VkInstanceCreateInfo( + pNext=ffi.cast("void *", info.pNext or 0), + flags=flags, + pApplicationInfo=app_info, + enabledLayerCount=len(layers), + ppEnabledLayerNames=layers, + enabledExtensionCount=len(extensions), + ppEnabledExtensionNames=extensions, + ) + return _raw(vk.vkCreateInstance(create_info, None)) + except Exception as e: + warn(f"vkCreateInstance failed in the create_instance wrapper: {e}") + return 0 + + wrapper = retro_vulkan_create_instance_wrapper_t(_create_instance_wrapper) + app_info_ctypes = VkApplicationInfo( + sType=0, + pApplicationName=b"libretro.py", + pEngineName=b"libretro.py", + apiVersion=api_version, + ) + return negotiation.create_instance(gipa_addr, byref(app_info_ctypes), wrapper, None) or 0 + + def __select_gpu(self) -> None: + gpus = vk.vkEnumeratePhysicalDevices(self._instance) + if not gpus: + raise RuntimeError("No Vulkan physical devices found") + + if self._gpu_index >= len(gpus): + warn( + f"gpu_index {self._gpu_index} is out of range " + f"({len(gpus)} devices found); using device 0" + ) + self._gpu = gpus[0] + else: + self._gpu = gpus[self._gpu_index] + + def __create_device(self, gipa_addr: int) -> None: + negotiation = self._negotiation + self._core_created_device = False + + if negotiation is not None: + if negotiation.interface_version >= 2 and negotiation.create_device2: + if self.__negotiate_device2(negotiation, gipa_addr): + return + warn("The core's create_device2 failed; falling back") + + if negotiation.create_device: + if self.__negotiate_device(negotiation, gipa_addr): + return + warn("The core's create_device failed; falling back to default device creation") + + self.__create_device_default() + + def __adopt_context(self, context: retro_vulkan_context) -> bool: + if not context.device or not context.queue: + return False + + self._device = ffi.cast("VkDevice", context.device) + self._queue = ffi.cast("VkQueue", context.queue) + self._queue_family = context.queue_family_index + if context.gpu: + self._gpu = ffi.cast("VkPhysicalDevice", context.gpu) + + self._core_created_device = True + return True + + def __negotiate_device( + self, + negotiation: retro_hw_render_context_negotiation_interface_vulkan, + gipa_addr: int, + ) -> bool: + self._negotiation_used = True + context = retro_vulkan_context() + features = VkPhysicalDeviceFeatures() # The frontend itself requires no features + + ok = negotiation.create_device( + byref(context), + _raw(self._instance), + _raw(self._gpu), + 0, # No surface; this driver is headless + gipa_addr, + None, + 0, + None, + 0, + byref(features), + ) + if not ok: + return False + + return self.__adopt_context(context) + + def __negotiate_device2( + self, + negotiation: retro_hw_render_context_negotiation_interface_vulkan, + gipa_addr: int, + ) -> bool: + self._negotiation_used = True + + device_extensions = { + ext.extensionName for ext in vk.vkEnumerateDeviceExtensionProperties(self._gpu, None) + } + + def _create_device_wrapper( + gpu_raw: int | None, _opaque: int | None, create_info_ptr: int | None + ) -> int: + try: + if not create_info_ptr: + return 0 + + info = ctypes.cast(create_info_ptr, POINTER(_VkDeviceCreateInfo))[0] + extensions = [ + info.ppEnabledExtensionNames[i].decode() + for i in range(info.enabledExtensionCount) + ] + if ( + vk.VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME in device_extensions + and vk.VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME not in extensions + ): + extensions.append(vk.VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME) + + layers = [ + info.ppEnabledLayerNames[i].decode() for i in range(info.enabledLayerCount) + ] + create_info = vk.VkDeviceCreateInfo( + pNext=ffi.cast("void *", info.pNext or 0), + flags=info.flags, + queueCreateInfoCount=info.queueCreateInfoCount, + pQueueCreateInfos=ffi.cast( + "VkDeviceQueueCreateInfo *", info.pQueueCreateInfos or 0 + ), + enabledLayerCount=len(layers), + ppEnabledLayerNames=layers, + enabledExtensionCount=len(extensions), + ppEnabledExtensionNames=extensions, + pEnabledFeatures=ffi.cast( + "VkPhysicalDeviceFeatures *", + ctypes.addressof(info.pEnabledFeatures[0]) if info.pEnabledFeatures else 0, + ), + ) + gpu = ffi.cast("VkPhysicalDevice", gpu_raw or 0) + return _raw(vk.vkCreateDevice(gpu, create_info, None)) + except Exception as e: + warn(f"vkCreateDevice failed in the create_device wrapper: {e}") + return 0 + + wrapper = retro_vulkan_create_device_wrapper_t(_create_device_wrapper) + + context = retro_vulkan_context() + ok = negotiation.create_device2( + byref(context), + _raw(self._instance), + _raw(self._gpu), + 0, # No surface; this driver is headless + gipa_addr, + wrapper, + None, + ) + if not ok: + # Retry allowing the core to pick the physical device itself + context = retro_vulkan_context() + ok = negotiation.create_device2( + byref(context), _raw(self._instance), 0, 0, gipa_addr, wrapper, None + ) + + if not ok: + return False + + return self.__adopt_context(context) + + def __create_device_default(self) -> None: + families = vk.vkGetPhysicalDeviceQueueFamilyProperties(self._gpu) + wanted = vk.VK_QUEUE_GRAPHICS_BIT | vk.VK_QUEUE_COMPUTE_BIT + try: + family = next(i for i, f in enumerate(families) if (f.queueFlags & wanted) == wanted) + except StopIteration: + raise RuntimeError("No Vulkan queue family supports both graphics and compute") + + extensions = [ + ext.extensionName + for ext in vk.vkEnumerateDeviceExtensionProperties(self._gpu, None) + if ext.extensionName == vk.VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME + ] + + queue_info = vk.VkDeviceQueueCreateInfo( + queueFamilyIndex=family, queueCount=1, pQueuePriorities=[1.0] + ) + create_info = vk.VkDeviceCreateInfo( + queueCreateInfoCount=1, + pQueueCreateInfos=[queue_info], + enabledExtensionCount=len(extensions), + ppEnabledExtensionNames=extensions, + # Like RetroArch, enable every feature the device supports + pEnabledFeatures=vk.vkGetPhysicalDeviceFeatures(self._gpu), + ) + self._device = vk.vkCreateDevice(self._gpu, create_info, None) + self._queue = vk.vkGetDeviceQueue(self._device, family, 0) + self._queue_family = family + + def __create_capture_resources(self) -> None: + self._command_pool = vk.vkCreateCommandPool( + self._device, + vk.VkCommandPoolCreateInfo( + flags=vk.VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, + queueFamilyIndex=self._queue_family, + ), + None, + ) + self._command_buffer = vk.vkAllocateCommandBuffers( + self._device, + vk.VkCommandBufferAllocateInfo( + commandPool=self._command_pool, + level=vk.VK_COMMAND_BUFFER_LEVEL_PRIMARY, + commandBufferCount=1, + ), + )[0] + self._fence = vk.vkCreateFence(self._device, vk.VkFenceCreateInfo(), None) + + def __ensure_staging_buffer(self, width: int, height: int) -> None: + if self._staging_dims == (width, height): + return + + self.__destroy_staging_buffer() + + size = width * height * 4 + self._staging_buffer = vk.vkCreateBuffer( + self._device, + vk.VkBufferCreateInfo( + size=size, + usage=vk.VK_BUFFER_USAGE_TRANSFER_DST_BIT, + sharingMode=vk.VK_SHARING_MODE_EXCLUSIVE, + ), + None, + ) + reqs: Any = vk.vkGetBufferMemoryRequirements(self._device, self._staging_buffer) + mem_props: Any = vk.vkGetPhysicalDeviceMemoryProperties(self._gpu) + wanted = vk.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | vk.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT + try: + type_index = next( + i + for i in range(mem_props.memoryTypeCount) + if (reqs.memoryTypeBits >> i) & 1 + and (mem_props.memoryTypes[i].propertyFlags & wanted) == wanted + ) + except StopIteration: + raise RuntimeError("No host-visible, host-coherent Vulkan memory type available") + + self._staging_memory = vk.vkAllocateMemory( + self._device, + vk.VkMemoryAllocateInfo(allocationSize=reqs.size, memoryTypeIndex=type_index), + None, + ) + vk.vkBindBufferMemory(self._device, self._staging_buffer, self._staging_memory, 0) + self._staging_map = vk.vkMapMemory(self._device, self._staging_memory, 0, size, 0) + self._staging_dims = (width, height) + + def __refresh_hardware(self, width: int, height: int) -> None: + if self._interface is None: + warn("RETRO_HW_FRAME_BUFFER_VALID passed but no Vulkan context is active") + return + + if self._hw_image is None: + if not self._warned_no_image: + warn("The core didn't provide an image with set_image before video_refresh") + self._warned_no_image = True + self.__finish_frame() + return + + image_raw, layout, vk_format, base_mip, base_layer = self._hw_image + if vk_format not in _CAPTURABLE_FORMATS: + if self._warned_format != vk_format: + warn(f"Unsupported VkFormat {vk_format} for frame capture; skipping") + self._warned_format = vk_format + self.__finish_frame() + return + + self.__ensure_staging_buffer(width, height) + self.__record_capture(image_raw, layout, base_mip, base_layer, width, height) + self.__submit_capture() + + assert self._staging_map is not None + # vkMapMemory in the vulkan package returns an ffi.buffer over the mapping + pixels = bytearray(self._staging_map) + self._hw_frame = (pixels, width, height, vk_format) + self._last_frame_hw = True + self.__consume_frame_state() + + def __record_capture( + self, image_raw: int, layout: int, base_mip: int, base_layer: int, width: int, height: int + ) -> None: + image = ffi.cast("VkImage", image_raw) + subresource = vk.VkImageSubresourceRange( + vk.VK_IMAGE_ASPECT_COLOR_BIT, base_mip, 1, base_layer, 1 + ) + ownership_transfer = ( + self._hw_src_queue_family != self._queue_family + and self._hw_src_queue_family != vk.VK_QUEUE_FAMILY_IGNORED + ) + + cmd = self._command_buffer + vk.vkResetCommandBuffer(cmd, 0) + vk.vkBeginCommandBuffer( + cmd, vk.VkCommandBufferBeginInfo(flags=vk.VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT) + ) + + to_transfer = vk.VkImageMemoryBarrier( + srcAccessMask=vk.VK_ACCESS_MEMORY_WRITE_BIT | vk.VK_ACCESS_MEMORY_READ_BIT, + dstAccessMask=vk.VK_ACCESS_TRANSFER_READ_BIT, + oldLayout=layout, + newLayout=vk.VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + srcQueueFamilyIndex=( + self._hw_src_queue_family if ownership_transfer else vk.VK_QUEUE_FAMILY_IGNORED + ), + dstQueueFamilyIndex=( + self._queue_family if ownership_transfer else vk.VK_QUEUE_FAMILY_IGNORED + ), + image=image, + subresourceRange=subresource, + ) + vk.vkCmdPipelineBarrier( + cmd, + vk.VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + vk.VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, + 0, + None, + 0, + None, + 1, + [to_transfer], + ) + + region = vk.VkBufferImageCopy( + bufferOffset=0, + bufferRowLength=0, + bufferImageHeight=0, + imageSubresource=vk.VkImageSubresourceLayers( + aspectMask=vk.VK_IMAGE_ASPECT_COLOR_BIT, + mipLevel=base_mip, + baseArrayLayer=base_layer, + layerCount=1, + ), + imageOffset=vk.VkOffset3D(0, 0, 0), + imageExtent=vk.VkExtent3D(width, height, 1), + ) + vk.vkCmdCopyImageToBuffer( + cmd, + image, + vk.VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + self._staging_buffer, + 1, + [region], + ) + + to_original = vk.VkImageMemoryBarrier( + srcAccessMask=vk.VK_ACCESS_TRANSFER_READ_BIT, + dstAccessMask=vk.VK_ACCESS_MEMORY_WRITE_BIT | vk.VK_ACCESS_MEMORY_READ_BIT, + oldLayout=vk.VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + newLayout=layout, + srcQueueFamilyIndex=( + self._queue_family if ownership_transfer else vk.VK_QUEUE_FAMILY_IGNORED + ), + dstQueueFamilyIndex=( + self._hw_src_queue_family if ownership_transfer else vk.VK_QUEUE_FAMILY_IGNORED + ), + image=image, + subresourceRange=subresource, + ) + vk.vkCmdPipelineBarrier( + cmd, + vk.VK_PIPELINE_STAGE_TRANSFER_BIT, + vk.VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + 0, + 0, + None, + 0, + None, + 1, + [to_original], + ) + vk.vkEndCommandBuffer(cmd) + + def __submit_capture(self) -> None: + # Semaphores from set_image are ignored when the core used set_command_buffers + wait_semaphores = self._hw_semaphores if not self._core_command_buffers else [] + assert self._command_buffer is not None + command_buffers = [ffi.cast("VkCommandBuffer", raw) for raw in self._core_command_buffers] + command_buffers.append(self._command_buffer) + signal_semaphores = ( + [ffi.cast("VkSemaphore", self._signal_semaphore)] if self._signal_semaphore else [] + ) + + submit = vk.VkSubmitInfo( + waitSemaphoreCount=len(wait_semaphores), + pWaitSemaphores=[ffi.cast("VkSemaphore", s) for s in wait_semaphores] or None, + pWaitDstStageMask=[vk.VK_PIPELINE_STAGE_TRANSFER_BIT] * len(wait_semaphores) or None, + commandBufferCount=len(command_buffers), + pCommandBuffers=command_buffers, + signalSemaphoreCount=len(signal_semaphores), + pSignalSemaphores=signal_semaphores or None, + ) + with self._queue_lock: + vk.vkQueueSubmit(self._queue, 1, [submit], self._fence) + + vk.vkWaitForFences(self._device, 1, [self._fence], vk.VK_TRUE, 10_000_000_000) + vk.vkResetFences(self._device, 1, [self._fence]) + + def __finish_frame(self) -> None: + """Handle per-frame bookkeeping for frames that don't capture anything.""" + if self._signal_semaphore and self._device is not None: + # The signal semaphore must be signalled even for duped or skipped frames + submit = vk.VkSubmitInfo( + signalSemaphoreCount=1, + pSignalSemaphores=[ffi.cast("VkSemaphore", self._signal_semaphore)], + ) + with self._queue_lock: + vk.vkQueueSubmit(self._queue, 1, [submit], self._fence) + + vk.vkWaitForFences(self._device, 1, [self._fence], vk.VK_TRUE, 10_000_000_000) + vk.vkResetFences(self._device, 1, [self._fence]) + + self.__consume_frame_state() + + def __consume_frame_state(self) -> None: + self._hw_semaphores = [] + self._core_command_buffers = [] + self._signal_semaphore = 0 + self._sync_index = (self._sync_index + 1) % self._sync_index_count + + def __build_interface(self, gipa_addr: int) -> None: + get_instance_proc_addr = _PFN_GetInstanceProcAddr(gipa_addr) + gdpa_addr = get_instance_proc_addr(_raw(self._instance), b"vkGetDeviceProcAddr") + if not gdpa_addr: + raise RuntimeError("vkGetInstanceProcAddr couldn't resolve vkGetDeviceProcAddr") + + def _set_image(_handle, image_ptr, num_semaphores, semaphores, src_queue_family) -> None: + if not image_ptr: + self._hw_image = None + return + + image: retro_vulkan_image = image_ptr[0] + info = image.create_info + self._hw_image = ( + info.image, + image.image_layout, + info.format, + info.subresourceRange.baseMipLevel, + info.subresourceRange.baseArrayLayer, + ) + if num_semaphores and semaphores: + self._hw_semaphores = [semaphores[i] for i in range(num_semaphores)] + else: + self._hw_semaphores = [] + + self._hw_src_queue_family = src_queue_family + + def _get_sync_index(_handle) -> int: + return self._sync_index + + def _get_sync_index_mask(_handle) -> int: + return (1 << self._sync_index_count) - 1 + + def _set_command_buffers(_handle, num_cmd, cmd) -> None: + if num_cmd and cmd: + self._core_command_buffers = [cmd[i] for i in range(num_cmd)] + else: + self._core_command_buffers = [] + + def _wait_sync_index(_handle) -> None: + # This driver submits synchronously (each frame waits on a fence), + # so waiting on the queue covers everything for the current sync index. + with self._queue_lock: + vk.vkQueueWaitIdle(self._queue) + + def _lock_queue(_handle) -> None: + self._queue_lock.acquire() + + def _unlock_queue(_handle) -> None: + self._queue_lock.release() + + def _set_signal_semaphore(_handle, semaphore) -> None: + self._signal_semaphore = semaphore + + refs = ( + retro_vulkan_set_image_t(_set_image), + retro_vulkan_get_sync_index_t(_get_sync_index), + retro_vulkan_get_sync_index_mask_t(_get_sync_index_mask), + retro_vulkan_set_command_buffers_t(_set_command_buffers), + retro_vulkan_wait_sync_index_t(_wait_sync_index), + retro_vulkan_lock_queue_t(_lock_queue), + retro_vulkan_unlock_queue_t(_unlock_queue), + retro_vulkan_set_signal_semaphore_t(_set_signal_semaphore), + ) + self._interface_refs = refs + self._interface = retro_hw_render_interface_vulkan( + interface_type=HardwareRenderInterfaceType.VULKAN, + interface_version=RETRO_HW_RENDER_INTERFACE_VULKAN_VERSION, + handle=None, + instance=_raw(self._instance), + gpu=_raw(self._gpu), + device=_raw(self._device), + get_device_proc_addr=gdpa_addr, + get_instance_proc_addr=gipa_addr, + queue=_raw(self._queue), + queue_index=self._queue_family, + set_image=refs[0], + get_sync_index=refs[1], + get_sync_index_mask=refs[2], + set_command_buffers=refs[3], + wait_sync_index=refs[4], + lock_queue=refs[5], + unlock_queue=refs[6], + set_signal_semaphore=refs[7], + ) + + def __destroy_staging_buffer(self) -> None: + if self._device is None: + return + + if self._staging_map is not None: + vk.vkUnmapMemory(self._device, self._staging_memory) + self._staging_map = None + + if self._staging_buffer is not None: + vk.vkDestroyBuffer(self._device, self._staging_buffer, None) + self._staging_buffer = None + + if self._staging_memory is not None: + vk.vkFreeMemory(self._device, self._staging_memory, None) + self._staging_memory = None + + self._staging_dims = None + + def __destroy_vulkan(self) -> None: + if self._device is not None: + with self._queue_lock: + try: + vk.vkDeviceWaitIdle(self._device) + except Exception as e: + warn(f"vkDeviceWaitIdle failed during teardown: {e}") + + self.__destroy_staging_buffer() + + if self._fence is not None: + vk.vkDestroyFence(self._device, self._fence, None) + self._fence = None + + if self._command_pool is not None: + vk.vkDestroyCommandPool(self._device, self._command_pool, None) + self._command_pool = None + self._command_buffer = None + + if ( + self._negotiation is not None + and self._negotiation_used + and self._negotiation.destroy_device + ): + self._negotiation.destroy_device() + + self._negotiation_used = False + + if self._device is not None: + vk.vkDestroyDevice(self._device, None) + self._device = None + self._queue = None + + if self._instance is not None: + vk.vkDestroyInstance(self._instance, None) + self._instance = None + self._gpu = None + + self._interface = None + self._interface_refs = None + self._hw_image = None + self._hw_frame = None + self._hw_semaphores = [] + self._core_command_buffers = [] + self._signal_semaphore = 0 + self._sync_index = 0 + self._last_frame_hw = False + self._core_created_device = False + + +__all__ = ["VulkanVideoDriver"] diff --git a/src/libretro/samples/video/__init__.py b/src/libretro/samples/video/__init__.py index 8851571..5730523 100644 --- a/src/libretro/samples/video/__init__.py +++ b/src/libretro/samples/video/__init__.py @@ -8,10 +8,11 @@ * ``gl_fixedfunction`` — legacy OpenGL 1.x fixed-function pipeline. * ``gl_shaders`` — OpenGL 2.x core profile with vertex/fragment shaders. * ``gl_compute_shaders`` — OpenGL 4.3 compute shader demo (C++). +* ``vulkan_rendering`` — Vulkan triangle demo using the HW render interface. -OpenGL-backed cores are only present on platforms where the build environment -included OpenGL headers; on systems without them the core build is skipped -and lookup raises :class:`ImportError`. +OpenGL- and Vulkan-backed cores are only present on platforms where the build +environment included the matching graphics headers; on systems without them +the core build is skipped and lookup raises :class:`ImportError`. .. seealso:: @@ -27,6 +28,7 @@ "gl_fixedfunction", "gl_shaders", # "gl_compute_shaders", + "vulkan_rendering", ) diff --git a/tests/integration/test_vulkan_driver.py b/tests/integration/test_vulkan_driver.py new file mode 100644 index 0000000..d9c41f2 --- /dev/null +++ b/tests/integration/test_vulkan_driver.py @@ -0,0 +1,42 @@ +"""Integration tests for the Vulkan driver against the ``vulkan_rendering`` sample core.""" + +from __future__ import annotations + +import pytest + +from libretro.api.video import HardwareContext +from libretro.session import Session + +from .conftest import SampleCoreLoader + +pytestmark = pytest.mark.vulkan + +# The sample core clears its framebuffer to (0.8, 0.6, 0.2, 1.0) +# before drawing a triangle over it +CLEAR_COLOR = (204, 153, 51, 255) + + +def test_vulkan_core_renders_and_screenshots(load_core: SampleCoreLoader) -> None: + pytest.importorskip("vulkan", reason="the libretro.py[vulkan] extra is not installed") + core = load_core("video", "vulkan_rendering") + + with Session(core, None) as session: + for _ in range(3): + session.run() + + video = session.video + assert video.active_context == HardwareContext.VULKAN + + shot = video.screenshot() + assert shot is not None + assert (shot.width, shot.height) == (320, 240) + + # The top-left corner is outside the triangle, so it must be the clear color + corner = tuple(shot.data[:4]) + assert all(abs(a - b) <= 2 for a, b in zip(corner, CLEAR_COLOR)), corner + + # The frame must not be a single flat color: + # the triangle covers a significant part of it + data = bytes(shot.data) + differing = sum(1 for i in range(0, len(data), 4) if data[i : i + 4] != bytes(corner)) + assert differing > 1000, differing diff --git a/tests/unit/api/test_video_vulkan.py b/tests/unit/api/test_video_vulkan.py new file mode 100644 index 0000000..b4e6cc7 --- /dev/null +++ b/tests/unit/api/test_video_vulkan.py @@ -0,0 +1,95 @@ +# ctypes Structure field descriptors expose .offset at runtime, +# which pyright can't see through the dataclass-style annotations. +# pyright: reportUnknownMemberType=false, reportAttributeAccessIssue=false + +from ctypes import sizeof + +from libretro.api.video import ( + RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION, + RETRO_HW_RENDER_INTERFACE_VULKAN_VERSION, + VkApplicationInfo, + VkComponentMapping, + VkImageSubresourceRange, + VkImageViewCreateInfo, + VkPhysicalDeviceFeatures, + retro_hw_render_context_negotiation_interface_vulkan, + retro_hw_render_interface_vulkan, + retro_vulkan_context, + retro_vulkan_image, +) + +# Reference values computed on a 64-bit platform from the C headers +# (vulkan_core.h 1.4.341 and libretro_vulkan.h negotiation v2); +# see docs/superpowers/plans/2026-07-23-vulkan-video-driver.md, Task 1. + + +def test_version_constants(): + assert RETRO_HW_RENDER_INTERFACE_VULKAN_VERSION == 5 + assert RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION == 2 + + +def test_vk_struct_sizes(): + assert sizeof(VkApplicationInfo) == 48 + assert sizeof(VkComponentMapping) == 16 + assert sizeof(VkImageSubresourceRange) == 20 + assert sizeof(VkImageViewCreateInfo) == 80 + assert sizeof(VkPhysicalDeviceFeatures) == 220 + + +def test_image_view_create_info_offsets(): + assert VkImageViewCreateInfo.image.offset == 24 + assert VkImageViewCreateInfo.components.offset == 40 + assert VkImageViewCreateInfo.subresourceRange.offset == 56 + + +def test_retro_vulkan_image_layout(): + assert sizeof(retro_vulkan_image) == 96 + assert retro_vulkan_image.image_layout.offset == 8 + assert retro_vulkan_image.create_info.offset == 16 + + +def test_retro_vulkan_context_layout(): + assert sizeof(retro_vulkan_context) == 48 + assert retro_vulkan_context.queue_family_index.offset == 24 + assert retro_vulkan_context.presentation_queue.offset == 32 + + +def test_render_interface_layout(): + assert sizeof(retro_hw_render_interface_vulkan) == 136 + iface = retro_hw_render_interface_vulkan + assert iface.interface_type.offset == 0 + assert iface.interface_version.offset == 4 + assert iface.handle.offset == 8 + assert iface.instance.offset == 16 + assert iface.gpu.offset == 24 + assert iface.device.offset == 32 + assert iface.get_device_proc_addr.offset == 40 + assert iface.get_instance_proc_addr.offset == 48 + assert iface.queue.offset == 56 + assert iface.queue_index.offset == 64 + assert iface.set_image.offset == 72 + assert iface.get_sync_index.offset == 80 + assert iface.get_sync_index_mask.offset == 88 + assert iface.set_command_buffers.offset == 96 + assert iface.wait_sync_index.offset == 104 + assert iface.lock_queue.offset == 112 + assert iface.unlock_queue.offset == 120 + assert iface.set_signal_semaphore.offset == 128 + + +def test_negotiation_interface_layout(): + assert sizeof(retro_hw_render_context_negotiation_interface_vulkan) == 48 + iface = retro_hw_render_context_negotiation_interface_vulkan + assert iface.get_application_info.offset == 8 + assert iface.create_device.offset == 16 + assert iface.destroy_device.offset == 24 + assert iface.create_instance.offset == 32 + assert iface.create_device2.offset == 40 + + +def test_physical_device_features_field_count_and_names(): + fields = VkPhysicalDeviceFeatures._fields_ + assert len(fields) == 55 + assert fields[0][0] == "robustBufferAccess" + assert fields[-1][0] == "inheritedQueries" + assert fields[20][0] == "textureCompressionETC2" diff --git a/tests/unit/drivers/test_video_negotiation.py b/tests/unit/drivers/test_video_negotiation.py new file mode 100644 index 0000000..2f6ffbd --- /dev/null +++ b/tests/unit/drivers/test_video_negotiation.py @@ -0,0 +1,105 @@ +# These tests intentionally call the composite driver's protected env handlers; +# the TypedPointer casts are runtime-equivalent to the handlers' annotations. +# pyright: reportPrivateUsage=false, reportArgumentType=false + +from ctypes import POINTER, cast, pointer + +import pytest + +from libretro.api.video import ( + ContextNegotiationInterfaceType, + HardwareContext, + retro_hw_render_context_negotiation_interface, + retro_hw_render_context_negotiation_interface_vulkan, +) +from libretro.drivers.audio import ArrayAudioDriver +from libretro.drivers.environment.composite import CompositeEnvironmentDriver +from libretro.drivers.input import IterableInputDriver +from libretro.drivers.video import ArrayVideoDriver, MultiVideoDriver, VideoDriver + + +def _vulkan_negotiation_iface() -> retro_hw_render_context_negotiation_interface_vulkan: + return retro_hw_render_context_negotiation_interface_vulkan( + interface_type=ContextNegotiationInterfaceType.VULKAN, + interface_version=2, + ) + + +def _composite(video: VideoDriver) -> CompositeEnvironmentDriver: + return CompositeEnvironmentDriver( + audio=ArrayAudioDriver(), + input=IterableInputDriver(), + video=video, + ) + + +def test_software_driver_rejects_negotiation_interface(): + driver = ArrayVideoDriver() + assert driver.context_negotiation_interface is None + + with pytest.raises(NotImplementedError): + driver.context_negotiation_interface = _vulkan_negotiation_iface() + + +def test_composite_returns_false_when_driver_rejects(): + env = _composite(ArrayVideoDriver()) + iface = _vulkan_negotiation_iface() + ptr = cast(pointer(iface), POINTER(retro_hw_render_context_negotiation_interface)) + + assert env._set_hw_render_context_negotiation_interface(ptr) is False + + +def test_multi_driver_stores_negotiation_interface(): + driver = MultiVideoDriver({HardwareContext.NONE: ArrayVideoDriver}) + iface = _vulkan_negotiation_iface() + + driver.context_negotiation_interface = iface + assert driver.context_negotiation_interface is iface + + +def test_composite_accepts_interface_with_multi_driver(): + env = _composite(MultiVideoDriver({HardwareContext.NONE: ArrayVideoDriver})) + iface = _vulkan_negotiation_iface() + ptr = cast(pointer(iface), POINTER(retro_hw_render_context_negotiation_interface)) + + assert env._set_hw_render_context_negotiation_interface(ptr) is True + stored = env._video.context_negotiation_interface + assert stored is not None + assert stored.interface_type == ContextNegotiationInterfaceType.VULKAN + assert stored.interface_version == 2 + + +def test_negotiation_support_reports_version_for_vulkan_capable_driver(): + # A driver map claiming Vulkan support is enough for the env call + driver = MultiVideoDriver( + {HardwareContext.NONE: ArrayVideoDriver, HardwareContext.VULKAN: ArrayVideoDriver} + ) + env = _composite(driver) + query = retro_hw_render_context_negotiation_interface( + interface_type=ContextNegotiationInterfaceType.VULKAN, + interface_version=0, + ) + ptr = cast(pointer(query), POINTER(retro_hw_render_context_negotiation_interface)) + + assert env._get_hw_render_context_negotiation_interface_support(ptr) is True + assert query.interface_version == 2 + + +def test_vulkan_driver_registered_when_available(): + pytest.importorskip("vulkan", reason="the libretro.py[vulkan] extra is not installed") + from libretro.drivers.video import DEFAULT_DRIVER_MAP + from libretro.drivers.video.vulkan import VulkanVideoDriver + + assert HardwareContext.VULKAN in DEFAULT_DRIVER_MAP + assert DEFAULT_DRIVER_MAP[HardwareContext.VULKAN] is VulkanVideoDriver + + +def test_negotiation_support_false_without_vulkan_driver(): + env = _composite(MultiVideoDriver({HardwareContext.NONE: ArrayVideoDriver})) + query = retro_hw_render_context_negotiation_interface( + interface_type=ContextNegotiationInterfaceType.VULKAN, + interface_version=0, + ) + ptr = cast(pointer(query), POINTER(retro_hw_render_context_negotiation_interface)) + + assert env._get_hw_render_context_negotiation_interface_support(ptr) is False diff --git a/tests/unit/drivers/test_vulkan_driver.py b/tests/unit/drivers/test_vulkan_driver.py new file mode 100644 index 0000000..38fdeb8 --- /dev/null +++ b/tests/unit/drivers/test_vulkan_driver.py @@ -0,0 +1,330 @@ +# The vulkan package is untyped CFFI; see the note in the driver module. +# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false +# pyright: reportUnknownVariableType=false, reportUnknownArgumentType=false + +from ctypes import byref + +import pytest + +vk = pytest.importorskip("vulkan", reason="the libretro.py[vulkan] extra is not installed") + +from vulkan import ffi # noqa: E402 + +from libretro.api.av import ( # noqa: E402 + retro_game_geometry, + retro_system_av_info, + retro_system_timing, +) +from libretro.api.video import ( # noqa: E402 + HardwareContext, + PixelFormat, + VkImageSubresourceRange, + VkImageViewCreateInfo, + retro_hw_context_reset_t, + retro_hw_render_callback, + retro_hw_render_interface_vulkan, + retro_vulkan_image, +) +from libretro.drivers.video import FrameBufferSpecial, UnsupportedContextError # noqa: E402 +from libretro.drivers.video.vulkan import VulkanVideoDriver # noqa: E402 + +pytestmark = [pytest.mark.vulkan, pytest.mark.isolated] + +WIDTH = 64 +HEIGHT = 48 + +VK_FORMAT_R8G8B8A8_UNORM = 37 +VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL = 5 +VK_IMAGE_ASPECT_COLOR_BIT = 1 + + +def _av_info() -> retro_system_av_info: + return retro_system_av_info( + geometry=retro_game_geometry( + base_width=WIDTH, + base_height=HEIGHT, + max_width=WIDTH, + max_height=HEIGHT, + aspect_ratio=WIDTH / HEIGHT, + ), + timing=retro_system_timing(fps=60.0, sample_rate=44100.0), + ) + + +def _vulkan_callback() -> retro_hw_render_callback: + return retro_hw_render_callback( + context_type=HardwareContext.VULKAN, + version_major=1, + version_minor=1, + ) + + +def test_driver_contexts(): + driver = VulkanVideoDriver() + assert HardwareContext.VULKAN in driver.supported_contexts + assert HardwareContext.NONE in driver.supported_contexts + assert driver.preferred_context == HardwareContext.VULKAN + assert driver.active_context == HardwareContext.NONE + + +def test_driver_rejects_opengl_context(): + driver = VulkanVideoDriver() + with pytest.raises(UnsupportedContextError): + driver.set_context(retro_hw_render_callback(context_type=HardwareContext.OPENGL)) + + +def test_software_frame_screenshot(): + driver = VulkanVideoDriver() + driver.pixel_format = PixelFormat.XRGB8888 + driver.system_av_info = _av_info() + + # One red XRGB8888 (B, G, R, X) frame + frame = bytearray(b"\x00\x00\xff\x00" * (WIDTH * HEIGHT)) + driver.refresh(memoryview(frame), WIDTH, HEIGHT, WIDTH * 4) + + shot = driver.screenshot() + assert shot is not None + assert (shot.width, shot.height) == (WIDTH, HEIGHT) + assert bytes(shot.data[:4]) == b"\xff\x00\x00\xff" + + +def _init_hw_driver() -> tuple[VulkanVideoDriver, list[int]]: + driver = VulkanVideoDriver() + driver.pixel_format = PixelFormat.XRGB8888 + resets: list[int] = [] + callback = _vulkan_callback() + callback.context_reset = retro_hw_context_reset_t(lambda: resets.append(1)) + driver.set_context(callback) + driver.system_av_info = _av_info() # triggers reinit + return driver, resets + + +def test_hw_context_bringup(): + driver, resets = _init_hw_driver() + + assert resets == [1] + iface = driver.hw_render_interface + assert isinstance(iface, retro_hw_render_interface_vulkan) + assert iface.interface_version == 5 + assert iface.instance + assert iface.gpu + assert iface.device + assert iface.queue + assert iface.get_instance_proc_addr + assert iface.get_device_proc_addr + + # The callbacks must be usable through the ABI, like a core would use them + n = driver.sync_indices + assert iface.get_sync_index_mask(iface.handle) == (1 << n) - 1 + index = iface.get_sync_index(iface.handle) + assert 0 <= index < n + iface.lock_queue(iface.handle) + iface.unlock_queue(iface.handle) + iface.wait_sync_index(iface.handle) + + +class _FakeCoreImage: + """Stands in for a core's Vulkan renderer: clears an image to a color.""" + + def __init__(self, iface: retro_hw_render_interface_vulkan): + self.device = ffi.cast("VkDevice", iface.device) + self.gpu = ffi.cast("VkPhysicalDevice", iface.gpu) + self.queue = ffi.cast("VkQueue", iface.queue) + self.queue_index = iface.queue_index + self.iface = iface + + image_info = vk.VkImageCreateInfo( + imageType=vk.VK_IMAGE_TYPE_2D, + format=VK_FORMAT_R8G8B8A8_UNORM, + extent=vk.VkExtent3D(WIDTH, HEIGHT, 1), + mipLevels=1, + arrayLayers=1, + samples=vk.VK_SAMPLE_COUNT_1_BIT, + tiling=vk.VK_IMAGE_TILING_OPTIMAL, + usage=( + vk.VK_IMAGE_USAGE_TRANSFER_SRC_BIT + | vk.VK_IMAGE_USAGE_TRANSFER_DST_BIT + | vk.VK_IMAGE_USAGE_SAMPLED_BIT + ), + sharingMode=vk.VK_SHARING_MODE_EXCLUSIVE, + initialLayout=vk.VK_IMAGE_LAYOUT_UNDEFINED, + ) + self.image = vk.vkCreateImage(self.device, image_info, None) + + reqs = vk.vkGetImageMemoryRequirements(self.device, self.image) + mem_props = vk.vkGetPhysicalDeviceMemoryProperties(self.gpu) + type_index = next( + i + for i in range(mem_props.memoryTypeCount) + if (reqs.memoryTypeBits >> i) & 1 + and mem_props.memoryTypes[i].propertyFlags & vk.VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT + ) + self.memory = vk.vkAllocateMemory( + self.device, + vk.VkMemoryAllocateInfo(allocationSize=reqs.size, memoryTypeIndex=type_index), + None, + ) + vk.vkBindImageMemory(self.device, self.image, self.memory, 0) + + view_info = vk.VkImageViewCreateInfo( + image=self.image, + viewType=vk.VK_IMAGE_VIEW_TYPE_2D, + format=VK_FORMAT_R8G8B8A8_UNORM, + components=vk.VkComponentMapping(0, 0, 0, 0), + subresourceRange=vk.VkImageSubresourceRange(VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1), + ) + self.view = vk.vkCreateImageView(self.device, view_info, None) + + self.pool = vk.vkCreateCommandPool( + self.device, + vk.VkCommandPoolCreateInfo( + flags=vk.VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, + queueFamilyIndex=self.queue_index, + ), + None, + ) + self.cmd = vk.vkAllocateCommandBuffers( + self.device, + vk.VkCommandBufferAllocateInfo( + commandPool=self.pool, + level=vk.VK_COMMAND_BUFFER_LEVEL_PRIMARY, + commandBufferCount=1, + ), + )[0] + self.fence = vk.vkCreateFence(self.device, vk.VkFenceCreateInfo(), None) + + def render(self, r: float, g: float, b: float) -> None: + vk.vkResetCommandBuffer(self.cmd, 0) + vk.vkBeginCommandBuffer( + self.cmd, + vk.VkCommandBufferBeginInfo(flags=vk.VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT), + ) + subresource = vk.VkImageSubresourceRange(VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1) + to_dst = vk.VkImageMemoryBarrier( + srcAccessMask=0, + dstAccessMask=vk.VK_ACCESS_TRANSFER_WRITE_BIT, + oldLayout=vk.VK_IMAGE_LAYOUT_UNDEFINED, + newLayout=vk.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + srcQueueFamilyIndex=vk.VK_QUEUE_FAMILY_IGNORED, + dstQueueFamilyIndex=vk.VK_QUEUE_FAMILY_IGNORED, + image=self.image, + subresourceRange=subresource, + ) + vk.vkCmdPipelineBarrier( + self.cmd, + vk.VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + vk.VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, + 0, + None, + 0, + None, + 1, + [to_dst], + ) + color = vk.VkClearColorValue(float32=[r, g, b, 1.0]) + vk.vkCmdClearColorImage( + self.cmd, + self.image, + vk.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + color, + 1, + [subresource], + ) + to_shader = vk.VkImageMemoryBarrier( + srcAccessMask=vk.VK_ACCESS_TRANSFER_WRITE_BIT, + dstAccessMask=vk.VK_ACCESS_SHADER_READ_BIT, + oldLayout=vk.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + newLayout=VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + srcQueueFamilyIndex=vk.VK_QUEUE_FAMILY_IGNORED, + dstQueueFamilyIndex=vk.VK_QUEUE_FAMILY_IGNORED, + image=self.image, + subresourceRange=subresource, + ) + vk.vkCmdPipelineBarrier( + self.cmd, + vk.VK_PIPELINE_STAGE_TRANSFER_BIT, + vk.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, + 0, + None, + 0, + None, + 1, + [to_shader], + ) + vk.vkEndCommandBuffer(self.cmd) + + submit = vk.VkSubmitInfo(commandBufferCount=1, pCommandBuffers=[self.cmd]) + self.iface.lock_queue(self.iface.handle) + try: + vk.vkQueueSubmit(self.queue, 1, [submit], self.fence) + finally: + self.iface.unlock_queue(self.iface.handle) + vk.vkWaitForFences(self.device, 1, [self.fence], vk.VK_TRUE, 10_000_000_000) + vk.vkResetFences(self.device, 1, [self.fence]) + + def libretro_image(self) -> retro_vulkan_image: + raw_view = int(ffi.cast("uint64_t", self.view)) + raw_image = int(ffi.cast("uint64_t", self.image)) + return retro_vulkan_image( + image_view=raw_view, + image_layout=VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + create_info=VkImageViewCreateInfo( + image=raw_image, + format=VK_FORMAT_R8G8B8A8_UNORM, + subresourceRange=VkImageSubresourceRange(VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1), + ), + ) + + +def test_hw_frame_capture_and_screenshot(): + driver, _ = _init_hw_driver() + iface = driver.hw_render_interface + assert isinstance(iface, retro_hw_render_interface_vulkan) + + core = _FakeCoreImage(iface) + core.render(1.0, 0.0, 0.0) # pure red + + image = core.libretro_image() + iface.set_image(iface.handle, byref(image), 0, None, iface.queue_index) + + index_before = iface.get_sync_index(iface.handle) + driver.refresh(FrameBufferSpecial.HARDWARE, WIDTH, HEIGHT, 0) + index_after = iface.get_sync_index(iface.handle) + assert index_after == (index_before + 1) % driver.sync_indices + + shot = driver.screenshot() + assert shot is not None + assert (shot.width, shot.height) == (WIDTH, HEIGHT) + assert bytes(shot.data[:4]) == b"\xff\x00\x00\xff" + # A pixel in the middle too, not just the first one + mid = (HEIGHT // 2 * WIDTH + WIDTH // 2) * 4 + assert bytes(shot.data[mid : mid + 4]) == b"\xff\x00\x00\xff" + + +def test_hw_frame_dupe_keeps_frame(): + driver, _ = _init_hw_driver() + iface = driver.hw_render_interface + assert isinstance(iface, retro_hw_render_interface_vulkan) + + core = _FakeCoreImage(iface) + core.render(0.0, 1.0, 0.0) # pure green + image = core.libretro_image() + iface.set_image(iface.handle, byref(image), 0, None, iface.queue_index) + driver.refresh(FrameBufferSpecial.HARDWARE, WIDTH, HEIGHT, 0) + + driver.refresh(FrameBufferSpecial.DUPE, WIDTH, HEIGHT, 0) + shot = driver.screenshot() + assert shot is not None + assert bytes(shot.data[:4]) == b"\x00\xff\x00\xff" + + +def test_context_destroy_called_on_teardown(): + driver, _ = _init_hw_driver() + assert driver.hw_render_interface is not None + + # Requesting a software context and reinitializing must tear down Vulkan + driver.set_context(retro_hw_render_callback(context_type=HardwareContext.NONE)) + driver.reinit() + assert driver.hw_render_interface is None From 689b13f762e9c905670c96175d03dfb9ce906803 Mon Sep 17 00:00:00 2001 From: Eric Warmenhoven Date: Thu, 23 Jul 2026 18:13:49 -0400 Subject: [PATCH 3/6] tested against dolphin, flycast, azahar, beetle_psx_hw, and mupen64plus-next --- CHANGELOG.md | 16 ++ src/libretro/api/vfs.py | 5 + src/libretro/drivers/environment/composite.py | 14 +- src/libretro/drivers/options/dict.py | 33 ++- src/libretro/drivers/video/multi.py | 16 +- src/libretro/drivers/video/vulkan/driver.py | 229 +++++++++++++++--- src/libretro/session.py | 18 ++ 7 files changed, 275 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d11bed8..90a21f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed `RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE` writing the interface struct by value instead of writing a pointer to it. +- Fixed `RETRO_ENVIRONMENT_GET_PERF_INTERFACE` crashing + by passing bound methods instead of function-pointer instances + to the `retro_perf_callback` struct (affected e.g. Beetle PSX HW). +- Fixed VFS file opens failing for the `WRITE | UPDATE_EXISTING` access mode + (affected e.g. Flycast). +- Fixed core options without an explicit default value tripping an assertion; + libretro.h treats the first value as the default (affected e.g. Mupen64Plus-Next). +- Sample cores now build with the `.dylib` suffix on macOS, + matching what `libretro.samples` expects to load. +- `Session` now tears down an active hardware rendering context + (calling the core's `context_destroy`) between `retro_unload_game` + and `retro_deinit`, like RetroArch does; + some cores otherwise release GPU resources in exit-time destructors + that call frontend callbacks after the interpreter has finalized. +- `MultiVideoDriver` now shuts down the outgoing video driver + (including its hardware context) when switching drivers at runtime. ## [0.8.2] - 2026-07-14 diff --git a/src/libretro/api/vfs.py b/src/libretro/api/vfs.py index 010566b..3fa71fb 100644 --- a/src/libretro/api/vfs.py +++ b/src/libretro/api/vfs.py @@ -187,6 +187,7 @@ class VfsFileAccess(IntFlag): UPDATE_EXISTING = RETRO_VFS_FILE_ACCESS_UPDATE_EXISTING READ_WRITE_EXISTING = READ_WRITE | UPDATE_EXISTING + WRITE_EXISTING = WRITE | UPDATE_EXISTING @property def open_flag(self) -> Literal["rb", "wb", "w+b", "r+b"]: @@ -206,6 +207,10 @@ def open_flag(self) -> Literal["rb", "wb", "w+b", "r+b"]: return "w+b" case VfsFileAccess.READ_WRITE_EXISTING: return "r+b" + case VfsFileAccess.WRITE_EXISTING: + # Write-only without truncation; Python has no such mode, + # so the closest match is read-write without truncation + return "r+b" case _: raise ValueError(f"Invalid VfsFileAccess: {self}") diff --git a/src/libretro/drivers/environment/composite.py b/src/libretro/drivers/environment/composite.py index 2dea4b6..67e2937 100644 --- a/src/libretro/drivers/environment/composite.py +++ b/src/libretro/drivers/environment/composite.py @@ -881,13 +881,13 @@ def _get_perf_interface(self, interface: TypedPointer[retro_perf_callback]) -> b if not self._perf_callback: self._perf_callback = retro_perf_callback( - get_time_usec=self._get_time_usec, - get_cpu_features=self._get_cpu_features, - get_perf_counter=self._get_perf_counter, - perf_register=self._perf_register, - perf_start=self._perf_start, - perf_stop=self._perf_stop, - perf_log=self._perf_log, + get_time_usec=retro_perf_get_time_usec_t(self._get_time_usec), + get_cpu_features=retro_get_cpu_features_t(self._get_cpu_features), + get_perf_counter=retro_perf_get_counter_t(self._get_perf_counter), + perf_register=retro_perf_register_t(self._perf_register), + perf_start=retro_perf_start_t(self._perf_start), + perf_stop=retro_perf_stop_t(self._perf_stop), + perf_log=retro_perf_log_t(self._perf_log), ) interface[0] = self._perf_callback diff --git a/src/libretro/drivers/options/dict.py b/src/libretro/drivers/options/dict.py index 5a52e25..e6ce90d 100644 --- a/src/libretro/drivers/options/dict.py +++ b/src/libretro/drivers/options/dict.py @@ -33,6 +33,24 @@ _SET_VARS = re.compile(rb"(?P[^;]+); (?P.+)") +def _default_value(definition: retro_core_option_v2_definition) -> bytes: + """ + Return an option definition's default value. + + libretro.h treats a ``NULL`` ``default_value`` + as meaning the first entry in ``values`` is the default. + """ + value = definition.default_value + if value is not None: + return value + + for v in definition.values: + if v.value is not None: + return v.value + + raise ValueError(f"Option {definition.key!r} has no values") + + @dataclass class _Option: value: bytes @@ -113,10 +131,7 @@ def get_variable(self, key: bytes) -> bytes | None: if key not in self._options: # If this option exists but hasn't been set yet, # return the default value and save it to the dict - value = self._options_us[key].default_value - assert value is not None, ( - f"Option {key!r} has no default value, it should've been filtered out when initializing" - ) + value = _default_value(self._options_us[key]) self._options[key] = _Option(value=value, visible=True) return value @@ -128,10 +143,7 @@ def get_variable(self, key: bytes) -> bytes | None: # Return the default value if the current value isn't in the definition, # but don't actually change the value in the dict # (RetroArch does this to handle cases like updated options) - value = self._options_us[key].default_value - assert value is not None, ( - f"Option {key!r} has no default value, it should've been filtered out when initializing" - ) + value = _default_value(self._options_us[key]) return value @@ -246,10 +258,7 @@ def set_display(self, key: bytes, visible: bool): else: # If this option exists but hasn't been set yet, # return the default value and save it to the dict - value = self._options_us[key].default_value - assert value is not None, ( - f"Option {key!r} has no default value, it should've been filtered out when initializing" - ) + value = _default_value(self._options_us[key]) self._options[key] = _Option(value=value, visible=visible) @override diff --git a/src/libretro/drivers/video/multi.py b/src/libretro/drivers/video/multi.py index e5cb7e2..8924e36 100644 --- a/src/libretro/drivers/video/multi.py +++ b/src/libretro/drivers/video/multi.py @@ -8,6 +8,7 @@ from copy import deepcopy from types import MappingProxyType from typing import final, override +from warnings import warn from libretro.api.av import retro_game_geometry, retro_system_av_info from libretro.api.proc import retro_proc_address_t @@ -205,6 +206,20 @@ def reinit(self) -> None: old_driver = self._current self._current = driver + if old_driver is not None and old_driver.active_context != HardwareContext.NONE: + # Let the outgoing driver notify the core (context_destroy) + # and release its resources while the core is still loaded, + # before the new driver's context_reset fires + try: + old_driver.set_context( + retro_hw_render_callback(context_type=HardwareContext.NONE) + ) + old_driver.reinit() + except Exception as e: + warn(f"Couldn't shut down the outgoing video driver: {e}") + + del old_driver + if self._context_negotiation is not None: try: driver.context_negotiation_interface = self._context_negotiation @@ -220,7 +235,6 @@ def reinit(self) -> None: driver.system_av_info = system_av_info # No need to call driver.reinit(); setting the system AV info should do that - del old_driver # TODO: If initializing the new driver fails, keep the old one self._next_hw_context = None diff --git a/src/libretro/drivers/video/vulkan/driver.py b/src/libretro/drivers/video/vulkan/driver.py index 399fd5d..f79f072 100644 --- a/src/libretro/drivers/video/vulkan/driver.py +++ b/src/libretro/drivers/video/vulkan/driver.py @@ -51,6 +51,7 @@ VkApplicationInfo, VkPhysicalDeviceFeatures, retro_framebuffer, + retro_hw_context_reset_t, retro_hw_render_callback, retro_hw_render_context_negotiation_interface, retro_hw_render_context_negotiation_interface_vulkan, @@ -75,25 +76,62 @@ _CONTEXTS = frozenset((HardwareContext.NONE, HardwareContext.VULKAN)) _VK_API_VERSION_1_1 = (1 << 22) | (1 << 12) +_VK_FORMAT_R5G6B5_UNORM_PACK16 = 4 +_VK_FORMAT_B5G6R5_UNORM_PACK16 = 5 +_VK_FORMAT_A1R5G5B5_UNORM_PACK16 = 8 _VK_FORMAT_R8G8B8A8_UNORM = 37 _VK_FORMAT_R8G8B8A8_SRGB = 43 _VK_FORMAT_B8G8R8A8_UNORM = 44 _VK_FORMAT_B8G8R8A8_SRGB = 50 - -# Formats whose texels are four bytes and can be captured with a plain buffer copy, -# mapped to True if the byte order is R, G, B, A (False for B, G, R, A). -_CAPTURABLE_FORMATS: dict[int, bool] = { - _VK_FORMAT_R8G8B8A8_UNORM: True, - _VK_FORMAT_R8G8B8A8_SRGB: True, - _VK_FORMAT_B8G8R8A8_UNORM: False, - _VK_FORMAT_B8G8R8A8_SRGB: False, +_VK_FORMAT_A2R10G10B10_UNORM_PACK32 = 58 +_VK_FORMAT_A2B10G10R10_UNORM_PACK32 = 64 + +# Formats the capture path can copy and convert to RGBA bytes, +# mapped to their texel size in bytes. +# (Beetle PSX HW scans out A1R5G5B5 when dithering is enabled; +# Dolphin scans out A2B10G10R10.) +_CAPTURABLE_FORMATS: dict[int, int] = { + _VK_FORMAT_R8G8B8A8_UNORM: 4, + _VK_FORMAT_R8G8B8A8_SRGB: 4, + _VK_FORMAT_B8G8R8A8_UNORM: 4, + _VK_FORMAT_B8G8R8A8_SRGB: 4, + _VK_FORMAT_A2R10G10B10_UNORM_PACK32: 4, + _VK_FORMAT_A2B10G10R10_UNORM_PACK32: 4, + _VK_FORMAT_R5G6B5_UNORM_PACK16: 2, + _VK_FORMAT_B5G6R5_UNORM_PACK16: 2, + _VK_FORMAT_A1R5G5B5_UNORM_PACK16: 2, } _LOADER_NAMES = ("libvulkan.so.1", "vulkan-1.dll", "libvulkan.dylib", "libvulkan.1.dylib") +_VK_KHR_SURFACE = "VK_KHR_surface" +_VK_EXT_HEADLESS_SURFACE = "VK_EXT_headless_surface" + _PFN_GetInstanceProcAddr = CFUNCTYPE(c_void_p, c_void_p, c_char_p) +def _wanted_instance_extensions(available: set[str]) -> tuple[list[str], int]: + """Return the instance extensions this driver enables (of those available), plus create flags.""" + extensions: list[str] = [] + flags = 0 + if vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME in available: + extensions.append(vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME) + flags |= vk.VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR + + if vk.VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME in available: + extensions.append(vk.VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME) + + if _VK_KHR_SURFACE in available: + # Some cores (e.g. PPSSPP) assume create_device receives a real surface + # and query surface support during queue selection; + # a headless surface satisfies them without a window + extensions.append(_VK_KHR_SURFACE) + if _VK_EXT_HEADLESS_SURFACE in available: + extensions.append(_VK_EXT_HEADLESS_SURFACE) + + return extensions, flags + + class _VkInstanceCreateInfo(Structure): # Only used to read the create info a core passes to the create_instance wrapper _fields_ = ( @@ -146,6 +184,80 @@ def _load_loader() -> ctypes.CDLL: ) +_RGBA_LUTS: dict[int, list[bytes]] = {} + + +def _pack16_lut(vk_format: int) -> list[bytes]: + """Return (and cache) a 65536-entry texel-to-RGBA lookup table for a 16-bit format.""" + lut = _RGBA_LUTS.get(vk_format) + if lut is not None: + return lut + + def expand5(v: int) -> int: + return (v << 3) | (v >> 2) + + def expand6(v: int) -> int: + return (v << 2) | (v >> 4) + + entries = [] + for texel in range(0x10000): + match vk_format: + case _ if vk_format == _VK_FORMAT_R5G6B5_UNORM_PACK16: + r = expand5(texel >> 11) + g = expand6((texel >> 5) & 0x3F) + b = expand5(texel & 0x1F) + case _ if vk_format == _VK_FORMAT_B5G6R5_UNORM_PACK16: + b = expand5(texel >> 11) + g = expand6((texel >> 5) & 0x3F) + r = expand5(texel & 0x1F) + case _: # A1R5G5B5 + r = expand5((texel >> 10) & 0x1F) + g = expand5((texel >> 5) & 0x1F) + b = expand5(texel & 0x1F) + entries.append(bytes((r, g, b, 255))) + + _RGBA_LUTS[vk_format] = entries + return entries + + +def _to_rgba32(pixels: bytearray, vk_format: int) -> bytearray: + """Convert captured texels in a supported format to R, G, B, A byte order.""" + match _CAPTURABLE_FORMATS[vk_format]: + case 2: + lut = _pack16_lut(vk_format) + texels = memoryview(pixels).cast("H") + return bytearray(b"".join(map(lut.__getitem__, texels))) + case _ if vk_format in ( + _VK_FORMAT_A2R10G10B10_UNORM_PACK32, + _VK_FORMAT_A2B10G10R10_UNORM_PACK32, + ): + rgba = bytearray(len(pixels)) + red_first = vk_format == _VK_FORMAT_A2B10G10R10_UNORM_PACK32 + i = 0 + for texel in memoryview(pixels).cast("I"): + low = (texel & 0x3FF) >> 2 + mid = (texel >> 10 & 0x3FF) >> 2 + high = (texel >> 20 & 0x3FF) >> 2 + if red_first: + rgba[i] = low + rgba[i + 2] = high + else: + rgba[i] = high + rgba[i + 2] = low + rgba[i + 1] = mid + rgba[i + 3] = 255 + i += 4 + + return rgba + case _: + rgba = bytearray(pixels) + if vk_format in (_VK_FORMAT_B8G8R8A8_UNORM, _VK_FORMAT_B8G8R8A8_SRGB): + # B, G, R, A: swap the red and blue channels + rgba[0::4], rgba[2::4] = rgba[2::4], rgba[0::4] + + return rgba + + def _rotate_rgba32( src: bytearray, width: int, height: int, rotation: Rotation ) -> tuple[bytearray, int, int]: @@ -224,6 +336,7 @@ def __init__(self, *, sync_indices: int = 2, gpu_index: int = 0): self._software = ArrayVideoDriver() self._callback = retro_hw_render_callback(context_type=HardwareContext.NONE) + self._pending_context_destroy: retro_hw_context_reset_t | None = None self._active = HardwareContext.NONE self._needs_reinit = True self._negotiation: retro_hw_render_context_negotiation_interface_vulkan | None = None @@ -237,6 +350,7 @@ def __init__(self, *, sync_indices: int = 2, gpu_index: int = 0): self._device = None self._queue = None self._queue_family = 0 + self._surface_raw = 0 self._core_created_device = False self._negotiation_used = False @@ -305,10 +419,12 @@ def reinit(self) -> None: if self._software.system_av_info is None: raise RuntimeError("Cannot reinitialize video driver without system AV info from core") - had_context = self._interface is not None - if had_context and self._callback.context_destroy: - self._callback.context_destroy() + if self._interface is not None: + context_destroy = self._pending_context_destroy or self._callback.context_destroy + if context_destroy: + context_destroy() + self._pending_context_destroy = None self.__destroy_vulkan() if self._active == HardwareContext.VULKAN: @@ -345,6 +461,11 @@ def set_context(self, callback: retro_hw_render_callback) -> None: f"VulkanVideoDriver only supports NONE and VULKAN contexts, got {context_type}" ) + if self._interface is not None: + # Keep the active context's destroy callback so the next reinit + # can still notify the core, even though the new callback replaces it + self._pending_context_destroy = self._callback.context_destroy + self._callback = deepcopy(callback) self._active = context_type self._needs_reinit = True @@ -471,11 +592,7 @@ def screenshot(self, prerotate: bool = True) -> Screenshot | None: return None pixels, width, height, vk_format = self._hw_frame - rgba = bytearray(pixels) - if not _CAPTURABLE_FORMATS.get(vk_format, True): - # B, G, R, A: swap the red and blue channels - rgba[0::4], rgba[2::4] = rgba[2::4], rgba[0::4] - + rgba = _to_rgba32(pixels, vk_format) rgba[3::4] = b"\xff" * (width * height) # Screenshots are opaque rotation = self._software.rotation @@ -496,11 +613,47 @@ def __init_vulkan(self) -> None: assert gipa_addr is not None self.__create_instance(gipa_addr) + self.__create_surface() self.__select_gpu() self.__create_device(gipa_addr) self.__create_capture_resources() self.__build_interface(gipa_addr) + def __create_surface(self) -> None: + """ + Create a headless surface for cores whose device negotiation + assumes one exists (they may query surface support during queue selection). + Leaves the surface at 0 when ``VK_EXT_headless_surface`` isn't available. + """ + self._surface_raw = 0 + try: + create: Any = vk.vkGetInstanceProcAddr(self._instance, "vkCreateHeadlessSurfaceEXT") + except Exception: + return + + if create is None: + return + + try: + surface = create(self._instance, vk.VkHeadlessSurfaceCreateInfoEXT(), None) + except Exception as e: + warn(f"Couldn't create a headless surface: {e}") + return + + self._surface_raw = int(ffi.cast("uint64_t", surface)) + + def __destroy_surface(self) -> None: + if not self._surface_raw or self._instance is None: + return + + try: + destroy = vk.vkGetInstanceProcAddr(self._instance, "vkDestroySurfaceKHR") + destroy(self._instance, ffi.cast("VkSurfaceKHR", self._surface_raw), None) + except Exception as e: + warn(f"Couldn't destroy the headless surface: {e}") + + self._surface_raw = 0 + def __create_instance(self, gipa_addr: int) -> None: negotiation = self._negotiation app_name = b"libretro.py" @@ -522,15 +675,10 @@ def __create_instance(self, gipa_addr: int) -> None: engine_name = app_info.pEngineName or engine_name api_version = max(api_version, app_info.apiVersion) - available = {ext.extensionName for ext in vk.vkEnumerateInstanceExtensionProperties(None)} - extensions: list[str] = [] - flags = 0 - if vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME in available: - extensions.append(vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME) - flags |= vk.VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR - - if vk.VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME in available: - extensions.append(vk.VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME) + available: set[str] = { + str(ext.extensionName) for ext in vk.vkEnumerateInstanceExtensionProperties(None) + } + extensions, flags = _wanted_instance_extensions(available) if ( negotiation is not None @@ -567,7 +715,9 @@ def __negotiate_instance( ) -> int: self._negotiation_used = True - available = {ext.extensionName for ext in vk.vkEnumerateInstanceExtensionProperties(None)} + available: set[str] = { + str(ext.extensionName) for ext in vk.vkEnumerateInstanceExtensionProperties(None) + } def _create_instance_wrapper(_opaque: int | None, create_info_ptr: int | None) -> int: try: @@ -582,11 +732,9 @@ def _create_instance_wrapper(_opaque: int | None, create_info_ptr: int | None) - layers = [ info.ppEnabledLayerNames[i].decode() for i in range(info.enabledLayerCount) ] - flags = info.flags - if vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME in available: - if vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME not in extensions: - extensions.append(vk.VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME) - flags |= vk.VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR + wanted, wanted_flags = _wanted_instance_extensions(available) + extensions.extend(ext for ext in wanted if ext not in extensions) + flags = info.flags | wanted_flags app_info = ( ffi.cast("VkApplicationInfo *", ctypes.addressof(info.pApplicationInfo[0])) @@ -673,7 +821,7 @@ def __negotiate_device( byref(context), _raw(self._instance), _raw(self._gpu), - 0, # No surface; this driver is headless + self._surface_raw, # A headless surface (or 0 if unavailable) gipa_addr, None, 0, @@ -747,7 +895,7 @@ def _create_device_wrapper( byref(context), _raw(self._instance), _raw(self._gpu), - 0, # No surface; this driver is headless + self._surface_raw, # A headless surface (or 0 if unavailable) gipa_addr, wrapper, None, @@ -756,7 +904,13 @@ def _create_device_wrapper( # Retry allowing the core to pick the physical device itself context = retro_vulkan_context() ok = negotiation.create_device2( - byref(context), _raw(self._instance), 0, 0, gipa_addr, wrapper, None + byref(context), + _raw(self._instance), + 0, + self._surface_raw, + gipa_addr, + wrapper, + None, ) if not ok: @@ -875,8 +1029,10 @@ def __refresh_hardware(self, width: int, height: int) -> None: self.__submit_capture() assert self._staging_map is not None - # vkMapMemory in the vulkan package returns an ffi.buffer over the mapping - pixels = bytearray(self._staging_map) + # vkMapMemory in the vulkan package returns an ffi.buffer over the mapping; + # the staging buffer is sized for 4-byte texels, so slice off what this + # frame's (possibly smaller) texel size actually filled + pixels = bytearray(self._staging_map[: width * height * _CAPTURABLE_FORMATS[vk_format]]) self._hw_frame = (pixels, width, height, vk_format) self._last_frame_hw = True self.__consume_frame_state() @@ -1161,6 +1317,7 @@ def __destroy_vulkan(self) -> None: self._queue = None if self._instance is not None: + self.__destroy_surface() vk.vkDestroyInstance(self._instance, None) self._instance = None self._gpu = None diff --git a/src/libretro/session.py b/src/libretro/session.py index 5ab8565..8c0cf6a 100644 --- a/src/libretro/session.py +++ b/src/libretro/session.py @@ -19,6 +19,7 @@ SubsystemContent, ThrottleMode, retro_device_power, + retro_hw_render_callback, retro_subsystem_info, retro_system_av_info, retro_system_content_info_override, @@ -1219,6 +1220,23 @@ def __exit__(self, exc_type: type[Exception], exc_val: Exception, exc_tb: Traceb self._core.unload_game() self._raise_pending_exceptions("retro_unload_game") + if self._video.active_context != HardwareContext.NONE: + # Tear down the hardware context (calling the core's context_destroy) + # while the core is still loaded, like RetroArch does on shutdown. + # Some cores (e.g. mupen64plus-next with paraLLEl-RDP) otherwise + # release their GPU resources in exit-time destructors, + # calling frontend callbacks after the interpreter has finalized. + # This must happen after retro_unload_game: + # cores may stop their background GPU threads there (e.g. Azahar), + # and destroying the Vulkan device under a live thread crashes. + try: + self._video.set_context( + retro_hw_render_callback(context_type=HardwareContext.NONE) + ) + self._video.reinit() + except Exception as e: + warnings.warn(f"Couldn't tear down the hardware rendering context: {e}") + self._core.deinit() self._raise_pending_exceptions("retro_deinit") From 6e1237974342c82eedfc99078f4206a13b4286bc Mon Sep 17 00:00:00 2001 From: Eric Warmenhoven Date: Thu, 23 Jul 2026 21:45:29 -0400 Subject: [PATCH 4/6] tested against ppsspp and swanstation --- CHANGELOG.md | 28 +++++- src/libretro/drivers/environment/composite.py | 12 ++- src/libretro/drivers/video/driver.py | 19 ++++ src/libretro/drivers/video/multi.py | 5 + src/libretro/drivers/video/opengl/moderngl.py | 14 ++- src/libretro/drivers/video/software/base.py | 5 + src/libretro/drivers/video/vulkan/driver.py | 50 ++++++++++ src/libretro/session.py | 30 +++--- tests/unit/drivers/test_vfs_interface.py | 92 +++++++++++++++++++ 9 files changed, 236 insertions(+), 19 deletions(-) create mode 100644 tests/unit/drivers/test_vfs_interface.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a21f1..bbd8bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,17 +35,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 to the `retro_perf_callback` struct (affected e.g. Beetle PSX HW). - Fixed VFS file opens failing for the `WRITE | UPDATE_EXISTING` access mode (affected e.g. Flycast). +- The VFS `seek` handler now returns 0 on success instead of the new position. + libretro.h documents returning the position, + but RetroArch returns 0 for ordinary buffered files + and cores are written against that behavior + (PPSSPP treats any non-zero return as an error, + making every file appear empty under a spec-conforming frontend). - Fixed core options without an explicit default value tripping an assertion; libretro.h treats the first value as the default (affected e.g. Mupen64Plus-Next). - Sample cores now build with the `.dylib` suffix on macOS, matching what `libretro.samples` expects to load. -- `Session` now tears down an active hardware rendering context - (calling the core's `context_destroy`) between `retro_unload_game` - and `retro_deinit`, like RetroArch does; - some cores otherwise release GPU resources in exit-time destructors - that call frontend callbacks after the interpreter has finalized. +- Added `VideoDriver.destroy_hw_context()`, + which calls the core's `context_destroy` without releasing + the driver's own graphics resources. + `Session` now calls it immediately before `retro_unload_game` + (matching RetroArch's `core_unload_game`), + then releases the video driver's GPU resources after `retro_deinit`, + because cores may have background threads submitting GPU work + until unload stops them. + Fixes crashes on session teardown with SwanStation, PPSSPP, + mupen64plus-next (paraLLEl-RDP), and Azahar. - `MultiVideoDriver` now shuts down the outgoing video driver (including its hardware context) when switching drivers at runtime. +- `VulkanVideoDriver` now permanently retains retired render-interface structs + with their callbacks replaced by a native no-op, + because some cores keep the interface pointer + in objects destroyed at process exit. +- Fixed `retro_video_refresh_t` rejecting NULL frame duping: + ctypes exposes a NULL pointer's value as `None`, not 0 + (affected e.g. SwanStation). ## [0.8.2] - 2026-07-14 diff --git a/src/libretro/drivers/environment/composite.py b/src/libretro/drivers/environment/composite.py index 67e2937..026195e 100644 --- a/src/libretro/drivers/environment/composite.py +++ b/src/libretro/drivers/environment/composite.py @@ -325,8 +325,9 @@ def rumble(self) -> RumbleDriver | None: def video_refresh(self, data: c_void_ptr, width: int, height: int, pitch: int) -> None: # Handle the constants and their equivalent ints, just to be safe match data.value: - case 0: + case 0 | None: # Passing NULL to retro_video_refresh_t means "redraw the frame" + # (ctypes exposes a NULL void pointer's value as None) self._video.refresh(FrameBufferSpecial.DUPE, width, height, pitch) case int(i) if i == MAX_POINTER_VALUE: self._video.refresh(FrameBufferSpecial.HARDWARE, width, height, pitch) @@ -1457,7 +1458,14 @@ def _vfs_seek( if self._vfs is None or not file or whence not in VfsSeekPosition: return -1 - return self._vfs.seek(file[0], offset, VfsSeekPosition(whence)) + result = self._vfs.seek(file[0], offset, VfsSeekPosition(whence)) + + # libretro.h documents that seek returns the new position, + # but RetroArch's implementation returns fseek's result (0 on success) + # for ordinary buffered files, and cores (e.g. PPSSPP) are written + # against that behavior: they treat any non-zero return as an error. + # Match RetroArch; a core that wants the position uses tell. + return 0 if result >= 0 else -1 @_return_on_raise(-1) def _vfs_read( diff --git a/src/libretro/drivers/video/driver.py b/src/libretro/drivers/video/driver.py index a107b5b..73a2baa 100644 --- a/src/libretro/drivers/video/driver.py +++ b/src/libretro/drivers/video/driver.py @@ -135,6 +135,25 @@ def reinit(self) -> None: """ ... + @abstractmethod + def destroy_hw_context(self) -> None: + """ + Call the core's registered :attr:`.retro_hw_render_callback.context_destroy` + (if a hardware context is active) + without releasing this driver's own graphics resources. + + RetroArch does the same immediately before ``retro_unload_game``: + the core releases its GPU resources while it is still loaded, + but the frontend's device outlives it, + since cores may still have background threads + finishing GPU work until ``retro_unload_game`` stops them. + + Calling :meth:`~.VideoDriver.reinit` afterwards + will not invoke ``context_destroy`` a second time. + No-op for drivers (or contexts) without hardware rendering. + """ + ... + @property @abstractmethod def supported_contexts(self) -> Set[HardwareContext]: diff --git a/src/libretro/drivers/video/multi.py b/src/libretro/drivers/video/multi.py index 8924e36..b4ceb01 100644 --- a/src/libretro/drivers/video/multi.py +++ b/src/libretro/drivers/video/multi.py @@ -400,6 +400,11 @@ def hw_render_interface(self) -> retro_hw_render_interface | None: return None + @override + def destroy_hw_context(self) -> None: + if self._current is not None: + self._current.destroy_hw_context() + @property @override def context_negotiation_interface( diff --git a/src/libretro/drivers/video/opengl/moderngl.py b/src/libretro/drivers/video/opengl/moderngl.py index b6e3578..c164c00 100644 --- a/src/libretro/drivers/video/opengl/moderngl.py +++ b/src/libretro/drivers/video/opengl/moderngl.py @@ -253,6 +253,7 @@ def __init__( self._pixel_format = PixelFormat.RGB1555 self._system_av_info: retro_system_av_info | None = None self._shared = False + self._context_destroyed = False self._context: Context | None = None self._shader_program: moderngl.Program | None = None self._vao: VertexArray | None = None @@ -456,7 +457,7 @@ def reinit(self) -> None: # TODO: Honor cache_context; try to avoid reinitializing the context if self._context: self._context.clear_errors() - if self._callback and self._callback.context_destroy: + if self._callback and self._callback.context_destroy and not self._context_destroyed: # If the core wants to clean up before the context is destroyed... with self._context.debug_scope( "libretro.ModernGlVideoDriver.reinit.context_destroy" @@ -464,6 +465,8 @@ def reinit(self) -> None: self._callback.context_destroy() _warn_unhandled_gl_errors() + self._context_destroyed = False + if self._window: self._window.destroy() del self._window @@ -760,6 +763,15 @@ def hw_render_interface(self) -> retro_hw_render_interface | None: # libretro doesn't define one of these for OpenGL, so no need return None + @override + def destroy_hw_context(self) -> None: + if self._context and self._callback and self._callback.context_destroy: + with self._context.debug_scope("libretro.ModernGlVideoDriver.destroy_hw_context"): + self._callback.context_destroy() + _warn_unhandled_gl_errors() + + self._context_destroyed = True + @property @override def context_negotiation_interface( diff --git a/src/libretro/drivers/video/software/base.py b/src/libretro/drivers/video/software/base.py index 2f63774..36165e5 100644 --- a/src/libretro/drivers/video/software/base.py +++ b/src/libretro/drivers/video/software/base.py @@ -62,6 +62,11 @@ def set_context(self, callback: retro_hw_render_callback) -> None: "Software-rendered drivers only support HardwareContext.NONE" ) + @override + @final + def destroy_hw_context(self) -> None: + """No-op: software-rendered drivers have no hardware context to destroy.""" + @property @override @final diff --git a/src/libretro/drivers/video/vulkan/driver.py b/src/libretro/drivers/video/vulkan/driver.py index f79f072..ea329c9 100644 --- a/src/libretro/drivers/video/vulkan/driver.py +++ b/src/libretro/drivers/video/vulkan/driver.py @@ -170,6 +170,37 @@ def _raw(handle) -> int: return int(ffi.cast("uintptr_t", handle)) +# Interface structs whose Python callbacks have been replaced with native stubs; +# kept alive forever because cores may hold the pointer in exit-time destructors +_RETIRED_INTERFACES: list[retro_hw_render_interface_vulkan] = [] + + +def _retire_interface(interface: retro_hw_render_interface_vulkan) -> None: + """ + Replace an interface's Python callbacks with a harmless native function + and keep the struct alive for the rest of the process. + + Some cores (e.g. mupen64plus-next's paraLLEl-RDP runtime) hold onto the + interface pointer in objects destroyed at process exit, calling + ``lock_queue``/``wait_sync_index`` after the Python interpreter has + finalized — which would crash if they still pointed at ctypes closures. + ``free`` is a safe stand-in: every callback receives this driver's + ``handle``, which is ``NULL``, and ``free(NULL)`` is a no-op. + """ + libc = ctypes.CDLL(None) + noop = ctypes.cast(libc.free, c_void_p).value + assert noop is not None + interface.set_image = ctypes.cast(noop, retro_vulkan_set_image_t) + interface.get_sync_index = ctypes.cast(noop, retro_vulkan_get_sync_index_t) + interface.get_sync_index_mask = ctypes.cast(noop, retro_vulkan_get_sync_index_mask_t) + interface.set_command_buffers = ctypes.cast(noop, retro_vulkan_set_command_buffers_t) + interface.wait_sync_index = ctypes.cast(noop, retro_vulkan_wait_sync_index_t) + interface.lock_queue = ctypes.cast(noop, retro_vulkan_lock_queue_t) + interface.unlock_queue = ctypes.cast(noop, retro_vulkan_unlock_queue_t) + interface.set_signal_semaphore = ctypes.cast(noop, retro_vulkan_set_signal_semaphore_t) + _RETIRED_INTERFACES.append(interface) + + def _load_loader() -> ctypes.CDLL: for name in _LOADER_NAMES: try: @@ -531,6 +562,22 @@ def get_software_framebuffer( ) -> retro_framebuffer | None: return self._software.get_software_framebuffer(width, height, flags) + @override + def destroy_hw_context(self) -> None: + if self._interface is None: + return + + context_destroy = self._pending_context_destroy or self._callback.context_destroy + self._pending_context_destroy = None + if context_destroy: + context_destroy() + + # The next reinit must not call context_destroy again. + # This must happen *after* the call above: + # ctypes function-pointer fields are views into the struct's memory, + # so nulling the field first would also null the captured value. + self._callback.context_destroy = None + @property @override def hw_render_interface(self) -> retro_hw_render_interface_vulkan | None: @@ -1322,6 +1369,9 @@ def __destroy_vulkan(self) -> None: self._instance = None self._gpu = None + if self._interface is not None: + _retire_interface(self._interface) + self._interface = None self._interface_refs = None self._hw_image = None diff --git a/src/libretro/session.py b/src/libretro/session.py index 8c0cf6a..b447fd0 100644 --- a/src/libretro/session.py +++ b/src/libretro/session.py @@ -1216,19 +1216,30 @@ def __exit__(self, exc_type: type[Exception], exc_val: Exception, exc_tb: Traceb :return: :obj:`True` if a :class:`.CoreShutDownException` should be suppressed. """ + if self._video.active_context != HardwareContext.NONE: + # Call the core's context_destroy immediately before retro_unload_game, + # like RetroArch's core_unload_game does; cores expect their emulated + # system to still exist at that point (e.g. SwanStation, PPSSPP). + # Some cores (e.g. mupen64plus-next with paraLLEl-RDP) otherwise + # release their GPU resources in exit-time destructors, + # calling frontend callbacks after the interpreter has finalized. + try: + self._video.destroy_hw_context() + except Exception as e: + warnings.warn(f"Couldn't destroy the hardware rendering context: {e}") + if self._content is not None: self._core.unload_game() self._raise_pending_exceptions("retro_unload_game") + self._core.deinit() + self._raise_pending_exceptions("retro_deinit") + if self._video.active_context != HardwareContext.NONE: - # Tear down the hardware context (calling the core's context_destroy) - # while the core is still loaded, like RetroArch does on shutdown. - # Some cores (e.g. mupen64plus-next with paraLLEl-RDP) otherwise - # release their GPU resources in exit-time destructors, - # calling frontend callbacks after the interpreter has finalized. - # This must happen after retro_unload_game: - # cores may stop their background GPU threads there (e.g. Azahar), - # and destroying the Vulkan device under a live thread crashes. + # Release the video driver's own GPU resources only now: + # unlike context_destroy, this must wait until the core is gone, + # because cores may have background threads submitting GPU work + # until retro_unload_game/retro_deinit stop them (e.g. Azahar). try: self._video.set_context( retro_hw_render_callback(context_type=HardwareContext.NONE) @@ -1237,9 +1248,6 @@ def __exit__(self, exc_type: type[Exception], exc_val: Exception, exc_tb: Traceb except Exception as e: warnings.warn(f"Couldn't tear down the hardware rendering context: {e}") - self._core.deinit() - self._raise_pending_exceptions("retro_deinit") - del self._core self._is_exited = True return isinstance(exc_val, CoreShutDownException) diff --git a/tests/unit/drivers/test_vfs_interface.py b/tests/unit/drivers/test_vfs_interface.py new file mode 100644 index 0000000..cb4400c --- /dev/null +++ b/tests/unit/drivers/test_vfs_interface.py @@ -0,0 +1,92 @@ +# These tests exercise the composite driver's VFS interface exactly the way +# a core does, through the ctypes function pointers; those calls are untyped +# at the ABI boundary, so the unknown-type family of checks is relaxed here. +# pyright: reportPrivateUsage=false, reportArgumentType=false +# pyright: reportUnknownMemberType=false, reportUnknownParameterType=false +# pyright: reportMissingParameterType=false, reportUnknownArgumentType=false +# pyright: reportUnknownVariableType=false, reportOptionalSubscript=false + +from ctypes import POINTER, c_ubyte, cast, pointer + +import pytest + +from libretro.api.vfs import ( + VfsFileAccess, + retro_vfs_file_handle, + retro_vfs_interface_info, +) +from libretro.drivers.audio import ArrayAudioDriver +from libretro.drivers.environment.composite import CompositeEnvironmentDriver +from libretro.drivers.input import IterableInputDriver +from libretro.drivers.vfs.default import DefaultFileSystemDriver +from libretro.drivers.video import ArrayVideoDriver + + +@pytest.fixture +def vfs_iface(): + env = CompositeEnvironmentDriver( + audio=ArrayAudioDriver(), + input=IterableInputDriver(), + video=ArrayVideoDriver(), + vfs=DefaultFileSystemDriver(), + ) + info = retro_vfs_interface_info(required_interface_version=2, iface=None) + assert env._get_vfs_interface(cast(pointer(info), POINTER(retro_vfs_interface_info))) + yield info.iface[0] + del env # Keep the env (and its callbacks) alive for the duration of the test + + +def test_seek_returns_zero_on_success(vfs_iface, tmp_path): + """ + libretro.h says seek returns the new position, + but cores are written against RetroArch, + which returns 0 on success for ordinary files; + PPSSPP treats any non-zero return as an error. + """ + path = tmp_path / "data.bin" + path.write_bytes(bytes(range(200))) + + handle = cast( + vfs_iface.open(str(path).encode(), VfsFileAccess.READ, 0), POINTER(retro_vfs_file_handle) + ) + assert handle + + assert vfs_iface.seek(handle, 100, 0) == 0 # SEEK_SET to a non-zero position + assert vfs_iface.tell(handle) == 100 + assert vfs_iface.seek(handle, 0, 2) == 0 # SEEK_END + assert vfs_iface.tell(handle) == 200 + assert vfs_iface.close(handle) == 0 + + +def test_read_after_seek(vfs_iface, tmp_path): + path = tmp_path / "data.bin" + path.write_bytes(b"MComprHD-like header and then some") + + handle = cast( + vfs_iface.open(str(path).encode(), VfsFileAccess.READ, 0), POINTER(retro_vfs_file_handle) + ) + buf = (c_ubyte * 8)() + assert vfs_iface.read(handle, buf, 8) == 8 + assert bytes(buf) == b"MComprHD" + assert vfs_iface.seek(handle, 5, 0) == 0 + assert vfs_iface.read(handle, buf, 4) == 4 + assert bytes(buf[:4]) == b"rHD-" + assert vfs_iface.size(handle) == path.stat().st_size + assert vfs_iface.close(handle) == 0 + + +def test_open_write_update_existing(vfs_iface, tmp_path): + """WRITE | UPDATE_EXISTING opens for writing without truncation.""" + path = tmp_path / "save.bin" + path.write_bytes(b"0123456789") + + handle = cast( + vfs_iface.open(str(path).encode(), VfsFileAccess.WRITE | VfsFileAccess.UPDATE_EXISTING, 0), + POINTER(retro_vfs_file_handle), + ) + assert handle + buf = (c_ubyte * 2)(*b"AB") + assert vfs_iface.seek(handle, 4, 0) == 0 + assert vfs_iface.write(handle, buf, 2) == 2 + assert vfs_iface.close(handle) == 0 + assert path.read_bytes() == b"0123AB6789" # Not truncated From 6fb778f05f53ffcd65c50018839a16727c4a8b5b Mon Sep 17 00:00:00 2001 From: Eric Warmenhoven Date: Sat, 25 Jul 2026 14:09:04 -0400 Subject: [PATCH 5/6] software vulkan In software mode the driver now brings up a real Vulkan device (instance/device/capture resources, no negotiation or render interface since there's no core context), and every software frame is uploaded to a VkImage via staging + vkCmdCopyBufferToImage, then read back through the same capture path used for hardware frames --- CHANGELOG.md | 5 + src/libretro/drivers/video/vulkan/driver.py | 220 +++++++++++++++++++- tests/integration/test_vulkan_driver.py | 25 +++ tests/unit/drivers/test_vulkan_driver.py | 40 +++- 4 files changed, 280 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd8bec..e7fb305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and a Vulkan loader (MoltenVK on macOS). - Added ctypes bindings for the types in `libretro_vulkan.h` to `libretro.api.video`. +- `VulkanVideoDriver` uploads software-rendered frames to a `VkImage` + and reads them back through its capture path, + mirroring the OpenGL driver's texture upload; + it falls back to CPU-side frames when Vulkan is unavailable + or the pixel format has no direct Vulkan equivalent. - Implemented `RETRO_ENVIRONMENT_SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE` and `RETRO_ENVIRONMENT_GET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_SUPPORT`, and added a `context_negotiation_interface` property to `VideoDriver`. diff --git a/src/libretro/drivers/video/vulkan/driver.py b/src/libretro/drivers/video/vulkan/driver.py index ea329c9..20ff081 100644 --- a/src/libretro/drivers/video/vulkan/driver.py +++ b/src/libretro/drivers/video/vulkan/driver.py @@ -102,6 +102,13 @@ _VK_FORMAT_A1R5G5B5_UNORM_PACK16: 2, } +# libretro software pixel formats that map directly onto a Vulkan format +_PIXEL_FORMAT_TO_VK: dict[PixelFormat, int] = { + PixelFormat.XRGB8888: _VK_FORMAT_B8G8R8A8_UNORM, + PixelFormat.RGB565: _VK_FORMAT_R5G6B5_UNORM_PACK16, + PixelFormat.RGB1555: _VK_FORMAT_A1R5G5B5_UNORM_PACK16, +} + _LOADER_NAMES = ("libvulkan.so.1", "vulkan-1.dll", "libvulkan.dylib", "libvulkan.1.dylib") _VK_KHR_SURFACE = "VK_KHR_surface" @@ -386,6 +393,9 @@ def __init__(self, *, sync_indices: int = 2, gpu_index: int = 0): self._negotiation_used = False # Frontend capture resources + self._sw_image = None + self._sw_image_memory = None + self._sw_image_key: tuple[int, int, int] | None = None self._command_pool = None self._command_buffer = None self._fence = None @@ -432,8 +442,13 @@ def refresh( self._software.refresh(data, width, height, pitch) case memoryview(): - self._software.refresh(data, width, height, pitch) - self._last_frame_hw = False + if self._device is not None and self.__refresh_software_vulkan( + data, width, height, pitch + ): + self._last_frame_hw = True + else: + self._software.refresh(data, width, height, pitch) + self._last_frame_hw = False case _: raise TypeError( @@ -459,11 +474,19 @@ def reinit(self) -> None: self.__destroy_vulkan() if self._active == HardwareContext.VULKAN: - self.__init_vulkan() + self.__init_vulkan(hardware=True) self._needs_reinit = False if self._callback.context_reset: self._callback.context_reset() else: + try: + # Like the OpenGL driver, software-rendered frames still go + # through the graphics API: they're uploaded to a VkImage + # and read back through the same capture path + self.__init_vulkan(hardware=False) + except Exception as e: + warn(f"Couldn't initialize Vulkan for software rendering, using CPU frames: {e}") + self._needs_reinit = False @property @@ -654,17 +677,20 @@ def screenshot(self, prerotate: bool = True) -> Screenshot | None: self._software.pixel_format, ) - def __init_vulkan(self) -> None: + def __init_vulkan(self, *, hardware: bool) -> None: self._loader = _load_loader() gipa_addr = ctypes.cast(self._loader.vkGetInstanceProcAddr, c_void_p).value assert gipa_addr is not None self.__create_instance(gipa_addr) - self.__create_surface() + if hardware: + self.__create_surface() + self.__select_gpu() self.__create_device(gipa_addr) self.__create_capture_resources() - self.__build_interface(gipa_addr) + if hardware: + self.__build_interface(gipa_addr) def __create_surface(self) -> None: """ @@ -702,7 +728,9 @@ def __destroy_surface(self) -> None: self._surface_raw = 0 def __create_instance(self, gipa_addr: int) -> None: - negotiation = self._negotiation + # The negotiation interface only applies when the core requested + # a Vulkan context; in software mode this driver renders privately + negotiation = self._negotiation if self._active == HardwareContext.VULKAN else None app_name = b"libretro.py" engine_name = b"libretro.py" # Vulkan cores put a full VK_MAKE_VERSION value in version_major @@ -826,7 +854,7 @@ def __select_gpu(self) -> None: self._gpu = gpus[self._gpu_index] def __create_device(self, gipa_addr: int) -> None: - negotiation = self._negotiation + negotiation = self._negotiation if self._active == HardwareContext.VULKAN else None self._core_created_device = False if negotiation is not None: @@ -1024,7 +1052,7 @@ def __ensure_staging_buffer(self, width: int, height: int) -> None: self._device, vk.VkBufferCreateInfo( size=size, - usage=vk.VK_BUFFER_USAGE_TRANSFER_DST_BIT, + usage=vk.VK_BUFFER_USAGE_TRANSFER_DST_BIT | vk.VK_BUFFER_USAGE_TRANSFER_SRC_BIT, sharingMode=vk.VK_SHARING_MODE_EXCLUSIVE, ), None, @@ -1084,6 +1112,179 @@ def __refresh_hardware(self, width: int, height: int) -> None: self._last_frame_hw = True self.__consume_frame_state() + def __ensure_software_image(self, width: int, height: int, vk_format: int) -> None: + if self._sw_image_key == (width, height, vk_format): + return + + self.__destroy_software_image() + + image_info = vk.VkImageCreateInfo( + imageType=vk.VK_IMAGE_TYPE_2D, + format=vk_format, + extent=vk.VkExtent3D(width, height, 1), + mipLevels=1, + arrayLayers=1, + samples=vk.VK_SAMPLE_COUNT_1_BIT, + tiling=vk.VK_IMAGE_TILING_OPTIMAL, + usage=vk.VK_IMAGE_USAGE_TRANSFER_DST_BIT | vk.VK_IMAGE_USAGE_TRANSFER_SRC_BIT, + sharingMode=vk.VK_SHARING_MODE_EXCLUSIVE, + initialLayout=vk.VK_IMAGE_LAYOUT_UNDEFINED, + ) + self._sw_image = vk.vkCreateImage(self._device, image_info, None) + reqs: Any = vk.vkGetImageMemoryRequirements(self._device, self._sw_image) + mem_props: Any = vk.vkGetPhysicalDeviceMemoryProperties(self._gpu) + try: + type_index = next( + i + for i in range(mem_props.memoryTypeCount) + if (reqs.memoryTypeBits >> i) & 1 + and mem_props.memoryTypes[i].propertyFlags & vk.VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT + ) + except StopIteration: + raise RuntimeError("No device-local Vulkan memory type available") + + self._sw_image_memory = vk.vkAllocateMemory( + self._device, + vk.VkMemoryAllocateInfo(allocationSize=reqs.size, memoryTypeIndex=type_index), + None, + ) + vk.vkBindImageMemory(self._device, self._sw_image, self._sw_image_memory, 0) + self._sw_image_key = (width, height, vk_format) + + def __destroy_software_image(self) -> None: + if self._device is None: + return + + if self._sw_image is not None: + vk.vkDestroyImage(self._device, self._sw_image, None) + self._sw_image = None + + if self._sw_image_memory is not None: + vk.vkFreeMemory(self._device, self._sw_image_memory, None) + self._sw_image_memory = None + + self._sw_image_key = None + + def __refresh_software_vulkan( + self, data: memoryview, width: int, height: int, pitch: int + ) -> bool: + """ + Upload a software-rendered frame to a :c:type:`VkImage` and read it back + through the capture path, like the OpenGL driver's texture upload. + + :return: :obj:`True` if the frame went through Vulkan, + :obj:`False` if the pixel format has no direct Vulkan equivalent + (the caller falls back to CPU frames). + """ + vk_format = _PIXEL_FORMAT_TO_VK.get(self._software.pixel_format) + if vk_format is None: + return False + + texel_size = _CAPTURABLE_FORMATS[vk_format] + row_bytes = width * texel_size + + self.__ensure_staging_buffer(width, height) + assert self._staging_map is not None + if pitch == row_bytes: + self._staging_map[: height * row_bytes] = bytes(data[: height * row_bytes]) + else: + for y in range(height): + row = data[y * pitch : y * pitch + row_bytes] + self._staging_map[y * row_bytes : (y + 1) * row_bytes] = bytes(row) + + self.__ensure_software_image(width, height, vk_format) + self.__record_software_frame(width, height) + self.__submit_capture() + + pixels = bytearray(self._staging_map[: height * row_bytes]) + self._hw_frame = (pixels, width, height, vk_format) + return True + + def __record_software_frame(self, width: int, height: int) -> None: + subresource = vk.VkImageSubresourceRange(vk.VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1) + layers = vk.VkImageSubresourceLayers( + aspectMask=vk.VK_IMAGE_ASPECT_COLOR_BIT, mipLevel=0, baseArrayLayer=0, layerCount=1 + ) + region = vk.VkBufferImageCopy( + bufferOffset=0, + bufferRowLength=0, + bufferImageHeight=0, + imageSubresource=layers, + imageOffset=vk.VkOffset3D(0, 0, 0), + imageExtent=vk.VkExtent3D(width, height, 1), + ) + + cmd = self._command_buffer + vk.vkResetCommandBuffer(cmd, 0) + vk.vkBeginCommandBuffer( + cmd, vk.VkCommandBufferBeginInfo(flags=vk.VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT) + ) + + to_dst = vk.VkImageMemoryBarrier( + srcAccessMask=0, + dstAccessMask=vk.VK_ACCESS_TRANSFER_WRITE_BIT, + oldLayout=vk.VK_IMAGE_LAYOUT_UNDEFINED, + newLayout=vk.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + srcQueueFamilyIndex=vk.VK_QUEUE_FAMILY_IGNORED, + dstQueueFamilyIndex=vk.VK_QUEUE_FAMILY_IGNORED, + image=self._sw_image, + subresourceRange=subresource, + ) + vk.vkCmdPipelineBarrier( + cmd, + vk.VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + vk.VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, + 0, + None, + 0, + None, + 1, + [to_dst], + ) + vk.vkCmdCopyBufferToImage( + cmd, + self._staging_buffer, + self._sw_image, + vk.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + 1, + [region], + ) + # The image barrier also orders the buffer accesses: + # the read-back below only has a write-after-read hazard on the buffer, + # which an execution dependency is enough for + to_src = vk.VkImageMemoryBarrier( + srcAccessMask=vk.VK_ACCESS_TRANSFER_WRITE_BIT, + dstAccessMask=vk.VK_ACCESS_TRANSFER_READ_BIT, + oldLayout=vk.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + newLayout=vk.VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + srcQueueFamilyIndex=vk.VK_QUEUE_FAMILY_IGNORED, + dstQueueFamilyIndex=vk.VK_QUEUE_FAMILY_IGNORED, + image=self._sw_image, + subresourceRange=subresource, + ) + vk.vkCmdPipelineBarrier( + cmd, + vk.VK_PIPELINE_STAGE_TRANSFER_BIT, + vk.VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, + 0, + None, + 0, + None, + 1, + [to_src], + ) + vk.vkCmdCopyImageToBuffer( + cmd, + self._sw_image, + vk.VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + self._staging_buffer, + 1, + [region], + ) + vk.vkEndCommandBuffer(cmd) + def __record_capture( self, image_raw: int, layout: int, base_mip: int, base_layer: int, width: int, height: int ) -> None: @@ -1339,6 +1540,7 @@ def __destroy_vulkan(self) -> None: warn(f"vkDeviceWaitIdle failed during teardown: {e}") self.__destroy_staging_buffer() + self.__destroy_software_image() if self._fence is not None: vk.vkDestroyFence(self._device, self._fence, None) diff --git a/tests/integration/test_vulkan_driver.py b/tests/integration/test_vulkan_driver.py index d9c41f2..409b0f1 100644 --- a/tests/integration/test_vulkan_driver.py +++ b/tests/integration/test_vulkan_driver.py @@ -1,5 +1,8 @@ """Integration tests for the Vulkan driver against the ``vulkan_rendering`` sample core.""" +# The software-core test asserts on the driver's private frame-path state. +# pyright: reportPrivateUsage=false + from __future__ import annotations import pytest @@ -16,6 +19,28 @@ CLEAR_COLOR = (204, 153, 51, 255) +def test_software_core_through_vulkan_driver(load_core: SampleCoreLoader) -> None: + """Software-rendered frames are uploaded to a VkImage, like the GL driver's textures.""" + pytest.importorskip("vulkan", reason="the libretro.py[vulkan] extra is not installed") + from libretro.drivers.video.vulkan import VulkanVideoDriver + + core = load_core("video", "software_rendering") + + driver = VulkanVideoDriver() + with Session(core, None, video={HardwareContext.NONE: lambda: driver}) as session: + for _ in range(3): + session.run() + + # The frame must have gone through the Vulkan upload path + assert driver._last_frame_hw # noqa: SLF001 + + shot = session.video.screenshot() + assert shot is not None + data = bytes(shot.data) + # The sample core renders a moving checkerboard: never a flat frame + assert any(data[i : i + 4] != data[:4] for i in range(0, len(data), 4)) + + def test_vulkan_core_renders_and_screenshots(load_core: SampleCoreLoader) -> None: pytest.importorskip("vulkan", reason="the libretro.py[vulkan] extra is not installed") core = load_core("video", "vulkan_rendering") diff --git a/tests/unit/drivers/test_vulkan_driver.py b/tests/unit/drivers/test_vulkan_driver.py index 38fdeb8..6139f6f 100644 --- a/tests/unit/drivers/test_vulkan_driver.py +++ b/tests/unit/drivers/test_vulkan_driver.py @@ -1,5 +1,6 @@ # The vulkan package is untyped CFFI; see the note in the driver module. -# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false +# These tests also assert on the driver's private frame-path state. +# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportPrivateUsage=false # pyright: reportUnknownVariableType=false, reportUnknownArgumentType=false from ctypes import byref @@ -82,12 +83,49 @@ def test_software_frame_screenshot(): frame = bytearray(b"\x00\x00\xff\x00" * (WIDTH * HEIGHT)) driver.refresh(memoryview(frame), WIDTH, HEIGHT, WIDTH * 4) + # Software frames go through a VkImage, like the GL driver's texture upload + assert driver._last_frame_hw + shot = driver.screenshot() assert shot is not None assert (shot.width, shot.height) == (WIDTH, HEIGHT) assert bytes(shot.data[:4]) == b"\xff\x00\x00\xff" +def test_software_frame_with_padded_pitch(): + driver = VulkanVideoDriver() + driver.pixel_format = PixelFormat.XRGB8888 + driver.system_av_info = _av_info() + + pitch = WIDTH * 4 + 32 # Rows padded past the visible width + frame = bytearray(pitch * HEIGHT) + for y in range(HEIGHT): + frame[y * pitch : y * pitch + WIDTH * 4] = b"\x00\xff\x00\x00" * WIDTH # Green + driver.refresh(memoryview(frame), WIDTH, HEIGHT, pitch) + + assert driver._last_frame_hw + shot = driver.screenshot() + assert shot is not None + assert bytes(shot.data[:4]) == b"\x00\xff\x00\xff" + end = (WIDTH * HEIGHT - 1) * 4 + assert bytes(shot.data[end : end + 4]) == b"\x00\xff\x00\xff" + + +def test_software_frame_rgb565(): + driver = VulkanVideoDriver() + driver.pixel_format = PixelFormat.RGB565 + driver.system_av_info = _av_info() + + # Pure blue in RGB565 is 0x001F (little-endian bytes 1F 00) + frame = bytearray(b"\x1f\x00" * (WIDTH * HEIGHT)) + driver.refresh(memoryview(frame), WIDTH, HEIGHT, WIDTH * 2) + + assert driver._last_frame_hw + shot = driver.screenshot() + assert shot is not None + assert bytes(shot.data[:4]) == b"\x00\x00\xff\xff" + + def _init_hw_driver() -> tuple[VulkanVideoDriver, list[int]]: driver = VulkanVideoDriver() driver.pixel_format = PixelFormat.XRGB8888 From 3833030d2cf7899dea724f11954db4cac61a9a4d Mon Sep 17 00:00:00 2001 From: Eric Warmenhoven Date: Tue, 28 Jul 2026 21:25:12 -0400 Subject: [PATCH 6/6] address review: typed Vulkan bindings, no CPU fallback, driver-owned negotiation version Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 +- src/libretro/api/video/vulkan.py | 685 ++++++++++++++---- src/libretro/drivers/environment/composite.py | 19 +- src/libretro/drivers/video/driver.py | 22 + src/libretro/drivers/video/multi.py | 25 + src/libretro/drivers/video/vulkan/_typing.py | 213 ++++++ src/libretro/drivers/video/vulkan/driver.py | 401 +++++++--- tests/integration/test_vulkan_driver.py | 2 +- tests/unit/api/test_video_vulkan.py | 55 +- tests/unit/drivers/test_video_negotiation.py | 29 +- tests/unit/drivers/test_vulkan_driver.py | 178 ++++- 11 files changed, 1355 insertions(+), 285 deletions(-) create mode 100644 src/libretro/drivers/video/vulkan/_typing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e7fb305..8069cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,11 +25,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `VulkanVideoDriver` uploads software-rendered frames to a `VkImage` and reads them back through its capture path, mirroring the OpenGL driver's texture upload; - it falls back to CPU-side frames when Vulkan is unavailable - or the pixel format has no direct Vulkan equivalent. + if Vulkan is unavailable, initialization fails + rather than silently falling back to CPU-side frames, + since the driver exists to test cores' Vulkan support. +- `VulkanVideoDriver` implements `RETRO_ENVIRONMENT_GET_CURRENT_SOFTWARE_FRAMEBUFFER` + by handing the core a pointer to persistently-mapped host-visible Vulkan memory. - Implemented `RETRO_ENVIRONMENT_SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE` and `RETRO_ENVIRONMENT_GET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_SUPPORT`, and added a `context_negotiation_interface` property to `VideoDriver`. +- Added `VideoDriver.context_negotiation_version`, + which lets each driver report the negotiation interface version it supports; + `VulkanVideoDriver` accepts a `negotiation_version` argument + so cores can be tested against the version 1 negotiation interface. ### Fixed diff --git a/src/libretro/api/video/vulkan.py b/src/libretro/api/video/vulkan.py index 7b44d63..0824dad 100644 --- a/src/libretro/api/video/vulkan.py +++ b/src/libretro/api/video/vulkan.py @@ -5,23 +5,22 @@ including the mirrored Vulkan API structs that appear in the libretro ABI by value. -Vulkan handles are represented as plain integers at this layer: -dispatchable handles (``VkInstance``, ``VkDevice``, ...) as :class:`~ctypes.c_void_p` -and non-dispatchable handles (``VkImage``, ``VkSemaphore``, ...) as :class:`~ctypes.c_uint64`. +Dispatchable Vulkan handles (``VkInstance``, ``VkDevice``, ...) +are represented as :class:`.c_void_ptr` +and non-dispatchable handles (``VkImage``, ``VkSemaphore``, ...) +as plain integers backed by :class:`~ctypes.c_uint64`. Only 64-bit platforms are supported. """ -from ctypes import ( - CFUNCTYPE, - POINTER, - Structure, - c_bool, - c_char_p, - c_int, - c_uint, - c_uint32, - c_uint64, - c_void_p, +from ctypes import Structure, c_bool, c_char_p, c_int, c_uint, c_uint32, c_uint64 +from dataclasses import dataclass + +from libretro.api._utils import NullPointerToNoneMixin +from libretro.ctypes import ( + CIntArg, + TypedFunctionPointer, + TypedPointer, + c_void_ptr, ) from .negotiate import retro_hw_render_context_negotiation_interface @@ -31,11 +30,11 @@ RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION = 2 # Dispatchable Vulkan handles (pointers to opaque driver objects) -VkInstance = c_void_p -VkPhysicalDevice = c_void_p -VkDevice = c_void_p -VkQueue = c_void_p -VkCommandBuffer = c_void_p +VkInstance = c_void_ptr +VkPhysicalDevice = c_void_ptr +VkDevice = c_void_ptr +VkQueue = c_void_ptr +VkCommandBuffer = c_void_ptr # Non-dispatchable Vulkan handles (64-bit integers) VkImage = c_uint64 @@ -48,19 +47,42 @@ VkFormat = c_int VkStructureType = c_int -PFN_vkGetInstanceProcAddr = c_void_p +PFN_vkGetInstanceProcAddr = c_void_ptr """Treated as an opaque function pointer at the ABI boundary.""" -PFN_vkGetDeviceProcAddr = c_void_p +PFN_vkGetDeviceProcAddr = c_void_ptr """Treated as an opaque function pointer at the ABI boundary.""" -class VkApplicationInfo(Structure): - """Corresponds to :c:type:`VkApplicationInfo` in ``vulkan_core.h``.""" +@dataclass(init=False, slots=True) +class VkApplicationInfo(Structure, NullPointerToNoneMixin): + """ + Corresponds to :c:type:`VkApplicationInfo` in ``vulkan_core.h``. + + >>> from libretro.api.video import VkApplicationInfo + >>> info = VkApplicationInfo() + >>> info.pApplicationName is None + True + """ + + sType: int + """Structure type tag (:c:type:`VkStructureType`).""" + pNext: c_void_ptr | None + """Pointer to an extension structure, if any.""" + pApplicationName: bytes | None + """Name of the application, if provided.""" + applicationVersion: int + """Application-defined version number.""" + pEngineName: bytes | None + """Name of the engine, if provided.""" + engineVersion: int + """Engine-defined version number.""" + apiVersion: int + """Highest Vulkan API version the application intends to use.""" _fields_ = ( ("sType", VkStructureType), - ("pNext", c_void_p), + ("pNext", c_void_ptr), ("pApplicationName", c_char_p), ("applicationVersion", c_uint32), ("pEngineName", c_char_p), @@ -69,8 +91,24 @@ class VkApplicationInfo(Structure): ) +@dataclass(init=False, slots=True) class VkComponentMapping(Structure): - """Corresponds to :c:type:`VkComponentMapping` in ``vulkan_core.h``.""" + """ + Corresponds to :c:type:`VkComponentMapping` in ``vulkan_core.h``. + + >>> from libretro.api.video import VkComponentMapping + >>> VkComponentMapping().r + 0 + """ + + r: int + """Swizzle for the red component (:c:type:`VkComponentSwizzle`).""" + g: int + """Swizzle for the green component (:c:type:`VkComponentSwizzle`).""" + b: int + """Swizzle for the blue component (:c:type:`VkComponentSwizzle`).""" + a: int + """Swizzle for the alpha component (:c:type:`VkComponentSwizzle`).""" _fields_ = ( ("r", c_int), @@ -79,9 +117,44 @@ class VkComponentMapping(Structure): ("a", c_int), ) - + def __deepcopy__(self, _): + """ + Return a copy of this object. + Intended for use with :func:`copy.deepcopy`. + + >>> from copy import deepcopy + >>> from ctypes import addressof + >>> from libretro.api.video import VkComponentMapping + >>> mapping = VkComponentMapping(r=1) + >>> mapping_copy = deepcopy(mapping) + >>> addressof(mapping) == addressof(mapping_copy) + False + >>> mapping_copy.r + 1 + """ + return VkComponentMapping(r=self.r, g=self.g, b=self.b, a=self.a) + + +@dataclass(init=False, slots=True) class VkImageSubresourceRange(Structure): - """Corresponds to :c:type:`VkImageSubresourceRange` in ``vulkan_core.h``.""" + """ + Corresponds to :c:type:`VkImageSubresourceRange` in ``vulkan_core.h``. + + >>> from libretro.api.video import VkImageSubresourceRange + >>> VkImageSubresourceRange().levelCount + 0 + """ + + aspectMask: int + """Bitmask of the image aspects included in the range.""" + baseMipLevel: int + """First mipmap level in the range.""" + levelCount: int + """Number of mipmap levels in the range.""" + baseArrayLayer: int + """First array layer in the range.""" + layerCount: int + """Number of array layers in the range.""" _fields_ = ( ("aspectMask", c_uint32), @@ -91,13 +164,60 @@ class VkImageSubresourceRange(Structure): ("layerCount", c_uint32), ) + def __deepcopy__(self, _): + """ + Return a copy of this object. + Intended for use with :func:`copy.deepcopy`. + + >>> from copy import deepcopy + >>> from ctypes import addressof + >>> from libretro.api.video import VkImageSubresourceRange + >>> subresource = VkImageSubresourceRange(layerCount=1) + >>> subresource_copy = deepcopy(subresource) + >>> addressof(subresource) == addressof(subresource_copy) + False + >>> subresource_copy.layerCount + 1 + """ + return VkImageSubresourceRange( + aspectMask=self.aspectMask, + baseMipLevel=self.baseMipLevel, + levelCount=self.levelCount, + baseArrayLayer=self.baseArrayLayer, + layerCount=self.layerCount, + ) + + +@dataclass(init=False, slots=True) +class VkImageViewCreateInfo(Structure, NullPointerToNoneMixin): + """ + Corresponds to :c:type:`VkImageViewCreateInfo` in ``vulkan_core.h``. + + >>> from libretro.api.video import VkImageViewCreateInfo + >>> VkImageViewCreateInfo().pNext is None + True + """ -class VkImageViewCreateInfo(Structure): - """Corresponds to :c:type:`VkImageViewCreateInfo` in ``vulkan_core.h``.""" + sType: int + """Structure type tag (:c:type:`VkStructureType`).""" + pNext: c_void_ptr | None + """Pointer to an extension structure, if any.""" + flags: int + """Reserved :c:type:`VkImageViewCreateFlags`.""" + image: int + """The :c:type:`VkImage` the view was created from.""" + viewType: int + """Type of the image view (:c:type:`VkImageViewType`).""" + format: int + """Pixel format of the view (:c:type:`VkFormat`).""" + components: VkComponentMapping + """Component swizzle applied by the view.""" + subresourceRange: VkImageSubresourceRange + """Subresource range the view covers.""" _fields_ = ( ("sType", VkStructureType), - ("pNext", c_void_p), + ("pNext", c_void_ptr), ("flags", c_uint32), ("image", VkImage), ("viewType", c_int), @@ -107,73 +227,169 @@ class VkImageViewCreateInfo(Structure): ) +@dataclass(init=False, slots=True) class VkPhysicalDeviceFeatures(Structure): - """Corresponds to :c:type:`VkPhysicalDeviceFeatures` in ``vulkan_core.h``.""" - - _fields_ = tuple( - (name, VkBool32) - for name in ( - "robustBufferAccess", - "fullDrawIndexUint32", - "imageCubeArray", - "independentBlend", - "geometryShader", - "tessellationShader", - "sampleRateShading", - "dualSrcBlend", - "logicOp", - "multiDrawIndirect", - "drawIndirectFirstInstance", - "depthClamp", - "depthBiasClamp", - "fillModeNonSolid", - "depthBounds", - "wideLines", - "largePoints", - "alphaToOne", - "multiViewport", - "samplerAnisotropy", - "textureCompressionETC2", - "textureCompressionASTC_LDR", - "textureCompressionBC", - "occlusionQueryPrecise", - "pipelineStatisticsQuery", - "vertexPipelineStoresAndAtomics", - "fragmentStoresAndAtomics", - "shaderTessellationAndGeometryPointSize", - "shaderImageGatherExtended", - "shaderStorageImageExtendedFormats", - "shaderStorageImageMultisample", - "shaderStorageImageReadWithoutFormat", - "shaderStorageImageWriteWithoutFormat", - "shaderUniformBufferArrayDynamicIndexing", - "shaderSampledImageArrayDynamicIndexing", - "shaderStorageBufferArrayDynamicIndexing", - "shaderStorageImageArrayDynamicIndexing", - "shaderClipDistance", - "shaderCullDistance", - "shaderFloat64", - "shaderInt64", - "shaderInt16", - "shaderResourceResidency", - "shaderResourceMinLod", - "sparseBinding", - "sparseResidencyBuffer", - "sparseResidencyImage2D", - "sparseResidencyImage3D", - "sparseResidency2Samples", - "sparseResidency4Samples", - "sparseResidency8Samples", - "sparseResidency16Samples", - "sparseResidencyAliased", - "variableMultisampleRate", - "inheritedQueries", - ) + """ + Corresponds to :c:type:`VkPhysicalDeviceFeatures` in ``vulkan_core.h``. + + Each field is a :c:type:`VkBool32` indicating whether the feature is + supported (when queried) or requested (when creating a device). + + >>> from libretro.api.video import VkPhysicalDeviceFeatures + >>> VkPhysicalDeviceFeatures().geometryShader + 0 + """ + + robustBufferAccess: int + fullDrawIndexUint32: int + imageCubeArray: int + independentBlend: int + geometryShader: int + tessellationShader: int + sampleRateShading: int + dualSrcBlend: int + logicOp: int + multiDrawIndirect: int + drawIndirectFirstInstance: int + depthClamp: int + depthBiasClamp: int + fillModeNonSolid: int + depthBounds: int + wideLines: int + largePoints: int + alphaToOne: int + multiViewport: int + samplerAnisotropy: int + textureCompressionETC2: int + textureCompressionASTC_LDR: int + textureCompressionBC: int + occlusionQueryPrecise: int + pipelineStatisticsQuery: int + vertexPipelineStoresAndAtomics: int + fragmentStoresAndAtomics: int + shaderTessellationAndGeometryPointSize: int + shaderImageGatherExtended: int + shaderStorageImageExtendedFormats: int + shaderStorageImageMultisample: int + shaderStorageImageReadWithoutFormat: int + shaderStorageImageWriteWithoutFormat: int + shaderUniformBufferArrayDynamicIndexing: int + shaderSampledImageArrayDynamicIndexing: int + shaderStorageBufferArrayDynamicIndexing: int + shaderStorageImageArrayDynamicIndexing: int + shaderClipDistance: int + shaderCullDistance: int + shaderFloat64: int + shaderInt64: int + shaderInt16: int + shaderResourceResidency: int + shaderResourceMinLod: int + sparseBinding: int + sparseResidencyBuffer: int + sparseResidencyImage2D: int + sparseResidencyImage3D: int + sparseResidency2Samples: int + sparseResidency4Samples: int + sparseResidency8Samples: int + sparseResidency16Samples: int + sparseResidencyAliased: int + variableMultisampleRate: int + inheritedQueries: int + + _fields_ = ( + ("robustBufferAccess", VkBool32), + ("fullDrawIndexUint32", VkBool32), + ("imageCubeArray", VkBool32), + ("independentBlend", VkBool32), + ("geometryShader", VkBool32), + ("tessellationShader", VkBool32), + ("sampleRateShading", VkBool32), + ("dualSrcBlend", VkBool32), + ("logicOp", VkBool32), + ("multiDrawIndirect", VkBool32), + ("drawIndirectFirstInstance", VkBool32), + ("depthClamp", VkBool32), + ("depthBiasClamp", VkBool32), + ("fillModeNonSolid", VkBool32), + ("depthBounds", VkBool32), + ("wideLines", VkBool32), + ("largePoints", VkBool32), + ("alphaToOne", VkBool32), + ("multiViewport", VkBool32), + ("samplerAnisotropy", VkBool32), + ("textureCompressionETC2", VkBool32), + ("textureCompressionASTC_LDR", VkBool32), + ("textureCompressionBC", VkBool32), + ("occlusionQueryPrecise", VkBool32), + ("pipelineStatisticsQuery", VkBool32), + ("vertexPipelineStoresAndAtomics", VkBool32), + ("fragmentStoresAndAtomics", VkBool32), + ("shaderTessellationAndGeometryPointSize", VkBool32), + ("shaderImageGatherExtended", VkBool32), + ("shaderStorageImageExtendedFormats", VkBool32), + ("shaderStorageImageMultisample", VkBool32), + ("shaderStorageImageReadWithoutFormat", VkBool32), + ("shaderStorageImageWriteWithoutFormat", VkBool32), + ("shaderUniformBufferArrayDynamicIndexing", VkBool32), + ("shaderSampledImageArrayDynamicIndexing", VkBool32), + ("shaderStorageBufferArrayDynamicIndexing", VkBool32), + ("shaderStorageImageArrayDynamicIndexing", VkBool32), + ("shaderClipDistance", VkBool32), + ("shaderCullDistance", VkBool32), + ("shaderFloat64", VkBool32), + ("shaderInt64", VkBool32), + ("shaderInt16", VkBool32), + ("shaderResourceResidency", VkBool32), + ("shaderResourceMinLod", VkBool32), + ("sparseBinding", VkBool32), + ("sparseResidencyBuffer", VkBool32), + ("sparseResidencyImage2D", VkBool32), + ("sparseResidencyImage3D", VkBool32), + ("sparseResidency2Samples", VkBool32), + ("sparseResidency4Samples", VkBool32), + ("sparseResidency8Samples", VkBool32), + ("sparseResidency16Samples", VkBool32), + ("sparseResidencyAliased", VkBool32), + ("variableMultisampleRate", VkBool32), + ("inheritedQueries", VkBool32), ) + def __deepcopy__(self, _): + """ + Return a copy of this object. + Intended for use with :func:`copy.deepcopy`. + + >>> from copy import deepcopy + >>> from ctypes import addressof + >>> from libretro.api.video import VkPhysicalDeviceFeatures + >>> features = VkPhysicalDeviceFeatures(geometryShader=1) + >>> features_copy = deepcopy(features) + >>> addressof(features) == addressof(features_copy) + False + >>> features_copy.geometryShader + 1 + """ + return VkPhysicalDeviceFeatures( + **{field[0]: getattr(self, field[0]) for field in VkPhysicalDeviceFeatures._fields_} + ) + +@dataclass(init=False, slots=True) class retro_vulkan_image(Structure): - """Corresponds to :c:type:`retro_vulkan_image` in ``libretro_vulkan.h``.""" + """ + Corresponds to :c:type:`retro_vulkan_image` in ``libretro_vulkan.h``. + + >>> from libretro.api.video import retro_vulkan_image + >>> retro_vulkan_image().image_view + 0 + """ + + image_view: int + """The :c:type:`VkImageView` the core rendered into.""" + image_layout: int + """Layout of the image at the time of ``set_image`` (:c:type:`VkImageLayout`).""" + create_info: VkImageViewCreateInfo + """The create info used to make ``image_view``.""" _fields_ = ( ("image_view", VkImageView), @@ -182,8 +398,32 @@ class retro_vulkan_image(Structure): ) -class retro_vulkan_context(Structure): - """Corresponds to :c:type:`retro_vulkan_context` in ``libretro_vulkan.h``.""" +@dataclass(init=False, slots=True) +class retro_vulkan_context(Structure, NullPointerToNoneMixin): + """ + Corresponds to :c:type:`retro_vulkan_context` in ``libretro_vulkan.h``. + + Filled by a core's ``create_device`` or ``create_device2`` + to hand the negotiated device back to the frontend. + + >>> from libretro.api.video import retro_vulkan_context + >>> context = retro_vulkan_context() + >>> context.device is None + True + """ + + gpu: VkPhysicalDevice | None + """The physical device the core selected, if any.""" + device: VkDevice | None + """The logical device the core created.""" + queue: VkQueue | None + """The queue the frontend should use for its own work.""" + queue_family_index: int + """Queue family that ``queue`` belongs to.""" + presentation_queue: VkQueue | None + """Queue to present with; may equal ``queue``.""" + presentation_queue_family_index: int + """Queue family that ``presentation_queue`` belongs to.""" _fields_ = ( ("gpu", VkPhysicalDevice), @@ -195,60 +435,168 @@ class retro_vulkan_context(Structure): ) -retro_vulkan_set_image_t = CFUNCTYPE( - None, c_void_p, POINTER(retro_vulkan_image), c_uint32, POINTER(VkSemaphore), c_uint32 -) -retro_vulkan_get_sync_index_t = CFUNCTYPE(c_uint32, c_void_p) -retro_vulkan_get_sync_index_mask_t = CFUNCTYPE(c_uint32, c_void_p) -retro_vulkan_set_command_buffers_t = CFUNCTYPE(None, c_void_p, c_uint32, POINTER(VkCommandBuffer)) -retro_vulkan_wait_sync_index_t = CFUNCTYPE(None, c_void_p) -retro_vulkan_lock_queue_t = CFUNCTYPE(None, c_void_p) -retro_vulkan_unlock_queue_t = CFUNCTYPE(None, c_void_p) -retro_vulkan_set_signal_semaphore_t = CFUNCTYPE(None, c_void_p, VkSemaphore) - -retro_vulkan_get_application_info_t = CFUNCTYPE(POINTER(VkApplicationInfo)) -retro_vulkan_create_device_t = CFUNCTYPE( +retro_vulkan_set_image_t = TypedFunctionPointer[ + None, + [ + c_void_ptr, + TypedPointer[retro_vulkan_image], + CIntArg[c_uint32], + TypedPointer[VkSemaphore], + CIntArg[c_uint32], + ], +] +""" +Give the frontend the image the core rendered into, +plus semaphores to wait on (if any) and the source queue family. + +Corresponds to :c:type:`retro_vulkan_set_image_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_get_sync_index_t = TypedFunctionPointer[c_uint32, [c_void_ptr]] +""" +Return the frontend's current frame-in-flight index. + +Corresponds to :c:type:`retro_vulkan_get_sync_index_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_get_sync_index_mask_t = TypedFunctionPointer[c_uint32, [c_void_ptr]] +""" +Return a bitmask of all valid sync indices. + +Corresponds to :c:type:`retro_vulkan_get_sync_index_mask_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_set_command_buffers_t = TypedFunctionPointer[ + None, [c_void_ptr, CIntArg[c_uint32], TypedPointer[VkCommandBuffer]] +] +""" +Give the frontend command buffers to submit alongside its own work. + +Corresponds to :c:type:`retro_vulkan_set_command_buffers_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_wait_sync_index_t = TypedFunctionPointer[None, [c_void_ptr]] +""" +Block until the frontend has finished all work for the current sync index. + +Corresponds to :c:type:`retro_vulkan_wait_sync_index_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_lock_queue_t = TypedFunctionPointer[None, [c_void_ptr]] +""" +Acquire exclusive access to the shared :c:type:`VkQueue`. + +Corresponds to :c:type:`retro_vulkan_lock_queue_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_unlock_queue_t = TypedFunctionPointer[None, [c_void_ptr]] +""" +Release exclusive access to the shared :c:type:`VkQueue`. + +Corresponds to :c:type:`retro_vulkan_unlock_queue_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_set_signal_semaphore_t = TypedFunctionPointer[ + None, [c_void_ptr, CIntArg[VkSemaphore]] +] +""" +Give the frontend a semaphore to signal when it finishes the current frame. + +Corresponds to :c:type:`retro_vulkan_set_signal_semaphore_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_get_application_info_t = TypedFunctionPointer[TypedPointer[VkApplicationInfo], []] +""" +Return the :c:type:`VkApplicationInfo` the frontend should create its instance with. + +Corresponds to :c:type:`retro_vulkan_get_application_info_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_create_device_t = TypedFunctionPointer[ c_bool, - POINTER(retro_vulkan_context), - VkInstance, - VkPhysicalDevice, - VkSurfaceKHR, - PFN_vkGetInstanceProcAddr, - POINTER(c_char_p), - c_uint, - POINTER(c_char_p), - c_uint, - POINTER(VkPhysicalDeviceFeatures), -) -retro_vulkan_destroy_device_t = CFUNCTYPE(None) + [ + TypedPointer[retro_vulkan_context], + VkInstance, + VkPhysicalDevice, + CIntArg[VkSurfaceKHR], + PFN_vkGetInstanceProcAddr, + TypedPointer[c_char_p], + CIntArg[c_uint], + TypedPointer[c_char_p], + CIntArg[c_uint], + TypedPointer[VkPhysicalDeviceFeatures], + ], +] +""" +Ask the core to create the :c:type:`VkDevice` itself +(version 1 of the negotiation interface). -retro_vulkan_create_instance_wrapper_t = CFUNCTYPE(VkInstance, c_void_p, c_void_p) -"""The second parameter is a ``const VkInstanceCreateInfo *``, opaque at this layer.""" +Corresponds to :c:type:`retro_vulkan_create_device_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_destroy_device_t = TypedFunctionPointer[None, []] +""" +Tell the core that the device it created is about to be destroyed. + +Corresponds to :c:type:`retro_vulkan_destroy_device_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_create_instance_wrapper_t = TypedFunctionPointer[VkInstance, [c_void_ptr, c_void_ptr]] +""" +Frontend-provided wrapper around :c:func:`vkCreateInstance`. +The second parameter is a ``const VkInstanceCreateInfo *``, opaque at this layer. -retro_vulkan_create_instance_t = CFUNCTYPE( +Corresponds to :c:type:`retro_vulkan_create_instance_wrapper_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_create_instance_t = TypedFunctionPointer[ VkInstance, - PFN_vkGetInstanceProcAddr, - POINTER(VkApplicationInfo), - retro_vulkan_create_instance_wrapper_t, - c_void_p, -) + [ + PFN_vkGetInstanceProcAddr, + TypedPointer[VkApplicationInfo], + retro_vulkan_create_instance_wrapper_t, + c_void_ptr, + ], +] +""" +Ask the core to create the :c:type:`VkInstance` itself +(version 2 of the negotiation interface). + +Corresponds to :c:type:`retro_vulkan_create_instance_t` in ``libretro_vulkan.h``. +""" -retro_vulkan_create_device_wrapper_t = CFUNCTYPE(VkDevice, VkPhysicalDevice, c_void_p, c_void_p) -"""The third parameter is a ``const VkDeviceCreateInfo *``, opaque at this layer.""" +retro_vulkan_create_device_wrapper_t = TypedFunctionPointer[ + VkDevice, [VkPhysicalDevice, c_void_ptr, c_void_ptr] +] +""" +Frontend-provided wrapper around :c:func:`vkCreateDevice`. +The third parameter is a ``const VkDeviceCreateInfo *``, opaque at this layer. + +Corresponds to :c:type:`retro_vulkan_create_device_wrapper_t` in ``libretro_vulkan.h``. +""" -retro_vulkan_create_device2_t = CFUNCTYPE( +retro_vulkan_create_device2_t = TypedFunctionPointer[ c_bool, - POINTER(retro_vulkan_context), - VkInstance, - VkPhysicalDevice, - VkSurfaceKHR, - PFN_vkGetInstanceProcAddr, - retro_vulkan_create_device_wrapper_t, - c_void_p, -) + [ + TypedPointer[retro_vulkan_context], + VkInstance, + VkPhysicalDevice, + CIntArg[VkSurfaceKHR], + PFN_vkGetInstanceProcAddr, + retro_vulkan_create_device_wrapper_t, + c_void_ptr, + ], +] +""" +Ask the core to create the :c:type:`VkDevice` itself +(version 2 of the negotiation interface). + +Corresponds to :c:type:`retro_vulkan_create_device2_t` in ``libretro_vulkan.h``. +""" -class retro_hw_render_interface_vulkan(retro_hw_render_interface): +@dataclass(init=False, slots=True) +class retro_hw_render_interface_vulkan(retro_hw_render_interface, NullPointerToNoneMixin): """ Corresponds to :c:type:`retro_hw_render_interface_vulkan` in ``libretro_vulkan.h``. @@ -256,10 +604,48 @@ class retro_hw_render_interface_vulkan(retro_hw_render_interface): through :attr:`.EnvironmentCall.GET_HW_RENDER_INTERFACE`. Extends :class:`.retro_hw_render_interface`, so a pointer to this struct may be reinterpreted as its base. + + >>> from libretro.api.video import retro_hw_render_interface_vulkan + >>> iface = retro_hw_render_interface_vulkan() + >>> iface.set_image is None + True """ + handle: c_void_ptr | None + """Opaque frontend handle passed back to every callback.""" + instance: VkInstance | None + """The frontend's :c:type:`VkInstance`.""" + gpu: VkPhysicalDevice | None + """The frontend's :c:type:`VkPhysicalDevice`.""" + device: VkDevice | None + """The frontend's :c:type:`VkDevice`.""" + get_device_proc_addr: PFN_vkGetDeviceProcAddr | None + """Device-level proc address loader for the core.""" + get_instance_proc_addr: PFN_vkGetInstanceProcAddr | None + """Instance-level proc address loader for the core.""" + queue: VkQueue | None + """The :c:type:`VkQueue` shared between core and frontend.""" + queue_index: int + """Queue family that ``queue`` belongs to.""" + set_image: retro_vulkan_set_image_t | None + """Gives the frontend the core's rendered image.""" + get_sync_index: retro_vulkan_get_sync_index_t | None + """Returns the current frame-in-flight index.""" + get_sync_index_mask: retro_vulkan_get_sync_index_mask_t | None + """Returns a bitmask of all valid sync indices.""" + set_command_buffers: retro_vulkan_set_command_buffers_t | None + """Gives the frontend command buffers to submit.""" + wait_sync_index: retro_vulkan_wait_sync_index_t | None + """Blocks until the current sync index is idle.""" + lock_queue: retro_vulkan_lock_queue_t | None + """Acquires exclusive access to the shared queue.""" + unlock_queue: retro_vulkan_unlock_queue_t | None + """Releases exclusive access to the shared queue.""" + set_signal_semaphore: retro_vulkan_set_signal_semaphore_t | None + """Gives the frontend a semaphore to signal each frame.""" + _fields_ = ( - ("handle", c_void_p), + ("handle", c_void_ptr), ("instance", VkInstance), ("gpu", VkPhysicalDevice), ("device", VkDevice), @@ -278,8 +664,9 @@ class retro_hw_render_interface_vulkan(retro_hw_render_interface): ) +@dataclass(init=False, slots=True) class retro_hw_render_context_negotiation_interface_vulkan( - retro_hw_render_context_negotiation_interface + retro_hw_render_context_negotiation_interface, NullPointerToNoneMixin ): """ Corresponds to :c:type:`retro_hw_render_context_negotiation_interface_vulkan` @@ -290,8 +677,24 @@ class retro_hw_render_context_negotiation_interface_vulkan( Extends :class:`.retro_hw_render_context_negotiation_interface`. This is the version 2 layout; cores that only know version 1 leave the trailing fields unset. + + >>> from libretro.api.video import retro_hw_render_context_negotiation_interface_vulkan + >>> iface = retro_hw_render_context_negotiation_interface_vulkan() + >>> iface.create_device is None + True """ + get_application_info: retro_vulkan_get_application_info_t | None + """Returns the application info for the frontend's instance.""" + create_device: retro_vulkan_create_device_t | None + """Creates the device on the core's terms (version 1).""" + destroy_device: retro_vulkan_destroy_device_t | None + """Notifies the core before its device is destroyed.""" + create_instance: retro_vulkan_create_instance_t | None + """Creates the instance on the core's terms (version 2).""" + create_device2: retro_vulkan_create_device2_t | None + """Creates the device on the core's terms (version 2).""" + _fields_ = ( ("get_application_info", retro_vulkan_get_application_info_t), ("create_device", retro_vulkan_create_device_t), diff --git a/src/libretro/drivers/environment/composite.py b/src/libretro/drivers/environment/composite.py index 026195e..81b0463 100644 --- a/src/libretro/drivers/environment/composite.py +++ b/src/libretro/drivers/environment/composite.py @@ -2074,16 +2074,17 @@ def _get_hw_render_context_negotiation_interface_support( "doesn't accept NULL" ) - if ( - support[0].interface_type == ContextNegotiationInterfaceType.VULKAN - and HardwareContext.VULKAN in self._video.supported_contexts - ): - support[ - 0 - ].interface_version = RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION - return True + try: + interface_type = ContextNegotiationInterfaceType(support[0].interface_type) + except ValueError: + return False + + version = self._video.context_negotiation_version(interface_type) + if version is None: + return False - return False + support[0].interface_version = version + return True @property def jit_capable(self) -> bool | None: diff --git a/src/libretro/drivers/video/driver.py b/src/libretro/drivers/video/driver.py index 73a2baa..447f45b 100644 --- a/src/libretro/drivers/video/driver.py +++ b/src/libretro/drivers/video/driver.py @@ -11,6 +11,7 @@ from libretro.api.av import retro_game_geometry, retro_system_av_info from libretro.api.proc import retro_proc_address_t from libretro.api.video import ( + ContextNegotiationInterfaceType, HardwareContext, MemoryAccess, PixelFormat, @@ -481,6 +482,27 @@ def context_negotiation_interface( self, interface: retro_hw_render_context_negotiation_interface | None ) -> None: ... + def context_negotiation_version( + self, + interface_type: ContextNegotiationInterfaceType, # noqa: ARG002 + ) -> int | None: + """ + Return the version of the given context negotiation interface type + that this driver exposes to cores, + or :obj:`None` if the driver doesn't support that interface type. + + Not abstract; drivers without context negotiation support + inherit this default implementation, which always returns :obj:`None`. + + :param interface_type: The negotiation interface type the core asked about. + + .. note:: + + Corresponds to + ``RETRO_ENVIRONMENT_GET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_SUPPORT``. + """ + return None + @property @abstractmethod def shared_context(self) -> bool: diff --git a/src/libretro/drivers/video/multi.py b/src/libretro/drivers/video/multi.py index b4ceb01..5f5afd2 100644 --- a/src/libretro/drivers/video/multi.py +++ b/src/libretro/drivers/video/multi.py @@ -13,6 +13,7 @@ from libretro.api.av import retro_game_geometry, retro_system_av_info from libretro.api.proc import retro_proc_address_t from libretro.api.video import ( + ContextNegotiationInterfaceType, HardwareContext, MemoryAccess, PixelFormat, @@ -28,6 +29,11 @@ DriverMap = Mapping[HardwareContext, Callable[[], VideoDriver]] +# The hardware context that serves each negotiation interface type +_NEGOTIATION_CONTEXTS: dict[ContextNegotiationInterfaceType, HardwareContext] = { + ContextNegotiationInterfaceType.VULKAN: HardwareContext.VULKAN, +} + _default_driver_map: dict[HardwareContext, Callable[[], VideoDriver]] = { HardwareContext.NONE: ArrayVideoDriver } @@ -433,6 +439,25 @@ def context_negotiation_interface( except NotImplementedError: pass # The active driver doesn't negotiate contexts; that's okay + @override + def context_negotiation_version( + self, interface_type: ContextNegotiationInterfaceType + ) -> int | None: + if self._current is not None: + version = self._current.context_negotiation_version(interface_type) + if version is not None: + return version + + # Cores query negotiation support before requesting their context, + # so the driver that would serve this interface type + # may not have been instantiated yet; ask a throwaway instance + context = _NEGOTIATION_CONTEXTS.get(interface_type) + factory = self._drivers.get(context) if context is not None else None + if factory is None: + return None + + return factory().context_negotiation_version(interface_type) + @property @override def shared_context(self) -> bool: diff --git a/src/libretro/drivers/video/vulkan/_typing.py b/src/libretro/drivers/video/vulkan/_typing.py new file mode 100644 index 0000000..002b85c --- /dev/null +++ b/src/libretro/drivers/video/vulkan/_typing.py @@ -0,0 +1,213 @@ +""" +Typed declarations for the subset of the :mod:`vulkan` package +used by :class:`.VulkanVideoDriver`. + +The :mod:`vulkan` package is generated CFFI code without type information, +so this module declares the functions, struct constructors, and constants +that the driver uses, in the spirit of :mod:`libretro.ctypes`: +the declarations exist only for static analysis +and erase to the real (untyped) module at runtime. + +Vulkan handles and structs are opaque CFFI ``cdata`` objects (``CData``, +from the :mod:`cffi` type stubs), and memory mapped with ``vkMapMemory`` +is a CFFI buffer (``CBuffer``). +""" + +# pyright: reportPrivateUsage=false +# The cffi type stubs only expose the cdata type under a private name. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from typing import Any, Protocol + + from _cffi_backend import _CDataBase + from cffi import FFI + + type CData = _CDataBase + """An opaque CFFI ``cdata`` object, e.g. a Vulkan handle or struct.""" + + class CBuffer(Protocol): + """ + The slice of the CFFI ``buffer`` interface used by the driver: + reading a slice yields :class:`bytes`, writing accepts them, + and the whole mapping can be re-exported through the buffer protocol. + """ + + def __len__(self) -> int: ... + def __getitem__(self, index: slice, /) -> bytes: ... + def __setitem__(self, index: slice, value: bytes, /) -> None: ... + def __buffer__(self, flags: int, /) -> memoryview: ... + + class _VulkanModule(Protocol): + """The names the driver uses from the :mod:`vulkan` package.""" + + VK_TRUE: int + VK_QUEUE_FAMILY_IGNORED: int + + VK_ACCESS_MEMORY_READ_BIT: int + VK_ACCESS_MEMORY_WRITE_BIT: int + VK_ACCESS_TRANSFER_READ_BIT: int + VK_ACCESS_TRANSFER_WRITE_BIT: int + VK_BUFFER_USAGE_TRANSFER_DST_BIT: int + VK_BUFFER_USAGE_TRANSFER_SRC_BIT: int + VK_COMMAND_BUFFER_LEVEL_PRIMARY: int + VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT: int + VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT: int + VK_IMAGE_ASPECT_COLOR_BIT: int + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL: int + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL: int + VK_IMAGE_LAYOUT_UNDEFINED: int + VK_IMAGE_TILING_OPTIMAL: int + VK_IMAGE_TYPE_2D: int + VK_IMAGE_USAGE_TRANSFER_DST_BIT: int + VK_IMAGE_USAGE_TRANSFER_SRC_BIT: int + VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR: int + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT: int + VK_MEMORY_PROPERTY_HOST_CACHED_BIT: int + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT: int + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT: int + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT: int + VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT: int + VK_PIPELINE_STAGE_TRANSFER_BIT: int + VK_QUEUE_COMPUTE_BIT: int + VK_QUEUE_GRAPHICS_BIT: int + VK_SAMPLE_COUNT_1_BIT: int + VK_SHARING_MODE_EXCLUSIVE: int + + VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME: str + VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME: str + VK_KHR_PORTABILITY_SUBSET_EXTENSION_NAME: str + + # Struct constructors; their keyword arguments mirror the C fields + VkApplicationInfo: Callable[..., CData] + VkBufferCreateInfo: Callable[..., CData] + VkBufferImageCopy: Callable[..., CData] + VkCommandBufferAllocateInfo: Callable[..., CData] + VkCommandBufferBeginInfo: Callable[..., CData] + VkCommandPoolCreateInfo: Callable[..., CData] + VkDeviceCreateInfo: Callable[..., CData] + VkDeviceQueueCreateInfo: Callable[..., CData] + VkExtent3D: Callable[..., CData] + VkFenceCreateInfo: Callable[..., CData] + VkHeadlessSurfaceCreateInfoEXT: Callable[..., CData] + VkImageCreateInfo: Callable[..., CData] + VkImageMemoryBarrier: Callable[..., CData] + VkImageSubresourceLayers: Callable[..., CData] + VkImageSubresourceRange: Callable[..., CData] + VkInstanceCreateInfo: Callable[..., CData] + VkMemoryAllocateInfo: Callable[..., CData] + VkOffset3D: Callable[..., CData] + VkSubmitInfo: Callable[..., CData] + + def vkCreateInstance(self, create_info: Any, allocator: None, /) -> CData: ... + def vkDestroyInstance(self, instance: CData, allocator: None, /) -> None: ... + def vkGetInstanceProcAddr( + self, instance: CData | None, name: str, / + ) -> Callable[..., Any] | None: ... + def vkEnumeratePhysicalDevices(self, instance: CData, /) -> Sequence[CData]: ... + def vkEnumerateInstanceExtensionProperties( + self, layer_name: str | None, / + ) -> Sequence[Any]: ... + def vkEnumerateDeviceExtensionProperties( + self, gpu: CData, layer_name: str | None, / + ) -> Sequence[Any]: ... + def vkGetPhysicalDeviceQueueFamilyProperties(self, gpu: CData, /) -> Sequence[Any]: ... + def vkGetPhysicalDeviceFeatures(self, gpu: CData, /) -> CData: ... + def vkGetPhysicalDeviceMemoryProperties(self, gpu: CData, /) -> Any: ... + def vkCreateDevice(self, gpu: CData, create_info: Any, allocator: None, /) -> CData: ... + def vkDestroyDevice(self, device: CData, allocator: None, /) -> None: ... + def vkDeviceWaitIdle(self, device: CData, /) -> None: ... + def vkGetDeviceQueue(self, device: CData, family: int, index: int, /) -> CData: ... + def vkQueueSubmit( + self, queue: CData, count: int, submits: Sequence[CData], fence: CData, / + ) -> None: ... + def vkQueueWaitIdle(self, queue: CData, /) -> None: ... + def vkCreateCommandPool( + self, device: CData, create_info: Any, allocator: None, / + ) -> CData: ... + def vkDestroyCommandPool(self, device: CData, pool: CData, allocator: None, /) -> None: ... + def vkAllocateCommandBuffers(self, device: CData, info: Any, /) -> Sequence[CData]: ... + def vkResetCommandBuffer(self, cmd: CData, flags: int, /) -> None: ... + def vkBeginCommandBuffer(self, cmd: CData, info: Any, /) -> None: ... + def vkEndCommandBuffer(self, cmd: CData, /) -> None: ... + def vkCreateFence(self, device: CData, info: Any, allocator: None, /) -> CData: ... + def vkDestroyFence(self, device: CData, fence: CData, allocator: None, /) -> None: ... + def vkWaitForFences( + self, + device: CData, + count: int, + fences: Sequence[CData], + wait_all: int, + timeout: int, + /, + ) -> None: ... + def vkResetFences(self, device: CData, count: int, fences: Sequence[CData], /) -> None: ... + def vkCreateBuffer(self, device: CData, info: Any, allocator: None, /) -> CData: ... + def vkDestroyBuffer(self, device: CData, buffer: CData, allocator: None, /) -> None: ... + def vkCreateImage(self, device: CData, info: Any, allocator: None, /) -> CData: ... + def vkDestroyImage(self, device: CData, image: CData, allocator: None, /) -> None: ... + def vkGetBufferMemoryRequirements(self, device: CData, buffer: CData, /) -> Any: ... + def vkGetImageMemoryRequirements(self, device: CData, image: CData, /) -> Any: ... + def vkAllocateMemory(self, device: CData, info: Any, allocator: None, /) -> CData: ... + def vkFreeMemory(self, device: CData, memory: CData, allocator: None, /) -> None: ... + def vkBindBufferMemory( + self, device: CData, buffer: CData, memory: CData, offset: int, / + ) -> None: ... + def vkBindImageMemory( + self, device: CData, image: CData, memory: CData, offset: int, / + ) -> None: ... + def vkMapMemory( + self, device: CData, memory: CData, offset: int, size: int, flags: int, / + ) -> CBuffer: ... + def vkUnmapMemory(self, device: CData, memory: CData, /) -> None: ... + def vkCmdPipelineBarrier( + self, + cmd: CData, + src_stage_mask: int, + dst_stage_mask: int, + dependency_flags: int, + memory_barrier_count: int, + memory_barriers: Sequence[CData] | None, + buffer_barrier_count: int, + buffer_barriers: Sequence[CData] | None, + image_barrier_count: int, + image_barriers: Sequence[CData] | None, + /, + ) -> None: ... + def vkCmdCopyBufferToImage( + self, + cmd: CData, + buffer: CData, + image: CData, + layout: int, + count: int, + regions: Sequence[CData], + /, + ) -> None: ... + def vkCmdCopyImageToBuffer( + self, + cmd: CData, + image: CData, + layout: int, + buffer: CData, + count: int, + regions: Sequence[CData], + /, + ) -> None: ... + + vk: _VulkanModule + ffi: FFI +else: + import vulkan as vk + from vulkan import ffi + + # Placeholders so the names above are importable at runtime; + # they only carry meaning for static analysis + CData = object + CBuffer = object + +__all__ = ["CBuffer", "CData", "ffi", "vk"] diff --git a/src/libretro/drivers/video/vulkan/driver.py b/src/libretro/drivers/video/vulkan/driver.py index 20ff081..d44e8e1 100644 --- a/src/libretro/drivers/video/vulkan/driver.py +++ b/src/libretro/drivers/video/vulkan/driver.py @@ -10,42 +10,37 @@ mirroring the offscreen default of :class:`.ModernGlVideoDriver`. """ -# The vulkan package is untyped CFFI, so the "unknown type" family of checks -# reports every use of it; relax those (and only those) for this module. -# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false -# pyright: reportUnknownVariableType=false, reportUnknownParameterType=false -# pyright: reportMissingParameterType=false, reportMissingTypeStubs=false - from __future__ import annotations import ctypes import threading -from collections.abc import Set +from collections.abc import Callable, Set from contextlib import suppress from copy import deepcopy from ctypes import ( - CFUNCTYPE, POINTER, Structure, byref, c_char_p, c_int, c_uint32, + c_uint64, c_void_p, ) +from dataclasses import dataclass from typing import Any, final, override from warnings import warn -import vulkan as vk -from vulkan import ffi - from libretro.api.av import retro_game_geometry, retro_system_av_info from libretro.api.proc import retro_proc_address_t from libretro.api.video import ( + RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION, RETRO_HW_RENDER_INTERFACE_VULKAN_VERSION, + ContextNegotiationInterfaceType, HardwareContext, HardwareRenderInterfaceType, MemoryAccess, + MemoryType, PixelFormat, Rotation, VkApplicationInfo, @@ -69,9 +64,10 @@ retro_vulkan_unlock_queue_t, retro_vulkan_wait_sync_index_t, ) +from libretro.ctypes import CStringArg, TypedFunctionPointer, TypedPointer, c_void_ptr from ..driver import FrameBufferSpecial, Screenshot, UnsupportedContextError, VideoDriver -from ..software import ArrayVideoDriver +from ._typing import CBuffer, CData, ffi, vk _CONTEXTS = frozenset((HardwareContext.NONE, HardwareContext.VULKAN)) @@ -114,7 +110,7 @@ _VK_KHR_SURFACE = "VK_KHR_surface" _VK_EXT_HEADLESS_SURFACE = "VK_EXT_headless_surface" -_PFN_GetInstanceProcAddr = CFUNCTYPE(c_void_p, c_void_p, c_char_p) +_PFN_GetInstanceProcAddr = TypedFunctionPointer[c_void_ptr, [c_void_ptr, CStringArg]] def _wanted_instance_extensions(available: set[str]) -> tuple[list[str], int]: @@ -139,8 +135,18 @@ def _wanted_instance_extensions(available: set[str]) -> tuple[list[str], int]: return extensions, flags +@dataclass(init=False) class _VkInstanceCreateInfo(Structure): # Only used to read the create info a core passes to the create_instance wrapper + sType: int + pNext: int | None + flags: int + pApplicationInfo: TypedPointer[VkApplicationInfo] + enabledLayerCount: int + ppEnabledLayerNames: TypedPointer[c_char_p] + enabledExtensionCount: int + ppEnabledExtensionNames: TypedPointer[c_char_p] + _fields_ = ( ("sType", c_int), ("pNext", c_void_p), @@ -153,8 +159,20 @@ class _VkInstanceCreateInfo(Structure): ) +@dataclass(init=False) class _VkDeviceCreateInfo(Structure): # Only used to read the create info a core passes to the create_device wrapper + sType: int + pNext: int | None + flags: int + queueCreateInfoCount: int + pQueueCreateInfos: int | None + enabledLayerCount: int + ppEnabledLayerNames: TypedPointer[c_char_p] + enabledExtensionCount: int + ppEnabledExtensionNames: TypedPointer[c_char_p] + pEnabledFeatures: TypedPointer[VkPhysicalDeviceFeatures] + _fields_ = ( ("sType", c_int), ("pNext", c_void_p), @@ -169,7 +187,7 @@ class _VkDeviceCreateInfo(Structure): ) -def _raw(handle) -> int: +def _raw(handle: CData | None) -> int: """Return the integer value of a CFFI Vulkan handle (0 for None).""" if handle is None: return 0 @@ -177,6 +195,17 @@ def _raw(handle) -> int: return int(ffi.cast("uintptr_t", handle)) +def _addr(handle: c_void_p | int | None) -> int: + """Return the address held by a ctypes pointer object or integer (0 for None).""" + match handle: + case None: + return 0 + case int(): + return handle + case _: + return handle.value or 0 + + # Interface structs whose Python callbacks have been replaced with native stubs; # kept alive forever because cores may hold the pointer in exit-time destructors _RETIRED_INTERFACES: list[retro_hw_render_interface_vulkan] = [] @@ -237,7 +266,7 @@ def expand5(v: int) -> int: def expand6(v: int) -> int: return (v << 2) | (v >> 4) - entries = [] + entries: list[bytes] = [] for texel in range(0x10000): match vk_format: case _ if vk_format == _VK_FORMAT_R5G6B5_UNORM_PACK16: @@ -346,11 +375,21 @@ class VulkanVideoDriver(VideoDriver): and pass it to the driver with ``set_image``; the driver copies the visible region into host memory each frame, which backs :meth:`~.VulkanVideoDriver.screenshot`. - Software-rendered frames are supported as well, - with the same semantics as :class:`.ArrayVideoDriver`. + Software-rendered frames also go through Vulkan: + they're uploaded to a :c:type:`VkImage` + and read back through the same capture path. + If Vulkan can't be initialized, this driver fails + rather than falling back to CPU rendering; + wrap it in another :class:`.VideoDriver` if you need fallback behavior. """ - def __init__(self, *, sync_indices: int = 2, gpu_index: int = 0): + def __init__( + self, + *, + sync_indices: int = 2, + gpu_index: int = 0, + negotiation_version: int = RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION, + ): """ Initialize the driver without creating any Vulkan objects; those are created in :meth:`~.VulkanVideoDriver.reinit`. @@ -359,9 +398,13 @@ def __init__(self, *, sync_indices: int = 2, gpu_index: int = 0): reported through ``get_sync_index_mask``. :param gpu_index: The index of the physical device to use, in the order reported by :c:func:`vkEnumeratePhysicalDevices`. + :param negotiation_version: The version of the Vulkan context negotiation + interface that this driver exposes and honors. + Lower it to test a core against version 1 frontends. :raises ValueError: If ``sync_indices`` is not between 1 and 32, - or if ``gpu_index`` is negative. + if ``gpu_index`` is negative, + or if ``negotiation_version`` is not a supported version. """ if not (1 <= sync_indices <= 32): raise ValueError(f"Expected 1 <= sync_indices <= 32, got {sync_indices}") @@ -369,10 +412,24 @@ def __init__(self, *, sync_indices: int = 2, gpu_index: int = 0): if gpu_index < 0: raise ValueError(f"Expected a non-negative gpu_index, got {gpu_index}") + if not ( + 1 + <= negotiation_version + <= RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION + ): + raise ValueError( + "Expected 1 <= negotiation_version <= " + f"{RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION}, " + f"got {negotiation_version}" + ) + self._sync_index_count = sync_indices self._gpu_index = gpu_index + self._negotiation_version = negotiation_version - self._software = ArrayVideoDriver() + self._pixel_format = PixelFormat.RGB1555 + self._rotation = Rotation.NONE + self._system_av_info: retro_system_av_info | None = None self._callback = retro_hw_render_callback(context_type=HardwareContext.NONE) self._pending_context_destroy: retro_hw_context_reset_t | None = None self._active = HardwareContext.NONE @@ -383,26 +440,27 @@ def __init__(self, *, sync_indices: int = 2, gpu_index: int = 0): self._loader: ctypes.CDLL | None = None # CFFI handles for the live context (None when no Vulkan context is active) - self._instance = None - self._gpu = None - self._device = None - self._queue = None + self._instance: CData | None = None + self._gpu: CData | None = None + self._device: CData | None = None + self._queue: CData | None = None self._queue_family = 0 self._surface_raw = 0 self._core_created_device = False self._negotiation_used = False # Frontend capture resources - self._sw_image = None - self._sw_image_memory = None + self._sw_image: CData | None = None + self._sw_image_memory: CData | None = None self._sw_image_key: tuple[int, int, int] | None = None - self._command_pool = None - self._command_buffer = None - self._fence = None - self._staging_buffer = None - self._staging_memory = None - self._staging_map = None + self._command_pool: CData | None = None + self._command_buffer: CData | None = None + self._fence: CData | None = None + self._staging_buffer: CData | None = None + self._staging_memory: CData | None = None + self._staging_map: CBuffer | None = None self._staging_dims: tuple[int, int] | None = None + self._staging_cached = False # Per-frame state provided by the core through the render interface self._sync_index = 0 @@ -412,9 +470,8 @@ def __init__(self, *, sync_indices: int = 2, gpu_index: int = 0): self._core_command_buffers: list[int] = [] self._signal_semaphore: int = 0 - # The most recent captured hardware frame: (pixels, width, height, vk_format) + # The most recent captured frame: (pixels, width, height, vk_format) self._hw_frame: tuple[bytearray, int, int, int] | None = None - self._last_frame_hw = False self._warned_no_image = False self._warned_format: int | None = None @@ -436,19 +493,13 @@ def refresh( self.__refresh_hardware(width, height) case FrameBufferSpecial.DUPE: - if self._last_frame_hw: + # Keep the previous frame; a hardware context still needs + # its per-frame semaphore and sync index bookkeeping + if self._interface is not None: self.__finish_frame() - else: - self._software.refresh(data, width, height, pitch) case memoryview(): - if self._device is not None and self.__refresh_software_vulkan( - data, width, height, pitch - ): - self._last_frame_hw = True - else: - self._software.refresh(data, width, height, pitch) - self._last_frame_hw = False + self.__refresh_software(data, width, height, pitch) case _: raise TypeError( @@ -462,7 +513,7 @@ def needs_reinit(self) -> bool: @override def reinit(self) -> None: - if self._software.system_av_info is None: + if self._system_av_info is None: raise RuntimeError("Cannot reinitialize video driver without system AV info from core") if self._interface is not None: @@ -479,14 +530,12 @@ def reinit(self) -> None: if self._callback.context_reset: self._callback.context_reset() else: - try: - # Like the OpenGL driver, software-rendered frames still go - # through the graphics API: they're uploaded to a VkImage - # and read back through the same capture path - self.__init_vulkan(hardware=False) - except Exception as e: - warn(f"Couldn't initialize Vulkan for software rendering, using CPU frames: {e}") - + # Like the OpenGL driver, software-rendered frames still go + # through the graphics API: they're uploaded to a VkImage + # and read back through the same capture path. + # If Vulkan can't be initialized, the error propagates to the core; + # this driver exists to test Vulkan and never falls back to CPU frames. + self.__init_vulkan(hardware=False) self._needs_reinit = False @property @@ -536,12 +585,18 @@ def get_proc_address(self, sym: bytes) -> retro_proc_address_t | None: @property @override def rotation(self) -> Rotation: - return self._software.rotation + return self._rotation @rotation.setter @override def rotation(self, rotation: Rotation) -> None: - self._software.rotation = rotation + if not isinstance(rotation, Rotation): + raise TypeError(f"Expected a Rotation, got {type(rotation).__name__}") + + if rotation not in Rotation: + raise ValueError(f"Invalid rotation: {rotation}") + + self._rotation = rotation @property @override @@ -551,39 +606,90 @@ def can_dupe(self) -> bool | None: @property @override def pixel_format(self) -> PixelFormat: - return self._software.pixel_format + return self._pixel_format @pixel_format.setter @override def pixel_format(self, format: PixelFormat) -> None: - self._software.pixel_format = format + if not isinstance(format, PixelFormat): + raise TypeError(f"Expected a PixelFormat, got {type(format).__name__}") + + if format not in PixelFormat: + raise ValueError(f"Invalid pixel format: {format}") + + self._pixel_format = format @property @override def system_av_info(self) -> retro_system_av_info | None: - return self._software.system_av_info + return deepcopy(self._system_av_info) if self._system_av_info else None @system_av_info.setter @override def system_av_info(self, av_info: retro_system_av_info) -> None: - self._software.system_av_info = av_info + if not isinstance(av_info, retro_system_av_info): + raise TypeError(f"Expected a retro_system_av_info, got {type(av_info).__name__}") + + self._system_av_info = deepcopy(av_info) self.reinit() @property @override def geometry(self) -> retro_game_geometry | None: - return self._software.geometry + if not self._system_av_info: + return None + + geometry = deepcopy(self._system_av_info.geometry) + if self._hw_frame is not None: + # Report the size of the Vulkan buffer that holds the latest frame; + # hardware-rendered cores may render at a different resolution + # than their declared base dimensions + _, width, height, _ = self._hw_frame + geometry.base_width = width + geometry.base_height = height + + return geometry @geometry.setter @override def geometry(self, geometry: retro_game_geometry) -> None: - self._software.geometry = geometry + if not isinstance(geometry, retro_game_geometry): + raise TypeError(f"Expected a retro_game_geometry, got {type(geometry).__name__}") + + if not self._system_av_info: + raise RuntimeError("Cannot set geometry without system AV info from core") + + self._system_av_info.geometry.base_width = geometry.base_width + self._system_av_info.geometry.base_height = geometry.base_height + self._system_av_info.geometry.aspect_ratio = geometry.aspect_ratio @override def get_software_framebuffer( self, width: int, height: int, flags: MemoryAccess ) -> retro_framebuffer | None: - return self._software.get_software_framebuffer(width, height, flags) + if width < 1 or height < 1: + raise ValueError(f"Expected a framebuffer of at least 1x1, got {width}x{height}") + + if self._device is None: + return None + + vk_format = _PIXEL_FORMAT_TO_VK.get(self._pixel_format) + if vk_format is None: + return None + + # The staging buffer is host-visible Vulkan memory kept persistently + # mapped with vkMapMemory, so the core can render directly into it + self.__ensure_staging_buffer(width, height) + assert self._staging_map is not None + return retro_framebuffer( + data=int(ffi.cast("uintptr_t", ffi.from_buffer(self._staging_map))), + width=width, + height=height, + pitch=width * _CAPTURABLE_FORMATS[vk_format], + format=self._pixel_format, + access_flags=flags, + memory_flags=MemoryType.CACHED if self._staging_cached else MemoryType.NONE, + ) @override def destroy_hw_context(self) -> None: @@ -638,6 +744,15 @@ def context_negotiation_interface( assert isinstance(interface, retro_hw_render_context_negotiation_interface_vulkan) self._negotiation = interface + @override + def context_negotiation_version( + self, interface_type: ContextNegotiationInterfaceType + ) -> int | None: + if interface_type != ContextNegotiationInterfaceType.VULKAN: + return None + + return self._negotiation_version + @property @override def shared_context(self) -> bool: @@ -655,9 +770,6 @@ def sync_indices(self) -> int: @override def screenshot(self, prerotate: bool = True) -> Screenshot | None: - if not self._last_frame_hw: - return self._software.screenshot(prerotate) - if self._hw_frame is None: return None @@ -665,18 +777,23 @@ def screenshot(self, prerotate: bool = True) -> Screenshot | None: rgba = _to_rgba32(pixels, vk_format) rgba[3::4] = b"\xff" * (width * height) # Screenshots are opaque - rotation = self._software.rotation - rot = rotation if prerotate else Rotation.NONE + rot = self._rotation if prerotate else Rotation.NONE rgba, out_width, out_height = _rotate_rgba32(rgba, width, height, rot) return Screenshot( memoryview(rgba), out_width, out_height, - rotation, - self._software.pixel_format, + self._rotation, + self._pixel_format, ) + def __negotiation_interface_version( + self, negotiation: retro_hw_render_context_negotiation_interface_vulkan + ) -> int: + """Return the negotiation version in effect: the lower of the core's and this driver's.""" + return min(int(negotiation.interface_version), self._negotiation_version) + def __init_vulkan(self, *, hardware: bool) -> None: self._loader = _load_loader() gipa_addr = ctypes.cast(self._loader.vkGetInstanceProcAddr, c_void_p).value @@ -700,7 +817,9 @@ def __create_surface(self) -> None: """ self._surface_raw = 0 try: - create: Any = vk.vkGetInstanceProcAddr(self._instance, "vkCreateHeadlessSurfaceEXT") + create: Callable[..., Any] | None = vk.vkGetInstanceProcAddr( + self._instance, "vkCreateHeadlessSurfaceEXT" + ) except Exception: return @@ -721,6 +840,7 @@ def __destroy_surface(self) -> None: try: destroy = vk.vkGetInstanceProcAddr(self._instance, "vkDestroySurfaceKHR") + assert destroy is not None destroy(self._instance, ffi.cast("VkSurfaceKHR", self._surface_raw), None) except Exception as e: warn(f"Couldn't destroy the headless surface: {e}") @@ -745,7 +865,7 @@ def __create_instance(self, gipa_addr: int) -> None: if negotiation is not None and negotiation.get_application_info: app_info_ptr = negotiation.get_application_info() if app_info_ptr: - app_info = app_info_ptr[0] + app_info = ctypes.cast(app_info_ptr, POINTER(VkApplicationInfo))[0] app_name = app_info.pApplicationName or app_name engine_name = app_info.pEngineName or engine_name api_version = max(api_version, app_info.apiVersion) @@ -757,7 +877,7 @@ def __create_instance(self, gipa_addr: int) -> None: if ( negotiation is not None - and negotiation.interface_version >= 2 + and self.__negotiation_interface_version(negotiation) >= 2 and negotiation.create_instance ): instance_raw = self.__negotiate_instance(negotiation, gipa_addr, api_version) @@ -794,7 +914,9 @@ def __negotiate_instance( str(ext.extensionName) for ext in vk.vkEnumerateInstanceExtensionProperties(None) } - def _create_instance_wrapper(_opaque: int | None, create_info_ptr: int | None) -> int: + def _create_instance_wrapper( + _opaque: c_void_ptr | None, create_info_ptr: c_void_ptr | None + ) -> int: try: if not create_info_ptr: return 0 @@ -837,9 +959,17 @@ def _create_instance_wrapper(_opaque: int | None, create_info_ptr: int | None) - pEngineName=b"libretro.py", apiVersion=api_version, ) - return negotiation.create_instance(gipa_addr, byref(app_info_ctypes), wrapper, None) or 0 + assert negotiation.create_instance is not None + instance = negotiation.create_instance( + c_void_ptr(gipa_addr), + ctypes.cast(ctypes.pointer(app_info_ctypes), TypedPointer[VkApplicationInfo]), + wrapper, + c_void_ptr(), + ) + return _addr(instance) def __select_gpu(self) -> None: + assert self._instance is not None gpus = vk.vkEnumeratePhysicalDevices(self._instance) if not gpus: raise RuntimeError("No Vulkan physical devices found") @@ -858,7 +988,10 @@ def __create_device(self, gipa_addr: int) -> None: self._core_created_device = False if negotiation is not None: - if negotiation.interface_version >= 2 and negotiation.create_device2: + if ( + self.__negotiation_interface_version(negotiation) >= 2 + and negotiation.create_device2 + ): if self.__negotiate_device2(negotiation, gipa_addr): return warn("The core's create_device2 failed; falling back") @@ -874,11 +1007,11 @@ def __adopt_context(self, context: retro_vulkan_context) -> bool: if not context.device or not context.queue: return False - self._device = ffi.cast("VkDevice", context.device) - self._queue = ffi.cast("VkQueue", context.queue) + self._device = ffi.cast("VkDevice", _addr(context.device)) + self._queue = ffi.cast("VkQueue", _addr(context.queue)) self._queue_family = context.queue_family_index if context.gpu: - self._gpu = ffi.cast("VkPhysicalDevice", context.gpu) + self._gpu = ffi.cast("VkPhysicalDevice", _addr(context.gpu)) self._core_created_device = True return True @@ -892,17 +1025,18 @@ def __negotiate_device( context = retro_vulkan_context() features = VkPhysicalDeviceFeatures() # The frontend itself requires no features + assert negotiation.create_device is not None ok = negotiation.create_device( - byref(context), - _raw(self._instance), - _raw(self._gpu), + ctypes.cast(ctypes.pointer(context), TypedPointer[retro_vulkan_context]), + c_void_ptr(_raw(self._instance)), + c_void_ptr(_raw(self._gpu)), self._surface_raw, # A headless surface (or 0 if unavailable) - gipa_addr, - None, + c_void_ptr(gipa_addr), + TypedPointer[c_char_p](), 0, - None, + TypedPointer[c_char_p](), 0, - byref(features), + ctypes.cast(ctypes.pointer(features), TypedPointer[VkPhysicalDeviceFeatures]), ) if not ok: return False @@ -916,12 +1050,15 @@ def __negotiate_device2( ) -> bool: self._negotiation_used = True + assert self._gpu is not None device_extensions = { ext.extensionName for ext in vk.vkEnumerateDeviceExtensionProperties(self._gpu, None) } def _create_device_wrapper( - gpu_raw: int | None, _opaque: int | None, create_info_ptr: int | None + gpu_raw: c_void_ptr | None, + _opaque: c_void_ptr | None, + create_info_ptr: c_void_ptr | None, ) -> int: try: if not create_info_ptr: @@ -957,7 +1094,7 @@ def _create_device_wrapper( ctypes.addressof(info.pEnabledFeatures[0]) if info.pEnabledFeatures else 0, ), ) - gpu = ffi.cast("VkPhysicalDevice", gpu_raw or 0) + gpu = ffi.cast("VkPhysicalDevice", _addr(gpu_raw)) return _raw(vk.vkCreateDevice(gpu, create_info, None)) except Exception as e: warn(f"vkCreateDevice failed in the create_device wrapper: {e}") @@ -965,27 +1102,28 @@ def _create_device_wrapper( wrapper = retro_vulkan_create_device_wrapper_t(_create_device_wrapper) + assert negotiation.create_device2 is not None context = retro_vulkan_context() ok = negotiation.create_device2( - byref(context), - _raw(self._instance), - _raw(self._gpu), + ctypes.cast(ctypes.pointer(context), TypedPointer[retro_vulkan_context]), + c_void_ptr(_raw(self._instance)), + c_void_ptr(_raw(self._gpu)), self._surface_raw, # A headless surface (or 0 if unavailable) - gipa_addr, + c_void_ptr(gipa_addr), wrapper, - None, + c_void_ptr(), ) if not ok: # Retry allowing the core to pick the physical device itself context = retro_vulkan_context() ok = negotiation.create_device2( - byref(context), - _raw(self._instance), - 0, + ctypes.cast(ctypes.pointer(context), TypedPointer[retro_vulkan_context]), + c_void_ptr(_raw(self._instance)), + c_void_ptr(), self._surface_raw, - gipa_addr, + c_void_ptr(gipa_addr), wrapper, - None, + c_void_ptr(), ) if not ok: @@ -994,6 +1132,7 @@ def _create_device_wrapper( return self.__adopt_context(context) def __create_device_default(self) -> None: + assert self._gpu is not None families = vk.vkGetPhysicalDeviceQueueFamilyProperties(self._gpu) wanted = vk.VK_QUEUE_GRAPHICS_BIT | vk.VK_QUEUE_COMPUTE_BIT try: @@ -1023,6 +1162,7 @@ def __create_device_default(self) -> None: self._queue_family = family def __create_capture_resources(self) -> None: + assert self._device is not None self._command_pool = vk.vkCreateCommandPool( self._device, vk.VkCommandPoolCreateInfo( @@ -1046,6 +1186,8 @@ def __ensure_staging_buffer(self, width: int, height: int) -> None: return self.__destroy_staging_buffer() + assert self._device is not None + assert self._gpu is not None size = width * height * 4 self._staging_buffer = vk.vkCreateBuffer( @@ -1078,6 +1220,9 @@ def __ensure_staging_buffer(self, width: int, height: int) -> None: vk.vkBindBufferMemory(self._device, self._staging_buffer, self._staging_memory, 0) self._staging_map = vk.vkMapMemory(self._device, self._staging_memory, 0, size, 0) self._staging_dims = (width, height) + self._staging_cached = bool( + mem_props.memoryTypes[type_index].propertyFlags & vk.VK_MEMORY_PROPERTY_HOST_CACHED_BIT + ) def __refresh_hardware(self, width: int, height: int) -> None: if self._interface is None: @@ -1109,7 +1254,6 @@ def __refresh_hardware(self, width: int, height: int) -> None: # frame's (possibly smaller) texel size actually filled pixels = bytearray(self._staging_map[: width * height * _CAPTURABLE_FORMATS[vk_format]]) self._hw_frame = (pixels, width, height, vk_format) - self._last_frame_hw = True self.__consume_frame_state() def __ensure_software_image(self, width: int, height: int, vk_format: int) -> None: @@ -1117,6 +1261,8 @@ def __ensure_software_image(self, width: int, height: int, vk_format: int) -> No return self.__destroy_software_image() + assert self._device is not None + assert self._gpu is not None image_info = vk.VkImageCreateInfo( imageType=vk.VK_IMAGE_TYPE_2D, @@ -1165,20 +1311,17 @@ def __destroy_software_image(self) -> None: self._sw_image_key = None - def __refresh_software_vulkan( - self, data: memoryview, width: int, height: int, pitch: int - ) -> bool: + def __refresh_software(self, data: memoryview, width: int, height: int, pitch: int) -> None: """ Upload a software-rendered frame to a :c:type:`VkImage` and read it back through the capture path, like the OpenGL driver's texture upload. - - :return: :obj:`True` if the frame went through Vulkan, - :obj:`False` if the pixel format has no direct Vulkan equivalent - (the caller falls back to CPU frames). """ - vk_format = _PIXEL_FORMAT_TO_VK.get(self._software.pixel_format) + if self._device is None: + raise RuntimeError("Vulkan is not initialized; can't upload a software-rendered frame") + + vk_format = _PIXEL_FORMAT_TO_VK.get(self._pixel_format) if vk_format is None: - return False + raise RuntimeError(f"{self._pixel_format} has no Vulkan format equivalent") texel_size = _CAPTURABLE_FORMATS[vk_format] row_bytes = width * texel_size @@ -1198,9 +1341,11 @@ def __refresh_software_vulkan( pixels = bytearray(self._staging_map[: height * row_bytes]) self._hw_frame = (pixels, width, height, vk_format) - return True def __record_software_frame(self, width: int, height: int) -> None: + assert self._command_buffer is not None + assert self._staging_buffer is not None + assert self._sw_image is not None subresource = vk.VkImageSubresourceRange(vk.VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1) layers = vk.VkImageSubresourceLayers( aspectMask=vk.VK_IMAGE_ASPECT_COLOR_BIT, mipLevel=0, baseArrayLayer=0, layerCount=1 @@ -1288,6 +1433,8 @@ def __record_software_frame(self, width: int, height: int) -> None: def __record_capture( self, image_raw: int, layout: int, base_mip: int, base_layer: int, width: int, height: int ) -> None: + assert self._command_buffer is not None + assert self._staging_buffer is not None image = ffi.cast("VkImage", image_raw) subresource = vk.VkImageSubresourceRange( vk.VK_IMAGE_ASPECT_COLOR_BIT, base_mip, 1, base_layer, 1 @@ -1384,6 +1531,9 @@ def __submit_capture(self) -> None: # Semaphores from set_image are ignored when the core used set_command_buffers wait_semaphores = self._hw_semaphores if not self._core_command_buffers else [] assert self._command_buffer is not None + assert self._device is not None + assert self._queue is not None + assert self._fence is not None command_buffers = [ffi.cast("VkCommandBuffer", raw) for raw in self._core_command_buffers] command_buffers.append(self._command_buffer) signal_semaphores = ( @@ -1408,6 +1558,8 @@ def __submit_capture(self) -> None: def __finish_frame(self) -> None: """Handle per-frame bookkeeping for frames that don't capture anything.""" if self._signal_semaphore and self._device is not None: + assert self._queue is not None + assert self._fence is not None # The signal semaphore must be signalled even for duped or skipped frames submit = vk.VkSubmitInfo( signalSemaphoreCount=1, @@ -1429,11 +1581,19 @@ def __consume_frame_state(self) -> None: def __build_interface(self, gipa_addr: int) -> None: get_instance_proc_addr = _PFN_GetInstanceProcAddr(gipa_addr) - gdpa_addr = get_instance_proc_addr(_raw(self._instance), b"vkGetDeviceProcAddr") + gdpa_addr = get_instance_proc_addr( + c_void_ptr(_raw(self._instance)), b"vkGetDeviceProcAddr" + ) if not gdpa_addr: raise RuntimeError("vkGetInstanceProcAddr couldn't resolve vkGetDeviceProcAddr") - def _set_image(_handle, image_ptr, num_semaphores, semaphores, src_queue_family) -> None: + def _set_image( + _handle: c_void_ptr | None, + image_ptr: TypedPointer[retro_vulkan_image] | None, + num_semaphores: int, + semaphores: TypedPointer[c_uint64] | None, + src_queue_family: int, + ) -> None: if not image_ptr: self._hw_image = None return @@ -1454,31 +1614,33 @@ def _set_image(_handle, image_ptr, num_semaphores, semaphores, src_queue_family) self._hw_src_queue_family = src_queue_family - def _get_sync_index(_handle) -> int: + def _get_sync_index(_handle: c_void_ptr | None) -> int: return self._sync_index - def _get_sync_index_mask(_handle) -> int: + def _get_sync_index_mask(_handle: c_void_ptr | None) -> int: return (1 << self._sync_index_count) - 1 - def _set_command_buffers(_handle, num_cmd, cmd) -> None: + def _set_command_buffers(_handle: c_void_ptr | None, num_cmd: int, cmd: Any) -> None: + # cmd is a POINTER(VkCommandBuffer): an array of void pointers if num_cmd and cmd: - self._core_command_buffers = [cmd[i] for i in range(num_cmd)] + self._core_command_buffers = [_addr(cmd[i]) for i in range(num_cmd)] else: self._core_command_buffers = [] - def _wait_sync_index(_handle) -> None: + def _wait_sync_index(_handle: c_void_ptr | None) -> None: # This driver submits synchronously (each frame waits on a fence), # so waiting on the queue covers everything for the current sync index. with self._queue_lock: + assert self._queue is not None vk.vkQueueWaitIdle(self._queue) - def _lock_queue(_handle) -> None: + def _lock_queue(_handle: c_void_ptr | None) -> None: self._queue_lock.acquire() - def _unlock_queue(_handle) -> None: + def _unlock_queue(_handle: c_void_ptr | None) -> None: self._queue_lock.release() - def _set_signal_semaphore(_handle, semaphore) -> None: + def _set_signal_semaphore(_handle: c_void_ptr | None, semaphore: int) -> None: self._signal_semaphore = semaphore refs = ( @@ -1518,6 +1680,7 @@ def __destroy_staging_buffer(self) -> None: return if self._staging_map is not None: + assert self._staging_memory is not None vk.vkUnmapMemory(self._device, self._staging_memory) self._staging_map = None @@ -1530,6 +1693,7 @@ def __destroy_staging_buffer(self) -> None: self._staging_memory = None self._staging_dims = None + self._staging_cached = False def __destroy_vulkan(self) -> None: if self._device is not None: @@ -1582,7 +1746,6 @@ def __destroy_vulkan(self) -> None: self._core_command_buffers = [] self._signal_semaphore = 0 self._sync_index = 0 - self._last_frame_hw = False self._core_created_device = False diff --git a/tests/integration/test_vulkan_driver.py b/tests/integration/test_vulkan_driver.py index 409b0f1..dd76f8f 100644 --- a/tests/integration/test_vulkan_driver.py +++ b/tests/integration/test_vulkan_driver.py @@ -32,7 +32,7 @@ def test_software_core_through_vulkan_driver(load_core: SampleCoreLoader) -> Non session.run() # The frame must have gone through the Vulkan upload path - assert driver._last_frame_hw # noqa: SLF001 + assert driver._hw_frame is not None # noqa: SLF001 shot = session.video.screenshot() assert shot is not None diff --git a/tests/unit/api/test_video_vulkan.py b/tests/unit/api/test_video_vulkan.py index b4e6cc7..610bf37 100644 --- a/tests/unit/api/test_video_vulkan.py +++ b/tests/unit/api/test_video_vulkan.py @@ -1,8 +1,10 @@ # ctypes Structure field descriptors expose .offset at runtime, # which pyright can't see through the dataclass-style annotations. # pyright: reportUnknownMemberType=false, reportAttributeAccessIssue=false +# pyright: reportOptionalMemberAccess=false -from ctypes import sizeof +from copy import deepcopy +from ctypes import addressof, sizeof from libretro.api.video import ( RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION, @@ -19,8 +21,7 @@ ) # Reference values computed on a 64-bit platform from the C headers -# (vulkan_core.h 1.4.341 and libretro_vulkan.h negotiation v2); -# see docs/superpowers/plans/2026-07-23-vulkan-video-driver.md, Task 1. +# (vulkan_core.h 1.4.341 and libretro_vulkan.h negotiation v2). def test_version_constants(): @@ -93,3 +94,51 @@ def test_physical_device_features_field_count_and_names(): assert fields[0][0] == "robustBufferAccess" assert fields[-1][0] == "inheritedQueries" assert fields[20][0] == "textureCompressionETC2" + + +def test_null_function_pointers_read_as_none(): + iface = retro_hw_render_interface_vulkan() + assert iface.handle is None + assert iface.set_image is None + assert iface.get_sync_index is None + assert iface.set_signal_semaphore is None + + negotiation = retro_hw_render_context_negotiation_interface_vulkan() + assert negotiation.get_application_info is None + assert negotiation.create_device is None + assert negotiation.create_device2 is None + + +def test_null_context_handles_read_as_none(): + context = retro_vulkan_context() + assert context.gpu is None + assert context.device is None + assert context.queue is None + assert context.presentation_queue is None + + +def test_component_mapping_deepcopy(): + mapping = VkComponentMapping(r=1, g=2, b=3, a=4) + copied = deepcopy(mapping) + assert copied is not mapping + assert addressof(copied) != addressof(mapping) + assert (copied.r, copied.g, copied.b, copied.a) == (1, 2, 3, 4) + + +def test_subresource_range_deepcopy(): + subresource = VkImageSubresourceRange( + aspectMask=1, baseMipLevel=2, levelCount=3, baseArrayLayer=4, layerCount=5 + ) + copied = deepcopy(subresource) + assert addressof(copied) != addressof(subresource) + assert copied.aspectMask == 1 + assert copied.layerCount == 5 + + +def test_physical_device_features_deepcopy(): + features = VkPhysicalDeviceFeatures(robustBufferAccess=1, inheritedQueries=1) + copied = deepcopy(features) + assert addressof(copied) != addressof(features) + assert copied.robustBufferAccess == 1 + assert copied.inheritedQueries == 1 + assert copied.geometryShader == 0 diff --git a/tests/unit/drivers/test_video_negotiation.py b/tests/unit/drivers/test_video_negotiation.py index 2f6ffbd..116593b 100644 --- a/tests/unit/drivers/test_video_negotiation.py +++ b/tests/unit/drivers/test_video_negotiation.py @@ -70,9 +70,13 @@ def test_composite_accepts_interface_with_multi_driver(): def test_negotiation_support_reports_version_for_vulkan_capable_driver(): - # A driver map claiming Vulkan support is enough for the env call + # The version comes from the driver that serves the interface type, + # even before the core has requested a Vulkan context + pytest.importorskip("vulkan", reason="the libretro.py[vulkan] extra is not installed") + from libretro.drivers.video.vulkan import VulkanVideoDriver + driver = MultiVideoDriver( - {HardwareContext.NONE: ArrayVideoDriver, HardwareContext.VULKAN: ArrayVideoDriver} + {HardwareContext.NONE: ArrayVideoDriver, HardwareContext.VULKAN: VulkanVideoDriver} ) env = _composite(driver) query = retro_hw_render_context_negotiation_interface( @@ -85,6 +89,27 @@ def test_negotiation_support_reports_version_for_vulkan_capable_driver(): assert query.interface_version == 2 +def test_negotiation_support_reports_configured_driver_version(): + pytest.importorskip("vulkan", reason="the libretro.py[vulkan] extra is not installed") + from libretro.drivers.video.vulkan import VulkanVideoDriver + + driver = MultiVideoDriver( + { + HardwareContext.NONE: ArrayVideoDriver, + HardwareContext.VULKAN: lambda: VulkanVideoDriver(negotiation_version=1), + } + ) + env = _composite(driver) + query = retro_hw_render_context_negotiation_interface( + interface_type=ContextNegotiationInterfaceType.VULKAN, + interface_version=0, + ) + ptr = cast(pointer(query), POINTER(retro_hw_render_context_negotiation_interface)) + + assert env._get_hw_render_context_negotiation_interface_support(ptr) is True + assert query.interface_version == 1 + + def test_vulkan_driver_registered_when_available(): pytest.importorskip("vulkan", reason="the libretro.py[vulkan] extra is not installed") from libretro.drivers.video import DEFAULT_DRIVER_MAP diff --git a/tests/unit/drivers/test_vulkan_driver.py b/tests/unit/drivers/test_vulkan_driver.py index 6139f6f..76d7cda 100644 --- a/tests/unit/drivers/test_vulkan_driver.py +++ b/tests/unit/drivers/test_vulkan_driver.py @@ -1,9 +1,13 @@ # The vulkan package is untyped CFFI; see the note in the driver module. -# These tests also assert on the driver's private frame-path state. +# These tests also assert on the driver's private frame-path state, +# and call the render interface's function pointers the way a C core would +# (passing NULL handles and byref pointers that the declared types don't admit). # pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportPrivateUsage=false # pyright: reportUnknownVariableType=false, reportUnknownArgumentType=false +# pyright: reportArgumentType=false, reportCallIssue=false +# pyright: reportOptionalCall=false, reportOptionalMemberAccess=false -from ctypes import byref +from ctypes import byref, c_ubyte import pytest @@ -17,13 +21,20 @@ retro_system_timing, ) from libretro.api.video import ( # noqa: E402 + ContextNegotiationInterfaceType, HardwareContext, + MemoryAccess, PixelFormat, + Rotation, VkImageSubresourceRange, VkImageViewCreateInfo, retro_hw_context_reset_t, retro_hw_render_callback, + retro_hw_render_context_negotiation_interface_vulkan, retro_hw_render_interface_vulkan, + retro_vulkan_create_device2_t, + retro_vulkan_create_device_t, + retro_vulkan_create_instance_t, retro_vulkan_image, ) from libretro.drivers.video import FrameBufferSpecial, UnsupportedContextError # noqa: E402 @@ -84,7 +95,7 @@ def test_software_frame_screenshot(): driver.refresh(memoryview(frame), WIDTH, HEIGHT, WIDTH * 4) # Software frames go through a VkImage, like the GL driver's texture upload - assert driver._last_frame_hw + assert driver._hw_frame is not None shot = driver.screenshot() assert shot is not None @@ -103,7 +114,7 @@ def test_software_frame_with_padded_pitch(): frame[y * pitch : y * pitch + WIDTH * 4] = b"\x00\xff\x00\x00" * WIDTH # Green driver.refresh(memoryview(frame), WIDTH, HEIGHT, pitch) - assert driver._last_frame_hw + assert driver._hw_frame is not None shot = driver.screenshot() assert shot is not None assert bytes(shot.data[:4]) == b"\x00\xff\x00\xff" @@ -120,7 +131,7 @@ def test_software_frame_rgb565(): frame = bytearray(b"\x1f\x00" * (WIDTH * HEIGHT)) driver.refresh(memoryview(frame), WIDTH, HEIGHT, WIDTH * 2) - assert driver._last_frame_hw + assert driver._hw_frame is not None shot = driver.screenshot() assert shot is not None assert bytes(shot.data[:4]) == b"\x00\x00\xff\xff" @@ -165,9 +176,10 @@ class _FakeCoreImage: """Stands in for a core's Vulkan renderer: clears an image to a color.""" def __init__(self, iface: retro_hw_render_interface_vulkan): - self.device = ffi.cast("VkDevice", iface.device) - self.gpu = ffi.cast("VkPhysicalDevice", iface.gpu) - self.queue = ffi.cast("VkQueue", iface.queue) + # Interface handles are c_void_ptr instances; CFFI needs their raw addresses + self.device = ffi.cast("VkDevice", iface.device.value) + self.gpu = ffi.cast("VkPhysicalDevice", iface.gpu.value) + self.queue = ffi.cast("VkQueue", iface.queue.value) self.queue_index = iface.queue_index self.iface = iface @@ -366,3 +378,153 @@ def test_context_destroy_called_on_teardown(): driver.set_context(retro_hw_render_callback(context_type=HardwareContext.NONE)) driver.reinit() assert driver.hw_render_interface is None + + +def test_software_dupe_keeps_frame(): + driver = VulkanVideoDriver() + driver.pixel_format = PixelFormat.XRGB8888 + driver.system_av_info = _av_info() + + frame = bytearray(b"\x00\x00\xff\x00" * (WIDTH * HEIGHT)) # Red + driver.refresh(memoryview(frame), WIDTH, HEIGHT, WIDTH * 4) + driver.refresh(FrameBufferSpecial.DUPE, WIDTH, HEIGHT, 0) + + shot = driver.screenshot() + assert shot is not None + assert bytes(shot.data[:4]) == b"\xff\x00\x00\xff" + + +def test_rotation_applies_to_vulkan_frames(): + driver = VulkanVideoDriver() + driver.pixel_format = PixelFormat.XRGB8888 + driver.system_av_info = _av_info() + driver.rotation = Rotation.NINETY + + frame = bytearray(b"\x00\x00\xff\x00" * (WIDTH * HEIGHT)) + driver.refresh(memoryview(frame), WIDTH, HEIGHT, WIDTH * 4) + + shot = driver.screenshot() + assert shot is not None + assert (shot.width, shot.height) == (HEIGHT, WIDTH) + assert shot.rotation == Rotation.NINETY + + +def test_reinit_fails_without_vulkan(monkeypatch: pytest.MonkeyPatch): + import libretro.drivers.video.vulkan.driver as driver_module + + def _no_loader(): + raise RuntimeError("No Vulkan loader for this test") + + monkeypatch.setattr(driver_module, "_load_loader", _no_loader) + + driver = VulkanVideoDriver() + with pytest.raises(RuntimeError): + driver.system_av_info = _av_info() + + +def test_geometry_reports_rendered_frame_size(): + driver = VulkanVideoDriver() + driver.pixel_format = PixelFormat.XRGB8888 + driver.system_av_info = _av_info() + + geometry = driver.geometry + assert geometry is not None + assert (geometry.base_width, geometry.base_height) == (WIDTH, HEIGHT) + + half_w, half_h = WIDTH // 2, HEIGHT // 2 + frame = bytearray(b"\x00\x00\xff\x00" * (half_w * half_h)) + driver.refresh(memoryview(frame), half_w, half_h, half_w * 4) + + geometry = driver.geometry + assert geometry is not None + assert (geometry.base_width, geometry.base_height) == (half_w, half_h) + + +def test_get_software_framebuffer_maps_vulkan_memory(): + driver = VulkanVideoDriver() + driver.pixel_format = PixelFormat.XRGB8888 + driver.system_av_info = _av_info() + + fb = driver.get_software_framebuffer(WIDTH, HEIGHT, MemoryAccess.WRITE | MemoryAccess.READ) + assert fb is not None + assert fb.data is not None + assert fb.format == PixelFormat.XRGB8888 + assert fb.pitch == WIDTH * 4 + + # Render into the mapped Vulkan memory like a core would, + # then present it through the ordinary refresh path + buf = (c_ubyte * (HEIGHT * fb.pitch)).from_address(fb.data.value) + view = memoryview(buf).cast("B") + view[:] = b"\x00\xff\x00\x00" * (WIDTH * HEIGHT) # Green + driver.refresh(view, WIDTH, HEIGHT, fb.pitch) + + shot = driver.screenshot() + assert shot is not None + assert bytes(shot.data[:4]) == b"\x00\xff\x00\xff" + + +def test_negotiation_version_delegated_to_driver(): + assert ( + VulkanVideoDriver().context_negotiation_version(ContextNegotiationInterfaceType.VULKAN) + == 2 + ) + + driver = VulkanVideoDriver(negotiation_version=1) + assert driver.context_negotiation_version(ContextNegotiationInterfaceType.VULKAN) == 1 + + with pytest.raises(ValueError): + VulkanVideoDriver(negotiation_version=0) + + with pytest.raises(ValueError): + VulkanVideoDriver(negotiation_version=3) + + +def _negotiation_recorder( + calls: list[str], +) -> retro_hw_render_context_negotiation_interface_vulkan: + """A fake core-side negotiation interface that records calls and always fails.""" + + def _create_device(*_args: object) -> bool: + calls.append("create_device") + return False + + def _create_device2(*_args: object) -> bool: + calls.append("create_device2") + return False + + def _create_instance(*_args: object) -> int: + calls.append("create_instance") + return 0 + + return retro_hw_render_context_negotiation_interface_vulkan( + interface_type=ContextNegotiationInterfaceType.VULKAN, + interface_version=2, + create_device=retro_vulkan_create_device_t(_create_device), + create_instance=retro_vulkan_create_instance_t(_create_instance), + create_device2=retro_vulkan_create_device2_t(_create_device2), + ) + + +def test_v2_negotiation_uses_v2_callbacks(): + calls: list[str] = [] + driver = VulkanVideoDriver() + driver.pixel_format = PixelFormat.XRGB8888 + driver.set_context(_vulkan_callback()) + driver.context_negotiation_interface = _negotiation_recorder(calls) + driver.system_av_info = _av_info() + + assert "create_instance" in calls + assert "create_device2" in calls + + +def test_v1_frontend_skips_v2_negotiation_callbacks(): + calls: list[str] = [] + driver = VulkanVideoDriver(negotiation_version=1) + driver.pixel_format = PixelFormat.XRGB8888 + driver.set_context(_vulkan_callback()) + driver.context_negotiation_interface = _negotiation_recorder(calls) + driver.system_av_info = _av_info() + + assert "create_device" in calls + assert "create_instance" not in calls + assert "create_device2" not in calls