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 =