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); + } + } + } +}