diff --git a/CHANGELOG.md b/CHANGELOG.md index 19eaadf..8069cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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`. +- `VulkanVideoDriver` uploads software-rendered frames to a `VkImage` + and reads them back through its capture path, + mirroring the OpenGL driver's texture upload; + 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 + +- 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). +- 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. +- 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 + ### Fixed - Remove stray references to `SessionBuilder` from the documentation. 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/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/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..0824dad --- /dev/null +++ b/src/libretro/api/video/vulkan.py @@ -0,0 +1,749 @@ +""" +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. + +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 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 +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_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 +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_ptr +"""Treated as an opaque function pointer at the ABI boundary.""" + +PFN_vkGetDeviceProcAddr = c_void_ptr +"""Treated as an opaque function pointer at the ABI boundary.""" + + +@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_ptr), + ("pApplicationName", c_char_p), + ("applicationVersion", c_uint32), + ("pEngineName", c_char_p), + ("engineVersion", c_uint32), + ("apiVersion", c_uint32), + ) + + +@dataclass(init=False, slots=True) +class VkComponentMapping(Structure): + """ + 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), + ("g", c_int), + ("b", c_int), + ("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``. + + >>> 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), + ("baseMipLevel", c_uint32), + ("levelCount", c_uint32), + ("baseArrayLayer", c_uint32), + ("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 + """ + + 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_ptr), + ("flags", c_uint32), + ("image", VkImage), + ("viewType", c_int), + ("format", VkFormat), + ("components", VkComponentMapping), + ("subresourceRange", VkImageSubresourceRange), + ) + + +@dataclass(init=False, slots=True) +class VkPhysicalDeviceFeatures(Structure): + """ + 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``. + + >>> 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), + ("image_layout", VkImageLayout), + ("create_info", VkImageViewCreateInfo), + ) + + +@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), + ("device", VkDevice), + ("queue", VkQueue), + ("queue_family_index", c_uint32), + ("presentation_queue", VkQueue), + ("presentation_queue_family_index", c_uint32), + ) + + +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, + [ + 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). + +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. + +Corresponds to :c:type:`retro_vulkan_create_instance_wrapper_t` in ``libretro_vulkan.h``. +""" + +retro_vulkan_create_instance_t = TypedFunctionPointer[ + VkInstance, + [ + 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 = 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 = TypedFunctionPointer[ + c_bool, + [ + 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``. +""" + + +@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``. + + 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. + + >>> 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_ptr), + ("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), + ) + + +@dataclass(init=False, slots=True) +class retro_hw_render_context_negotiation_interface_vulkan( + retro_hw_render_context_negotiation_interface, NullPointerToNoneMixin +): + """ + 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. + + >>> 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), + ("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..81b0463 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, @@ -323,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) @@ -879,13 +882,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 @@ -1261,7 +1264,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 +1293,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: @@ -1435,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( @@ -2038,7 +2068,23 @@ 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" + ) + + 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 + + support[0].interface_version = version + return True @property def jit_capable(self) -> bool | None: 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/__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..447f45b 100644 --- a/src/libretro/drivers/video/driver.py +++ b/src/libretro/drivers/video/driver.py @@ -11,12 +11,14 @@ 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, Rotation, retro_framebuffer, retro_hw_render_callback, + retro_hw_render_context_negotiation_interface, retro_hw_render_interface, ) @@ -134,6 +136,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]: @@ -431,6 +452,57 @@ 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: ... + + 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 c5094c4..5f5afd2 100644 --- a/src/libretro/drivers/video/multi.py +++ b/src/libretro/drivers/video/multi.py @@ -8,16 +8,19 @@ 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 from libretro.api.video import ( + ContextNegotiationInterfaceType, HardwareContext, MemoryAccess, PixelFormat, Rotation, retro_framebuffer, retro_hw_render_callback, + retro_hw_render_context_negotiation_interface, retro_hw_render_interface, ) @@ -26,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 } @@ -38,6 +46,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 +65,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 +129,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 +212,26 @@ 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 + 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 @@ -200,7 +241,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 @@ -366,6 +406,58 @@ 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( + 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 + + @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/opengl/moderngl.py b/src/libretro/drivers/video/opengl/moderngl.py index c99d2b4..c164c00 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, ) @@ -252,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 @@ -455,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" @@ -463,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 @@ -759,6 +763,30 @@ 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( + 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..36165e5 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 @@ -61,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 @@ -86,6 +92,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/_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 new file mode 100644 index 0000000..d44e8e1 --- /dev/null +++ b/src/libretro/drivers/video/vulkan/driver.py @@ -0,0 +1,1752 @@ +""" +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`. +""" + +from __future__ import annotations + +import ctypes +import threading +from collections.abc import Callable, Set +from contextlib import suppress +from copy import deepcopy +from ctypes import ( + 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 + +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, + 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, + 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 libretro.ctypes import CStringArg, TypedFunctionPointer, TypedPointer, c_void_ptr + +from ..driver import FrameBufferSpecial, Screenshot, UnsupportedContextError, VideoDriver +from ._typing import CBuffer, CData, ffi, vk + +_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 +_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, +} + +# 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" +_VK_EXT_HEADLESS_SURFACE = "VK_EXT_headless_surface" + +_PFN_GetInstanceProcAddr = TypedFunctionPointer[c_void_ptr, [c_void_ptr, CStringArg]] + + +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 + + +@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), + ("flags", c_uint32), + ("pApplicationInfo", POINTER(VkApplicationInfo)), + ("enabledLayerCount", c_uint32), + ("ppEnabledLayerNames", POINTER(c_char_p)), + ("enabledExtensionCount", c_uint32), + ("ppEnabledExtensionNames", POINTER(c_char_p)), + ) + + +@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), + ("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: CData | None) -> 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 _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] = [] + + +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: + 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" + ) + + +_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: list[bytes] = [] + 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]: + """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 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, + 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`. + + :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`. + :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, + 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}") + + 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._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 + 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: 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: CData | None = None + self._sw_image_memory: CData | None = None + self._sw_image_key: tuple[int, int, int] | None = 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 + 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 frame: (pixels, width, height, vk_format) + self._hw_frame: tuple[bytearray, int, int, int] | None = None + 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: + # 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() + + case memoryview(): + self.__refresh_software(data, width, height, pitch) + + 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._system_av_info is None: + raise RuntimeError("Cannot reinitialize video driver without system AV info from core") + + 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: + self.__init_vulkan(hardware=True) + self._needs_reinit = False + if self._callback.context_reset: + self._callback.context_reset() + else: + # 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 + @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}" + ) + + 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 + + @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._rotation + + @rotation.setter + @override + def rotation(self, rotation: Rotation) -> None: + 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 + def can_dupe(self) -> bool | None: + return True + + @property + @override + def pixel_format(self) -> PixelFormat: + return self._pixel_format + + @pixel_format.setter + @override + def pixel_format(self, format: PixelFormat) -> None: + 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 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: + 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: + 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: + 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: + 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: + 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: + 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 + + @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: + 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 self._hw_frame is None: + return None + + pixels, width, height, vk_format = self._hw_frame + rgba = _to_rgba32(pixels, vk_format) + rgba[3::4] = b"\xff" * (width * height) # Screenshots are opaque + + 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, + 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 + assert gipa_addr is not None + + self.__create_instance(gipa_addr) + if hardware: + self.__create_surface() + + self.__select_gpu() + self.__create_device(gipa_addr) + self.__create_capture_resources() + if hardware: + 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: Callable[..., Any] | None = 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") + 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}") + + self._surface_raw = 0 + + def __create_instance(self, gipa_addr: int) -> None: + # 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 + # (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 = 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) + + available: set[str] = { + str(ext.extensionName) for ext in vk.vkEnumerateInstanceExtensionProperties(None) + } + extensions, flags = _wanted_instance_extensions(available) + + if ( + negotiation is not None + and self.__negotiation_interface_version(negotiation) >= 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: set[str] = { + str(ext.extensionName) for ext in vk.vkEnumerateInstanceExtensionProperties(None) + } + + 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 + + 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) + ] + 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])) + 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, + ) + 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") + + 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 if self._active == HardwareContext.VULKAN else None + self._core_created_device = False + + if negotiation is not None: + 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") + + 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", _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", _addr(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 + + assert negotiation.create_device is not None + ok = negotiation.create_device( + 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) + c_void_ptr(gipa_addr), + TypedPointer[c_char_p](), + 0, + TypedPointer[c_char_p](), + 0, + ctypes.cast(ctypes.pointer(features), TypedPointer[VkPhysicalDeviceFeatures]), + ) + 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 + + assert self._gpu is not None + device_extensions = { + ext.extensionName for ext in vk.vkEnumerateDeviceExtensionProperties(self._gpu, None) + } + + def _create_device_wrapper( + 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: + 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", _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}") + return 0 + + 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( + 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) + c_void_ptr(gipa_addr), + wrapper, + c_void_ptr(), + ) + if not ok: + # Retry allowing the core to pick the physical device itself + context = retro_vulkan_context() + ok = negotiation.create_device2( + ctypes.cast(ctypes.pointer(context), TypedPointer[retro_vulkan_context]), + c_void_ptr(_raw(self._instance)), + c_void_ptr(), + self._surface_raw, + c_void_ptr(gipa_addr), + wrapper, + c_void_ptr(), + ) + + if not ok: + return False + + 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: + 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: + assert self._device is not 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() + assert self._device is not None + assert self._gpu is not None + + size = width * height * 4 + self._staging_buffer = vk.vkCreateBuffer( + self._device, + vk.VkBufferCreateInfo( + size=size, + usage=vk.VK_BUFFER_USAGE_TRANSFER_DST_BIT | vk.VK_BUFFER_USAGE_TRANSFER_SRC_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) + 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: + 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; + # 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.__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() + assert self._device is not None + assert self._gpu is not None + + 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(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. + """ + 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: + raise RuntimeError(f"{self._pixel_format} has no Vulkan format equivalent") + + 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) + + 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 + ) + 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: + 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 + ) + 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 + 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 = ( + [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: + 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, + 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( + c_void_ptr(_raw(self._instance)), b"vkGetDeviceProcAddr" + ) + if not gdpa_addr: + raise RuntimeError("vkGetInstanceProcAddr couldn't resolve vkGetDeviceProcAddr") + + 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 + + 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: c_void_ptr | None) -> int: + return self._sync_index + + def _get_sync_index_mask(_handle: c_void_ptr | None) -> int: + return (1 << self._sync_index_count) - 1 + + 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 = [_addr(cmd[i]) for i in range(num_cmd)] + else: + self._core_command_buffers = [] + + 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: c_void_ptr | None) -> None: + self._queue_lock.acquire() + + def _unlock_queue(_handle: c_void_ptr | None) -> None: + self._queue_lock.release() + + def _set_signal_semaphore(_handle: c_void_ptr | None, semaphore: int) -> 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: + assert self._staging_memory 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 + self._staging_cached = False + + 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() + self.__destroy_software_image() + + 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: + self.__destroy_surface() + vk.vkDestroyInstance(self._instance, 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 + self._hw_frame = None + self._hw_semaphores = [] + self._core_command_buffers = [] + self._signal_semaphore = 0 + self._sync_index = 0 + 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/src/libretro/session.py b/src/libretro/session.py index 5ab8565..b447fd0 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, @@ -1215,6 +1216,18 @@ 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") @@ -1222,6 +1235,19 @@ def __exit__(self, exc_type: type[Exception], exc_val: Exception, exc_tb: Traceb self._core.deinit() self._raise_pending_exceptions("retro_deinit") + if self._video.active_context != HardwareContext.NONE: + # 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) + ) + self._video.reinit() + except Exception as e: + warnings.warn(f"Couldn't tear down the hardware rendering context: {e}") + del self._core self._is_exited = True return isinstance(exc_val, CoreShutDownException) diff --git a/tests/integration/test_vulkan_driver.py b/tests/integration/test_vulkan_driver.py new file mode 100644 index 0000000..dd76f8f --- /dev/null +++ b/tests/integration/test_vulkan_driver.py @@ -0,0 +1,67 @@ +"""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 + +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_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._hw_frame is not None # 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") + + 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..610bf37 --- /dev/null +++ b/tests/unit/api/test_video_vulkan.py @@ -0,0 +1,144 @@ +# 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 copy import deepcopy +from ctypes import addressof, 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). + + +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" + + +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_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 diff --git a/tests/unit/drivers/test_video_negotiation.py b/tests/unit/drivers/test_video_negotiation.py new file mode 100644 index 0000000..116593b --- /dev/null +++ b/tests/unit/drivers/test_video_negotiation.py @@ -0,0 +1,130 @@ +# 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(): + # 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: VulkanVideoDriver} + ) + 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_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 + 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..76d7cda --- /dev/null +++ b/tests/unit/drivers/test_vulkan_driver.py @@ -0,0 +1,530 @@ +# 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, +# 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, c_ubyte + +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 + 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 +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) + + # Software frames go through a VkImage, like the GL driver's texture upload + assert driver._hw_frame is not None + + 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._hw_frame is not None + 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._hw_frame is not None + 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 + 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): + # 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 + + 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 + + +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