[video] added sdl window and host display plumbing

This commit is contained in:
ParantezTech
2026-07-28 01:05:42 +03:00
parent 5181df82c8
commit 8c0df4b371
17 changed files with 2925 additions and 1546 deletions
+15 -1
View File
@@ -237,7 +237,21 @@ internal interface IGuestGpuBackend
void SubmitGuestImageFill(ulong address, uint fillValue); void SubmitGuestImageFill(ulong address, uint fillValue);
void SubmitGuestImageWrite(ulong address, byte[] pixels); /// <summary>
/// Uploads guest-authored pixels into a live guest image. <paramref name="rowOffset"/>
/// 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.
/// </summary>
void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0);
/// <summary>
/// Whether a non-zero <c>rowOffset</c> 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.
/// </summary>
bool SupportsPartialImageWrite => false;
/// <summary> /// <summary>
/// Asks the presenter to refresh CPU-dirty guest images on its render/present /// Asks the presenter to refresh CPU-dirty guest images on its render/present
@@ -398,7 +398,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageFill(ulong address, uint fillValue) => public void SubmitGuestImageFill(ulong address, uint fillValue) =>
MetalVideoPresenter.SubmitGuestImageFill(address, 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); MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) => public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
+2 -89
View File
@@ -5,19 +5,6 @@ using System.Runtime.InteropServices;
namespace SharpEmu.Libs.Gpu.Metal; 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)] [StructLayout(LayoutKind.Sequential)]
internal struct CGSize internal struct CGSize
{ {
@@ -93,31 +80,21 @@ internal struct MtlViewport
} }
/// <summary> /// <summary>
/// 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 /// through objc_msgSend, with one LibraryImport overload per distinct native
/// signature. Dependency-free by design — this plus the OS frameworks is the entire /// signature. Dependency-free by design — this plus the OS frameworks is the entire
/// Metal path, which is what keeps it NativeAOT-clean. /// Metal path, which is what keeps it NativeAOT-clean.
/// </summary> /// </summary>
internal static partial class MetalNative 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 ObjCLibrary = "/usr/lib/libobjc.A.dylib";
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal"; 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 const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
private static bool _frameworksLoaded; private static bool _frameworksLoaded;
/// <summary> /// <summary>
/// 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. /// pulled in by its own LibraryImport. Call once before any Class() lookup.
/// </summary> /// </summary>
public static void EnsureFrameworksLoaded() public static void EnsureFrameworksLoaded()
@@ -127,7 +104,6 @@ internal static partial class MetalNative
return; return;
} }
NativeLibrary.Load(AppKitFramework);
NativeLibrary.Load(QuartzCoreFramework); NativeLibrary.Load(QuartzCoreFramework);
_frameworksLoaded = true; _frameworksLoaded = true;
} }
@@ -141,16 +117,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)] [LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint sel_registerName(string name); 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)] [LibraryImport(ObjCLibrary)]
public static partial nint objc_autoreleasePoolPush(); public static partial nint objc_autoreleasePoolPush();
@@ -169,14 +135,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument); public static partial nint Send(nint receiver, nint selector, nint argument);
/// <summary>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.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")]
public static partial void SendStretRect(out CGRect result, nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error); 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")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1); public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
/// <summary>performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
/// to perform is itself an argument, followed by the object and the wait
/// flag.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidPerformSelector(
nint receiver,
nint selector,
nint performedSelector,
nint argument,
[MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
/// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte /// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary> /// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
@@ -237,9 +184,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size); 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")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color); public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
@@ -328,37 +272,6 @@ internal static partial class MetalNative
nuint indexBufferOffset, nuint indexBufferOffset,
nuint instanceCount); 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")] [LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTextureDescriptor( public static partial nint SendTextureDescriptor(
nint receiver, nint receiver,
@@ -1,8 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut; using SharpEmu.Libs.VideoOut;
using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler;
@@ -11,28 +9,12 @@ using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal; namespace SharpEmu.Libs.Gpu.Metal;
/// <summary> /// <summary>
/// The Metal presenter: an AppKit window hosting a CAMetalLayer. A CADisplayLink /// The Metal presenter uses SDL for its host window, events and input, then renders
/// requested from the content view drives <see cref="RenderFrame"/> in sync with the /// into the CAMetalLayer owned by SDL's Metal view. Windowing and input therefore
/// display refresh, on the main run loop — the loop whose Core Animation observer /// share the Vulkan path while all Metal command encoding remains native.
/// 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
/// <see cref="HostMainThread"/> (AppKit traps off-main), which the CLI parks for us.
/// </summary> /// </summary>
internal static partial class MetalVideoPresenter 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 PixelFormatBgra8Unorm = (nuint)MtlPixelFormat.Bgra8Unorm;
private const nuint LoadActionLoad = 1; private const nuint LoadActionLoad = 1;
private const nuint LoadActionClear = 2; private const nuint LoadActionClear = 2;
@@ -69,17 +51,14 @@ internal static partial class MetalVideoPresenter
private static readonly byte[] _overlayPixels = private static readonly byte[] _overlayPixels =
new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4]; new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4];
// Presenter objects and per-frame present state, shared between window setup // Presenter objects and per-frame present state, all used on the SDL host thread.
// and the display-link RenderFrame callback (both on the main thread).
private static nint _device; private static nint _device;
private static nint _commandQueue; private static nint _commandQueue;
private static nint _metalLayer; private static nint _metalLayer;
private static nint _presentPipeline; private static nint _presentPipeline;
private static nint _presentSampler; private static nint _presentSampler;
private static nint _window; private static SdlHostWindow? _hostWindow;
private static nint _application; private static HostVideoOptions _videoOptions = HostVideoOptions.Default;
private static nint _renderTimer;
private static nint _renderTimerTarget;
private static double _drawableWidth; private static double _drawableWidth;
private static double _drawableHeight; private static double _drawableHeight;
private static nint _frameTexture; private static nint _frameTexture;
@@ -91,10 +70,23 @@ internal static partial class MetalVideoPresenter
private static nint _ownedVersionTexture; private static nint _ownedVersionTexture;
private static ulong _presentGuestAddress; private static ulong _presentGuestAddress;
private static long _presentedSequence = -1; private static long _presentedSequence = -1;
private static bool _userClosed;
private static uint _windowWidth; private static uint _windowWidth;
private static uint _windowHeight; 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) public static void EnsureStarted(uint width, uint height)
{ {
if (width == 0 || height == 0) if (width == 0 || height == 0)
@@ -183,6 +175,7 @@ internal static partial class MetalVideoPresenter
public static void RequestClose() public static void RequestClose()
{ {
Volatile.Write(ref _closeRequested, true); Volatile.Write(ref _closeRequested, true);
Volatile.Read(ref _hostWindow)?.Close();
} }
private static void StartPresenterLocked() private static void StartPresenterLocked()
@@ -247,33 +240,23 @@ internal static partial class MetalVideoPresenter
VideoOutExports.SetSelectedGpuName(deviceName); VideoOutExports.SetSelectedGpuName(deviceName);
} }
// Fixed window like the Vulkan presenter: guest frames letterbox into HostVideoOptions videoOptions;
// it. Sizing the window from the guest's display mode (4K) exceeds the lock (_gate)
// screen — macOS clamps the window while the layer keeps the requested {
// geometry, leaving the visible region showing nothing but clear. videoOptions = _videoOptions;
const uint width = DefaultWindowWidth; }
const uint height = DefaultWindowHeight;
using var hostWindow = new SdlHostWindow(
VideoOutExports.GetWindowTitle(),
videoOptions,
SdlGraphicsApi.Metal,
ToggleMetalPerformanceHud);
Volatile.Write(ref _hostWindow, hostWindow);
var setupPool = MetalNative.objc_autoreleasePoolPush(); var setupPool = MetalNative.objc_autoreleasePoolPush();
try try
{ {
_application = MetalNative.Send( _metalLayer = hostWindow.CreateMetalLayer();
MetalNative.Class("NSApplication"), MetalNative.Selector("sharedApplication")); ConfigureMetalLayer(_device, _metalLayer);
// 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);
_commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue")); _commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue"));
if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError)) if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError))
{ {
@@ -282,31 +265,7 @@ internal static partial class MetalVideoPresenter
} }
_presentSampler = CreateLinearSampler(_device); _presentSampler = CreateLinearSampler(_device);
SyncDrawableSizeToLayer();
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"));
} }
finally finally
{ {
@@ -314,25 +273,20 @@ internal static partial class MetalVideoPresenter
} }
Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut presenter started."); 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 try
{ {
MetalNative.SendVoid(_window, MetalNative.Selector("close")); hostWindow.Run(
static () => { },
static _ => RenderFrameSafely(),
static () => { });
} }
finally finally
{ {
MetalNative.objc_autoreleasePoolPop(closePool); Volatile.Write(ref _hostWindow, null);
_metalLayer = 0;
} }
if (_userClosed) if (hostWindow.ClosedByUser)
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
"[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown."); "[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown.");
@@ -340,115 +294,8 @@ internal static partial class MetalVideoPresenter
} }
} }
/// <summary>
/// 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.
/// </summary>
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]<nint, nint, nint, void>)&OnKeyDown;
MetalNative.class_addMethod(cls, MetalNative.Selector("keyDown:"), keyDown, "v@:@");
var keyUp = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, void>)&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]<nint, nint, nint, byte>)&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]<nint, nint, byte>)&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; 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;
}
/// <summary> /// <summary>
/// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps /// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps
/// the built-in CPU-rasterized perf overlay). Configured per Apple's /// 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 /// mode=default|disabled and logging=default, plus any MTL_HUD_* environment
/// keys directly in the dictionary — all three HUD flags (enabled, per-frame /// keys directly in the dictionary — all three HUD flags (enabled, per-frame
/// logging, shader-compile logging) ride in one property set. Runs on the /// 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.
/// </summary> /// </summary>
private static void ToggleMetalPerformanceHud() private static void ToggleMetalPerformanceHud()
{ {
@@ -508,33 +355,13 @@ internal static partial class MetalVideoPresenter
$"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1)."); $"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1).");
} }
private static unsafe nint CreateRenderTimerTarget() /// <summary>The SDL host loop drains guest work continuously. The method
/// remains as the enqueue-side hook used by guest image synchronization.</summary>
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]<nint, nint, nint, void>)&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]<nint, nint, nint, void>)&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 RenderFrameSafely()
private static void OnRenderTimer(nint self, nint cmd, nint timer)
{ {
try try
{ {
@@ -546,78 +373,13 @@ internal static partial class MetalVideoPresenter
} }
} }
/// <summary>Set while an onGuestWork: wake is scheduled on the main run
/// loop; coalesces enqueue-side wake requests to one in-flight message.</summary>
private static int _guestWorkWakeScheduled;
/// <summary>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.</summary>
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() private static void RenderFrame()
{ {
MetalHostInput.PumpAutoKeys();
var pool = MetalNative.objc_autoreleasePoolPush(); var pool = MetalNative.objc_autoreleasePoolPush();
try try
{ {
// NSApp.run dispatches window events itself, so there is no manual if (Volatile.Read(ref _closeRequested))
// event drain here.
var visible = MetalNative.SendBool(_window, MetalNative.Selector("isVisible"));
if (Volatile.Read(ref _closeRequested) || !visible)
{ {
_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; return;
} }
@@ -715,8 +477,7 @@ internal static partial class MetalVideoPresenter
if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal)) if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal))
{ {
_lastWindowTitle = title; _lastWindowTitle = title;
MetalNative.SendVoid( Volatile.Read(ref _hostWindow)?.SetTitle(title);
_window, MetalNative.Selector("setTitle:"), MetalNative.NsString(title));
} }
} }
@@ -725,8 +486,20 @@ internal static partial class MetalVideoPresenter
// for a drawable, or nextDrawable keeps handing back the original // for a drawable, or nextDrawable keeps handing back the original
// resolution and Core Animation stretches it (blurry, mis-scaled // resolution and Core Animation stretches it (blurry, mis-scaled
// overlay). No-op when the size is unchanged, i.e. almost every tick. // 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(); SyncDrawableSizeToLayer();
if (hostWindow?.IsMinimized == true)
{
return;
}
var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable")); var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable"));
if (drawable == 0) if (drawable == 0)
{ {
@@ -846,43 +619,33 @@ internal static partial class MetalVideoPresenter
3); 3);
} }
private static nint CreateWindow(uint width, uint height) private static void ConfigureMetalLayer(nint device, nint layer)
{ {
var window = MetalNative.SendInitWindow( MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
MetalNative.Send(MetalNative.Class("NSWindow"), MetalNative.Selector("alloc")), MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
MetalNative.Selector("initWithContentRect:styleMask:backing:defer:"), MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
new CGRect { X = 0, Y = 0, Width = width, Height = height }, MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
WindowStyleMask,
BackingStoreBuffered, var setDisplaySync = MetalNative.Selector("setDisplaySyncEnabled:");
defer: false); if (MetalNative.SendBool(layer, MetalNative.Selector("respondsToSelector:"), setDisplaySync))
// The presenter owns the handle; AppKit must not free it on user close. {
MetalNative.SendVoidBool(window, MetalNative.Selector("setReleasedWhenClosed:"), false); MetalNative.SendVoidBool(layer, setDisplaySync, _videoOptions.VSync);
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;
} }
/// <summary>Keeps the CAMetalLayer's drawable size (pixels) matched to its /// <summary>Keeps the SDL-owned CAMetalLayer drawable in host pixels as the
/// current bounds (points) × scale as the window resizes or moves between /// window resizes or moves between displays with different scale factors.</summary>
/// displays. CAMetalLayer never updates drawableSize on its own, even as a
/// view's backing layer, so the render loop drives it.</summary>
private static void SyncDrawableSizeToLayer() private static void SyncDrawableSizeToLayer()
{ {
MetalNative.SendStretRect(out var bounds, _metalLayer, MetalNative.Selector("bounds")); var hostWindow = Volatile.Read(ref _hostWindow);
var scale = MetalNative.SendDouble(_metalLayer, MetalNative.Selector("contentsScale")); if (hostWindow is null || _metalLayer == 0)
if (scale <= 0)
{ {
scale = 1; return;
} }
var width = Math.Max(1, Math.Round(bounds.Width * scale)); var pixelSize = hostWindow.PixelSize;
var height = Math.Max(1, Math.Round(bounds.Height * scale)); var width = (double)pixelSize.Width;
var height = (double)pixelSize.Height;
if (width == _drawableWidth && height == _drawableHeight) if (width == _drawableWidth && height == _drawableHeight)
{ {
return; return;
@@ -896,57 +659,6 @@ internal static partial class MetalVideoPresenter
new CGSize { Width = width, Height = height }); 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) private static bool TryCreatePresentPipeline(nint device, out nint pipeline, out string error)
{ {
pipeline = 0; pipeline = 0;
@@ -1105,10 +817,24 @@ internal static partial class MetalVideoPresenter
{ {
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline); MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
// Aspect-fit letterbox: scale the frame into the drawable via the viewport. var scaleX = drawableWidth / frameWidth;
var scale = Math.Min(drawableWidth / frameWidth, drawableHeight / frameHeight); var scaleY = drawableHeight / frameHeight;
var viewportWidth = frameWidth * scale; var viewportWidth = drawableWidth;
var viewportHeight = frameHeight * scale; 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( MetalNative.SendVoidViewport(
encoder, encoder,
MetalNative.Selector("setViewport:"), MetalNative.Selector("setViewport:"),
@@ -378,8 +378,10 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageFill(ulong address, uint fillValue) => public void SubmitGuestImageFill(ulong address, uint fillValue) =>
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue); VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
public void SubmitGuestImageWrite(ulong address, byte[] pixels) => public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels); VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels, rowOffset);
public bool SupportsPartialImageWrite => true;
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) => public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount); VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
@@ -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<HostDisplayMode> Modes);
public static unsafe class HostDisplayCatalog
{
private const SDL_InitFlags VideoFlag = SDL_InitFlags.SDL_INIT_VIDEO;
private static int _queryFailureLogged;
public static IReadOnlyList<HostDisplayInfo> 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<HostDisplayInfo>(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<HostDisplayMode> ReadModes(SDL_DisplayID display)
{
var modes = new HashSet<HostDisplayMode>();
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<HostDisplayMode> 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<HostDisplayInfo> CreateFallback() =>
[new HostDisplayInfo(0, "Display 1", CreateFallbackModes())];
private static IReadOnlyList<HostDisplayMode> 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}");
}
}
}
@@ -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);
}
}
@@ -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;
/// <summary>
/// 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 <see cref="Phase.QueueSubmit"/> inside
/// <see cref="Phase.Flush"/> is never counted twice.
/// </summary>
internal static class RenderPhaseProfile
{
internal enum Phase
{
/// <summary>Outside any measured phase — loop overhead.</summary>
Unattributed = 0,
/// <summary>Parked because no guest work and no newer flip exist.</summary>
Idle,
/// <summary>Blocked on the frame slot's fence: the GPU is behind.</summary>
FrameSlotWait,
/// <summary>Reaping completed guest submissions (fence polls).</summary>
Collect,
Evict,
/// <summary>Dequeuing the next guest work item.</summary>
TakeWork,
/// <summary>Building the diagnostic label for a work item.</summary>
Describe,
/// <summary>Publishing a work item's completion to its waiters.</summary>
CompleteWork,
/// <summary>Selecting the presentation to show this iteration.</summary>
TakePresentation,
Draw,
Compute,
ColorClear,
ImageWrite,
OrderedAction,
Flip,
/// <summary>Closing and submitting the batched guest command buffer.</summary>
Flush,
/// <summary>vkQueueSubmit itself.</summary>
QueueSubmit,
/// <summary>vkAcquireNextImageKHR.</summary>
Acquire,
/// <summary>Recording + submitting the presentation command buffer.</summary>
Present,
/// <summary>vkQueuePresentKHR.</summary>
QueuePresent,
Count,
}
public static readonly bool Enabled = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_RENDER"),
"1",
StringComparison.Ordinal);
/// <summary>
/// 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.
/// </summary>
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<string, OrderedActionStats> _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;
}
/// <summary>
/// Closes out the running phase and switches to <paramref name="next"/>,
/// returning the phase that was running.
/// </summary>
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;
}
/// <summary>Called once per presented frame; also drives the report.</summary>
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 <packet>". 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;
}
}
+832
View File
@@ -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<byte> 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<double> 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<float> acceleration = stackalloc float[3];
Span<float> 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}.");
}
}
}
+48 -30
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Logging;
using SharpEmu.HLE.Host; using SharpEmu.HLE.Host;
using SharpEmu.Libs.Diagnostics; using SharpEmu.Libs.Diagnostics;
using SharpEmu.Libs.Gpu; using SharpEmu.Libs.Gpu;
@@ -69,11 +70,14 @@ public static class VideoOutExports
private static readonly Dictionary<int, VideoOutPortState> _ports = new(); private static readonly Dictionary<int, VideoOutPortState> _ports = new();
private static int _presentationWindowCloseNotified; private static int _presentationWindowCloseNotified;
private static int _vblankStopRequested; private static int _vblankStopRequested;
private static int _hdrOutputRequested;
private static readonly Dictionary<(int Handle, int BufferIndex, ulong Address), ulong> _lastFrameFingerprints = new(); private static readonly Dictionary<(int Handle, int BufferIndex, ulong Address), ulong> _lastFrameFingerprints = new();
private static int _nextHandle = 1; private static int _nextHandle = 1;
private static int _frameDumpCount; private static int _frameDumpCount;
private static long _nextFrameDumpIndex; 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( private static readonly bool _logFrameRate = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"), Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"),
"1", "1",
@@ -113,7 +117,18 @@ public static class VideoOutExports
var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}"; var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}";
lock (_stateGate) 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) 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; : string.Empty;
lock (_stateGate) 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) private static void RequestHostShutdown(string reason)
{ {
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}"); Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
var embedded = VulkanVideoHost.IsEmbedded;
AudioOutExports.ShutdownAllPorts(); AudioOutExports.ShutdownAllPorts();
Interlocked.Exchange(ref _vblankStopRequested, 1); Interlocked.Exchange(ref _vblankStopRequested, 1);
HostSessionControl.RequestShutdown(reason); HostSessionControl.RequestShutdown(reason);
GuestGpu.Current.RequestClose();
// A hosted game can still be issuing AGC work after it requests its // Give guest and GPU threads a bounded window to leave cooperatively.
// own shutdown. Keep the presenter's resources alive until the GUI ThreadPool.QueueUserWorkItem(static _ =>
// session reaches its guest-safe exit path and disposes the host
// surface.
if (!embedded)
{ {
GuestGpu.Current.RequestClose(); Thread.Sleep(2000);
} Environment.Exit(0);
});
// 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);
});
}
} }
private sealed class VideoOutPortState private sealed class VideoOutPortState
@@ -871,6 +876,8 @@ public static class VideoOutExports
} }
} }
internal static bool IsHdrOutputRequested => Volatile.Read(ref _hdrOutputRequested) != 0;
[SysAbiExport( [SysAbiExport(
Nid = "MTxxrOCeSig", Nid = "MTxxrOCeSig",
ExportName = "sceVideoOutSetWindowModeMargins", ExportName = "sceVideoOutSetWindowModeMargins",
@@ -1175,16 +1182,21 @@ public static class VideoOutExports
var guestImageSubmitted = false; var guestImageSubmitted = false;
ulong guestImageAddress = 0; ulong guestImageAddress = 0;
if (submitGpuImage && if (bufferIndex >= 0 &&
bufferIndex >= 0 &&
TryGetDisplayBufferInfo(handle, bufferIndex, out var displayBuffer)) TryGetDisplayBufferInfo(handle, bufferIndex, out var displayBuffer))
{ {
Interlocked.Exchange(
ref _hdrOutputRequested,
IsHdrPixelFormat(displayBuffer.PixelFormat) ? 1 : 0);
guestImageAddress = displayBuffer.Address; guestImageAddress = displayBuffer.Address;
guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage( if (submitGpuImage)
displayBuffer.Address, {
displayBuffer.Width, guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
displayBuffer.Height, displayBuffer.Address,
displayBuffer.PitchInPixel); displayBuffer.Width,
displayBuffer.Height,
displayBuffer.PitchInPixel);
}
} }
if (_dumpVideoOut) if (_dumpVideoOut)
@@ -1711,6 +1723,12 @@ public static class VideoOutExports
internal static bool IsPacked10BitPixelFormat(ulong pixelFormat) => internal static bool IsPacked10BitPixelFormat(ulong pixelFormat) =>
IsPacked10BitPixelFormatNormalized(NormalizePixelFormat(pixelFormat)); IsPacked10BitPixelFormatNormalized(NormalizePixelFormat(pixelFormat));
internal static bool IsHdrPixelFormat(ulong pixelFormat) =>
NormalizePixelFormat(pixelFormat) is
SceVideoOutPixelFormatA2R10G10B10Bt2020Pq or
SceVideoOutPixelFormat2R10G10B10A2Bt2100Pq or
SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq;
private static bool IsPacked10BitPixelFormatNormalized(ulong pixelFormat) => private static bool IsPacked10BitPixelFormatNormalized(ulong pixelFormat) =>
pixelFormat is pixelFormat is
SceVideoOutPixelFormatA2R10G10B10 or SceVideoOutPixelFormatA2R10G10B10 or
@@ -1,270 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
/// <summary>
/// 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.
/// </summary>
public enum VulkanHostSurfaceKind
{
Win32,
Xlib,
Metal,
}
/// <summary>
/// Platform-native handles required to create a Vulkan presentation surface.
/// The GUI owns their lifetime and updates the physical pixel size on resize.
/// </summary>
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; }
/// <summary>X11 Display* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Xlib"/>.</summary>
public nint DisplayHandle { get; }
/// <summary>CAMetalLayer* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Metal"/>.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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));
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Reconstructs a surface in the isolated emulator process. X11 clients
/// must open their own Display connection; Display* values cannot cross a
/// process boundary.
/// </summary>
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;
}
}
/// <summary>
/// Small public bridge between a desktop UI and the internal Vulkan
/// presenter. Launchers can host a surface without depending on renderer
/// submission internals.
/// </summary>
public static class VulkanVideoHost
{
/// <summary>
/// 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.
/// </summary>
public static event Action<VulkanHostSurface>? 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;
}
@@ -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<char> 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);
}
}
File diff suppressed because it is too large Load Diff
@@ -92,7 +92,7 @@ public static class SpirvFixedShaders
return module.Build(); return module.Build();
} }
public static byte[] CreateCopyFragment() public static byte[] CreateCopyFragment(float colorScale = 1f)
{ {
var module = new SpirvModuleBuilder(); var module = new SpirvModuleBuilder();
module.AddCapability(SpirvCapability.Shader); module.AddCapability(SpirvCapability.Shader);
@@ -152,6 +152,16 @@ public static class SpirvFixedShaders
coordinates, coordinates,
2, 2,
lod); 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.Store, output, color);
module.AddStatement(SpirvOp.Return); module.AddStatement(SpirvOp.Return);
module.EndFunction(); module.EndFunction();
@@ -165,6 +175,155 @@ public static class SpirvFixedShaders
return module.Build(); 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) public static byte[] CreateSolidFragment(float red, float green, float blue, float alpha)
{ {
var module = new SpirvModuleBuilder(); var module = new SpirvModuleBuilder();
@@ -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);
}
}
@@ -97,6 +97,24 @@ public sealed class VideoOutPixelFormatTests
Assert.Equal(56u, InvokeMapPixelFormat(0xDEADBEEFUL)); 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 ---- // ---- Self-check activation ----
[Fact] [Fact]
@@ -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);
}
}
}
}