diff --git a/src/SharpEmu.GUI/GameSurfaceHost.cs b/src/SharpEmu.GUI/GameSurfaceHost.cs
deleted file mode 100644
index 7e3471f5..00000000
--- a/src/SharpEmu.GUI/GameSurfaceHost.cs
+++ /dev/null
@@ -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;
-
-///
-/// 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.
-///
-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? SurfaceAvailable;
-
- public event EventHandler? SurfaceDestroyed;
-
- public VulkanHostSurface? Surface => _surface;
-
- public void RefreshSurfaceSize() => UpdateSurfaceSize();
-
- ///
- /// 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.
- ///
- public void SetPresentationVisible(bool visible)
- {
- _presentationVisible = visible;
- ApplyPresentationVisibility();
- }
-
- ///
- /// 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.
- ///
- 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(),
- 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);
-}
diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs
index a6510036..73e98be2 100644
--- a/src/SharpEmu.GUI/GuiSettings.cs
+++ b/src/SharpEmu.GUI/GuiSettings.cs
@@ -50,6 +50,20 @@ public sealed class GuiSettings
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";
+
/// Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.
public List EnvironmentToggles { get; set; } = new();
@@ -103,6 +117,12 @@ public sealed class GuiSettings
{
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;
}
@@ -118,6 +138,20 @@ public sealed class GuiSettings
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()
{
try
diff --git a/src/SharpEmu.GUI/HostDisplayOptions.cs b/src/SharpEmu.GUI/HostDisplayOptions.cs
new file mode 100644
index 00000000..ee3d1143
--- /dev/null
+++ b/src/SharpEmu.GUI/HostDisplayOptions.cs
@@ -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 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 BuildDisplays(
+ IReadOnlyList 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 displays,
+ int selectedIndex) =>
+ displays.FirstOrDefault(display => display.Index == selectedIndex) ?? displays[0];
+
+ public static IReadOnlyList 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 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 CreateFallbackModes() =>
+ [
+ new HostDisplayMode(3840, 2160, 60),
+ new HostDisplayMode(2560, 1440, 60),
+ new HostDisplayMode(1920, 1080, 60),
+ new HostDisplayMode(1280, 720, 60),
+ ];
+}
diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json
index 9d4e7ab4..ce7d8a46 100644
--- a/src/SharpEmu.GUI/Languages/en.json
+++ b/src/SharpEmu.GUI/Languages/en.json
@@ -41,6 +41,24 @@
"Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "LOGGING",
"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.Desc": "Execution engine used to run game code.",
@@ -87,6 +105,8 @@
"PerGame.Title": "Per-game settings — {0} ({1})",
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
+ "PerGame.Tab.General": "General",
+ "PerGame.Tab.Graphics": "Graphics",
"PerGame.EnvToggles.Label": "Environment toggles",
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
diff --git a/src/SharpEmu.GUI/Languages/tr.json b/src/SharpEmu.GUI/Languages/tr.json
index 89fa5ff7..5132220c 100644
--- a/src/SharpEmu.GUI/Languages/tr.json
+++ b/src/SharpEmu.GUI/Languages/tr.json
@@ -29,6 +29,24 @@
"Options.Section.Emulation": "EMÜLASYON",
"Options.Section.Logging": "GÜNLÜKLEME",
"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.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
@@ -159,6 +177,8 @@
"Common.Cancel": "İptal",
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
"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.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
"Options.About": "Hakkında",
diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml
index 9e59d23f..f60e3a10 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml
+++ b/src/SharpEmu.GUI/MainWindow.axaml
@@ -60,12 +60,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs
index a89b752f..1ce5352b 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml.cs
+++ b/src/SharpEmu.GUI/MainWindow.axaml.cs
@@ -16,7 +16,7 @@ using Avalonia.VisualTree;
using SharpEmu.Core.Cpu;
using SharpEmu.Core.Runtime;
using SharpEmu.HLE.Host;
-using SharpEmu.HLE.Host.Windows;
+using SharpEmu.Libs.Pad;
using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging;
using System.Collections.Concurrent;
@@ -62,18 +62,17 @@ public partial class MainWindow : Window
private bool _clearLibraryBlurWhenComplete;
private GuiSettings _settings = new();
+ private IReadOnlyList _hostDisplays = [];
+ private bool _updatingHostDisplayOptions;
private EmulatorProcess? _emulator;
- private GameSurfaceHost? _gameSurfaceHost;
private ConsoleWindow? _consoleWindow;
private GuiConsoleMirror? _consoleMirror;
private StreamWriter? _fileLog;
private readonly SndPreviewPlayer _sndPreview = new();
private string? _emulatorExePath;
private PendingLaunch? _pendingLaunch;
- private bool _gameFullscreen;
private bool _isRunning;
private bool _isStopping;
- private bool _awaitingFirstFrame;
private int _autoScrollTicks;
private int _activePageIndex;
private Updater.UpdateInfo? _availableUpdate;
@@ -114,7 +113,7 @@ public partial class MainWindow : Window
string EbootPath,
string DisplayName,
string? TitleId,
- string LogLevel,
+ EffectiveLaunchSettings Settings,
SharpEmuRuntimeOptions RuntimeOptions);
public MainWindow()
@@ -160,12 +159,10 @@ public partial class MainWindow : Window
// follow the launcher into the background or a minimized state.
Activated += (_, _) =>
{
- UpdateSessionBarVisibility();
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
};
Deactivated += (_, _) =>
{
- SessionBarPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = false;
};
@@ -181,8 +178,6 @@ public partial class MainWindow : Window
LaunchButton.Click += (_, _) => LaunchSelected();
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
StopButton.Click += (_, _) => StopEmulator();
- SessionStopButton.Click += (_, _) => StopEmulator();
- SessionConsoleButton.Click += (_, _) => ShowConsoleWindow();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
LibraryTabButton.Click += (_, _) => SetActivePage(0);
@@ -221,6 +216,13 @@ public partial class MainWindow : Window
};
AutoUpdateToggle.IsCheckedChanged += (_, _) =>
_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();
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
EnvBthidToggle.IsCheckedChanged += (_, _) =>
@@ -255,8 +257,7 @@ public partial class MainWindow : Window
Opened += async (_, _) => await OnOpenedAsync();
Closing += (_, _) => OnWindowClosing();
- WindowsDualSenseReader.EnsureStarted();
- WindowsXInputReader.EnsureStarted();
+ SdlLauncherGamepad.EnsureStarted();
_gamepadTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50),
@@ -427,8 +428,7 @@ public partial class MainWindow : Window
private void PollGamepad()
{
- // DualSense wins when both are connected; XInput covers Xbox pads.
- if (!WindowsDualSenseReader.TryGetState(out var pad) && !WindowsXInputReader.TryGetState(out pad))
+ if (!SdlLauncherGamepad.TryGetState(out var pad))
{
_previousPadButtons = HostGamepadButtons.None;
return;
@@ -444,9 +444,8 @@ public partial class MainWindow : Window
if (_isRunning || _isStopping)
{
- // The game renders inside the launcher window, so the launcher
- // stays active while playing. The controller belongs to the game
- // then: no navigation, and Circle/B must never stop the session.
+ // The controller belongs to the separate game window while a
+ // session is active; Circle/B must never stop the session.
_previousPadButtons = pad.Buttons;
return;
}
@@ -685,7 +684,25 @@ public partial class MainWindow : Window
AutoUpdateRow.Label = loc.Get("Updater.Auto.Label");
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.OffContent = loc.Get("Common.Off");
@@ -758,91 +775,21 @@ public partial class MainWindow : Window
private void OnKeyDown(object sender, KeyEventArgs args)
{
- args.Handled = true;
- switch (args.Key)
+ if (args.Key == Key.F11 && !_isRunning)
{
- case Key.F11:
- OnWindowFullScreen(this, new RoutedEventArgs());
- break;
- default:
- args.Handled = false;
- break;
+ WindowState = WindowState == WindowState.FullScreen
+ ? WindowState.Maximized
+ : WindowState.FullScreen;
+ args.Handled = true;
}
}
private void OnPreviewKeyDown(object? sender, KeyEventArgs args)
{
- // While a session is on screen, Enter and Space are game input
- // (Cross button). Keyboard focus stays on the launcher window, so a
- // previously clicked, still-focused button (console toggle, session
- // bar) would also activate and reshape the game view. Swallow the
- // 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);
+ // The session runs in its own SDL window and takes keyboard focus with
+ // it, so launcher buttons no longer see game input and nothing has to
+ // be swallowed here. Kept as the wired handler because the launcher
+ // still needs a preview hook for its own shortcuts.
}
private void OnWindowClosing()
@@ -851,6 +798,7 @@ public partial class MainWindow : Window
_consoleFlushTimer.Stop();
_libraryBlurTimer.Stop();
_gamepadTimer.Stop();
+ SdlLauncherGamepad.Shutdown();
_sndPreview.Stop();
_discord?.Dispose();
_consoleWindow?.Close();
@@ -903,9 +851,139 @@ public partial class MainWindow : Window
EnvLogDirectMemoryToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_DIRECT_MEMORY");
EnvLogIoToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_IO");
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();
}
+ 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()
{
if (_availableUpdate is null)
@@ -1828,7 +1906,6 @@ public partial class MainWindow : Window
_isRunning = true;
_runningGameName = displayName;
- SessionGameTitle.Text = displayName;
_runningGameTitleId = resolvedTitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush;
@@ -1837,18 +1914,15 @@ public partial class MainWindow : Window
UpdateRunButtons();
UpdateDiscordPresence();
- ShowGameView();
+ BeginSessionUi();
_pendingLaunch = new PendingLaunch(
Path.GetFullPath(ebootPath),
displayName,
_runningGameTitleId,
- effective.LogLevel,
+ effective,
runtimeOptions);
- if (_gameSurfaceHost?.Surface is { } surface)
- {
- StartPendingSession(surface);
- }
+ StartPendingSession();
}
///
@@ -1877,9 +1951,6 @@ public partial class MainWindow : Window
_isStopping = true;
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...");
_emulator.Stop();
_runningGameName = null;
@@ -1887,7 +1958,6 @@ public partial class MainWindow : Window
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence();
- UpdateSessionBarVisibility();
ReturnToLibraryWhileStopping();
}
@@ -1930,8 +2000,7 @@ public partial class MainWindow : Window
_emulator?.Dispose();
_emulator = null;
_pendingLaunch = null;
- DisposeGameSurfaceHost();
- HideGameView();
+ EndSessionUi();
var meaningKey = exitCode switch
{
@@ -1964,7 +2033,7 @@ public partial class MainWindow : Window
UpdateDiscordPresence();
}
- private void StartPendingSession(VulkanHostSurface surface)
+ private void StartPendingSession()
{
if (_pendingLaunch is not { } launch || _emulator is not null)
{
@@ -1984,7 +2053,7 @@ public partial class MainWindow : Window
try
{
- var arguments = BuildEmulatorArguments(launch, surface);
+ var arguments = BuildEmulatorArguments(launch);
_emulator = process;
_pendingLaunch = null;
process.Start(
@@ -2006,12 +2075,12 @@ public partial class MainWindow : Window
}
}
- private List BuildEmulatorArguments(PendingLaunch launch, VulkanHostSurface surface)
+ private List BuildEmulatorArguments(PendingLaunch launch)
{
var arguments = new List
{
"--cpu-engine=native",
- $"--log-level={launch.LogLevel}",
+ $"--log-level={launch.Settings.LogLevel}",
};
if (launch.RuntimeOptions.StrictDynlibResolution)
{
@@ -2022,16 +2091,13 @@ public partial class MainWindow : Window
arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}");
}
- if (surface.TryGetChildProcessDescriptor(out var descriptor))
- {
- arguments.Add($"--host-surface={descriptor}");
- }
- else
- {
- AppendConsoleLine(
- "[GUI][WARN] Embedded child surfaces are unavailable on this platform; opening a game window instead.",
- WarningLineBrush);
- }
+ arguments.Add($"--window-mode={launch.Settings.WindowMode.ToLowerInvariant()}");
+ arguments.Add($"--resolution={launch.Settings.Resolution}");
+ arguments.Add($"--display={launch.Settings.DisplayIndex}");
+ arguments.Add($"--refresh-rate={launch.Settings.RefreshRate}");
+ arguments.Add($"--scaling={launch.Settings.ScalingMode.ToLowerInvariant()}");
+ arguments.Add($"--vsync={(launch.Settings.VSync ? "on" : "off")}");
+ arguments.Add($"--hdr={launch.Settings.HdrMode.ToLowerInvariant()}");
arguments.Add(launch.EbootPath);
return arguments;
@@ -2040,8 +2106,8 @@ public partial class MainWindow : Window
private void OnEmulatorOutput(string line, bool isError)
{
_pendingLines.Enqueue((line, isError));
- if (!line.Contains("[VIDEOOUT][INFO] Hosted splash ready.", StringComparison.Ordinal) &&
- !line.Contains("[VIDEOOUT][INFO] Hosted first frame presented.", StringComparison.Ordinal))
+ if (!line.Contains("Vulkan VideoOut presented first frame:", StringComparison.Ordinal) &&
+ !line.Contains("Vulkan VideoOut ready:", StringComparison.Ordinal))
{
return;
}
@@ -2050,143 +2116,25 @@ public partial class MainWindow : Window
{
if (_isRunning && !_isStopping)
{
- _awaitingFirstFrame = false;
- 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);
- });
+ ShowSessionStatus("Game is running");
}
});
}
- private GameSurfaceHost EnsureGameSurfaceHost()
- {
- 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();
- }
- }
-
- ///
- /// 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.
- ///
- private void ParkGameViewOffscreen()
- {
- GameView.Margin = new Thickness(-20000, 0, 20000, 0);
- }
-
- private void RestoreGameViewToFull()
- {
- GameView.Margin = new Thickness(0);
- }
-
- private void ShowGameView()
+ private void BeginSessionUi()
{
_isStopping = false;
- _awaitingFirstFrame = true;
- var host = EnsureGameSurfaceHost();
- ParkGameViewOffscreen();
- GameView.IsVisible = true;
- GameView.Background = Brushes.Transparent;
- GameView.IsHitTestVisible = false;
- host.SetPresentationVisible(false);
AnimateLibraryBlur(LaunchBlurRadius);
- SessionHintText.Text = "Fullscreen";
- SessionF11Badge.IsVisible = true;
- UpdateSessionBarVisibility();
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();
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;
- LibraryPage.IsVisible = _activePageIndex == 0;
- 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;
+ ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
}
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
@@ -2258,7 +2206,20 @@ public partial class MainWindow : Window
private void ShowSessionLoading(string title, string detail)
{
SessionLoadingTitle.Text = title;
+ SessionLoadingTitle.IsVisible = true;
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;
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
}
@@ -2271,33 +2232,11 @@ public partial class MainWindow : Window
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);
- MainContent.Margin = new Thickness(32, 24, 32, 20);
- ContentToolbar.IsVisible = true;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
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();
- 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)
@@ -2356,16 +2295,9 @@ public partial class MainWindow : Window
{
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
StopButton.IsEnabled = _isRunning && !_isStopping;
- SessionStopButton.IsEnabled = _isRunning && !_isStopping;
OpenFileButton.IsEnabled = !_isRunning;
}
- private void UpdateSessionBarVisibility()
- {
- SessionBarPopup.IsOpen = _isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible &&
- !_gameFullscreen && WindowState != WindowState.FullScreen;
- }
-
// ---- Console ----
private void FlushPendingConsoleLines()
diff --git a/src/SharpEmu.GUI/PerGameSettings.cs b/src/SharpEmu.GUI/PerGameSettings.cs
index 7023d778..ca932e3c 100644
--- a/src/SharpEmu.GUI/PerGameSettings.cs
+++ b/src/SharpEmu.GUI/PerGameSettings.cs
@@ -21,6 +21,20 @@ public sealed class PerGameSettings
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? EnvironmentToggles { get; set; }
[JsonIgnore]
@@ -29,6 +43,13 @@ public sealed class PerGameSettings
ImportTraceLimit is null &&
StrictDynlibResolution 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;
public static string DirectoryPath =>
@@ -116,6 +137,13 @@ public sealed record EffectiveLaunchSettings(
int ImportTraceLimit,
bool StrictDynlibResolution,
bool LogToFile,
+ string WindowMode,
+ string Resolution,
+ int DisplayIndex,
+ int RefreshRate,
+ string ScalingMode,
+ bool VSync,
+ string HdrMode,
IReadOnlyList EnvironmentToggles)
{
public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new(
@@ -123,5 +151,12 @@ public sealed record EffectiveLaunchSettings(
perGame?.ImportTraceLimit ?? global.ImportTraceLimit,
perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution,
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);
}
diff --git a/src/SharpEmu.GUI/PerGameSettingsDialog.cs b/src/SharpEmu.GUI/PerGameSettingsDialog.cs
index fa6bde4c..c06850fb 100644
--- a/src/SharpEmu.GUI/PerGameSettingsDialog.cs
+++ b/src/SharpEmu.GUI/PerGameSettingsDialog.cs
@@ -5,6 +5,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
+using SharpEmu.Libs.VideoOut;
namespace SharpEmu.GUI;
@@ -12,6 +13,9 @@ public sealed class PerGameSettingsDialog : Window
{
private static readonly string[] LogLevels =
{ "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 =
{
@@ -26,6 +30,8 @@ public sealed class PerGameSettingsDialog : Window
};
private readonly string _titleId;
+ private IReadOnlyList _hostDisplays = [];
+ private bool _updatingHostDisplayOptions;
private readonly SettingRow _logLevelRow;
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
@@ -42,6 +48,27 @@ public sealed class PerGameSettingsDialog : Window
private readonly SettingRow _logToFileRow;
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 StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
@@ -60,13 +87,20 @@ public sealed class PerGameSettingsDialog : Window
Background = new SolidColorBrush(Color.Parse("#0D1017"));
- _strict.OnContent = _logToFile.OnContent = loc.Get("Common.On");
- _strict.OffContent = _logToFile.OffContent = loc.Get("Common.Off");
+ _strict.OnContent = _logToFile.OnContent = _vsync.OnContent = loc.Get("Common.On");
+ _strict.OffContent = _logToFile.OffContent = _vsync.OffContent = loc.Get("Common.Off");
_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);
_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);
+ _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
{
Label = loc.Get("PerGame.EnvToggles.Label"),
@@ -81,6 +115,22 @@ public sealed class PerGameSettingsDialog : Window
_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) };
content.Children.Add(new TextBlock
{
@@ -88,9 +138,14 @@ public sealed class PerGameSettingsDialog : Window
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
FontSize = 12,
});
- content.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
- content.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
- content.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
+ content.Children.Add(new TabControl
+ {
+ 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 cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
@@ -119,6 +174,8 @@ public sealed class PerGameSettingsDialog : Window
root.Children.Add(buttonBar);
Content = root;
+ _displayIndex.SelectionChanged += (_, _) => OnHostDisplayChanged();
+ _resolution.SelectionChanged += (_, _) => OnHostResolutionChanged();
LoadValues(global);
_envRow.PropertyChanged += (_, e) =>
{
@@ -154,16 +211,38 @@ public sealed class PerGameSettingsDialog : Window
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";
_trace.Value = global.ImportTraceLimit;
_strict.IsChecked = global.StrictDynlibResolution;
_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)
{
box.IsChecked = global.EnvironmentToggles.Contains(name);
}
- var existing = PerGameSettings.Load(_titleId);
if (existing is null)
{
return;
@@ -178,6 +257,38 @@ public sealed class PerGameSettingsDialog : Window
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
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)
{
_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()
{
var settings = new PerGameSettings
@@ -196,6 +379,15 @@ public sealed class PerGameSettingsDialog : Window
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.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
? _envBoxes.Where(e => e.Box.IsChecked == true).Select(e => e.Name).ToList()
: null,
diff --git a/src/SharpEmu.GUI/SharpEmu.GUI.csproj b/src/SharpEmu.GUI/SharpEmu.GUI.csproj
index 740bc0b2..16363611 100644
--- a/src/SharpEmu.GUI/SharpEmu.GUI.csproj
+++ b/src/SharpEmu.GUI/SharpEmu.GUI.csproj
@@ -9,16 +9,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
the executable is started without arguments. -->
false
-
true
-
+
+
diff --git a/tests/SharpEmu.Libs.Tests/GUI/GuiSettingsTests.cs b/tests/SharpEmu.Libs.Tests/GUI/GuiSettingsTests.cs
index baac02e2..ebfb7d11 100644
--- a/tests/SharpEmu.Libs.Tests/GUI/GuiSettingsTests.cs
+++ b/tests/SharpEmu.Libs.Tests/GUI/GuiSettingsTests.cs
@@ -30,6 +30,45 @@ public sealed class GuiSettingsTests
Assert.Empty(settings.GameFolders);
Assert.Empty(settings.ExcludedGames);
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]
@@ -97,4 +136,44 @@ public sealed class GuiSettingsTests
Assert.Empty(settings.ExcludedGames);
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);
+ }
}
diff --git a/tests/SharpEmu.Libs.Tests/GUI/HostDisplayOptionsTests.cs b/tests/SharpEmu.Libs.Tests/GUI/HostDisplayOptionsTests.cs
new file mode 100644
index 00000000..67e001c6
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/GUI/HostDisplayOptionsTests.cs
@@ -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);
+ }
+}