From 94b1fb3b5a47e59aa45c4e46a37c7e304371ec83 Mon Sep 17 00:00:00 2001 From: ParantezTech <12572227+par274@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:05:43 +0300 Subject: [PATCH] [gui] added host display options and per-game render settings --- src/SharpEmu.GUI/GameSurfaceHost.cs | 620 ------------------ src/SharpEmu.GUI/GuiSettings.cs | 34 + src/SharpEmu.GUI/HostDisplayOptions.cs | 138 ++++ src/SharpEmu.GUI/Languages/en.json | 20 + src/SharpEmu.GUI/Languages/tr.json | 20 + src/SharpEmu.GUI/MainWindow.axaml | 96 ++- src/SharpEmu.GUI/MainWindow.axaml.cs | 488 ++++++-------- src/SharpEmu.GUI/PerGameSettings.cs | 35 + src/SharpEmu.GUI/PerGameSettingsDialog.cs | 204 +++++- src/SharpEmu.GUI/SharpEmu.GUI.csproj | 7 +- .../GUI/GuiSettingsTests.cs | 79 +++ .../GUI/HostDisplayOptionsTests.cs | 51 ++ 12 files changed, 831 insertions(+), 961 deletions(-) delete mode 100644 src/SharpEmu.GUI/GameSurfaceHost.cs create mode 100644 src/SharpEmu.GUI/HostDisplayOptions.cs create mode 100644 tests/SharpEmu.Libs.Tests/GUI/HostDisplayOptionsTests.cs 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 - - - - - - - - - - - - - - - - - - - - - - - -