[VideoOut] Separate guest display mode from presentation buffer dimensions

This commit is contained in:
kuba 2026-10-04 11:15:38 +02:00
parent f8c85b6168
commit 5c9b409e18
12 changed files with 113 additions and 15 deletions

View File

@ -1011,7 +1011,7 @@ internal static partial class Program
private static void PrintUsage()
{
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--console=<ps5>] [--console-language=<language>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--overlay=<on|off>] [--overlay-mode=<full|minimal|titlebar>] [--overlay-corner=<topleft|topright|bottomright|bottomleft>] [--debug-server[=host:port]] <path-to-eboot.bin>");
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--console=<ps5>] [--console-language=<language>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--guest-resolution=<1920x1080|3840x2160>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--overlay=<on|off>] [--overlay-mode=<full|minimal|titlebar>] [--overlay-corner=<topleft|topright|bottomright|bottomleft>] [--debug-server[=host:port]] <path-to-eboot.bin>");
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,

View File

@ -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";
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
public double RenderResolutionScale { get; set; } = 1.0;
/// <summary>
/// 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");

View File

@ -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",

View File

@ -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,

View File

@ -856,6 +856,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="GameGuestResolutionRow"
Classes="optionRow"
Label="{Binding [Options.GuestResolution.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.GuestResolution.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="GameGuestResolutionBox"
Classes="optionValue"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="GameDisplayRow"
Classes="optionRow"
Label="{Binding [Options.Display.Label],
@ -1490,6 +1498,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
Classes="optionValue"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="GuestResolutionRow"
Classes="optionRow"
Label="{Binding [Options.GuestResolution.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.GuestResolution.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="GuestResolutionBox" Classes="optionValue" VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="DisplayRow"
Classes="optionRow"
Label="{Binding [Options.Display.Label], Source={x:Static local:Localization.Instance}}"

View File

@ -322,6 +322,7 @@ public partial class MainWindow : Window
WindowModeBox.SelectionChanged += (_, _) => _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()}");

View File

@ -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,

View File

@ -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,

View File

@ -519,18 +519,19 @@ public static partial class VideoOutExports
}
Span<byte> 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<byte> status, uint width, uint height, uint refreshRate,
internal static void WriteOutputStatus(Span<byte> 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.

View File

@ -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()
{

View File

@ -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<byte> status = stackalloc byte[0x30];
VideoOutExports.WriteOutputStatus(status, options.GuestResolution, 60, false);
Assert.Equal((int)mode, System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(status));
}
}

View File

@ -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]);