Skip to content

vulkan api support - #29

Open
warmenhoven wants to merge 6 commits into
JesseTG:devfrom
warmenhoven:warmenhoven/pr/vulkan
Open

vulkan api support#29
warmenhoven wants to merge 6 commits into
JesseTG:devfrom
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.

JesseTG and others added 5 commits July 14, 2026 21:10
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 thread src/libretro/drivers/environment/composite.py Outdated
Comment thread src/libretro/api/video/vulkan.py
Comment thread src/libretro/api/video/vulkan.py Outdated
Comment thread tests/unit/api/test_video_vulkan.py Outdated
Comment thread src/libretro/drivers/video/vulkan/driver.py Outdated
Comment thread src/libretro/drivers/video/vulkan/driver.py Outdated
Comment thread CHANGELOG.md Outdated
Comment thread src/libretro/session.py
Comment thread src/libretro/drivers/video/vulkan/driver.py Outdated
Comment thread src/libretro/drivers/video/vulkan/driver.py Outdated
@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>
@warmenhoven
warmenhoven changed the base branch from main to dev July 31, 2026 19:35
@warmenhoven
warmenhoven requested a review from JesseTG August 5, 2026 02:17
@warmenhoven
warmenhoven marked this pull request as ready for review August 5, 2026 02:17

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

Nearly there, thank you for your patience. Got a few more changes I'd like you to make.

case int():
return handle
case _:
return handle.value or 0

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.

Use libretro.api._utils.address instead, please.

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

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.

Where in mupen64plus-next does this happen?


# 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] = []

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 this really have to be global?

)[0]

assert isinstance(interface, retro_hw_render_context_negotiation_interface_vulkan)
self._negotiation = interface

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 make this a deepcopy of the argument so that cores don't have to keep an interface struct alive. (And to protect against accidental modification.)

if not create_info_ptr:
return 0

info = ctypes.cast(create_info_ptr, POINTER(_VkDeviceCreateInfo))[0]

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.

Use TypedPointer instead of plain POINTER, please. (Not just here, everywhere in the file.) This way the type checker can catch mistakes.

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

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.

Cast cmd to TypedPointer[VkCommandBuffer], please.

self._interface = retro_hw_render_interface_vulkan(
interface_type=HardwareRenderInterfaceType.VULKAN,
interface_version=RETRO_HW_RENDER_INTERFACE_VULKAN_VERSION,
handle=None,

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 like it if handle was id(self), so that the driver can verify that the core passes in the handle pointer like it's supposed to.

Comment on lines 1266 to +1269

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)

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.

If I got the type wrong, then maybe it would be best to retype interface as TypedPointer[TypedPointer[retro_hw_render_interface]].

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