mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-31 23:19:44 +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 SubmitGuestImageFill(ulong address, uint fillValue);
|
||||||
|
|
||||||
void SubmitGuestImageWrite(ulong address, byte[] pixels);
|
/// <summary>
|
||||||
|
/// Uploads guest-authored pixels into a live guest image. <paramref name="rowOffset"/>
|
||||||
|
/// is the first image row the buffer covers, so a caller that knows only part
|
||||||
|
/// of the surface changed can send that band instead of the whole thing; the
|
||||||
|
/// untouched rows on the host already hold the same bytes.
|
||||||
|
/// </summary>
|
||||||
|
void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a non-zero <c>rowOffset</c> is honoured. Backends that cannot
|
||||||
|
/// upload a sub-range must report false so callers keep sending the whole
|
||||||
|
/// surface: a dropped band would leave the host copy stale, which is worse
|
||||||
|
/// than an oversized upload.
|
||||||
|
/// </summary>
|
||||||
|
bool SupportsPartialImageWrite => false;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Asks the presenter to refresh CPU-dirty guest images on its render/present
|
/// Asks the presenter to refresh CPU-dirty guest images on its render/present
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
|||||||
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
|
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
|
||||||
MetalVideoPresenter.SubmitGuestImageFill(address, fillValue);
|
MetalVideoPresenter.SubmitGuestImageFill(address, fillValue);
|
||||||
|
|
||||||
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
|
public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
|
||||||
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
|
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
|
||||||
|
|
||||||
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
|
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
|
||||||
|
|||||||
@@ -5,19 +5,6 @@ using System.Runtime.InteropServices;
|
|||||||
|
|
||||||
namespace SharpEmu.Libs.Gpu.Metal;
|
namespace SharpEmu.Libs.Gpu.Metal;
|
||||||
|
|
||||||
// Core Graphics / Metal ABI structs passed by value through objc_msgSend. Struct
|
|
||||||
// *returns* are deliberately never used: on x86-64 (this process runs under Rosetta
|
|
||||||
// on Apple silicon) large struct returns switch to objc_msgSend_stret, and avoiding
|
|
||||||
// them entirely keeps one calling convention everywhere.
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
internal struct CGRect
|
|
||||||
{
|
|
||||||
public double X;
|
|
||||||
public double Y;
|
|
||||||
public double Width;
|
|
||||||
public double Height;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
internal struct CGSize
|
internal struct CGSize
|
||||||
{
|
{
|
||||||
@@ -93,31 +80,21 @@ internal struct MtlViewport
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Objective-C runtime access for the Metal presenter: AppKit, QuartzCore, and Metal
|
/// Objective-C runtime access for the Metal presenter: QuartzCore and Metal
|
||||||
/// through objc_msgSend, with one LibraryImport overload per distinct native
|
/// through objc_msgSend, with one LibraryImport overload per distinct native
|
||||||
/// signature. Dependency-free by design — this plus the OS frameworks is the entire
|
/// signature. Dependency-free by design — this plus the OS frameworks is the entire
|
||||||
/// Metal path, which is what keeps it NativeAOT-clean.
|
/// Metal path, which is what keeps it NativeAOT-clean.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static partial class MetalNative
|
internal static partial class MetalNative
|
||||||
{
|
{
|
||||||
private const string CoreFoundation =
|
|
||||||
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
|
|
||||||
|
|
||||||
[LibraryImport(CoreFoundation)]
|
|
||||||
public static partial nint CFRunLoopGetMain();
|
|
||||||
|
|
||||||
[LibraryImport(CoreFoundation)]
|
|
||||||
public static partial void CFRunLoopStop(nint runLoop);
|
|
||||||
|
|
||||||
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
|
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
|
||||||
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
|
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
|
||||||
private const string AppKitFramework = "/System/Library/Frameworks/AppKit.framework/AppKit";
|
|
||||||
private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
|
private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
|
||||||
|
|
||||||
private static bool _frameworksLoaded;
|
private static bool _frameworksLoaded;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is
|
/// Makes QuartzCore classes visible to objc_getClass; Metal is
|
||||||
/// pulled in by its own LibraryImport. Call once before any Class() lookup.
|
/// pulled in by its own LibraryImport. Call once before any Class() lookup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void EnsureFrameworksLoaded()
|
public static void EnsureFrameworksLoaded()
|
||||||
@@ -127,7 +104,6 @@ internal static partial class MetalNative
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
NativeLibrary.Load(AppKitFramework);
|
|
||||||
NativeLibrary.Load(QuartzCoreFramework);
|
NativeLibrary.Load(QuartzCoreFramework);
|
||||||
_frameworksLoaded = true;
|
_frameworksLoaded = true;
|
||||||
}
|
}
|
||||||
@@ -141,16 +117,6 @@ internal static partial class MetalNative
|
|||||||
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
|
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
|
||||||
private static partial nint sel_registerName(string name);
|
private static partial nint sel_registerName(string name);
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
|
|
||||||
public static partial nint objc_allocateClassPair(nint superclass, string name, nuint extraBytes);
|
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary)]
|
|
||||||
public static partial void objc_registerClassPair(nint cls);
|
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
|
|
||||||
[return: MarshalAs(UnmanagedType.I1)]
|
|
||||||
public static partial bool class_addMethod(nint cls, nint name, nint imp, string types);
|
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary)]
|
[LibraryImport(ObjCLibrary)]
|
||||||
public static partial nint objc_autoreleasePoolPush();
|
public static partial nint objc_autoreleasePoolPush();
|
||||||
|
|
||||||
@@ -169,14 +135,6 @@ internal static partial class MetalNative
|
|||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||||
public static partial nint Send(nint receiver, nint selector, nint argument);
|
public static partial nint Send(nint receiver, nint selector, nint argument);
|
||||||
|
|
||||||
|
|
||||||
/// <summary>objc_msgSend for a CGRect-returning selector (e.g. -bounds).
|
|
||||||
/// A 32-byte struct is returned via the x86-64 stret ABI — a hidden
|
|
||||||
/// pointer to caller storage passed ahead of self/_cmd — so this must not
|
|
||||||
/// be folded into the plain objc_msgSend overloads.</summary>
|
|
||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")]
|
|
||||||
public static partial void SendStretRect(out CGRect result, nint receiver, nint selector);
|
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||||
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
|
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
|
||||||
|
|
||||||
@@ -209,17 +167,6 @@ internal static partial class MetalNative
|
|||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||||
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
|
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
|
||||||
|
|
||||||
/// <summary>performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
|
|
||||||
/// to perform is itself an argument, followed by the object and the wait
|
|
||||||
/// flag.</summary>
|
|
||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
|
||||||
public static partial void SendVoidPerformSelector(
|
|
||||||
nint receiver,
|
|
||||||
nint selector,
|
|
||||||
nint performedSelector,
|
|
||||||
nint argument,
|
|
||||||
[MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
|
|
||||||
|
|
||||||
/// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte
|
/// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte
|
||||||
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
|
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
|
||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||||
@@ -237,9 +184,6 @@ internal static partial class MetalNative
|
|||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||||
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size);
|
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size);
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
|
||||||
public static partial void SendVoidRect(nint receiver, nint selector, CGRect rect);
|
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||||
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
|
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
|
||||||
|
|
||||||
@@ -328,37 +272,6 @@ internal static partial class MetalNative
|
|||||||
nuint indexBufferOffset,
|
nuint indexBufferOffset,
|
||||||
nuint instanceCount);
|
nuint instanceCount);
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
|
||||||
public static partial nint SendTimer(
|
|
||||||
nint receiver,
|
|
||||||
nint selector,
|
|
||||||
double interval,
|
|
||||||
nint target,
|
|
||||||
nint timerSelector,
|
|
||||||
nint userInfo,
|
|
||||||
[MarshalAs(UnmanagedType.I1)] bool repeats);
|
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
|
||||||
public static partial nint SendInitFrame(nint receiver, nint selector, CGRect frame);
|
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
|
||||||
public static partial nint SendInitWindow(
|
|
||||||
nint receiver,
|
|
||||||
nint selector,
|
|
||||||
CGRect contentRect,
|
|
||||||
nuint styleMask,
|
|
||||||
nuint backing,
|
|
||||||
[MarshalAs(UnmanagedType.I1)] bool defer);
|
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
|
||||||
public static partial nint SendNextEvent(
|
|
||||||
nint receiver,
|
|
||||||
nint selector,
|
|
||||||
ulong eventMask,
|
|
||||||
nint untilDate,
|
|
||||||
nint inMode,
|
|
||||||
[MarshalAs(UnmanagedType.I1)] bool dequeue);
|
|
||||||
|
|
||||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||||
public static partial nint SendTextureDescriptor(
|
public static partial nint SendTextureDescriptor(
|
||||||
nint receiver,
|
nint receiver,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
using SharpEmu.Libs.VideoOut;
|
using SharpEmu.Libs.VideoOut;
|
||||||
using SharpEmu.ShaderCompiler;
|
using SharpEmu.ShaderCompiler;
|
||||||
@@ -11,28 +9,12 @@ using SharpEmu.ShaderCompiler.Metal;
|
|||||||
namespace SharpEmu.Libs.Gpu.Metal;
|
namespace SharpEmu.Libs.Gpu.Metal;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The Metal presenter: an AppKit window hosting a CAMetalLayer. A CADisplayLink
|
/// The Metal presenter uses SDL for its host window, events and input, then renders
|
||||||
/// requested from the content view drives <see cref="RenderFrame"/> in sync with the
|
/// into the CAMetalLayer owned by SDL's Metal view. Windowing and input therefore
|
||||||
/// display refresh, on the main run loop — the loop whose Core Animation observer
|
/// share the Vulkan path while all Metal command encoding remains native.
|
||||||
/// commits presented drawables to the window server, so it must be a real running
|
|
||||||
/// run loop (a hand-pumped event drain never fires that observer and the window
|
|
||||||
/// stays black). Everything AppKit runs on the process main thread via
|
|
||||||
/// <see cref="HostMainThread"/> (AppKit traps off-main), which the CLI parks for us.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static partial class MetalVideoPresenter
|
internal static partial class MetalVideoPresenter
|
||||||
{
|
{
|
||||||
private const uint DefaultWindowWidth = 1280;
|
|
||||||
private const uint DefaultWindowHeight = 720;
|
|
||||||
|
|
||||||
// NSWindow style: Titled | Closable | Miniaturizable | Resizable. Resizable
|
|
||||||
// both lets the user drag the window edges and turns the green zoom button
|
|
||||||
// into the full-screen toggle (paired with the collection behavior below).
|
|
||||||
private const nuint WindowStyleMask = 1 | 2 | 4 | 8;
|
|
||||||
|
|
||||||
// NSWindowCollectionBehaviorFullScreenPrimary: opt this window into native
|
|
||||||
// full-screen, so the green button enters full-screen rather than zooming.
|
|
||||||
private const nuint FullScreenPrimaryBehavior = 1 << 7;
|
|
||||||
private const nuint BackingStoreBuffered = 2;
|
|
||||||
private const nuint PixelFormatBgra8Unorm = (nuint)MtlPixelFormat.Bgra8Unorm;
|
private const nuint PixelFormatBgra8Unorm = (nuint)MtlPixelFormat.Bgra8Unorm;
|
||||||
private const nuint LoadActionLoad = 1;
|
private const nuint LoadActionLoad = 1;
|
||||||
private const nuint LoadActionClear = 2;
|
private const nuint LoadActionClear = 2;
|
||||||
@@ -69,17 +51,14 @@ internal static partial class MetalVideoPresenter
|
|||||||
private static readonly byte[] _overlayPixels =
|
private static readonly byte[] _overlayPixels =
|
||||||
new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4];
|
new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4];
|
||||||
|
|
||||||
// Presenter objects and per-frame present state, shared between window setup
|
// Presenter objects and per-frame present state, all used on the SDL host thread.
|
||||||
// and the display-link RenderFrame callback (both on the main thread).
|
|
||||||
private static nint _device;
|
private static nint _device;
|
||||||
private static nint _commandQueue;
|
private static nint _commandQueue;
|
||||||
private static nint _metalLayer;
|
private static nint _metalLayer;
|
||||||
private static nint _presentPipeline;
|
private static nint _presentPipeline;
|
||||||
private static nint _presentSampler;
|
private static nint _presentSampler;
|
||||||
private static nint _window;
|
private static SdlHostWindow? _hostWindow;
|
||||||
private static nint _application;
|
private static HostVideoOptions _videoOptions = HostVideoOptions.Default;
|
||||||
private static nint _renderTimer;
|
|
||||||
private static nint _renderTimerTarget;
|
|
||||||
private static double _drawableWidth;
|
private static double _drawableWidth;
|
||||||
private static double _drawableHeight;
|
private static double _drawableHeight;
|
||||||
private static nint _frameTexture;
|
private static nint _frameTexture;
|
||||||
@@ -91,10 +70,23 @@ internal static partial class MetalVideoPresenter
|
|||||||
private static nint _ownedVersionTexture;
|
private static nint _ownedVersionTexture;
|
||||||
private static ulong _presentGuestAddress;
|
private static ulong _presentGuestAddress;
|
||||||
private static long _presentedSequence = -1;
|
private static long _presentedSequence = -1;
|
||||||
private static bool _userClosed;
|
|
||||||
private static uint _windowWidth;
|
private static uint _windowWidth;
|
||||||
private static uint _windowHeight;
|
private static uint _windowHeight;
|
||||||
|
|
||||||
|
public static bool TryConfigureVideo(HostVideoOptions options)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_thread is not null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_videoOptions = options.Normalize();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void EnsureStarted(uint width, uint height)
|
public static void EnsureStarted(uint width, uint height)
|
||||||
{
|
{
|
||||||
if (width == 0 || height == 0)
|
if (width == 0 || height == 0)
|
||||||
@@ -183,6 +175,7 @@ internal static partial class MetalVideoPresenter
|
|||||||
public static void RequestClose()
|
public static void RequestClose()
|
||||||
{
|
{
|
||||||
Volatile.Write(ref _closeRequested, true);
|
Volatile.Write(ref _closeRequested, true);
|
||||||
|
Volatile.Read(ref _hostWindow)?.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void StartPresenterLocked()
|
private static void StartPresenterLocked()
|
||||||
@@ -247,33 +240,23 @@ internal static partial class MetalVideoPresenter
|
|||||||
VideoOutExports.SetSelectedGpuName(deviceName);
|
VideoOutExports.SetSelectedGpuName(deviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed window like the Vulkan presenter: guest frames letterbox into
|
HostVideoOptions videoOptions;
|
||||||
// it. Sizing the window from the guest's display mode (4K) exceeds the
|
lock (_gate)
|
||||||
// screen — macOS clamps the window while the layer keeps the requested
|
{
|
||||||
// geometry, leaving the visible region showing nothing but clear.
|
videoOptions = _videoOptions;
|
||||||
const uint width = DefaultWindowWidth;
|
}
|
||||||
const uint height = DefaultWindowHeight;
|
|
||||||
|
|
||||||
|
using var hostWindow = new SdlHostWindow(
|
||||||
|
VideoOutExports.GetWindowTitle(),
|
||||||
|
videoOptions,
|
||||||
|
SdlGraphicsApi.Metal,
|
||||||
|
ToggleMetalPerformanceHud);
|
||||||
|
Volatile.Write(ref _hostWindow, hostWindow);
|
||||||
var setupPool = MetalNative.objc_autoreleasePoolPush();
|
var setupPool = MetalNative.objc_autoreleasePoolPush();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_application = MetalNative.Send(
|
_metalLayer = hostWindow.CreateMetalLayer();
|
||||||
MetalNative.Class("NSApplication"), MetalNative.Selector("sharedApplication"));
|
ConfigureMetalLayer(_device, _metalLayer);
|
||||||
// NSApplicationActivationPolicyRegular: dock icon + key window like any app.
|
|
||||||
MetalNative.Send(_application, MetalNative.Selector("setActivationPolicy:"), 0);
|
|
||||||
MetalNative.SendVoid(_application, MetalNative.Selector("finishLaunching"));
|
|
||||||
|
|
||||||
_window = CreateWindow(width, height);
|
|
||||||
|
|
||||||
// Swap in the key-capturing view before the metal layer attaches so
|
|
||||||
// the layer lands on the input-aware content view.
|
|
||||||
var keyView = MetalNative.SendInitFrame(
|
|
||||||
MetalNative.Send(CreateKeyViewClass(), MetalNative.Selector("alloc")),
|
|
||||||
MetalNative.Selector("initWithFrame:"),
|
|
||||||
new CGRect { X = 0, Y = 0, Width = width, Height = height });
|
|
||||||
MetalNative.SendVoid(_window, MetalNative.Selector("setContentView:"), keyView);
|
|
||||||
|
|
||||||
_metalLayer = CreateLayer(_device, _window, out _drawableWidth, out _drawableHeight);
|
|
||||||
_commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue"));
|
_commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue"));
|
||||||
if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError))
|
if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError))
|
||||||
{
|
{
|
||||||
@@ -282,31 +265,7 @@ internal static partial class MetalVideoPresenter
|
|||||||
}
|
}
|
||||||
|
|
||||||
_presentSampler = CreateLinearSampler(_device);
|
_presentSampler = CreateLinearSampler(_device);
|
||||||
|
SyncDrawableSizeToLayer();
|
||||||
MetalNative.SendVoid(_window, MetalNative.Selector("makeKeyAndOrderFront:"), 0);
|
|
||||||
MetalNative.SendVoidBool(
|
|
||||||
_application, MetalNative.Selector("activateIgnoringOtherApps:"), true);
|
|
||||||
MetalNative.SendVoid(_window, MetalNative.Selector("makeFirstResponder:"), keyView);
|
|
||||||
MetalHostInput.Attach();
|
|
||||||
|
|
||||||
// A repeating NSTimer on this (main) run loop fires onFrame: at the
|
|
||||||
// display rate. CADisplayLink (NSView.displayLinkWithTarget:selector:)
|
|
||||||
// is the natural choice but its callback never fires under the x86-64
|
|
||||||
// Rosetta process this emulator runs as — proven in isolation against
|
|
||||||
// a bare AppKit harness, where a timer fires and composites and the
|
|
||||||
// display link does not. nextDrawable still throttles presentation to
|
|
||||||
// the display, so the timer only needs to keep up, not pace precisely.
|
|
||||||
_renderTimerTarget = CreateRenderTimerTarget();
|
|
||||||
_renderTimer = MetalNative.Send(
|
|
||||||
MetalNative.SendTimer(
|
|
||||||
MetalNative.Class("NSTimer"),
|
|
||||||
MetalNative.Selector("scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:"),
|
|
||||||
1.0 / 60.0,
|
|
||||||
_renderTimerTarget,
|
|
||||||
MetalNative.Selector("onFrame:"),
|
|
||||||
0,
|
|
||||||
repeats: true),
|
|
||||||
MetalNative.Selector("retain"));
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -314,25 +273,20 @@ internal static partial class MetalVideoPresenter
|
|||||||
}
|
}
|
||||||
|
|
||||||
Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut presenter started.");
|
Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut presenter started.");
|
||||||
|
|
||||||
// [NSApp run] runs the main run loop (its Core Animation observer commits
|
|
||||||
// presented drawables to the window server) AND fully activates the app,
|
|
||||||
// which a bare CFRunLoopRun does not — the CADisplayLink is only serviced
|
|
||||||
// once the app is running, and NSApp dispatches window events itself.
|
|
||||||
// Returns once RenderFrame stops it.
|
|
||||||
MetalNative.SendVoid(_application, MetalNative.Selector("run"));
|
|
||||||
|
|
||||||
var closePool = MetalNative.objc_autoreleasePoolPush();
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
MetalNative.SendVoid(_window, MetalNative.Selector("close"));
|
hostWindow.Run(
|
||||||
|
static () => { },
|
||||||
|
static _ => RenderFrameSafely(),
|
||||||
|
static () => { });
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
MetalNative.objc_autoreleasePoolPop(closePool);
|
Volatile.Write(ref _hostWindow, null);
|
||||||
|
_metalLayer = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_userClosed)
|
if (hostWindow.ClosedByUser)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
"[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown.");
|
"[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown.");
|
||||||
@@ -340,115 +294,8 @@ internal static partial class MetalVideoPresenter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An NSView subclass that records key events for pad emulation. Overriding
|
|
||||||
/// keyDown:/keyUp: (instead of an event monitor) needs no ObjC blocks, and
|
|
||||||
/// swallowing the events also silences the system alert beep AppKit plays
|
|
||||||
/// for unhandled keys. Registered once per process.
|
|
||||||
/// </summary>
|
|
||||||
private static unsafe nint CreateKeyViewClass()
|
|
||||||
{
|
|
||||||
var cls = MetalNative.objc_allocateClassPair(
|
|
||||||
MetalNative.Class("NSView"), "SharpEmuMetalView", 0);
|
|
||||||
if (cls == 0)
|
|
||||||
{
|
|
||||||
return MetalNative.Class("SharpEmuMetalView");
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyDown = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, void>)&OnKeyDown;
|
|
||||||
MetalNative.class_addMethod(cls, MetalNative.Selector("keyDown:"), keyDown, "v@:@");
|
|
||||||
var keyUp = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, void>)&OnKeyUp;
|
|
||||||
MetalNative.class_addMethod(cls, MetalNative.Selector("keyUp:"), keyUp, "v@:@");
|
|
||||||
// Command-modified keys never reach keyDown: — AppKit routes them through
|
|
||||||
// performKeyEquivalent:, so Cmd+F1 (Metal Performance HUD) hooks in here.
|
|
||||||
var keyEquivalent = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, byte>)&OnPerformKeyEquivalent;
|
|
||||||
MetalNative.class_addMethod(
|
|
||||||
cls, MetalNative.Selector("performKeyEquivalent:"), keyEquivalent, "c@:@");
|
|
||||||
// First responder status is what routes key events to this view.
|
|
||||||
var accepts = (nint)(delegate* unmanaged[Cdecl]<nint, nint, byte>)&AcceptsFirstResponder;
|
|
||||||
MetalNative.class_addMethod(
|
|
||||||
cls, MetalNative.Selector("acceptsFirstResponder"), accepts, "c@:");
|
|
||||||
MetalNative.objc_registerClassPair(cls);
|
|
||||||
return cls;
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
|
|
||||||
private static void OnKeyDown(nint self, nint cmd, nint nsEvent)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
|
|
||||||
var isRepeat = MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat"));
|
|
||||||
|
|
||||||
// Function keys can arrive here even with Command held (AppKit only
|
|
||||||
// reroutes some chords through the key-equivalent path), so catch
|
|
||||||
// Cmd+F1 in both places — and keep it away from MetalHostInput so it
|
|
||||||
// never toggles the plain-F1 perf overlay.
|
|
||||||
var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags"));
|
|
||||||
if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0)
|
|
||||||
{
|
|
||||||
if (!isRepeat)
|
|
||||||
{
|
|
||||||
ToggleMetalPerformanceHud();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
MetalHostInput.KeyDown(keyCode, isRepeat);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[LOADER][WARN] Metal key-down handler failed: {exception.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
|
|
||||||
private static void OnKeyUp(nint self, nint cmd, nint nsEvent)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
|
|
||||||
MetalHostInput.KeyUp(keyCode);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[LOADER][WARN] Metal key-up handler failed: {exception.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
|
|
||||||
private static byte AcceptsFirstResponder(nint self, nint cmd) => 1;
|
|
||||||
|
|
||||||
private const ushort KeyCodeF1 = 0x7A;
|
|
||||||
private const ulong NsEventModifierFlagCommand = 1UL << 20;
|
|
||||||
private static bool _metalHudVisible;
|
private static bool _metalHudVisible;
|
||||||
|
|
||||||
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
|
|
||||||
private static byte OnPerformKeyEquivalent(nint self, nint cmd, nint nsEvent)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
|
|
||||||
var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags"));
|
|
||||||
if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0)
|
|
||||||
{
|
|
||||||
if (!MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat")))
|
|
||||||
{
|
|
||||||
ToggleMetalPerformanceHud();
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1; // handled: no system beep, no further routing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[LOADER][WARN] Metal key-equivalent handler failed: {exception.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps
|
/// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps
|
||||||
/// the built-in CPU-rasterized perf overlay). Configured per Apple's
|
/// the built-in CPU-rasterized perf overlay). Configured per Apple's
|
||||||
@@ -456,7 +303,7 @@ internal static partial class MetalVideoPresenter
|
|||||||
/// mode=default|disabled and logging=default, plus any MTL_HUD_* environment
|
/// mode=default|disabled and logging=default, plus any MTL_HUD_* environment
|
||||||
/// keys directly in the dictionary — all three HUD flags (enabled, per-frame
|
/// keys directly in the dictionary — all three HUD flags (enabled, per-frame
|
||||||
/// logging, shader-compile logging) ride in one property set. Runs on the
|
/// logging, shader-compile logging) ride in one property set. Runs on the
|
||||||
/// AppKit main thread (the key-equivalent path), same thread as the render loop.
|
/// SDL host thread, the same thread as the render loop.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void ToggleMetalPerformanceHud()
|
private static void ToggleMetalPerformanceHud()
|
||||||
{
|
{
|
||||||
@@ -508,33 +355,13 @@ internal static partial class MetalVideoPresenter
|
|||||||
$"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1).");
|
$"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1).");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static unsafe nint CreateRenderTimerTarget()
|
/// <summary>The SDL host loop drains guest work continuously. The method
|
||||||
|
/// remains as the enqueue-side hook used by guest image synchronization.</summary>
|
||||||
|
internal static void ScheduleGuestWorkDrain()
|
||||||
{
|
{
|
||||||
// A minimal NSObject subclass whose onFrame: is our unmanaged callback —
|
|
||||||
// the dependency-free way to hand a target/selector to NSTimer without a
|
|
||||||
// binding library. Registered once per process.
|
|
||||||
var cls = MetalNative.objc_allocateClassPair(
|
|
||||||
MetalNative.Class("NSObject"), "SharpEmuRenderTimerTarget", 0);
|
|
||||||
if (cls != 0)
|
|
||||||
{
|
|
||||||
var imp = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, void>)&OnRenderTimer;
|
|
||||||
// "v@:@": void return, self, _cmd, one object argument (the timer).
|
|
||||||
MetalNative.class_addMethod(cls, MetalNative.Selector("onFrame:"), imp, "v@:@");
|
|
||||||
var wakeImp = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, void>)&OnGuestWorkWake;
|
|
||||||
MetalNative.class_addMethod(cls, MetalNative.Selector("onGuestWork:"), wakeImp, "v@:@");
|
|
||||||
MetalNative.objc_registerClassPair(cls);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
cls = MetalNative.Class("SharpEmuRenderTimerTarget");
|
|
||||||
}
|
|
||||||
|
|
||||||
return MetalNative.Send(
|
|
||||||
MetalNative.Send(cls, MetalNative.Selector("alloc")), MetalNative.Selector("init"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
|
private static void RenderFrameSafely()
|
||||||
private static void OnRenderTimer(nint self, nint cmd, nint timer)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -546,78 +373,13 @@ internal static partial class MetalVideoPresenter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Set while an onGuestWork: wake is scheduled on the main run
|
|
||||||
/// loop; coalesces enqueue-side wake requests to one in-flight message.</summary>
|
|
||||||
private static int _guestWorkWakeScheduled;
|
|
||||||
|
|
||||||
/// <summary>Wakes the main run loop to drain guest work now instead of at
|
|
||||||
/// the next render tick. Guest submit→wait round-trips (release-mem labels,
|
|
||||||
/// CPU-visible write-backs) otherwise cost a full frame interval each —
|
|
||||||
/// games that chain several per frame crawl at a fraction of the display
|
|
||||||
/// rate. Safe from any thread; no-op until the presenter starts.</summary>
|
|
||||||
internal static void ScheduleGuestWorkDrain()
|
|
||||||
{
|
|
||||||
if (Interlocked.CompareExchange(ref _guestWorkWakeScheduled, 1, 0) != 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var target = _renderTimerTarget;
|
|
||||||
if (target == 0)
|
|
||||||
{
|
|
||||||
Volatile.Write(ref _guestWorkWakeScheduled, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
MetalNative.SendVoidPerformSelector(
|
|
||||||
target,
|
|
||||||
MetalNative.Selector("performSelectorOnMainThread:withObject:waitUntilDone:"),
|
|
||||||
MetalNative.Selector("onGuestWork:"),
|
|
||||||
0,
|
|
||||||
waitUntilDone: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
|
|
||||||
private static void OnGuestWorkWake(nint self, nint cmd, nint argument)
|
|
||||||
{
|
|
||||||
Volatile.Write(ref _guestWorkWakeScheduled, 0);
|
|
||||||
if (_device == 0 || _commandQueue == 0 || Volatile.Read(ref _closeRequested))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var pool = MetalNative.objc_autoreleasePoolPush();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
DrainGuestWork(_device, _commandQueue);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[LOADER][ERROR] Metal guest work wake failed: {exception}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
MetalNative.objc_autoreleasePoolPop(pool);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RenderFrame()
|
private static void RenderFrame()
|
||||||
{
|
{
|
||||||
MetalHostInput.PumpAutoKeys();
|
|
||||||
var pool = MetalNative.objc_autoreleasePoolPush();
|
var pool = MetalNative.objc_autoreleasePoolPush();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// NSApp.run dispatches window events itself, so there is no manual
|
if (Volatile.Read(ref _closeRequested))
|
||||||
// event drain here.
|
|
||||||
var visible = MetalNative.SendBool(_window, MetalNative.Selector("isVisible"));
|
|
||||||
if (Volatile.Read(ref _closeRequested) || !visible)
|
|
||||||
{
|
{
|
||||||
_userClosed = !visible && !Volatile.Read(ref _closeRequested);
|
|
||||||
MetalNative.SendVoid(_renderTimer, MetalNative.Selector("invalidate"));
|
|
||||||
// Stop both the AppKit loop and the underlying CFRunLoop so
|
|
||||||
// [NSApp run] returns.
|
|
||||||
MetalNative.SendVoid(_application, MetalNative.Selector("stop:"), 0);
|
|
||||||
MetalNative.CFRunLoopStop(MetalNative.CFRunLoopGetMain());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,8 +477,7 @@ internal static partial class MetalVideoPresenter
|
|||||||
if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal))
|
if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
_lastWindowTitle = title;
|
_lastWindowTitle = title;
|
||||||
MetalNative.SendVoid(
|
Volatile.Read(ref _hostWindow)?.SetTitle(title);
|
||||||
_window, MetalNative.Selector("setTitle:"), MetalNative.NsString(title));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,8 +486,20 @@ internal static partial class MetalVideoPresenter
|
|||||||
// for a drawable, or nextDrawable keeps handing back the original
|
// for a drawable, or nextDrawable keeps handing back the original
|
||||||
// resolution and Core Animation stretches it (blurry, mis-scaled
|
// resolution and Core Animation stretches it (blurry, mis-scaled
|
||||||
// overlay). No-op when the size is unchanged, i.e. almost every tick.
|
// overlay). No-op when the size is unchanged, i.e. almost every tick.
|
||||||
|
var hostWindow = Volatile.Read(ref _hostWindow);
|
||||||
|
if (hostWindow?.ConsumeSurfaceRestore() == true)
|
||||||
|
{
|
||||||
|
_drawableWidth = 0;
|
||||||
|
_drawableHeight = 0;
|
||||||
|
}
|
||||||
|
|
||||||
SyncDrawableSizeToLayer();
|
SyncDrawableSizeToLayer();
|
||||||
|
|
||||||
|
if (hostWindow?.IsMinimized == true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable"));
|
var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable"));
|
||||||
if (drawable == 0)
|
if (drawable == 0)
|
||||||
{
|
{
|
||||||
@@ -846,43 +619,33 @@ internal static partial class MetalVideoPresenter
|
|||||||
3);
|
3);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static nint CreateWindow(uint width, uint height)
|
private static void ConfigureMetalLayer(nint device, nint layer)
|
||||||
{
|
{
|
||||||
var window = MetalNative.SendInitWindow(
|
MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
|
||||||
MetalNative.Send(MetalNative.Class("NSWindow"), MetalNative.Selector("alloc")),
|
MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
|
||||||
MetalNative.Selector("initWithContentRect:styleMask:backing:defer:"),
|
MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
|
||||||
new CGRect { X = 0, Y = 0, Width = width, Height = height },
|
MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
|
||||||
WindowStyleMask,
|
|
||||||
BackingStoreBuffered,
|
var setDisplaySync = MetalNative.Selector("setDisplaySyncEnabled:");
|
||||||
defer: false);
|
if (MetalNative.SendBool(layer, MetalNative.Selector("respondsToSelector:"), setDisplaySync))
|
||||||
// The presenter owns the handle; AppKit must not free it on user close.
|
{
|
||||||
MetalNative.SendVoidBool(window, MetalNative.Selector("setReleasedWhenClosed:"), false);
|
MetalNative.SendVoidBool(layer, setDisplaySync, _videoOptions.VSync);
|
||||||
MetalNative.Send(
|
}
|
||||||
window, MetalNative.Selector("setCollectionBehavior:"), (nint)FullScreenPrimaryBehavior);
|
|
||||||
MetalNative.SendVoid(
|
|
||||||
window,
|
|
||||||
MetalNative.Selector("setTitle:"),
|
|
||||||
MetalNative.NsString(VideoOutExports.GetWindowTitle()));
|
|
||||||
MetalNative.SendVoid(window, MetalNative.Selector("center"));
|
|
||||||
// makeKeyAndOrderFront happens after the metal layer is attached.
|
|
||||||
return window;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Keeps the CAMetalLayer's drawable size (pixels) matched to its
|
/// <summary>Keeps the SDL-owned CAMetalLayer drawable in host pixels as the
|
||||||
/// current bounds (points) × scale as the window resizes or moves between
|
/// window resizes or moves between displays with different scale factors.</summary>
|
||||||
/// displays. CAMetalLayer never updates drawableSize on its own, even as a
|
|
||||||
/// view's backing layer, so the render loop drives it.</summary>
|
|
||||||
private static void SyncDrawableSizeToLayer()
|
private static void SyncDrawableSizeToLayer()
|
||||||
{
|
{
|
||||||
MetalNative.SendStretRect(out var bounds, _metalLayer, MetalNative.Selector("bounds"));
|
var hostWindow = Volatile.Read(ref _hostWindow);
|
||||||
var scale = MetalNative.SendDouble(_metalLayer, MetalNative.Selector("contentsScale"));
|
if (hostWindow is null || _metalLayer == 0)
|
||||||
if (scale <= 0)
|
|
||||||
{
|
{
|
||||||
scale = 1;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var width = Math.Max(1, Math.Round(bounds.Width * scale));
|
var pixelSize = hostWindow.PixelSize;
|
||||||
var height = Math.Max(1, Math.Round(bounds.Height * scale));
|
var width = (double)pixelSize.Width;
|
||||||
|
var height = (double)pixelSize.Height;
|
||||||
if (width == _drawableWidth && height == _drawableHeight)
|
if (width == _drawableWidth && height == _drawableHeight)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -896,57 +659,6 @@ internal static partial class MetalVideoPresenter
|
|||||||
new CGSize { Width = width, Height = height });
|
new CGSize { Width = width, Height = height });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static nint CreateLayer(nint device, nint window, out double drawableWidth, out double drawableHeight)
|
|
||||||
{
|
|
||||||
var contentView = MetalNative.Send(window, MetalNative.Selector("contentView"));
|
|
||||||
var scale = MetalNative.SendDouble(window, MetalNative.Selector("backingScaleFactor"));
|
|
||||||
if (scale <= 0)
|
|
||||||
{
|
|
||||||
scale = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint width = DefaultWindowWidth;
|
|
||||||
const uint height = DefaultWindowHeight;
|
|
||||||
drawableWidth = width * scale;
|
|
||||||
drawableHeight = height * scale;
|
|
||||||
|
|
||||||
var layer = MetalNative.Send(
|
|
||||||
MetalNative.Send(MetalNative.Class("CAMetalLayer"), MetalNative.Selector("alloc")),
|
|
||||||
MetalNative.Selector("init"));
|
|
||||||
MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
|
|
||||||
MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
|
|
||||||
// A Core Animation layer composites with its alpha channel by default, so
|
|
||||||
// a presented frame whose guest alpha is zero would show through as the
|
|
||||||
// window background (black). The presenter output is a finished opaque
|
|
||||||
// frame; mark the layer opaque so alpha never reaches the compositor.
|
|
||||||
MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
|
|
||||||
MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
|
|
||||||
MetalNative.SendVoidDouble(layer, MetalNative.Selector("setContentsScale:"), scale);
|
|
||||||
MetalNative.SendVoidSize(
|
|
||||||
layer,
|
|
||||||
MetalNative.Selector("setDrawableSize:"),
|
|
||||||
new CGSize { Width = drawableWidth, Height = drawableHeight });
|
|
||||||
|
|
||||||
// A manually created layer defaults to a zero-size frame, and a hosted
|
|
||||||
// layer's geometry is the caller's job: without this the presenter
|
|
||||||
// happily presents every drawable into a layer with no on-screen
|
|
||||||
// extent — a permanently black window.
|
|
||||||
MetalNative.SendVoidRect(
|
|
||||||
layer,
|
|
||||||
MetalNative.Selector("setFrame:"),
|
|
||||||
new CGRect { X = 0, Y = 0, Width = width, Height = height });
|
|
||||||
|
|
||||||
// wantsLayer FIRST, then the layer: that makes the metal layer the
|
|
||||||
// view's AppKit-managed BACKING layer (geometry and window-server
|
|
||||||
// commits handled by AppKit) — the SDL/GLFW pattern. The reverse order
|
|
||||||
// creates a layer-hosting view whose tree the app must commit itself,
|
|
||||||
// which never composites under a manually pumped run loop.
|
|
||||||
MetalNative.SendVoidBool(contentView, MetalNative.Selector("setWantsLayer:"), true);
|
|
||||||
MetalNative.SendVoid(contentView, MetalNative.Selector("setLayer:"), layer);
|
|
||||||
MetalNative.SendVoid(MetalNative.Class("CATransaction"), MetalNative.Selector("flush"));
|
|
||||||
return layer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryCreatePresentPipeline(nint device, out nint pipeline, out string error)
|
private static bool TryCreatePresentPipeline(nint device, out nint pipeline, out string error)
|
||||||
{
|
{
|
||||||
pipeline = 0;
|
pipeline = 0;
|
||||||
@@ -1105,10 +817,24 @@ internal static partial class MetalVideoPresenter
|
|||||||
{
|
{
|
||||||
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
|
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
|
||||||
|
|
||||||
// Aspect-fit letterbox: scale the frame into the drawable via the viewport.
|
var scaleX = drawableWidth / frameWidth;
|
||||||
var scale = Math.Min(drawableWidth / frameWidth, drawableHeight / frameHeight);
|
var scaleY = drawableHeight / frameHeight;
|
||||||
var viewportWidth = frameWidth * scale;
|
var viewportWidth = drawableWidth;
|
||||||
var viewportHeight = frameHeight * scale;
|
var viewportHeight = drawableHeight;
|
||||||
|
if (_videoOptions.ScalingMode != HostScalingMode.Stretch)
|
||||||
|
{
|
||||||
|
var scale = _videoOptions.ScalingMode == HostScalingMode.Cover
|
||||||
|
? Math.Max(scaleX, scaleY)
|
||||||
|
: Math.Min(scaleX, scaleY);
|
||||||
|
if (_videoOptions.ScalingMode == HostScalingMode.Integer && scale >= 1)
|
||||||
|
{
|
||||||
|
scale = Math.Floor(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
viewportWidth = frameWidth * scale;
|
||||||
|
viewportHeight = frameHeight * scale;
|
||||||
|
}
|
||||||
|
|
||||||
MetalNative.SendVoidViewport(
|
MetalNative.SendVoidViewport(
|
||||||
encoder,
|
encoder,
|
||||||
MetalNative.Selector("setViewport:"),
|
MetalNative.Selector("setViewport:"),
|
||||||
|
|||||||
@@ -378,8 +378,10 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
|||||||
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
|
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
|
||||||
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
|
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
|
||||||
|
|
||||||
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
|
public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
|
||||||
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
|
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels, rowOffset);
|
||||||
|
|
||||||
|
public bool SupportsPartialImageWrite => true;
|
||||||
|
|
||||||
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
|
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
|
||||||
VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
|
VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SDL;
|
||||||
|
using static SDL.SDL3;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.VideoOut;
|
||||||
|
|
||||||
|
public sealed record HostDisplayMode(int Width, int Height, int RefreshRate);
|
||||||
|
|
||||||
|
public sealed record HostDisplayInfo(
|
||||||
|
int Index,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<HostDisplayMode> Modes);
|
||||||
|
|
||||||
|
public static unsafe class HostDisplayCatalog
|
||||||
|
{
|
||||||
|
private const SDL_InitFlags VideoFlag = SDL_InitFlags.SDL_INIT_VIDEO;
|
||||||
|
private static int _queryFailureLogged;
|
||||||
|
|
||||||
|
public static IReadOnlyList<HostDisplayInfo> Query()
|
||||||
|
{
|
||||||
|
var initializedHere = false;
|
||||||
|
var videoReady = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
initializedHere = (SDL_WasInit(VideoFlag) & VideoFlag) == 0;
|
||||||
|
if (initializedHere && !SDL_InitSubSystem(VideoFlag))
|
||||||
|
{
|
||||||
|
LogQueryFailure(SDL_GetError() ?? "unknown SDL error");
|
||||||
|
return CreateFallback();
|
||||||
|
}
|
||||||
|
videoReady = true;
|
||||||
|
|
||||||
|
using var displays = SDL_GetDisplays();
|
||||||
|
if (displays is null || displays.Count == 0)
|
||||||
|
{
|
||||||
|
LogQueryFailure("SDL reported no displays");
|
||||||
|
return CreateFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<HostDisplayInfo>(displays.Count);
|
||||||
|
for (var index = 0; index < displays.Count; index++)
|
||||||
|
{
|
||||||
|
var display = displays[index];
|
||||||
|
var name = SDL_GetDisplayName(display);
|
||||||
|
var modes = ReadModes(display);
|
||||||
|
result.Add(new HostDisplayInfo(
|
||||||
|
index,
|
||||||
|
string.IsNullOrWhiteSpace(name) ? $"Display {index + 1}" : name,
|
||||||
|
modes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
LogQueryFailure(exception.Message);
|
||||||
|
return CreateFallback();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (initializedHere && videoReady)
|
||||||
|
{
|
||||||
|
SDL_QuitSubSystem(VideoFlag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<HostDisplayMode> ReadModes(SDL_DisplayID display)
|
||||||
|
{
|
||||||
|
var modes = new HashSet<HostDisplayMode>();
|
||||||
|
using (var fullscreenModes = SDL_GetFullscreenDisplayModes(display))
|
||||||
|
{
|
||||||
|
if (fullscreenModes is not null)
|
||||||
|
{
|
||||||
|
for (var index = 0; index < fullscreenModes.Count; index++)
|
||||||
|
{
|
||||||
|
var mode = fullscreenModes[index];
|
||||||
|
AddMode(modes, &mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddMode(modes, SDL_GetDesktopDisplayMode(display));
|
||||||
|
if (modes.Count == 0)
|
||||||
|
{
|
||||||
|
return CreateFallbackModes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return modes
|
||||||
|
.OrderByDescending(mode => (long)mode.Width * mode.Height)
|
||||||
|
.ThenByDescending(mode => mode.Width)
|
||||||
|
.ThenByDescending(mode => mode.RefreshRate)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddMode(HashSet<HostDisplayMode> modes, SDL_DisplayMode* mode)
|
||||||
|
{
|
||||||
|
if (mode is null || mode->w <= 0 || mode->h <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshRate = mode->refresh_rate > 0
|
||||||
|
? Math.Max(1, (int)Math.Round(mode->refresh_rate, MidpointRounding.AwayFromZero))
|
||||||
|
: 0;
|
||||||
|
modes.Add(new HostDisplayMode(mode->w, mode->h, refreshRate));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<HostDisplayInfo> CreateFallback() =>
|
||||||
|
[new HostDisplayInfo(0, "Display 1", CreateFallbackModes())];
|
||||||
|
|
||||||
|
private static IReadOnlyList<HostDisplayMode> CreateFallbackModes() =>
|
||||||
|
[
|
||||||
|
new HostDisplayMode(3840, 2160, 60),
|
||||||
|
new HostDisplayMode(2560, 1440, 60),
|
||||||
|
new HostDisplayMode(1920, 1080, 60),
|
||||||
|
new HostDisplayMode(1280, 720, 60),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static void LogQueryFailure(string message)
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _queryFailureLogged, 1) == 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[GUI][WARN] SDL display query failed: {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.VideoOut;
|
||||||
|
|
||||||
|
using SharpEmu.Libs.Gpu.Metal;
|
||||||
|
|
||||||
|
public enum HostWindowMode
|
||||||
|
{
|
||||||
|
Windowed,
|
||||||
|
Borderless,
|
||||||
|
ExclusiveFullscreen,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HostScalingMode
|
||||||
|
{
|
||||||
|
Fit,
|
||||||
|
Cover,
|
||||||
|
Stretch,
|
||||||
|
Integer,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HostHdrMode
|
||||||
|
{
|
||||||
|
Auto,
|
||||||
|
On,
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record HostVideoOptions
|
||||||
|
{
|
||||||
|
public static HostVideoOptions Default { get; } = new();
|
||||||
|
|
||||||
|
public HostWindowMode WindowMode { get; init; } = HostWindowMode.Windowed;
|
||||||
|
|
||||||
|
public HostScalingMode ScalingMode { get; init; } = HostScalingMode.Fit;
|
||||||
|
|
||||||
|
public int Width { get; init; } = 1920;
|
||||||
|
|
||||||
|
public int Height { get; init; } = 1080;
|
||||||
|
|
||||||
|
public int DisplayIndex { get; init; }
|
||||||
|
|
||||||
|
public int RefreshRate { get; init; }
|
||||||
|
|
||||||
|
public bool VSync { get; init; } = true;
|
||||||
|
|
||||||
|
public HostHdrMode HdrMode { get; init; } = HostHdrMode.Auto;
|
||||||
|
|
||||||
|
public HostVideoOptions Normalize() => this with
|
||||||
|
{
|
||||||
|
Width = Math.Clamp(Width, 640, 16384),
|
||||||
|
Height = Math.Clamp(Height, 360, 16384),
|
||||||
|
DisplayIndex = Math.Max(0, DisplayIndex),
|
||||||
|
RefreshRate = Math.Clamp(RefreshRate, 0, 1000),
|
||||||
|
HdrMode = Enum.IsDefined(HdrMode) ? HdrMode : HostHdrMode.Auto,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class HostVideoHost
|
||||||
|
{
|
||||||
|
public static bool TryConfigureVideo(HostVideoOptions options)
|
||||||
|
{
|
||||||
|
var normalized = options.Normalize();
|
||||||
|
return VulkanVideoPresenter.TryConfigureVideo(normalized) &
|
||||||
|
MetalVideoPresenter.TryConfigureVideo(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.VideoOut;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Self-time accounting for the render thread, enabled with
|
||||||
|
/// SHARPEMU_PROFILE_RENDER=1. The existing videoout counters report how much
|
||||||
|
/// work was done (draws, pipelines, SPIR-V) but not where the render thread's
|
||||||
|
/// second went, which is the number that decides whether a low frame rate is
|
||||||
|
/// the emulator recording commands, the GPU executing them, or neither.
|
||||||
|
///
|
||||||
|
/// Scopes nest: entering a phase suspends the enclosing one and resumes it on
|
||||||
|
/// dispose, so a <see cref="Phase.QueueSubmit"/> inside
|
||||||
|
/// <see cref="Phase.Flush"/> is never counted twice.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RenderPhaseProfile
|
||||||
|
{
|
||||||
|
internal enum Phase
|
||||||
|
{
|
||||||
|
/// <summary>Outside any measured phase — loop overhead.</summary>
|
||||||
|
Unattributed = 0,
|
||||||
|
/// <summary>Parked because no guest work and no newer flip exist.</summary>
|
||||||
|
Idle,
|
||||||
|
/// <summary>Blocked on the frame slot's fence: the GPU is behind.</summary>
|
||||||
|
FrameSlotWait,
|
||||||
|
/// <summary>Reaping completed guest submissions (fence polls).</summary>
|
||||||
|
Collect,
|
||||||
|
Evict,
|
||||||
|
/// <summary>Dequeuing the next guest work item.</summary>
|
||||||
|
TakeWork,
|
||||||
|
/// <summary>Building the diagnostic label for a work item.</summary>
|
||||||
|
Describe,
|
||||||
|
/// <summary>Publishing a work item's completion to its waiters.</summary>
|
||||||
|
CompleteWork,
|
||||||
|
/// <summary>Selecting the presentation to show this iteration.</summary>
|
||||||
|
TakePresentation,
|
||||||
|
Draw,
|
||||||
|
Compute,
|
||||||
|
ColorClear,
|
||||||
|
ImageWrite,
|
||||||
|
OrderedAction,
|
||||||
|
Flip,
|
||||||
|
/// <summary>Closing and submitting the batched guest command buffer.</summary>
|
||||||
|
Flush,
|
||||||
|
/// <summary>vkQueueSubmit itself.</summary>
|
||||||
|
QueueSubmit,
|
||||||
|
/// <summary>vkAcquireNextImageKHR.</summary>
|
||||||
|
Acquire,
|
||||||
|
/// <summary>Recording + submitting the presentation command buffer.</summary>
|
||||||
|
Present,
|
||||||
|
/// <summary>vkQueuePresentKHR.</summary>
|
||||||
|
QueuePresent,
|
||||||
|
Count,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly bool Enabled = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_RENDER"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Breaks down the CPU-visible actions which are deliberately serialized
|
||||||
|
/// behind guest GPU work. This stays opt-in because it is diagnostic data,
|
||||||
|
/// not a normal render-thread cost.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly bool OrderedActionDetailsEnabled = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_ORDERED_ACTION"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static readonly double _reportSeconds =
|
||||||
|
double.TryParse(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_RENDER_REPORT_S"),
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out var seconds) && seconds > 0
|
||||||
|
? seconds
|
||||||
|
: 5.0;
|
||||||
|
|
||||||
|
private static readonly long[] _ticks = new long[(int)Phase.Count];
|
||||||
|
private static readonly long[] _entries = new long[(int)Phase.Count];
|
||||||
|
private static long _frames;
|
||||||
|
private static long _windowStart = Stopwatch.GetTimestamp();
|
||||||
|
private static readonly Dictionary<string, OrderedActionStats> _orderedActions =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// The render loop is single-threaded, so plain fields are enough and keep
|
||||||
|
// the per-scope cost to two timestamp reads.
|
||||||
|
[ThreadStatic] private static Phase _current;
|
||||||
|
[ThreadStatic] private static long _lastTimestamp;
|
||||||
|
|
||||||
|
internal readonly ref struct Scope
|
||||||
|
{
|
||||||
|
private readonly Phase _previous;
|
||||||
|
private readonly bool _active;
|
||||||
|
|
||||||
|
internal Scope(Phase previous)
|
||||||
|
{
|
||||||
|
_previous = previous;
|
||||||
|
_active = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Charge(_previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Scope Measure(Phase phase)
|
||||||
|
{
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous = Charge(phase);
|
||||||
|
_entries[(int)phase]++;
|
||||||
|
return new Scope(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RecordOrderedAction(string debugName, bool completed)
|
||||||
|
{
|
||||||
|
if (!OrderedActionDetailsEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var category = GetOrderedActionCategory(debugName);
|
||||||
|
ref var stats = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(
|
||||||
|
_orderedActions,
|
||||||
|
category,
|
||||||
|
out _);
|
||||||
|
stats.Executed += completed ? 1 : 0;
|
||||||
|
stats.Deferred += completed ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes out the running phase and switches to <paramref name="next"/>,
|
||||||
|
/// returning the phase that was running.
|
||||||
|
/// </summary>
|
||||||
|
private static Phase Charge(Phase next)
|
||||||
|
{
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
var previous = _current;
|
||||||
|
if (_lastTimestamp != 0)
|
||||||
|
{
|
||||||
|
_ticks[(int)previous] += now - _lastTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastTimestamp = now;
|
||||||
|
_current = next;
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Called once per presented frame; also drives the report.</summary>
|
||||||
|
public static void RecordFrame()
|
||||||
|
{
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_frames++;
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
var elapsedTicks = now - _windowStart;
|
||||||
|
if (elapsedTicks < _reportSeconds * Stopwatch.Frequency)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_windowStart = now;
|
||||||
|
var seconds = elapsedTicks / (double)Stopwatch.Frequency;
|
||||||
|
var frames = _frames;
|
||||||
|
_frames = 0;
|
||||||
|
|
||||||
|
var parts = new List<(Phase Phase, double Percent, long Entries)>((int)Phase.Count);
|
||||||
|
var accounted = 0L;
|
||||||
|
for (var index = 0; index < (int)Phase.Count; index++)
|
||||||
|
{
|
||||||
|
var phaseTicks = _ticks[index];
|
||||||
|
_ticks[index] = 0;
|
||||||
|
var entries = _entries[index];
|
||||||
|
_entries[index] = 0;
|
||||||
|
accounted += phaseTicks;
|
||||||
|
if (phaseTicks <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.Add(((Phase)index, phaseTicks * 100.0 / elapsedTicks, entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.Sort(static (left, right) => right.Percent.CompareTo(left.Percent));
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[PERF][RENDER] {seconds:F1}s fps={frames / seconds:F1} " +
|
||||||
|
$"covered={accounted * 100.0 / elapsedTicks:F0}% " +
|
||||||
|
string.Join(
|
||||||
|
" ",
|
||||||
|
parts.Select(part =>
|
||||||
|
$"{part.Phase}={part.Percent:F1}%" +
|
||||||
|
(part.Entries > 0 ? $"/n{part.Entries}" : string.Empty))));
|
||||||
|
|
||||||
|
if (OrderedActionDetailsEnabled && _orderedActions.Count != 0)
|
||||||
|
{
|
||||||
|
var ordered = _orderedActions
|
||||||
|
.OrderByDescending(static pair => pair.Value.Executed + pair.Value.Deferred)
|
||||||
|
.Take(12)
|
||||||
|
.Select(static pair =>
|
||||||
|
$"{pair.Key}=ok{pair.Value.Executed}/defer{pair.Value.Deferred}");
|
||||||
|
Console.Error.WriteLine($"[PERF][ORDERED] {string.Join(" ", ordered)}");
|
||||||
|
_orderedActions.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOrderedActionCategory(string debugName)
|
||||||
|
{
|
||||||
|
if (debugName.EndsWith(" completion", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "completion";
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstSpace = debugName.IndexOf(' ');
|
||||||
|
if (firstSpace < 0)
|
||||||
|
{
|
||||||
|
return debugName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AGC labels conventionally begin with "agc <packet>". Keeping the
|
||||||
|
// packet token separates DMA, submit and register traffic without
|
||||||
|
// retaining guest addresses in the diagnostic key.
|
||||||
|
if (debugName.StartsWith("agc ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var secondSpace = debugName.IndexOf(' ', firstSpace + 1);
|
||||||
|
return secondSpace < 0 ? debugName : debugName[..secondSpace];
|
||||||
|
}
|
||||||
|
|
||||||
|
return debugName[..firstSpace];
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OrderedActionStats
|
||||||
|
{
|
||||||
|
public long Executed;
|
||||||
|
public long Deferred;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
|
using SharpEmu.Logging;
|
||||||
using SharpEmu.HLE.Host;
|
using SharpEmu.HLE.Host;
|
||||||
using SharpEmu.Libs.Diagnostics;
|
using SharpEmu.Libs.Diagnostics;
|
||||||
using SharpEmu.Libs.Gpu;
|
using SharpEmu.Libs.Gpu;
|
||||||
@@ -69,11 +70,14 @@ public static class VideoOutExports
|
|||||||
private static readonly Dictionary<int, VideoOutPortState> _ports = new();
|
private static readonly Dictionary<int, VideoOutPortState> _ports = new();
|
||||||
private static int _presentationWindowCloseNotified;
|
private static int _presentationWindowCloseNotified;
|
||||||
private static int _vblankStopRequested;
|
private static int _vblankStopRequested;
|
||||||
|
private static int _hdrOutputRequested;
|
||||||
private static readonly Dictionary<(int Handle, int BufferIndex, ulong Address), ulong> _lastFrameFingerprints = new();
|
private static readonly Dictionary<(int Handle, int BufferIndex, ulong Address), ulong> _lastFrameFingerprints = new();
|
||||||
private static int _nextHandle = 1;
|
private static int _nextHandle = 1;
|
||||||
private static int _frameDumpCount;
|
private static int _frameDumpCount;
|
||||||
private static long _nextFrameDumpIndex;
|
private static long _nextFrameDumpIndex;
|
||||||
private static string _windowTitle = "SharpEmu VideoOut";
|
private static string _applicationWindowTitle = "VideoOut";
|
||||||
|
private static string _selectedGpuName = string.Empty;
|
||||||
|
private static string _applicationTitleId = "UNKNOWN";
|
||||||
private static readonly bool _logFrameRate = string.Equals(
|
private static readonly bool _logFrameRate = string.Equals(
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"),
|
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"),
|
||||||
"1",
|
"1",
|
||||||
@@ -113,7 +117,18 @@ public static class VideoOutExports
|
|||||||
var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}";
|
var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}";
|
||||||
lock (_stateGate)
|
lock (_stateGate)
|
||||||
{
|
{
|
||||||
_windowTitle = $"SharpEmu - {application}{versionSuffix}";
|
_applicationTitleId = string.IsNullOrWhiteSpace(titleId)
|
||||||
|
? "UNKNOWN"
|
||||||
|
: titleId.Trim();
|
||||||
|
_applicationWindowTitle = $"{application}{versionSuffix}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GetApplicationTitleId()
|
||||||
|
{
|
||||||
|
lock (_stateGate)
|
||||||
|
{
|
||||||
|
return _applicationTitleId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +136,10 @@ public static class VideoOutExports
|
|||||||
{
|
{
|
||||||
lock (_stateGate)
|
lock (_stateGate)
|
||||||
{
|
{
|
||||||
return _windowTitle;
|
var gpuSuffix = string.IsNullOrWhiteSpace(_selectedGpuName)
|
||||||
|
? string.Empty
|
||||||
|
: $" · {_selectedGpuName}";
|
||||||
|
return $"SharpEmu · {BuildInfo.CommitSha ?? "dev"} - {_applicationWindowTitle}{gpuSuffix}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +157,7 @@ public static class VideoOutExports
|
|||||||
: string.Empty;
|
: string.Empty;
|
||||||
lock (_stateGate)
|
lock (_stateGate)
|
||||||
{
|
{
|
||||||
_windowTitle = $"{_windowTitle} · {gpuName.Trim()}{backendSuffix}";
|
_selectedGpuName = $"{gpuName.Trim()}{backendSuffix}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,30 +184,17 @@ public static class VideoOutExports
|
|||||||
private static void RequestHostShutdown(string reason)
|
private static void RequestHostShutdown(string reason)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
|
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
|
||||||
var embedded = VulkanVideoHost.IsEmbedded;
|
|
||||||
AudioOutExports.ShutdownAllPorts();
|
AudioOutExports.ShutdownAllPorts();
|
||||||
Interlocked.Exchange(ref _vblankStopRequested, 1);
|
Interlocked.Exchange(ref _vblankStopRequested, 1);
|
||||||
HostSessionControl.RequestShutdown(reason);
|
HostSessionControl.RequestShutdown(reason);
|
||||||
|
GuestGpu.Current.RequestClose();
|
||||||
|
|
||||||
// A hosted game can still be issuing AGC work after it requests its
|
// Give guest and GPU threads a bounded window to leave cooperatively.
|
||||||
// own shutdown. Keep the presenter's resources alive until the GUI
|
ThreadPool.QueueUserWorkItem(static _ =>
|
||||||
// session reaches its guest-safe exit path and disposes the host
|
|
||||||
// surface.
|
|
||||||
if (!embedded)
|
|
||||||
{
|
{
|
||||||
GuestGpu.Current.RequestClose();
|
Thread.Sleep(2000);
|
||||||
}
|
Environment.Exit(0);
|
||||||
|
});
|
||||||
// The embedded GUI owns the process lifetime. A guest shutdown should
|
|
||||||
// end only that session rather than terminating the launcher itself.
|
|
||||||
if (!embedded)
|
|
||||||
{
|
|
||||||
ThreadPool.QueueUserWorkItem(static _ =>
|
|
||||||
{
|
|
||||||
Thread.Sleep(2000);
|
|
||||||
Environment.Exit(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class VideoOutPortState
|
private sealed class VideoOutPortState
|
||||||
@@ -871,6 +876,8 @@ public static class VideoOutExports
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static bool IsHdrOutputRequested => Volatile.Read(ref _hdrOutputRequested) != 0;
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "MTxxrOCeSig",
|
Nid = "MTxxrOCeSig",
|
||||||
ExportName = "sceVideoOutSetWindowModeMargins",
|
ExportName = "sceVideoOutSetWindowModeMargins",
|
||||||
@@ -1175,16 +1182,21 @@ public static class VideoOutExports
|
|||||||
|
|
||||||
var guestImageSubmitted = false;
|
var guestImageSubmitted = false;
|
||||||
ulong guestImageAddress = 0;
|
ulong guestImageAddress = 0;
|
||||||
if (submitGpuImage &&
|
if (bufferIndex >= 0 &&
|
||||||
bufferIndex >= 0 &&
|
|
||||||
TryGetDisplayBufferInfo(handle, bufferIndex, out var displayBuffer))
|
TryGetDisplayBufferInfo(handle, bufferIndex, out var displayBuffer))
|
||||||
{
|
{
|
||||||
|
Interlocked.Exchange(
|
||||||
|
ref _hdrOutputRequested,
|
||||||
|
IsHdrPixelFormat(displayBuffer.PixelFormat) ? 1 : 0);
|
||||||
guestImageAddress = displayBuffer.Address;
|
guestImageAddress = displayBuffer.Address;
|
||||||
guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
|
if (submitGpuImage)
|
||||||
displayBuffer.Address,
|
{
|
||||||
displayBuffer.Width,
|
guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
|
||||||
displayBuffer.Height,
|
displayBuffer.Address,
|
||||||
displayBuffer.PitchInPixel);
|
displayBuffer.Width,
|
||||||
|
displayBuffer.Height,
|
||||||
|
displayBuffer.PitchInPixel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_dumpVideoOut)
|
if (_dumpVideoOut)
|
||||||
@@ -1711,6 +1723,12 @@ public static class VideoOutExports
|
|||||||
internal static bool IsPacked10BitPixelFormat(ulong pixelFormat) =>
|
internal static bool IsPacked10BitPixelFormat(ulong pixelFormat) =>
|
||||||
IsPacked10BitPixelFormatNormalized(NormalizePixelFormat(pixelFormat));
|
IsPacked10BitPixelFormatNormalized(NormalizePixelFormat(pixelFormat));
|
||||||
|
|
||||||
|
internal static bool IsHdrPixelFormat(ulong pixelFormat) =>
|
||||||
|
NormalizePixelFormat(pixelFormat) is
|
||||||
|
SceVideoOutPixelFormatA2R10G10B10Bt2020Pq or
|
||||||
|
SceVideoOutPixelFormat2R10G10B10A2Bt2100Pq or
|
||||||
|
SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq;
|
||||||
|
|
||||||
private static bool IsPacked10BitPixelFormatNormalized(ulong pixelFormat) =>
|
private static bool IsPacked10BitPixelFormatNormalized(ulong pixelFormat) =>
|
||||||
pixelFormat is
|
pixelFormat is
|
||||||
SceVideoOutPixelFormatA2R10G10B10 or
|
SceVideoOutPixelFormatA2R10G10B10 or
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
namespace SharpEmu.Libs.VideoOut;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A native child surface owned by a host UI. The presenter consumes this
|
|
||||||
/// directly, avoiding a second top-level GLFW window when the GUI is active.
|
|
||||||
/// </summary>
|
|
||||||
public enum VulkanHostSurfaceKind
|
|
||||||
{
|
|
||||||
Win32,
|
|
||||||
Xlib,
|
|
||||||
Metal,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Platform-native handles required to create a Vulkan presentation surface.
|
|
||||||
/// The GUI owns their lifetime and updates the physical pixel size on resize.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class VulkanHostSurface : IDisposable
|
|
||||||
{
|
|
||||||
private int _pixelWidth;
|
|
||||||
private int _pixelHeight;
|
|
||||||
private int _resizeGeneration;
|
|
||||||
private readonly bool _ownsDisplay;
|
|
||||||
private readonly bool _pollNativeSize;
|
|
||||||
private long _nextNativeSizePoll;
|
|
||||||
|
|
||||||
public VulkanHostSurface(
|
|
||||||
VulkanHostSurfaceKind kind,
|
|
||||||
nint windowHandle,
|
|
||||||
nint displayHandle = 0,
|
|
||||||
nint metalLayerHandle = 0,
|
|
||||||
bool ownsDisplay = false,
|
|
||||||
bool pollNativeSize = false)
|
|
||||||
{
|
|
||||||
Kind = kind;
|
|
||||||
WindowHandle = windowHandle;
|
|
||||||
DisplayHandle = displayHandle;
|
|
||||||
MetalLayerHandle = metalLayerHandle;
|
|
||||||
_ownsDisplay = ownsDisplay;
|
|
||||||
_pollNativeSize = pollNativeSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
public VulkanHostSurfaceKind Kind { get; }
|
|
||||||
|
|
||||||
public nint WindowHandle { get; }
|
|
||||||
|
|
||||||
/// <summary>X11 Display* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Xlib"/>.</summary>
|
|
||||||
public nint DisplayHandle { get; }
|
|
||||||
|
|
||||||
/// <summary>CAMetalLayer* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Metal"/>.</summary>
|
|
||||||
public nint MetalLayerHandle { get; }
|
|
||||||
|
|
||||||
public int PixelWidth => Volatile.Read(ref _pixelWidth);
|
|
||||||
|
|
||||||
public int PixelHeight => Volatile.Read(ref _pixelHeight);
|
|
||||||
|
|
||||||
internal int ResizeGeneration => Volatile.Read(ref _resizeGeneration);
|
|
||||||
|
|
||||||
public void UpdatePixelSize(int width, int height)
|
|
||||||
{
|
|
||||||
width = Math.Max(width, 1);
|
|
||||||
height = Math.Max(height, 1);
|
|
||||||
if (Volatile.Read(ref _pixelWidth) == width && Volatile.Read(ref _pixelHeight) == height)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Volatile.Write(ref _pixelWidth, width);
|
|
||||||
Volatile.Write(ref _pixelHeight, height);
|
|
||||||
Interlocked.Increment(ref _resizeGeneration);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The child emulator cannot receive Avalonia resize notifications. Poll
|
|
||||||
/// the native host at a bounded rate so embedded child swapchains still
|
|
||||||
/// follow normal resize and F11 transitions.
|
|
||||||
/// </summary>
|
|
||||||
internal void RefreshChildProcessPixelSize()
|
|
||||||
{
|
|
||||||
if (!_pollNativeSize)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
||||||
var due = Volatile.Read(ref _nextNativeSizePoll);
|
|
||||||
if (now < due || Interlocked.CompareExchange(
|
|
||||||
ref _nextNativeSizePoll,
|
|
||||||
now + (System.Diagnostics.Stopwatch.Frequency / 8),
|
|
||||||
due) != due)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Kind == VulkanHostSurfaceKind.Win32 && GetClientRect(WindowHandle, out var rect))
|
|
||||||
{
|
|
||||||
UpdatePixelSize(rect.Right - rect.Left, rect.Bottom - rect.Top);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Kind == VulkanHostSurfaceKind.Xlib && DisplayHandle != 0 &&
|
|
||||||
XGetGeometry(
|
|
||||||
DisplayHandle,
|
|
||||||
WindowHandle,
|
|
||||||
out _,
|
|
||||||
out _,
|
|
||||||
out _,
|
|
||||||
out var width,
|
|
||||||
out var height,
|
|
||||||
out _,
|
|
||||||
out _) != 0)
|
|
||||||
{
|
|
||||||
UpdatePixelSize(unchecked((int)width), unchecked((int)height));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serializes a native child handle for a separately hosted emulator
|
|
||||||
/// process. Metal object pointers are process-local, so macOS falls back
|
|
||||||
/// to a standalone child window until an IPC Metal host is implemented.
|
|
||||||
/// </summary>
|
|
||||||
public bool TryGetChildProcessDescriptor(out string descriptor)
|
|
||||||
{
|
|
||||||
descriptor = string.Empty;
|
|
||||||
if (Kind == VulkanHostSurfaceKind.Metal || WindowHandle == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var kind = Kind == VulkanHostSurfaceKind.Win32 ? "win32" : "xlib";
|
|
||||||
descriptor = string.Create(
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
|
||||||
$"{kind}:{unchecked((ulong)WindowHandle):X}:{Math.Max(PixelWidth, 1)}:{Math.Max(PixelHeight, 1)}:{unchecked((ulong)DisplayHandle):X}");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reconstructs a surface in the isolated emulator process. X11 clients
|
|
||||||
/// must open their own Display connection; Display* values cannot cross a
|
|
||||||
/// process boundary.
|
|
||||||
/// </summary>
|
|
||||||
public static bool TryCreateChildProcessSurface(
|
|
||||||
string descriptor,
|
|
||||||
out VulkanHostSurface? surface,
|
|
||||||
out string? error)
|
|
||||||
{
|
|
||||||
surface = null;
|
|
||||||
error = null;
|
|
||||||
var parts = descriptor.Split(':');
|
|
||||||
if (parts.Length != 5 ||
|
|
||||||
!ulong.TryParse(parts[1], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var window) ||
|
|
||||||
!int.TryParse(parts[2], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var width) ||
|
|
||||||
!int.TryParse(parts[3], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var height) ||
|
|
||||||
!ulong.TryParse(parts[4], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var nativeDisplay))
|
|
||||||
{
|
|
||||||
error = "invalid host-surface descriptor";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window == 0 || width <= 0 || height <= 0)
|
|
||||||
{
|
|
||||||
error = "host-surface descriptor has an invalid size or handle";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(parts[0], "win32", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
surface = new VulkanHostSurface(
|
|
||||||
VulkanHostSurfaceKind.Win32,
|
|
||||||
unchecked((nint)window),
|
|
||||||
unchecked((nint)nativeDisplay),
|
|
||||||
pollNativeSize: true);
|
|
||||||
}
|
|
||||||
else if (string.Equals(parts[0], "xlib", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var display = XOpenDisplay(0);
|
|
||||||
if (display == 0)
|
|
||||||
{
|
|
||||||
error = "could not open an X11 display for the host surface";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
surface = new VulkanHostSurface(
|
|
||||||
VulkanHostSurfaceKind.Xlib,
|
|
||||||
unchecked((nint)window),
|
|
||||||
display,
|
|
||||||
ownsDisplay: true,
|
|
||||||
pollNativeSize: true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
error = $"unsupported host-surface kind '{parts[0]}'";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
surface.UpdatePixelSize(width, height);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_ownsDisplay && DisplayHandle != 0 && OperatingSystem.IsLinux())
|
|
||||||
{
|
|
||||||
_ = XCloseDisplay(DisplayHandle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
|
|
||||||
private static extern nint XOpenDisplay(nint displayName);
|
|
||||||
|
|
||||||
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
|
|
||||||
private static extern int XCloseDisplay(nint display);
|
|
||||||
|
|
||||||
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetClientRect", SetLastError = true)]
|
|
||||||
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
|
|
||||||
private static extern bool GetClientRect(nint window, out Rect rect);
|
|
||||||
|
|
||||||
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XGetGeometry")]
|
|
||||||
private static extern int XGetGeometry(
|
|
||||||
nint display,
|
|
||||||
nint drawable,
|
|
||||||
out nint root,
|
|
||||||
out int x,
|
|
||||||
out int y,
|
|
||||||
out uint width,
|
|
||||||
out uint height,
|
|
||||||
out uint borderWidth,
|
|
||||||
out uint depth);
|
|
||||||
|
|
||||||
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
|
|
||||||
private struct Rect
|
|
||||||
{
|
|
||||||
public int Left;
|
|
||||||
public int Top;
|
|
||||||
public int Right;
|
|
||||||
public int Bottom;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Small public bridge between a desktop UI and the internal Vulkan
|
|
||||||
/// presenter. Launchers can host a surface without depending on renderer
|
|
||||||
/// submission internals.
|
|
||||||
/// </summary>
|
|
||||||
public static class VulkanVideoHost
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Raised after the first successful Vulkan present to an embedded host
|
|
||||||
/// surface. UI hosts use this to retire their launch affordance only once
|
|
||||||
/// a real frame can be seen.
|
|
||||||
/// </summary>
|
|
||||||
public static event Action<VulkanHostSurface>? FirstFramePresented
|
|
||||||
{
|
|
||||||
add => VulkanVideoPresenter.FirstHostFramePresented += value;
|
|
||||||
remove => VulkanVideoPresenter.FirstHostFramePresented -= value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryAttachSurface(VulkanHostSurface surface) =>
|
|
||||||
VulkanVideoPresenter.TryAttachHostSurface(surface);
|
|
||||||
|
|
||||||
public static void DetachSurface(VulkanHostSurface surface) =>
|
|
||||||
VulkanVideoPresenter.DetachHostSurface(surface);
|
|
||||||
|
|
||||||
public static void RequestClose() => VulkanVideoPresenter.RequestClose();
|
|
||||||
|
|
||||||
public static bool IsEmbedded => VulkanVideoPresenter.UsesHostSurface;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.VideoOut;
|
||||||
|
|
||||||
|
internal static class VulkanPipelineCacheStorage
|
||||||
|
{
|
||||||
|
private const string CacheFileName = "vulkan-pipeline-cache.bin";
|
||||||
|
|
||||||
|
internal static string ResolvePath(string? titleId, string? configuredPath)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(
|
||||||
|
Environment.ExpandEnvironmentVariables(configuredPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
"user",
|
||||||
|
"pipeline_cache",
|
||||||
|
SanitizeTitleId(titleId),
|
||||||
|
CacheFileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GetLegacyPath()
|
||||||
|
{
|
||||||
|
var root = OperatingSystem.IsMacOS()
|
||||||
|
? Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
"Library",
|
||||||
|
"Caches")
|
||||||
|
: Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
return Path.Combine(root, "SharpEmu", CacheFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool ImportLegacyCache(string legacyPath, string destinationPath)
|
||||||
|
{
|
||||||
|
if (File.Exists(destinationPath) || !File.Exists(legacyPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var directory = Path.GetDirectoryName(destinationPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Copy(legacyPath, destinationPath, overwrite: false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(legacyPath);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// The destination is valid; a locked legacy cache can remain
|
||||||
|
// as an unused artifact without affecting future writes.
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// See above. Cache migration must not block game startup.
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (IOException) when (File.Exists(destinationPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeTitleId(string? titleId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(titleId))
|
||||||
|
{
|
||||||
|
return "UNKNOWN";
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = titleId.Trim();
|
||||||
|
Span<char> sanitized = source.Length <= 128
|
||||||
|
? stackalloc char[source.Length]
|
||||||
|
: new char[source.Length];
|
||||||
|
for (var index = 0; index < source.Length; index++)
|
||||||
|
{
|
||||||
|
var value = source[index];
|
||||||
|
sanitized[index] = char.IsAsciiLetterOrDigit(value) || value is '-' or '_'
|
||||||
|
? char.ToUpperInvariant(value)
|
||||||
|
: '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(sanitized);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -92,7 +92,7 @@ public static class SpirvFixedShaders
|
|||||||
return module.Build();
|
return module.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static byte[] CreateCopyFragment()
|
public static byte[] CreateCopyFragment(float colorScale = 1f)
|
||||||
{
|
{
|
||||||
var module = new SpirvModuleBuilder();
|
var module = new SpirvModuleBuilder();
|
||||||
module.AddCapability(SpirvCapability.Shader);
|
module.AddCapability(SpirvCapability.Shader);
|
||||||
@@ -152,6 +152,16 @@ public static class SpirvFixedShaders
|
|||||||
coordinates,
|
coordinates,
|
||||||
2,
|
2,
|
||||||
lod);
|
lod);
|
||||||
|
if (colorScale != 1f)
|
||||||
|
{
|
||||||
|
var scale = module.ConstantComposite(
|
||||||
|
vec4Type,
|
||||||
|
module.ConstantFloat(floatType, colorScale),
|
||||||
|
module.ConstantFloat(floatType, colorScale),
|
||||||
|
module.ConstantFloat(floatType, colorScale),
|
||||||
|
module.ConstantFloat(floatType, 1f));
|
||||||
|
color = module.AddInstruction(SpirvOp.FMul, vec4Type, color, scale);
|
||||||
|
}
|
||||||
module.AddStatement(SpirvOp.Store, output, color);
|
module.AddStatement(SpirvOp.Store, output, color);
|
||||||
module.AddStatement(SpirvOp.Return);
|
module.AddStatement(SpirvOp.Return);
|
||||||
module.EndFunction();
|
module.EndFunction();
|
||||||
@@ -165,6 +175,155 @@ public static class SpirvFixedShaders
|
|||||||
return module.Build();
|
return module.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static byte[] CreatePqToScRgbFragment()
|
||||||
|
{
|
||||||
|
const float inverseM1 = 16384f / 2610f;
|
||||||
|
const float inverseM2 = 32f / 2523f;
|
||||||
|
const float c1 = 3424f / 4096f;
|
||||||
|
const float c2 = 2413f / 128f;
|
||||||
|
const float c3 = 2392f / 128f;
|
||||||
|
|
||||||
|
var module = new SpirvModuleBuilder();
|
||||||
|
module.AddCapability(SpirvCapability.Shader);
|
||||||
|
var glsl = module.ImportExtInst("GLSL.std.450");
|
||||||
|
var voidType = module.TypeVoid();
|
||||||
|
var floatType = module.TypeFloat(32);
|
||||||
|
var vec2Type = module.TypeVector(floatType, 2);
|
||||||
|
var vec3Type = module.TypeVector(floatType, 3);
|
||||||
|
var vec4Type = module.TypeVector(floatType, 4);
|
||||||
|
var inputVec4Pointer = module.TypePointer(SpirvStorageClass.Input, vec4Type);
|
||||||
|
var outputVec4Pointer = module.TypePointer(SpirvStorageClass.Output, vec4Type);
|
||||||
|
var imageType = module.TypeImage(
|
||||||
|
floatType,
|
||||||
|
SpirvImageDim.Dim2D,
|
||||||
|
depth: false,
|
||||||
|
arrayed: false,
|
||||||
|
multisampled: false,
|
||||||
|
sampled: 1,
|
||||||
|
SpirvImageFormat.Unknown);
|
||||||
|
var sampledImageType = module.TypeSampledImage(imageType);
|
||||||
|
var sampledImagePointer =
|
||||||
|
module.TypePointer(SpirvStorageClass.UniformConstant, sampledImageType);
|
||||||
|
var attribute = module.AddGlobalVariable(inputVec4Pointer, SpirvStorageClass.Input);
|
||||||
|
module.AddDecoration(attribute, SpirvDecoration.Location, 0);
|
||||||
|
var texture = module.AddGlobalVariable(
|
||||||
|
sampledImagePointer,
|
||||||
|
SpirvStorageClass.UniformConstant);
|
||||||
|
module.AddDecoration(texture, SpirvDecoration.DescriptorSet, 0);
|
||||||
|
module.AddDecoration(texture, SpirvDecoration.Binding, 1);
|
||||||
|
var output = module.AddGlobalVariable(outputVec4Pointer, SpirvStorageClass.Output);
|
||||||
|
module.AddDecoration(output, SpirvDecoration.Location, 0);
|
||||||
|
|
||||||
|
uint Float(float value) => module.ConstantFloat(floatType, value);
|
||||||
|
uint Vec3(float value) => module.ConstantComposite(
|
||||||
|
vec3Type,
|
||||||
|
Float(value),
|
||||||
|
Float(value),
|
||||||
|
Float(value));
|
||||||
|
uint Ext(uint operation, uint resultType, params uint[] operands)
|
||||||
|
{
|
||||||
|
var values = new uint[2 + operands.Length];
|
||||||
|
values[0] = glsl;
|
||||||
|
values[1] = operation;
|
||||||
|
operands.CopyTo(values, 2);
|
||||||
|
return module.AddInstruction(SpirvOp.ExtInst, resultType, values);
|
||||||
|
}
|
||||||
|
uint Component(uint vector, uint index) =>
|
||||||
|
module.AddInstruction(SpirvOp.CompositeExtract, floatType, vector, index);
|
||||||
|
uint Multiply(uint left, float right) =>
|
||||||
|
module.AddInstruction(SpirvOp.FMul, floatType, left, Float(right));
|
||||||
|
uint Add3(uint first, uint second, uint third) =>
|
||||||
|
module.AddInstruction(
|
||||||
|
SpirvOp.FAdd,
|
||||||
|
floatType,
|
||||||
|
module.AddInstruction(SpirvOp.FAdd, floatType, first, second),
|
||||||
|
third);
|
||||||
|
|
||||||
|
var functionType = module.TypeFunction(voidType);
|
||||||
|
var main = module.BeginFunction(voidType, functionType);
|
||||||
|
module.AddLabel();
|
||||||
|
var attributeValue = module.AddInstruction(SpirvOp.Load, vec4Type, attribute);
|
||||||
|
var coordinates = module.AddInstruction(
|
||||||
|
SpirvOp.VectorShuffle,
|
||||||
|
vec2Type,
|
||||||
|
attributeValue,
|
||||||
|
attributeValue,
|
||||||
|
0,
|
||||||
|
1);
|
||||||
|
var sampledImage = module.AddInstruction(SpirvOp.Load, sampledImageType, texture);
|
||||||
|
var color = module.AddInstruction(
|
||||||
|
SpirvOp.ImageSampleExplicitLod,
|
||||||
|
vec4Type,
|
||||||
|
sampledImage,
|
||||||
|
coordinates,
|
||||||
|
2,
|
||||||
|
Float(0));
|
||||||
|
var pq = module.AddInstruction(
|
||||||
|
SpirvOp.VectorShuffle,
|
||||||
|
vec3Type,
|
||||||
|
color,
|
||||||
|
color,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2);
|
||||||
|
|
||||||
|
// SMPTE ST 2084 converts normalized PQ code values to absolute luminance.
|
||||||
|
var powered = Ext(26, vec3Type, pq, Vec3(inverseM2));
|
||||||
|
var numerator = Ext(
|
||||||
|
40,
|
||||||
|
vec3Type,
|
||||||
|
module.AddInstruction(SpirvOp.FSub, vec3Type, powered, Vec3(c1)),
|
||||||
|
Vec3(0));
|
||||||
|
var denominator = module.AddInstruction(
|
||||||
|
SpirvOp.FSub,
|
||||||
|
vec3Type,
|
||||||
|
Vec3(c2),
|
||||||
|
module.AddInstruction(SpirvOp.FMul, vec3Type, Vec3(c3), powered));
|
||||||
|
var normalizedLuminance = Ext(
|
||||||
|
26,
|
||||||
|
vec3Type,
|
||||||
|
module.AddInstruction(SpirvOp.FDiv, vec3Type, numerator, denominator),
|
||||||
|
Vec3(inverseM1));
|
||||||
|
var scRgb2020 = module.AddInstruction(
|
||||||
|
SpirvOp.FMul,
|
||||||
|
vec3Type,
|
||||||
|
normalizedLuminance,
|
||||||
|
Vec3(10000f / 80f));
|
||||||
|
|
||||||
|
var red2020 = Component(scRgb2020, 0);
|
||||||
|
var green2020 = Component(scRgb2020, 1);
|
||||||
|
var blue2020 = Component(scRgb2020, 2);
|
||||||
|
var red = Add3(
|
||||||
|
Multiply(red2020, 1.660491f),
|
||||||
|
Multiply(green2020, -0.587641f),
|
||||||
|
Multiply(blue2020, -0.072850f));
|
||||||
|
var green = Add3(
|
||||||
|
Multiply(red2020, -0.124550f),
|
||||||
|
Multiply(green2020, 1.132900f),
|
||||||
|
Multiply(blue2020, -0.008349f));
|
||||||
|
var blue = Add3(
|
||||||
|
Multiply(red2020, -0.018151f),
|
||||||
|
Multiply(green2020, -0.100579f),
|
||||||
|
Multiply(blue2020, 1.118730f));
|
||||||
|
var converted = module.AddInstruction(
|
||||||
|
SpirvOp.CompositeConstruct,
|
||||||
|
vec4Type,
|
||||||
|
red,
|
||||||
|
green,
|
||||||
|
blue,
|
||||||
|
Component(color, 3));
|
||||||
|
module.AddStatement(SpirvOp.Store, output, converted);
|
||||||
|
module.AddStatement(SpirvOp.Return);
|
||||||
|
module.EndFunction();
|
||||||
|
module.AddEntryPoint(
|
||||||
|
SpirvExecutionModel.Fragment,
|
||||||
|
main,
|
||||||
|
"main",
|
||||||
|
[attribute, texture, output]);
|
||||||
|
module.AddExecutionMode(main, SpirvExecutionMode.OriginUpperLeft);
|
||||||
|
return module.Build();
|
||||||
|
}
|
||||||
|
|
||||||
public static byte[] CreateSolidFragment(float red, float green, float blue, float alpha)
|
public static byte[] CreateSolidFragment(float red, float green, float blue, float alpha)
|
||||||
{
|
{
|
||||||
var module = new SpirvModuleBuilder();
|
var module = new SpirvModuleBuilder();
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.Libs.VideoOut;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Tests.VideoOut;
|
||||||
|
|
||||||
|
public sealed class HostVideoOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeClampsUnsafeHostValues()
|
||||||
|
{
|
||||||
|
var options = new HostVideoOptions
|
||||||
|
{
|
||||||
|
Width = 1,
|
||||||
|
Height = 99_999,
|
||||||
|
DisplayIndex = -2,
|
||||||
|
RefreshRate = 5000,
|
||||||
|
HdrMode = (HostHdrMode)99,
|
||||||
|
}.Normalize();
|
||||||
|
|
||||||
|
Assert.Equal(640, options.Width);
|
||||||
|
Assert.Equal(16384, options.Height);
|
||||||
|
Assert.Equal(0, options.DisplayIndex);
|
||||||
|
Assert.Equal(1000, options.RefreshRate);
|
||||||
|
Assert.Equal(HostHdrMode.Auto, options.HdrMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,24 @@ public sealed class VideoOutPixelFormatTests
|
|||||||
Assert.Equal(56u, InvokeMapPixelFormat(0xDEADBEEFUL));
|
Assert.Equal(56u, InvokeMapPixelFormat(0xDEADBEEFUL));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x88740000UL)]
|
||||||
|
[InlineData(0x8100070422000000UL)]
|
||||||
|
[InlineData(0x8100070400000000UL)]
|
||||||
|
public void IsHdrPixelFormat_PqFormats_ReturnTrue(ulong pixelFormat)
|
||||||
|
{
|
||||||
|
Assert.True(VideoOutExports.IsHdrPixelFormat(pixelFormat));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0x80000000UL)]
|
||||||
|
[InlineData(0x88060000UL)]
|
||||||
|
[InlineData(0x8100000622000000UL)]
|
||||||
|
public void IsHdrPixelFormat_SdrFormats_ReturnFalse(ulong pixelFormat)
|
||||||
|
{
|
||||||
|
Assert.False(VideoOutExports.IsHdrPixelFormat(pixelFormat));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Self-check activation ----
|
// ---- Self-check activation ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.Libs.VideoOut;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Tests.VideoOut;
|
||||||
|
|
||||||
|
public sealed class VulkanPipelineCacheStorageTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ResolvePathUsesExecutableUserDirectoryAndTitleId()
|
||||||
|
{
|
||||||
|
var path = VulkanPipelineCacheStorage.ResolvePath("PPSA02929", configuredPath: null);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
Path.GetFullPath(Path.Combine(
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
"user",
|
||||||
|
"pipeline_cache",
|
||||||
|
"PPSA02929",
|
||||||
|
"vulkan-pipeline-cache.bin")),
|
||||||
|
path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvePathSanitizesTitleId()
|
||||||
|
{
|
||||||
|
var path = VulkanPipelineCacheStorage.ResolvePath("ppsa/02:929", configuredPath: null);
|
||||||
|
|
||||||
|
Assert.EndsWith(
|
||||||
|
Path.Combine("PPSA_02_929", "vulkan-pipeline-cache.bin"),
|
||||||
|
path,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvePathPreservesConfiguredOverride()
|
||||||
|
{
|
||||||
|
var configured = Path.Combine(Path.GetTempPath(), "SharpEmuTests", "custom-cache.bin");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
Path.GetFullPath(configured),
|
||||||
|
VulkanPipelineCacheStorage.ResolvePath("PPSA02929", configured));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImportLegacyCacheDoesNotOverwriteExistingTitleCache()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N"));
|
||||||
|
var legacyPath = Path.Combine(root, "legacy.bin");
|
||||||
|
var destinationPath = Path.Combine(root, "user", "pipeline_cache", "PPSA02929", "cache.bin");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
File.WriteAllBytes(legacyPath, [1, 2, 3]);
|
||||||
|
|
||||||
|
Assert.True(VulkanPipelineCacheStorage.ImportLegacyCache(legacyPath, destinationPath));
|
||||||
|
Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(destinationPath));
|
||||||
|
Assert.False(File.Exists(legacyPath));
|
||||||
|
|
||||||
|
File.WriteAllBytes(legacyPath, [4, 5, 6]);
|
||||||
|
Assert.False(VulkanPipelineCacheStorage.ImportLegacyCache(legacyPath, destinationPath));
|
||||||
|
Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(destinationPath));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(root))
|
||||||
|
{
|
||||||
|
Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user