From 8c0df4b371133aa25767a90037ab60d16c19390a Mon Sep 17 00:00:00 2001
From: ParantezTech <12572227+par274@users.noreply.github.com>
Date: Tue, 28 Jul 2026 01:05:42 +0300
Subject: [PATCH] [video] added sdl window and host display plumbing
---
src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs | 16 +-
.../Gpu/Metal/MetalGuestGpuBackend.cs | 2 +-
src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs | 91 +-
.../Gpu/Metal/MetalVideoPresenter.cs | 470 +----
.../Gpu/Vulkan/VulkanGuestGpuBackend.cs | 6 +-
.../VideoOut/HostDisplayCatalog.cs | 129 ++
.../VideoOut/HostVideoOptions.cs | 68 +
.../VideoOut/RenderPhaseProfile.cs | 252 +++
src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs | 832 ++++++++
src/SharpEmu.Libs/VideoOut/VideoOutExports.cs | 78 +-
.../VideoOut/VulkanHostSurface.cs | 270 ---
.../VideoOut/VulkanPipelineCacheStorage.cs | 96 +
.../VideoOut/VulkanVideoPresenter.cs | 1879 ++++++++++-------
.../SpirvFixedShaders.cs | 161 +-
.../VideoOut/HostVideoOptionsTests.cs | 29 +
.../VideoOut/VideoOutPixelFormatTests.cs | 18 +
.../VulkanPipelineCacheStorageTests.cs | 74 +
17 files changed, 2925 insertions(+), 1546 deletions(-)
create mode 100644 src/SharpEmu.Libs/VideoOut/HostDisplayCatalog.cs
create mode 100644 src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs
create mode 100644 src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs
create mode 100644 src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs
delete mode 100644 src/SharpEmu.Libs/VideoOut/VulkanHostSurface.cs
create mode 100644 src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs
create mode 100644 tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs
create mode 100644 tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheStorageTests.cs
diff --git a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
index 818621eb..16142a06 100644
--- a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
@@ -237,7 +237,21 @@ internal interface IGuestGpuBackend
void SubmitGuestImageFill(ulong address, uint fillValue);
- void SubmitGuestImageWrite(ulong address, byte[] pixels);
+ ///
+ /// Uploads guest-authored pixels into a live guest image.
+ /// is the first image row the buffer covers, so a caller that knows only part
+ /// of the surface changed can send that band instead of the whole thing; the
+ /// untouched rows on the host already hold the same bytes.
+ ///
+ void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0);
+
+ ///
+ /// Whether a non-zero rowOffset is honoured. Backends that cannot
+ /// upload a sub-range must report false so callers keep sending the whole
+ /// surface: a dropped band would leave the host copy stale, which is worse
+ /// than an oversized upload.
+ ///
+ bool SupportsPartialImageWrite => false;
///
/// Asks the presenter to refresh CPU-dirty guest images on its render/present
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
index cffef1ff..e60eafc4 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
@@ -398,7 +398,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
MetalVideoPresenter.SubmitGuestImageFill(address, fillValue);
- public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
+ public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs
index 2afb7f10..15ca01e3 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalNative.cs
@@ -5,19 +5,6 @@ using System.Runtime.InteropServices;
namespace SharpEmu.Libs.Gpu.Metal;
-// Core Graphics / Metal ABI structs passed by value through objc_msgSend. Struct
-// *returns* are deliberately never used: on x86-64 (this process runs under Rosetta
-// on Apple silicon) large struct returns switch to objc_msgSend_stret, and avoiding
-// them entirely keeps one calling convention everywhere.
-[StructLayout(LayoutKind.Sequential)]
-internal struct CGRect
-{
- public double X;
- public double Y;
- public double Width;
- public double Height;
-}
-
[StructLayout(LayoutKind.Sequential)]
internal struct CGSize
{
@@ -93,31 +80,21 @@ internal struct MtlViewport
}
///
-/// Objective-C runtime access for the Metal presenter: AppKit, QuartzCore, and Metal
+/// Objective-C runtime access for the Metal presenter: QuartzCore and Metal
/// through objc_msgSend, with one LibraryImport overload per distinct native
/// signature. Dependency-free by design — this plus the OS frameworks is the entire
/// Metal path, which is what keeps it NativeAOT-clean.
///
internal static partial class MetalNative
{
- private const string CoreFoundation =
- "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
-
- [LibraryImport(CoreFoundation)]
- public static partial nint CFRunLoopGetMain();
-
- [LibraryImport(CoreFoundation)]
- public static partial void CFRunLoopStop(nint runLoop);
-
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
- private const string AppKitFramework = "/System/Library/Frameworks/AppKit.framework/AppKit";
private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
private static bool _frameworksLoaded;
///
- /// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is
+ /// Makes QuartzCore classes visible to objc_getClass; Metal is
/// pulled in by its own LibraryImport. Call once before any Class() lookup.
///
public static void EnsureFrameworksLoaded()
@@ -127,7 +104,6 @@ internal static partial class MetalNative
return;
}
- NativeLibrary.Load(AppKitFramework);
NativeLibrary.Load(QuartzCoreFramework);
_frameworksLoaded = true;
}
@@ -141,16 +117,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint sel_registerName(string name);
- [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
- public static partial nint objc_allocateClassPair(nint superclass, string name, nuint extraBytes);
-
- [LibraryImport(ObjCLibrary)]
- public static partial void objc_registerClassPair(nint cls);
-
- [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
- [return: MarshalAs(UnmanagedType.I1)]
- public static partial bool class_addMethod(nint cls, nint name, nint imp, string types);
-
[LibraryImport(ObjCLibrary)]
public static partial nint objc_autoreleasePoolPush();
@@ -169,14 +135,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument);
-
- /// objc_msgSend for a CGRect-returning selector (e.g. -bounds).
- /// A 32-byte struct is returned via the x86-64 stret ABI — a hidden
- /// pointer to caller storage passed ahead of self/_cmd — so this must not
- /// be folded into the plain objc_msgSend overloads.
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")]
- public static partial void SendStretRect(out CGRect result, nint receiver, nint selector);
-
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
@@ -209,17 +167,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
- /// performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
- /// to perform is itself an argument, followed by the object and the wait
- /// flag.
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial void SendVoidPerformSelector(
- nint receiver,
- nint selector,
- nint performedSelector,
- nint argument,
- [MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
-
/// setSwizzle: on MTLTextureDescriptor. Four one-byte
/// MTLTextureSwizzle values, passed packed like the framework expects.
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
@@ -237,9 +184,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size);
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial void SendVoidRect(nint receiver, nint selector, CGRect rect);
-
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
@@ -328,37 +272,6 @@ internal static partial class MetalNative
nuint indexBufferOffset,
nuint instanceCount);
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial nint SendTimer(
- nint receiver,
- nint selector,
- double interval,
- nint target,
- nint timerSelector,
- nint userInfo,
- [MarshalAs(UnmanagedType.I1)] bool repeats);
-
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial nint SendInitFrame(nint receiver, nint selector, CGRect frame);
-
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial nint SendInitWindow(
- nint receiver,
- nint selector,
- CGRect contentRect,
- nuint styleMask,
- nuint backing,
- [MarshalAs(UnmanagedType.I1)] bool defer);
-
- [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
- public static partial nint SendNextEvent(
- nint receiver,
- nint selector,
- ulong eventMask,
- nint untilDate,
- nint inMode,
- [MarshalAs(UnmanagedType.I1)] bool dequeue);
-
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTextureDescriptor(
nint receiver,
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs
index ee9f8095..d0f2fee3 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.cs
@@ -1,8 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut;
using SharpEmu.ShaderCompiler;
@@ -11,28 +9,12 @@ using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal;
///
-/// The Metal presenter: an AppKit window hosting a CAMetalLayer. A CADisplayLink
-/// requested from the content view drives in sync with the
-/// display refresh, on the main run loop — the loop whose Core Animation observer
-/// commits presented drawables to the window server, so it must be a real running
-/// run loop (a hand-pumped event drain never fires that observer and the window
-/// stays black). Everything AppKit runs on the process main thread via
-/// (AppKit traps off-main), which the CLI parks for us.
+/// The Metal presenter uses SDL for its host window, events and input, then renders
+/// into the CAMetalLayer owned by SDL's Metal view. Windowing and input therefore
+/// share the Vulkan path while all Metal command encoding remains native.
///
internal static partial class MetalVideoPresenter
{
- private const uint DefaultWindowWidth = 1280;
- private const uint DefaultWindowHeight = 720;
-
- // NSWindow style: Titled | Closable | Miniaturizable | Resizable. Resizable
- // both lets the user drag the window edges and turns the green zoom button
- // into the full-screen toggle (paired with the collection behavior below).
- private const nuint WindowStyleMask = 1 | 2 | 4 | 8;
-
- // NSWindowCollectionBehaviorFullScreenPrimary: opt this window into native
- // full-screen, so the green button enters full-screen rather than zooming.
- private const nuint FullScreenPrimaryBehavior = 1 << 7;
- private const nuint BackingStoreBuffered = 2;
private const nuint PixelFormatBgra8Unorm = (nuint)MtlPixelFormat.Bgra8Unorm;
private const nuint LoadActionLoad = 1;
private const nuint LoadActionClear = 2;
@@ -69,17 +51,14 @@ internal static partial class MetalVideoPresenter
private static readonly byte[] _overlayPixels =
new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4];
- // Presenter objects and per-frame present state, shared between window setup
- // and the display-link RenderFrame callback (both on the main thread).
+ // Presenter objects and per-frame present state, all used on the SDL host thread.
private static nint _device;
private static nint _commandQueue;
private static nint _metalLayer;
private static nint _presentPipeline;
private static nint _presentSampler;
- private static nint _window;
- private static nint _application;
- private static nint _renderTimer;
- private static nint _renderTimerTarget;
+ private static SdlHostWindow? _hostWindow;
+ private static HostVideoOptions _videoOptions = HostVideoOptions.Default;
private static double _drawableWidth;
private static double _drawableHeight;
private static nint _frameTexture;
@@ -91,10 +70,23 @@ internal static partial class MetalVideoPresenter
private static nint _ownedVersionTexture;
private static ulong _presentGuestAddress;
private static long _presentedSequence = -1;
- private static bool _userClosed;
private static uint _windowWidth;
private static uint _windowHeight;
+ public static bool TryConfigureVideo(HostVideoOptions options)
+ {
+ lock (_gate)
+ {
+ if (_thread is not null)
+ {
+ return false;
+ }
+
+ _videoOptions = options.Normalize();
+ return true;
+ }
+ }
+
public static void EnsureStarted(uint width, uint height)
{
if (width == 0 || height == 0)
@@ -183,6 +175,7 @@ internal static partial class MetalVideoPresenter
public static void RequestClose()
{
Volatile.Write(ref _closeRequested, true);
+ Volatile.Read(ref _hostWindow)?.Close();
}
private static void StartPresenterLocked()
@@ -247,33 +240,23 @@ internal static partial class MetalVideoPresenter
VideoOutExports.SetSelectedGpuName(deviceName);
}
- // Fixed window like the Vulkan presenter: guest frames letterbox into
- // it. Sizing the window from the guest's display mode (4K) exceeds the
- // screen — macOS clamps the window while the layer keeps the requested
- // geometry, leaving the visible region showing nothing but clear.
- const uint width = DefaultWindowWidth;
- const uint height = DefaultWindowHeight;
+ HostVideoOptions videoOptions;
+ lock (_gate)
+ {
+ videoOptions = _videoOptions;
+ }
+ using var hostWindow = new SdlHostWindow(
+ VideoOutExports.GetWindowTitle(),
+ videoOptions,
+ SdlGraphicsApi.Metal,
+ ToggleMetalPerformanceHud);
+ Volatile.Write(ref _hostWindow, hostWindow);
var setupPool = MetalNative.objc_autoreleasePoolPush();
try
{
- _application = MetalNative.Send(
- MetalNative.Class("NSApplication"), MetalNative.Selector("sharedApplication"));
- // NSApplicationActivationPolicyRegular: dock icon + key window like any app.
- MetalNative.Send(_application, MetalNative.Selector("setActivationPolicy:"), 0);
- MetalNative.SendVoid(_application, MetalNative.Selector("finishLaunching"));
-
- _window = CreateWindow(width, height);
-
- // Swap in the key-capturing view before the metal layer attaches so
- // the layer lands on the input-aware content view.
- var keyView = MetalNative.SendInitFrame(
- MetalNative.Send(CreateKeyViewClass(), MetalNative.Selector("alloc")),
- MetalNative.Selector("initWithFrame:"),
- new CGRect { X = 0, Y = 0, Width = width, Height = height });
- MetalNative.SendVoid(_window, MetalNative.Selector("setContentView:"), keyView);
-
- _metalLayer = CreateLayer(_device, _window, out _drawableWidth, out _drawableHeight);
+ _metalLayer = hostWindow.CreateMetalLayer();
+ ConfigureMetalLayer(_device, _metalLayer);
_commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue"));
if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError))
{
@@ -282,31 +265,7 @@ internal static partial class MetalVideoPresenter
}
_presentSampler = CreateLinearSampler(_device);
-
- MetalNative.SendVoid(_window, MetalNative.Selector("makeKeyAndOrderFront:"), 0);
- MetalNative.SendVoidBool(
- _application, MetalNative.Selector("activateIgnoringOtherApps:"), true);
- MetalNative.SendVoid(_window, MetalNative.Selector("makeFirstResponder:"), keyView);
- MetalHostInput.Attach();
-
- // A repeating NSTimer on this (main) run loop fires onFrame: at the
- // display rate. CADisplayLink (NSView.displayLinkWithTarget:selector:)
- // is the natural choice but its callback never fires under the x86-64
- // Rosetta process this emulator runs as — proven in isolation against
- // a bare AppKit harness, where a timer fires and composites and the
- // display link does not. nextDrawable still throttles presentation to
- // the display, so the timer only needs to keep up, not pace precisely.
- _renderTimerTarget = CreateRenderTimerTarget();
- _renderTimer = MetalNative.Send(
- MetalNative.SendTimer(
- MetalNative.Class("NSTimer"),
- MetalNative.Selector("scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:"),
- 1.0 / 60.0,
- _renderTimerTarget,
- MetalNative.Selector("onFrame:"),
- 0,
- repeats: true),
- MetalNative.Selector("retain"));
+ SyncDrawableSizeToLayer();
}
finally
{
@@ -314,25 +273,20 @@ internal static partial class MetalVideoPresenter
}
Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut presenter started.");
-
- // [NSApp run] runs the main run loop (its Core Animation observer commits
- // presented drawables to the window server) AND fully activates the app,
- // which a bare CFRunLoopRun does not — the CADisplayLink is only serviced
- // once the app is running, and NSApp dispatches window events itself.
- // Returns once RenderFrame stops it.
- MetalNative.SendVoid(_application, MetalNative.Selector("run"));
-
- var closePool = MetalNative.objc_autoreleasePoolPush();
try
{
- MetalNative.SendVoid(_window, MetalNative.Selector("close"));
+ hostWindow.Run(
+ static () => { },
+ static _ => RenderFrameSafely(),
+ static () => { });
}
finally
{
- MetalNative.objc_autoreleasePoolPop(closePool);
+ Volatile.Write(ref _hostWindow, null);
+ _metalLayer = 0;
}
- if (_userClosed)
+ if (hostWindow.ClosedByUser)
{
Console.Error.WriteLine(
"[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown.");
@@ -340,115 +294,8 @@ internal static partial class MetalVideoPresenter
}
}
- ///
- /// An NSView subclass that records key events for pad emulation. Overriding
- /// keyDown:/keyUp: (instead of an event monitor) needs no ObjC blocks, and
- /// swallowing the events also silences the system alert beep AppKit plays
- /// for unhandled keys. Registered once per process.
- ///
- private static unsafe nint CreateKeyViewClass()
- {
- var cls = MetalNative.objc_allocateClassPair(
- MetalNative.Class("NSView"), "SharpEmuMetalView", 0);
- if (cls == 0)
- {
- return MetalNative.Class("SharpEmuMetalView");
- }
-
- var keyDown = (nint)(delegate* unmanaged[Cdecl])&OnKeyDown;
- MetalNative.class_addMethod(cls, MetalNative.Selector("keyDown:"), keyDown, "v@:@");
- var keyUp = (nint)(delegate* unmanaged[Cdecl])&OnKeyUp;
- MetalNative.class_addMethod(cls, MetalNative.Selector("keyUp:"), keyUp, "v@:@");
- // Command-modified keys never reach keyDown: — AppKit routes them through
- // performKeyEquivalent:, so Cmd+F1 (Metal Performance HUD) hooks in here.
- var keyEquivalent = (nint)(delegate* unmanaged[Cdecl])&OnPerformKeyEquivalent;
- MetalNative.class_addMethod(
- cls, MetalNative.Selector("performKeyEquivalent:"), keyEquivalent, "c@:@");
- // First responder status is what routes key events to this view.
- var accepts = (nint)(delegate* unmanaged[Cdecl])&AcceptsFirstResponder;
- MetalNative.class_addMethod(
- cls, MetalNative.Selector("acceptsFirstResponder"), accepts, "c@:");
- MetalNative.objc_registerClassPair(cls);
- return cls;
- }
-
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static void OnKeyDown(nint self, nint cmd, nint nsEvent)
- {
- try
- {
- var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
- var isRepeat = MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat"));
-
- // Function keys can arrive here even with Command held (AppKit only
- // reroutes some chords through the key-equivalent path), so catch
- // Cmd+F1 in both places — and keep it away from MetalHostInput so it
- // never toggles the plain-F1 perf overlay.
- var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags"));
- if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0)
- {
- if (!isRepeat)
- {
- ToggleMetalPerformanceHud();
- }
-
- return;
- }
-
- MetalHostInput.KeyDown(keyCode, isRepeat);
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] Metal key-down handler failed: {exception.Message}");
- }
- }
-
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static void OnKeyUp(nint self, nint cmd, nint nsEvent)
- {
- try
- {
- var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
- MetalHostInput.KeyUp(keyCode);
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] Metal key-up handler failed: {exception.Message}");
- }
- }
-
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static byte AcceptsFirstResponder(nint self, nint cmd) => 1;
-
- private const ushort KeyCodeF1 = 0x7A;
- private const ulong NsEventModifierFlagCommand = 1UL << 20;
private static bool _metalHudVisible;
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static byte OnPerformKeyEquivalent(nint self, nint cmd, nint nsEvent)
- {
- try
- {
- var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
- var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags"));
- if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0)
- {
- if (!MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat")))
- {
- ToggleMetalPerformanceHud();
- }
-
- return 1; // handled: no system beep, no further routing
- }
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] Metal key-equivalent handler failed: {exception.Message}");
- }
-
- return 0;
- }
-
///
/// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps
/// the built-in CPU-rasterized perf overlay). Configured per Apple's
@@ -456,7 +303,7 @@ internal static partial class MetalVideoPresenter
/// mode=default|disabled and logging=default, plus any MTL_HUD_* environment
/// keys directly in the dictionary — all three HUD flags (enabled, per-frame
/// logging, shader-compile logging) ride in one property set. Runs on the
- /// AppKit main thread (the key-equivalent path), same thread as the render loop.
+ /// SDL host thread, the same thread as the render loop.
///
private static void ToggleMetalPerformanceHud()
{
@@ -508,33 +355,13 @@ internal static partial class MetalVideoPresenter
$"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1).");
}
- private static unsafe nint CreateRenderTimerTarget()
+ /// The SDL host loop drains guest work continuously. The method
+ /// remains as the enqueue-side hook used by guest image synchronization.
+ internal static void ScheduleGuestWorkDrain()
{
- // A minimal NSObject subclass whose onFrame: is our unmanaged callback —
- // the dependency-free way to hand a target/selector to NSTimer without a
- // binding library. Registered once per process.
- var cls = MetalNative.objc_allocateClassPair(
- MetalNative.Class("NSObject"), "SharpEmuRenderTimerTarget", 0);
- if (cls != 0)
- {
- var imp = (nint)(delegate* unmanaged[Cdecl])&OnRenderTimer;
- // "v@:@": void return, self, _cmd, one object argument (the timer).
- MetalNative.class_addMethod(cls, MetalNative.Selector("onFrame:"), imp, "v@:@");
- var wakeImp = (nint)(delegate* unmanaged[Cdecl])&OnGuestWorkWake;
- MetalNative.class_addMethod(cls, MetalNative.Selector("onGuestWork:"), wakeImp, "v@:@");
- MetalNative.objc_registerClassPair(cls);
- }
- else
- {
- cls = MetalNative.Class("SharpEmuRenderTimerTarget");
- }
-
- return MetalNative.Send(
- MetalNative.Send(cls, MetalNative.Selector("alloc")), MetalNative.Selector("init"));
}
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static void OnRenderTimer(nint self, nint cmd, nint timer)
+ private static void RenderFrameSafely()
{
try
{
@@ -546,78 +373,13 @@ internal static partial class MetalVideoPresenter
}
}
- /// Set while an onGuestWork: wake is scheduled on the main run
- /// loop; coalesces enqueue-side wake requests to one in-flight message.
- private static int _guestWorkWakeScheduled;
-
- /// Wakes the main run loop to drain guest work now instead of at
- /// the next render tick. Guest submit→wait round-trips (release-mem labels,
- /// CPU-visible write-backs) otherwise cost a full frame interval each —
- /// games that chain several per frame crawl at a fraction of the display
- /// rate. Safe from any thread; no-op until the presenter starts.
- internal static void ScheduleGuestWorkDrain()
- {
- if (Interlocked.CompareExchange(ref _guestWorkWakeScheduled, 1, 0) != 0)
- {
- return;
- }
-
- var target = _renderTimerTarget;
- if (target == 0)
- {
- Volatile.Write(ref _guestWorkWakeScheduled, 0);
- return;
- }
-
- MetalNative.SendVoidPerformSelector(
- target,
- MetalNative.Selector("performSelectorOnMainThread:withObject:waitUntilDone:"),
- MetalNative.Selector("onGuestWork:"),
- 0,
- waitUntilDone: false);
- }
-
- [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
- private static void OnGuestWorkWake(nint self, nint cmd, nint argument)
- {
- Volatile.Write(ref _guestWorkWakeScheduled, 0);
- if (_device == 0 || _commandQueue == 0 || Volatile.Read(ref _closeRequested))
- {
- return;
- }
-
- var pool = MetalNative.objc_autoreleasePoolPush();
- try
- {
- DrainGuestWork(_device, _commandQueue);
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][ERROR] Metal guest work wake failed: {exception}");
- }
- finally
- {
- MetalNative.objc_autoreleasePoolPop(pool);
- }
- }
-
private static void RenderFrame()
{
- MetalHostInput.PumpAutoKeys();
var pool = MetalNative.objc_autoreleasePoolPush();
try
{
- // NSApp.run dispatches window events itself, so there is no manual
- // event drain here.
- var visible = MetalNative.SendBool(_window, MetalNative.Selector("isVisible"));
- if (Volatile.Read(ref _closeRequested) || !visible)
+ if (Volatile.Read(ref _closeRequested))
{
- _userClosed = !visible && !Volatile.Read(ref _closeRequested);
- MetalNative.SendVoid(_renderTimer, MetalNative.Selector("invalidate"));
- // Stop both the AppKit loop and the underlying CFRunLoop so
- // [NSApp run] returns.
- MetalNative.SendVoid(_application, MetalNative.Selector("stop:"), 0);
- MetalNative.CFRunLoopStop(MetalNative.CFRunLoopGetMain());
return;
}
@@ -715,8 +477,7 @@ internal static partial class MetalVideoPresenter
if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal))
{
_lastWindowTitle = title;
- MetalNative.SendVoid(
- _window, MetalNative.Selector("setTitle:"), MetalNative.NsString(title));
+ Volatile.Read(ref _hostWindow)?.SetTitle(title);
}
}
@@ -725,8 +486,20 @@ internal static partial class MetalVideoPresenter
// for a drawable, or nextDrawable keeps handing back the original
// resolution and Core Animation stretches it (blurry, mis-scaled
// overlay). No-op when the size is unchanged, i.e. almost every tick.
+ var hostWindow = Volatile.Read(ref _hostWindow);
+ if (hostWindow?.ConsumeSurfaceRestore() == true)
+ {
+ _drawableWidth = 0;
+ _drawableHeight = 0;
+ }
+
SyncDrawableSizeToLayer();
+ if (hostWindow?.IsMinimized == true)
+ {
+ return;
+ }
+
var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable"));
if (drawable == 0)
{
@@ -846,43 +619,33 @@ internal static partial class MetalVideoPresenter
3);
}
- private static nint CreateWindow(uint width, uint height)
+ private static void ConfigureMetalLayer(nint device, nint layer)
{
- var window = MetalNative.SendInitWindow(
- MetalNative.Send(MetalNative.Class("NSWindow"), MetalNative.Selector("alloc")),
- MetalNative.Selector("initWithContentRect:styleMask:backing:defer:"),
- new CGRect { X = 0, Y = 0, Width = width, Height = height },
- WindowStyleMask,
- BackingStoreBuffered,
- defer: false);
- // The presenter owns the handle; AppKit must not free it on user close.
- MetalNative.SendVoidBool(window, MetalNative.Selector("setReleasedWhenClosed:"), false);
- MetalNative.Send(
- window, MetalNative.Selector("setCollectionBehavior:"), (nint)FullScreenPrimaryBehavior);
- MetalNative.SendVoid(
- window,
- MetalNative.Selector("setTitle:"),
- MetalNative.NsString(VideoOutExports.GetWindowTitle()));
- MetalNative.SendVoid(window, MetalNative.Selector("center"));
- // makeKeyAndOrderFront happens after the metal layer is attached.
- return window;
+ MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
+ MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
+ MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
+ MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
+
+ var setDisplaySync = MetalNative.Selector("setDisplaySyncEnabled:");
+ if (MetalNative.SendBool(layer, MetalNative.Selector("respondsToSelector:"), setDisplaySync))
+ {
+ MetalNative.SendVoidBool(layer, setDisplaySync, _videoOptions.VSync);
+ }
}
- /// Keeps the CAMetalLayer's drawable size (pixels) matched to its
- /// current bounds (points) × scale as the window resizes or moves between
- /// displays. CAMetalLayer never updates drawableSize on its own, even as a
- /// view's backing layer, so the render loop drives it.
+ /// Keeps the SDL-owned CAMetalLayer drawable in host pixels as the
+ /// window resizes or moves between displays with different scale factors.
private static void SyncDrawableSizeToLayer()
{
- MetalNative.SendStretRect(out var bounds, _metalLayer, MetalNative.Selector("bounds"));
- var scale = MetalNative.SendDouble(_metalLayer, MetalNative.Selector("contentsScale"));
- if (scale <= 0)
+ var hostWindow = Volatile.Read(ref _hostWindow);
+ if (hostWindow is null || _metalLayer == 0)
{
- scale = 1;
+ return;
}
- var width = Math.Max(1, Math.Round(bounds.Width * scale));
- var height = Math.Max(1, Math.Round(bounds.Height * scale));
+ var pixelSize = hostWindow.PixelSize;
+ var width = (double)pixelSize.Width;
+ var height = (double)pixelSize.Height;
if (width == _drawableWidth && height == _drawableHeight)
{
return;
@@ -896,57 +659,6 @@ internal static partial class MetalVideoPresenter
new CGSize { Width = width, Height = height });
}
- private static nint CreateLayer(nint device, nint window, out double drawableWidth, out double drawableHeight)
- {
- var contentView = MetalNative.Send(window, MetalNative.Selector("contentView"));
- var scale = MetalNative.SendDouble(window, MetalNative.Selector("backingScaleFactor"));
- if (scale <= 0)
- {
- scale = 1;
- }
-
- const uint width = DefaultWindowWidth;
- const uint height = DefaultWindowHeight;
- drawableWidth = width * scale;
- drawableHeight = height * scale;
-
- var layer = MetalNative.Send(
- MetalNative.Send(MetalNative.Class("CAMetalLayer"), MetalNative.Selector("alloc")),
- MetalNative.Selector("init"));
- MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
- MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
- // A Core Animation layer composites with its alpha channel by default, so
- // a presented frame whose guest alpha is zero would show through as the
- // window background (black). The presenter output is a finished opaque
- // frame; mark the layer opaque so alpha never reaches the compositor.
- MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
- MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
- MetalNative.SendVoidDouble(layer, MetalNative.Selector("setContentsScale:"), scale);
- MetalNative.SendVoidSize(
- layer,
- MetalNative.Selector("setDrawableSize:"),
- new CGSize { Width = drawableWidth, Height = drawableHeight });
-
- // A manually created layer defaults to a zero-size frame, and a hosted
- // layer's geometry is the caller's job: without this the presenter
- // happily presents every drawable into a layer with no on-screen
- // extent — a permanently black window.
- MetalNative.SendVoidRect(
- layer,
- MetalNative.Selector("setFrame:"),
- new CGRect { X = 0, Y = 0, Width = width, Height = height });
-
- // wantsLayer FIRST, then the layer: that makes the metal layer the
- // view's AppKit-managed BACKING layer (geometry and window-server
- // commits handled by AppKit) — the SDL/GLFW pattern. The reverse order
- // creates a layer-hosting view whose tree the app must commit itself,
- // which never composites under a manually pumped run loop.
- MetalNative.SendVoidBool(contentView, MetalNative.Selector("setWantsLayer:"), true);
- MetalNative.SendVoid(contentView, MetalNative.Selector("setLayer:"), layer);
- MetalNative.SendVoid(MetalNative.Class("CATransaction"), MetalNative.Selector("flush"));
- return layer;
- }
-
private static bool TryCreatePresentPipeline(nint device, out nint pipeline, out string error)
{
pipeline = 0;
@@ -1105,10 +817,24 @@ internal static partial class MetalVideoPresenter
{
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
- // Aspect-fit letterbox: scale the frame into the drawable via the viewport.
- var scale = Math.Min(drawableWidth / frameWidth, drawableHeight / frameHeight);
- var viewportWidth = frameWidth * scale;
- var viewportHeight = frameHeight * scale;
+ var scaleX = drawableWidth / frameWidth;
+ var scaleY = drawableHeight / frameHeight;
+ var viewportWidth = drawableWidth;
+ var viewportHeight = drawableHeight;
+ if (_videoOptions.ScalingMode != HostScalingMode.Stretch)
+ {
+ var scale = _videoOptions.ScalingMode == HostScalingMode.Cover
+ ? Math.Max(scaleX, scaleY)
+ : Math.Min(scaleX, scaleY);
+ if (_videoOptions.ScalingMode == HostScalingMode.Integer && scale >= 1)
+ {
+ scale = Math.Floor(scale);
+ }
+
+ viewportWidth = frameWidth * scale;
+ viewportHeight = frameHeight * scale;
+ }
+
MetalNative.SendVoidViewport(
encoder,
MetalNative.Selector("setViewport:"),
diff --git a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
index fbe46938..d8973905 100644
--- a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
@@ -378,8 +378,10 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
- public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
- VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
+ public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
+ VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels, rowOffset);
+
+ public bool SupportsPartialImageWrite => true;
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
diff --git a/src/SharpEmu.Libs/VideoOut/HostDisplayCatalog.cs b/src/SharpEmu.Libs/VideoOut/HostDisplayCatalog.cs
new file mode 100644
index 00000000..6c755ecc
--- /dev/null
+++ b/src/SharpEmu.Libs/VideoOut/HostDisplayCatalog.cs
@@ -0,0 +1,129 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SDL;
+using static SDL.SDL3;
+
+namespace SharpEmu.Libs.VideoOut;
+
+public sealed record HostDisplayMode(int Width, int Height, int RefreshRate);
+
+public sealed record HostDisplayInfo(
+ int Index,
+ string Name,
+ IReadOnlyList Modes);
+
+public static unsafe class HostDisplayCatalog
+{
+ private const SDL_InitFlags VideoFlag = SDL_InitFlags.SDL_INIT_VIDEO;
+ private static int _queryFailureLogged;
+
+ public static IReadOnlyList Query()
+ {
+ var initializedHere = false;
+ var videoReady = false;
+ try
+ {
+ initializedHere = (SDL_WasInit(VideoFlag) & VideoFlag) == 0;
+ if (initializedHere && !SDL_InitSubSystem(VideoFlag))
+ {
+ LogQueryFailure(SDL_GetError() ?? "unknown SDL error");
+ return CreateFallback();
+ }
+ videoReady = true;
+
+ using var displays = SDL_GetDisplays();
+ if (displays is null || displays.Count == 0)
+ {
+ LogQueryFailure("SDL reported no displays");
+ return CreateFallback();
+ }
+
+ var result = new List(displays.Count);
+ for (var index = 0; index < displays.Count; index++)
+ {
+ var display = displays[index];
+ var name = SDL_GetDisplayName(display);
+ var modes = ReadModes(display);
+ result.Add(new HostDisplayInfo(
+ index,
+ string.IsNullOrWhiteSpace(name) ? $"Display {index + 1}" : name,
+ modes));
+ }
+
+ return result;
+ }
+ catch (Exception exception)
+ {
+ LogQueryFailure(exception.Message);
+ return CreateFallback();
+ }
+ finally
+ {
+ if (initializedHere && videoReady)
+ {
+ SDL_QuitSubSystem(VideoFlag);
+ }
+ }
+ }
+
+ private static IReadOnlyList ReadModes(SDL_DisplayID display)
+ {
+ var modes = new HashSet();
+ using (var fullscreenModes = SDL_GetFullscreenDisplayModes(display))
+ {
+ if (fullscreenModes is not null)
+ {
+ for (var index = 0; index < fullscreenModes.Count; index++)
+ {
+ var mode = fullscreenModes[index];
+ AddMode(modes, &mode);
+ }
+ }
+ }
+
+ AddMode(modes, SDL_GetDesktopDisplayMode(display));
+ if (modes.Count == 0)
+ {
+ return CreateFallbackModes();
+ }
+
+ return modes
+ .OrderByDescending(mode => (long)mode.Width * mode.Height)
+ .ThenByDescending(mode => mode.Width)
+ .ThenByDescending(mode => mode.RefreshRate)
+ .ToArray();
+ }
+
+ private static void AddMode(HashSet modes, SDL_DisplayMode* mode)
+ {
+ if (mode is null || mode->w <= 0 || mode->h <= 0)
+ {
+ return;
+ }
+
+ var refreshRate = mode->refresh_rate > 0
+ ? Math.Max(1, (int)Math.Round(mode->refresh_rate, MidpointRounding.AwayFromZero))
+ : 0;
+ modes.Add(new HostDisplayMode(mode->w, mode->h, refreshRate));
+ }
+
+ private static IReadOnlyList CreateFallback() =>
+ [new HostDisplayInfo(0, "Display 1", CreateFallbackModes())];
+
+ private static IReadOnlyList CreateFallbackModes() =>
+ [
+ new HostDisplayMode(3840, 2160, 60),
+ new HostDisplayMode(2560, 1440, 60),
+ new HostDisplayMode(1920, 1080, 60),
+ new HostDisplayMode(1280, 720, 60),
+ ];
+
+ private static void LogQueryFailure(string message)
+ {
+ if (Interlocked.Exchange(ref _queryFailureLogged, 1) == 0)
+ {
+ Console.Error.WriteLine($"[GUI][WARN] SDL display query failed: {message}");
+ }
+ }
+}
diff --git a/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs
new file mode 100644
index 00000000..0511d541
--- /dev/null
+++ b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs
@@ -0,0 +1,68 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.Libs.VideoOut;
+
+using SharpEmu.Libs.Gpu.Metal;
+
+public enum HostWindowMode
+{
+ Windowed,
+ Borderless,
+ ExclusiveFullscreen,
+}
+
+public enum HostScalingMode
+{
+ Fit,
+ Cover,
+ Stretch,
+ Integer,
+}
+
+public enum HostHdrMode
+{
+ Auto,
+ On,
+ Off,
+}
+
+public sealed record HostVideoOptions
+{
+ public static HostVideoOptions Default { get; } = new();
+
+ public HostWindowMode WindowMode { get; init; } = HostWindowMode.Windowed;
+
+ public HostScalingMode ScalingMode { get; init; } = HostScalingMode.Fit;
+
+ public int Width { get; init; } = 1920;
+
+ public int Height { get; init; } = 1080;
+
+ public int DisplayIndex { get; init; }
+
+ public int RefreshRate { get; init; }
+
+ public bool VSync { get; init; } = true;
+
+ public HostHdrMode HdrMode { get; init; } = HostHdrMode.Auto;
+
+ public HostVideoOptions Normalize() => this with
+ {
+ Width = Math.Clamp(Width, 640, 16384),
+ Height = Math.Clamp(Height, 360, 16384),
+ DisplayIndex = Math.Max(0, DisplayIndex),
+ RefreshRate = Math.Clamp(RefreshRate, 0, 1000),
+ HdrMode = Enum.IsDefined(HdrMode) ? HdrMode : HostHdrMode.Auto,
+ };
+}
+
+public static class HostVideoHost
+{
+ public static bool TryConfigureVideo(HostVideoOptions options)
+ {
+ var normalized = options.Normalize();
+ return VulkanVideoPresenter.TryConfigureVideo(normalized) &
+ MetalVideoPresenter.TryConfigureVideo(normalized);
+ }
+}
diff --git a/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs b/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs
new file mode 100644
index 00000000..2f2fb5b9
--- /dev/null
+++ b/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs
@@ -0,0 +1,252 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Diagnostics;
+
+namespace SharpEmu.Libs.VideoOut;
+
+///
+/// Self-time accounting for the render thread, enabled with
+/// SHARPEMU_PROFILE_RENDER=1. The existing videoout counters report how much
+/// work was done (draws, pipelines, SPIR-V) but not where the render thread's
+/// second went, which is the number that decides whether a low frame rate is
+/// the emulator recording commands, the GPU executing them, or neither.
+///
+/// Scopes nest: entering a phase suspends the enclosing one and resumes it on
+/// dispose, so a inside
+/// is never counted twice.
+///
+internal static class RenderPhaseProfile
+{
+ internal enum Phase
+ {
+ /// Outside any measured phase — loop overhead.
+ Unattributed = 0,
+ /// Parked because no guest work and no newer flip exist.
+ Idle,
+ /// Blocked on the frame slot's fence: the GPU is behind.
+ FrameSlotWait,
+ /// Reaping completed guest submissions (fence polls).
+ Collect,
+ Evict,
+ /// Dequeuing the next guest work item.
+ TakeWork,
+ /// Building the diagnostic label for a work item.
+ Describe,
+ /// Publishing a work item's completion to its waiters.
+ CompleteWork,
+ /// Selecting the presentation to show this iteration.
+ TakePresentation,
+ Draw,
+ Compute,
+ ColorClear,
+ ImageWrite,
+ OrderedAction,
+ Flip,
+ /// Closing and submitting the batched guest command buffer.
+ Flush,
+ /// vkQueueSubmit itself.
+ QueueSubmit,
+ /// vkAcquireNextImageKHR.
+ Acquire,
+ /// Recording + submitting the presentation command buffer.
+ Present,
+ /// vkQueuePresentKHR.
+ QueuePresent,
+ Count,
+ }
+
+ public static readonly bool Enabled = string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_RENDER"),
+ "1",
+ StringComparison.Ordinal);
+
+ ///
+ /// Breaks down the CPU-visible actions which are deliberately serialized
+ /// behind guest GPU work. This stays opt-in because it is diagnostic data,
+ /// not a normal render-thread cost.
+ ///
+ public static readonly bool OrderedActionDetailsEnabled = string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_ORDERED_ACTION"),
+ "1",
+ StringComparison.Ordinal);
+
+ private static readonly double _reportSeconds =
+ double.TryParse(
+ Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_RENDER_REPORT_S"),
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var seconds) && seconds > 0
+ ? seconds
+ : 5.0;
+
+ private static readonly long[] _ticks = new long[(int)Phase.Count];
+ private static readonly long[] _entries = new long[(int)Phase.Count];
+ private static long _frames;
+ private static long _windowStart = Stopwatch.GetTimestamp();
+ private static readonly Dictionary _orderedActions =
+ new(StringComparer.Ordinal);
+
+ // The render loop is single-threaded, so plain fields are enough and keep
+ // the per-scope cost to two timestamp reads.
+ [ThreadStatic] private static Phase _current;
+ [ThreadStatic] private static long _lastTimestamp;
+
+ internal readonly ref struct Scope
+ {
+ private readonly Phase _previous;
+ private readonly bool _active;
+
+ internal Scope(Phase previous)
+ {
+ _previous = previous;
+ _active = true;
+ }
+
+ public void Dispose()
+ {
+ if (!_active)
+ {
+ return;
+ }
+
+ Charge(_previous);
+ }
+ }
+
+ public static Scope Measure(Phase phase)
+ {
+ if (!Enabled)
+ {
+ return default;
+ }
+
+ var previous = Charge(phase);
+ _entries[(int)phase]++;
+ return new Scope(previous);
+ }
+
+ public static void RecordOrderedAction(string debugName, bool completed)
+ {
+ if (!OrderedActionDetailsEnabled)
+ {
+ return;
+ }
+
+ var category = GetOrderedActionCategory(debugName);
+ ref var stats = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(
+ _orderedActions,
+ category,
+ out _);
+ stats.Executed += completed ? 1 : 0;
+ stats.Deferred += completed ? 0 : 1;
+ }
+
+ ///
+ /// Closes out the running phase and switches to ,
+ /// returning the phase that was running.
+ ///
+ private static Phase Charge(Phase next)
+ {
+ var now = Stopwatch.GetTimestamp();
+ var previous = _current;
+ if (_lastTimestamp != 0)
+ {
+ _ticks[(int)previous] += now - _lastTimestamp;
+ }
+
+ _lastTimestamp = now;
+ _current = next;
+ return previous;
+ }
+
+ /// Called once per presented frame; also drives the report.
+ public static void RecordFrame()
+ {
+ if (!Enabled)
+ {
+ return;
+ }
+
+ _frames++;
+ var now = Stopwatch.GetTimestamp();
+ var elapsedTicks = now - _windowStart;
+ if (elapsedTicks < _reportSeconds * Stopwatch.Frequency)
+ {
+ return;
+ }
+
+ _windowStart = now;
+ var seconds = elapsedTicks / (double)Stopwatch.Frequency;
+ var frames = _frames;
+ _frames = 0;
+
+ var parts = new List<(Phase Phase, double Percent, long Entries)>((int)Phase.Count);
+ var accounted = 0L;
+ for (var index = 0; index < (int)Phase.Count; index++)
+ {
+ var phaseTicks = _ticks[index];
+ _ticks[index] = 0;
+ var entries = _entries[index];
+ _entries[index] = 0;
+ accounted += phaseTicks;
+ if (phaseTicks <= 0)
+ {
+ continue;
+ }
+
+ parts.Add(((Phase)index, phaseTicks * 100.0 / elapsedTicks, entries));
+ }
+
+ parts.Sort(static (left, right) => right.Percent.CompareTo(left.Percent));
+ Console.Error.WriteLine(
+ $"[PERF][RENDER] {seconds:F1}s fps={frames / seconds:F1} " +
+ $"covered={accounted * 100.0 / elapsedTicks:F0}% " +
+ string.Join(
+ " ",
+ parts.Select(part =>
+ $"{part.Phase}={part.Percent:F1}%" +
+ (part.Entries > 0 ? $"/n{part.Entries}" : string.Empty))));
+
+ if (OrderedActionDetailsEnabled && _orderedActions.Count != 0)
+ {
+ var ordered = _orderedActions
+ .OrderByDescending(static pair => pair.Value.Executed + pair.Value.Deferred)
+ .Take(12)
+ .Select(static pair =>
+ $"{pair.Key}=ok{pair.Value.Executed}/defer{pair.Value.Deferred}");
+ Console.Error.WriteLine($"[PERF][ORDERED] {string.Join(" ", ordered)}");
+ _orderedActions.Clear();
+ }
+ }
+
+ private static string GetOrderedActionCategory(string debugName)
+ {
+ if (debugName.EndsWith(" completion", StringComparison.Ordinal))
+ {
+ return "completion";
+ }
+
+ var firstSpace = debugName.IndexOf(' ');
+ if (firstSpace < 0)
+ {
+ return debugName;
+ }
+
+ // AGC labels conventionally begin with "agc ". Keeping the
+ // packet token separates DMA, submit and register traffic without
+ // retaining guest addresses in the diagnostic key.
+ if (debugName.StartsWith("agc ", StringComparison.Ordinal))
+ {
+ var secondSpace = debugName.IndexOf(' ', firstSpace + 1);
+ return secondSpace < 0 ? debugName : debugName[..secondSpace];
+ }
+
+ return debugName[..firstSpace];
+ }
+
+ private struct OrderedActionStats
+ {
+ public long Executed;
+ public long Deferred;
+ }
+}
diff --git a/src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs b/src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs
new file mode 100644
index 00000000..5cfa46a0
--- /dev/null
+++ b/src/SharpEmu.Libs/VideoOut/SdlHostWindow.cs
@@ -0,0 +1,832 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using SharpEmu.HLE.Host;
+using SharpEmu.Libs.Pad;
+using SDL;
+using Silk.NET.Vulkan;
+using static SDL.SDL3;
+
+namespace SharpEmu.Libs.VideoOut;
+
+internal enum SdlGraphicsApi
+{
+ Vulkan,
+ Metal,
+}
+
+internal readonly record struct SdlHdrState(
+ bool Enabled,
+ float SdrWhiteLevel,
+ float Headroom);
+
+internal sealed unsafe class SdlHostWindow : IDisposable, IHostGamepadOutput
+{
+ private const SDL_InitFlags InitFlags = SDL_InitFlags.SDL_INIT_VIDEO | SDL_InitFlags.SDL_INIT_GAMEPAD;
+ private static readonly long CursorHideDelayTicks = 2 * Stopwatch.Frequency;
+ private const uint OutputDurationMs = 5_000;
+
+ private readonly HostVideoOptions _options;
+ private readonly SdlGraphicsApi _graphicsApi;
+ private readonly Action? _toggleBackendHud;
+ private readonly object _gamepadGate = new();
+ private SDL_Window* _window;
+ private nint _metalView;
+ private SDL_Gamepad* _gamepad;
+ private HostGamepadType _gamepadType;
+ private byte _leftTriggerRumble;
+ private byte _rightTriggerRumble;
+ private int _closeRequested;
+ private bool _closedByUser;
+ private bool _fullscreen;
+ private bool _focused = true;
+ private bool _cursorVisible = true;
+ private long _cursorHideDeadline;
+ private bool _surfaceRestorePending;
+ private bool _hdrStateChangePending;
+ private bool _disposed;
+
+ public SdlHostWindow(
+ string title,
+ HostVideoOptions options,
+ SdlGraphicsApi graphicsApi,
+ Action? toggleBackendHud = null)
+ {
+ _options = options.Normalize();
+ _graphicsApi = graphicsApi;
+ _toggleBackendHud = toggleBackendHud;
+ SdlGamepadStateReader.EnableSonyHidApi();
+ if (!SDL_InitSubSystem(InitFlags))
+ {
+ throw new InvalidOperationException($"SDL video initialization failed: {GetError()}");
+ }
+
+ var graphicsFlag = graphicsApi == SdlGraphicsApi.Vulkan
+ ? SDL_WindowFlags.SDL_WINDOW_VULKAN
+ : SDL_WindowFlags.SDL_WINDOW_METAL;
+ var flags = graphicsFlag |
+ SDL_WindowFlags.SDL_WINDOW_RESIZABLE |
+ SDL_WindowFlags.SDL_WINDOW_HIGH_PIXEL_DENSITY |
+ SDL_WindowFlags.SDL_WINDOW_HIDDEN;
+ _window = CreateWindow(title, _options.Width, _options.Height, flags);
+ if (_window is null)
+ {
+ SDL_QuitSubSystem(InitFlags);
+ throw new InvalidOperationException($"SDL window creation failed: {GetError()}");
+ }
+
+ MoveToConfiguredDisplay();
+ ApplyConfiguredMode(_options.WindowMode);
+ SetIcon();
+ SDL_ShowWindow(_window);
+ SDL_RaiseWindow(_window);
+ if (_fullscreen && !SDL_SyncWindow(_window))
+ {
+ Console.Error.WriteLine($"[LOADER][WARN] SDL initial fullscreen sync failed: {GetError()}");
+ }
+ HostWindowInput.Connect(this);
+ OpenFirstGamepad();
+
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] SDL3 window ready: mode={_options.WindowMode} " +
+ $"size={_options.Width}x{_options.Height} display={_options.DisplayIndex} " +
+ $"refresh={(_options.RefreshRate == 0 ? "auto" : _options.RefreshRate)}");
+ var hdr = HdrState;
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] SDL3 display HDR: enabled={hdr.Enabled} " +
+ $"sdr_white={hdr.SdrWhiteLevel:F3} headroom={hdr.Headroom:F3}");
+ }
+
+ public (int Width, int Height) PixelSize
+ {
+ get
+ {
+ var width = 0;
+ var height = 0;
+ if (_window is not null)
+ {
+ SDL_GetWindowSizeInPixels(_window, &width, &height);
+ }
+
+ return (Math.Max(width, 1), Math.Max(height, 1));
+ }
+ }
+
+ public bool IsMinimized =>
+ _window is not null &&
+ (SDL_GetWindowFlags(_window) & SDL_WindowFlags.SDL_WINDOW_MINIMIZED) != 0;
+
+ public bool ClosedByUser => _closedByUser;
+
+ public SdlHdrState HdrState
+ {
+ get
+ {
+ if (_window is null)
+ {
+ return default;
+ }
+
+ var properties = SDL_GetWindowProperties(_window);
+ fixed (byte* hdrEnabledName = SDL_PROP_WINDOW_HDR_ENABLED_BOOLEAN)
+ fixed (byte* sdrWhiteName = SDL_PROP_WINDOW_SDR_WHITE_LEVEL_FLOAT)
+ fixed (byte* headroomName = SDL_PROP_WINDOW_HDR_HEADROOM_FLOAT)
+ {
+ return new SdlHdrState(
+ (bool)SDL_GetBooleanProperty(properties, hdrEnabledName, false),
+ SDL_GetFloatProperty(properties, sdrWhiteName, 1f),
+ SDL_GetFloatProperty(properties, headroomName, 1f));
+ }
+ }
+ }
+
+ public bool ConsumeSurfaceRestore()
+ {
+ var pending = _surfaceRestorePending;
+ _surfaceRestorePending = false;
+ return pending;
+ }
+
+ public bool ConsumeHdrStateChange()
+ {
+ var pending = _hdrStateChangePending;
+ _hdrStateChangePending = false;
+ return pending;
+ }
+
+ public byte** GetRequiredVulkanInstanceExtensions(out uint count)
+ {
+ EnsureGraphicsApi(SdlGraphicsApi.Vulkan);
+ uint extensionCount = 0;
+ var extensions = SDL_Vulkan_GetInstanceExtensions(&extensionCount);
+ count = extensionCount;
+ if (extensions is null || count == 0)
+ {
+ throw new InvalidOperationException($"SDL did not provide Vulkan instance extensions: {GetError()}");
+ }
+
+ return extensions;
+ }
+
+ public SurfaceKHR CreateVulkanSurface(Instance instance)
+ {
+ EnsureGraphicsApi(SdlGraphicsApi.Vulkan);
+ VkSurfaceKHR_T* surface = null;
+ if (!SDL_Vulkan_CreateSurface(
+ _window,
+ (VkInstance_T*)instance.Handle,
+ null,
+ &surface) || surface is null)
+ {
+ throw new InvalidOperationException($"SDL Vulkan surface creation failed: {GetError()}");
+ }
+
+ return new SurfaceKHR(unchecked((ulong)surface));
+ }
+
+ public nint CreateMetalLayer()
+ {
+ EnsureGraphicsApi(SdlGraphicsApi.Metal);
+ if (_metalView == 0)
+ {
+ _metalView = SDL_Metal_CreateView(_window);
+ if (_metalView == 0)
+ {
+ throw new InvalidOperationException($"SDL Metal view creation failed: {GetError()}");
+ }
+ }
+
+ var layer = SDL_Metal_GetLayer(_metalView);
+ if (layer == 0)
+ {
+ throw new InvalidOperationException($"SDL Metal layer lookup failed: {GetError()}");
+ }
+
+ return layer;
+ }
+
+ public void SetTitle(string title)
+ {
+ if (_window is null)
+ {
+ return;
+ }
+
+ var utf8 = Marshal.StringToCoTaskMemUTF8(title);
+ try
+ {
+ SDL_SetWindowTitle(_window, (byte*)utf8);
+ }
+ finally
+ {
+ Marshal.FreeCoTaskMem(utf8);
+ }
+ }
+
+ public void Close() => Volatile.Write(ref _closeRequested, 1);
+
+ public void SetRumble(byte largeMotor, byte smallMotor)
+ {
+ lock (_gamepadGate)
+ {
+ if (IsGamepadConnected())
+ {
+ SDL_RumbleGamepad(
+ _gamepad,
+ ExpandByte(largeMotor),
+ ExpandByte(smallMotor),
+ OutputDurationMs);
+ }
+ }
+ }
+
+ public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
+ {
+ lock (_gamepadGate)
+ {
+ if (IsGamepadConnected())
+ {
+ if (leftTrigger is { } left)
+ {
+ _leftTriggerRumble = left;
+ }
+ if (rightTrigger is { } right)
+ {
+ _rightTriggerRumble = right;
+ }
+
+ SDL_RumbleGamepadTriggers(
+ _gamepad,
+ ExpandByte(_leftTriggerRumble),
+ ExpandByte(_rightTriggerRumble),
+ OutputDurationMs);
+ }
+ }
+ }
+
+ public void SetAdaptiveTriggerEffect(
+ HostAdaptiveTriggerEffect? leftTrigger,
+ HostAdaptiveTriggerEffect? rightTrigger)
+ {
+ lock (_gamepadGate)
+ {
+ if (!IsGamepadConnected())
+ {
+ return;
+ }
+
+ if (_gamepadType != HostGamepadType.DualSense)
+ {
+ if (leftTrigger is { } fallbackLeft)
+ {
+ _leftTriggerRumble = fallbackLeft.FallbackStrength;
+ }
+ if (rightTrigger is { } fallbackRight)
+ {
+ _rightTriggerRumble = fallbackRight.FallbackStrength;
+ }
+
+ SDL_RumbleGamepadTriggers(
+ _gamepad,
+ ExpandByte(_leftTriggerRumble),
+ ExpandByte(_rightTriggerRumble),
+ OutputDurationMs);
+ return;
+ }
+
+ Span state = stackalloc byte[47];
+ state.Clear();
+ if (rightTrigger is { } nativeRight)
+ {
+ state[0] |= 0x04;
+ nativeRight.CopyTo(state[10..21]);
+ }
+ if (leftTrigger is { } nativeLeft)
+ {
+ state[0] |= 0x08;
+ nativeLeft.CopyTo(state[21..32]);
+ }
+
+ if (state[0] == 0)
+ {
+ return;
+ }
+
+ fixed (byte* data = state)
+ {
+ SDL_SendGamepadEffect(_gamepad, (nint)data, state.Length);
+ }
+ }
+ }
+
+ public void SetLightbar(byte red, byte green, byte blue)
+ {
+ lock (_gamepadGate)
+ {
+ if (IsGamepadConnected())
+ {
+ SDL_SetGamepadLED(_gamepad, red, green, blue);
+ }
+ }
+ }
+
+ public void ResetLightbar() => SetLightbar(0, 0, 64);
+
+ public void Run(
+ Action initialize,
+ Action render,
+ Action closing,
+ Action? idle = null)
+ {
+ initialize();
+ var timer = Stopwatch.StartNew();
+ var last = timer.Elapsed.TotalSeconds;
+ try
+ {
+ while (Volatile.Read(ref _closeRequested) == 0)
+ {
+ PumpEvents();
+ if (Volatile.Read(ref _closeRequested) != 0)
+ {
+ break;
+ }
+
+ UpdateCursorAutoHide();
+ SampleGamepad();
+ var now = timer.Elapsed.TotalSeconds;
+ render(now - last);
+ last = now;
+ if (IsMinimized)
+ {
+ SDL_Delay(10);
+ }
+ else
+ {
+ idle?.Invoke();
+ }
+ }
+ }
+ finally
+ {
+ closing();
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ SDL_ShowCursor();
+ HostWindowInput.Disconnect();
+ CloseGamepad();
+ if (_metalView != 0)
+ {
+ SDL_Metal_DestroyView(_metalView);
+ _metalView = 0;
+ }
+
+ if (_window is not null)
+ {
+ SDL_DestroyWindow(_window);
+ _window = null;
+ }
+
+ SDL_QuitSubSystem(InitFlags);
+ }
+
+ private void PumpEvents()
+ {
+ SDL_Event windowEvent;
+ while (SDL_PollEvent(&windowEvent))
+ {
+ switch (windowEvent.Type)
+ {
+ case SDL_EventType.SDL_EVENT_QUIT:
+ case SDL_EventType.SDL_EVENT_WINDOW_CLOSE_REQUESTED:
+ _closedByUser = true;
+ Volatile.Write(ref _closeRequested, 1);
+ break;
+ case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_GAINED:
+ _focused = true;
+ UpdateCursorVisibility();
+ HostWindowInput.SetFocused(true);
+ break;
+ case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_LOST:
+ _focused = false;
+ UpdateCursorVisibility();
+ HostWindowInput.SetFocused(false);
+ break;
+ case SDL_EventType.SDL_EVENT_WINDOW_MINIMIZED:
+ _focused = false;
+ UpdateCursorVisibility();
+ HostWindowInput.SetFocused(false);
+ break;
+ case SDL_EventType.SDL_EVENT_WINDOW_RESTORED:
+ case SDL_EventType.SDL_EVENT_WINDOW_RESIZED:
+ case SDL_EventType.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:
+ case SDL_EventType.SDL_EVENT_WINDOW_MAXIMIZED:
+ case SDL_EventType.SDL_EVENT_WINDOW_ENTER_FULLSCREEN:
+ case SDL_EventType.SDL_EVENT_WINDOW_LEAVE_FULLSCREEN:
+ case SDL_EventType.SDL_EVENT_WINDOW_EXPOSED:
+ case SDL_EventType.SDL_EVENT_WINDOW_DISPLAY_CHANGED:
+ case SDL_EventType.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED:
+ _surfaceRestorePending = true;
+ break;
+ case SDL_EventType.SDL_EVENT_WINDOW_HDR_STATE_CHANGED:
+ _hdrStateChangePending = true;
+ break;
+ case SDL_EventType.SDL_EVENT_KEY_DOWN:
+ case SDL_EventType.SDL_EVENT_KEY_UP:
+ HandleKey(windowEvent.key);
+ break;
+ case SDL_EventType.SDL_EVENT_MOUSE_MOTION:
+ ShowCursorTemporarily();
+ break;
+ case SDL_EventType.SDL_EVENT_MOUSE_BUTTON_DOWN:
+ ShowCursorTemporarily();
+ if (windowEvent.button.button == 1 && windowEvent.button.clicks == 2)
+ {
+ ToggleFullscreen();
+ }
+ break;
+ case SDL_EventType.SDL_EVENT_GAMEPAD_ADDED:
+ if (_gamepad is null)
+ {
+ OpenFirstGamepad();
+ }
+ break;
+ case SDL_EventType.SDL_EVENT_GAMEPAD_REMOVED:
+ if (_gamepad is not null && !SDL_GamepadConnected(_gamepad))
+ {
+ CloseGamepad();
+ OpenFirstGamepad();
+ }
+ break;
+ }
+ }
+ }
+
+ private void HandleKey(SDL_KeyboardEvent keyEvent)
+ {
+ var down = keyEvent.type == SDL_EventType.SDL_EVENT_KEY_DOWN;
+ if (down && !keyEvent.repeat)
+ {
+ if (keyEvent.key == SDL_Keycode.SDLK_F1 &&
+ (keyEvent.mod & SDL_Keymod.SDL_KMOD_GUI) != 0 &&
+ _toggleBackendHud is not null)
+ {
+ _toggleBackendHud();
+ }
+ else if (keyEvent.key == SDL_Keycode.SDLK_F1)
+ {
+ PerfOverlay.Toggle();
+ }
+ else if (keyEvent.key == SDL_Keycode.SDLK_F11)
+ {
+ ToggleFullscreen();
+ }
+ }
+
+ if (TryMapVirtualKey(keyEvent.key, out var virtualKey))
+ {
+ HostWindowInput.SetKey(virtualKey, down);
+ }
+ }
+
+ private void ToggleFullscreen()
+ {
+ if (_fullscreen)
+ {
+ SDL_SetWindowFullscreen(_window, false);
+ SDL_SetWindowFullscreenMode(_window, null);
+ SDL_SetWindowResizable(_window, true);
+ SDL_SetWindowSize(_window, _options.Width, _options.Height);
+ MoveToConfiguredDisplay();
+ _fullscreen = false;
+ UpdateCursorVisibility();
+ return;
+ }
+
+ ApplyConfiguredMode(
+ _options.WindowMode == HostWindowMode.ExclusiveFullscreen
+ ? HostWindowMode.ExclusiveFullscreen
+ : HostWindowMode.Borderless);
+ }
+
+ private void ApplyConfiguredMode(HostWindowMode mode)
+ {
+ if (mode == HostWindowMode.Windowed)
+ {
+ _fullscreen = false;
+ UpdateCursorVisibility();
+ return;
+ }
+
+ if (mode == HostWindowMode.ExclusiveFullscreen)
+ {
+ var display = GetConfiguredDisplay();
+ SDL_DisplayMode closest;
+ if (SDL_GetClosestFullscreenDisplayMode(
+ display,
+ _options.Width,
+ _options.Height,
+ _options.RefreshRate,
+ true,
+ &closest))
+ {
+ SDL_SetWindowFullscreenMode(_window, &closest);
+ }
+ else
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] SDL exclusive mode unavailable; using borderless: {GetError()}");
+ SDL_SetWindowFullscreenMode(_window, null);
+ }
+ }
+ else
+ {
+ SDL_SetWindowFullscreenMode(_window, null);
+ }
+
+ SDL_SetWindowFullscreen(_window, true);
+ _fullscreen = true;
+ UpdateCursorVisibility();
+ }
+
+ private void UpdateCursorVisibility()
+ {
+ _cursorHideDeadline = 0;
+ SetCursorVisible(!_fullscreen || !_focused);
+ }
+
+ private void ShowCursorTemporarily()
+ {
+ if (!_fullscreen || !_focused)
+ {
+ return;
+ }
+
+ SetCursorVisible(true);
+ _cursorHideDeadline = Stopwatch.GetTimestamp() + CursorHideDelayTicks;
+ }
+
+ private void UpdateCursorAutoHide()
+ {
+ if (!_fullscreen || !_focused || !_cursorVisible || _cursorHideDeadline == 0 ||
+ Stopwatch.GetTimestamp() < _cursorHideDeadline)
+ {
+ return;
+ }
+
+ _cursorHideDeadline = 0;
+ SetCursorVisible(false);
+ }
+
+ private void SetCursorVisible(bool visible)
+ {
+ if (_cursorVisible == visible)
+ {
+ return;
+ }
+
+ if (visible)
+ {
+ SDL_ShowCursor();
+ }
+ else
+ {
+ SDL_HideCursor();
+ }
+
+ _cursorVisible = visible;
+ }
+
+ private void MoveToConfiguredDisplay()
+ {
+ var display = GetConfiguredDisplay();
+ SDL_Rect bounds;
+ if (SDL_GetDisplayBounds(display, &bounds))
+ {
+ var x = bounds.x + Math.Max(0, (bounds.w - _options.Width) / 2);
+ var y = bounds.y + Math.Max(0, (bounds.h - _options.Height) / 2);
+ SDL_SetWindowPosition(_window, x, y);
+ }
+ }
+
+ private SDL_DisplayID GetConfiguredDisplay()
+ {
+ using var displays = SDL_GetDisplays();
+ if (displays is null || displays.Count == 0)
+ {
+ return SDL_GetPrimaryDisplay();
+ }
+
+ return displays[Math.Clamp(_options.DisplayIndex, 0, displays.Count - 1)];
+ }
+
+ private void OpenFirstGamepad()
+ {
+ lock (_gamepadGate)
+ {
+ _gamepad = SdlGamepadStateReader.OpenPreferredGamepad();
+ if (_gamepad is null)
+ {
+ return;
+ }
+
+ _gamepadType = SdlGamepadStateReader.MapGamepadType(SDL_GetRealGamepadType(_gamepad));
+ EnableSensor(SDL_SensorType.SDL_SENSOR_ACCEL);
+ EnableSensor(SDL_SensorType.SDL_SENSOR_GYRO);
+ }
+
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] SDL gamepad connected: {DescribeGamepad()} " +
+ $"type={_gamepadType} connection={SdlGamepadStateReader.GetConnection(_gamepad)}");
+ SampleGamepad();
+ }
+
+ private void CloseGamepad()
+ {
+ lock (_gamepadGate)
+ {
+ if (_gamepad is not null)
+ {
+ SDL_CloseGamepad(_gamepad);
+ _gamepad = null;
+ }
+
+ _gamepadType = HostGamepadType.Generic;
+ _leftTriggerRumble = 0;
+ _rightTriggerRumble = 0;
+ }
+
+ HostWindowInput.ClearGamepad();
+ }
+
+ private void SampleGamepad()
+ {
+ lock (_gamepadGate)
+ {
+ if (!IsGamepadConnected())
+ {
+ HostWindowInput.ClearGamepad();
+ return;
+ }
+
+ SDL_UpdateGamepads();
+ var state = SdlGamepadStateReader.Read(_gamepad) with
+ {
+ Motion = ReadMotion(),
+ Touch = ReadTouch(),
+ };
+ HostWindowInput.SetGamepad(
+ DescribeGamepad(),
+ state);
+ }
+ }
+
+ private void EnableSensor(SDL_SensorType sensor)
+ {
+ if (SDL_GamepadHasSensor(_gamepad, sensor))
+ {
+ SDL_SetGamepadSensorEnabled(_gamepad, sensor, true);
+ }
+ }
+
+ private HostMotionState ReadMotion()
+ {
+ Span acceleration = stackalloc float[3];
+ Span angularVelocity = stackalloc float[3];
+ acceleration.Clear();
+ angularVelocity.Clear();
+ var hasAcceleration = false;
+ var hasAngularVelocity = false;
+ fixed (float* data = acceleration)
+ {
+ hasAcceleration = SDL_GamepadHasSensor(_gamepad, SDL_SensorType.SDL_SENSOR_ACCEL) &&
+ SDL_GetGamepadSensorData(_gamepad, SDL_SensorType.SDL_SENSOR_ACCEL, data, acceleration.Length);
+ }
+ fixed (float* data = angularVelocity)
+ {
+ hasAngularVelocity = SDL_GamepadHasSensor(_gamepad, SDL_SensorType.SDL_SENSOR_GYRO) &&
+ SDL_GetGamepadSensorData(_gamepad, SDL_SensorType.SDL_SENSOR_GYRO, data, angularVelocity.Length);
+ }
+
+ return new HostMotionState(
+ hasAcceleration || hasAngularVelocity,
+ acceleration[0],
+ acceleration[1],
+ acceleration[2],
+ angularVelocity[0],
+ angularVelocity[1],
+ angularVelocity[2]);
+ }
+
+ private HostTouchState ReadTouch()
+ {
+ if (SDL_GetNumGamepadTouchpads(_gamepad) <= 0)
+ {
+ return default;
+ }
+
+ return new HostTouchState(ReadTouchPoint(0), ReadTouchPoint(1));
+ }
+
+ private HostTouchPoint ReadTouchPoint(int finger)
+ {
+ if (SDL_GetNumGamepadTouchpadFingers(_gamepad, 0) <= finger)
+ {
+ return default;
+ }
+
+ SDLBool down = false;
+ float x = 0;
+ float y = 0;
+ float pressure = 0;
+ if (!SDL_GetGamepadTouchpadFinger(_gamepad, 0, finger, &down, &x, &y, &pressure))
+ {
+ return default;
+ }
+
+ return new HostTouchPoint(down, (byte)finger, Math.Clamp(x, 0, 1), Math.Clamp(y, 0, 1));
+ }
+
+ private bool IsGamepadConnected() => _gamepad is not null && SDL_GamepadConnected(_gamepad);
+
+ private string DescribeGamepad() =>
+ Marshal.PtrToStringUTF8((nint)Unsafe_SDL_GetGamepadName(_gamepad)) ?? "SDL gamepad";
+
+ private static ushort ExpandByte(byte value) => (ushort)(value * 257);
+
+ private static bool TryMapVirtualKey(SDL_Keycode key, out int virtualKey)
+ {
+ virtualKey = key switch
+ {
+ SDL_Keycode.SDLK_BACKSPACE => 0x08,
+ SDL_Keycode.SDLK_TAB => 0x09,
+ SDL_Keycode.SDLK_RETURN => 0x0D,
+ SDL_Keycode.SDLK_ESCAPE => 0x1B,
+ SDL_Keycode.SDLK_LEFT => 0x25,
+ SDL_Keycode.SDLK_UP => 0x26,
+ SDL_Keycode.SDLK_RIGHT => 0x27,
+ SDL_Keycode.SDLK_DOWN => 0x28,
+ >= SDL_Keycode.SDLK_A and <= SDL_Keycode.SDLK_Z => 0x41 + (int)(key - SDL_Keycode.SDLK_A),
+ _ => 0,
+ };
+ return virtualKey != 0;
+ }
+
+ private void SetIcon()
+ {
+ if (!PngSplashLoader.TryLoadIcon(out var pixels, out var width, out var height))
+ {
+ return;
+ }
+
+ fixed (byte* data = pixels)
+ {
+ var surface = SDL_CreateSurfaceFrom(
+ (int)width,
+ (int)height,
+ SDL_PixelFormat.SDL_PIXELFORMAT_ABGR8888,
+ (nint)data,
+ checked((int)width * 4));
+ if (surface is not null)
+ {
+ SDL_SetWindowIcon(_window, surface);
+ SDL_DestroySurface(surface);
+ }
+ }
+ }
+
+ private static SDL_Window* CreateWindow(string title, int width, int height, SDL_WindowFlags flags)
+ {
+ var utf8 = Marshal.StringToCoTaskMemUTF8(title);
+ try
+ {
+ return SDL_CreateWindow((byte*)utf8, width, height, flags);
+ }
+ finally
+ {
+ Marshal.FreeCoTaskMem(utf8);
+ }
+ }
+
+ private static string GetError() =>
+ Marshal.PtrToStringUTF8((nint)Unsafe_SDL_GetError()) ?? "unknown SDL error";
+
+ private void EnsureGraphicsApi(SdlGraphicsApi expected)
+ {
+ if (_graphicsApi != expected)
+ {
+ throw new InvalidOperationException(
+ $"SDL host window uses {_graphicsApi}, not {expected}.");
+ }
+ }
+}
diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
index 290d9bd7..1fb386fb 100644
--- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
+++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
+using SharpEmu.Logging;
using SharpEmu.HLE.Host;
using SharpEmu.Libs.Diagnostics;
using SharpEmu.Libs.Gpu;
@@ -69,11 +70,14 @@ public static class VideoOutExports
private static readonly Dictionary _ports = new();
private static int _presentationWindowCloseNotified;
private static int _vblankStopRequested;
+ private static int _hdrOutputRequested;
private static readonly Dictionary<(int Handle, int BufferIndex, ulong Address), ulong> _lastFrameFingerprints = new();
private static int _nextHandle = 1;
private static int _frameDumpCount;
private static long _nextFrameDumpIndex;
- private static string _windowTitle = "SharpEmu VideoOut";
+ private static string _applicationWindowTitle = "VideoOut";
+ private static string _selectedGpuName = string.Empty;
+ private static string _applicationTitleId = "UNKNOWN";
private static readonly bool _logFrameRate = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"),
"1",
@@ -113,7 +117,18 @@ public static class VideoOutExports
var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}";
lock (_stateGate)
{
- _windowTitle = $"SharpEmu - {application}{versionSuffix}";
+ _applicationTitleId = string.IsNullOrWhiteSpace(titleId)
+ ? "UNKNOWN"
+ : titleId.Trim();
+ _applicationWindowTitle = $"{application}{versionSuffix}";
+ }
+ }
+
+ internal static string GetApplicationTitleId()
+ {
+ lock (_stateGate)
+ {
+ return _applicationTitleId;
}
}
@@ -121,7 +136,10 @@ public static class VideoOutExports
{
lock (_stateGate)
{
- return _windowTitle;
+ var gpuSuffix = string.IsNullOrWhiteSpace(_selectedGpuName)
+ ? string.Empty
+ : $" · {_selectedGpuName}";
+ return $"SharpEmu · {BuildInfo.CommitSha ?? "dev"} - {_applicationWindowTitle}{gpuSuffix}";
}
}
@@ -139,7 +157,7 @@ public static class VideoOutExports
: string.Empty;
lock (_stateGate)
{
- _windowTitle = $"{_windowTitle} · {gpuName.Trim()}{backendSuffix}";
+ _selectedGpuName = $"{gpuName.Trim()}{backendSuffix}";
}
}
@@ -166,30 +184,17 @@ public static class VideoOutExports
private static void RequestHostShutdown(string reason)
{
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
- var embedded = VulkanVideoHost.IsEmbedded;
AudioOutExports.ShutdownAllPorts();
Interlocked.Exchange(ref _vblankStopRequested, 1);
HostSessionControl.RequestShutdown(reason);
+ GuestGpu.Current.RequestClose();
- // A hosted game can still be issuing AGC work after it requests its
- // own shutdown. Keep the presenter's resources alive until the GUI
- // session reaches its guest-safe exit path and disposes the host
- // surface.
- if (!embedded)
+ // Give guest and GPU threads a bounded window to leave cooperatively.
+ ThreadPool.QueueUserWorkItem(static _ =>
{
- GuestGpu.Current.RequestClose();
- }
-
- // The embedded GUI owns the process lifetime. A guest shutdown should
- // end only that session rather than terminating the launcher itself.
- if (!embedded)
- {
- ThreadPool.QueueUserWorkItem(static _ =>
- {
- Thread.Sleep(2000);
- Environment.Exit(0);
- });
- }
+ Thread.Sleep(2000);
+ Environment.Exit(0);
+ });
}
private sealed class VideoOutPortState
@@ -871,6 +876,8 @@ public static class VideoOutExports
}
}
+ internal static bool IsHdrOutputRequested => Volatile.Read(ref _hdrOutputRequested) != 0;
+
[SysAbiExport(
Nid = "MTxxrOCeSig",
ExportName = "sceVideoOutSetWindowModeMargins",
@@ -1175,16 +1182,21 @@ public static class VideoOutExports
var guestImageSubmitted = false;
ulong guestImageAddress = 0;
- if (submitGpuImage &&
- bufferIndex >= 0 &&
+ if (bufferIndex >= 0 &&
TryGetDisplayBufferInfo(handle, bufferIndex, out var displayBuffer))
{
+ Interlocked.Exchange(
+ ref _hdrOutputRequested,
+ IsHdrPixelFormat(displayBuffer.PixelFormat) ? 1 : 0);
guestImageAddress = displayBuffer.Address;
- guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
- displayBuffer.Address,
- displayBuffer.Width,
- displayBuffer.Height,
- displayBuffer.PitchInPixel);
+ if (submitGpuImage)
+ {
+ guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
+ displayBuffer.Address,
+ displayBuffer.Width,
+ displayBuffer.Height,
+ displayBuffer.PitchInPixel);
+ }
}
if (_dumpVideoOut)
@@ -1711,6 +1723,12 @@ public static class VideoOutExports
internal static bool IsPacked10BitPixelFormat(ulong pixelFormat) =>
IsPacked10BitPixelFormatNormalized(NormalizePixelFormat(pixelFormat));
+ internal static bool IsHdrPixelFormat(ulong pixelFormat) =>
+ NormalizePixelFormat(pixelFormat) is
+ SceVideoOutPixelFormatA2R10G10B10Bt2020Pq or
+ SceVideoOutPixelFormat2R10G10B10A2Bt2100Pq or
+ SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq;
+
private static bool IsPacked10BitPixelFormatNormalized(ulong pixelFormat) =>
pixelFormat is
SceVideoOutPixelFormatA2R10G10B10 or
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanHostSurface.cs b/src/SharpEmu.Libs/VideoOut/VulkanHostSurface.cs
deleted file mode 100644
index 311af154..00000000
--- a/src/SharpEmu.Libs/VideoOut/VulkanHostSurface.cs
+++ /dev/null
@@ -1,270 +0,0 @@
-// Copyright (C) 2026 SharpEmu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-namespace SharpEmu.Libs.VideoOut;
-
-///
-/// A native child surface owned by a host UI. The presenter consumes this
-/// directly, avoiding a second top-level GLFW window when the GUI is active.
-///
-public enum VulkanHostSurfaceKind
-{
- Win32,
- Xlib,
- Metal,
-}
-
-///
-/// Platform-native handles required to create a Vulkan presentation surface.
-/// The GUI owns their lifetime and updates the physical pixel size on resize.
-///
-public sealed class VulkanHostSurface : IDisposable
-{
- private int _pixelWidth;
- private int _pixelHeight;
- private int _resizeGeneration;
- private readonly bool _ownsDisplay;
- private readonly bool _pollNativeSize;
- private long _nextNativeSizePoll;
-
- public VulkanHostSurface(
- VulkanHostSurfaceKind kind,
- nint windowHandle,
- nint displayHandle = 0,
- nint metalLayerHandle = 0,
- bool ownsDisplay = false,
- bool pollNativeSize = false)
- {
- Kind = kind;
- WindowHandle = windowHandle;
- DisplayHandle = displayHandle;
- MetalLayerHandle = metalLayerHandle;
- _ownsDisplay = ownsDisplay;
- _pollNativeSize = pollNativeSize;
- }
-
- public VulkanHostSurfaceKind Kind { get; }
-
- public nint WindowHandle { get; }
-
- /// X11 Display* when is .
- public nint DisplayHandle { get; }
-
- /// CAMetalLayer* when is .
- public nint MetalLayerHandle { get; }
-
- public int PixelWidth => Volatile.Read(ref _pixelWidth);
-
- public int PixelHeight => Volatile.Read(ref _pixelHeight);
-
- internal int ResizeGeneration => Volatile.Read(ref _resizeGeneration);
-
- public void UpdatePixelSize(int width, int height)
- {
- width = Math.Max(width, 1);
- height = Math.Max(height, 1);
- if (Volatile.Read(ref _pixelWidth) == width && Volatile.Read(ref _pixelHeight) == height)
- {
- return;
- }
-
- Volatile.Write(ref _pixelWidth, width);
- Volatile.Write(ref _pixelHeight, height);
- Interlocked.Increment(ref _resizeGeneration);
- }
-
- ///
- /// The child emulator cannot receive Avalonia resize notifications. Poll
- /// the native host at a bounded rate so embedded child swapchains still
- /// follow normal resize and F11 transitions.
- ///
- internal void RefreshChildProcessPixelSize()
- {
- if (!_pollNativeSize)
- {
- return;
- }
-
- var now = System.Diagnostics.Stopwatch.GetTimestamp();
- var due = Volatile.Read(ref _nextNativeSizePoll);
- if (now < due || Interlocked.CompareExchange(
- ref _nextNativeSizePoll,
- now + (System.Diagnostics.Stopwatch.Frequency / 8),
- due) != due)
- {
- return;
- }
-
- if (Kind == VulkanHostSurfaceKind.Win32 && GetClientRect(WindowHandle, out var rect))
- {
- UpdatePixelSize(rect.Right - rect.Left, rect.Bottom - rect.Top);
- return;
- }
-
- if (Kind == VulkanHostSurfaceKind.Xlib && DisplayHandle != 0 &&
- XGetGeometry(
- DisplayHandle,
- WindowHandle,
- out _,
- out _,
- out _,
- out var width,
- out var height,
- out _,
- out _) != 0)
- {
- UpdatePixelSize(unchecked((int)width), unchecked((int)height));
- }
- }
-
- ///
- /// Serializes a native child handle for a separately hosted emulator
- /// process. Metal object pointers are process-local, so macOS falls back
- /// to a standalone child window until an IPC Metal host is implemented.
- ///
- public bool TryGetChildProcessDescriptor(out string descriptor)
- {
- descriptor = string.Empty;
- if (Kind == VulkanHostSurfaceKind.Metal || WindowHandle == 0)
- {
- return false;
- }
-
- var kind = Kind == VulkanHostSurfaceKind.Win32 ? "win32" : "xlib";
- descriptor = string.Create(
- System.Globalization.CultureInfo.InvariantCulture,
- $"{kind}:{unchecked((ulong)WindowHandle):X}:{Math.Max(PixelWidth, 1)}:{Math.Max(PixelHeight, 1)}:{unchecked((ulong)DisplayHandle):X}");
- return true;
- }
-
- ///
- /// Reconstructs a surface in the isolated emulator process. X11 clients
- /// must open their own Display connection; Display* values cannot cross a
- /// process boundary.
- ///
- public static bool TryCreateChildProcessSurface(
- string descriptor,
- out VulkanHostSurface? surface,
- out string? error)
- {
- surface = null;
- error = null;
- var parts = descriptor.Split(':');
- if (parts.Length != 5 ||
- !ulong.TryParse(parts[1], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var window) ||
- !int.TryParse(parts[2], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var width) ||
- !int.TryParse(parts[3], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var height) ||
- !ulong.TryParse(parts[4], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var nativeDisplay))
- {
- error = "invalid host-surface descriptor";
- return false;
- }
-
- if (window == 0 || width <= 0 || height <= 0)
- {
- error = "host-surface descriptor has an invalid size or handle";
- return false;
- }
-
- if (string.Equals(parts[0], "win32", StringComparison.OrdinalIgnoreCase))
- {
- surface = new VulkanHostSurface(
- VulkanHostSurfaceKind.Win32,
- unchecked((nint)window),
- unchecked((nint)nativeDisplay),
- pollNativeSize: true);
- }
- else if (string.Equals(parts[0], "xlib", StringComparison.OrdinalIgnoreCase))
- {
- var display = XOpenDisplay(0);
- if (display == 0)
- {
- error = "could not open an X11 display for the host surface";
- return false;
- }
-
- surface = new VulkanHostSurface(
- VulkanHostSurfaceKind.Xlib,
- unchecked((nint)window),
- display,
- ownsDisplay: true,
- pollNativeSize: true);
- }
- else
- {
- error = $"unsupported host-surface kind '{parts[0]}'";
- return false;
- }
-
- surface.UpdatePixelSize(width, height);
- return true;
- }
-
- public void Dispose()
- {
- if (_ownsDisplay && DisplayHandle != 0 && OperatingSystem.IsLinux())
- {
- _ = XCloseDisplay(DisplayHandle);
- }
- }
-
- [System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
- private static extern nint XOpenDisplay(nint displayName);
-
- [System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
- private static extern int XCloseDisplay(nint display);
-
- [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetClientRect", SetLastError = true)]
- [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
- private static extern bool GetClientRect(nint window, out Rect rect);
-
- [System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XGetGeometry")]
- private static extern int XGetGeometry(
- nint display,
- nint drawable,
- out nint root,
- out int x,
- out int y,
- out uint width,
- out uint height,
- out uint borderWidth,
- out uint depth);
-
- [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
- private struct Rect
- {
- public int Left;
- public int Top;
- public int Right;
- public int Bottom;
- }
-}
-
-///
-/// Small public bridge between a desktop UI and the internal Vulkan
-/// presenter. Launchers can host a surface without depending on renderer
-/// submission internals.
-///
-public static class VulkanVideoHost
-{
- ///
- /// Raised after the first successful Vulkan present to an embedded host
- /// surface. UI hosts use this to retire their launch affordance only once
- /// a real frame can be seen.
- ///
- public static event Action? FirstFramePresented
- {
- add => VulkanVideoPresenter.FirstHostFramePresented += value;
- remove => VulkanVideoPresenter.FirstHostFramePresented -= value;
- }
-
- public static bool TryAttachSurface(VulkanHostSurface surface) =>
- VulkanVideoPresenter.TryAttachHostSurface(surface);
-
- public static void DetachSurface(VulkanHostSurface surface) =>
- VulkanVideoPresenter.DetachHostSurface(surface);
-
- public static void RequestClose() => VulkanVideoPresenter.RequestClose();
-
- public static bool IsEmbedded => VulkanVideoPresenter.UsesHostSurface;
-}
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs b/src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs
new file mode 100644
index 00000000..137c91a8
--- /dev/null
+++ b/src/SharpEmu.Libs/VideoOut/VulkanPipelineCacheStorage.cs
@@ -0,0 +1,96 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.Libs.VideoOut;
+
+internal static class VulkanPipelineCacheStorage
+{
+ private const string CacheFileName = "vulkan-pipeline-cache.bin";
+
+ internal static string ResolvePath(string? titleId, string? configuredPath)
+ {
+ if (!string.IsNullOrWhiteSpace(configuredPath))
+ {
+ return Path.GetFullPath(
+ Environment.ExpandEnvironmentVariables(configuredPath));
+ }
+
+ return Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "pipeline_cache",
+ SanitizeTitleId(titleId),
+ CacheFileName));
+ }
+
+ internal static string GetLegacyPath()
+ {
+ var root = OperatingSystem.IsMacOS()
+ ? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Library",
+ "Caches")
+ : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ return Path.Combine(root, "SharpEmu", CacheFileName);
+ }
+
+ internal static bool ImportLegacyCache(string legacyPath, string destinationPath)
+ {
+ if (File.Exists(destinationPath) || !File.Exists(legacyPath))
+ {
+ return false;
+ }
+
+ var directory = Path.GetDirectoryName(destinationPath);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ try
+ {
+ File.Copy(legacyPath, destinationPath, overwrite: false);
+ try
+ {
+ File.Delete(legacyPath);
+ }
+ catch (IOException)
+ {
+ // The destination is valid; a locked legacy cache can remain
+ // as an unused artifact without affecting future writes.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // See above. Cache migration must not block game startup.
+ }
+
+ return true;
+ }
+ catch (IOException) when (File.Exists(destinationPath))
+ {
+ return false;
+ }
+ }
+
+ private static string SanitizeTitleId(string? titleId)
+ {
+ if (string.IsNullOrWhiteSpace(titleId))
+ {
+ return "UNKNOWN";
+ }
+
+ var source = titleId.Trim();
+ Span sanitized = source.Length <= 128
+ ? stackalloc char[source.Length]
+ : new char[source.Length];
+ for (var index = 0; index < source.Length; index++)
+ {
+ var value = source[index];
+ sanitized[index] = char.IsAsciiLetterOrDigit(value) || value is '-' or '_'
+ ? char.ToUpperInvariant(value)
+ : '_';
+ }
+
+ return new string(sanitized);
+ }
+}
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
index 9928d675..7c0d3498 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
@@ -3,18 +3,15 @@
using Silk.NET.Core;
using Silk.NET.Core.Native;
-using Silk.NET.Maths;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Bink;
-using Silk.NET.Input;
using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Vulkan;
using Silk.NET.Vulkan;
using Silk.NET.Vulkan.Extensions.KHR;
using Silk.NET.Vulkan.Extensions.EXT;
-using Silk.NET.Windowing;
using System.Buffers.Binary;
using System.Diagnostics;
using System.Numerics;
@@ -110,8 +107,7 @@ internal readonly record struct VulkanGuestQueueIdentity(
internal static unsafe class VulkanVideoPresenter
{
- // Standalone CLI launches use a desktop-sized surface. The embedded GUI
- // always takes its dimensions from the native child control instead.
+ // Standalone launches use a desktop-sized SDL surface unless configured.
private const uint DefaultWindowWidth = 1920;
private const uint DefaultWindowHeight = 1080;
internal const uint Gen5TextureType2D = 9;
@@ -557,9 +553,7 @@ internal static unsafe class VulkanVideoPresenter
private static readonly HashSet<(ulong Address, uint Width, uint Height)>
_tracedGuestImageSubmissions = [];
private static Thread? _thread;
- private static VulkanHostSurface? _hostSurface;
- private static VulkanHostSurface? _hostSurfacePendingDetach;
- internal static event Action? FirstHostFramePresented;
+ private static HostVideoOptions _videoOptions = HostVideoOptions.Default;
private static Presentation? _latestPresentation;
private static byte[]? _copyFragmentSpirv;
private static uint _windowWidth;
@@ -567,6 +561,7 @@ internal static unsafe class VulkanVideoPresenter
private static bool _closed;
private static bool _presenterCloseRequested;
private const string DebugUtilsExtensionName = "VK_EXT_debug_utils";
+ private const string SwapchainColorspaceExtensionName = "VK_EXT_swapchain_colorspace";
private const uint NvidiaVendorId = 0x10DE;
private const uint AmdVendorId = 0x1002;
// Other GPU PCI vendor IDs, for reference when adding future rules:
@@ -574,12 +569,6 @@ internal static unsafe class VulkanVideoPresenter
private const int LastResortPenalty = 1000;
private const string PortabilityEnumerationExtensionName = "VK_KHR_portability_enumeration";
private const string PortabilitySubsetExtensionName = "VK_KHR_portability_subset";
- private const int GlfwPlatformHint = 0x00050003;
- private const int GlfwPlatformWin32 = 0x00060001;
- private const int GlfwPlatformCocoa = 0x00060002;
- private const int GlfwPlatformWayland = 0x00060003;
- private const int GlfwPlatformX11 = 0x00060004;
- private const int GlfwPlatformNull = 0x00060005;
internal static int ScorePhysicalDevice(
PhysicalDeviceProperties properties,
@@ -760,26 +749,13 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDraw: null,
RequiredGuestWorkSequence: 0,
IsSplash: false);
- if (_hostSurface is not null && _latestPresentation is { IsSplash: true })
- {
- // The GUI keeps its native child hidden while the launcher is
- // loading. Reveal it for the real VideoOut splash rather than
- // substituting a launcher-side image.
- Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted splash ready.");
- }
StartPresenterLocked();
}
}
- ///
- /// Selects a same-process native surface for the next VideoOut session.
- /// This is intentionally rejected once the presenter has started: Vulkan
- /// surface ownership cannot change under an active swapchain.
- ///
- public static bool TryAttachHostSurface(VulkanHostSurface surface)
+ public static bool TryConfigureVideo(HostVideoOptions options)
{
- ArgumentNullException.ThrowIfNull(surface);
-
+ ArgumentNullException.ThrowIfNull(options);
lock (_gate)
{
if (_thread is not null)
@@ -787,72 +763,11 @@ internal static unsafe class VulkanVideoPresenter
return false;
}
- ResetHostSessionStateLocked();
- _hostSurface = surface;
- _closed = false;
- _presenterCloseRequested = false;
+ _videoOptions = options.Normalize();
return true;
}
}
- ///
- /// Releases the host surface after the guest session has stopped.
- ///
- public static void DetachHostSurface(VulkanHostSurface surface)
- {
- ArgumentNullException.ThrowIfNull(surface);
-
- lock (_gate)
- {
- if (!ReferenceEquals(_hostSurface, surface))
- {
- return;
- }
-
- if (_thread is null)
- {
- _hostSurface = null;
- }
- else
- {
- _hostSurfacePendingDetach = surface;
- }
- }
- }
-
- internal static bool UsesHostSurface
- {
- get
- {
- lock (_gate)
- {
- return _hostSurface is not null;
- }
- }
- }
-
- private static void NotifyFirstHostFramePresented(VulkanHostSurface surface)
- {
- // This marker crosses the GUI child-process pipe. The in-process
- // event remains for hosts that embed the renderer directly.
- Console.Error.WriteLine("[VIDEOOUT][INFO] Hosted first frame presented.");
- var callback = FirstHostFramePresented;
- if (callback is null)
- {
- return;
- }
-
- try
- {
- callback(surface);
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine(
- $"[LOADER][WARN] Embedded first-frame notification failed: {exception.Message}");
- }
- }
-
private static void ResetHostSessionStateLocked()
{
_latestPresentation = null;
@@ -879,7 +794,6 @@ internal static unsafe class VulkanVideoPresenter
_completedGuestWorkOutOfOrder.Clear();
_lastEnqueuedGuestWorkByQueue.Clear();
_executingGuestWorkSequence = 0;
- _hostSurfacePendingDetach = null;
}
public static void HideSplashScreen()
@@ -1266,7 +1180,8 @@ internal static unsafe class VulkanVideoPresenter
private sealed record VulkanGuestImageWrite(
ulong Address,
byte[]? Pixels,
- uint FillValue);
+ uint FillValue,
+ uint RowOffset = 0);
///
/// Reports the extent of a live guest image so DMA writes to its backing
@@ -1369,7 +1284,7 @@ internal static unsafe class VulkanVideoPresenter
}
}
- internal static void SubmitGuestImageWrite(ulong address, byte[] pixels)
+ internal static void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0)
{
lock (_gate)
{
@@ -1379,7 +1294,7 @@ internal static unsafe class VulkanVideoPresenter
}
_guestImageWorkSequences[address] = EnqueueGuestWorkLocked(
- new VulkanGuestImageWrite(address, pixels, 0));
+ new VulkanGuestImageWrite(address, pixels, 0, rowOffset));
}
}
@@ -1671,7 +1586,8 @@ internal static unsafe class VulkanVideoPresenter
// renderer and turns every flip into a dropped black frame.
RequiredGuestWorkSequence: requiredWorkSequence,
IsSplash: false,
- GuestImageAddress: address);
+ GuestImageAddress: address,
+ IsHdr: VideoOutExports.IsHdrOutputRequested);
_latestPresentation = presentation;
_pendingGuestImagePresentations.Enqueue(presentation);
while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
@@ -2258,9 +2174,9 @@ internal static unsafe class VulkanVideoPresenter
private static void StartPresenterLocked()
{
- if (_hostSurface is null && HostMainThread.IsAvailable)
+ if (HostMainThread.IsAvailable)
{
- // AppKit (and therefore GLFW) traps when touched off the process
+ // AppKit windowing traps when touched off the process
// main thread on macOS, so hand the whole window loop to the
// main-thread pump the CLI parked for us. _thread only marks the
// presenter as running; Run() clears it on exit either way.
@@ -2287,162 +2203,19 @@ internal static unsafe class VulkanVideoPresenter
Volatile.Write(ref _presenterCloseRequested, true);
}
- ///
- /// GLFW resolves Vulkan with dlopen("libvulkan.1.dylib"), which cannot
- /// find the app-local MoltenVK on macOS (Homebrew's Vulkan libraries are
- /// arm64-only and this is an x86-64 process). GLFW 3.4 accepts the
- /// loader entry point directly instead, so hand it MoltenVK's
- /// vkGetInstanceProcAddr before any window exists.
- ///
- private static unsafe void InitializeMacVulkanLoader()
- {
- if (!OperatingSystem.IsMacOS())
- {
- return;
- }
-
- try
- {
- nint vulkan = 0;
- foreach (var candidate in new[]
- {
- Path.Combine(AppContext.BaseDirectory, "libvulkan.1.dylib"),
- Path.Combine(AppContext.BaseDirectory, "libMoltenVK.dylib"),
- "libvulkan.1.dylib",
- "libMoltenVK.dylib",
- })
- {
- if (System.Runtime.InteropServices.NativeLibrary.TryLoad(candidate, out vulkan))
- {
- break;
- }
- }
-
- if (vulkan == 0 ||
- !System.Runtime.InteropServices.NativeLibrary.TryGetExport(
- vulkan, "vkGetInstanceProcAddr", out var procAddr))
- {
- Console.Error.WriteLine(
- "[LOADER][WARN] No Vulkan loader for GLFW; place a universal libMoltenVK.dylib " +
- "next to SharpEmu as libvulkan.1.dylib.");
- return;
- }
-
- var glfw = System.Runtime.InteropServices.NativeLibrary.Load(
- Path.Combine(AppContext.BaseDirectory, "libglfw.3.dylib"));
- var initVulkanLoader = (delegate* unmanaged)
- System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitVulkanLoader");
- initVulkanLoader(procAddr);
- Console.Error.WriteLine("[LOADER][INFO] GLFW Vulkan loader wired to MoltenVK.");
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] GLFW Vulkan loader setup failed: {exception.Message}");
- }
- }
-
- private static unsafe void PreferX11OnLinuxWayland()
- {
- if (!OperatingSystem.IsLinux() ||
- string.Equals(
- Environment.GetEnvironmentVariable("SHARPEMU_ENABLE_WAYLAND"),
- "1",
- StringComparison.Ordinal))
- {
- return;
- }
-
- if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
- {
- return;
- }
-
- if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")))
- {
- Console.Error.WriteLine(
- "[LOADER][WARN] Wayland session without an X server (DISPLAY unset); " +
- "cannot steer GLFW to XWayland. Set SHARPEMU_ENABLE_WAYLAND=1 to use native Wayland.");
- return;
- }
-
- if (!TryLoadGlfw(out var glfw))
- {
- return;
- }
-
- try
- {
- var initHint = (delegate* unmanaged)
- System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitHint");
- initHint(GlfwPlatformHint, GlfwPlatformX11);
- Console.Error.WriteLine(
- "[LOADER][INFO] Wayland session detected; requested GLFW X11/XWayland backend.");
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine(
- $"[LOADER][WARN] Could not set GLFW X11 platform hint: {exception.Message}");
- }
- }
-
- private static unsafe void LogGlfwPlatformInUse()
- {
- if (OperatingSystem.IsWindows() || !TryLoadGlfw(out var glfw))
- {
- return;
- }
-
- try
- {
- var getPlatform = (delegate* unmanaged)
- System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwGetPlatform");
- var platform = getPlatform();
- var label = platform switch
- {
- GlfwPlatformWin32 => "Win32",
- GlfwPlatformCocoa => "Cocoa",
- GlfwPlatformWayland => "Wayland",
- GlfwPlatformX11 => "X11",
- GlfwPlatformNull => "Null",
- _ => $"0x{platform:X}",
- };
- Console.Error.WriteLine($"[LOADER][INFO] GLFW windowing platform in use: {label}");
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] Could not query GLFW platform: {exception.Message}");
- }
- }
-
- private static bool TryLoadGlfw(out nint handle)
- {
- var name = OperatingSystem.IsMacOS() ? "libglfw.3.dylib" : "libglfw.so.3";
- return System.Runtime.InteropServices.NativeLibrary.TryLoad(
- Path.Combine(AppContext.BaseDirectory, name), out handle) ||
- System.Runtime.InteropServices.NativeLibrary.TryLoad(name, out handle);
- }
-
private static void Run()
{
uint width;
uint height;
- VulkanHostSurface? hostSurface;
lock (_gate)
{
width = _windowWidth == 0 ? _latestPresentation?.Width ?? 1280 : _windowWidth;
height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight;
- hostSurface = _hostSurface;
- }
-
- if (hostSurface is null)
- {
- PreferX11OnLinuxWayland();
- InitializeMacVulkanLoader();
}
try
{
- using var presenter = new Presenter(width, height, hostSurface);
+ using var presenter = new Presenter(width, height);
presenter.Run();
}
catch (Exception exception)
@@ -2455,12 +2228,6 @@ internal static unsafe class VulkanVideoPresenter
{
_closed = true;
_thread = null;
- if (_hostSurfacePendingDetach is not null &&
- ReferenceEquals(_hostSurface, _hostSurfacePendingDetach))
- {
- _hostSurface = null;
- _hostSurfacePendingDetach = null;
- }
System.Threading.Monitor.PulseAll(_gate);
}
}
@@ -2672,8 +2439,7 @@ internal static unsafe class VulkanVideoPresenter
}
_pendingGuestWorkBytes = SaturatingAdd(_pendingGuestWorkBytes, payloadBytes);
- // Wake the embedded render loop parked in WaitForRenderWork; without a
- // pulse it only notices new work when its timed wait expires.
+ // Wake any thread waiting for guest work completion or queue space.
System.Threading.Monitor.PulseAll(_gate);
return sequence;
}
@@ -3181,7 +2947,8 @@ internal static unsafe class VulkanVideoPresenter
long RequiredGuestWorkSequence,
bool IsSplash,
ulong GuestImageAddress = 0,
- long GuestImageVersion = 0);
+ long GuestImageVersion = 0,
+ bool IsHdr = false);
private sealed class Presenter : IDisposable
{
@@ -3191,10 +2958,7 @@ internal static unsafe class VulkanVideoPresenter
private const string FullscreenBarycentricFragmentSpirv =
"AwIjBwAAAQALAAgAEgAAAAAAAAARAAIAAQAAAAsABgABAAAAR0xTTC5zdGQuNDUwAAAAAA4AAwAAAAAAAQAAAA8ABwAEAAAABAAAAG1haW4AAAAACQAAAAwAAAAQAAMABAAAAAcAAAADAAMAAgAAAMIBAAAFAAQABAAAAG1haW4AAAAABQAFAAkAAABvdXRDb2xvcgAAAAAFAAUADAAAAGJhcnljZW50cmljAEcABAAJAAAAHgAAAAAAAABHAAQADAAAAB4AAAAAAAAAEwACAAIAAAAhAAMAAwAAAAIAAAAWAAMABgAAACAAAAAXAAQABwAAAAYAAAAEAAAAIAAEAAgAAAADAAAABwAAADsABAAIAAAACQAAAAMAAAAXAAQACgAAAAYAAAACAAAAIAAEAAsAAAABAAAACgAAADsABAALAAAADAAAAAEAAAArAAQABgAAAA4AAAAAAAAANgAFAAIAAAAEAAAAAAAAAAMAAAD4AAIABQAAAD0ABAAKAAAADQAAAAwAAABRAAUABgAAAA8AAAANAAAAAAAAAFEABQAGAAAAEAAAAA0AAAABAAAAUAAHAAcAAAARAAAADwAAABAAAAAOAAAADgAAAD4AAwAJAAAAEQAAAP0AAQA4AAEA";
- private readonly IWindow? _window;
- private readonly VulkanHostSurface? _hostSurface;
- private int _lastHostResizeGeneration;
- private bool _embeddedLoopClosed;
+ private readonly SdlHostWindow _window;
private const int MaxInFlightGuestSubmissions = 8;
private Vk _vk = null!;
private KhrSurface _surfaceApi = null!;
@@ -3238,6 +3002,25 @@ internal static unsafe class VulkanVideoPresenter
private Framebuffer[] _framebuffers = [];
private bool[] _imageInitialized = [];
private Format _swapchainFormat;
+ private ColorSpaceKHR _swapchainColorSpace;
+ private bool _hdrOutputActive;
+ private bool _hdrRequestedForSwapchain;
+ private float _hdrSdrWhiteLevel = 1f;
+ private float _hdrHeadroom = 1f;
+ private Image[] _presentationImages = [];
+ private DeviceMemory[] _presentationImageMemory = [];
+ private ImageView[] _presentationImageViews = [];
+ private ImageView[] _presentationSampleViews = [];
+ private RenderPass _hdrRenderPass;
+ private Framebuffer[] _hdrFramebuffers = [];
+ private DescriptorSetLayout _hdrDescriptorSetLayout;
+ private DescriptorPool _hdrDescriptorPool;
+ private DescriptorSet[] _hdrDescriptorSets = [];
+ private DescriptorSet[] _hdrPqDescriptorSets = [];
+ private PipelineLayout _hdrPipelineLayout;
+ private Pipeline _hdrPipeline;
+ private Pipeline _hdrPqPipeline;
+ private Sampler _hdrSampler;
private Extent2D _extent;
private RenderPass _renderPass;
private PipelineLayout _pipelineLayout;
@@ -3330,11 +3113,8 @@ internal static unsafe class VulkanVideoPresenter
private long _presentNotTakenLoggedSequence = long.MinValue;
private bool _vulkanReady;
private bool _firstFramePresented;
- private bool _firstHostFramePresented;
private bool _firstGuestDrawPresented;
private bool _splashPresented;
- private Presentation? _lastHostSplashPresentation;
- private Presentation? _pendingHostSplashReplay;
private bool _swapchainRecreateDeferred;
private bool _tracedPresentedSwapchain;
private bool _swapchainReadbackPending;
@@ -3580,6 +3360,7 @@ internal static unsafe class VulkanVideoPresenter
}
private const Format DepthFormat = Format.D32Sfloat;
+ private const ulong SwapchainAcquireTimeoutNs = 250_000_000;
private sealed class GuestDepthResource
{
@@ -3658,124 +3439,55 @@ internal static unsafe class VulkanVideoPresenter
VulkanGuestQueueIdentity Queue,
long WorkSequence);
- public Presenter(uint width, uint height, VulkanHostSurface? hostSurface)
+ public Presenter(uint width, uint height)
{
- _hostSurface = hostSurface;
_hostBufferPool = new VulkanHostBufferPool(
MaximumCachedHostBufferBytes,
DestroyHostBufferAllocation);
-
- if (_hostSurface is not null)
- {
- _hostSurface.UpdatePixelSize(
- _hostSurface.PixelWidth > 0 ? _hostSurface.PixelWidth : (int)width,
- _hostSurface.PixelHeight > 0 ? _hostSurface.PixelHeight : (int)height);
- _lastHostResizeGeneration = _hostSurface.ResizeGeneration;
- return;
- }
-
- var options = WindowOptions.DefaultVulkan;
- options.Size = new Vector2D((int)DefaultWindowWidth, (int)DefaultWindowHeight);
- options.Title = VideoOutExports.GetWindowTitle();
- options.WindowBorder = WindowBorder.Fixed;
- options.VSync = true;
- options.FramesPerSecond = 60;
- options.UpdatesPerSecond = 60;
- _window = Window.Create(options);
- _window.Load += Initialize;
- _window.Render += Render;
- _window.Closing += () =>
- {
- Console.Error.WriteLine(
- $"[LOADER][WARN] Vulkan VideoOut window closing; " +
- $"requested={Volatile.Read(ref _presenterCloseRequested)} " +
- $"deviceLost={_deviceLost}");
- VideoOutExports.NotifyPresentationWindowClosed();
- DisposeVulkan();
- };
+ _window = new SdlHostWindow(
+ VideoOutExports.GetWindowTitle(),
+ _videoOptions,
+ SdlGraphicsApi.Vulkan);
}
public void Run()
{
- if (_window is not null)
- {
- _window.Run();
- return;
- }
-
- Initialize();
- while (!_embeddedLoopClosed)
- {
- Render(0);
- // Guest draws and flips execute on this thread. An unconditional
- // Thread.Sleep here is quantized to the Windows timer period
- // (~15.6ms) and throttles every hosted game far below the GLFW
- // path. Wait only while there is genuinely no render work.
- WaitForRenderWork();
- }
+ _window.Run(
+ Initialize,
+ Render,
+ () =>
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] Vulkan VideoOut window closing; " +
+ $"requested={Volatile.Read(ref _presenterCloseRequested)} " +
+ $"deviceLost={_deviceLost}");
+ VideoOutExports.NotifyPresentationWindowClosed();
+ DisposeVulkan();
+ },
+ WaitForRenderWork);
}
public void Dispose()
{
DisposeVulkan();
- try
- {
- _window?.Dispose();
- }
- catch (InvalidOperationException exception)
- when (exception.Message.Contains("render loop", StringComparison.OrdinalIgnoreCase))
- {
- Console.Error.WriteLine(
- $"[LOADER][WARN] Vulkan VideoOut window dispose skipped during render loop: {exception.Message}");
- }
+ _window.Dispose();
}
private void Initialize()
{
- if (_window is not null)
- {
- LogGlfwPlatformInUse();
- if (!OperatingSystem.IsWindows())
- {
- try
- {
- Pad.HostWindowInput.Attach(_window.CreateInput());
- Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
- }
- catch (Exception exception)
- {
- Console.Error.WriteLine($"[LOADER][WARN] Window keyboard input unavailable: {exception.Message}");
- }
- }
-
- if (PngSplashLoader.TryLoadIcon(out var iconPixels, out var iconWidth, out var iconHeight))
- {
- var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
- _window.SetWindowIcon(ref icon);
- }
- }
-
- try
- {
- WaitForRenderDocAttachIfRequested();
- _vk = Vk.GetApi();
- CreateInstance();
- CreateSurface();
- SelectPhysicalDevice();
- CreateDevice();
- CreatePipelineCache();
- CreateSwapchain();
- CreateCommandResources();
- CreateGuestDrawResources();
- _vulkanReady = true;
- Console.Error.WriteLine(
- $"[LOADER][INFO] Vulkan VideoOut ready: {_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
- }
- catch (Exception exception)
- {
- _vulkanReady = false;
- Console.Error.WriteLine($"[LOADER][WARN] Vulkan VideoOut disabled: {exception.Message}");
- }
+ WaitForRenderDocAttachIfRequested();
+ _vk = Vk.GetApi();
+ CreateInstance();
+ CreateSurface();
+ SelectPhysicalDevice();
+ CreateDevice();
+ CreatePipelineCache();
+ CreateSwapchain();
+ CreateCommandResources();
+ CreateGuestDrawResources();
+ _vulkanReady = true;
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] Vulkan VideoOut ready: {_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
}
private static void WaitForRenderDocAttachIfRequested()
@@ -4016,45 +3728,17 @@ internal static unsafe class VulkanVideoPresenter
ApiVersion = Vk.Version12,
};
- var hostExtensionNames = _hostSurface is null
- ? null
- : GetHostSurfaceExtensions(_hostSurface.Kind);
- byte** extensions;
- uint extensionCount;
- if (hostExtensionNames is not null)
- {
- extensions = null;
- extensionCount = (uint)hostExtensionNames.Length;
- }
- else if (_window?.VkSurface is { } glfwSurface)
- {
- extensions = glfwSurface.GetRequiredExtensions(out extensionCount);
- }
- else
- {
- throw new InvalidOperationException("GLFW did not provide Vulkan surface extensions.");
- }
+ var extensions = _window.GetRequiredVulkanInstanceExtensions(out var extensionCount);
byte* debugUtilsExtension = null;
byte* portabilityExtension = null;
+ byte* swapchainColorspaceExtension = null;
var instanceCreateFlags = InstanceCreateFlags.None;
var enabledExtensionCount = (int)extensionCount;
- var enabledExtensions = stackalloc byte*[(int)extensionCount + 2];
- var allocatedHostExtensions = hostExtensionNames is null
- ? null
- : new nint[hostExtensionNames.Length];
+ var enabledExtensions = stackalloc byte*[(int)extensionCount + 3];
for (var index = 0; index < (int)extensionCount; index++)
{
- if (hostExtensionNames is null)
- {
- enabledExtensions[index] = extensions[index];
- }
- else
- {
- var extension = (byte*)SilkMarshal.StringToPtr(hostExtensionNames[index]);
- allocatedHostExtensions![index] = (nint)extension;
- enabledExtensions[index] = extension;
- }
+ enabledExtensions[index] = extensions[index];
}
if (_vulkanDebugUtilsEnabled &&
@@ -4073,6 +3757,13 @@ internal static unsafe class VulkanVideoPresenter
instanceCreateFlags |= InstanceCreateFlags.EnumeratePortabilityBitKhr;
}
+ if (IsInstanceExtensionAvailable(SwapchainColorspaceExtensionName))
+ {
+ swapchainColorspaceExtension =
+ (byte*)SilkMarshal.StringToPtr(SwapchainColorspaceExtensionName);
+ enabledExtensions[enabledExtensionCount++] = swapchainColorspaceExtension;
+ }
+
if (_vulkanValidationEnabled &&
IsInstanceLayerAvailable("VK_LAYER_KHRONOS_validation"))
{
@@ -4125,12 +3816,9 @@ internal static unsafe class VulkanVideoPresenter
{
SilkMarshal.Free((nint)portabilityExtension);
}
- if (allocatedHostExtensions is not null)
+ if (swapchainColorspaceExtension is not null)
{
- foreach (var extension in allocatedHostExtensions)
- {
- SilkMarshal.Free(extension);
- }
+ SilkMarshal.Free((nint)swapchainColorspaceExtension);
}
}
}
@@ -4241,99 +3929,9 @@ internal static unsafe class VulkanVideoPresenter
}
private void CreateSurface()
{
- if (_hostSurface is not null)
- {
- CreateHostSurface(_hostSurface);
- return;
- }
-
- var instanceHandle = new VkHandle(_instance.Handle);
- var surfaceHandle = _window!.VkSurface!.Create(instanceHandle, null);
- _surface = new SurfaceKHR(surfaceHandle.Handle);
+ _surface = _window.CreateVulkanSurface(_instance);
}
- private static string[] GetHostSurfaceExtensions(VulkanHostSurfaceKind kind) => kind switch
- {
- VulkanHostSurfaceKind.Win32 => ["VK_KHR_surface", "VK_KHR_win32_surface"],
- VulkanHostSurfaceKind.Xlib => ["VK_KHR_surface", "VK_KHR_xlib_surface"],
- VulkanHostSurfaceKind.Metal => ["VK_KHR_surface", "VK_EXT_metal_surface"],
- _ => throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {kind}."),
- };
-
- private void CreateHostSurface(VulkanHostSurface hostSurface)
- {
- switch (hostSurface.Kind)
- {
- case VulkanHostSurfaceKind.Win32:
- CreateWin32HostSurface(hostSurface);
- return;
- case VulkanHostSurfaceKind.Xlib:
- CreateXlibHostSurface(hostSurface);
- return;
- case VulkanHostSurfaceKind.Metal:
- CreateMetalHostSurface(hostSurface);
- return;
- default:
- throw new PlatformNotSupportedException($"Unsupported Vulkan host surface: {hostSurface.Kind}.");
- }
- }
-
- private void CreateWin32HostSurface(VulkanHostSurface hostSurface)
- {
- if (!_vk.TryGetInstanceExtension(_instance, out KhrWin32Surface win32Surface))
- {
- throw new InvalidOperationException("VK_KHR_win32_surface is unavailable.");
- }
-
- var createInfo = new Win32SurfaceCreateInfoKHR
- {
- SType = StructureType.Win32SurfaceCreateInfoKhr,
- Hinstance = hostSurface.DisplayHandle != 0
- ? hostSurface.DisplayHandle
- : GetModuleHandleW(null),
- Hwnd = hostSurface.WindowHandle,
- };
- Check(win32Surface.CreateWin32Surface(_instance, &createInfo, null, out _surface), "vkCreateWin32SurfaceKHR");
- }
-
- private void CreateXlibHostSurface(VulkanHostSurface hostSurface)
- {
- if (!_vk.TryGetInstanceExtension(_instance, out KhrXlibSurface xlibSurface))
- {
- throw new InvalidOperationException("VK_KHR_xlib_surface is unavailable.");
- }
-
- var createInfo = new XlibSurfaceCreateInfoKHR
- {
- SType = StructureType.XlibSurfaceCreateInfoKhr,
- Dpy = (nint*)hostSurface.DisplayHandle,
- Window = hostSurface.WindowHandle,
- };
- Check(xlibSurface.CreateXlibSurface(_instance, &createInfo, null, out _surface), "vkCreateXlibSurfaceKHR");
- }
-
- private void CreateMetalHostSurface(VulkanHostSurface hostSurface)
- {
- var proc = _vk.GetInstanceProcAddr(_instance, "vkCreateMetalSurfaceEXT");
- if (proc == 0)
- {
- throw new InvalidOperationException("VK_EXT_metal_surface is unavailable.");
- }
-
- var createInfo = new MetalSurfaceCreateInfoEXT
- {
- SType = StructureType.MetalSurfaceCreateInfoExt,
- PLayer = (nint*)hostSurface.MetalLayerHandle,
- };
- var createSurface = (delegate* unmanaged)proc.Handle;
- SurfaceKHR surface;
- Check(createSurface(_instance, &createInfo, null, &surface), "vkCreateMetalSurfaceEXT");
- _surface = surface;
- }
-
- [DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
- private static extern nint GetModuleHandleW(string? moduleName);
-
private void SelectPhysicalDevice()
{
uint deviceCount = 0;
@@ -4402,7 +4000,7 @@ internal static unsafe class VulkanVideoPresenter
VideoOutExports.SetSelectedGpuName(selectedName);
if (_window is not null)
{
- _window.Title = VideoOutExports.GetWindowTitle();
+ _window.SetTitle(VideoOutExports.GetWindowTitle());
}
}
@@ -4708,19 +4306,28 @@ internal static unsafe class VulkanVideoPresenter
private static string GetPipelineCachePath()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_VK_PIPELINE_CACHE_PATH");
- if (!string.IsNullOrWhiteSpace(configured))
+ var cachePath = VulkanPipelineCacheStorage.ResolvePath(
+ VideoOutExports.GetApplicationTitleId(),
+ configured);
+ if (string.IsNullOrWhiteSpace(configured))
{
- return Path.GetFullPath(
- Environment.ExpandEnvironmentVariables(configured));
+ try
+ {
+ var legacyPath = VulkanPipelineCacheStorage.GetLegacyPath();
+ if (VulkanPipelineCacheStorage.ImportLegacyCache(legacyPath, cachePath))
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] Imported legacy Vulkan pipeline cache: source={legacyPath} destination={cachePath}");
+ }
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] Vulkan pipeline cache migration failed: {exception.Message}");
+ }
}
- var root = OperatingSystem.IsMacOS()
- ? Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
- "Library",
- "Caches")
- : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
- return Path.Combine(root, "SharpEmu", "vulkan-pipeline-cache.bin");
+ return cachePath;
}
private void MarkPipelineCacheDirty()
@@ -4830,8 +4437,20 @@ internal static unsafe class VulkanVideoPresenter
"vkGetPhysicalDeviceSurfaceFormatsKHR");
}
- var surfaceFormat = ChooseSurfaceFormat(formats);
+ var hdrState = _window.HdrState;
+ var guestHdrRequested = VideoOutExports.IsHdrOutputRequested;
+ var requestHdr = _videoOptions.HdrMode switch
+ {
+ HostHdrMode.On => true,
+ HostHdrMode.Auto => hdrState.Enabled && guestHdrRequested,
+ _ => false,
+ };
+ _hdrRequestedForSwapchain = requestHdr;
+ var surfaceFormat = ChooseSurfaceFormat(formats, requestHdr, out _hdrOutputActive);
+ _hdrSdrWhiteLevel = _hdrOutputActive ? Math.Max(1f, hdrState.SdrWhiteLevel) : 1f;
+ _hdrHeadroom = _hdrOutputActive ? Math.Max(1f, hdrState.Headroom) : 1f;
_swapchainFormat = surfaceFormat.Format;
+ _swapchainColorSpace = surfaceFormat.ColorSpace;
_extent = ChooseExtent(capabilities);
Bink2MovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
var presentMode = ChoosePresentMode();
@@ -4877,6 +4496,16 @@ internal static unsafe class VulkanVideoPresenter
}
_imageInitialized = new bool[swapchainImageCount];
+ Console.Error.WriteLine(
+ $"[LOADER][INFO] Vulkan output color: requested={_videoOptions.HdrMode} " +
+ $"display_hdr={hdrState.Enabled} guest_hdr={guestHdrRequested} active={_hdrOutputActive} " +
+ $"format={_swapchainFormat} colorspace={_swapchainColorSpace} " +
+ $"sdr_white={_hdrSdrWhiteLevel:F3} headroom={_hdrHeadroom:F3}");
+ if (requestHdr && !_hdrOutputActive)
+ {
+ Console.Error.WriteLine(
+ "[LOADER][WARN] HDR output requested but the Vulkan surface exposes no scRGB format; using SDR.");
+ }
}
private PresentModeKHR ChoosePresentMode()
@@ -4907,6 +4536,17 @@ internal static unsafe class VulkanVideoPresenter
return PresentModeKHR.FifoKhr;
}
+ if (!_videoOptions.VSync)
+ {
+ for (var index = 0u; index < modeCount; index++)
+ {
+ if (modes[index] == PresentModeKHR.ImmediateKhr)
+ {
+ return PresentModeKHR.ImmediateKhr;
+ }
+ }
+ }
+
for (var index = 0u; index < modeCount; index++)
{
if (modes[index] == PresentModeKHR.MailboxKhr)
@@ -5054,6 +4694,7 @@ internal static unsafe class VulkanVideoPresenter
(void*)_overlayStagingMapped[frameSlot],
PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4);
PerfOverlay.Fill(pixels, pendingWork, _pendingGuestSubmissions.Count);
+ var presentationTarget = PresentationTargetImage(imageIndex);
var toTransferDst = new ImageMemoryBarrier
{
@@ -5105,11 +4746,11 @@ internal static unsafe class VulkanVideoPresenter
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = 0,
DstAccessMask = AccessFlags.TransferWriteBit,
- OldLayout = ImageLayout.PresentSrcKhr,
+ OldLayout = PresentationTargetFinalLayout,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
var preBlitBarriers = stackalloc ImageMemoryBarrier[2] { toTransferSrc, swapchainToDst };
@@ -5140,28 +4781,30 @@ internal static unsafe class VulkanVideoPresenter
_commandBuffer,
_overlayImage,
ImageLayout.TransferSrcOptimal,
- _swapchainImages[imageIndex],
+ presentationTarget,
ImageLayout.TransferDstOptimal,
1,
©);
- var swapchainToPresent = new ImageMemoryBarrier
+ var presentationTargetToFinal = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
- DstAccessMask = 0,
+ DstAccessMask = _hdrOutputActive ? AccessFlags.ShaderReadBit : 0,
OldLayout = ImageLayout.TransferDstOptimal,
- NewLayout = ImageLayout.PresentSrcKhr,
+ NewLayout = PresentationTargetFinalLayout,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
- PipelineStageFlags.BottomOfPipeBit,
- 0, 0, null, 0, null, 1, &swapchainToPresent);
+ _hdrOutputActive
+ ? PipelineStageFlags.FragmentShaderBit
+ : PipelineStageFlags.BottomOfPipeBit,
+ 0, 0, null, 0, null, 1, &presentationTargetToFinal);
_overlayImageInitialized = true;
}
@@ -5387,9 +5030,12 @@ internal static unsafe class VulkanVideoPresenter
var submitLabel = string.IsNullOrEmpty(submitContext)
? "vkQueueSubmit(guest)"
: $"vkQueueSubmit(guest) during {submitContext}";
- Check(
- _vk.QueueSubmit(_queue, 1, &submitInfo, fence),
- submitLabel);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.QueueSubmit))
+ {
+ Check(
+ _vk.QueueSubmit(_queue, 1, &submitInfo, fence),
+ submitLabel);
+ }
}
catch
{
@@ -5778,11 +5424,13 @@ internal static unsafe class VulkanVideoPresenter
{
if (!TryMakeActiveGuestQueueSubmissionsCpuVisible())
{
+ RenderPhaseProfile.RecordOrderedAction(work.DebugName, completed: false);
return false;
}
WriteBackAllDirtyGuestBuffers(_activeGuestQueue.Name);
work.Action();
+ RenderPhaseProfile.RecordOrderedAction(work.DebugName, completed: true);
if (_traceVulkanShaderEnabled)
{
TraceVulkanShader(
@@ -5931,6 +5579,12 @@ internal static unsafe class VulkanVideoPresenter
lock (_gate)
{
var sequence = (_latestPresentation?.Sequence ?? 0) + 1;
+ var isHdr =
+ VideoOutExports.TryGetDisplayBufferInfo(
+ work.VideoOutHandle,
+ work.DisplayBufferIndex,
+ out var displayBuffer) &&
+ VideoOutExports.IsHdrPixelFormat(displayBuffer.PixelFormat);
var presentation = new Presentation(
null,
work.Width,
@@ -5941,7 +5595,8 @@ internal static unsafe class VulkanVideoPresenter
RequiredGuestWorkSequence: _activeGuestWorkSequence,
IsSplash: false,
GuestImageAddress: work.Address,
- GuestImageVersion: work.Version);
+ GuestImageVersion: work.Version,
+ IsHdr: isHdr);
_latestPresentation = presentation;
_pendingGuestImagePresentations.Enqueue(presentation);
while (_pendingGuestImagePresentations.Count > MaxPendingGuestFlipVersions)
@@ -6298,16 +5953,17 @@ internal static unsafe class VulkanVideoPresenter
private void CreateGuestDrawResources()
{
+ var presentationFormat = PresentationTargetFormat;
var colorAttachment = new AttachmentDescription
{
- Format = _swapchainFormat,
+ Format = presentationFormat,
Samples = SampleCountFlags.Count1Bit,
LoadOp = AttachmentLoadOp.Clear,
StoreOp = AttachmentStoreOp.Store,
StencilLoadOp = AttachmentLoadOp.DontCare,
StencilStoreOp = AttachmentStoreOp.DontCare,
InitialLayout = ImageLayout.Undefined,
- FinalLayout = ImageLayout.PresentSrcKhr,
+ FinalLayout = PresentationTargetFinalLayout,
};
var colorReference = new AttachmentReference
{
@@ -6355,6 +6011,13 @@ internal static unsafe class VulkanVideoPresenter
_swapchainImageViews = new ImageView[_swapchainImages.Length];
_framebuffers = new Framebuffer[_swapchainImages.Length];
+ if (_hdrOutputActive)
+ {
+ _presentationImages = new Image[_swapchainImages.Length];
+ _presentationImageMemory = new DeviceMemory[_swapchainImages.Length];
+ _presentationImageViews = new ImageView[_swapchainImages.Length];
+ _presentationSampleViews = new ImageView[_swapchainImages.Length];
+ }
for (var index = 0; index < _swapchainImages.Length; index++)
{
var viewInfo = new ImageViewCreateInfo
@@ -6375,6 +6038,11 @@ internal static unsafe class VulkanVideoPresenter
"vkCreateImageView");
var imageView = _swapchainImageViews[index];
+ if (_hdrOutputActive)
+ {
+ CreatePresentationImage(index);
+ imageView = _presentationImageViews[index];
+ }
var framebufferInfo = new FramebufferCreateInfo
{
SType = StructureType.FramebufferCreateInfo,
@@ -6398,6 +6066,457 @@ internal static unsafe class VulkanVideoPresenter
_vk.CreatePipelineLayout(_device, &layoutInfo, null, out _pipelineLayout),
"vkCreatePipelineLayout");
CreateBarycentricPipeline();
+ if (_hdrOutputActive)
+ {
+ CreateHdrPresentationResources();
+ }
+ }
+
+ private Format PresentationTargetFormat =>
+ _hdrOutputActive ? Format.B8G8R8A8Unorm : _swapchainFormat;
+
+ private ImageLayout PresentationTargetFinalLayout =>
+ _hdrOutputActive ? ImageLayout.ShaderReadOnlyOptimal : ImageLayout.PresentSrcKhr;
+
+ private Image PresentationTargetImage(uint imageIndex) =>
+ _hdrOutputActive ? _presentationImages[imageIndex] : _swapchainImages[imageIndex];
+
+ private void CreatePresentationImage(int index)
+ {
+ var imageInfo = new ImageCreateInfo
+ {
+ SType = StructureType.ImageCreateInfo,
+ Flags = ImageCreateFlags.CreateMutableFormatBit,
+ ImageType = ImageType.Type2D,
+ Format = Format.B8G8R8A8Unorm,
+ Extent = new Extent3D(_extent.Width, _extent.Height, 1),
+ MipLevels = 1,
+ ArrayLayers = 1,
+ Samples = SampleCountFlags.Count1Bit,
+ Tiling = ImageTiling.Optimal,
+ Usage = ImageUsageFlags.ColorAttachmentBit |
+ ImageUsageFlags.TransferDstBit |
+ ImageUsageFlags.TransferSrcBit |
+ ImageUsageFlags.SampledBit,
+ SharingMode = SharingMode.Exclusive,
+ InitialLayout = ImageLayout.Undefined,
+ };
+ Check(
+ _vk.CreateImage(_device, &imageInfo, null, out _presentationImages[index]),
+ "vkCreateImage(HDR presentation source)");
+ _vk.GetImageMemoryRequirements(
+ _device,
+ _presentationImages[index],
+ out var requirements);
+ var allocationInfo = new MemoryAllocateInfo
+ {
+ SType = StructureType.MemoryAllocateInfo,
+ AllocationSize = requirements.Size,
+ MemoryTypeIndex = FindMemoryType(
+ requirements.MemoryTypeBits,
+ MemoryPropertyFlags.DeviceLocalBit),
+ };
+ Check(
+ _vk.AllocateMemory(
+ _device,
+ &allocationInfo,
+ null,
+ out _presentationImageMemory[index]),
+ "vkAllocateMemory(HDR presentation source)");
+ Check(
+ _vk.BindImageMemory(
+ _device,
+ _presentationImages[index],
+ _presentationImageMemory[index],
+ 0),
+ "vkBindImageMemory(HDR presentation source)");
+
+ _presentationImageViews[index] = CreatePresentationImageView(
+ _presentationImages[index],
+ Format.B8G8R8A8Unorm);
+ _presentationSampleViews[index] = CreatePresentationImageView(
+ _presentationImages[index],
+ Format.B8G8R8A8Srgb);
+ }
+
+ private ImageView CreatePresentationImageView(Image image, Format format)
+ {
+ var viewInfo = new ImageViewCreateInfo
+ {
+ SType = StructureType.ImageViewCreateInfo,
+ Image = image,
+ ViewType = ImageViewType.Type2D,
+ Format = format,
+ Components = new ComponentMapping(
+ ComponentSwizzle.Identity,
+ ComponentSwizzle.Identity,
+ ComponentSwizzle.Identity,
+ ComponentSwizzle.Identity),
+ SubresourceRange = ColorSubresourceRange(),
+ };
+ Check(
+ _vk.CreateImageView(_device, &viewInfo, null, out var view),
+ "vkCreateImageView(HDR presentation source)");
+ return view;
+ }
+
+ private void CreateHdrPresentationResources()
+ {
+ var colorAttachment = new AttachmentDescription
+ {
+ Format = _swapchainFormat,
+ Samples = SampleCountFlags.Count1Bit,
+ LoadOp = AttachmentLoadOp.Clear,
+ StoreOp = AttachmentStoreOp.Store,
+ StencilLoadOp = AttachmentLoadOp.DontCare,
+ StencilStoreOp = AttachmentStoreOp.DontCare,
+ InitialLayout = ImageLayout.Undefined,
+ FinalLayout = ImageLayout.PresentSrcKhr,
+ };
+ var colorReference = new AttachmentReference(0, ImageLayout.ColorAttachmentOptimal);
+ var subpass = new SubpassDescription
+ {
+ PipelineBindPoint = PipelineBindPoint.Graphics,
+ ColorAttachmentCount = 1,
+ PColorAttachments = &colorReference,
+ };
+ var dependency = new SubpassDependency
+ {
+ SrcSubpass = Vk.SubpassExternal,
+ DstSubpass = 0,
+ SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit,
+ DstStageMask = PipelineStageFlags.ColorAttachmentOutputBit,
+ DstAccessMask = AccessFlags.ColorAttachmentWriteBit,
+ };
+ var renderPassInfo = new RenderPassCreateInfo
+ {
+ SType = StructureType.RenderPassCreateInfo,
+ AttachmentCount = 1,
+ PAttachments = &colorAttachment,
+ SubpassCount = 1,
+ PSubpasses = &subpass,
+ DependencyCount = 1,
+ PDependencies = &dependency,
+ };
+ Check(
+ _vk.CreateRenderPass(_device, &renderPassInfo, null, out _hdrRenderPass),
+ "vkCreateRenderPass(HDR presentation)");
+
+ _hdrFramebuffers = new Framebuffer[_swapchainImageViews.Length];
+ for (var index = 0; index < _swapchainImageViews.Length; index++)
+ {
+ var view = _swapchainImageViews[index];
+ var framebufferInfo = new FramebufferCreateInfo
+ {
+ SType = StructureType.FramebufferCreateInfo,
+ RenderPass = _hdrRenderPass,
+ AttachmentCount = 1,
+ PAttachments = &view,
+ Width = _extent.Width,
+ Height = _extent.Height,
+ Layers = 1,
+ };
+ Check(
+ _vk.CreateFramebuffer(
+ _device,
+ &framebufferInfo,
+ null,
+ out _hdrFramebuffers[index]),
+ "vkCreateFramebuffer(HDR presentation)");
+ }
+
+ var binding = new DescriptorSetLayoutBinding
+ {
+ Binding = 1,
+ DescriptorType = DescriptorType.CombinedImageSampler,
+ DescriptorCount = 1,
+ StageFlags = ShaderStageFlags.FragmentBit,
+ };
+ var descriptorLayoutInfo = new DescriptorSetLayoutCreateInfo
+ {
+ SType = StructureType.DescriptorSetLayoutCreateInfo,
+ BindingCount = 1,
+ PBindings = &binding,
+ };
+ Check(
+ _vk.CreateDescriptorSetLayout(
+ _device,
+ &descriptorLayoutInfo,
+ null,
+ out _hdrDescriptorSetLayout),
+ "vkCreateDescriptorSetLayout(HDR presentation)");
+
+ var descriptorPoolSize = new DescriptorPoolSize
+ {
+ Type = DescriptorType.CombinedImageSampler,
+ DescriptorCount = checked((uint)_presentationSampleViews.Length * 2),
+ };
+ var descriptorPoolInfo = new DescriptorPoolCreateInfo
+ {
+ SType = StructureType.DescriptorPoolCreateInfo,
+ MaxSets = checked((uint)_presentationSampleViews.Length * 2),
+ PoolSizeCount = 1,
+ PPoolSizes = &descriptorPoolSize,
+ };
+ Check(
+ _vk.CreateDescriptorPool(
+ _device,
+ &descriptorPoolInfo,
+ null,
+ out _hdrDescriptorPool),
+ "vkCreateDescriptorPool(HDR presentation)");
+
+ var samplerInfo = new SamplerCreateInfo
+ {
+ SType = StructureType.SamplerCreateInfo,
+ MagFilter = Filter.Linear,
+ MinFilter = Filter.Linear,
+ MipmapMode = SamplerMipmapMode.Linear,
+ AddressModeU = SamplerAddressMode.ClampToEdge,
+ AddressModeV = SamplerAddressMode.ClampToEdge,
+ AddressModeW = SamplerAddressMode.ClampToEdge,
+ MaxLod = 1f,
+ };
+ Check(
+ _vk.CreateSampler(_device, &samplerInfo, null, out _hdrSampler),
+ "vkCreateSampler(HDR presentation)");
+
+ _hdrDescriptorSets = new DescriptorSet[_presentationSampleViews.Length];
+ _hdrPqDescriptorSets = new DescriptorSet[_presentationSampleViews.Length];
+ for (var pq = 0; pq < 2; pq++)
+ {
+ var descriptorSets = pq == 0 ? _hdrDescriptorSets : _hdrPqDescriptorSets;
+ var imageViews = pq == 0 ? _presentationSampleViews : _presentationImageViews;
+ for (var index = 0; index < descriptorSets.Length; index++)
+ {
+ var layout = _hdrDescriptorSetLayout;
+ var allocateInfo = new DescriptorSetAllocateInfo
+ {
+ SType = StructureType.DescriptorSetAllocateInfo,
+ DescriptorPool = _hdrDescriptorPool,
+ DescriptorSetCount = 1,
+ PSetLayouts = &layout,
+ };
+ Check(
+ _vk.AllocateDescriptorSets(
+ _device,
+ &allocateInfo,
+ out descriptorSets[index]),
+ "vkAllocateDescriptorSets(HDR presentation)");
+ var imageInfo = new DescriptorImageInfo
+ {
+ Sampler = _hdrSampler,
+ ImageView = imageViews[index],
+ ImageLayout = ImageLayout.ShaderReadOnlyOptimal,
+ };
+ var write = new WriteDescriptorSet
+ {
+ SType = StructureType.WriteDescriptorSet,
+ DstSet = descriptorSets[index],
+ DstBinding = 1,
+ DescriptorCount = 1,
+ DescriptorType = DescriptorType.CombinedImageSampler,
+ PImageInfo = &imageInfo,
+ };
+ _vk.UpdateDescriptorSets(_device, 1, &write, 0, null);
+ }
+ }
+
+ var descriptorSetLayout = _hdrDescriptorSetLayout;
+ var pipelineLayoutInfo = new PipelineLayoutCreateInfo
+ {
+ SType = StructureType.PipelineLayoutCreateInfo,
+ SetLayoutCount = 1,
+ PSetLayouts = &descriptorSetLayout,
+ };
+ Check(
+ _vk.CreatePipelineLayout(
+ _device,
+ &pipelineLayoutInfo,
+ null,
+ out _hdrPipelineLayout),
+ "vkCreatePipelineLayout(HDR presentation)");
+ CreateHdrPresentationPipelines();
+ }
+
+ private void CreateHdrPresentationPipelines()
+ {
+ CreateHdrPresentationPipeline(
+ SpirvFixedShaders.CreateCopyFragment(_hdrSdrWhiteLevel),
+ "SDR",
+ out _hdrPipeline);
+ CreateHdrPresentationPipeline(
+ SpirvFixedShaders.CreatePqToScRgbFragment(),
+ "PQ",
+ out _hdrPqPipeline);
+ }
+
+ private void CreateHdrPresentationPipeline(
+ byte[] fragmentBytes,
+ string label,
+ out Pipeline pipeline)
+ {
+ var vertexModule = CreateShaderModule(SpirvFixedShaders.CreateFullscreenVertex(1));
+ var fragmentModule = CreateShaderModule(fragmentBytes);
+ var entryPoint = (byte*)SilkMarshal.StringToPtr("main");
+ try
+ {
+ var stages = stackalloc PipelineShaderStageCreateInfo[2];
+ stages[0] = new PipelineShaderStageCreateInfo
+ {
+ SType = StructureType.PipelineShaderStageCreateInfo,
+ Stage = ShaderStageFlags.VertexBit,
+ Module = vertexModule,
+ PName = entryPoint,
+ };
+ stages[1] = new PipelineShaderStageCreateInfo
+ {
+ SType = StructureType.PipelineShaderStageCreateInfo,
+ Stage = ShaderStageFlags.FragmentBit,
+ Module = fragmentModule,
+ PName = entryPoint,
+ };
+ var vertexInput = new PipelineVertexInputStateCreateInfo
+ {
+ SType = StructureType.PipelineVertexInputStateCreateInfo,
+ };
+ var inputAssembly = new PipelineInputAssemblyStateCreateInfo
+ {
+ SType = StructureType.PipelineInputAssemblyStateCreateInfo,
+ Topology = PrimitiveTopology.TriangleList,
+ };
+ var viewport = new Viewport(0, 0, _extent.Width, _extent.Height, 0, 1);
+ var scissor = new Rect2D(new Offset2D(0, 0), _extent);
+ var viewportState = new PipelineViewportStateCreateInfo
+ {
+ SType = StructureType.PipelineViewportStateCreateInfo,
+ ViewportCount = 1,
+ PViewports = &viewport,
+ ScissorCount = 1,
+ PScissors = &scissor,
+ };
+ var rasterization = new PipelineRasterizationStateCreateInfo
+ {
+ SType = StructureType.PipelineRasterizationStateCreateInfo,
+ PolygonMode = PolygonMode.Fill,
+ CullMode = CullModeFlags.None,
+ FrontFace = FrontFace.CounterClockwise,
+ LineWidth = 1,
+ };
+ var multisample = new PipelineMultisampleStateCreateInfo
+ {
+ SType = StructureType.PipelineMultisampleStateCreateInfo,
+ RasterizationSamples = SampleCountFlags.Count1Bit,
+ };
+ var blendAttachment = new PipelineColorBlendAttachmentState
+ {
+ ColorWriteMask = ColorComponentFlags.RBit |
+ ColorComponentFlags.GBit |
+ ColorComponentFlags.BBit |
+ ColorComponentFlags.ABit,
+ };
+ var blend = new PipelineColorBlendStateCreateInfo
+ {
+ SType = StructureType.PipelineColorBlendStateCreateInfo,
+ AttachmentCount = 1,
+ PAttachments = &blendAttachment,
+ };
+ var pipelineInfo = new GraphicsPipelineCreateInfo
+ {
+ SType = StructureType.GraphicsPipelineCreateInfo,
+ StageCount = 2,
+ PStages = stages,
+ PVertexInputState = &vertexInput,
+ PInputAssemblyState = &inputAssembly,
+ PViewportState = &viewportState,
+ PRasterizationState = &rasterization,
+ PMultisampleState = &multisample,
+ PColorBlendState = &blend,
+ Layout = _hdrPipelineLayout,
+ RenderPass = _hdrRenderPass,
+ };
+ Check(
+ _vk.CreateGraphicsPipelines(
+ _device,
+ _pipelineCache,
+ 1,
+ &pipelineInfo,
+ null,
+ out pipeline),
+ $"vkCreateGraphicsPipelines(HDR {label} presentation)");
+ MarkPipelineCacheDirty();
+ }
+ finally
+ {
+ SilkMarshal.Free((nint)entryPoint);
+ _vk.DestroyShaderModule(_device, fragmentModule, null);
+ _vk.DestroyShaderModule(_device, vertexModule, null);
+ }
+ }
+
+ private void RecordHdrPresentation(uint imageIndex, bool isHdr)
+ {
+ var sourceBarrier = new ImageMemoryBarrier
+ {
+ SType = StructureType.ImageMemoryBarrier,
+ SrcAccessMask = AccessFlags.MemoryWriteBit |
+ AccessFlags.TransferWriteBit |
+ AccessFlags.ColorAttachmentWriteBit,
+ DstAccessMask = AccessFlags.ShaderReadBit,
+ OldLayout = ImageLayout.ShaderReadOnlyOptimal,
+ NewLayout = ImageLayout.ShaderReadOnlyOptimal,
+ SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
+ DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
+ Image = _presentationImages[imageIndex],
+ SubresourceRange = ColorSubresourceRange(),
+ };
+ _vk.CmdPipelineBarrier(
+ _commandBuffer,
+ PipelineStageFlags.AllCommandsBit,
+ PipelineStageFlags.FragmentShaderBit,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 1,
+ &sourceBarrier);
+
+ var clearValue = new ClearValue
+ {
+ Color = new ClearColorValue(0f, 0f, 0f, 1f),
+ };
+ var renderPassInfo = new RenderPassBeginInfo
+ {
+ SType = StructureType.RenderPassBeginInfo,
+ RenderPass = _hdrRenderPass,
+ Framebuffer = _hdrFramebuffers[imageIndex],
+ RenderArea = new Rect2D(new Offset2D(0, 0), _extent),
+ ClearValueCount = 1,
+ PClearValues = &clearValue,
+ };
+ _vk.CmdBeginRenderPass(
+ _commandBuffer,
+ &renderPassInfo,
+ SubpassContents.Inline);
+ _vk.CmdBindPipeline(
+ _commandBuffer,
+ PipelineBindPoint.Graphics,
+ isHdr ? _hdrPqPipeline : _hdrPipeline);
+ var descriptorSet = isHdr
+ ? _hdrPqDescriptorSets[imageIndex]
+ : _hdrDescriptorSets[imageIndex];
+ _vk.CmdBindDescriptorSets(
+ _commandBuffer,
+ PipelineBindPoint.Graphics,
+ _hdrPipelineLayout,
+ 0,
+ 1,
+ &descriptorSet,
+ 0,
+ null);
+ _vk.CmdDraw(_commandBuffer, 3, 1, 0, 0);
+ _vk.CmdEndRenderPass(_commandBuffer);
}
private void CreateBarycentricPipeline()
@@ -12438,9 +12557,16 @@ internal static unsafe class VulkanVideoPresenter
}
}
+ // Non-Vulkan failures here are emulator bugs rather than device
+ // state, and the message alone ("overflow", "index out of range")
+ // names neither the guest draw nor the code that rejected it.
Console.Error.WriteLine(
$"[LOADER][ERROR] Vulkan offscreen draw failed " +
- $"mrt={work.Targets.Count}: {exception.Message}");
+ $"mrt={work.Targets.Count} vs=0x{work.ShaderAddress:X16} " +
+ $"size={work.Targets[0].Width}x{work.Targets[0].Height} " +
+ $"format={work.Targets[0].Format}/{work.Targets[0].NumberType} " +
+ $"textures={work.Draw.Textures.Count} " +
+ $"vertices={work.Draw.VertexCount}: {exception}");
}
finally
{
@@ -12605,7 +12731,7 @@ internal static unsafe class VulkanVideoPresenter
{
if (pixels.Length > 0)
{
- UploadGuestImageInitialData(target, pixels);
+ UploadGuestImageInitialData(target, pixels, work.RowOffset);
}
return;
@@ -12744,7 +12870,10 @@ internal static unsafe class VulkanVideoPresenter
private static uint AlignUp(uint value, uint alignment) =>
(value + alignment - 1) / alignment * alignment;
- private void UploadGuestImageInitialData(GuestImageResource target, byte[] pixels)
+ private void UploadGuestImageInitialData(
+ GuestImageResource target,
+ byte[] pixels,
+ uint rowOffset = 0)
{
var guestDataFormat = (target.GuestFormat & 0x8000_0000u) != 0
? (target.GuestFormat >> 8) & 0x1FFu
@@ -12758,6 +12887,37 @@ internal static unsafe class VulkanVideoPresenter
target.Height,
target.Depth);
+ // A band upload only makes sense on an image that already holds the
+ // rest of the surface. Uninitialized images enter the barrier below
+ // with OldLayout=Undefined, which discards every existing texel — a
+ // partial upload there would leave everything outside the band black
+ // and, because only rewritten rows are ever sent afterwards, it would
+ // stay that way. Refuse the band and take the full path instead.
+ if (rowOffset != 0 && !target.Initialized)
+ {
+ return;
+ }
+
+ // A band upload covers rows [rowOffset, rowOffset + rowCount) of an
+ // otherwise-correct image, so validate it against one row's worth of
+ // the surface rather than the whole thing.
+ var uploadHeight = target.Height;
+ if (rowOffset != 0 || (ulong)uploadPixels.Length != expectedByteCount)
+ {
+ var rowBytes = target.Height == 0 ? 0 : expectedByteCount / target.Height;
+ if (rowBytes != 0 &&
+ target.Depth <= 1 &&
+ (ulong)uploadPixels.Length % rowBytes == 0)
+ {
+ var rows = (uint)((ulong)uploadPixels.Length / rowBytes);
+ if (rows != 0 && rowOffset + rows <= target.Height)
+ {
+ uploadHeight = rows;
+ expectedByteCount = (ulong)uploadPixels.Length;
+ }
+ }
+ }
+
// The guest can hand us linear pixel data whose rows are padded out
// to a hardware pitch wider than the image, so the byte count runs
// past the tightly packed width*height*bpp we compute. Recover the
@@ -12850,10 +13010,10 @@ internal static unsafe class VulkanVideoPresenter
0,
0,
1),
- ImageOffset = default,
+ ImageOffset = new Offset3D(0, (int)rowOffset, 0),
ImageExtent = new Extent3D(
target.Width,
- target.Height,
+ uploadHeight,
target.Depth),
};
_vk.CmdCopyBufferToImage(
@@ -13981,6 +14141,7 @@ internal static unsafe class VulkanVideoPresenter
private void WaitForRenderWork()
{
+ using var profileScope = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Idle);
var gpuWorkInFlight = _pendingGuestSubmissions.Count > 0 ||
Array.Exists(_frameFencePending, static pending => pending);
lock (_gate)
@@ -13998,30 +14159,6 @@ internal static unsafe class VulkanVideoPresenter
}
}
- private bool _overlayHotkeyWasDown;
-
- private void PollPerfOverlayHotkey()
- {
- // POSIX hosts route F1 through the GLFW window's keyboard events
- // (HostWindowInput.Attach). Windows never attaches that path — its
- // input comes from user32 polling — so sample the hotkey here for
- // both the standalone window and the embedded host surface.
- if (!OperatingSystem.IsWindows())
- {
- return;
- }
-
- const int VkF1 = 0x70;
- var input = SharpEmu.HLE.Host.HostPlatform.Current.Input;
- var down = input.IsKeyDown(VkF1) && input.IsHostWindowFocused();
- if (down && !_overlayHotkeyWasDown)
- {
- PerfOverlay.Toggle();
- }
-
- _overlayHotkeyWasDown = down;
- }
-
private void Render(double _)
{
try
@@ -14044,30 +14181,10 @@ internal static unsafe class VulkanVideoPresenter
if (Volatile.Read(ref _presenterCloseRequested))
{
Console.Error.WriteLine("[LOADER][WARN] Vulkan VideoOut closing on host shutdown request.");
- if (_window is not null)
- {
- _window.Close();
- }
- else
- {
- _embeddedLoopClosed = true;
- }
+ _window.Close();
return;
}
- PollPerfOverlayHotkey();
-
- if (_hostSurface is not null)
- {
- _hostSurface.RefreshChildProcessPixelSize();
- if (_lastHostResizeGeneration != _hostSurface.ResizeGeneration)
- {
- _lastHostResizeGeneration = _hostSurface.ResizeGeneration;
- RecreateSwapchainResources("embedded host resize", Result.SuboptimalKhr);
- _pendingHostSplashReplay = _lastHostSplashPresentation;
- }
- }
-
if (!_vulkanReady)
{
return;
@@ -14088,7 +14205,13 @@ internal static unsafe class VulkanVideoPresenter
// Reuse of a frame slot waits only on that slot's fence, keeping
// up to MaxFramesInFlight frames pipelined between CPU and GPU.
var frameSlot = _currentFrameSlot;
- if (!TryWaitFrameSlot(frameSlot, _frameSlotWaitBudgetNs))
+ bool frameSlotReady;
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.FrameSlotWait))
+ {
+ frameSlotReady = TryWaitFrameSlot(frameSlot, _frameSlotWaitBudgetNs);
+ }
+
+ if (!frameSlotReady)
{
// The GPU is still finishing this slot's previous frame (slow
// compute backlog). Don't block the macOS main thread — return
@@ -14102,10 +14225,15 @@ internal static unsafe class VulkanVideoPresenter
_commandBuffer = _presentationCommandBuffer;
if (!_deviceLost)
{
+ using var collectScope = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Collect);
CollectCompletedGuestSubmissions(waitForOldest: false);
}
- DrainGuestImageCpuSync();
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Evict))
+ {
+ DrainGuestImageCpuSync();
+ }
+
var completedWork = 0;
HashSet? deferredOrderedQueues = null;
var workBudgetTicks = _renderWorkBudgetTicks;
@@ -14126,17 +14254,28 @@ internal static unsafe class VulkanVideoPresenter
// backlog), stop processing and let the event pump run; the
// remaining queued work is picked up on later frames as the GPU
// completions free up capacity (collected non-blockingly here).
- CollectCompletedGuestSubmissions(waitForOldest: false);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Collect))
+ {
+ CollectCompletedGuestSubmissions(waitForOldest: false);
+ }
+
if (OperatingSystem.IsMacOS() &&
_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
{
break;
}
- if (!TryTakeGuestWork(
- out var pendingGuestWork,
+ PendingGuestWork pendingGuestWork;
+ bool tookGuestWork;
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.TakeWork))
+ {
+ tookGuestWork = TryTakeGuestWork(
+ out pendingGuestWork,
deferredOrderedQueues,
- preferSyncWork))
+ preferSyncWork);
+ }
+
+ if (!tookGuestWork)
{
break;
}
@@ -14165,10 +14304,14 @@ internal static unsafe class VulkanVideoPresenter
_enqueueAsImmediateQueueFollowup = true;
_immediateFollowupTail = null;
var work = pendingGuestWork.Work;
- _activeGuestWorkLabel = DescribeGuestWork(
- work,
- pendingGuestWork.Queue,
- pendingGuestWork.Sequence);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Describe))
+ {
+ _activeGuestWorkLabel = DescribeGuestWork(
+ work,
+ pendingGuestWork.Queue,
+ pendingGuestWork.Sequence);
+ }
+
_lastGuestWorkLabel = _activeGuestWorkLabel;
var deferGuestWork = false;
@@ -14197,34 +14340,66 @@ internal static unsafe class VulkanVideoPresenter
switch (work)
{
case VulkanOffscreenGuestDraw offscreenDraw:
- ExecuteOffscreenDraw(offscreenDraw);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Draw))
+ {
+ ExecuteOffscreenDraw(offscreenDraw);
+ }
+
break;
case VulkanOffscreenColorClear colorClear:
- ExecuteOffscreenColorClear(colorClear);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.ColorClear))
+ {
+ ExecuteOffscreenColorClear(colorClear);
+ }
+
break;
case VulkanComputeGuestDispatch computeDispatch:
- ExecuteComputeDispatch(computeDispatch);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Compute))
+ {
+ ExecuteComputeDispatch(computeDispatch);
+ }
+
break;
case VulkanGuestImageWrite guestImageWrite:
- ExecuteGuestImageWrite(guestImageWrite);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.ImageWrite))
+ {
+ ExecuteGuestImageWrite(guestImageWrite);
+ }
+
break;
case VulkanOrderedGuestAction orderedAction:
- deferGuestWork = !TryExecuteOrderedGuestAction(orderedAction);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.OrderedAction))
+ {
+ deferGuestWork = !TryExecuteOrderedGuestAction(orderedAction);
+ }
+
break;
case VulkanOrderedGuestFlip orderedFlip:
- ExecuteOrderedGuestFlip(orderedFlip);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Flip))
+ {
+ ExecuteOrderedGuestFlip(orderedFlip);
+ }
+
break;
case VulkanOrderedGuestFlipWait flipWait:
- ExecuteOrderedGuestFlipWait(flipWait);
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Flip))
+ {
+ ExecuteOrderedGuestFlipWait(flipWait);
+ }
+
break;
}
}
finally
{
- if (!deferGuestWork || !RequeueGuestWorkFront(pendingGuestWork))
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.CompleteWork))
{
- CompleteGuestWork(pendingGuestWork);
+ if (!deferGuestWork || !RequeueGuestWorkFront(pendingGuestWork))
+ {
+ CompleteGuestWork(pendingGuestWork);
+ }
}
+
_enqueueAsImmediateQueueFollowup = false;
_immediateFollowupTail = null;
_activeGuestWorkLabel = string.Empty;
@@ -14285,21 +14460,61 @@ internal static unsafe class VulkanVideoPresenter
}
}
- FlushBatchedGuestCommands();
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Flush))
+ {
+ FlushBatchedGuestCommands();
+ }
+
CollectAbandonedGuestImageVersions();
Presentation presentation;
- if (!TryTakePresentation(_presentedSequence, out presentation))
+ if (_window.IsMinimized)
{
- if (_pendingHostSplashReplay is { } splash)
- {
- presentation = splash;
- _pendingHostSplashReplay = null;
- }
- else
- {
- // A render-loop tick with no newer flip is normal. Warn only when
- // an actual queued presentation is waiting on unfinished guest work.
+ return;
+ }
+
+ var framebufferSize = GetFramebufferSize();
+ var drawableSizeChanged =
+ (uint)Math.Max(framebufferSize.X, 1) != _extent.Width ||
+ (uint)Math.Max(framebufferSize.Y, 1) != _extent.Height;
+ var hdrStateChanged = _window.ConsumeHdrStateChange();
+ var guestHdrRequestChanged =
+ _videoOptions.HdrMode == HostHdrMode.Auto &&
+ _hdrRequestedForSwapchain !=
+ (_window.HdrState.Enabled && VideoOutExports.IsHdrOutputRequested);
+ if (_window.ConsumeSurfaceRestore() ||
+ drawableSizeChanged ||
+ _swapchainRecreateDeferred ||
+ hdrStateChanged && _videoOptions.HdrMode != HostHdrMode.Off ||
+ guestHdrRequestChanged)
+ {
+ RecreateSwapchainResources(
+ guestHdrRequestChanged
+ ? "guest HDR output change"
+ : hdrStateChanged
+ ? "SDL HDR state change"
+ : drawableSizeChanged
+ ? "SDL drawable resize"
+ : "restored SDL window",
+ Result.SuboptimalKhr);
+ return;
+ }
+
+ bool tookPresentation;
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.TakePresentation))
+ {
+ tookPresentation = TryTakePresentation(_presentedSequence, out presentation);
+ }
+
+ if (!tookPresentation)
+ {
+ // Upstream also replays the last host splash here after an
+ // embedded-surface resize. That path is gone with the SDL
+ // window: there is no host surface to resize around, and the
+ // swapchain recreate above already covers SDL's own resize.
+ //
+ // A render-loop tick with no newer flip is normal. Warn only when
+ // an actual queued presentation is waiting on unfinished guest work.
var hasPendingPresentation =
HasPendingGuestPresentation(_presentedSequence);
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TracePresentNotTaken(
@@ -14307,7 +14522,7 @@ internal static unsafe class VulkanVideoPresenter
hasPendingPresentation);
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot();
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
- hasPendingPresentation &&
+ hasPendingPresentation &&
_presentNotTakenLoggedSequence != _presentedSequence)
{
_presentNotTakenLoggedSequence = _presentedSequence;
@@ -14317,19 +14532,6 @@ internal static unsafe class VulkanVideoPresenter
}
return;
- }
- }
- if (_hostSurface is not null)
- {
- if (presentation.IsSplash && presentation.Pixels is not null)
- {
- _lastHostSplashPresentation = presentation;
- }
- else
- {
- _lastHostSplashPresentation = null;
- _pendingHostSplashReplay = null;
- }
}
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TracePresentTaken(
@@ -14358,13 +14560,6 @@ internal static unsafe class VulkanVideoPresenter
{
pixels = presentation.Width == _extent.Width && presentation.Height == _extent.Height
? sourcePixels
- : _hostSurface is not null
- ? ScaleBgraCoverBilinear(
- sourcePixels,
- presentation.Width,
- presentation.Height,
- _extent.Width,
- _extent.Height)
: ScaleBgra(
sourcePixels,
presentation.Width,
@@ -14444,7 +14639,7 @@ internal static unsafe class VulkanVideoPresenter
translatedResources = CreateTranslatedDrawResources(
translatedDraw,
_renderPass,
- [_swapchainFormat],
+ [PresentationTargetFormat],
_extent);
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
!_firstGuestDrawPresented &&
@@ -14467,33 +14662,35 @@ internal static unsafe class VulkanVideoPresenter
}
uint imageIndex;
- var acquireResult = _swapchainApi.AcquireNextImage(
- _device,
- _swapchain,
- ulong.MaxValue,
- _frameImageAvailable[frameSlot],
- default,
- &imageIndex);
+ Result acquireResult;
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Acquire))
+ {
+ acquireResult = _swapchainApi.AcquireNextImage(
+ _device,
+ _swapchain,
+ SwapchainAcquireTimeoutNs,
+ _frameImageAvailable[frameSlot],
+ default,
+ &imageIndex);
+ }
+ if (acquireResult == Result.Timeout)
+ {
+ ReleaseUnsubmittedPresentationResources(
+ frameSlot,
+ translatedResources,
+ ownsPresentedGuestImageVersion,
+ presentedGuestImage);
+ return;
+ }
+
if (acquireResult == Result.ErrorOutOfDateKhr)
{
RecreateSwapchainResources("vkAcquireNextImageKHR", acquireResult);
- if (translatedResources is not null)
- {
- DestroyTranslatedDrawResources(translatedResources);
- }
- if (ownsPresentedGuestImageVersion && presentedGuestImage is not null)
- {
- if (_frameGuestImageVersions.Length > frameSlot &&
- ReferenceEquals(
- _frameGuestImageVersions[frameSlot],
- presentedGuestImage))
- {
- _frameGuestImageVersions[frameSlot] = null;
- _capturedGuestFlipVersions.Remove(
- presentedGuestImage.FlipVersion);
- DestroyGuestImage(presentedGuestImage);
- }
- }
+ ReleaseUnsubmittedPresentationResources(
+ frameSlot,
+ translatedResources,
+ ownsPresentedGuestImageVersion,
+ presentedGuestImage);
return;
}
@@ -14510,6 +14707,7 @@ internal static unsafe class VulkanVideoPresenter
}
}
+ using var presentScope = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Present);
Check(_vk.ResetCommandBuffer(_commandBuffer, 0), "vkResetCommandBuffer");
var beginInfo = new CommandBufferBeginInfo
{
@@ -14569,6 +14767,12 @@ internal static unsafe class VulkanVideoPresenter
RecordOverlayBlit(imageIndex, frameSlot);
}
+ if (_hdrOutputActive)
+ {
+ RecordHdrPresentation(imageIndex, presentation.IsHdr);
+ waitStage = PipelineStageFlags.ColorAttachmentOutputBit;
+ }
+
Check(_vk.EndCommandBuffer(_commandBuffer), "vkEndCommandBuffer");
var imageAvailable = _frameImageAvailable[frameSlot];
@@ -14585,9 +14789,13 @@ internal static unsafe class VulkanVideoPresenter
SignalSemaphoreCount = 1,
PSignalSemaphores = &renderFinished,
};
- Check(
- _vk.QueueSubmit(_queue, 1, &submitInfo, _frameFences[frameSlot]),
- "vkQueueSubmit");
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.QueueSubmit))
+ {
+ Check(
+ _vk.QueueSubmit(_queue, 1, &submitInfo, _frameFences[frameSlot]),
+ "vkQueueSubmit");
+ }
+
_submitTimeline++;
_frameTimelines[frameSlot] = _submitTimeline;
_frameFencePending[frameSlot] = true;
@@ -14611,7 +14819,12 @@ internal static unsafe class VulkanVideoPresenter
PSwapchains = &swapchain,
PImageIndices = &imageIndex,
};
- var presentResult = _swapchainApi.QueuePresent(_queue, &presentInfo);
+ Result presentResult;
+ using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.QueuePresent))
+ {
+ presentResult = _swapchainApi.QueuePresent(_queue, &presentInfo);
+ }
+
if (presentResult == Result.ErrorOutOfDateKhr)
{
// The submitted frame still executes; RecreateSwapchainResources
@@ -14624,11 +14837,7 @@ internal static unsafe class VulkanVideoPresenter
recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
VideoOutExports.ReportPresentedFrame();
PerfOverlay.RecordPresent();
- if (_hostSurface is not null && !_firstHostFramePresented)
- {
- _firstHostFramePresented = true;
- NotifyFirstHostFramePresented(_hostSurface);
- }
+ RenderPhaseProfile.RecordFrame();
if (_swapchainReadbackPending || !_pendingAliasImageDumps.IsEmpty)
{
// Diagnostics read back GPU memory and need this frame done.
@@ -14682,6 +14891,29 @@ internal static unsafe class VulkanVideoPresenter
}
}
+ private void ReleaseUnsubmittedPresentationResources(
+ int frameSlot,
+ TranslatedDrawResources? translatedResources,
+ bool ownsPresentedGuestImageVersion,
+ GuestImageResource? presentedGuestImage)
+ {
+ if (translatedResources is not null)
+ {
+ DestroyTranslatedDrawResources(translatedResources);
+ }
+
+ if (!ownsPresentedGuestImageVersion || presentedGuestImage is null ||
+ _frameGuestImageVersions.Length <= frameSlot ||
+ !ReferenceEquals(_frameGuestImageVersions[frameSlot], presentedGuestImage))
+ {
+ return;
+ }
+
+ _frameGuestImageVersions[frameSlot] = null;
+ _capturedGuestFlipVersions.Remove(presentedGuestImage.FlipVersion);
+ DestroyGuestImage(presentedGuestImage);
+ }
+
private void TraceGuestImageContents(GuestImageResource image)
{
var bytesPerPixel = GetReadbackBytesPerPixel(image.Format);
@@ -16638,25 +16870,32 @@ internal static unsafe class VulkanVideoPresenter
private void RecordUpload(uint imageIndex, int frameSlot)
{
+ var presentationTarget = PresentationTargetImage(imageIndex);
var oldLayout = _imageInitialized[imageIndex]
- ? ImageLayout.PresentSrcKhr
+ ? PresentationTargetFinalLayout
: ImageLayout.Undefined;
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
- SrcAccessMask = _imageInitialized[imageIndex] ? AccessFlags.MemoryReadBit : 0,
+ SrcAccessMask = _imageInitialized[imageIndex]
+ ? _hdrOutputActive
+ ? AccessFlags.ShaderReadBit
+ : AccessFlags.MemoryReadBit
+ : 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = oldLayout,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
_imageInitialized[imageIndex]
- ? PipelineStageFlags.BottomOfPipeBit
+ ? _hdrOutputActive
+ ? PipelineStageFlags.FragmentShaderBit
+ : PipelineStageFlags.BottomOfPipeBit
: PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
@@ -16679,34 +16918,38 @@ internal static unsafe class VulkanVideoPresenter
_vk.CmdCopyBufferToImage(
_commandBuffer,
_frameUploadBuffers[frameSlot],
- _swapchainImages[imageIndex],
+ presentationTarget,
ImageLayout.TransferDstOptimal,
1,
©Region);
- var toPresent = new ImageMemoryBarrier
+ var toFinalLayout = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
- DstAccessMask = AccessFlags.MemoryReadBit,
+ DstAccessMask = _hdrOutputActive
+ ? AccessFlags.ShaderReadBit
+ : AccessFlags.MemoryReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
- NewLayout = ImageLayout.PresentSrcKhr,
+ NewLayout = PresentationTargetFinalLayout,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
- PipelineStageFlags.BottomOfPipeBit,
+ _hdrOutputActive
+ ? PipelineStageFlags.FragmentShaderBit
+ : PipelineStageFlags.BottomOfPipeBit,
0,
0,
null,
0,
null,
1,
- &toPresent);
+ &toFinalLayout);
}
// PS5 float VideoOut buffers (A16B16G16R16F flips) hold linear scRGB
@@ -16806,6 +17049,7 @@ internal static unsafe class VulkanVideoPresenter
uint imageIndex,
GuestImageResource source)
{
+ var presentationTarget = PresentationTargetImage(imageIndex);
var presentedCount = Interlocked.Increment(ref _presentedSwapchainCount);
var periodicDumpInterval = SwapchainDumpInterval();
var traceDestination =
@@ -16841,16 +17085,18 @@ internal static unsafe class VulkanVideoPresenter
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = _imageInitialized[imageIndex]
- ? AccessFlags.MemoryReadBit
+ ? _hdrOutputActive
+ ? AccessFlags.ShaderReadBit
+ : AccessFlags.MemoryReadBit
: 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = _imageInitialized[imageIndex]
- ? ImageLayout.PresentSrcKhr
+ ? PresentationTargetFinalLayout
: ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
// Linear-float flips need a linear->sRGB encode on the way to a
@@ -16896,11 +17142,13 @@ internal static unsafe class VulkanVideoPresenter
var sourceY = 0u;
var sourceWidth = source.Width;
var sourceHeight = source.Height;
- if (_hostSurface is not null)
+ var destinationX = 0u;
+ var destinationY = 0u;
+ var destinationWidth = _extent.Width;
+ var destinationHeight = _extent.Height;
+ var scalingMode = _videoOptions.ScalingMode;
+ if (scalingMode == HostScalingMode.Cover)
{
- // The embedded GUI fills its game surface like CSS
- // object-fit: cover. Preserve the guest aspect ratio and crop
- // only the excess instead of distorting every video frame.
var sourceIsWider = (ulong)sourceWidth * _extent.Height >
(ulong)_extent.Width * sourceHeight;
if (sourceIsWider)
@@ -16914,6 +17162,44 @@ internal static unsafe class VulkanVideoPresenter
sourceY = (source.Height - sourceHeight) / 2;
}
}
+ else if (scalingMode is HostScalingMode.Fit or HostScalingMode.Integer)
+ {
+ var useIntegerScale = scalingMode == HostScalingMode.Integer &&
+ sourceWidth <= _extent.Width && sourceHeight <= _extent.Height;
+ if (useIntegerScale)
+ {
+ var scale = Math.Max(1u, Math.Min(_extent.Width / sourceWidth, _extent.Height / sourceHeight));
+ destinationWidth = sourceWidth * scale;
+ destinationHeight = sourceHeight * scale;
+ }
+ else if ((ulong)sourceWidth * _extent.Height > (ulong)_extent.Width * sourceHeight)
+ {
+ destinationWidth = _extent.Width;
+ destinationHeight = Math.Max(1u, (uint)((ulong)_extent.Width * sourceHeight / sourceWidth));
+ }
+ else
+ {
+ destinationHeight = _extent.Height;
+ destinationWidth = Math.Max(1u, (uint)((ulong)_extent.Height * sourceWidth / sourceHeight));
+ }
+
+ destinationX = (_extent.Width - destinationWidth) / 2;
+ destinationY = (_extent.Height - destinationHeight) / 2;
+ }
+
+ if (destinationX != 0 || destinationY != 0 ||
+ destinationWidth != _extent.Width || destinationHeight != _extent.Height)
+ {
+ var clearColor = new ClearColorValue(0f, 0f, 0f, 1f);
+ var clearRange = ColorSubresourceRange();
+ _vk.CmdClearColorImage(
+ _commandBuffer,
+ presentationTarget,
+ ImageLayout.TransferDstOptimal,
+ &clearColor,
+ 1,
+ &clearRange);
+ }
var sourceOffsets = new ImageBlit.SrcOffsetsBuffer
{
@@ -16925,10 +17211,10 @@ internal static unsafe class VulkanVideoPresenter
};
var destinationOffsets = new ImageBlit.DstOffsetsBuffer
{
- Element0 = new Offset3D(0, 0, 0),
+ Element0 = new Offset3D(checked((int)destinationX), checked((int)destinationY), 0),
Element1 = new Offset3D(
- checked((int)_extent.Width),
- checked((int)_extent.Height),
+ checked((int)(destinationX + destinationWidth)),
+ checked((int)(destinationY + destinationHeight)),
1),
};
var region = new ImageBlit
@@ -16952,13 +17238,13 @@ internal static unsafe class VulkanVideoPresenter
// row/column, which shreds 1-2px features in the guest frame.
var isIntegerUpscale =
sourceWidth != 0 && sourceHeight != 0 &&
- _extent.Width >= sourceWidth && _extent.Height >= sourceHeight &&
- _extent.Width % sourceWidth == 0 && _extent.Height % sourceHeight == 0;
+ destinationWidth >= sourceWidth && destinationHeight >= sourceHeight &&
+ destinationWidth % sourceWidth == 0 && destinationHeight % sourceHeight == 0;
_vk.CmdBlitImage(
_commandBuffer,
source.Image,
ImageLayout.TransferSrcOptimal,
- encodeForPresent ? encodeImage : _swapchainImages[imageIndex],
+ encodeForPresent ? encodeImage : presentationTarget,
ImageLayout.TransferDstOptimal,
1,
®ion,
@@ -17021,7 +17307,7 @@ internal static unsafe class VulkanVideoPresenter
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
@@ -17047,7 +17333,7 @@ internal static unsafe class VulkanVideoPresenter
};
_vk.CmdCopyImageToBuffer(
_commandBuffer,
- _swapchainImages[imageIndex],
+ presentationTarget,
ImageLayout.TransferSrcOptimal,
_stagingBuffer,
1,
@@ -17067,28 +17353,32 @@ internal static unsafe class VulkanVideoPresenter
Image = source.Image,
SubresourceRange = ColorSubresourceRange(),
};
- var destinationToPresent = new ImageMemoryBarrier
+ var destinationToFinalLayout = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = traceDestination
? AccessFlags.TransferReadBit
: AccessFlags.TransferWriteBit,
- DstAccessMask = AccessFlags.MemoryReadBit,
+ DstAccessMask = _hdrOutputActive
+ ? AccessFlags.ShaderReadBit
+ : AccessFlags.MemoryReadBit,
OldLayout = traceDestination
? ImageLayout.TransferSrcOptimal
: ImageLayout.TransferDstOptimal,
- NewLayout = ImageLayout.PresentSrcKhr,
+ NewLayout = PresentationTargetFinalLayout,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
- Image = _swapchainImages[imageIndex],
+ Image = presentationTarget,
SubresourceRange = ColorSubresourceRange(),
};
barriers[0] = sourceToShaderRead;
- barriers[1] = destinationToPresent;
+ barriers[1] = destinationToFinalLayout;
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
- PipelineStageFlags.AllCommandsBit,
+ _hdrOutputActive
+ ? PipelineStageFlags.FragmentShaderBit
+ : PipelineStageFlags.AllCommandsBit,
0,
0,
null,
@@ -17179,10 +17469,10 @@ internal static unsafe class VulkanVideoPresenter
{
var fallbackWidth = _extent.Width != 0
? _extent.Width
- : DefaultWindowWidth;
+ : (uint)_videoOptions.Width;
var fallbackHeight = _extent.Height != 0
? _extent.Height
- : DefaultWindowHeight;
+ : (uint)_videoOptions.Height;
return new Extent2D(
ClampSurfaceExtent(
capabilities.CurrentExtent.Width,
@@ -17200,29 +17490,20 @@ internal static unsafe class VulkanVideoPresenter
return new Extent2D(
ClampSurfaceExtent(
(uint)Math.Max(size.X, 1),
- DefaultWindowWidth,
+ (uint)_videoOptions.Width,
capabilities.MinImageExtent.Width,
capabilities.MaxImageExtent.Width),
ClampSurfaceExtent(
(uint)Math.Max(size.Y, 1),
- DefaultWindowHeight,
+ (uint)_videoOptions.Height,
capabilities.MinImageExtent.Height,
capabilities.MaxImageExtent.Height));
}
- private Vector2D GetFramebufferSize()
+ private (int X, int Y) GetFramebufferSize()
{
- if (_window is not null)
- {
- return _window.FramebufferSize;
- }
-
- if (_hostSurface is not null)
- {
- return new Vector2D(_hostSurface.PixelWidth, _hostSurface.PixelHeight);
- }
-
- return new Vector2D((int)DefaultWindowWidth, (int)DefaultWindowHeight);
+ var size = _window.PixelSize;
+ return (size.Width, size.Height);
}
private static uint ClampSurfaceExtent(
@@ -17244,8 +17525,25 @@ internal static unsafe class VulkanVideoPresenter
return Math.Clamp(value, minimum, maximum);
}
- private static SurfaceFormatKHR ChooseSurfaceFormat(IReadOnlyList formats)
+ private static SurfaceFormatKHR ChooseSurfaceFormat(
+ IReadOnlyList formats,
+ bool requestHdr,
+ out bool hdrActive)
{
+ if (requestHdr)
+ {
+ foreach (var format in formats)
+ {
+ if (format.Format == Format.R16G16B16A16Sfloat &&
+ format.ColorSpace == ColorSpaceKHR.SpaceExtendedSrgbLinearExt)
+ {
+ hdrActive = true;
+ return format;
+ }
+ }
+ }
+
+ hdrActive = false;
foreach (var format in formats)
{
if (format.Format is Format.B8G8R8A8Srgb or Format.B8G8R8A8Unorm &&
@@ -17309,88 +17607,6 @@ internal static unsafe class VulkanVideoPresenter
return destination;
}
- private static byte[] ScaleBgraCoverBilinear(
- byte[] source,
- uint sourceWidth,
- uint sourceHeight,
- uint width,
- uint height)
- {
- var destination = new byte[checked((int)(width * height * 4))];
- var sourceIsWider = (ulong)sourceWidth * height > (ulong)width * sourceHeight;
- var cropWidth = sourceIsWider
- ? Math.Max(1u, (uint)((ulong)sourceHeight * width / height))
- : sourceWidth;
- var cropHeight = sourceIsWider
- ? sourceHeight
- : Math.Max(1u, (uint)((ulong)sourceWidth * height / width));
- var offsetX = (sourceWidth - cropWidth) / 2;
- var offsetY = (sourceHeight - cropHeight) / 2;
- var maxSourceX = offsetX + cropWidth - 1;
- var maxSourceY = offsetY + cropHeight - 1;
-
- for (uint y = 0; y < height; y++)
- {
- for (uint x = 0; x < width; x++)
- {
- var destinationOffset = checked((int)(((ulong)y * width + x) * 4));
- float blue = 0;
- float green = 0;
- float red = 0;
- float alpha = 0;
- for (var sampleY = 0; sampleY < 2; sampleY++)
- {
- var scaledY = offsetY + (((y + ((sampleY + 0.5f) / 2)) * cropHeight) / height) - 0.5f;
- var sourceY0 = (uint)Math.Clamp((int)MathF.Floor(scaledY), (int)offsetY, (int)maxSourceY);
- var sourceY1 = Math.Min(sourceY0 + 1, maxSourceY);
- var fractionY = scaledY - MathF.Floor(scaledY);
- for (var sampleX = 0; sampleX < 2; sampleX++)
- {
- var scaledX = offsetX + (((x + ((sampleX + 0.5f) / 2)) * cropWidth) / width) - 0.5f;
- var sourceX0 = (uint)Math.Clamp((int)MathF.Floor(scaledX), (int)offsetX, (int)maxSourceX);
- var sourceX1 = Math.Min(sourceX0 + 1, maxSourceX);
- var fractionX = scaledX - MathF.Floor(scaledX);
- var sourceOffset00 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX0) * 4));
- var sourceOffset10 = checked((int)(((ulong)sourceY0 * sourceWidth + sourceX1) * 4));
- var sourceOffset01 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX0) * 4));
- var sourceOffset11 = checked((int)(((ulong)sourceY1 * sourceWidth + sourceX1) * 4));
-
- var topBlue = source[sourceOffset00] +
- ((source[sourceOffset10] - source[sourceOffset00]) * fractionX);
- var bottomBlue = source[sourceOffset01] +
- ((source[sourceOffset11] - source[sourceOffset01]) * fractionX);
- blue += topBlue + ((bottomBlue - topBlue) * fractionY);
-
- var topGreen = source[sourceOffset00 + 1] +
- ((source[sourceOffset10 + 1] - source[sourceOffset00 + 1]) * fractionX);
- var bottomGreen = source[sourceOffset01 + 1] +
- ((source[sourceOffset11 + 1] - source[sourceOffset01 + 1]) * fractionX);
- green += topGreen + ((bottomGreen - topGreen) * fractionY);
-
- var topRed = source[sourceOffset00 + 2] +
- ((source[sourceOffset10 + 2] - source[sourceOffset00 + 2]) * fractionX);
- var bottomRed = source[sourceOffset01 + 2] +
- ((source[sourceOffset11 + 2] - source[sourceOffset01 + 2]) * fractionX);
- red += topRed + ((bottomRed - topRed) * fractionY);
-
- var topAlpha = source[sourceOffset00 + 3] +
- ((source[sourceOffset10 + 3] - source[sourceOffset00 + 3]) * fractionX);
- var bottomAlpha = source[sourceOffset01 + 3] +
- ((source[sourceOffset11 + 3] - source[sourceOffset01 + 3]) * fractionX);
- alpha += topAlpha + ((bottomAlpha - topAlpha) * fractionY);
- }
- }
-
- destination[destinationOffset] = (byte)MathF.Round(blue * 0.25f);
- destination[destinationOffset + 1] = (byte)MathF.Round(green * 0.25f);
- destination[destinationOffset + 2] = (byte)MathF.Round(red * 0.25f);
- destination[destinationOffset + 3] = (byte)MathF.Round(alpha * 0.25f);
- }
- }
-
- return destination;
- }
-
private void DisposeVulkan()
{
if (!_vulkanReady)
@@ -17550,11 +17766,33 @@ internal static unsafe class VulkanVideoPresenter
CreateSwapchain();
CreateCommandResources();
CreateGuestDrawResources();
+ QueueSplashReplayAfterSwapchainRecreate();
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan VideoOut recreated swapchain: " +
$"{_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
}
+ private void QueueSplashReplayAfterSwapchainRecreate()
+ {
+ lock (_gate)
+ {
+ if (_latestPresentation is not
+ {
+ IsSplash: true,
+ Pixels: not null,
+ } splash ||
+ splash.Sequence > _presentedSequence)
+ {
+ return;
+ }
+
+ _latestPresentation = splash with
+ {
+ Sequence = _presentedSequence + 1,
+ };
+ }
+ }
+
private void DestroySwapchainResources()
{
DestroyHostMovieImage();
@@ -17645,6 +17883,51 @@ internal static unsafe class VulkanVideoPresenter
{
_vk.DestroyFence(_device, recycledFence, null);
}
+ if (_hdrPipeline.Handle != 0)
+ {
+ _vk.DestroyPipeline(_device, _hdrPipeline, null);
+ _hdrPipeline = default;
+ }
+ if (_hdrPqPipeline.Handle != 0)
+ {
+ _vk.DestroyPipeline(_device, _hdrPqPipeline, null);
+ _hdrPqPipeline = default;
+ }
+ if (_hdrPipelineLayout.Handle != 0)
+ {
+ _vk.DestroyPipelineLayout(_device, _hdrPipelineLayout, null);
+ _hdrPipelineLayout = default;
+ }
+ if (_hdrDescriptorPool.Handle != 0)
+ {
+ _vk.DestroyDescriptorPool(_device, _hdrDescriptorPool, null);
+ _hdrDescriptorPool = default;
+ }
+ _hdrDescriptorSets = [];
+ _hdrPqDescriptorSets = [];
+ if (_hdrDescriptorSetLayout.Handle != 0)
+ {
+ _vk.DestroyDescriptorSetLayout(_device, _hdrDescriptorSetLayout, null);
+ _hdrDescriptorSetLayout = default;
+ }
+ if (_hdrSampler.Handle != 0)
+ {
+ _vk.DestroySampler(_device, _hdrSampler, null);
+ _hdrSampler = default;
+ }
+ foreach (var framebuffer in _hdrFramebuffers)
+ {
+ if (framebuffer.Handle != 0)
+ {
+ _vk.DestroyFramebuffer(_device, framebuffer, null);
+ }
+ }
+ _hdrFramebuffers = [];
+ if (_hdrRenderPass.Handle != 0)
+ {
+ _vk.DestroyRenderPass(_device, _hdrRenderPass, null);
+ _hdrRenderPass = default;
+ }
if (_barycentricPipeline.Handle != 0)
{
_vk.DestroyPipeline(_device, _barycentricPipeline, null);
@@ -17674,6 +17957,38 @@ internal static unsafe class VulkanVideoPresenter
_vk.DestroyImageView(_device, imageView, null);
}
}
+ foreach (var imageView in _presentationSampleViews)
+ {
+ if (imageView.Handle != 0)
+ {
+ _vk.DestroyImageView(_device, imageView, null);
+ }
+ }
+ _presentationSampleViews = [];
+ foreach (var imageView in _presentationImageViews)
+ {
+ if (imageView.Handle != 0)
+ {
+ _vk.DestroyImageView(_device, imageView, null);
+ }
+ }
+ _presentationImageViews = [];
+ foreach (var image in _presentationImages)
+ {
+ if (image.Handle != 0)
+ {
+ _vk.DestroyImage(_device, image, null);
+ }
+ }
+ _presentationImages = [];
+ foreach (var memory in _presentationImageMemory)
+ {
+ if (memory.Handle != 0)
+ {
+ _vk.FreeMemory(_device, memory, null);
+ }
+ }
+ _presentationImageMemory = [];
if (_commandPool.Handle != 0)
{
// Destroying the pool frees every command buffer allocated
@@ -17695,6 +18010,10 @@ internal static unsafe class VulkanVideoPresenter
_swapchainImageViews = [];
_framebuffers = [];
_imageInitialized = [];
+ _hdrOutputActive = false;
+ _hdrRequestedForSwapchain = false;
+ _hdrSdrWhiteLevel = 1f;
+ _hdrHeadroom = 1f;
}
private static void CheckSwapchainResult(Result result, string operation)
diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs b/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs
index 3b2493c1..c35e265c 100644
--- a/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs
+++ b/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs
@@ -92,7 +92,7 @@ public static class SpirvFixedShaders
return module.Build();
}
- public static byte[] CreateCopyFragment()
+ public static byte[] CreateCopyFragment(float colorScale = 1f)
{
var module = new SpirvModuleBuilder();
module.AddCapability(SpirvCapability.Shader);
@@ -152,6 +152,16 @@ public static class SpirvFixedShaders
coordinates,
2,
lod);
+ if (colorScale != 1f)
+ {
+ var scale = module.ConstantComposite(
+ vec4Type,
+ module.ConstantFloat(floatType, colorScale),
+ module.ConstantFloat(floatType, colorScale),
+ module.ConstantFloat(floatType, colorScale),
+ module.ConstantFloat(floatType, 1f));
+ color = module.AddInstruction(SpirvOp.FMul, vec4Type, color, scale);
+ }
module.AddStatement(SpirvOp.Store, output, color);
module.AddStatement(SpirvOp.Return);
module.EndFunction();
@@ -165,6 +175,155 @@ public static class SpirvFixedShaders
return module.Build();
}
+ public static byte[] CreatePqToScRgbFragment()
+ {
+ const float inverseM1 = 16384f / 2610f;
+ const float inverseM2 = 32f / 2523f;
+ const float c1 = 3424f / 4096f;
+ const float c2 = 2413f / 128f;
+ const float c3 = 2392f / 128f;
+
+ var module = new SpirvModuleBuilder();
+ module.AddCapability(SpirvCapability.Shader);
+ var glsl = module.ImportExtInst("GLSL.std.450");
+ var voidType = module.TypeVoid();
+ var floatType = module.TypeFloat(32);
+ var vec2Type = module.TypeVector(floatType, 2);
+ var vec3Type = module.TypeVector(floatType, 3);
+ var vec4Type = module.TypeVector(floatType, 4);
+ var inputVec4Pointer = module.TypePointer(SpirvStorageClass.Input, vec4Type);
+ var outputVec4Pointer = module.TypePointer(SpirvStorageClass.Output, vec4Type);
+ var imageType = module.TypeImage(
+ floatType,
+ SpirvImageDim.Dim2D,
+ depth: false,
+ arrayed: false,
+ multisampled: false,
+ sampled: 1,
+ SpirvImageFormat.Unknown);
+ var sampledImageType = module.TypeSampledImage(imageType);
+ var sampledImagePointer =
+ module.TypePointer(SpirvStorageClass.UniformConstant, sampledImageType);
+ var attribute = module.AddGlobalVariable(inputVec4Pointer, SpirvStorageClass.Input);
+ module.AddDecoration(attribute, SpirvDecoration.Location, 0);
+ var texture = module.AddGlobalVariable(
+ sampledImagePointer,
+ SpirvStorageClass.UniformConstant);
+ module.AddDecoration(texture, SpirvDecoration.DescriptorSet, 0);
+ module.AddDecoration(texture, SpirvDecoration.Binding, 1);
+ var output = module.AddGlobalVariable(outputVec4Pointer, SpirvStorageClass.Output);
+ module.AddDecoration(output, SpirvDecoration.Location, 0);
+
+ uint Float(float value) => module.ConstantFloat(floatType, value);
+ uint Vec3(float value) => module.ConstantComposite(
+ vec3Type,
+ Float(value),
+ Float(value),
+ Float(value));
+ uint Ext(uint operation, uint resultType, params uint[] operands)
+ {
+ var values = new uint[2 + operands.Length];
+ values[0] = glsl;
+ values[1] = operation;
+ operands.CopyTo(values, 2);
+ return module.AddInstruction(SpirvOp.ExtInst, resultType, values);
+ }
+ uint Component(uint vector, uint index) =>
+ module.AddInstruction(SpirvOp.CompositeExtract, floatType, vector, index);
+ uint Multiply(uint left, float right) =>
+ module.AddInstruction(SpirvOp.FMul, floatType, left, Float(right));
+ uint Add3(uint first, uint second, uint third) =>
+ module.AddInstruction(
+ SpirvOp.FAdd,
+ floatType,
+ module.AddInstruction(SpirvOp.FAdd, floatType, first, second),
+ third);
+
+ var functionType = module.TypeFunction(voidType);
+ var main = module.BeginFunction(voidType, functionType);
+ module.AddLabel();
+ var attributeValue = module.AddInstruction(SpirvOp.Load, vec4Type, attribute);
+ var coordinates = module.AddInstruction(
+ SpirvOp.VectorShuffle,
+ vec2Type,
+ attributeValue,
+ attributeValue,
+ 0,
+ 1);
+ var sampledImage = module.AddInstruction(SpirvOp.Load, sampledImageType, texture);
+ var color = module.AddInstruction(
+ SpirvOp.ImageSampleExplicitLod,
+ vec4Type,
+ sampledImage,
+ coordinates,
+ 2,
+ Float(0));
+ var pq = module.AddInstruction(
+ SpirvOp.VectorShuffle,
+ vec3Type,
+ color,
+ color,
+ 0,
+ 1,
+ 2);
+
+ // SMPTE ST 2084 converts normalized PQ code values to absolute luminance.
+ var powered = Ext(26, vec3Type, pq, Vec3(inverseM2));
+ var numerator = Ext(
+ 40,
+ vec3Type,
+ module.AddInstruction(SpirvOp.FSub, vec3Type, powered, Vec3(c1)),
+ Vec3(0));
+ var denominator = module.AddInstruction(
+ SpirvOp.FSub,
+ vec3Type,
+ Vec3(c2),
+ module.AddInstruction(SpirvOp.FMul, vec3Type, Vec3(c3), powered));
+ var normalizedLuminance = Ext(
+ 26,
+ vec3Type,
+ module.AddInstruction(SpirvOp.FDiv, vec3Type, numerator, denominator),
+ Vec3(inverseM1));
+ var scRgb2020 = module.AddInstruction(
+ SpirvOp.FMul,
+ vec3Type,
+ normalizedLuminance,
+ Vec3(10000f / 80f));
+
+ var red2020 = Component(scRgb2020, 0);
+ var green2020 = Component(scRgb2020, 1);
+ var blue2020 = Component(scRgb2020, 2);
+ var red = Add3(
+ Multiply(red2020, 1.660491f),
+ Multiply(green2020, -0.587641f),
+ Multiply(blue2020, -0.072850f));
+ var green = Add3(
+ Multiply(red2020, -0.124550f),
+ Multiply(green2020, 1.132900f),
+ Multiply(blue2020, -0.008349f));
+ var blue = Add3(
+ Multiply(red2020, -0.018151f),
+ Multiply(green2020, -0.100579f),
+ Multiply(blue2020, 1.118730f));
+ var converted = module.AddInstruction(
+ SpirvOp.CompositeConstruct,
+ vec4Type,
+ red,
+ green,
+ blue,
+ Component(color, 3));
+ module.AddStatement(SpirvOp.Store, output, converted);
+ module.AddStatement(SpirvOp.Return);
+ module.EndFunction();
+ module.AddEntryPoint(
+ SpirvExecutionModel.Fragment,
+ main,
+ "main",
+ [attribute, texture, output]);
+ module.AddExecutionMode(main, SpirvExecutionMode.OriginUpperLeft);
+ return module.Build();
+ }
+
public static byte[] CreateSolidFragment(float red, float green, float blue, float alpha)
{
var module = new SpirvModuleBuilder();
diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs
new file mode 100644
index 00000000..4d81175b
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs
@@ -0,0 +1,29 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.Libs.VideoOut;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.VideoOut;
+
+public sealed class HostVideoOptionsTests
+{
+ [Fact]
+ public void NormalizeClampsUnsafeHostValues()
+ {
+ var options = new HostVideoOptions
+ {
+ Width = 1,
+ Height = 99_999,
+ DisplayIndex = -2,
+ RefreshRate = 5000,
+ HdrMode = (HostHdrMode)99,
+ }.Normalize();
+
+ Assert.Equal(640, options.Width);
+ Assert.Equal(16384, options.Height);
+ Assert.Equal(0, options.DisplayIndex);
+ Assert.Equal(1000, options.RefreshRate);
+ Assert.Equal(HostHdrMode.Auto, options.HdrMode);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutPixelFormatTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutPixelFormatTests.cs
index cd6c9bf2..08a3bad4 100644
--- a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutPixelFormatTests.cs
+++ b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutPixelFormatTests.cs
@@ -97,6 +97,24 @@ public sealed class VideoOutPixelFormatTests
Assert.Equal(56u, InvokeMapPixelFormat(0xDEADBEEFUL));
}
+ [Theory]
+ [InlineData(0x88740000UL)]
+ [InlineData(0x8100070422000000UL)]
+ [InlineData(0x8100070400000000UL)]
+ public void IsHdrPixelFormat_PqFormats_ReturnTrue(ulong pixelFormat)
+ {
+ Assert.True(VideoOutExports.IsHdrPixelFormat(pixelFormat));
+ }
+
+ [Theory]
+ [InlineData(0x80000000UL)]
+ [InlineData(0x88060000UL)]
+ [InlineData(0x8100000622000000UL)]
+ public void IsHdrPixelFormat_SdrFormats_ReturnFalse(ulong pixelFormat)
+ {
+ Assert.False(VideoOutExports.IsHdrPixelFormat(pixelFormat));
+ }
+
// ---- Self-check activation ----
[Fact]
diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheStorageTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheStorageTests.cs
new file mode 100644
index 00000000..5b069388
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/VideoOut/VulkanPipelineCacheStorageTests.cs
@@ -0,0 +1,74 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.Libs.VideoOut;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.VideoOut;
+
+public sealed class VulkanPipelineCacheStorageTests
+{
+ [Fact]
+ public void ResolvePathUsesExecutableUserDirectoryAndTitleId()
+ {
+ var path = VulkanPipelineCacheStorage.ResolvePath("PPSA02929", configuredPath: null);
+
+ Assert.Equal(
+ Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "pipeline_cache",
+ "PPSA02929",
+ "vulkan-pipeline-cache.bin")),
+ path);
+ }
+
+ [Fact]
+ public void ResolvePathSanitizesTitleId()
+ {
+ var path = VulkanPipelineCacheStorage.ResolvePath("ppsa/02:929", configuredPath: null);
+
+ Assert.EndsWith(
+ Path.Combine("PPSA_02_929", "vulkan-pipeline-cache.bin"),
+ path,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ResolvePathPreservesConfiguredOverride()
+ {
+ var configured = Path.Combine(Path.GetTempPath(), "SharpEmuTests", "custom-cache.bin");
+
+ Assert.Equal(
+ Path.GetFullPath(configured),
+ VulkanPipelineCacheStorage.ResolvePath("PPSA02929", configured));
+ }
+
+ [Fact]
+ public void ImportLegacyCacheDoesNotOverwriteExistingTitleCache()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N"));
+ var legacyPath = Path.Combine(root, "legacy.bin");
+ var destinationPath = Path.Combine(root, "user", "pipeline_cache", "PPSA02929", "cache.bin");
+ try
+ {
+ Directory.CreateDirectory(root);
+ File.WriteAllBytes(legacyPath, [1, 2, 3]);
+
+ Assert.True(VulkanPipelineCacheStorage.ImportLegacyCache(legacyPath, destinationPath));
+ Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(destinationPath));
+ Assert.False(File.Exists(legacyPath));
+
+ File.WriteAllBytes(legacyPath, [4, 5, 6]);
+ Assert.False(VulkanPipelineCacheStorage.ImportLegacyCache(legacyPath, destinationPath));
+ Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(destinationPath));
+ }
+ finally
+ {
+ if (Directory.Exists(root))
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+ }
+}