mirror of
https://github.com/par274/sharpemu.git
synced 2026-09-01 06:13:38 +08:00
[gui] added host display options and per-game render settings
This commit is contained in:
@@ -1,620 +0,0 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Platform;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using SharpEmu.Libs.VideoOut;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace SharpEmu.GUI;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Native child surface owned by Avalonia. The isolated emulator process uses
|
|
||||||
/// its platform handle to create the Vulkan presentation surface, keeping the
|
|
||||||
/// guest address space out of the GUI process.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GameSurfaceHost : NativeControlHost
|
|
||||||
{
|
|
||||||
private const uint SwpNoSize = 0x0001;
|
|
||||||
private const uint SwpNoMove = 0x0002;
|
|
||||||
private const uint SwpNoZOrder = 0x0004;
|
|
||||||
private const uint SwpNoActivate = 0x0010;
|
|
||||||
private const uint SwpShowWindow = 0x0040;
|
|
||||||
private const uint SwpHideWindow = 0x0080;
|
|
||||||
private const uint WsChild = 0x40000000;
|
|
||||||
private const uint WsVisible = 0x10000000;
|
|
||||||
private const uint WsClipSiblings = 0x04000000;
|
|
||||||
private const uint WsClipChildren = 0x02000000;
|
|
||||||
private const uint CsOwnDc = 0x0020;
|
|
||||||
private const uint WmSetCursor = 0x0020;
|
|
||||||
private const uint WmMouseMove = 0x0200;
|
|
||||||
private const int IdcArrow = 32512;
|
|
||||||
private const int CursorHideDelayMs = 2500;
|
|
||||||
|
|
||||||
private VulkanHostSurface? _surface;
|
|
||||||
private nint _windowHandle;
|
|
||||||
private nint _x11Display;
|
|
||||||
private string? _win32ClassName;
|
|
||||||
private WindowProcedure? _windowProcedure;
|
|
||||||
private nint _metalLayer;
|
|
||||||
private bool _presentationVisible = true;
|
|
||||||
private DispatcherTimer? _cursorIdleTimer;
|
|
||||||
private bool _cursorAutoHide;
|
|
||||||
private bool _cursorHidden;
|
|
||||||
private long _lastPointerActivity;
|
|
||||||
|
|
||||||
public GameSurfaceHost()
|
|
||||||
{
|
|
||||||
PropertyChanged += (_, change) =>
|
|
||||||
{
|
|
||||||
if (change.Property == BoundsProperty)
|
|
||||||
{
|
|
||||||
UpdateSurfaceSize();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
LayoutUpdated += (_, _) =>
|
|
||||||
{
|
|
||||||
// Fullscreen can change a monitor's DPI scale without changing
|
|
||||||
// the logical Bounds. Refresh the native child from physical size.
|
|
||||||
UpdateSurfaceSize();
|
|
||||||
|
|
||||||
// NativeControlHost may make its HWND visible again as part of a
|
|
||||||
// later arrange pass. Keep a loading surface hidden until its
|
|
||||||
// child process reports a real first frame.
|
|
||||||
if (!_presentationVisible)
|
|
||||||
{
|
|
||||||
ApplyPresentationVisibility();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public event EventHandler<VulkanHostSurface>? SurfaceAvailable;
|
|
||||||
|
|
||||||
public event EventHandler<VulkanHostSurface>? SurfaceDestroyed;
|
|
||||||
|
|
||||||
public VulkanHostSurface? Surface => _surface;
|
|
||||||
|
|
||||||
public void RefreshSurfaceSize() => UpdateSurfaceSize();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hides the platform child without detaching the Vulkan surface. This
|
|
||||||
/// allows the launcher to return to its library while guest teardown is
|
|
||||||
/// still finishing on the render thread.
|
|
||||||
/// </summary>
|
|
||||||
public void SetPresentationVisible(bool visible)
|
|
||||||
{
|
|
||||||
_presentationVisible = visible;
|
|
||||||
ApplyPresentationVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Auto-hides the mouse cursor over the game surface after a short idle
|
|
||||||
/// period; any pointer movement brings it back. Enabling (again) restarts
|
|
||||||
/// the idle countdown, so both "first frame presented" and "entered
|
|
||||||
/// fullscreen" can arm it. Windows-only; a no-op elsewhere.
|
|
||||||
/// </summary>
|
|
||||||
public void SetCursorAutoHide(bool enabled)
|
|
||||||
{
|
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cursorAutoHide = enabled;
|
|
||||||
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
||||||
if (enabled)
|
|
||||||
{
|
|
||||||
_cursorIdleTimer ??= CreateCursorIdleTimer();
|
|
||||||
_cursorIdleTimer.Start();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cursorIdleTimer?.Stop();
|
|
||||||
ShowCursorNow();
|
|
||||||
}
|
|
||||||
|
|
||||||
private DispatcherTimer CreateCursorIdleTimer()
|
|
||||||
{
|
|
||||||
var timer = new DispatcherTimer
|
|
||||||
{
|
|
||||||
Interval = TimeSpan.FromMilliseconds(250),
|
|
||||||
};
|
|
||||||
timer.Tick += (_, _) => HideCursorWhenIdle();
|
|
||||||
return timer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HideCursorWhenIdle()
|
|
||||||
{
|
|
||||||
if (!_cursorAutoHide || _cursorHidden || _windowHandle == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var idleMs = (System.Diagnostics.Stopwatch.GetTimestamp() - _lastPointerActivity) *
|
|
||||||
1000 / System.Diagnostics.Stopwatch.Frequency;
|
|
||||||
if (idleMs < CursorHideDelayMs)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only swallow the cursor while it is actually over the game surface;
|
|
||||||
// hovering launcher chrome (console, toolbar) must keep the arrow.
|
|
||||||
if (!GetCursorPos(out var point) || WindowFromPoint(point) != _windowHandle)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cursorHidden = true;
|
|
||||||
_ = SetCursor(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowCursorNow()
|
|
||||||
{
|
|
||||||
if (!_cursorHidden)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cursorHidden = false;
|
|
||||||
_ = SetCursor(LoadCursorW(0, IdcArrow));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyPresentationVisibility()
|
|
||||||
{
|
|
||||||
if (_windowHandle == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var visible = _presentationVisible;
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
// SW_HIDE can be ignored for a window's initial show state. Force
|
|
||||||
// the state through SetWindowPos so an old child swapchain cannot
|
|
||||||
// remain composed while the next game is loading.
|
|
||||||
var flags = SwpNoSize | SwpNoMove | SwpNoZOrder | SwpNoActivate |
|
|
||||||
(visible ? SwpShowWindow : SwpHideWindow);
|
|
||||||
_ = SetWindowPos(_windowHandle, 0, 0, 0, 0, 0, flags);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsLinux() && _x11Display != 0)
|
|
||||||
{
|
|
||||||
_ = visible
|
|
||||||
? XMapWindow(_x11Display, _windowHandle)
|
|
||||||
: XUnmapWindow(_x11Display, _windowHandle);
|
|
||||||
_ = XFlush(_x11Display);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsMacOS())
|
|
||||||
{
|
|
||||||
SendBool(_windowHandle, "setHidden:", !visible);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle control)
|
|
||||||
{
|
|
||||||
PlatformHandle handle;
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
handle = CreateWin32(control);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsLinux())
|
|
||||||
{
|
|
||||||
handle = CreateX11(control);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsMacOS())
|
|
||||||
{
|
|
||||||
handle = CreateMacOS();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new PlatformNotSupportedException("SharpEmu's embedded Vulkan surface is unsupported on this platform.");
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateSurfaceSize();
|
|
||||||
if (_surface is { } surface)
|
|
||||||
{
|
|
||||||
SurfaceAvailable?.Invoke(this, surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void DestroyNativeControlCore(IPlatformHandle control)
|
|
||||||
{
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
SetCursorAutoHide(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
var surface = _surface;
|
|
||||||
_surface = null;
|
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
DestroyWin32();
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsLinux())
|
|
||||||
{
|
|
||||||
DestroyX11();
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsMacOS())
|
|
||||||
{
|
|
||||||
DestroyMacOS();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (surface is not null)
|
|
||||||
{
|
|
||||||
SurfaceDestroyed?.Invoke(this, surface);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private PlatformHandle CreateWin32(IPlatformHandle control)
|
|
||||||
{
|
|
||||||
_win32ClassName = $"SharpEmuGameSurface-{Guid.NewGuid():N}";
|
|
||||||
_windowProcedure = WindowProcedureImpl;
|
|
||||||
var classInfo = new WndClassEx
|
|
||||||
{
|
|
||||||
Size = (uint)Marshal.SizeOf<WndClassEx>(),
|
|
||||||
Style = CsOwnDc,
|
|
||||||
WindowProcedure = Marshal.GetFunctionPointerForDelegate(_windowProcedure),
|
|
||||||
Instance = GetModuleHandleW(null),
|
|
||||||
ClassName = _win32ClassName,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (RegisterClassExW(ref classInfo) == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Could not register the embedded game window class (Win32 error {Marshal.GetLastWin32Error()}).");
|
|
||||||
}
|
|
||||||
|
|
||||||
_windowHandle = CreateWindowExW(
|
|
||||||
0,
|
|
||||||
_win32ClassName,
|
|
||||||
"SharpEmu Game Surface",
|
|
||||||
WsChild | (_presentationVisible ? WsVisible : 0) | WsClipSiblings | WsClipChildren,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
control.Handle,
|
|
||||||
0,
|
|
||||||
classInfo.Instance,
|
|
||||||
0);
|
|
||||||
if (_windowHandle == 0)
|
|
||||||
{
|
|
||||||
var error = Marshal.GetLastWin32Error();
|
|
||||||
_ = UnregisterClassW(_win32ClassName, classInfo.Instance);
|
|
||||||
throw new InvalidOperationException($"Could not create the embedded game window (Win32 error {error}).");
|
|
||||||
}
|
|
||||||
|
|
||||||
_surface = new VulkanHostSurface(
|
|
||||||
VulkanHostSurfaceKind.Win32,
|
|
||||||
_windowHandle,
|
|
||||||
classInfo.Instance);
|
|
||||||
return new PlatformHandle(_windowHandle, "HWND");
|
|
||||||
}
|
|
||||||
|
|
||||||
private PlatformHandle CreateX11(IPlatformHandle control)
|
|
||||||
{
|
|
||||||
_x11Display = XOpenDisplay(0);
|
|
||||||
if (_x11Display == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Could not connect to the X11 server for the embedded game surface.");
|
|
||||||
}
|
|
||||||
|
|
||||||
_windowHandle = XCreateSimpleWindow(
|
|
||||||
_x11Display,
|
|
||||||
control.Handle,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0);
|
|
||||||
if (_windowHandle == 0)
|
|
||||||
{
|
|
||||||
XCloseDisplay(_x11Display);
|
|
||||||
_x11Display = 0;
|
|
||||||
throw new InvalidOperationException("Could not create the X11 embedded game surface.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_presentationVisible)
|
|
||||||
{
|
|
||||||
_ = XMapWindow(_x11Display, _windowHandle);
|
|
||||||
}
|
|
||||||
_ = XFlush(_x11Display);
|
|
||||||
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Xlib, _windowHandle, _x11Display);
|
|
||||||
return new PlatformHandle(_windowHandle, "X11");
|
|
||||||
}
|
|
||||||
|
|
||||||
private PlatformHandle CreateMacOS()
|
|
||||||
{
|
|
||||||
_metalLayer = CreateObjectiveCObject("CAMetalLayer");
|
|
||||||
_windowHandle = CreateObjectiveCObject("NSView");
|
|
||||||
SendBool(_windowHandle, "setWantsLayer:", true);
|
|
||||||
SendPointer(_windowHandle, "setLayer:", _metalLayer);
|
|
||||||
SendBool(_windowHandle, "setHidden:", !_presentationVisible);
|
|
||||||
|
|
||||||
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Metal, _windowHandle, metalLayerHandle: _metalLayer);
|
|
||||||
return new PlatformHandle(_windowHandle, "NSView");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateSurfaceSize()
|
|
||||||
{
|
|
||||||
if (_surface is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var renderScale = (VisualRoot as TopLevel)?.RenderScaling ?? 1.0;
|
|
||||||
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
|
|
||||||
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
|
|
||||||
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
|
|
||||||
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
|
|
||||||
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
|
|
||||||
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
|
|
||||||
}
|
|
||||||
_surface.UpdatePixelSize(width, height);
|
|
||||||
|
|
||||||
if (!sizeChanged)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows() && _windowHandle != 0)
|
|
||||||
{
|
|
||||||
_ = SetWindowPos(
|
|
||||||
_windowHandle,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
SwpNoMove | SwpNoZOrder | SwpNoActivate);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsLinux() && _x11Display != 0 && _windowHandle != 0)
|
|
||||||
{
|
|
||||||
_ = XResizeWindow(_x11Display, _windowHandle, (uint)width, (uint)height);
|
|
||||||
_ = XFlush(_x11Display);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsMacOS() && _metalLayer != 0)
|
|
||||||
{
|
|
||||||
SendDouble(_metalLayer, "setContentsScale:", renderScale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DestroyWin32()
|
|
||||||
{
|
|
||||||
if (_windowHandle != 0)
|
|
||||||
{
|
|
||||||
_ = DestroyWindow(_windowHandle);
|
|
||||||
_windowHandle = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_win32ClassName))
|
|
||||||
{
|
|
||||||
_ = UnregisterClassW(_win32ClassName, GetModuleHandleW(null));
|
|
||||||
_win32ClassName = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_windowProcedure = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DestroyX11()
|
|
||||||
{
|
|
||||||
if (_x11Display != 0 && _windowHandle != 0)
|
|
||||||
{
|
|
||||||
_ = XDestroyWindow(_x11Display, _windowHandle);
|
|
||||||
}
|
|
||||||
if (_x11Display != 0)
|
|
||||||
{
|
|
||||||
_ = XCloseDisplay(_x11Display);
|
|
||||||
}
|
|
||||||
|
|
||||||
_windowHandle = 0;
|
|
||||||
_x11Display = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DestroyMacOS()
|
|
||||||
{
|
|
||||||
if (_windowHandle != 0)
|
|
||||||
{
|
|
||||||
SendVoid(_windowHandle, "release");
|
|
||||||
}
|
|
||||||
if (_metalLayer != 0)
|
|
||||||
{
|
|
||||||
SendVoid(_metalLayer, "release");
|
|
||||||
}
|
|
||||||
|
|
||||||
_windowHandle = 0;
|
|
||||||
_metalLayer = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private nint WindowProcedureImpl(nint window, uint message, nint wParam, nint lParam)
|
|
||||||
{
|
|
||||||
if (message == WmMouseMove)
|
|
||||||
{
|
|
||||||
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
||||||
ShowCursorNow();
|
|
||||||
}
|
|
||||||
else if (message == WmSetCursor && _cursorHidden)
|
|
||||||
{
|
|
||||||
// Win32 re-resolves the cursor on every mouse message; returning
|
|
||||||
// TRUE here keeps the parent chain from restoring the arrow.
|
|
||||||
_ = SetCursor(0);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DefWindowProcW(window, message, wParam, lParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static nint CreateObjectiveCObject(string className)
|
|
||||||
{
|
|
||||||
var classHandle = objc_getClass(className);
|
|
||||||
if (classHandle == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Objective-C class '{className}' is unavailable.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var instance = objc_msgSend_id(classHandle, sel_registerName("alloc"));
|
|
||||||
instance = objc_msgSend_id(instance, sel_registerName("init"));
|
|
||||||
if (instance == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Could not create Objective-C '{className}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SendVoid(nint receiver, string selector) =>
|
|
||||||
objc_msgSend_void(receiver, sel_registerName(selector));
|
|
||||||
|
|
||||||
private static void SendBool(nint receiver, string selector, bool value) =>
|
|
||||||
objc_msgSend_bool(receiver, sel_registerName(selector), value ? (byte)1 : (byte)0);
|
|
||||||
|
|
||||||
private static void SendPointer(nint receiver, string selector, nint value) =>
|
|
||||||
objc_msgSend_pointer(receiver, sel_registerName(selector), value);
|
|
||||||
|
|
||||||
private static void SendDouble(nint receiver, string selector, double value) =>
|
|
||||||
objc_msgSend_double(receiver, sel_registerName(selector), value);
|
|
||||||
|
|
||||||
private delegate nint WindowProcedure(nint window, uint message, nint wParam, nint lParam);
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
||||||
private struct WndClassEx
|
|
||||||
{
|
|
||||||
public uint Size;
|
|
||||||
public uint Style;
|
|
||||||
public nint WindowProcedure;
|
|
||||||
public int ClassExtra;
|
|
||||||
public int WindowExtra;
|
|
||||||
public nint Instance;
|
|
||||||
public nint Icon;
|
|
||||||
public nint Cursor;
|
|
||||||
public nint Background;
|
|
||||||
public string? MenuName;
|
|
||||||
public string? ClassName;
|
|
||||||
public nint IconSmall;
|
|
||||||
}
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern nint GetModuleHandleW(string? moduleName);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "RegisterClassExW", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
||||||
private static extern ushort RegisterClassExW(ref WndClassEx classInfo);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "UnregisterClassW", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool UnregisterClassW(string className, nint instance);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "CreateWindowExW", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
||||||
private static extern nint CreateWindowExW(
|
|
||||||
uint extendedStyle,
|
|
||||||
string className,
|
|
||||||
string windowName,
|
|
||||||
uint style,
|
|
||||||
int x,
|
|
||||||
int y,
|
|
||||||
int width,
|
|
||||||
int height,
|
|
||||||
nint parent,
|
|
||||||
nint menu,
|
|
||||||
nint instance,
|
|
||||||
nint parameter);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "DestroyWindow", SetLastError = true)]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool DestroyWindow(nint window);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "SetWindowPos", SetLastError = true)]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool SetWindowPos(
|
|
||||||
nint window,
|
|
||||||
nint insertAfter,
|
|
||||||
int x,
|
|
||||||
int y,
|
|
||||||
int width,
|
|
||||||
int height,
|
|
||||||
uint flags);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "DefWindowProcW", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern nint DefWindowProcW(nint window, uint message, nint wParam, nint lParam);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "SetCursor")]
|
|
||||||
private static extern nint SetCursor(nint cursor);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "LoadCursorW", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern nint LoadCursorW(nint instance, nint cursorName);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "GetCursorPos")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool GetCursorPos(out NativePoint point);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "WindowFromPoint")]
|
|
||||||
private static extern nint WindowFromPoint(NativePoint point);
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
private struct NativePoint
|
|
||||||
{
|
|
||||||
public int X;
|
|
||||||
public int Y;
|
|
||||||
}
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
|
|
||||||
private static extern nint XOpenDisplay(nint displayName);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XCreateSimpleWindow")]
|
|
||||||
private static extern nint XCreateSimpleWindow(
|
|
||||||
nint display,
|
|
||||||
nint parent,
|
|
||||||
int x,
|
|
||||||
int y,
|
|
||||||
uint width,
|
|
||||||
uint height,
|
|
||||||
uint borderWidth,
|
|
||||||
ulong border,
|
|
||||||
ulong background);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XMapWindow")]
|
|
||||||
private static extern int XMapWindow(nint display, nint window);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XUnmapWindow")]
|
|
||||||
private static extern int XUnmapWindow(nint display, nint window);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XResizeWindow")]
|
|
||||||
private static extern int XResizeWindow(nint display, nint window, uint width, uint height);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XDestroyWindow")]
|
|
||||||
private static extern int XDestroyWindow(nint display, nint window);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
|
|
||||||
private static extern int XCloseDisplay(nint display);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XFlush")]
|
|
||||||
private static extern int XFlush(nint display);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib")]
|
|
||||||
private static extern nint objc_getClass(string name);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib")]
|
|
||||||
private static extern nint sel_registerName(string name);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
|
||||||
private static extern nint objc_msgSend_id(nint receiver, nint selector);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
|
||||||
private static extern void objc_msgSend_void(nint receiver, nint selector);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
|
||||||
private static extern void objc_msgSend_bool(nint receiver, nint selector, byte value);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
|
||||||
private static extern void objc_msgSend_pointer(nint receiver, nint selector, nint value);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
|
||||||
private static extern void objc_msgSend_double(nint receiver, nint selector, double value);
|
|
||||||
}
|
|
||||||
@@ -50,6 +50,20 @@ public sealed class GuiSettings
|
|||||||
|
|
||||||
public bool CheckForUpdatesOnStartup { get; set; } = true;
|
public bool CheckForUpdatesOnStartup { get; set; } = true;
|
||||||
|
|
||||||
|
public string WindowMode { get; set; } = "Windowed";
|
||||||
|
|
||||||
|
public string Resolution { get; set; } = "1920x1080";
|
||||||
|
|
||||||
|
public int DisplayIndex { get; set; }
|
||||||
|
|
||||||
|
public int RefreshRate { get; set; }
|
||||||
|
|
||||||
|
public string ScalingMode { get; set; } = "Fit";
|
||||||
|
|
||||||
|
public bool VSync { get; set; } = true;
|
||||||
|
|
||||||
|
public string HdrMode { get; set; } = "Auto";
|
||||||
|
|
||||||
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
|
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
|
||||||
public List<string> EnvironmentToggles { get; set; } = new();
|
public List<string> EnvironmentToggles { get; set; } = new();
|
||||||
|
|
||||||
@@ -103,6 +117,12 @@ public sealed class GuiSettings
|
|||||||
{
|
{
|
||||||
settings.RenderResolutionScale = 1.0;
|
settings.RenderResolutionScale = 1.0;
|
||||||
}
|
}
|
||||||
|
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
||||||
|
settings.Resolution = NormalizeResolution(settings.Resolution);
|
||||||
|
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
||||||
|
settings.HdrMode = NormalizeChoice(settings.HdrMode, "Auto", "On", "Off");
|
||||||
|
settings.DisplayIndex = Math.Max(0, settings.DisplayIndex);
|
||||||
|
settings.RefreshRate = Math.Clamp(settings.RefreshRate, 0, 1000);
|
||||||
|
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
@@ -118,6 +138,20 @@ public sealed class GuiSettings
|
|||||||
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeChoice(string? value, string fallback, params string[] choices) =>
|
||||||
|
choices.Prepend(fallback).FirstOrDefault(
|
||||||
|
choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
|
||||||
|
|
||||||
|
private static string NormalizeResolution(string? value)
|
||||||
|
{
|
||||||
|
if (!HostDisplayOptions.TryParseResolution(value, out var width, out var height))
|
||||||
|
{
|
||||||
|
return "1920x1080";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{width}x{height}";
|
||||||
|
}
|
||||||
|
|
||||||
public void Save()
|
public void Save()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.Libs.VideoOut;
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
internal sealed record HostDisplayOption(HostDisplayInfo Display)
|
||||||
|
{
|
||||||
|
public int Index => Display.Index;
|
||||||
|
|
||||||
|
public IReadOnlyList<HostDisplayMode> Modes => Display.Modes;
|
||||||
|
|
||||||
|
public override string ToString() => $"{Index + 1}: {Display.Name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record HostRefreshRateOption(int Value, string Label)
|
||||||
|
{
|
||||||
|
public override string ToString() => Label;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class HostDisplayOptions
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<HostDisplayOption> BuildDisplays(
|
||||||
|
IReadOnlyList<HostDisplayInfo> detected,
|
||||||
|
int selectedIndex)
|
||||||
|
{
|
||||||
|
selectedIndex = Math.Max(0, selectedIndex);
|
||||||
|
var options = detected
|
||||||
|
.Select(display => new HostDisplayOption(display))
|
||||||
|
.ToList();
|
||||||
|
if (options.Count == 0)
|
||||||
|
{
|
||||||
|
options.Add(new HostDisplayOption(new HostDisplayInfo(
|
||||||
|
0,
|
||||||
|
"Display 1",
|
||||||
|
CreateFallbackModes())));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.All(display => display.Index != selectedIndex))
|
||||||
|
{
|
||||||
|
options.Add(new HostDisplayOption(new HostDisplayInfo(
|
||||||
|
selectedIndex,
|
||||||
|
$"Display {selectedIndex + 1}",
|
||||||
|
options[0].Modes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.OrderBy(display => display.Index).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HostDisplayOption SelectDisplay(
|
||||||
|
IReadOnlyList<HostDisplayOption> displays,
|
||||||
|
int selectedIndex) =>
|
||||||
|
displays.FirstOrDefault(display => display.Index == selectedIndex) ?? displays[0];
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> BuildResolutions(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string? selectedResolution)
|
||||||
|
{
|
||||||
|
var resolutions = display.Modes
|
||||||
|
.Where(mode => mode.Width > 0 && mode.Height > 0)
|
||||||
|
.Select(mode => $"{mode.Width}x{mode.Height}")
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
if (TryParseResolution(selectedResolution, out var selectedWidth, out var selectedHeight))
|
||||||
|
{
|
||||||
|
var selected = $"{selectedWidth}x{selectedHeight}";
|
||||||
|
if (!resolutions.Contains(selected, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
resolutions.Add(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolutions.Count == 0)
|
||||||
|
{
|
||||||
|
resolutions.Add("1920x1080");
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolutions
|
||||||
|
.OrderByDescending(resolution => ResolutionArea(resolution))
|
||||||
|
.ThenByDescending(resolution => resolution, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<HostRefreshRateOption> BuildRefreshRates(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string? resolution,
|
||||||
|
int selectedRefreshRate,
|
||||||
|
string automaticLabel)
|
||||||
|
{
|
||||||
|
TryParseResolution(resolution, out var width, out var height);
|
||||||
|
var rates = display.Modes
|
||||||
|
.Where(mode => mode.Width == width && mode.Height == height && mode.RefreshRate > 0)
|
||||||
|
.Select(mode => mode.RefreshRate)
|
||||||
|
.Distinct()
|
||||||
|
.OrderByDescending(rate => rate)
|
||||||
|
.ToList();
|
||||||
|
if (selectedRefreshRate > 0 && !rates.Contains(selectedRefreshRate))
|
||||||
|
{
|
||||||
|
rates.Add(selectedRefreshRate);
|
||||||
|
rates.Sort((left, right) => right.CompareTo(left));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new[] { new HostRefreshRateOption(0, automaticLabel) }
|
||||||
|
.Concat(rates.Select(rate => new HostRefreshRateOption(rate, $"{rate} Hz")))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParseResolution(string? value, out int width, out int height)
|
||||||
|
{
|
||||||
|
width = 0;
|
||||||
|
height = 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var separator = value.IndexOf('x', StringComparison.OrdinalIgnoreCase);
|
||||||
|
return separator > 0 &&
|
||||||
|
int.TryParse(value.AsSpan(0, separator), out width) &&
|
||||||
|
int.TryParse(value.AsSpan(separator + 1), out height) &&
|
||||||
|
width > 0 &&
|
||||||
|
height > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ResolutionArea(string resolution) =>
|
||||||
|
TryParseResolution(resolution, out var width, out var height)
|
||||||
|
? (long)width * height
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -41,6 +41,24 @@
|
|||||||
"Options.Section.Emulation": "EMULATION",
|
"Options.Section.Emulation": "EMULATION",
|
||||||
"Options.Section.Logging": "LOGGING",
|
"Options.Section.Logging": "LOGGING",
|
||||||
"Options.Section.Launcher": "LAUNCHER",
|
"Options.Section.Launcher": "LAUNCHER",
|
||||||
|
"Options.Section.Display": "DISPLAY",
|
||||||
|
"Options.Graphics": "Graphics",
|
||||||
|
|
||||||
|
"Options.WindowMode.Label": "Window mode",
|
||||||
|
"Options.WindowMode.Desc": "Regular window, desktop borderless, or exclusive fullscreen.",
|
||||||
|
"Options.Resolution.Label": "Resolution",
|
||||||
|
"Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.",
|
||||||
|
"Options.Display.Label": "Display",
|
||||||
|
"Options.Display.Desc": "Monitor used for centering and fullscreen.",
|
||||||
|
"Options.RefreshRate.Label": "Refresh rate",
|
||||||
|
"Options.RefreshRate.Desc": "Exclusive fullscreen refresh rate. Automatic selects the closest mode.",
|
||||||
|
"Options.RefreshRate.Automatic": "Automatic",
|
||||||
|
"Options.Scaling.Label": "Scaling",
|
||||||
|
"Options.Scaling.Desc": "Scale the native guest image without changing its internal resolution.",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Use FIFO presentation for tear-free output.",
|
||||||
|
"Options.Hdr.Label": "HDR output",
|
||||||
|
"Options.Hdr.Desc": "Use HDR when the selected display and graphics backend support it. Auto falls back to SDR.",
|
||||||
|
|
||||||
"Options.CpuEngine.Label": "CPU engine",
|
"Options.CpuEngine.Label": "CPU engine",
|
||||||
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
|
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
|
||||||
@@ -87,6 +105,8 @@
|
|||||||
|
|
||||||
"PerGame.Title": "Per-game settings — {0} ({1})",
|
"PerGame.Title": "Per-game settings — {0} ({1})",
|
||||||
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
|
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
|
||||||
|
"PerGame.Tab.General": "General",
|
||||||
|
"PerGame.Tab.Graphics": "Graphics",
|
||||||
"PerGame.EnvToggles.Label": "Environment toggles",
|
"PerGame.EnvToggles.Label": "Environment toggles",
|
||||||
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
|
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,24 @@
|
|||||||
"Options.Section.Emulation": "EMÜLASYON",
|
"Options.Section.Emulation": "EMÜLASYON",
|
||||||
"Options.Section.Logging": "GÜNLÜKLEME",
|
"Options.Section.Logging": "GÜNLÜKLEME",
|
||||||
"Options.Section.Launcher": "BAŞLATICI",
|
"Options.Section.Launcher": "BAŞLATICI",
|
||||||
|
"Options.Section.Display": "GÖRÜNTÜ",
|
||||||
|
"Options.Graphics": "Grafik",
|
||||||
|
|
||||||
|
"Options.WindowMode.Label": "Pencere modu",
|
||||||
|
"Options.WindowMode.Desc": "Normal pencere, kenarlıksız masaüstü veya özel tam ekran.",
|
||||||
|
"Options.Resolution.Label": "Çözünürlük",
|
||||||
|
"Options.Resolution.Desc": "Başlangıç pencere boyutu veya özel tam ekran çözünürlüğü.",
|
||||||
|
"Options.Display.Label": "Ekran",
|
||||||
|
"Options.Display.Desc": "Ortalama ve tam ekran için kullanılan monitör.",
|
||||||
|
"Options.RefreshRate.Label": "Yenileme hızı",
|
||||||
|
"Options.RefreshRate.Desc": "Özel tam ekran yenileme hızı. Otomatik, en yakın modu seçer.",
|
||||||
|
"Options.RefreshRate.Automatic": "Otomatik",
|
||||||
|
"Options.Scaling.Label": "Ölçekleme",
|
||||||
|
"Options.Scaling.Desc": "Dahili çözünürlüğü değiştirmeden oyun görüntüsünü ölçekle.",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Yırtılmasız görüntü için FIFO sunumunu kullan.",
|
||||||
|
"Options.Hdr.Label": "HDR çıkışı",
|
||||||
|
"Options.Hdr.Desc": "Seçili ekran ve grafik backend'i destekliyorsa HDR kullan. Otomatik mod SDR'ye geri döner.",
|
||||||
|
|
||||||
"Options.CpuEngine.Label": "CPU motoru",
|
"Options.CpuEngine.Label": "CPU motoru",
|
||||||
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
|
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
|
||||||
@@ -159,6 +177,8 @@
|
|||||||
"Common.Cancel": "İptal",
|
"Common.Cancel": "İptal",
|
||||||
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
|
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
|
||||||
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
|
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
|
||||||
|
"PerGame.Tab.General": "Genel",
|
||||||
|
"PerGame.Tab.Graphics": "Grafik",
|
||||||
"PerGame.EnvToggles.Label": "Ortam anahtarları",
|
"PerGame.EnvToggles.Label": "Ortam anahtarları",
|
||||||
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
|
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
|
||||||
"Options.About": "Hakkında",
|
"Options.About": "Hakkında",
|
||||||
|
|||||||
@@ -60,12 +60,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
|
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
<!-- The game owns the full client area while running. Session controls
|
|
||||||
use a native popup so they can stay above this native child surface. -->
|
|
||||||
<Border x:Name="GameView" Grid.Row="0" Grid.RowSpan="4" IsVisible="False" Background="#000000" ClipToBounds="True">
|
|
||||||
<Grid x:Name="GameSurfaceContainer" />
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Library / Options page switcher, with the library toolbar sharing
|
<!-- Library / Options page switcher, with the library toolbar sharing
|
||||||
the same row on the right. Plain buttons (not TabItem) so there is
|
the same row on the right. Plain buttons (not TabItem) so there is
|
||||||
no underline; LB/RB hint chips flank the pair and the gamepad's
|
no underline; LB/RB hint chips flank the pair and the gamepad's
|
||||||
@@ -408,7 +402,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
|
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||||
|
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
|
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
|
||||||
@@ -425,6 +418,46 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="card">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
|
<TextBlock x:Name="DisplaySectionTitle" Classes="sectionTitle" Text="DISPLAY" />
|
||||||
|
<local:SettingRow x:Name="WindowModeRow" Label="Window mode" Description="Regular window, desktop borderless, or exclusive fullscreen.">
|
||||||
|
<ComboBox x:Name="WindowModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
|
||||||
|
<ComboBoxItem Content="Windowed" />
|
||||||
|
<ComboBoxItem Content="Borderless" />
|
||||||
|
<ComboBoxItem Content="Exclusive" />
|
||||||
|
</ComboBox>
|
||||||
|
</local:SettingRow>
|
||||||
|
<local:SettingRow x:Name="ResolutionRow" Label="Resolution" Description="Initial window size or exclusive fullscreen resolution.">
|
||||||
|
<ComboBox x:Name="ResolutionBox" Width="180" CornerRadius="8" />
|
||||||
|
</local:SettingRow>
|
||||||
|
<local:SettingRow x:Name="DisplayRow" Label="Display" Description="Monitor used for centering and fullscreen.">
|
||||||
|
<ComboBox x:Name="DisplayBox" Width="260" CornerRadius="8" />
|
||||||
|
</local:SettingRow>
|
||||||
|
<local:SettingRow x:Name="RefreshRateRow" Label="Refresh rate" Description="Exclusive fullscreen refresh rate. Automatic selects the closest mode.">
|
||||||
|
<ComboBox x:Name="RefreshRateBox" Width="180" CornerRadius="8" />
|
||||||
|
</local:SettingRow>
|
||||||
|
<local:SettingRow x:Name="ScalingRow" Label="Scaling" Description="Scale the native guest image without changing its internal resolution.">
|
||||||
|
<ComboBox x:Name="ScalingModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
|
||||||
|
<ComboBoxItem Content="Fit" />
|
||||||
|
<ComboBoxItem Content="Cover" />
|
||||||
|
<ComboBoxItem Content="Stretch" />
|
||||||
|
<ComboBoxItem Content="Integer" />
|
||||||
|
</ComboBox>
|
||||||
|
</local:SettingRow>
|
||||||
|
<local:SettingRow x:Name="VSyncRow" Label="VSync" Description="Use FIFO presentation for tear-free output.">
|
||||||
|
<ToggleSwitch x:Name="VSyncToggle" IsChecked="True" OnContent="On" OffContent="Off" />
|
||||||
|
</local:SettingRow>
|
||||||
|
<local:SettingRow x:Name="HdrRow" Label="HDR" Description="Use HDR output when the selected display and graphics backend support it.">
|
||||||
|
<ComboBox x:Name="HdrModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
|
||||||
|
<ComboBoxItem Content="Auto" />
|
||||||
|
<ComboBoxItem Content="On" />
|
||||||
|
<ComboBoxItem Content="Off" />
|
||||||
|
</ComboBox>
|
||||||
|
</local:SettingRow>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
@@ -595,51 +628,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Avalonia's regular overlay layer cannot appear over a native child
|
<!-- Keep launch progress above the blurred library while the SDL game
|
||||||
HWND/X11/Metal surface. Keep the running-session controls in a native
|
process owns its independent top-level window. -->
|
||||||
popup so the game reaches the bottom status bar without losing Stop. -->
|
|
||||||
<primitives:Popup x:Name="SessionBarPopup"
|
|
||||||
IsOpen="False"
|
|
||||||
PlacementTarget="{Binding #GameView}"
|
|
||||||
Placement="Bottom"
|
|
||||||
VerticalOffset="-66"
|
|
||||||
Topmost="True"
|
|
||||||
ShouldUseOverlayLayer="False"
|
|
||||||
TakesFocusFromNativeControl="False"
|
|
||||||
IsLightDismissEnabled="False">
|
|
||||||
<Border Classes="card" Width="598" Height="58" CornerRadius="16" Padding="14,8">
|
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
|
||||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<TextBlock x:Name="SessionGameTitle" Text="GAME RUNNING" FontSize="13" FontWeight="SemiBold"
|
|
||||||
MaxWidth="240" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
|
||||||
<Border Classes="badge running" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="RUNNING" FontSize="9" FontWeight="Bold" LetterSpacing="1"
|
|
||||||
Foreground="{StaticResource SuccessBrush}" />
|
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
|
||||||
<Border x:Name="SessionF11Badge" Classes="badge key" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="F11" FontSize="9" FontWeight="Bold"
|
|
||||||
Foreground="{StaticResource InfoBrush}" />
|
|
||||||
</Border>
|
|
||||||
<TextBlock x:Name="SessionHintText" Text="Fullscreen" FontSize="11"
|
|
||||||
Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
|
||||||
<Button x:Name="SessionConsoleButton" Classes="ghost" Content="≡ Console" />
|
|
||||||
<Button x:Name="SessionStopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</primitives:Popup>
|
|
||||||
|
|
||||||
<!-- This is a native popup rather than an Avalonia overlay because the
|
|
||||||
emulated Vulkan surface is a native child window. -->
|
|
||||||
<!-- Anchored to MainContent, not GameView: the surface host is parked in
|
|
||||||
a 1x1 corner while loading/closing, which would pull a GameView-
|
|
||||||
anchored popup into the corner with it. -->
|
|
||||||
<primitives:Popup x:Name="SessionLoadingPopup"
|
<primitives:Popup x:Name="SessionLoadingPopup"
|
||||||
IsOpen="False"
|
IsOpen="False"
|
||||||
PlacementTarget="{Binding #MainContent}"
|
PlacementTarget="{Binding #MainContent}"
|
||||||
@@ -653,7 +643,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
|
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
|
||||||
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
|
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
|
||||||
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||||
<ProgressBar IsIndeterminate="True" Height="5" />
|
<ProgressBar x:Name="SessionLoadingProgress" IsIndeterminate="True" Height="5" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</primitives:Popup>
|
</primitives:Popup>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ using Avalonia.VisualTree;
|
|||||||
using SharpEmu.Core.Cpu;
|
using SharpEmu.Core.Cpu;
|
||||||
using SharpEmu.Core.Runtime;
|
using SharpEmu.Core.Runtime;
|
||||||
using SharpEmu.HLE.Host;
|
using SharpEmu.HLE.Host;
|
||||||
using SharpEmu.HLE.Host.Windows;
|
using SharpEmu.Libs.Pad;
|
||||||
using SharpEmu.Libs.VideoOut;
|
using SharpEmu.Libs.VideoOut;
|
||||||
using SharpEmu.Logging;
|
using SharpEmu.Logging;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
@@ -62,18 +62,17 @@ public partial class MainWindow : Window
|
|||||||
private bool _clearLibraryBlurWhenComplete;
|
private bool _clearLibraryBlurWhenComplete;
|
||||||
|
|
||||||
private GuiSettings _settings = new();
|
private GuiSettings _settings = new();
|
||||||
|
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
|
||||||
|
private bool _updatingHostDisplayOptions;
|
||||||
private EmulatorProcess? _emulator;
|
private EmulatorProcess? _emulator;
|
||||||
private GameSurfaceHost? _gameSurfaceHost;
|
|
||||||
private ConsoleWindow? _consoleWindow;
|
private ConsoleWindow? _consoleWindow;
|
||||||
private GuiConsoleMirror? _consoleMirror;
|
private GuiConsoleMirror? _consoleMirror;
|
||||||
private StreamWriter? _fileLog;
|
private StreamWriter? _fileLog;
|
||||||
private readonly SndPreviewPlayer _sndPreview = new();
|
private readonly SndPreviewPlayer _sndPreview = new();
|
||||||
private string? _emulatorExePath;
|
private string? _emulatorExePath;
|
||||||
private PendingLaunch? _pendingLaunch;
|
private PendingLaunch? _pendingLaunch;
|
||||||
private bool _gameFullscreen;
|
|
||||||
private bool _isRunning;
|
private bool _isRunning;
|
||||||
private bool _isStopping;
|
private bool _isStopping;
|
||||||
private bool _awaitingFirstFrame;
|
|
||||||
private int _autoScrollTicks;
|
private int _autoScrollTicks;
|
||||||
private int _activePageIndex;
|
private int _activePageIndex;
|
||||||
private Updater.UpdateInfo? _availableUpdate;
|
private Updater.UpdateInfo? _availableUpdate;
|
||||||
@@ -114,7 +113,7 @@ public partial class MainWindow : Window
|
|||||||
string EbootPath,
|
string EbootPath,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string? TitleId,
|
string? TitleId,
|
||||||
string LogLevel,
|
EffectiveLaunchSettings Settings,
|
||||||
SharpEmuRuntimeOptions RuntimeOptions);
|
SharpEmuRuntimeOptions RuntimeOptions);
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
@@ -160,12 +159,10 @@ public partial class MainWindow : Window
|
|||||||
// follow the launcher into the background or a minimized state.
|
// follow the launcher into the background or a minimized state.
|
||||||
Activated += (_, _) =>
|
Activated += (_, _) =>
|
||||||
{
|
{
|
||||||
UpdateSessionBarVisibility();
|
|
||||||
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
|
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
|
||||||
};
|
};
|
||||||
Deactivated += (_, _) =>
|
Deactivated += (_, _) =>
|
||||||
{
|
{
|
||||||
SessionBarPopup.IsOpen = false;
|
|
||||||
SessionLoadingPopup.IsOpen = false;
|
SessionLoadingPopup.IsOpen = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,8 +178,6 @@ public partial class MainWindow : Window
|
|||||||
LaunchButton.Click += (_, _) => LaunchSelected();
|
LaunchButton.Click += (_, _) => LaunchSelected();
|
||||||
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
|
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
|
||||||
StopButton.Click += (_, _) => StopEmulator();
|
StopButton.Click += (_, _) => StopEmulator();
|
||||||
SessionStopButton.Click += (_, _) => StopEmulator();
|
|
||||||
SessionConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
|
||||||
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
||||||
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
||||||
LibraryTabButton.Click += (_, _) => SetActivePage(0);
|
LibraryTabButton.Click += (_, _) => SetActivePage(0);
|
||||||
@@ -221,6 +216,13 @@ public partial class MainWindow : Window
|
|||||||
};
|
};
|
||||||
AutoUpdateToggle.IsCheckedChanged += (_, _) =>
|
AutoUpdateToggle.IsCheckedChanged += (_, _) =>
|
||||||
_settings.CheckForUpdatesOnStartup = AutoUpdateToggle.IsChecked == true;
|
_settings.CheckForUpdatesOnStartup = AutoUpdateToggle.IsChecked == true;
|
||||||
|
WindowModeBox.SelectionChanged += (_, _) => _settings.WindowMode = SelectedComboText(WindowModeBox, "Windowed");
|
||||||
|
DisplayBox.SelectionChanged += (_, _) => OnHostDisplayChanged();
|
||||||
|
ResolutionBox.SelectionChanged += (_, _) => OnHostResolutionChanged();
|
||||||
|
RefreshRateBox.SelectionChanged += (_, _) => OnHostRefreshRateChanged();
|
||||||
|
ScalingModeBox.SelectionChanged += (_, _) => _settings.ScalingMode = SelectedComboText(ScalingModeBox, "Fit");
|
||||||
|
VSyncToggle.IsCheckedChanged += (_, _) => _settings.VSync = VSyncToggle.IsChecked == true;
|
||||||
|
HdrModeBox.SelectionChanged += (_, _) => _settings.HdrMode = SelectedComboText(HdrModeBox, "Auto");
|
||||||
UpdateButton.Click += async (_, _) => await OnUpdateButtonAsync();
|
UpdateButton.Click += async (_, _) => await OnUpdateButtonAsync();
|
||||||
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
|
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
|
||||||
EnvBthidToggle.IsCheckedChanged += (_, _) =>
|
EnvBthidToggle.IsCheckedChanged += (_, _) =>
|
||||||
@@ -255,8 +257,7 @@ public partial class MainWindow : Window
|
|||||||
Opened += async (_, _) => await OnOpenedAsync();
|
Opened += async (_, _) => await OnOpenedAsync();
|
||||||
Closing += (_, _) => OnWindowClosing();
|
Closing += (_, _) => OnWindowClosing();
|
||||||
|
|
||||||
WindowsDualSenseReader.EnsureStarted();
|
SdlLauncherGamepad.EnsureStarted();
|
||||||
WindowsXInputReader.EnsureStarted();
|
|
||||||
_gamepadTimer = new DispatcherTimer
|
_gamepadTimer = new DispatcherTimer
|
||||||
{
|
{
|
||||||
Interval = TimeSpan.FromMilliseconds(50),
|
Interval = TimeSpan.FromMilliseconds(50),
|
||||||
@@ -427,8 +428,7 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void PollGamepad()
|
private void PollGamepad()
|
||||||
{
|
{
|
||||||
// DualSense wins when both are connected; XInput covers Xbox pads.
|
if (!SdlLauncherGamepad.TryGetState(out var pad))
|
||||||
if (!WindowsDualSenseReader.TryGetState(out var pad) && !WindowsXInputReader.TryGetState(out pad))
|
|
||||||
{
|
{
|
||||||
_previousPadButtons = HostGamepadButtons.None;
|
_previousPadButtons = HostGamepadButtons.None;
|
||||||
return;
|
return;
|
||||||
@@ -444,9 +444,8 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
if (_isRunning || _isStopping)
|
if (_isRunning || _isStopping)
|
||||||
{
|
{
|
||||||
// The game renders inside the launcher window, so the launcher
|
// The controller belongs to the separate game window while a
|
||||||
// stays active while playing. The controller belongs to the game
|
// session is active; Circle/B must never stop the session.
|
||||||
// then: no navigation, and Circle/B must never stop the session.
|
|
||||||
_previousPadButtons = pad.Buttons;
|
_previousPadButtons = pad.Buttons;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -685,7 +684,25 @@ public partial class MainWindow : Window
|
|||||||
AutoUpdateRow.Label = loc.Get("Updater.Auto.Label");
|
AutoUpdateRow.Label = loc.Get("Updater.Auto.Label");
|
||||||
AutoUpdateRow.Description = loc.Get("Updater.Auto.Desc");
|
AutoUpdateRow.Description = loc.Get("Updater.Auto.Desc");
|
||||||
|
|
||||||
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle })
|
GraphicsTabItem.Header = loc.Get("Options.Graphics");
|
||||||
|
DisplaySectionTitle.Text = loc.Get("Options.Section.Display");
|
||||||
|
WindowModeRow.Label = loc.Get("Options.WindowMode.Label");
|
||||||
|
WindowModeRow.Description = loc.Get("Options.WindowMode.Desc");
|
||||||
|
ResolutionRow.Label = loc.Get("Options.Resolution.Label");
|
||||||
|
ResolutionRow.Description = loc.Get("Options.Resolution.Desc");
|
||||||
|
DisplayRow.Label = loc.Get("Options.Display.Label");
|
||||||
|
DisplayRow.Description = loc.Get("Options.Display.Desc");
|
||||||
|
RefreshRateRow.Label = loc.Get("Options.RefreshRate.Label");
|
||||||
|
RefreshRateRow.Description = loc.Get("Options.RefreshRate.Desc");
|
||||||
|
ScalingRow.Label = loc.Get("Options.Scaling.Label");
|
||||||
|
ScalingRow.Description = loc.Get("Options.Scaling.Desc");
|
||||||
|
VSyncRow.Label = loc.Get("Options.VSync.Label");
|
||||||
|
VSyncRow.Description = loc.Get("Options.VSync.Desc");
|
||||||
|
HdrRow.Label = loc.Get("Options.Hdr.Label");
|
||||||
|
HdrRow.Description = loc.Get("Options.Hdr.Desc");
|
||||||
|
RefreshHostRefreshRates(_settings.RefreshRate);
|
||||||
|
|
||||||
|
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle, VSyncToggle })
|
||||||
{
|
{
|
||||||
toggle.OnContent = loc.Get("Common.On");
|
toggle.OnContent = loc.Get("Common.On");
|
||||||
toggle.OffContent = loc.Get("Common.Off");
|
toggle.OffContent = loc.Get("Common.Off");
|
||||||
@@ -758,91 +775,21 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void OnKeyDown(object sender, KeyEventArgs args)
|
private void OnKeyDown(object sender, KeyEventArgs args)
|
||||||
{
|
{
|
||||||
args.Handled = true;
|
if (args.Key == Key.F11 && !_isRunning)
|
||||||
switch (args.Key)
|
|
||||||
{
|
{
|
||||||
case Key.F11:
|
WindowState = WindowState == WindowState.FullScreen
|
||||||
OnWindowFullScreen(this, new RoutedEventArgs());
|
? WindowState.Maximized
|
||||||
break;
|
: WindowState.FullScreen;
|
||||||
default:
|
args.Handled = true;
|
||||||
args.Handled = false;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPreviewKeyDown(object? sender, KeyEventArgs args)
|
private void OnPreviewKeyDown(object? sender, KeyEventArgs args)
|
||||||
{
|
{
|
||||||
// While a session is on screen, Enter and Space are game input
|
// The session runs in its own SDL window and takes keyboard focus with
|
||||||
// (Cross button). Keyboard focus stays on the launcher window, so a
|
// it, so launcher buttons no longer see game input and nothing has to
|
||||||
// previously clicked, still-focused button (console toggle, session
|
// be swallowed here. Kept as the wired handler because the launcher
|
||||||
// bar) would also activate and reshape the game view. Swallow the
|
// still needs a preview hook for its own shortcuts.
|
||||||
// keys before button activation; the emulator process reads raw key
|
|
||||||
// state and is unaffected. Fullscreen hides those buttons, which is
|
|
||||||
// why this only manifested in windowed sessions.
|
|
||||||
if (_isRunning && GameView.IsVisible &&
|
|
||||||
args.Key is Key.Enter or Key.Space)
|
|
||||||
{
|
|
||||||
args.Handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnWindowFullScreen(object sender, RoutedEventArgs args)
|
|
||||||
{
|
|
||||||
if (WindowState == WindowState.FullScreen)
|
|
||||||
{
|
|
||||||
// Leaving F11 should restore a monitor-sized window with the
|
|
||||||
// launcher chrome, not fall back to the design-time window size.
|
|
||||||
WindowState = WindowState.Maximized;
|
|
||||||
WindowDecorations = WindowDecorations.Full;
|
|
||||||
TitleBar.IsVisible = true;
|
|
||||||
StatusBar.IsVisible = true;
|
|
||||||
if (_gameFullscreen)
|
|
||||||
{
|
|
||||||
_gameFullscreen = false;
|
|
||||||
Grid.SetRow(MainContent, 1);
|
|
||||||
Grid.SetRowSpan(MainContent, 1);
|
|
||||||
MainContent.Margin = _isRunning
|
|
||||||
? new Thickness(0)
|
|
||||||
: new Thickness(32, 24, 32, 20);
|
|
||||||
ContentToolbar.IsVisible = !_isRunning;
|
|
||||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
|
||||||
LaunchBar.IsVisible = true;
|
|
||||||
QueueGameSurfaceResize();
|
|
||||||
UpdateSessionBarVisibility();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
WindowState = WindowState.FullScreen;
|
|
||||||
WindowDecorations = WindowDecorations.None;
|
|
||||||
TitleBar.IsVisible = false;
|
|
||||||
StatusBar.IsVisible = false;
|
|
||||||
if (_isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible)
|
|
||||||
{
|
|
||||||
// The native child receives its new physical Bounds as soon
|
|
||||||
// as this grid spans the monitor. The presenter recreates its
|
|
||||||
// swapchain from that size, rather than stretching 720p.
|
|
||||||
_gameFullscreen = true;
|
|
||||||
// Re-arming restarts the idle countdown, so the cursor also
|
|
||||||
// hides a moment after F11 even without further mouse motion.
|
|
||||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
|
||||||
Grid.SetRow(MainContent, 0);
|
|
||||||
Grid.SetRowSpan(MainContent, 3);
|
|
||||||
MainContent.Margin = new Thickness(0);
|
|
||||||
ContentToolbar.IsVisible = false;
|
|
||||||
ConsolePanel.IsVisible = false;
|
|
||||||
LaunchBar.IsVisible = false;
|
|
||||||
QueueGameSurfaceResize();
|
|
||||||
UpdateSessionBarVisibility();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void QueueGameSurfaceResize()
|
|
||||||
{
|
|
||||||
Dispatcher.UIThread.Post(
|
|
||||||
() => _gameSurfaceHost?.RefreshSurfaceSize(),
|
|
||||||
DispatcherPriority.Render);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnWindowClosing()
|
private void OnWindowClosing()
|
||||||
@@ -851,6 +798,7 @@ public partial class MainWindow : Window
|
|||||||
_consoleFlushTimer.Stop();
|
_consoleFlushTimer.Stop();
|
||||||
_libraryBlurTimer.Stop();
|
_libraryBlurTimer.Stop();
|
||||||
_gamepadTimer.Stop();
|
_gamepadTimer.Stop();
|
||||||
|
SdlLauncherGamepad.Shutdown();
|
||||||
_sndPreview.Stop();
|
_sndPreview.Stop();
|
||||||
_discord?.Dispose();
|
_discord?.Dispose();
|
||||||
_consoleWindow?.Close();
|
_consoleWindow?.Close();
|
||||||
@@ -903,9 +851,139 @@ public partial class MainWindow : Window
|
|||||||
EnvLogDirectMemoryToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_DIRECT_MEMORY");
|
EnvLogDirectMemoryToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_DIRECT_MEMORY");
|
||||||
EnvLogIoToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_IO");
|
EnvLogIoToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_IO");
|
||||||
EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP");
|
EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP");
|
||||||
|
WindowModeBox.SelectedIndex = ChoiceIndex(_settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
||||||
|
LoadHostDisplayOptions();
|
||||||
|
ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
||||||
|
VSyncToggle.IsChecked = _settings.VSync;
|
||||||
|
HdrModeBox.SelectedIndex = ChoiceIndex(_settings.HdrMode, "Auto", "On", "Off");
|
||||||
UpdateLogFilePathText();
|
UpdateLogFilePathText();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string SelectedComboText(ComboBox comboBox, string fallback) =>
|
||||||
|
comboBox.SelectedItem switch
|
||||||
|
{
|
||||||
|
ComboBoxItem item => item.Content?.ToString() ?? fallback,
|
||||||
|
string value => value,
|
||||||
|
_ => fallback,
|
||||||
|
};
|
||||||
|
|
||||||
|
private void LoadHostDisplayOptions()
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_hostDisplays = HostDisplayOptions.BuildDisplays(
|
||||||
|
HostDisplayCatalog.Query(),
|
||||||
|
_settings.DisplayIndex);
|
||||||
|
DisplayBox.ItemsSource = _hostDisplays;
|
||||||
|
var display = HostDisplayOptions.SelectDisplay(_hostDisplays, _settings.DisplayIndex);
|
||||||
|
DisplayBox.SelectedItem = display;
|
||||||
|
PopulateHostModes(display, _settings.Resolution, _settings.RefreshRate);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncHostVideoSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHostDisplayChanged()
|
||||||
|
{
|
||||||
|
if (_updatingHostDisplayOptions || DisplayBox.SelectedItem is not HostDisplayOption display)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatingHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PopulateHostModes(display, _settings.Resolution, _settings.RefreshRate);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncHostVideoSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHostResolutionChanged()
|
||||||
|
{
|
||||||
|
if (_updatingHostDisplayOptions || DisplayBox.SelectedItem is not HostDisplayOption)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settings.Resolution = SelectedComboText(ResolutionBox, "1920x1080");
|
||||||
|
RefreshHostRefreshRates(_settings.RefreshRate);
|
||||||
|
OnHostRefreshRateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHostRefreshRateChanged()
|
||||||
|
{
|
||||||
|
if (!_updatingHostDisplayOptions && RefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate)
|
||||||
|
{
|
||||||
|
_settings.RefreshRate = refreshRate.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateHostModes(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string selectedResolution,
|
||||||
|
int selectedRefreshRate)
|
||||||
|
{
|
||||||
|
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
|
||||||
|
ResolutionBox.ItemsSource = resolutions;
|
||||||
|
ResolutionBox.SelectedItem = resolutions.FirstOrDefault(resolution =>
|
||||||
|
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
|
||||||
|
RefreshHostRefreshRates(selectedRefreshRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshHostRefreshRates(int selectedRefreshRate)
|
||||||
|
{
|
||||||
|
if (DisplayBox.SelectedItem is not HostDisplayOption display)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wasUpdating = _updatingHostDisplayOptions;
|
||||||
|
_updatingHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rates = HostDisplayOptions.BuildRefreshRates(
|
||||||
|
display,
|
||||||
|
SelectedComboText(ResolutionBox, _settings.Resolution),
|
||||||
|
selectedRefreshRate,
|
||||||
|
Localization.Instance.Get("Options.RefreshRate.Automatic"));
|
||||||
|
RefreshRateBox.ItemsSource = rates;
|
||||||
|
RefreshRateBox.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = wasUpdating;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncHostVideoSettings()
|
||||||
|
{
|
||||||
|
if (DisplayBox.SelectedItem is HostDisplayOption display)
|
||||||
|
{
|
||||||
|
_settings.DisplayIndex = display.Index;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settings.Resolution = SelectedComboText(ResolutionBox, "1920x1080");
|
||||||
|
_settings.RefreshRate = RefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate
|
||||||
|
? refreshRate.Value
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ChoiceIndex(string value, params string[] choices)
|
||||||
|
{
|
||||||
|
var index = Array.FindIndex(choices, choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return index < 0 ? 0 : index;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task OnUpdateButtonAsync()
|
private async Task OnUpdateButtonAsync()
|
||||||
{
|
{
|
||||||
if (_availableUpdate is null)
|
if (_availableUpdate is null)
|
||||||
@@ -1828,7 +1906,6 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
_isRunning = true;
|
_isRunning = true;
|
||||||
_runningGameName = displayName;
|
_runningGameName = displayName;
|
||||||
SessionGameTitle.Text = displayName;
|
|
||||||
_runningGameTitleId = resolvedTitleId;
|
_runningGameTitleId = resolvedTitleId;
|
||||||
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
StatusDot.Fill = SuccessLineBrush;
|
StatusDot.Fill = SuccessLineBrush;
|
||||||
@@ -1837,18 +1914,15 @@ public partial class MainWindow : Window
|
|||||||
UpdateRunButtons();
|
UpdateRunButtons();
|
||||||
UpdateDiscordPresence();
|
UpdateDiscordPresence();
|
||||||
|
|
||||||
ShowGameView();
|
BeginSessionUi();
|
||||||
_pendingLaunch = new PendingLaunch(
|
_pendingLaunch = new PendingLaunch(
|
||||||
Path.GetFullPath(ebootPath),
|
Path.GetFullPath(ebootPath),
|
||||||
displayName,
|
displayName,
|
||||||
_runningGameTitleId,
|
_runningGameTitleId,
|
||||||
effective.LogLevel,
|
effective,
|
||||||
runtimeOptions);
|
runtimeOptions);
|
||||||
|
|
||||||
if (_gameSurfaceHost?.Surface is { } surface)
|
StartPendingSession();
|
||||||
{
|
|
||||||
StartPendingSession(surface);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1877,9 +1951,6 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
_isStopping = true;
|
_isStopping = true;
|
||||||
StopButton.IsEnabled = false;
|
StopButton.IsEnabled = false;
|
||||||
SessionStopButton.IsEnabled = false;
|
|
||||||
SessionHintText.Text = Localization.Instance.Get("Launch.Stopping");
|
|
||||||
SessionF11Badge.IsVisible = false;
|
|
||||||
ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
|
ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
|
||||||
_emulator.Stop();
|
_emulator.Stop();
|
||||||
_runningGameName = null;
|
_runningGameName = null;
|
||||||
@@ -1887,7 +1958,6 @@ public partial class MainWindow : Window
|
|||||||
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
|
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
|
||||||
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
|
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
|
||||||
UpdateDiscordPresence();
|
UpdateDiscordPresence();
|
||||||
UpdateSessionBarVisibility();
|
|
||||||
ReturnToLibraryWhileStopping();
|
ReturnToLibraryWhileStopping();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1930,8 +2000,7 @@ public partial class MainWindow : Window
|
|||||||
_emulator?.Dispose();
|
_emulator?.Dispose();
|
||||||
_emulator = null;
|
_emulator = null;
|
||||||
_pendingLaunch = null;
|
_pendingLaunch = null;
|
||||||
DisposeGameSurfaceHost();
|
EndSessionUi();
|
||||||
HideGameView();
|
|
||||||
|
|
||||||
var meaningKey = exitCode switch
|
var meaningKey = exitCode switch
|
||||||
{
|
{
|
||||||
@@ -1964,7 +2033,7 @@ public partial class MainWindow : Window
|
|||||||
UpdateDiscordPresence();
|
UpdateDiscordPresence();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartPendingSession(VulkanHostSurface surface)
|
private void StartPendingSession()
|
||||||
{
|
{
|
||||||
if (_pendingLaunch is not { } launch || _emulator is not null)
|
if (_pendingLaunch is not { } launch || _emulator is not null)
|
||||||
{
|
{
|
||||||
@@ -1984,7 +2053,7 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var arguments = BuildEmulatorArguments(launch, surface);
|
var arguments = BuildEmulatorArguments(launch);
|
||||||
_emulator = process;
|
_emulator = process;
|
||||||
_pendingLaunch = null;
|
_pendingLaunch = null;
|
||||||
process.Start(
|
process.Start(
|
||||||
@@ -2006,12 +2075,12 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<string> BuildEmulatorArguments(PendingLaunch launch, VulkanHostSurface surface)
|
private List<string> BuildEmulatorArguments(PendingLaunch launch)
|
||||||
{
|
{
|
||||||
var arguments = new List<string>
|
var arguments = new List<string>
|
||||||
{
|
{
|
||||||
"--cpu-engine=native",
|
"--cpu-engine=native",
|
||||||
$"--log-level={launch.LogLevel}",
|
$"--log-level={launch.Settings.LogLevel}",
|
||||||
};
|
};
|
||||||
if (launch.RuntimeOptions.StrictDynlibResolution)
|
if (launch.RuntimeOptions.StrictDynlibResolution)
|
||||||
{
|
{
|
||||||
@@ -2022,16 +2091,13 @@ public partial class MainWindow : Window
|
|||||||
arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}");
|
arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (surface.TryGetChildProcessDescriptor(out var descriptor))
|
arguments.Add($"--window-mode={launch.Settings.WindowMode.ToLowerInvariant()}");
|
||||||
{
|
arguments.Add($"--resolution={launch.Settings.Resolution}");
|
||||||
arguments.Add($"--host-surface={descriptor}");
|
arguments.Add($"--display={launch.Settings.DisplayIndex}");
|
||||||
}
|
arguments.Add($"--refresh-rate={launch.Settings.RefreshRate}");
|
||||||
else
|
arguments.Add($"--scaling={launch.Settings.ScalingMode.ToLowerInvariant()}");
|
||||||
{
|
arguments.Add($"--vsync={(launch.Settings.VSync ? "on" : "off")}");
|
||||||
AppendConsoleLine(
|
arguments.Add($"--hdr={launch.Settings.HdrMode.ToLowerInvariant()}");
|
||||||
"[GUI][WARN] Embedded child surfaces are unavailable on this platform; opening a game window instead.",
|
|
||||||
WarningLineBrush);
|
|
||||||
}
|
|
||||||
|
|
||||||
arguments.Add(launch.EbootPath);
|
arguments.Add(launch.EbootPath);
|
||||||
return arguments;
|
return arguments;
|
||||||
@@ -2040,8 +2106,8 @@ public partial class MainWindow : Window
|
|||||||
private void OnEmulatorOutput(string line, bool isError)
|
private void OnEmulatorOutput(string line, bool isError)
|
||||||
{
|
{
|
||||||
_pendingLines.Enqueue((line, isError));
|
_pendingLines.Enqueue((line, isError));
|
||||||
if (!line.Contains("[VIDEOOUT][INFO] Hosted splash ready.", StringComparison.Ordinal) &&
|
if (!line.Contains("Vulkan VideoOut presented first frame:", StringComparison.Ordinal) &&
|
||||||
!line.Contains("[VIDEOOUT][INFO] Hosted first frame presented.", StringComparison.Ordinal))
|
!line.Contains("Vulkan VideoOut ready:", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2050,143 +2116,25 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
if (_isRunning && !_isStopping)
|
if (_isRunning && !_isStopping)
|
||||||
{
|
{
|
||||||
_awaitingFirstFrame = false;
|
ShowSessionStatus("Game is running");
|
||||||
ClearLibraryBlur();
|
|
||||||
MainContent.Margin = new Thickness(0);
|
|
||||||
RestoreGameViewToFull();
|
|
||||||
GameView.Background = Brushes.Black;
|
|
||||||
GameView.IsHitTestVisible = true;
|
|
||||||
LibraryPage.IsVisible = false;
|
|
||||||
OptionsPage.IsVisible = false;
|
|
||||||
LibraryToolbar.IsVisible = false;
|
|
||||||
ContentToolbar.IsVisible = false;
|
|
||||||
ConsolePanel.IsVisible = false;
|
|
||||||
LaunchBar.IsVisible = false;
|
|
||||||
HideSessionLoading();
|
|
||||||
UpdateSessionBarVisibility();
|
|
||||||
|
|
||||||
// Defer so the layout pass from the margin change above settles first.
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
if (!_isRunning || _isStopping)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_gameSurfaceHost?.RefreshSurfaceSize();
|
|
||||||
_gameSurfaceHost?.SetPresentationVisible(true);
|
|
||||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private GameSurfaceHost EnsureGameSurfaceHost()
|
private void BeginSessionUi()
|
||||||
{
|
|
||||||
if (_gameSurfaceHost is not null)
|
|
||||||
{
|
|
||||||
return _gameSurfaceHost;
|
|
||||||
}
|
|
||||||
|
|
||||||
var host = new GameSurfaceHost();
|
|
||||||
// Configure this before attaching it to Avalonia so its first native
|
|
||||||
// HWND is hidden while the child process starts.
|
|
||||||
host.SetPresentationVisible(false);
|
|
||||||
host.SurfaceAvailable += (_, surface) =>
|
|
||||||
{
|
|
||||||
if (ReferenceEquals(_gameSurfaceHost, host))
|
|
||||||
{
|
|
||||||
StartPendingSession(surface);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
host.SurfaceDestroyed += (_, surface) => OnGameSurfaceDestroyed(host, surface);
|
|
||||||
_gameSurfaceHost = host;
|
|
||||||
GameSurfaceContainer.Children.Add(host);
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DisposeGameSurfaceHost()
|
|
||||||
{
|
|
||||||
var host = _gameSurfaceHost;
|
|
||||||
if (host is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_gameSurfaceHost = null;
|
|
||||||
host.SetPresentationVisible(false);
|
|
||||||
GameSurfaceContainer.Children.Remove(host);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnGameSurfaceDestroyed(GameSurfaceHost host, VulkanHostSurface surface)
|
|
||||||
{
|
|
||||||
if (ReferenceEquals(_gameSurfaceHost, host) && _isRunning)
|
|
||||||
{
|
|
||||||
StopEmulator();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The native host attachment is a real child window: it sits above every
|
|
||||||
/// Avalonia control it covers and swallows their mouse input regardless of
|
|
||||||
/// hit-test settings. While the library must stay interactive (loading,
|
|
||||||
/// closing), the surface is parked offscreen AT FULL SIZE via a negative
|
|
||||||
/// margin. It must not be shrunk instead: the emulator child polls the
|
|
||||||
/// HWND client size and its presenter defers swapchain creation while the
|
|
||||||
/// surface is 1px, which would deadlock the loading handshake.
|
|
||||||
/// </summary>
|
|
||||||
private void ParkGameViewOffscreen()
|
|
||||||
{
|
|
||||||
GameView.Margin = new Thickness(-20000, 0, 20000, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RestoreGameViewToFull()
|
|
||||||
{
|
|
||||||
GameView.Margin = new Thickness(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowGameView()
|
|
||||||
{
|
{
|
||||||
_isStopping = false;
|
_isStopping = false;
|
||||||
_awaitingFirstFrame = true;
|
|
||||||
var host = EnsureGameSurfaceHost();
|
|
||||||
ParkGameViewOffscreen();
|
|
||||||
GameView.IsVisible = true;
|
|
||||||
GameView.Background = Brushes.Transparent;
|
|
||||||
GameView.IsHitTestVisible = false;
|
|
||||||
host.SetPresentationVisible(false);
|
|
||||||
AnimateLibraryBlur(LaunchBlurRadius);
|
AnimateLibraryBlur(LaunchBlurRadius);
|
||||||
SessionHintText.Text = "Fullscreen";
|
|
||||||
SessionF11Badge.IsVisible = true;
|
|
||||||
UpdateSessionBarVisibility();
|
|
||||||
ShowSessionLoading("Loading game", "Preparing the emulation session...");
|
ShowSessionLoading("Loading game", "Preparing the emulation session...");
|
||||||
|
LaunchBar.IsVisible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HideGameView()
|
private void EndSessionUi()
|
||||||
{
|
{
|
||||||
if (_gameFullscreen && WindowState == WindowState.FullScreen)
|
|
||||||
{
|
|
||||||
OnWindowFullScreen(this, new RoutedEventArgs());
|
|
||||||
}
|
|
||||||
|
|
||||||
_gameSurfaceHost?.SetCursorAutoHide(false);
|
|
||||||
_gameSurfaceHost?.SetPresentationVisible(false);
|
|
||||||
_awaitingFirstFrame = false;
|
|
||||||
GameView.IsVisible = false;
|
|
||||||
GameView.IsHitTestVisible = true;
|
|
||||||
SessionBarPopup.IsOpen = false;
|
|
||||||
HideSessionLoading();
|
HideSessionLoading();
|
||||||
AnimateLibraryBlur(0, clearWhenComplete: true);
|
AnimateLibraryBlur(0, clearWhenComplete: true);
|
||||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
|
||||||
ContentToolbar.IsVisible = true;
|
|
||||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
|
||||||
LaunchBar.IsVisible = true;
|
LaunchBar.IsVisible = true;
|
||||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
|
||||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
|
||||||
// Game art when the source still holds it, otherwise the bundled
|
|
||||||
// default; a bare color only when neither is available.
|
|
||||||
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
|
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
|
||||||
@@ -2258,7 +2206,20 @@ public partial class MainWindow : Window
|
|||||||
private void ShowSessionLoading(string title, string detail)
|
private void ShowSessionLoading(string title, string detail)
|
||||||
{
|
{
|
||||||
SessionLoadingTitle.Text = title;
|
SessionLoadingTitle.Text = title;
|
||||||
|
SessionLoadingTitle.IsVisible = true;
|
||||||
SessionLoadingDetail.Text = detail;
|
SessionLoadingDetail.Text = detail;
|
||||||
|
SessionLoadingDetail.IsVisible = true;
|
||||||
|
SessionLoadingProgress.IsVisible = true;
|
||||||
|
_sessionLoadingActive = true;
|
||||||
|
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowSessionStatus(string message)
|
||||||
|
{
|
||||||
|
SessionLoadingTitle.Text = message;
|
||||||
|
SessionLoadingTitle.IsVisible = true;
|
||||||
|
SessionLoadingDetail.IsVisible = false;
|
||||||
|
SessionLoadingProgress.IsVisible = false;
|
||||||
_sessionLoadingActive = true;
|
_sessionLoadingActive = true;
|
||||||
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
|
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
|
||||||
}
|
}
|
||||||
@@ -2271,33 +2232,11 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void ReturnToLibraryWhileStopping()
|
private void ReturnToLibraryWhileStopping()
|
||||||
{
|
{
|
||||||
if (_gameFullscreen && WindowState == WindowState.FullScreen)
|
|
||||||
{
|
|
||||||
OnWindowFullScreen(this, new RoutedEventArgs());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the native child alive until the session exits, but hide it
|
|
||||||
// immediately. Destroying it while Vulkan still owns the surface can
|
|
||||||
// crash the GUI; parking it in the 1x1 corner lets the library
|
|
||||||
// recover — and stay clickable — while the native closing popup
|
|
||||||
// reports teardown progress.
|
|
||||||
_gameSurfaceHost?.SetPresentationVisible(false);
|
|
||||||
_awaitingFirstFrame = false;
|
|
||||||
ParkGameViewOffscreen();
|
|
||||||
GameView.Background = Brushes.Transparent;
|
|
||||||
GameView.IsHitTestVisible = false;
|
|
||||||
SessionBarPopup.IsOpen = false;
|
|
||||||
AnimateLibraryBlur(LaunchBlurRadius);
|
AnimateLibraryBlur(LaunchBlurRadius);
|
||||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
|
||||||
ContentToolbar.IsVisible = true;
|
|
||||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||||
LaunchBar.IsVisible = true;
|
LaunchBar.IsVisible = true;
|
||||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
|
||||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
|
||||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
|
||||||
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
|
|
||||||
UpdateRunButtons();
|
UpdateRunButtons();
|
||||||
Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
|
Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenFileLog(string? titleId)
|
private void OpenFileLog(string? titleId)
|
||||||
@@ -2356,16 +2295,9 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
|
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
|
||||||
StopButton.IsEnabled = _isRunning && !_isStopping;
|
StopButton.IsEnabled = _isRunning && !_isStopping;
|
||||||
SessionStopButton.IsEnabled = _isRunning && !_isStopping;
|
|
||||||
OpenFileButton.IsEnabled = !_isRunning;
|
OpenFileButton.IsEnabled = !_isRunning;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateSessionBarVisibility()
|
|
||||||
{
|
|
||||||
SessionBarPopup.IsOpen = _isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible &&
|
|
||||||
!_gameFullscreen && WindowState != WindowState.FullScreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Console ----
|
// ---- Console ----
|
||||||
|
|
||||||
private void FlushPendingConsoleLines()
|
private void FlushPendingConsoleLines()
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ public sealed class PerGameSettings
|
|||||||
|
|
||||||
public bool? LogToFile { get; set; }
|
public bool? LogToFile { get; set; }
|
||||||
|
|
||||||
|
public string? WindowMode { get; set; }
|
||||||
|
|
||||||
|
public string? Resolution { get; set; }
|
||||||
|
|
||||||
|
public int? DisplayIndex { get; set; }
|
||||||
|
|
||||||
|
public int? RefreshRate { get; set; }
|
||||||
|
|
||||||
|
public string? ScalingMode { get; set; }
|
||||||
|
|
||||||
|
public bool? VSync { get; set; }
|
||||||
|
|
||||||
|
public string? HdrMode { get; set; }
|
||||||
|
|
||||||
public List<string>? EnvironmentToggles { get; set; }
|
public List<string>? EnvironmentToggles { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
@@ -29,6 +43,13 @@ public sealed class PerGameSettings
|
|||||||
ImportTraceLimit is null &&
|
ImportTraceLimit is null &&
|
||||||
StrictDynlibResolution is null &&
|
StrictDynlibResolution is null &&
|
||||||
LogToFile is null &&
|
LogToFile is null &&
|
||||||
|
WindowMode is null &&
|
||||||
|
Resolution is null &&
|
||||||
|
DisplayIndex is null &&
|
||||||
|
RefreshRate is null &&
|
||||||
|
ScalingMode is null &&
|
||||||
|
VSync is null &&
|
||||||
|
HdrMode is null &&
|
||||||
EnvironmentToggles is null;
|
EnvironmentToggles is null;
|
||||||
|
|
||||||
public static string DirectoryPath =>
|
public static string DirectoryPath =>
|
||||||
@@ -116,6 +137,13 @@ public sealed record EffectiveLaunchSettings(
|
|||||||
int ImportTraceLimit,
|
int ImportTraceLimit,
|
||||||
bool StrictDynlibResolution,
|
bool StrictDynlibResolution,
|
||||||
bool LogToFile,
|
bool LogToFile,
|
||||||
|
string WindowMode,
|
||||||
|
string Resolution,
|
||||||
|
int DisplayIndex,
|
||||||
|
int RefreshRate,
|
||||||
|
string ScalingMode,
|
||||||
|
bool VSync,
|
||||||
|
string HdrMode,
|
||||||
IReadOnlyList<string> EnvironmentToggles)
|
IReadOnlyList<string> EnvironmentToggles)
|
||||||
{
|
{
|
||||||
public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new(
|
public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new(
|
||||||
@@ -123,5 +151,12 @@ public sealed record EffectiveLaunchSettings(
|
|||||||
perGame?.ImportTraceLimit ?? global.ImportTraceLimit,
|
perGame?.ImportTraceLimit ?? global.ImportTraceLimit,
|
||||||
perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution,
|
perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution,
|
||||||
perGame?.LogToFile ?? global.LogToFile,
|
perGame?.LogToFile ?? global.LogToFile,
|
||||||
|
perGame?.WindowMode ?? global.WindowMode,
|
||||||
|
perGame?.Resolution ?? global.Resolution,
|
||||||
|
Math.Max(0, perGame?.DisplayIndex ?? global.DisplayIndex),
|
||||||
|
Math.Clamp(perGame?.RefreshRate ?? global.RefreshRate, 0, 1000),
|
||||||
|
perGame?.ScalingMode ?? global.ScalingMode,
|
||||||
|
perGame?.VSync ?? global.VSync,
|
||||||
|
perGame?.HdrMode ?? global.HdrMode,
|
||||||
perGame?.EnvironmentToggles ?? global.EnvironmentToggles);
|
perGame?.EnvironmentToggles ?? global.EnvironmentToggles);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Avalonia;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using SharpEmu.Libs.VideoOut;
|
||||||
|
|
||||||
namespace SharpEmu.GUI;
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
@@ -12,6 +13,9 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
{
|
{
|
||||||
private static readonly string[] LogLevels =
|
private static readonly string[] LogLevels =
|
||||||
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
|
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
|
||||||
|
private static readonly string[] WindowModes = { "Windowed", "Borderless", "Exclusive" };
|
||||||
|
private static readonly string[] ScalingModes = { "Fit", "Cover", "Stretch", "Integer" };
|
||||||
|
private static readonly string[] HdrModes = { "Auto", "On", "Off" };
|
||||||
|
|
||||||
private static readonly string[] EnvToggles =
|
private static readonly string[] EnvToggles =
|
||||||
{
|
{
|
||||||
@@ -26,6 +30,8 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly string _titleId;
|
private readonly string _titleId;
|
||||||
|
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
|
||||||
|
private bool _updatingHostDisplayOptions;
|
||||||
|
|
||||||
private readonly SettingRow _logLevelRow;
|
private readonly SettingRow _logLevelRow;
|
||||||
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
|
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
|
||||||
@@ -42,6 +48,27 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
private readonly SettingRow _logToFileRow;
|
private readonly SettingRow _logToFileRow;
|
||||||
private readonly ToggleSwitch _logToFile = new();
|
private readonly ToggleSwitch _logToFile = new();
|
||||||
|
|
||||||
|
private readonly SettingRow _windowModeRow;
|
||||||
|
private readonly ComboBox _windowMode = new() { ItemsSource = WindowModes, Width = 160 };
|
||||||
|
|
||||||
|
private readonly SettingRow _resolutionRow;
|
||||||
|
private readonly ComboBox _resolution = new() { Width = 160 };
|
||||||
|
|
||||||
|
private readonly SettingRow _displayIndexRow;
|
||||||
|
private readonly ComboBox _displayIndex = new() { Width = 240 };
|
||||||
|
|
||||||
|
private readonly SettingRow _refreshRateRow;
|
||||||
|
private readonly ComboBox _refreshRate = new() { Width = 160 };
|
||||||
|
|
||||||
|
private readonly SettingRow _scalingModeRow;
|
||||||
|
private readonly ComboBox _scalingMode = new() { ItemsSource = ScalingModes, Width = 160 };
|
||||||
|
|
||||||
|
private readonly SettingRow _vsyncRow;
|
||||||
|
private readonly ToggleSwitch _vsync = new();
|
||||||
|
|
||||||
|
private readonly SettingRow _hdrModeRow;
|
||||||
|
private readonly ComboBox _hdrMode = new() { ItemsSource = HdrModes, Width = 160 };
|
||||||
|
|
||||||
private readonly SettingRow _envRow;
|
private readonly SettingRow _envRow;
|
||||||
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
|
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
|
||||||
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
|
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
|
||||||
@@ -60,13 +87,20 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
|
|
||||||
Background = new SolidColorBrush(Color.Parse("#0D1017"));
|
Background = new SolidColorBrush(Color.Parse("#0D1017"));
|
||||||
|
|
||||||
_strict.OnContent = _logToFile.OnContent = loc.Get("Common.On");
|
_strict.OnContent = _logToFile.OnContent = _vsync.OnContent = loc.Get("Common.On");
|
||||||
_strict.OffContent = _logToFile.OffContent = loc.Get("Common.Off");
|
_strict.OffContent = _logToFile.OffContent = _vsync.OffContent = loc.Get("Common.Off");
|
||||||
|
|
||||||
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
|
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
|
||||||
_traceRow = Row(loc.Get("Options.TraceImports.Label"), loc.Get("Options.TraceImports.Desc"), _trace);
|
_traceRow = Row(loc.Get("Options.TraceImports.Label"), loc.Get("Options.TraceImports.Desc"), _trace);
|
||||||
_strictRow = Row(loc.Get("Options.Strict.Label"), loc.Get("Options.Strict.Desc"), _strict);
|
_strictRow = Row(loc.Get("Options.Strict.Label"), loc.Get("Options.Strict.Desc"), _strict);
|
||||||
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
|
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
|
||||||
|
_windowModeRow = Row(loc.Get("Options.WindowMode.Label"), loc.Get("Options.WindowMode.Desc"), _windowMode);
|
||||||
|
_resolutionRow = Row(loc.Get("Options.Resolution.Label"), loc.Get("Options.Resolution.Desc"), _resolution);
|
||||||
|
_displayIndexRow = Row(loc.Get("Options.Display.Label"), loc.Get("Options.Display.Desc"), _displayIndex);
|
||||||
|
_refreshRateRow = Row(loc.Get("Options.RefreshRate.Label"), loc.Get("Options.RefreshRate.Desc"), _refreshRate);
|
||||||
|
_scalingModeRow = Row(loc.Get("Options.Scaling.Label"), loc.Get("Options.Scaling.Desc"), _scalingMode);
|
||||||
|
_vsyncRow = Row(loc.Get("Options.VSync.Label"), loc.Get("Options.VSync.Desc"), _vsync);
|
||||||
|
_hdrModeRow = Row(loc.Get("Options.Hdr.Label"), loc.Get("Options.Hdr.Desc"), _hdrMode);
|
||||||
_envRow = new SettingRow
|
_envRow = new SettingRow
|
||||||
{
|
{
|
||||||
Label = loc.Get("PerGame.EnvToggles.Label"),
|
Label = loc.Get("PerGame.EnvToggles.Label"),
|
||||||
@@ -81,6 +115,22 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
_envList.Children.Add(box);
|
_envList.Children.Add(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var general = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
|
||||||
|
general.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
|
||||||
|
general.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
|
||||||
|
general.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
|
||||||
|
|
||||||
|
var graphics = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
|
||||||
|
graphics.Children.Add(Card(
|
||||||
|
loc.Get("Options.Section.Display"),
|
||||||
|
_windowModeRow,
|
||||||
|
_resolutionRow,
|
||||||
|
_displayIndexRow,
|
||||||
|
_refreshRateRow,
|
||||||
|
_scalingModeRow,
|
||||||
|
_vsyncRow,
|
||||||
|
_hdrModeRow));
|
||||||
|
|
||||||
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
|
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
|
||||||
content.Children.Add(new TextBlock
|
content.Children.Add(new TextBlock
|
||||||
{
|
{
|
||||||
@@ -88,9 +138,14 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
|
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
|
||||||
FontSize = 12,
|
FontSize = 12,
|
||||||
});
|
});
|
||||||
content.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
|
content.Children.Add(new TabControl
|
||||||
content.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
|
{
|
||||||
content.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
|
ItemsSource = new[]
|
||||||
|
{
|
||||||
|
new TabItem { Header = loc.Get("PerGame.Tab.General"), Content = general },
|
||||||
|
new TabItem { Header = loc.Get("PerGame.Tab.Graphics"), Content = graphics },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
|
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
|
||||||
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
|
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
|
||||||
@@ -119,6 +174,8 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
root.Children.Add(buttonBar);
|
root.Children.Add(buttonBar);
|
||||||
Content = root;
|
Content = root;
|
||||||
|
|
||||||
|
_displayIndex.SelectionChanged += (_, _) => OnHostDisplayChanged();
|
||||||
|
_resolution.SelectionChanged += (_, _) => OnHostResolutionChanged();
|
||||||
LoadValues(global);
|
LoadValues(global);
|
||||||
_envRow.PropertyChanged += (_, e) =>
|
_envRow.PropertyChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
@@ -154,16 +211,38 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
|
|
||||||
private void LoadValues(GuiSettings global)
|
private void LoadValues(GuiSettings global)
|
||||||
{
|
{
|
||||||
|
var existing = PerGameSettings.Load(_titleId);
|
||||||
|
var displayIndex = Math.Max(0, existing?.DisplayIndex ?? global.DisplayIndex);
|
||||||
|
var resolution = existing?.Resolution ?? global.Resolution;
|
||||||
|
var refreshRate = Math.Clamp(existing?.RefreshRate ?? global.RefreshRate, 0, 1000);
|
||||||
|
|
||||||
|
_updatingHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_hostDisplays = HostDisplayOptions.BuildDisplays(HostDisplayCatalog.Query(), displayIndex);
|
||||||
|
_displayIndex.ItemsSource = _hostDisplays;
|
||||||
|
var display = HostDisplayOptions.SelectDisplay(_hostDisplays, displayIndex);
|
||||||
|
_displayIndex.SelectedItem = display;
|
||||||
|
PopulateHostModes(display, resolution, refreshRate);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
|
||||||
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
|
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
|
||||||
_trace.Value = global.ImportTraceLimit;
|
_trace.Value = global.ImportTraceLimit;
|
||||||
_strict.IsChecked = global.StrictDynlibResolution;
|
_strict.IsChecked = global.StrictDynlibResolution;
|
||||||
_logToFile.IsChecked = global.LogToFile;
|
_logToFile.IsChecked = global.LogToFile;
|
||||||
|
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, global.WindowMode, "Windowed");
|
||||||
|
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, global.ScalingMode, "Fit");
|
||||||
|
_vsync.IsChecked = global.VSync;
|
||||||
|
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, global.HdrMode, "Auto");
|
||||||
foreach (var (name, box) in _envBoxes)
|
foreach (var (name, box) in _envBoxes)
|
||||||
{
|
{
|
||||||
box.IsChecked = global.EnvironmentToggles.Contains(name);
|
box.IsChecked = global.EnvironmentToggles.Contains(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing = PerGameSettings.Load(_titleId);
|
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -178,6 +257,38 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
|
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
|
||||||
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
|
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
|
||||||
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
|
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
|
||||||
|
if (existing.WindowMode is { } windowMode && WindowModes.Contains(windowMode, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_windowModeRow.IsOverridden = true;
|
||||||
|
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, windowMode, "Windowed");
|
||||||
|
}
|
||||||
|
if (existing.Resolution is not null)
|
||||||
|
{
|
||||||
|
_resolutionRow.IsOverridden = true;
|
||||||
|
}
|
||||||
|
if (existing.DisplayIndex is not null)
|
||||||
|
{
|
||||||
|
_displayIndexRow.IsOverridden = true;
|
||||||
|
}
|
||||||
|
if (existing.RefreshRate is not null)
|
||||||
|
{
|
||||||
|
_refreshRateRow.IsOverridden = true;
|
||||||
|
}
|
||||||
|
if (existing.ScalingMode is { } scalingMode && ScalingModes.Contains(scalingMode, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_scalingModeRow.IsOverridden = true;
|
||||||
|
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, scalingMode, "Fit");
|
||||||
|
}
|
||||||
|
if (existing.VSync is { } vsync)
|
||||||
|
{
|
||||||
|
_vsyncRow.IsOverridden = true;
|
||||||
|
_vsync.IsChecked = vsync;
|
||||||
|
}
|
||||||
|
if (existing.HdrMode is { } hdrMode && HdrModes.Contains(hdrMode, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_hdrModeRow.IsOverridden = true;
|
||||||
|
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, hdrMode, "Auto");
|
||||||
|
}
|
||||||
if (existing.EnvironmentToggles is { } env)
|
if (existing.EnvironmentToggles is { } env)
|
||||||
{
|
{
|
||||||
_envRow.IsOverridden = true;
|
_envRow.IsOverridden = true;
|
||||||
@@ -188,6 +299,78 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ChoiceOrDefault(string[] choices, string? value, string fallback) =>
|
||||||
|
choices.FirstOrDefault(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
|
||||||
|
|
||||||
|
private void OnHostDisplayChanged()
|
||||||
|
{
|
||||||
|
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatingHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PopulateHostModes(
|
||||||
|
display,
|
||||||
|
_resolution.SelectedItem as string ?? "1920x1080",
|
||||||
|
SelectedRefreshRate());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHostResolutionChanged()
|
||||||
|
{
|
||||||
|
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedRefreshRate = SelectedRefreshRate();
|
||||||
|
_updatingHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateHostModes(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string selectedResolution,
|
||||||
|
int selectedRefreshRate)
|
||||||
|
{
|
||||||
|
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
|
||||||
|
_resolution.ItemsSource = resolutions;
|
||||||
|
_resolution.SelectedItem = resolutions.FirstOrDefault(resolution =>
|
||||||
|
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
|
||||||
|
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateRefreshRates(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string? resolution,
|
||||||
|
int selectedRefreshRate)
|
||||||
|
{
|
||||||
|
var rates = HostDisplayOptions.BuildRefreshRates(
|
||||||
|
display,
|
||||||
|
resolution,
|
||||||
|
selectedRefreshRate,
|
||||||
|
Localization.Instance.Get("Options.RefreshRate.Automatic"));
|
||||||
|
_refreshRate.ItemsSource = rates;
|
||||||
|
_refreshRate.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private int SelectedRefreshRate() =>
|
||||||
|
_refreshRate.SelectedItem is HostRefreshRateOption refreshRate ? refreshRate.Value : 0;
|
||||||
|
|
||||||
private void Persist()
|
private void Persist()
|
||||||
{
|
{
|
||||||
var settings = new PerGameSettings
|
var settings = new PerGameSettings
|
||||||
@@ -196,6 +379,15 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
|
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
|
||||||
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
|
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
|
||||||
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
|
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
|
||||||
|
WindowMode = _windowModeRow.IsOverridden ? _windowMode.SelectedItem as string : null,
|
||||||
|
Resolution = _resolutionRow.IsOverridden ? _resolution.SelectedItem as string : null,
|
||||||
|
DisplayIndex = _displayIndexRow.IsOverridden && _displayIndex.SelectedItem is HostDisplayOption display
|
||||||
|
? display.Index
|
||||||
|
: null,
|
||||||
|
RefreshRate = _refreshRateRow.IsOverridden ? SelectedRefreshRate() : null,
|
||||||
|
ScalingMode = _scalingModeRow.IsOverridden ? _scalingMode.SelectedItem as string : null,
|
||||||
|
VSync = _vsyncRow.IsOverridden ? _vsync.IsChecked == true : null,
|
||||||
|
HdrMode = _hdrModeRow.IsOverridden ? _hdrMode.SelectedItem as string : null,
|
||||||
EnvironmentToggles = _envRow.IsOverridden
|
EnvironmentToggles = _envRow.IsOverridden
|
||||||
? _envBoxes.Where(e => e.Box.IsChecked == true).Select(e => e.Name).ToList()
|
? _envBoxes.Where(e => e.Box.IsChecked == true).Select(e => e.Name).ToList()
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
the executable is started without arguments. -->
|
the executable is started without arguments. -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
<!-- Required by the source-generated LibraryImport stubs in the linked
|
|
||||||
controller readers below. -->
|
|
||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Dependency-free; provides the BuildInfo provenance shown in the
|
<!-- Dependency-free; provides the BuildInfo provenance shown in the
|
||||||
title bar. -->
|
title bar. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- The GUI owns the native presentation control while each game runs in
|
<!-- Games run in isolated SDL-window processes; the GUI owns launch and
|
||||||
an isolated emulator process. -->
|
session controls only. -->
|
||||||
|
<ProjectReference Include="..\SharpEmu.LibAtrac9\SharpEmu.LibAtrac9.csproj" />
|
||||||
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
|
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
|
||||||
<ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" />
|
<ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" />
|
||||||
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||||
|
|||||||
@@ -30,6 +30,45 @@ public sealed class GuiSettingsTests
|
|||||||
Assert.Empty(settings.GameFolders);
|
Assert.Empty(settings.GameFolders);
|
||||||
Assert.Empty(settings.ExcludedGames);
|
Assert.Empty(settings.ExcludedGames);
|
||||||
Assert.Empty(settings.EnvironmentToggles);
|
Assert.Empty(settings.EnvironmentToggles);
|
||||||
|
Assert.Equal("Windowed", settings.WindowMode);
|
||||||
|
Assert.Equal("1920x1080", settings.Resolution);
|
||||||
|
Assert.Equal("Fit", settings.ScalingMode);
|
||||||
|
Assert.Equal("Auto", settings.HdrMode);
|
||||||
|
Assert.True(settings.VSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromJson_InvalidVideoValues_FallBackAndClamp()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"WindowMode": "not-a-mode",
|
||||||
|
"Resolution": "not-a-resolution",
|
||||||
|
"ScalingMode": "nearest-ish",
|
||||||
|
"HdrMode": "maybe",
|
||||||
|
"DisplayIndex": -4,
|
||||||
|
"RefreshRate": 5000
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var settings = GuiSettings.NormalizeFromJson(json);
|
||||||
|
|
||||||
|
Assert.Equal("Windowed", settings.WindowMode);
|
||||||
|
Assert.Equal("1920x1080", settings.Resolution);
|
||||||
|
Assert.Equal("Fit", settings.ScalingMode);
|
||||||
|
Assert.Equal("Auto", settings.HdrMode);
|
||||||
|
Assert.Equal(0, settings.DisplayIndex);
|
||||||
|
Assert.Equal(1000, settings.RefreshRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromJson_CustomResolution_IsPreserved()
|
||||||
|
{
|
||||||
|
const string json = """{ "Resolution": "3440x1440" }""";
|
||||||
|
|
||||||
|
var settings = GuiSettings.NormalizeFromJson(json);
|
||||||
|
|
||||||
|
Assert.Equal("3440x1440", settings.Resolution);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -97,4 +136,44 @@ public sealed class GuiSettingsTests
|
|||||||
Assert.Empty(settings.ExcludedGames);
|
Assert.Empty(settings.ExcludedGames);
|
||||||
Assert.Empty(settings.EnvironmentToggles);
|
Assert.Empty(settings.EnvironmentToggles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EffectiveLaunchSettings_PerGameVideoValuesOverrideOnlySelectedFields()
|
||||||
|
{
|
||||||
|
var global = new GuiSettings
|
||||||
|
{
|
||||||
|
WindowMode = "Windowed",
|
||||||
|
Resolution = "1920x1080",
|
||||||
|
DisplayIndex = 1,
|
||||||
|
RefreshRate = 60,
|
||||||
|
ScalingMode = "Fit",
|
||||||
|
HdrMode = "Auto",
|
||||||
|
VSync = true,
|
||||||
|
};
|
||||||
|
var perGame = new PerGameSettings
|
||||||
|
{
|
||||||
|
Resolution = "2560x1440",
|
||||||
|
DisplayIndex = 2,
|
||||||
|
HdrMode = "On",
|
||||||
|
VSync = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
var effective = EffectiveLaunchSettings.Resolve(global, perGame);
|
||||||
|
|
||||||
|
Assert.Equal("Windowed", effective.WindowMode);
|
||||||
|
Assert.Equal("2560x1440", effective.Resolution);
|
||||||
|
Assert.Equal(2, effective.DisplayIndex);
|
||||||
|
Assert.Equal(60, effective.RefreshRate);
|
||||||
|
Assert.Equal("Fit", effective.ScalingMode);
|
||||||
|
Assert.Equal("On", effective.HdrMode);
|
||||||
|
Assert.False(effective.VSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerGameSettings_VideoOverridesParticipateInEmptyCheck()
|
||||||
|
{
|
||||||
|
Assert.True(new PerGameSettings().IsEmpty);
|
||||||
|
Assert.False(new PerGameSettings { ScalingMode = "Integer" }.IsEmpty);
|
||||||
|
Assert.False(new PerGameSettings { HdrMode = "Off" }.IsEmpty);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.GUI;
|
||||||
|
using SharpEmu.Libs.VideoOut;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Tests.GUI;
|
||||||
|
|
||||||
|
public sealed class HostDisplayOptionsTests
|
||||||
|
{
|
||||||
|
private static readonly HostDisplayInfo Display = new(
|
||||||
|
1,
|
||||||
|
"Test monitor",
|
||||||
|
[
|
||||||
|
new HostDisplayMode(2560, 1440, 144),
|
||||||
|
new HostDisplayMode(2560, 1440, 60),
|
||||||
|
new HostDisplayMode(1920, 1080, 120),
|
||||||
|
new HostDisplayMode(1920, 1080, 60),
|
||||||
|
]);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildDisplays_PreservesUnavailableSavedIndex()
|
||||||
|
{
|
||||||
|
var displays = HostDisplayOptions.BuildDisplays([Display], 3);
|
||||||
|
|
||||||
|
Assert.Equal([1, 3], displays.Select(display => display.Index));
|
||||||
|
Assert.Equal(3, HostDisplayOptions.SelectDisplay(displays, 3).Index);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildResolutions_DeduplicatesModesAndPreservesCustomValue()
|
||||||
|
{
|
||||||
|
var display = new HostDisplayOption(Display);
|
||||||
|
|
||||||
|
var resolutions = HostDisplayOptions.BuildResolutions(display, "3440x1440");
|
||||||
|
|
||||||
|
Assert.Equal(["3440x1440", "2560x1440", "1920x1080"], resolutions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRefreshRates_FiltersResolutionAndKeepsAutomaticFirst()
|
||||||
|
{
|
||||||
|
var display = new HostDisplayOption(Display);
|
||||||
|
|
||||||
|
var rates = HostDisplayOptions.BuildRefreshRates(display, "1920x1080", 75, "Automatic");
|
||||||
|
|
||||||
|
Assert.Equal([0, 120, 75, 60], rates.Select(rate => rate.Value));
|
||||||
|
Assert.Equal("Automatic", rates[0].Label);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user