vulkan api support - #29
Conversation
In software mode the driver now brings up a real Vulkan device (instance/device/capture resources, no negotiation or render interface since there's no core context), and every software frame is uploaded to a VkImage via staging + vkCmdCopyBufferToImage, then read back through the same capture path used for hardware frames
JesseTG
left a comment
There was a problem hiding this comment.
Thanks for your contribution! I do have some feedback I'd like you to address before I'm ready to merge (and therefore maintain) this.
| if ( | ||
| support[0].interface_type == ContextNegotiationInterfaceType.VULKAN | ||
| and HardwareContext.VULKAN in self._video.supported_contexts | ||
| ): | ||
| support[ | ||
| 0 | ||
| ].interface_version = RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN_VERSION | ||
| return True |
There was a problem hiding this comment.
The version number should be delegated to the driver itself, so that cores can be tested against the V1 context negotiation interface.
There was a problem hiding this comment.
Done in 3833030: VideoDriver now has a context_negotiation_version(interface_type) method (default None) that the env call delegates to, and VulkanVideoDriver takes a negotiation_version constructor argument. The driver honors min(core's version, driver's version) when deciding whether to use create_instance/create_device2, so a V1-configured frontend never calls the V2 callbacks. MultiVideoDriver asks the driver that would serve the interface type, since cores query support before requesting their context. Unit tests cover both the version reporting and the V1 path.
| class retro_vulkan_image(Structure): | ||
| """Corresponds to :c:type:`retro_vulkan_image` in ``libretro_vulkan.h``.""" | ||
|
|
||
| _fields_ = ( | ||
| ("image_view", VkImageView), | ||
| ("image_layout", VkImageLayout), | ||
| ("create_info", VkImageViewCreateInfo), | ||
| ) | ||
|
|
||
|
|
||
| class retro_vulkan_context(Structure): | ||
| """Corresponds to :c:type:`retro_vulkan_context` in ``libretro_vulkan.h``.""" | ||
|
|
||
| _fields_ = ( | ||
| ("gpu", VkPhysicalDevice), | ||
| ("device", VkDevice), | ||
| ("queue", VkQueue), | ||
| ("queue_family_index", c_uint32), | ||
| ("presentation_queue", VkQueue), | ||
| ("presentation_queue_family_index", c_uint32), | ||
| ) |
There was a problem hiding this comment.
Please follow the conventions that the other libretro Structure subclasses do:
- Fields should be declared with type annotations that match the implicit conversions that
ctypesdoes. This helps static type checkers. But keep the_fields_attributes as they are,ctypesneeds those. - All structures should be declared as dataclasses, with the
NullPointerToNoneMixinif they have any pointers (so that null pointers get converted toNoneinstead of empty objects) - Normally all
Structures should provide an explicit__deepcopy__method, but since these have handles to GPU resources I'm not sure that makes sense here -- outside of a few classes likeVkComponentMappingthat don't directly point to other Vulkan resources.
There was a problem hiding this comment.
Done in 3833030: the structs in libretro.api.video.vulkan are now dataclasses with field annotations matching the ctypes conversions (keeping _fields_), with NullPointerToNoneMixin on the ones that have pointer fields. __deepcopy__ is only on the value structs that don't reference GPU resources (VkComponentMapping, VkImageSubresourceRange, VkPhysicalDeviceFeatures); the handle-bearing structs deliberately don't get one, per your note.
| retro_vulkan_set_image_t = CFUNCTYPE( | ||
| None, c_void_p, POINTER(retro_vulkan_image), c_uint32, POINTER(VkSemaphore), c_uint32 | ||
| ) | ||
| retro_vulkan_get_sync_index_t = CFUNCTYPE(c_uint32, c_void_p) | ||
| retro_vulkan_get_sync_index_mask_t = CFUNCTYPE(c_uint32, c_void_p) | ||
| retro_vulkan_set_command_buffers_t = CFUNCTYPE(None, c_void_p, c_uint32, POINTER(VkCommandBuffer)) | ||
| retro_vulkan_wait_sync_index_t = CFUNCTYPE(None, c_void_p) | ||
| retro_vulkan_lock_queue_t = CFUNCTYPE(None, c_void_p) | ||
| retro_vulkan_unlock_queue_t = CFUNCTYPE(None, c_void_p) | ||
| retro_vulkan_set_signal_semaphore_t = CFUNCTYPE(None, c_void_p, VkSemaphore) | ||
|
|
||
| retro_vulkan_get_application_info_t = CFUNCTYPE(POINTER(VkApplicationInfo)) | ||
| retro_vulkan_create_device_t = CFUNCTYPE( | ||
| c_bool, | ||
| POINTER(retro_vulkan_context), | ||
| VkInstance, | ||
| VkPhysicalDevice, | ||
| VkSurfaceKHR, | ||
| PFN_vkGetInstanceProcAddr, | ||
| POINTER(c_char_p), | ||
| c_uint, | ||
| POINTER(c_char_p), | ||
| c_uint, | ||
| POINTER(VkPhysicalDeviceFeatures), | ||
| ) | ||
| retro_vulkan_destroy_device_t = CFUNCTYPE(None) | ||
|
|
||
| retro_vulkan_create_instance_wrapper_t = CFUNCTYPE(VkInstance, c_void_p, c_void_p) | ||
| """The second parameter is a ``const VkInstanceCreateInfo *``, opaque at this layer.""" | ||
|
|
||
| retro_vulkan_create_instance_t = CFUNCTYPE( | ||
| VkInstance, | ||
| PFN_vkGetInstanceProcAddr, | ||
| POINTER(VkApplicationInfo), | ||
| retro_vulkan_create_instance_wrapper_t, | ||
| c_void_p, | ||
| ) | ||
|
|
||
| retro_vulkan_create_device_wrapper_t = CFUNCTYPE(VkDevice, VkPhysicalDevice, c_void_p, c_void_p) | ||
| """The third parameter is a ``const VkDeviceCreateInfo *``, opaque at this layer.""" | ||
|
|
||
| retro_vulkan_create_device2_t = CFUNCTYPE( | ||
| c_bool, | ||
| POINTER(retro_vulkan_context), | ||
| VkInstance, | ||
| VkPhysicalDevice, | ||
| VkSurfaceKHR, | ||
| PFN_vkGetInstanceProcAddr, | ||
| retro_vulkan_create_device_wrapper_t, | ||
| c_void_p, | ||
| ) |
There was a problem hiding this comment.
Please follow the conventions that libretro.api does for pointer types:
- Define all function pointer types with
TypedFunctionPointer, for the benefit of static type checkers. - Annotate all pointer-typed fields (including strings) as a union of
the_pointer_type | None, so that type checkers correctly report the possibility ofNone. - Use
CIntArg,CBoolArg, etc. so that implicit conversions are factored in. (TypedFunctionPointeris equivalent toCFUNCTYPEat runtime.) - Use
c_void_ptrinstead ofc_void_pso thatvoid*'s aren't implicitly converted toints. - Use
TypedPointerfor explicitly declaring pointer types, so that type checkers don't collapse every operation toAny.
See libretro.api.disk, it's a pretty good example.
There was a problem hiding this comment.
Done in 3833030, modeled on libretro.api.disk: TypedFunctionPointer for every function pointer type, CIntArg/TypedPointer/c_void_ptr in the signatures, and | None annotations on all pointer-typed fields. Dispatchable Vulkan handles are c_void_ptr aliases now; non-dispatchable ones stay c_uint64.
|
|
||
| # Reference values computed on a 64-bit platform from the C headers | ||
| # (vulkan_core.h 1.4.341 and libretro_vulkan.h negotiation v2); | ||
| # see docs/superpowers/plans/2026-07-23-vulkan-video-driver.md, Task 1. |
There was a problem hiding this comment.
Is this a directory that you just use locally?
There was a problem hiding this comment.
Yes — that's a local planning directory that isn't checked in. Removed the reference in 3833030.
| the driver copies the visible region into host memory each frame, | ||
| which backs :meth:`~.VulkanVideoDriver.screenshot`. | ||
| Software-rendered frames are supported as well, | ||
| with the same semantics as :class:`.ArrayVideoDriver`. |
There was a problem hiding this comment.
Docstring updated in 3833030 to describe the actual path: software frames are uploaded to a VkImage and read back through the same capture path as hardware frames, and the driver fails rather than falling back if Vulkan can't be initialized.
| def get_software_framebuffer( | ||
| self, width: int, height: int, flags: MemoryAccess | ||
| ) -> retro_framebuffer | None: | ||
| return self._software.get_software_framebuffer(width, height, flags) |
There was a problem hiding this comment.
Doesn't Vulkan have an API for mapping a buffer into the process's address space?
There was a problem hiding this comment.
It does — GET_CURRENT_SOFTWARE_FRAMEBUFFER is now backed by the driver's host-visible staging buffer, kept persistently mapped with vkMapMemory. The returned retro_framebuffer points into that mapping and reports RETRO_MEMORY_TYPE_CACHED when the chosen memory type has HOST_CACHED (3833030).
| it falls back to CPU-side frames when Vulkan is unavailable | ||
| or the pixel format has no direct Vulkan equivalent. |
There was a problem hiding this comment.
It shouldn't do this, it should fail and report an error to the core. The entire point is to test a core's use of Vulkan. Anyone who needs fallback behavior can write a wrapper VideoDriver.
There was a problem hiding this comment.
Fallback removed in 3833030: if Vulkan can't be initialized, reinit raises instead of dropping to CPU frames (covered by a unit test), and the CHANGELOG entry now describes that behavior.
| 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}") | ||
|
|
There was a problem hiding this comment.
Does RetroArch do something like this?
There was a problem hiding this comment.
The ordering mirrors RetroArch: core_unload_game() in runloop.c calls video_driver_free_hw_context() immediately before retro_unload_game(), while the frontend's own video driver outlives retro_deinit(). The warn-and-continue is the Python analog of C having no exception to catch here — if the driver's teardown throws, the session still finishes unloading the core instead of dying in __exit__, but the failure is surfaced instead of swallowed. Happy to change it to re-raise if you'd rather teardown failures be fatal.
| @property | ||
| @override | ||
| def geometry(self) -> retro_game_geometry | None: | ||
| return self._software.geometry | ||
|
|
||
| @geometry.setter | ||
| @override | ||
| def geometry(self, geometry: retro_game_geometry) -> None: | ||
| self._software.geometry = geometry |
There was a problem hiding this comment.
This is wrong, it should return the size of whatever Vulkan buffer contains the final rendered frame.
There was a problem hiding this comment.
Fixed in 3833030: the getter now reports the dimensions of the Vulkan buffer holding the most recent captured frame, falling back to the core's declared geometry before the first frame. Covered by a unit test.
| # reports every use of it; relax those (and only those) for this module. | ||
| # pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false | ||
| # pyright: reportUnknownVariableType=false, reportUnknownParameterType=false | ||
| # pyright: reportMissingParameterType=false, reportMissingTypeStubs=false |
There was a problem hiding this comment.
I'd prefer it if you wrote type declarations for the Vulkan types that you use, similar to what I did in libretro.ctypes (though not generic, so these would be simpler). cffi has type stubs, you'll want to use those.
There was a problem hiding this comment.
Done in 3833030: drivers/video/vulkan/_typing.py declares the subset of the vulkan package the driver uses — constants, struct constructors, and function signatures in terms of cffi's CData, plus a small protocol for the mapped-memory buffer — using the cffi type stubs, and erasing to the real module at runtime like libretro.ctypes does. The driver module's blanket pyright suppressions are gone; the whole project typechecks clean under strict mode.
|
Also, could you retarget this PR against |
…negotiation version Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
I one-shotted this with fable and haven't even read it yet
(The model wrote the summary below; a human has still not read the diff. All claims are backed by the test suite and by screenshots from real cores.)
This adds the Vulkan video driver that
HardwareContext.VULKAN's docstring has been asking for. It was developed against, and validated with, seven real Vulkan cores on macOS/MoltenVK: Azahar, Beetle PSX HW, Flycast, Dolphin, Mupen64Plus-Next (paraLLEl-RDP/RSP), PPSSPP, and SwanStation — each boots, renders, and screenshots correctly through this driver.What's new
VulkanVideoDriver(drivers/video/vulkan/): a headless driver implementing the full version-5retro_hw_render_interface_vulkan(set_image, sync indices, queue locking,set_command_buffers,set_signal_semaphore) plus the context negotiation interface (v1get_application_info/create_deviceand v2create_instance/create_device2wrapper paths, with fallback device creation). Frames are captured to host memory withvkCmdCopyImageToBuffer, soscreenshot()works like the GL/software drivers. Supported capture formats: RGBA8/BGRA8 (+sRGB), A1R5G5B5/R5G6B5/B5G6R5 (Beetle scans out 16-bit when dithering), and A2B10G10R10/A2R10G10B10 (Dolphin).libretro_vulkan.h(api/video/vulkan.py), with struct layouts unit-tested against values computed from the C headers.vulkanpackage (newlibretro.py[vulkan]extra) and bridges handles as integers at the ABI boundary. On macOS it enablesVK_KHR_portability_enumerationand creates aVK_EXT_headless_surfaceto hand to cores — PPSSPP crashes ifcreate_devicereceives a NULL surface.VideoDriverprotocol members:context_negotiation_interface(backs the previously-stubbedSET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE) anddestroy_hw_context()(see lifecycle below).DEFAULT_DRIVER_MAP, documented in a new guide page, exercised by a new integration test using the upstreamvk_renderingsample core (gated on Vulkan headers at build time).Pre-existing bugs fixed along the way
Each of these was found because a real core tripped over it:
GET_HW_RENDER_INTERFACEwrote the interface struct by value into aretro_hw_render_interface **instead of writing a pointer (never exercised before — no driver returned an interface).GET_PERF_INTERFACEcrashed: bound methods were assigned toretro_perf_callbackfields instead of CFUNCTYPE instances (Beetle PSX HW requests perf atretro_init).WRITE | UPDATE_EXISTINGaccess mode (write without truncation) was rejected (Flycast uses it).seeknow returns 0 on success instead of the new position. libretro.h documents returning the position, but RetroArch returnsfseek's result for ordinary files, and cores are written against that — PPSSPP treats any non-zero return as an error, which made every file look empty under a spec-conforming frontend. De facto beats de jure here; there's an ABI-level regression test.default_valuetripped an assertion; per libretro.h the first value is the default (Mupen64Plus-Next has such an option).retro_video_refresh_trejected NULL frame dupes: ctypes exposes a NULL pointer's.valueasNone, not0(SwanStation dupes frames)..so, butlibretro.samples._loaderexpects.dylib.Hardware-context lifecycle
Session.__exit__now mirrors RetroArch'score_unload_game: the core'scontext_destroyfires immediately beforeretro_unload_game(SwanStation and PPSSPP need their emulated system alive for it), while the driver's own GPU objects are released only afterretro_deinit(Azahar has GPU threads that only stop during unload). Retired interface structs are kept for the life of the process with their callbacks swapped to a native no-op, because paraLLEl-RDP holds the interface pointer in exit-time static destructors that would otherwise call into a finalized interpreter.Testing
pytestpasses (526 tests here): struct-layout unit tests, env-call dispatch tests, driver tests markedvulkanthat run against a real GPU (a simulated core clears aVkImage, hands it over viaset_image, and pixel values are asserted), VFS ABI tests, and thevk_renderingend-to-end test. pyright (strict) and ruff are clean.vulkanextra or a loader is missing; everything else runs without Vulkan installed.Known limitations (documented): headless only (no window path yet), and the capture path reads the underlying image rather than sampling the
VkImageView, so view swizzles are ignored — correct for all cores tested.