diff --git a/src/SharpEmu.CLI/app.manifest b/src/SharpEmu.CLI/app.manifest index 61dfc52d..8b7d5f4b 100644 --- a/src/SharpEmu.CLI/app.manifest +++ b/src/SharpEmu.CLI/app.manifest @@ -13,4 +13,9 @@ SPDX-License-Identifier: GPL-2.0-or-later + + + PerMonitorV2 + + diff --git a/src/SharpEmu.GUI/GameSurfaceHost.cs b/src/SharpEmu.GUI/GameSurfaceHost.cs index 0cba545c..7e3471f5 100644 --- a/src/SharpEmu.GUI/GameSurfaceHost.cs +++ b/src/SharpEmu.GUI/GameSurfaceHost.cs @@ -351,6 +351,13 @@ public sealed class GameSurfaceHost : NativeControlHost 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) diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs index fae06814..a6510036 100644 --- a/src/SharpEmu.GUI/GuiSettings.cs +++ b/src/SharpEmu.GUI/GuiSettings.cs @@ -53,6 +53,9 @@ public sealed class GuiSettings /// Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch. public List EnvironmentToggles { get; set; } = new(); + /// Internal render resolution scale (1.0 = native, 0.5 = half). + public double RenderResolutionScale { get; set; } = 1.0; + /// /// Discord application ID used for Rich Presence; the default is the /// SharpEmu application. Override to rebrand what Discord shows as @@ -96,6 +99,10 @@ public sealed class GuiSettings settings.LogLevel ??= "Info"; settings.Language ??= "en"; settings.DiscordClientId ??= "1525606762248540221"; + if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0) + { + settings.RenderResolutionScale = 1.0; + } return settings; } diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml index 16baeec5..6eb7bf18 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml +++ b/src/SharpEmu.GUI/MainWindow.axaml @@ -400,6 +400,29 @@ 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 4a212edb..136f20a4 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml.cs +++ b/src/SharpEmu.GUI/MainWindow.axaml.cs @@ -192,6 +192,18 @@ public partial class MainWindow : Window // it is open already uses the new values. LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel(); TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0); + RenderResolutionBox.SelectionChanged += (_, _) => + { + if (RenderResolutionBox.SelectedItem is ComboBoxItem { Tag: string tag } && + double.TryParse( + tag, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var scale)) + { + _settings.RenderResolutionScale = scale; + } + }; StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true; LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true; OverrideLogFileToggle.IsCheckedChanged += (_, _) => @@ -869,6 +881,13 @@ public partial class MainWindow : Window _ => 2, }; TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096); + RenderResolutionBox.SelectedIndex = _settings.RenderResolutionScale switch + { + >= 0.875 => 0, + >= 0.625 => 1, + >= 0.375 => 2, + _ => 3, + }; StrictToggle.IsChecked = _settings.StrictDynlibResolution; LogToFileToggle.IsChecked = _settings.LogToFile; OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile; @@ -1788,6 +1807,12 @@ public partial class MainWindow : Window _appliedEnvironmentVariables.Add(name); } + Environment.SetEnvironmentVariable( + "SHARPEMU_RENDER_SCALE", + _settings.RenderResolutionScale.ToString( + "0.###", + System.Globalization.CultureInfo.InvariantCulture)); + if (SharpEmuLog.TryParseLevel(effective.LogLevel, out var logLevel)) { SharpEmuLog.MinimumLevel = logLevel; @@ -2030,8 +2055,6 @@ public partial class MainWindow : Window RestoreGameViewToFull(); GameView.Background = Brushes.Black; GameView.IsHitTestVisible = true; - _gameSurfaceHost?.SetPresentationVisible(true); - _gameSurfaceHost?.SetCursorAutoHide(true); LibraryPage.IsVisible = false; OptionsPage.IsVisible = false; LibraryToolbar.IsVisible = false; @@ -2040,6 +2063,19 @@ public partial class MainWindow : Window 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); + }); } }); } diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index 565fe000..a7252d8c 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -3088,6 +3088,9 @@ internal static unsafe class VulkanVideoPresenter public ulong WriteAddress; public uint Width; public uint Height; + // Unscaled guest-requested size; Width/Height are the physical (scaled) backing size. + public uint LogicalWidth; + public uint LogicalHeight; public uint GuestFormat; public uint SwizzleMode; public Image Image; @@ -3117,6 +3120,9 @@ internal static unsafe class VulkanVideoPresenter public long FlipVersion; public uint Width; public uint Height; + // Unscaled guest-requested size; Width/Height are the physical (scaled) backing size. + public uint LogicalWidth; + public uint LogicalHeight; public uint MipLevels; public uint GuestFormat; public Format Format; @@ -7116,8 +7122,8 @@ internal static unsafe class VulkanVideoPresenter // but it must not make a differently sized alias outrank the image // that the texture descriptor actually names. var score = 0; - if (candidate.Width == texture.Width && - candidate.Height == texture.Height) + if (candidate.LogicalWidth == texture.Width && + candidate.LogicalHeight == texture.Height) { score += 32; } @@ -7202,7 +7208,7 @@ internal static unsafe class VulkanVideoPresenter continue; } - if (texture.Width > depth.Width || texture.Height > depth.Height) + if (texture.Width > depth.LogicalWidth || texture.Height > depth.LogicalHeight) { continue; } @@ -7412,8 +7418,8 @@ internal static unsafe class VulkanVideoPresenter GuestDrawTexture texture, GuestImageResource guestImage) { - if (guestImage.Width == texture.Width && - guestImage.Height == texture.Height) + if (guestImage.LogicalWidth == texture.Width && + guestImage.LogicalHeight == texture.Height) { return true; } @@ -7425,8 +7431,8 @@ internal static unsafe class VulkanVideoPresenter return false; } - return texture.Width <= guestImage.Width && - texture.Height <= guestImage.Height; + return texture.Width <= guestImage.LogicalWidth && + texture.Height <= guestImage.LogicalHeight; } [MethodImpl(MethodImplOptions.NoInlining)] @@ -9161,11 +9167,19 @@ internal static unsafe class VulkanVideoPresenter private static GuestRect ClampScissor(GuestRect? scissor, Extent2D extent) { - if (scissor is not { } rect) + if (scissor is not { } guestRect) { return new GuestRect(0, 0, extent.Width, extent.Height); } + var rect = _renderResolutionScale == 1.0 + ? guestRect + : new GuestRect( + (int)Math.Round(guestRect.X * _renderResolutionScale), + (int)Math.Round(guestRect.Y * _renderResolutionScale), + Math.Max(1u, (uint)Math.Round(guestRect.Width * _renderResolutionScale)), + Math.Max(1u, (uint)Math.Round(guestRect.Height * _renderResolutionScale))); + var left = Math.Clamp(rect.X, 0, checked((int)extent.Width)); var top = Math.Clamp(rect.Y, 0, checked((int)extent.Height)); var right = Math.Clamp( @@ -9193,11 +9207,22 @@ internal static unsafe class VulkanVideoPresenter private static Viewport ClampViewport(GuestViewport? viewport, Extent2D extent) { - if (viewport is not { } rect) + if (viewport is not { } guestRect) { return new Viewport(0, 0, extent.Width, extent.Height, 0, 1); } + var scale = (float)_renderResolutionScale; + var rect = scale == 1f + ? guestRect + : guestRect with + { + X = guestRect.X * scale, + Y = guestRect.Y * scale, + Width = guestRect.Width * scale, + Height = guestRect.Height * scale, + }; + // Do NOT trim the rectangle to the render target: Vulkan allows // viewports that extend beyond the framebuffer (rendering is // confined by the scissor), and trimming changes the guest's @@ -10513,10 +10538,11 @@ internal static unsafe class VulkanVideoPresenter draw.RenderState.Depth) && work.DepthTarget is { } depthTarget) { + // Logical dims: GetOrCreateGuestDepth below scales itself. var resolution = GuestDepthExtentResolver.Resolve( depthTarget, - firstTarget.Width, - firstTarget.Height, + firstTarget.LogicalWidth, + firstTarget.LogicalHeight, draw.Textures); var effectiveDepthTarget = resolution.IsUsable && (resolution.Width != depthTarget.Width || @@ -11216,7 +11242,14 @@ internal static unsafe class VulkanVideoPresenter $"address 0x{target.Address:X16}."); } - var mipLevels = ClampMipLevels(target.Width, target.Height, target.MipLevels); + // Storage/UAV images keep native guest dimensions (compute shaders index them directly). + var physicalWidth = requiresStorage + ? target.Width + : ScaleGuestDimension(target.Width); + var physicalHeight = requiresStorage + ? target.Height + : ScaleGuestDimension(target.Height); + var mipLevels = ClampMipLevels(physicalWidth, physicalHeight, target.MipLevels); var guestFormat = GetGuestTextureFormat(target.Format, target.NumberType); var requestedKey = new GuestImageVariantKey( target.Address, @@ -11227,8 +11260,8 @@ internal static unsafe class VulkanVideoPresenter format); if (_guestImages.TryGetValue(target.Address, out var existing)) { - if (existing.Width == target.Width && - existing.Height == target.Height && + if (existing.LogicalWidth == target.Width && + existing.LogicalHeight == target.Height && existing.MipLevels == mipLevels && existing.GuestFormat == guestFormat && existing.Format == format) @@ -11278,8 +11311,8 @@ internal static unsafe class VulkanVideoPresenter _guestImageVariants.Add( new GuestImageVariantKey( existing.Address, - existing.Width, - existing.Height, + existing.LogicalWidth, + existing.LogicalHeight, existing.MipLevels, existing.GuestFormat, existing.Format), @@ -11348,7 +11381,7 @@ internal static unsafe class VulkanVideoPresenter ImageCreateFlags.CreateExtendedUsageBit, ImageType = ImageType.Type2D, Format = format, - Extent = new Extent3D(target.Width, target.Height, 1), + Extent = new Extent3D(physicalWidth, physicalHeight, 1), MipLevels = mipLevels, ArrayLayers = 1, Samples = SampleCountFlags.Count1Bit, @@ -11421,15 +11454,17 @@ internal static unsafe class VulkanVideoPresenter CreateRenderPassAndFramebuffer( format, mipViews[0], - target.Width, - target.Height); + physicalWidth, + physicalHeight); } var resource = new GuestImageResource { Address = target.Address, - Width = target.Width, - Height = target.Height, + Width = physicalWidth, + Height = physicalHeight, + LogicalWidth = target.Width, + LogicalHeight = target.Height, MipLevels = mipLevels, GuestFormat = guestFormat, Format = format, @@ -11685,15 +11720,19 @@ internal static unsafe class VulkanVideoPresenter return existing; } - var (image, memory, view) = CreateDepthAttachment(target.Width, target.Height); + var physicalWidth = ScaleGuestDimension(target.Width); + var physicalHeight = ScaleGuestDimension(target.Height); + var (image, memory, view) = CreateDepthAttachment(physicalWidth, physicalHeight); var resource = new GuestDepthResource { Key = key, Address = target.Address, ReadAddress = target.ReadAddress, WriteAddress = target.WriteAddress, - Width = target.Width, - Height = target.Height, + Width = physicalWidth, + Height = physicalHeight, + LogicalWidth = target.Width, + LogicalHeight = target.Height, GuestFormat = target.GuestFormat, SwizzleMode = target.SwizzleMode, Image = image, @@ -14153,6 +14192,22 @@ internal static unsafe class VulkanVideoPresenter !string.IsNullOrWhiteSpace( Environment.GetEnvironmentVariable( "SHARPEMU_TRACE_GUEST_IMAGE_ADDRS")); + private static readonly double _renderResolutionScale = + double.TryParse( + Environment.GetEnvironmentVariable("SHARPEMU_RENDER_SCALE"), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var renderResolutionScale) && + renderResolutionScale > 0 && + renderResolutionScale <= 2.0 + ? renderResolutionScale + : 1.0; + + private static uint ScaleGuestDimension(uint value) => + _renderResolutionScale == 1.0 + ? value + : Math.Max(1u, (uint)Math.Round(value * _renderResolutionScale)); + private static readonly bool _forceFullscreenPipeline = Environment.GetEnvironmentVariable("SHARPEMU_FORCE_FULLSCREEN_PIPELINE") == "1"; private static readonly bool _forceFullscreenVertex =