Skip to content

vulkan api support - #29

Draft
warmenhoven wants to merge 5 commits into
JesseTG:mainfrom
warmenhoven:warmenhoven/pr/vulkan
Draft

vulkan api support#29
warmenhoven wants to merge 5 commits into
JesseTG:mainfrom
warmenhoven:warmenhoven/pr/vulkan

Conversation

@warmenhoven

@warmenhoven warmenhoven commented Jul 23, 2026

Copy link
Copy Markdown

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-5 retro_hw_render_interface_vulkan (set_image, sync indices, queue locking, set_command_buffers, set_signal_semaphore) plus the context negotiation interface (v1 get_application_info/create_device and v2 create_instance/create_device2 wrapper paths, with fallback device creation). Frames are captured to host memory with vkCmdCopyImageToBuffer, so screenshot() 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).
  • ctypes bindings for everything in libretro_vulkan.h (api/video/vulkan.py), with struct layouts unit-tested against values computed from the C headers.
  • The frontend uses the CFFI vulkan package (new libretro.py[vulkan] extra) and bridges handles as integers at the ABI boundary. On macOS it enables VK_KHR_portability_enumeration and creates a VK_EXT_headless_surface to hand to cores — PPSSPP crashes if create_device receives a NULL surface.
  • New VideoDriver protocol members: context_negotiation_interface (backs the previously-stubbed SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE) and destroy_hw_context() (see lifecycle below).
  • Registered in DEFAULT_DRIVER_MAP, documented in a new guide page, exercised by a new integration test using the upstream vk_rendering sample 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_INTERFACE wrote the interface struct by value into a retro_hw_render_interface ** instead of writing a pointer (never exercised before — no driver returned an interface).
  • GET_PERF_INTERFACE crashed: bound methods were assigned to retro_perf_callback fields instead of CFUNCTYPE instances (Beetle PSX HW requests perf at retro_init).
  • VFS: the WRITE | UPDATE_EXISTING access mode (write without truncation) was rejected (Flycast uses it).
  • VFS: seek now returns 0 on success instead of the new position. libretro.h documents returning the position, but RetroArch returns fseek'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.
  • Core options with no explicit default_value tripped an assertion; per libretro.h the first value is the default (Mupen64Plus-Next has such an option).
  • retro_video_refresh_t rejected NULL frame dupes: ctypes exposes a NULL pointer's .value as None, not 0 (SwanStation dupes frames).
  • Sample cores were unloadable on macOS: CMake MODULE targets emit .so, but libretro.samples._loader expects .dylib.

Hardware-context lifecycle

Session.__exit__ now mirrors RetroArch's core_unload_game: the core's context_destroy fires immediately before retro_unload_game (SwanStation and PPSSPP need their emulated system alive for it), while the driver's own GPU objects are released only after retro_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

  • pytest passes (526 tests here): struct-layout unit tests, env-call dispatch tests, driver tests marked vulkan that run against a real GPU (a simulated core clears a VkImage, hands it over via set_image, and pixel values are asserted), VFS ABI tests, and the vk_rendering end-to-end test. pyright (strict) and ruff are clean.
  • GPU-marked tests skip cleanly when the vulkan extra 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.

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 JesseTG left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +2077 to +2084
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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version number should be delegated to the driver itself, so that cores can be tested against the V1 context negotiation interface.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +175 to +195
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),
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please follow the conventions that the other libretro Structure subclasses do:

  • Fields should be declared with type annotations that match the implicit conversions that ctypes does. This helps static type checkers. But keep the _fields_ attributes as they are, ctypes needs those.
  • All structures should be declared as dataclasses, with the NullPointerToNoneMixin if they have any pointers (so that null pointers get converted to None instead 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 like VkComponentMapping that don't directly point to other Vulkan resources.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/libretro/api/video/vulkan.py Outdated
Comment on lines +198 to +248
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,
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of None.
  • Use CIntArg, CBoolArg, etc. so that implicit conversions are factored in. (TypedFunctionPointer is equivalent to CFUNCTYPE at runtime.)
  • Use c_void_ptr instead of c_void_p so that void*'s aren't implicitly converted to ints.
  • Use TypedPointer for explicitly declaring pointer types, so that type checkers don't collapse every operation to Any.

See libretro.api.disk, it's a pretty good example.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/unit/api/test_video_vulkan.py Outdated

# 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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a directory that you just use locally?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not anymore!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +583 to +586
def get_software_framebuffer(
self, width: int, height: int, flags: MemoryAccess
) -> retro_framebuffer | None:
return self._software.get_software_framebuffer(width, height, flags)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't Vulkan have an API for mapping a buffer into the process's address space?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread CHANGELOG.md Outdated
Comment on lines +28 to +29
it falls back to CPU-side frames when Vulkan is unavailable
or the pixel format has no direct Vulkan equivalent.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/libretro/session.py
Comment on lines +1238 to +1250
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}")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does RetroArch do something like this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +572 to +580
@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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong, it should return the size of whatever Vulkan buffer contains the final rendered frame.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@JesseTG

JesseTG commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Also, could you retarget this PR against dev rather than main?

…negotiation version

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants