mirror of
https://github.com/par274/sharpemu.git
synced 2026-09-02 14:56:49 +08:00
[video] added sdl window and host display plumbing
This commit is contained in:
@@ -237,7 +237,21 @@ internal interface IGuestGpuBackend
|
||||
|
||||
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>
|
||||
/// 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) =>
|
||||
MetalVideoPresenter.SubmitGuestImageFill(address, fillValue);
|
||||
|
||||
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
|
||||
public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
|
||||
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
|
||||
|
||||
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
|
||||
|
||||
@@ -5,19 +5,6 @@ using System.Runtime.InteropServices;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
// Core Graphics / Metal ABI structs passed by value through objc_msgSend. Struct
|
||||
// *returns* are deliberately never used: on x86-64 (this process runs under Rosetta
|
||||
// on Apple silicon) large struct returns switch to objc_msgSend_stret, and avoiding
|
||||
// them entirely keeps one calling convention everywhere.
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct CGRect
|
||||
{
|
||||
public double X;
|
||||
public double Y;
|
||||
public double Width;
|
||||
public double Height;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct CGSize
|
||||
{
|
||||
@@ -93,31 +80,21 @@ internal struct MtlViewport
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// signature. Dependency-free by design — this plus the OS frameworks is the entire
|
||||
/// Metal path, which is what keeps it NativeAOT-clean.
|
||||
/// </summary>
|
||||
internal static partial class MetalNative
|
||||
{
|
||||
private const string CoreFoundation =
|
||||
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
|
||||
|
||||
[LibraryImport(CoreFoundation)]
|
||||
public static partial nint CFRunLoopGetMain();
|
||||
|
||||
[LibraryImport(CoreFoundation)]
|
||||
public static partial void CFRunLoopStop(nint runLoop);
|
||||
|
||||
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
|
||||
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
|
||||
private const string AppKitFramework = "/System/Library/Frameworks/AppKit.framework/AppKit";
|
||||
private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
|
||||
|
||||
private static bool _frameworksLoaded;
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public static void EnsureFrameworksLoaded()
|
||||
@@ -127,7 +104,6 @@ internal static partial class MetalNative
|
||||
return;
|
||||
}
|
||||
|
||||
NativeLibrary.Load(AppKitFramework);
|
||||
NativeLibrary.Load(QuartzCoreFramework);
|
||||
_frameworksLoaded = true;
|
||||
}
|
||||
@@ -141,16 +117,6 @@ internal static partial class MetalNative
|
||||
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
|
||||
private static partial nint sel_registerName(string name);
|
||||
|
||||
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
|
||||
public static partial nint objc_allocateClassPair(nint superclass, string name, nuint extraBytes);
|
||||
|
||||
[LibraryImport(ObjCLibrary)]
|
||||
public static partial void objc_registerClassPair(nint cls);
|
||||
|
||||
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
public static partial bool class_addMethod(nint cls, nint name, nint imp, string types);
|
||||
|
||||
[LibraryImport(ObjCLibrary)]
|
||||
public static partial nint objc_autoreleasePoolPush();
|
||||
|
||||
@@ -169,14 +135,6 @@ internal static partial class MetalNative
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint Send(nint receiver, nint selector, nint argument);
|
||||
|
||||
|
||||
/// <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")]
|
||||
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
|
||||
|
||||
@@ -209,17 +167,6 @@ internal static partial class MetalNative
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
|
||||
|
||||
/// <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
|
||||
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
@@ -237,9 +184,6 @@ internal static partial class MetalNative
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidRect(nint receiver, nint selector, CGRect rect);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
|
||||
|
||||
@@ -328,37 +272,6 @@ internal static partial class MetalNative
|
||||
nuint indexBufferOffset,
|
||||
nuint instanceCount);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendTimer(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
double interval,
|
||||
nint target,
|
||||
nint timerSelector,
|
||||
nint userInfo,
|
||||
[MarshalAs(UnmanagedType.I1)] bool repeats);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendInitFrame(nint receiver, nint selector, CGRect frame);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendInitWindow(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
CGRect contentRect,
|
||||
nuint styleMask,
|
||||
nuint backing,
|
||||
[MarshalAs(UnmanagedType.I1)] bool defer);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendNextEvent(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
ulong eventMask,
|
||||
nint untilDate,
|
||||
nint inMode,
|
||||
[MarshalAs(UnmanagedType.I1)] bool dequeue);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendTextureDescriptor(
|
||||
nint receiver,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
@@ -11,28 +9,12 @@ using SharpEmu.ShaderCompiler.Metal;
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
/// <summary>
|
||||
/// The Metal presenter: an AppKit window hosting a CAMetalLayer. A CADisplayLink
|
||||
/// requested from the content view drives <see cref="RenderFrame"/> in sync with the
|
||||
/// display refresh, on the main run loop — the loop whose Core Animation observer
|
||||
/// commits presented drawables to the window server, so it must be a real running
|
||||
/// run loop (a hand-pumped event drain never fires that observer and the window
|
||||
/// stays black). Everything AppKit runs on the process main thread via
|
||||
/// <see cref="HostMainThread"/> (AppKit traps off-main), which the CLI parks for us.
|
||||
/// The Metal presenter uses SDL for its host window, events and input, then renders
|
||||
/// into the CAMetalLayer owned by SDL's Metal view. Windowing and input therefore
|
||||
/// share the Vulkan path while all Metal command encoding remains native.
|
||||
/// </summary>
|
||||
internal static partial class MetalVideoPresenter
|
||||
{
|
||||
private const uint DefaultWindowWidth = 1280;
|
||||
private const uint DefaultWindowHeight = 720;
|
||||
|
||||
// NSWindow style: Titled | Closable | Miniaturizable | Resizable. Resizable
|
||||
// both lets the user drag the window edges and turns the green zoom button
|
||||
// into the full-screen toggle (paired with the collection behavior below).
|
||||
private const nuint WindowStyleMask = 1 | 2 | 4 | 8;
|
||||
|
||||
// NSWindowCollectionBehaviorFullScreenPrimary: opt this window into native
|
||||
// full-screen, so the green button enters full-screen rather than zooming.
|
||||
private const nuint FullScreenPrimaryBehavior = 1 << 7;
|
||||
private const nuint BackingStoreBuffered = 2;
|
||||
private const nuint PixelFormatBgra8Unorm = (nuint)MtlPixelFormat.Bgra8Unorm;
|
||||
private const nuint LoadActionLoad = 1;
|
||||
private const nuint LoadActionClear = 2;
|
||||
@@ -69,17 +51,14 @@ internal static partial class MetalVideoPresenter
|
||||
private static readonly byte[] _overlayPixels =
|
||||
new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4];
|
||||
|
||||
// Presenter objects and per-frame present state, shared between window setup
|
||||
// and the display-link RenderFrame callback (both on the main thread).
|
||||
// Presenter objects and per-frame present state, all used on the SDL host thread.
|
||||
private static nint _device;
|
||||
private static nint _commandQueue;
|
||||
private static nint _metalLayer;
|
||||
private static nint _presentPipeline;
|
||||
private static nint _presentSampler;
|
||||
private static nint _window;
|
||||
private static nint _application;
|
||||
private static nint _renderTimer;
|
||||
private static nint _renderTimerTarget;
|
||||
private static SdlHostWindow? _hostWindow;
|
||||
private static HostVideoOptions _videoOptions = HostVideoOptions.Default;
|
||||
private static double _drawableWidth;
|
||||
private static double _drawableHeight;
|
||||
private static nint _frameTexture;
|
||||
@@ -91,10 +70,23 @@ internal static partial class MetalVideoPresenter
|
||||
private static nint _ownedVersionTexture;
|
||||
private static ulong _presentGuestAddress;
|
||||
private static long _presentedSequence = -1;
|
||||
private static bool _userClosed;
|
||||
private static uint _windowWidth;
|
||||
private static uint _windowHeight;
|
||||
|
||||
public static bool TryConfigureVideo(HostVideoOptions options)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_thread is not null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_videoOptions = options.Normalize();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static void EnsureStarted(uint width, uint height)
|
||||
{
|
||||
if (width == 0 || height == 0)
|
||||
@@ -183,6 +175,7 @@ internal static partial class MetalVideoPresenter
|
||||
public static void RequestClose()
|
||||
{
|
||||
Volatile.Write(ref _closeRequested, true);
|
||||
Volatile.Read(ref _hostWindow)?.Close();
|
||||
}
|
||||
|
||||
private static void StartPresenterLocked()
|
||||
@@ -247,33 +240,23 @@ internal static partial class MetalVideoPresenter
|
||||
VideoOutExports.SetSelectedGpuName(deviceName);
|
||||
}
|
||||
|
||||
// Fixed window like the Vulkan presenter: guest frames letterbox into
|
||||
// it. Sizing the window from the guest's display mode (4K) exceeds the
|
||||
// screen — macOS clamps the window while the layer keeps the requested
|
||||
// geometry, leaving the visible region showing nothing but clear.
|
||||
const uint width = DefaultWindowWidth;
|
||||
const uint height = DefaultWindowHeight;
|
||||
HostVideoOptions videoOptions;
|
||||
lock (_gate)
|
||||
{
|
||||
videoOptions = _videoOptions;
|
||||
}
|
||||
|
||||
using var hostWindow = new SdlHostWindow(
|
||||
VideoOutExports.GetWindowTitle(),
|
||||
videoOptions,
|
||||
SdlGraphicsApi.Metal,
|
||||
ToggleMetalPerformanceHud);
|
||||
Volatile.Write(ref _hostWindow, hostWindow);
|
||||
var setupPool = MetalNative.objc_autoreleasePoolPush();
|
||||
try
|
||||
{
|
||||
_application = MetalNative.Send(
|
||||
MetalNative.Class("NSApplication"), MetalNative.Selector("sharedApplication"));
|
||||
// NSApplicationActivationPolicyRegular: dock icon + key window like any app.
|
||||
MetalNative.Send(_application, MetalNative.Selector("setActivationPolicy:"), 0);
|
||||
MetalNative.SendVoid(_application, MetalNative.Selector("finishLaunching"));
|
||||
|
||||
_window = CreateWindow(width, height);
|
||||
|
||||
// Swap in the key-capturing view before the metal layer attaches so
|
||||
// the layer lands on the input-aware content view.
|
||||
var keyView = MetalNative.SendInitFrame(
|
||||
MetalNative.Send(CreateKeyViewClass(), MetalNative.Selector("alloc")),
|
||||
MetalNative.Selector("initWithFrame:"),
|
||||
new CGRect { X = 0, Y = 0, Width = width, Height = height });
|
||||
MetalNative.SendVoid(_window, MetalNative.Selector("setContentView:"), keyView);
|
||||
|
||||
_metalLayer = CreateLayer(_device, _window, out _drawableWidth, out _drawableHeight);
|
||||
_metalLayer = hostWindow.CreateMetalLayer();
|
||||
ConfigureMetalLayer(_device, _metalLayer);
|
||||
_commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue"));
|
||||
if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError))
|
||||
{
|
||||
@@ -282,31 +265,7 @@ internal static partial class MetalVideoPresenter
|
||||
}
|
||||
|
||||
_presentSampler = CreateLinearSampler(_device);
|
||||
|
||||
MetalNative.SendVoid(_window, MetalNative.Selector("makeKeyAndOrderFront:"), 0);
|
||||
MetalNative.SendVoidBool(
|
||||
_application, MetalNative.Selector("activateIgnoringOtherApps:"), true);
|
||||
MetalNative.SendVoid(_window, MetalNative.Selector("makeFirstResponder:"), keyView);
|
||||
MetalHostInput.Attach();
|
||||
|
||||
// A repeating NSTimer on this (main) run loop fires onFrame: at the
|
||||
// display rate. CADisplayLink (NSView.displayLinkWithTarget:selector:)
|
||||
// is the natural choice but its callback never fires under the x86-64
|
||||
// Rosetta process this emulator runs as — proven in isolation against
|
||||
// a bare AppKit harness, where a timer fires and composites and the
|
||||
// display link does not. nextDrawable still throttles presentation to
|
||||
// the display, so the timer only needs to keep up, not pace precisely.
|
||||
_renderTimerTarget = CreateRenderTimerTarget();
|
||||
_renderTimer = MetalNative.Send(
|
||||
MetalNative.SendTimer(
|
||||
MetalNative.Class("NSTimer"),
|
||||
MetalNative.Selector("scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:"),
|
||||
1.0 / 60.0,
|
||||
_renderTimerTarget,
|
||||
MetalNative.Selector("onFrame:"),
|
||||
0,
|
||||
repeats: true),
|
||||
MetalNative.Selector("retain"));
|
||||
SyncDrawableSizeToLayer();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -314,25 +273,20 @@ internal static partial class MetalVideoPresenter
|
||||
}
|
||||
|
||||
Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut presenter started.");
|
||||
|
||||
// [NSApp run] runs the main run loop (its Core Animation observer commits
|
||||
// presented drawables to the window server) AND fully activates the app,
|
||||
// which a bare CFRunLoopRun does not — the CADisplayLink is only serviced
|
||||
// once the app is running, and NSApp dispatches window events itself.
|
||||
// Returns once RenderFrame stops it.
|
||||
MetalNative.SendVoid(_application, MetalNative.Selector("run"));
|
||||
|
||||
var closePool = MetalNative.objc_autoreleasePoolPush();
|
||||
try
|
||||
{
|
||||
MetalNative.SendVoid(_window, MetalNative.Selector("close"));
|
||||
hostWindow.Run(
|
||||
static () => { },
|
||||
static _ => RenderFrameSafely(),
|
||||
static () => { });
|
||||
}
|
||||
finally
|
||||
{
|
||||
MetalNative.objc_autoreleasePoolPop(closePool);
|
||||
Volatile.Write(ref _hostWindow, null);
|
||||
_metalLayer = 0;
|
||||
}
|
||||
|
||||
if (_userClosed)
|
||||
if (hostWindow.ClosedByUser)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown.");
|
||||
@@ -340,115 +294,8 @@ internal static partial class MetalVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
[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>
|
||||
/// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps
|
||||
/// the built-in CPU-rasterized perf overlay). Configured per Apple's
|
||||
@@ -456,7 +303,7 @@ internal static partial class MetalVideoPresenter
|
||||
/// mode=default|disabled and logging=default, plus any MTL_HUD_* environment
|
||||
/// keys directly in the dictionary — all three HUD flags (enabled, per-frame
|
||||
/// logging, shader-compile logging) ride in one property set. Runs on the
|
||||
/// AppKit main thread (the key-equivalent path), same thread as the render loop.
|
||||
/// SDL host thread, the same thread as the render loop.
|
||||
/// </summary>
|
||||
private static void ToggleMetalPerformanceHud()
|
||||
{
|
||||
@@ -508,33 +355,13 @@ internal static partial class MetalVideoPresenter
|
||||
$"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1).");
|
||||
}
|
||||
|
||||
private static unsafe nint CreateRenderTimerTarget()
|
||||
/// <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 OnRenderTimer(nint self, nint cmd, nint timer)
|
||||
private static void RenderFrameSafely()
|
||||
{
|
||||
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()
|
||||
{
|
||||
MetalHostInput.PumpAutoKeys();
|
||||
var pool = MetalNative.objc_autoreleasePoolPush();
|
||||
try
|
||||
{
|
||||
// NSApp.run dispatches window events itself, so there is no manual
|
||||
// event drain here.
|
||||
var visible = MetalNative.SendBool(_window, MetalNative.Selector("isVisible"));
|
||||
if (Volatile.Read(ref _closeRequested) || !visible)
|
||||
if (Volatile.Read(ref _closeRequested))
|
||||
{
|
||||
_userClosed = !visible && !Volatile.Read(ref _closeRequested);
|
||||
MetalNative.SendVoid(_renderTimer, MetalNative.Selector("invalidate"));
|
||||
// Stop both the AppKit loop and the underlying CFRunLoop so
|
||||
// [NSApp run] returns.
|
||||
MetalNative.SendVoid(_application, MetalNative.Selector("stop:"), 0);
|
||||
MetalNative.CFRunLoopStop(MetalNative.CFRunLoopGetMain());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -715,8 +477,7 @@ internal static partial class MetalVideoPresenter
|
||||
if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal))
|
||||
{
|
||||
_lastWindowTitle = title;
|
||||
MetalNative.SendVoid(
|
||||
_window, MetalNative.Selector("setTitle:"), MetalNative.NsString(title));
|
||||
Volatile.Read(ref _hostWindow)?.SetTitle(title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,8 +486,20 @@ internal static partial class MetalVideoPresenter
|
||||
// for a drawable, or nextDrawable keeps handing back the original
|
||||
// resolution and Core Animation stretches it (blurry, mis-scaled
|
||||
// overlay). No-op when the size is unchanged, i.e. almost every tick.
|
||||
var hostWindow = Volatile.Read(ref _hostWindow);
|
||||
if (hostWindow?.ConsumeSurfaceRestore() == true)
|
||||
{
|
||||
_drawableWidth = 0;
|
||||
_drawableHeight = 0;
|
||||
}
|
||||
|
||||
SyncDrawableSizeToLayer();
|
||||
|
||||
if (hostWindow?.IsMinimized == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable"));
|
||||
if (drawable == 0)
|
||||
{
|
||||
@@ -846,43 +619,33 @@ internal static partial class MetalVideoPresenter
|
||||
3);
|
||||
}
|
||||
|
||||
private static nint CreateWindow(uint width, uint height)
|
||||
private static void ConfigureMetalLayer(nint device, nint layer)
|
||||
{
|
||||
var window = MetalNative.SendInitWindow(
|
||||
MetalNative.Send(MetalNative.Class("NSWindow"), MetalNative.Selector("alloc")),
|
||||
MetalNative.Selector("initWithContentRect:styleMask:backing:defer:"),
|
||||
new CGRect { X = 0, Y = 0, Width = width, Height = height },
|
||||
WindowStyleMask,
|
||||
BackingStoreBuffered,
|
||||
defer: false);
|
||||
// The presenter owns the handle; AppKit must not free it on user close.
|
||||
MetalNative.SendVoidBool(window, MetalNative.Selector("setReleasedWhenClosed:"), false);
|
||||
MetalNative.Send(
|
||||
window, MetalNative.Selector("setCollectionBehavior:"), (nint)FullScreenPrimaryBehavior);
|
||||
MetalNative.SendVoid(
|
||||
window,
|
||||
MetalNative.Selector("setTitle:"),
|
||||
MetalNative.NsString(VideoOutExports.GetWindowTitle()));
|
||||
MetalNative.SendVoid(window, MetalNative.Selector("center"));
|
||||
// makeKeyAndOrderFront happens after the metal layer is attached.
|
||||
return window;
|
||||
MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
|
||||
MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
|
||||
MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
|
||||
MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
|
||||
|
||||
var setDisplaySync = MetalNative.Selector("setDisplaySyncEnabled:");
|
||||
if (MetalNative.SendBool(layer, MetalNative.Selector("respondsToSelector:"), setDisplaySync))
|
||||
{
|
||||
MetalNative.SendVoidBool(layer, setDisplaySync, _videoOptions.VSync);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Keeps the CAMetalLayer's drawable size (pixels) matched to its
|
||||
/// current bounds (points) × scale as the window resizes or moves between
|
||||
/// displays. CAMetalLayer never updates drawableSize on its own, even as a
|
||||
/// view's backing layer, so the render loop drives it.</summary>
|
||||
/// <summary>Keeps the SDL-owned CAMetalLayer drawable in host pixels as the
|
||||
/// window resizes or moves between displays with different scale factors.</summary>
|
||||
private static void SyncDrawableSizeToLayer()
|
||||
{
|
||||
MetalNative.SendStretRect(out var bounds, _metalLayer, MetalNative.Selector("bounds"));
|
||||
var scale = MetalNative.SendDouble(_metalLayer, MetalNative.Selector("contentsScale"));
|
||||
if (scale <= 0)
|
||||
var hostWindow = Volatile.Read(ref _hostWindow);
|
||||
if (hostWindow is null || _metalLayer == 0)
|
||||
{
|
||||
scale = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var width = Math.Max(1, Math.Round(bounds.Width * scale));
|
||||
var height = Math.Max(1, Math.Round(bounds.Height * scale));
|
||||
var pixelSize = hostWindow.PixelSize;
|
||||
var width = (double)pixelSize.Width;
|
||||
var height = (double)pixelSize.Height;
|
||||
if (width == _drawableWidth && height == _drawableHeight)
|
||||
{
|
||||
return;
|
||||
@@ -896,57 +659,6 @@ internal static partial class MetalVideoPresenter
|
||||
new CGSize { Width = width, Height = height });
|
||||
}
|
||||
|
||||
private static nint CreateLayer(nint device, nint window, out double drawableWidth, out double drawableHeight)
|
||||
{
|
||||
var contentView = MetalNative.Send(window, MetalNative.Selector("contentView"));
|
||||
var scale = MetalNative.SendDouble(window, MetalNative.Selector("backingScaleFactor"));
|
||||
if (scale <= 0)
|
||||
{
|
||||
scale = 1;
|
||||
}
|
||||
|
||||
const uint width = DefaultWindowWidth;
|
||||
const uint height = DefaultWindowHeight;
|
||||
drawableWidth = width * scale;
|
||||
drawableHeight = height * scale;
|
||||
|
||||
var layer = MetalNative.Send(
|
||||
MetalNative.Send(MetalNative.Class("CAMetalLayer"), MetalNative.Selector("alloc")),
|
||||
MetalNative.Selector("init"));
|
||||
MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
|
||||
MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
|
||||
// A Core Animation layer composites with its alpha channel by default, so
|
||||
// a presented frame whose guest alpha is zero would show through as the
|
||||
// window background (black). The presenter output is a finished opaque
|
||||
// frame; mark the layer opaque so alpha never reaches the compositor.
|
||||
MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
|
||||
MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
|
||||
MetalNative.SendVoidDouble(layer, MetalNative.Selector("setContentsScale:"), scale);
|
||||
MetalNative.SendVoidSize(
|
||||
layer,
|
||||
MetalNative.Selector("setDrawableSize:"),
|
||||
new CGSize { Width = drawableWidth, Height = drawableHeight });
|
||||
|
||||
// A manually created layer defaults to a zero-size frame, and a hosted
|
||||
// layer's geometry is the caller's job: without this the presenter
|
||||
// happily presents every drawable into a layer with no on-screen
|
||||
// extent — a permanently black window.
|
||||
MetalNative.SendVoidRect(
|
||||
layer,
|
||||
MetalNative.Selector("setFrame:"),
|
||||
new CGRect { X = 0, Y = 0, Width = width, Height = height });
|
||||
|
||||
// wantsLayer FIRST, then the layer: that makes the metal layer the
|
||||
// view's AppKit-managed BACKING layer (geometry and window-server
|
||||
// commits handled by AppKit) — the SDL/GLFW pattern. The reverse order
|
||||
// creates a layer-hosting view whose tree the app must commit itself,
|
||||
// which never composites under a manually pumped run loop.
|
||||
MetalNative.SendVoidBool(contentView, MetalNative.Selector("setWantsLayer:"), true);
|
||||
MetalNative.SendVoid(contentView, MetalNative.Selector("setLayer:"), layer);
|
||||
MetalNative.SendVoid(MetalNative.Class("CATransaction"), MetalNative.Selector("flush"));
|
||||
return layer;
|
||||
}
|
||||
|
||||
private static bool TryCreatePresentPipeline(nint device, out nint pipeline, out string error)
|
||||
{
|
||||
pipeline = 0;
|
||||
@@ -1105,10 +817,24 @@ internal static partial class MetalVideoPresenter
|
||||
{
|
||||
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
|
||||
|
||||
// Aspect-fit letterbox: scale the frame into the drawable via the viewport.
|
||||
var scale = Math.Min(drawableWidth / frameWidth, drawableHeight / frameHeight);
|
||||
var viewportWidth = frameWidth * scale;
|
||||
var viewportHeight = frameHeight * scale;
|
||||
var scaleX = drawableWidth / frameWidth;
|
||||
var scaleY = drawableHeight / frameHeight;
|
||||
var viewportWidth = drawableWidth;
|
||||
var viewportHeight = drawableHeight;
|
||||
if (_videoOptions.ScalingMode != HostScalingMode.Stretch)
|
||||
{
|
||||
var scale = _videoOptions.ScalingMode == HostScalingMode.Cover
|
||||
? Math.Max(scaleX, scaleY)
|
||||
: Math.Min(scaleX, scaleY);
|
||||
if (_videoOptions.ScalingMode == HostScalingMode.Integer && scale >= 1)
|
||||
{
|
||||
scale = Math.Floor(scale);
|
||||
}
|
||||
|
||||
viewportWidth = frameWidth * scale;
|
||||
viewportHeight = frameHeight * scale;
|
||||
}
|
||||
|
||||
MetalNative.SendVoidViewport(
|
||||
encoder,
|
||||
MetalNative.Selector("setViewport:"),
|
||||
|
||||
@@ -378,8 +378,10 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
|
||||
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
|
||||
|
||||
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
|
||||
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
|
||||
public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
|
||||
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels, rowOffset);
|
||||
|
||||
public bool SupportsPartialImageWrite => true;
|
||||
|
||||
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
|
||||
VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Logging;
|
||||
using SharpEmu.HLE.Host;
|
||||
using SharpEmu.Libs.Diagnostics;
|
||||
using SharpEmu.Libs.Gpu;
|
||||
@@ -69,11 +70,14 @@ public static class VideoOutExports
|
||||
private static readonly Dictionary<int, VideoOutPortState> _ports = new();
|
||||
private static int _presentationWindowCloseNotified;
|
||||
private static int _vblankStopRequested;
|
||||
private static int _hdrOutputRequested;
|
||||
private static readonly Dictionary<(int Handle, int BufferIndex, ulong Address), ulong> _lastFrameFingerprints = new();
|
||||
private static int _nextHandle = 1;
|
||||
private static int _frameDumpCount;
|
||||
private static long _nextFrameDumpIndex;
|
||||
private static string _windowTitle = "SharpEmu VideoOut";
|
||||
private static string _applicationWindowTitle = "VideoOut";
|
||||
private static string _selectedGpuName = string.Empty;
|
||||
private static string _applicationTitleId = "UNKNOWN";
|
||||
private static readonly bool _logFrameRate = string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"),
|
||||
"1",
|
||||
@@ -113,7 +117,18 @@ public static class VideoOutExports
|
||||
var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}";
|
||||
lock (_stateGate)
|
||||
{
|
||||
_windowTitle = $"SharpEmu - {application}{versionSuffix}";
|
||||
_applicationTitleId = string.IsNullOrWhiteSpace(titleId)
|
||||
? "UNKNOWN"
|
||||
: titleId.Trim();
|
||||
_applicationWindowTitle = $"{application}{versionSuffix}";
|
||||
}
|
||||
}
|
||||
|
||||
internal static string GetApplicationTitleId()
|
||||
{
|
||||
lock (_stateGate)
|
||||
{
|
||||
return _applicationTitleId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +136,10 @@ public static class VideoOutExports
|
||||
{
|
||||
lock (_stateGate)
|
||||
{
|
||||
return _windowTitle;
|
||||
var gpuSuffix = string.IsNullOrWhiteSpace(_selectedGpuName)
|
||||
? string.Empty
|
||||
: $" · {_selectedGpuName}";
|
||||
return $"SharpEmu · {BuildInfo.CommitSha ?? "dev"} - {_applicationWindowTitle}{gpuSuffix}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +157,7 @@ public static class VideoOutExports
|
||||
: string.Empty;
|
||||
lock (_stateGate)
|
||||
{
|
||||
_windowTitle = $"{_windowTitle} · {gpuName.Trim()}{backendSuffix}";
|
||||
_selectedGpuName = $"{gpuName.Trim()}{backendSuffix}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,30 +184,17 @@ public static class VideoOutExports
|
||||
private static void RequestHostShutdown(string reason)
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
|
||||
var embedded = VulkanVideoHost.IsEmbedded;
|
||||
AudioOutExports.ShutdownAllPorts();
|
||||
Interlocked.Exchange(ref _vblankStopRequested, 1);
|
||||
HostSessionControl.RequestShutdown(reason);
|
||||
GuestGpu.Current.RequestClose();
|
||||
|
||||
// A hosted game can still be issuing AGC work after it requests its
|
||||
// own shutdown. Keep the presenter's resources alive until the GUI
|
||||
// session reaches its guest-safe exit path and disposes the host
|
||||
// surface.
|
||||
if (!embedded)
|
||||
// Give guest and GPU threads a bounded window to leave cooperatively.
|
||||
ThreadPool.QueueUserWorkItem(static _ =>
|
||||
{
|
||||
GuestGpu.Current.RequestClose();
|
||||
}
|
||||
|
||||
// The embedded GUI owns the process lifetime. A guest shutdown should
|
||||
// end only that session rather than terminating the launcher itself.
|
||||
if (!embedded)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(static _ =>
|
||||
{
|
||||
Thread.Sleep(2000);
|
||||
Environment.Exit(0);
|
||||
});
|
||||
}
|
||||
Thread.Sleep(2000);
|
||||
Environment.Exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class VideoOutPortState
|
||||
@@ -871,6 +876,8 @@ public static class VideoOutExports
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsHdrOutputRequested => Volatile.Read(ref _hdrOutputRequested) != 0;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "MTxxrOCeSig",
|
||||
ExportName = "sceVideoOutSetWindowModeMargins",
|
||||
@@ -1175,16 +1182,21 @@ public static class VideoOutExports
|
||||
|
||||
var guestImageSubmitted = false;
|
||||
ulong guestImageAddress = 0;
|
||||
if (submitGpuImage &&
|
||||
bufferIndex >= 0 &&
|
||||
if (bufferIndex >= 0 &&
|
||||
TryGetDisplayBufferInfo(handle, bufferIndex, out var displayBuffer))
|
||||
{
|
||||
Interlocked.Exchange(
|
||||
ref _hdrOutputRequested,
|
||||
IsHdrPixelFormat(displayBuffer.PixelFormat) ? 1 : 0);
|
||||
guestImageAddress = displayBuffer.Address;
|
||||
guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
|
||||
displayBuffer.Address,
|
||||
displayBuffer.Width,
|
||||
displayBuffer.Height,
|
||||
displayBuffer.PitchInPixel);
|
||||
if (submitGpuImage)
|
||||
{
|
||||
guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
|
||||
displayBuffer.Address,
|
||||
displayBuffer.Width,
|
||||
displayBuffer.Height,
|
||||
displayBuffer.PitchInPixel);
|
||||
}
|
||||
}
|
||||
|
||||
if (_dumpVideoOut)
|
||||
@@ -1711,6 +1723,12 @@ public static class VideoOutExports
|
||||
internal static bool IsPacked10BitPixelFormat(ulong pixelFormat) =>
|
||||
IsPacked10BitPixelFormatNormalized(NormalizePixelFormat(pixelFormat));
|
||||
|
||||
internal static bool IsHdrPixelFormat(ulong pixelFormat) =>
|
||||
NormalizePixelFormat(pixelFormat) is
|
||||
SceVideoOutPixelFormatA2R10G10B10Bt2020Pq or
|
||||
SceVideoOutPixelFormat2R10G10B10A2Bt2100Pq or
|
||||
SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq;
|
||||
|
||||
private static bool IsPacked10BitPixelFormatNormalized(ulong pixelFormat) =>
|
||||
pixelFormat is
|
||||
SceVideoOutPixelFormatA2R10G10B10 or
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user