diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index 2c88471..4d93c62 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -1011,7 +1011,7 @@ internal static partial class Program private static void PrintUsage() { - Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--console=] [--console-language=] [--log-level=] [--log-file[=]] [--window-mode=] [--resolution=] [--display=] [--refresh-rate=] [--scaling=] [--vsync=] [--hdr=] [--overlay=] [--overlay-mode=] [--overlay-corner=] [--debug-server[=host:port]] "); + Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--console=] [--console-language=] [--log-level=] [--log-file[=]] [--window-mode=] [--resolution=] [--guest-resolution=<1920x1080|3840x2160>] [--display=] [--refresh-rate=] [--scaling=] [--vsync=] [--hdr=] [--overlay=] [--overlay-mode=] [--overlay-corner=] [--debug-server[=host:port]] "); Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin"""); Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient."); } @@ -1076,6 +1076,7 @@ internal static partial class Program var systemLanguage = 1; HostWindowMode? windowModeOverride = null; HostScalingMode? scalingModeOverride = null; + GuestDisplayResolution? guestResolutionOverride = null; int? windowWidthOverride = null; int? windowHeightOverride = null; int? displayIndexOverride = null; @@ -1135,6 +1136,18 @@ internal static partial class Program windowHeightOverride = windowHeight; continue; } + if (TrySplitOption(argument, "--guest-resolution", out var guestResolutionText)) + { + if (guestResolutionText is not ("1920x1080" or "3840x2160")) + { + ebootPath = string.Empty; + runtimeOptions = default; + return false; + } + guestResolutionOverride = guestResolutionText == "3840x2160" + ? GuestDisplayResolution.UltraHd : GuestDisplayResolution.Hd; + continue; + } if (TrySplitOption(argument, "--display", out var displayText)) { if (!int.TryParse(displayText, out var displayIndex) || displayIndex < 0) @@ -1392,6 +1405,7 @@ internal static partial class Program { WindowMode = windowModeOverride ?? configuredVideoOptions.WindowMode, ScalingMode = scalingModeOverride ?? configuredVideoOptions.ScalingMode, + GuestResolution = guestResolutionOverride ?? configuredVideoOptions.GuestResolution, Width = windowWidthOverride ?? configuredVideoOptions.Width, Height = windowHeightOverride ?? configuredVideoOptions.Height, DisplayIndex = displayIndexOverride ?? configuredVideoOptions.DisplayIndex, @@ -1432,6 +1446,8 @@ internal static partial class Program { WindowMode = windowMode, ScalingMode = scalingMode, + GuestResolution = effective.GuestResolution == "3840x2160" + ? GuestDisplayResolution.UltraHd : GuestDisplayResolution.Hd, Width = hasResolution ? configuredWidth : defaults.Width, Height = hasResolution ? configuredHeight : defaults.Height, DisplayIndex = effective.DisplayIndex, diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs index 1e48bba..54655a1 100644 --- a/src/SharpEmu.GUI/GuiSettings.cs +++ b/src/SharpEmu.GUI/GuiSettings.cs @@ -70,6 +70,11 @@ public sealed class GuiSettings public string Resolution { get; set; } = "1920x1080"; + public string GuestResolution { get; set; } = "1920x1080"; + + internal static string NormalizeGuestResolution(string? value) => + string.Equals(value, "3840x2160", StringComparison.OrdinalIgnoreCase) ? "3840x2160" : "1920x1080"; + public int DisplayIndex { get; set; } public int RefreshRate { get; set; } @@ -89,9 +94,6 @@ public sealed class GuiSettings public string BinkPlaybackMode { get; set; } = "Guest"; - /// 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 @@ -184,6 +186,7 @@ public sealed class GuiSettings "Ukrainian"); settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive"); settings.Resolution = NormalizeResolution(settings.Resolution); + settings.GuestResolution = NormalizeGuestResolution(settings.GuestResolution); settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer"); settings.HdrMode = NormalizeChoice(settings.HdrMode, "Auto", "On", "Off"); settings.BinkPlaybackMode = NormalizeChoice(settings.BinkPlaybackMode, "Guest", "Host", "Skip"); diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json index cedc742..e12e4df 100644 --- a/src/SharpEmu.GUI/Languages/en.json +++ b/src/SharpEmu.GUI/Languages/en.json @@ -85,6 +85,8 @@ "Options.WindowMode.Exclusive": "Exclusive", "Options.Resolution.Label": "Resolution", "Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.", + "Options.GuestResolution.Label": "Game display mode", + "Options.GuestResolution.Desc": "Display resolution reported to the game. Defaults to 1080p. Games choose their internal rendering resolution; fixed-resolution games may ignore this setting.", "Options.Display.Label": "Display", "Options.Display.Desc": "Monitor used for centering and fullscreen.", "Options.RefreshRate.Label": "Refresh rate", diff --git a/src/SharpEmu.GUI/MainWindow.GameOptions.cs b/src/SharpEmu.GUI/MainWindow.GameOptions.cs index 2f42710..f441fa8 100644 --- a/src/SharpEmu.GUI/MainWindow.GameOptions.cs +++ b/src/SharpEmu.GUI/MainWindow.GameOptions.cs @@ -45,6 +45,7 @@ public partial class MainWindow GameLogLevelBox.ItemsSource = _logLevelChoices; GameWindowModeBox.ItemsSource = _windowModeChoices; GameScalingModeBox.ItemsSource = _scalingModeChoices; + GameGuestResolutionBox.ItemsSource = new[] { "1920x1080", "3840x2160" }; GameHdrModeBox.ItemsSource = _hdrModeChoices; GameOverlayModeBox.ItemsSource = _overlayModeChoices; GameOverlayCornerBox.ItemsSource = _overlayCornerChoices; @@ -115,6 +116,7 @@ public partial class MainWindow GameRefreshRateBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); GameScalingModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); GameVSyncToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings(); + GameGuestResolutionBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); GameHdrModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); GameOverlayEnabledToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings(); GameOverlayModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); @@ -216,6 +218,7 @@ public partial class MainWindow _scalingModeChoices, effective.ScalingMode, "Fit"); + GameGuestResolutionBox.SelectedItem = effective.GuestResolution; GameVSyncToggle.IsChecked = effective.VSync; GameOverlayEnabledToggle.IsChecked = effective.OverlayEnabled; GameOverlayModeBox.SelectedItem = FindChoice(_overlayModeChoices, effective.OverlayMode, "TitleBar"); @@ -277,6 +280,7 @@ public partial class MainWindow LogToFile = GameLogToFileToggle.IsChecked == true, WindowMode = SelectedComboText(GameWindowModeBox, "Windowed"), Resolution = SelectedComboText(GameResolutionBox, "1920x1080"), + GuestResolution = SelectedComboText(GameGuestResolutionBox, "1920x1080"), DisplayIndex = GameDisplayBox.SelectedItem is HostDisplayOption display ? display.Index : 0, diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml index 518bb78..9581eb3 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml +++ b/src/SharpEmu.GUI/MainWindow.axaml @@ -856,6 +856,14 @@ SPDX-License-Identifier: GPL-2.0-or-later VerticalAlignment="Center" /> + + + + + + _settings.WindowMode = SelectedComboText(WindowModeBox, "Windowed"); DisplayBox.SelectionChanged += (_, _) => OnHostDisplayChanged(); ResolutionBox.SelectionChanged += (_, _) => OnHostResolutionChanged(); + GuestResolutionBox.SelectionChanged += (_, _) => _settings.GuestResolution = SelectedComboText(GuestResolutionBox, "1920x1080"); RefreshRateBox.SelectionChanged += (_, _) => OnHostRefreshRateChanged(); ScalingModeBox.SelectionChanged += (_, _) => _settings.ScalingMode = SelectedComboText(ScalingModeBox, "Fit"); VSyncToggle.IsCheckedChanged += (_, _) => _settings.VSync = VSyncToggle.IsChecked == true; @@ -1249,6 +1250,7 @@ public partial class MainWindow : Window LogLevelBox.ItemsSource = _logLevelChoices; WindowModeBox.ItemsSource = _windowModeChoices; ScalingModeBox.ItemsSource = _scalingModeChoices; + GuestResolutionBox.ItemsSource = new[] { "1920x1080", "3840x2160" }; HdrModeBox.ItemsSource = _hdrModeChoices; BinkPlaybackBox.ItemsSource = _binkPlaybackChoices; OverlayModeBox.ItemsSource = _overlayModeChoices; @@ -1324,6 +1326,7 @@ public partial class MainWindow : Window LoadHostDisplayOptions(); ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer"); VSyncToggle.IsChecked = _settings.VSync; + GuestResolutionBox.SelectedItem = GuiSettings.NormalizeGuestResolution(_settings.GuestResolution); HdrModeBox.SelectedIndex = ChoiceIndex(_settings.HdrMode, "Auto", "On", "Off"); OverlayEnabledToggle.IsChecked = _settings.OverlayEnabled; OverlayModeBox.SelectedIndex = ChoiceIndex(_settings.OverlayMode, "Full", "Minimal", "TitleBar"); @@ -2560,13 +2563,6 @@ public partial class MainWindow : Window }); _appliedEnvironmentVariables.Add(BinkModeEnvironmentName); - Environment.SetEnvironmentVariable( - "SHARPEMU_RENDER_SCALE", - _settings.RenderResolutionScale.ToString( - "0.###", - System.Globalization.CultureInfo.InvariantCulture)); - _appliedEnvironmentVariables.Add("SHARPEMU_RENDER_SCALE"); - foreach (var entry in customEnvironment) { if (!CustomEnvironmentVariables.TryParseEntry(entry, out var name, out var value)) @@ -2784,6 +2780,7 @@ public partial class MainWindow : Window arguments.Add($"--window-mode={launch.Settings.WindowMode.ToLowerInvariant()}"); arguments.Add($"--resolution={launch.Settings.Resolution}"); + arguments.Add($"--guest-resolution={launch.Settings.GuestResolution}"); arguments.Add($"--display={launch.Settings.DisplayIndex}"); arguments.Add($"--refresh-rate={launch.Settings.RefreshRate}"); arguments.Add($"--scaling={launch.Settings.ScalingMode.ToLowerInvariant()}"); diff --git a/src/SharpEmu.GUI/PerGameSettings.cs b/src/SharpEmu.GUI/PerGameSettings.cs index 58b2cff..7c8e403 100644 --- a/src/SharpEmu.GUI/PerGameSettings.cs +++ b/src/SharpEmu.GUI/PerGameSettings.cs @@ -26,6 +26,8 @@ public sealed class PerGameSettings public string? Resolution { get; set; } + public string? GuestResolution { get; set; } + public int? DisplayIndex { get; set; } public int? RefreshRate { get; set; } @@ -52,6 +54,7 @@ public sealed class PerGameSettings LogToFile is null && WindowMode is null && Resolution is null && + GuestResolution is null && DisplayIndex is null && RefreshRate is null && ScalingMode is null && @@ -145,6 +148,11 @@ public sealed class PerGameSettings Resolution = null; } + if (string.Equals(GuestResolution, global.GuestResolution, StringComparison.OrdinalIgnoreCase)) + { + GuestResolution = null; + } + if (DisplayIndex == global.DisplayIndex) { DisplayIndex = null; @@ -272,6 +280,7 @@ public sealed record EffectiveLaunchSettings( bool LogToFile, string WindowMode, string Resolution, + string GuestResolution, int DisplayIndex, int RefreshRate, string ScalingMode, @@ -289,6 +298,7 @@ public sealed record EffectiveLaunchSettings( perGame?.LogToFile ?? global.LogToFile, perGame?.WindowMode ?? global.WindowMode, perGame?.Resolution ?? global.Resolution, + GuiSettings.NormalizeGuestResolution(perGame?.GuestResolution ?? global.GuestResolution), Math.Max(0, perGame?.DisplayIndex ?? global.DisplayIndex), Math.Clamp(perGame?.RefreshRate ?? global.RefreshRate, 0, 1000), perGame?.ScalingMode ?? global.ScalingMode, diff --git a/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs index b0a8f9c..4581d72 100644 --- a/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs +++ b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs @@ -21,6 +21,13 @@ public enum HostScalingMode Integer, } +// The display mode exposed to the guest, independent of host window and guest buffer sizes. +public enum GuestDisplayResolution +{ + Hd = 1, + UltraHd = 2, +} + public enum HostHdrMode { Auto, @@ -55,6 +62,8 @@ public sealed record HostVideoOptions public int Height { get; init; } = 1080; + public GuestDisplayResolution GuestResolution { get; init; } = GuestDisplayResolution.Hd; + public int DisplayIndex { get; init; } public int RefreshRate { get; init; } @@ -82,6 +91,7 @@ public sealed record HostVideoOptions { Width = Math.Clamp(Width, 640, 16384), Height = Math.Clamp(Height, 360, 16384), + GuestResolution = Enum.IsDefined(GuestResolution) ? GuestResolution : GuestDisplayResolution.Hd, DisplayIndex = Math.Max(0, DisplayIndex), RefreshRate = Math.Clamp(RefreshRate, 0, 1000), HdrMode = Enum.IsDefined(HdrMode) ? HdrMode : HostHdrMode.Auto, diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs index 2688f09..25eec50 100644 --- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs +++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs @@ -519,18 +519,19 @@ public static partial class VideoOutExports } Span status = stackalloc byte[VideoOutOutputStatusSize]; - WriteOutputStatus(status, port.OutputWidth, port.OutputHeight, port.RefreshRate, + WriteOutputStatus(status, HostVideoHost.CurrentOptions.GuestResolution, port.RefreshRate, HostVideoHost.IsHdrOutputSupported); return ctx.Memory.TryWrite(statusAddress, status) ? (int)OrbisGen2Result.ORBIS_GEN2_OK : (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; } - internal static void WriteOutputStatus(Span status, uint width, uint height, uint refreshRate, + internal static void WriteOutputStatus(Span status, GuestDisplayResolution resolution, uint refreshRate, bool hdrOutputSupported) { status.Clear(); - var resolutionClass = width >= 3840 || height >= 2160 ? 2 : 1; + // Buffer registration describes a source image, not the connected display. + var resolutionClass = (int)resolution; BinaryPrimitives.WriteInt32LittleEndian(status[0x00..0x04], resolutionClass); BinaryPrimitives.WriteInt32LittleEndian(status[0x04..0x08], hdrOutputSupported ? 2 : 1); // The status uses a refresh-rate code, not the frequency used for pacing. diff --git a/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs b/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs index 3237d57..5f67277 100644 --- a/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs +++ b/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs @@ -8,6 +8,33 @@ namespace SharpEmu.Libs.Tests.GUI; public sealed class PerGameSettingsTests { + [Fact] + public void GuestResolutionDefaultsToHdAndCanBeOverriddenPerGame() + { + var global = new GuiSettings { Resolution = "3840x2160" }; + Assert.Equal("1920x1080", EffectiveLaunchSettings.Resolve(global, null).GuestResolution); + var game = new PerGameSettings { GuestResolution = "3840x2160" }; + game.RemoveInheritedValues(global); + Assert.False(game.IsEmpty); + var restored = PerGameSettings.NormalizeFromJson(System.Text.Json.JsonSerializer.Serialize(game)); + Assert.Equal("3840x2160", EffectiveLaunchSettings.Resolve(global, restored).GuestResolution); + global.GuestResolution = "3840x2160"; + game.RemoveInheritedValues(global); + Assert.True(game.IsEmpty); + Assert.Equal("3840x2160", EffectiveLaunchSettings.Resolve(global, game).GuestResolution); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("invalid")] + [InlineData("7680x4320")] + public void InvalidGuestResolutionFallsBackToHd(string? value) + { + Assert.Equal("1920x1080", EffectiveLaunchSettings.Resolve( + new GuiSettings(), new PerGameSettings { GuestResolution = value }).GuestResolution); + } + [Fact] public void NewGameInheritsWritableApp0AndGlobalCrashCapture() { diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs index 4d81175..40755b3 100644 --- a/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs +++ b/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs @@ -18,6 +18,7 @@ public sealed class HostVideoOptionsTests DisplayIndex = -2, RefreshRate = 5000, HdrMode = (HostHdrMode)99, + GuestResolution = (GuestDisplayResolution)99, }.Normalize(); Assert.Equal(640, options.Width); @@ -25,5 +26,18 @@ public sealed class HostVideoOptionsTests Assert.Equal(0, options.DisplayIndex); Assert.Equal(1000, options.RefreshRate); Assert.Equal(HostHdrMode.Auto, options.HdrMode); + Assert.Equal(GuestDisplayResolution.Hd, options.GuestResolution); + } + [Theory] + [InlineData(1920, 1080, GuestDisplayResolution.UltraHd)] + [InlineData(3840, 2160, GuestDisplayResolution.Hd)] + public void GuestModeIsIndependentOfWindowSize(int width, int height, GuestDisplayResolution mode) + { + var options = new HostVideoOptions { Width = width, Height = height, GuestResolution = mode }.Normalize(); + Assert.Equal(mode, options.GuestResolution); + Assert.Equal(GuestDisplayResolution.Hd, HostVideoOptions.Default.GuestResolution); + Span status = stackalloc byte[0x30]; + VideoOutExports.WriteOutputStatus(status, options.GuestResolution, 60, false); + Assert.Equal((int)mode, System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(status)); } } diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutOutputSupportTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutOutputSupportTests.cs index cbf2e08..34e8d30 100644 --- a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutOutputSupportTests.cs +++ b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutOutputSupportTests.cs @@ -48,7 +48,7 @@ public sealed class VideoOutOutputSupportTests Assert.Equal(expectedHdr, supported); var bytes = Enumerable.Repeat((byte)0xA5, 0x32).ToArray(); - VideoOutExports.WriteOutputStatus(bytes.AsSpan(1, 0x30), 3840, 2160, 120, supported); + VideoOutExports.WriteOutputStatus(bytes.AsSpan(1, 0x30), GuestDisplayResolution.UltraHd, 120, supported); Assert.Equal(0xA5, bytes[0]); Assert.Equal(0xA5, bytes[^1]);