From e764a63f9551341d6d248e36c90c00b4e2481b14 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Fri, 19 Jun 2026 10:13:31 -0400 Subject: [PATCH] VRR: optional per-game FPS cap Adds a per-game FPS cap for the VRR present mode so games whose logic is tied to the frame rate (e.g. TOTK's menu cursor running wild at 200 fps) can be limited, while keeping G-Sync/FreeSync smoothing. The cap is off by default (VRR stays unbounded); 30/60/120 are exposed. Reuses the existing custom-VSync-interval plumbing: when VSyncMode is Vrr and the custom interval is enabled, SurfaceFlinger paces frames at that value (forcing a non-zero swap interval even when a Dynamic FPS mod submits 0) instead of running unbounded, while Window.cs keeps FifoRelaxed so the display still follows the frame rate. EnableCustomVSyncInterval is now read at Switch construction so the per-game setting applies at launch. UI: the VSync dropdown now offers Switch / Unbounded / VRR, with a VRR FPS cap picker shown only for VRR. The legacy Custom mode is hidden (kept in the enum for config compatibility) and the percentage slider / experimental checkbox are removed. --- src/Ryujinx.Common/Configuration/VSyncMode.cs | 4 +- .../Services/SurfaceFlinger/SurfaceFlinger.cs | 20 ++++- src/Ryujinx.HLE/HleConfiguration.cs | 7 ++ src/Ryujinx.HLE/Switch.cs | 8 ++ .../Configuration/ConfigurationState.Model.cs | 1 + .../UI/ViewModels/SettingsViewModel.cs | 76 +++++++++++++++++-- .../Views/Settings/SettingsSystemView.axaml | 53 ++++--------- 7 files changed, 122 insertions(+), 47 deletions(-) diff --git a/src/Ryujinx.Common/Configuration/VSyncMode.cs b/src/Ryujinx.Common/Configuration/VSyncMode.cs index b2818eb7a..3cfc9549e 100644 --- a/src/Ryujinx.Common/Configuration/VSyncMode.cs +++ b/src/Ryujinx.Common/Configuration/VSyncMode.cs @@ -13,10 +13,10 @@ namespace Ryujinx.Common.Configuration public static VSyncMode Next(this VSyncMode vsync, bool customEnabled = false) => vsync switch { - VSyncMode.Switch => customEnabled ? VSyncMode.Custom : VSyncMode.Unbounded, - VSyncMode.Custom => VSyncMode.Unbounded, + VSyncMode.Switch => VSyncMode.Unbounded, VSyncMode.Unbounded => VSyncMode.Vrr, VSyncMode.Vrr => VSyncMode.Switch, + VSyncMode.Custom => VSyncMode.Switch, // legacy mode, no longer in the cycle _ => VSyncMode.Switch }; } diff --git a/src/Ryujinx.HLE/HOS/Services/SurfaceFlinger/SurfaceFlinger.cs b/src/Ryujinx.HLE/HOS/Services/SurfaceFlinger/SurfaceFlinger.cs index 971778175..99e49af71 100644 --- a/src/Ryujinx.HLE/HOS/Services/SurfaceFlinger/SurfaceFlinger.cs +++ b/src/Ryujinx.HLE/HOS/Services/SurfaceFlinger/SurfaceFlinger.cs @@ -372,7 +372,25 @@ namespace Ryujinx.HLE.HOS.Services.SurfaceFlinger if (acquireStatus == Status.Success) { - if (_device.VSyncMode == VSyncMode.Unbounded) + bool vrrCapped = _device.VSyncMode == VSyncMode.Vrr + && _device.CustomVSyncIntervalEnabled + && _device.CustomVSyncInterval > 0; + + if (vrrCapped) + { + // VRR with a per-game FPS cap: force a paced interval (swap interval 1 -> + // _ticksPerFrame derived from TargetVSyncInterval) instead of running + // unbounded. The present mode stays FifoRelaxed (chosen from VSyncMode.Vrr + // in Window.cs), so the display still follows the framerate, just capped. + if (_swapInterval != 1 + || _device.TargetVSyncInterval != _targetVSyncInterval + || _vSyncMode != _device.VSyncMode) + { + UpdateSwapInterval(1); + _vSyncMode = _device.VSyncMode; + } + } + else if (_device.VSyncMode == VSyncMode.Unbounded || _device.VSyncMode == VSyncMode.Vrr) { if (_swapInterval != 0) { diff --git a/src/Ryujinx.HLE/HleConfiguration.cs b/src/Ryujinx.HLE/HleConfiguration.cs index e2f95ede7..f9999118a 100644 --- a/src/Ryujinx.HLE/HleConfiguration.cs +++ b/src/Ryujinx.HLE/HleConfiguration.cs @@ -93,6 +93,11 @@ namespace Ryujinx.HLE /// internal readonly int CustomVSyncInterval; + /// + /// Whether the custom VSync interval is enabled. Also acts as the per-game VRR FPS cap toggle. + /// + internal readonly bool EnableCustomVSyncInterval; + /// /// Control the initial state of the docked mode. /// @@ -241,6 +246,7 @@ namespace Ryujinx.HLE ushort gdbStubPort, bool debuggerSuspendOnStart, int customVSyncInterval, + bool enableCustomVSyncInterval = false, EnabledDirtyHack[] dirtyHacks = null) { MemoryConfiguration = memoryConfiguration; @@ -248,6 +254,7 @@ namespace Ryujinx.HLE Region = region; VSyncMode = vSyncMode; CustomVSyncInterval = customVSyncInterval; + EnableCustomVSyncInterval = enableCustomVSyncInterval; EnableDockedMode = enableDockedMode; EnablePtc = enablePtc; TickScalar = tickScalar; diff --git a/src/Ryujinx.HLE/Switch.cs b/src/Ryujinx.HLE/Switch.cs index 90af47988..d60d7518f 100644 --- a/src/Ryujinx.HLE/Switch.cs +++ b/src/Ryujinx.HLE/Switch.cs @@ -98,6 +98,7 @@ namespace Ryujinx.HLE VSyncMode = Configuration.VSyncMode; CustomVSyncInterval = Configuration.CustomVSyncInterval; + CustomVSyncIntervalEnabled = Configuration.EnableCustomVSyncInterval; TickScalar = TurboMode ? Configuration.TickScalar : ITickSource.RealityTickScalar; System.State.DockedMode = Configuration.EnableDockedMode; System.PerformanceState.PerformanceMode = System.State.DockedMode ? PerformanceMode.Boost : PerformanceMode.Default; @@ -147,6 +148,13 @@ namespace Ryujinx.HLE case VSyncMode.Unbounded: TargetVSyncInterval = 1; break; + case VSyncMode.Vrr: + // VRR runs unbounded by default (interval 1). If the per-game FPS cap is + // enabled, pace to that value instead (e.g. TOTK capped at 60). + TargetVSyncInterval = (CustomVSyncIntervalEnabled && CustomVSyncInterval > 0) + ? CustomVSyncInterval + : 1; + break; } } diff --git a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs index c4d6ce273..66632e5d1 100644 --- a/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs +++ b/src/Ryujinx/Systems/Configuration/ConfigurationState.Model.cs @@ -987,6 +987,7 @@ namespace Ryujinx.Ava.Systems.Configuration Debug.GdbStubPort, Debug.DebuggerSuspendOnStart, Graphics.CustomVSyncInterval, + Graphics.EnableCustomVSyncInterval, Hacks.ShowDirtyHacks ? Hacks.EnabledHacks : null); } } diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index 0f6e473e3..68ed064b0 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -260,6 +260,45 @@ namespace Ryujinx.Ava.UI.ViewModels OnPropertyChanged(); } } + + // --- Clean VSync / VRR UI (Roofer Edition) --- + // Dropdown positions: 0 = Switch, 1 = Unbounded, 2 = VRR. We translate position <-> enum + // here so we can skip the legacy "Custom" enum value (2) and still bind by SelectedIndex. + public int VSyncModeIndex + { + get => _vSyncMode switch + { + VSyncMode.Unbounded => 1, + VSyncMode.Vrr => 2, + _ => 0, + }; + set + { + _vSyncMode = value switch + { + 1 => VSyncMode.Unbounded, + 2 => VSyncMode.Vrr, + _ => VSyncMode.Switch, + }; + OnPropertyChanged(); + OnPropertyChanged(nameof(ShowVrrCapPicker)); + } + } + + // The VRR FPS cap picker is only relevant when VRR is the selected mode. + public bool ShowVrrCapPicker => _vSyncMode == VSyncMode.Vrr; + + // 0 = Off (unbounded VRR), 1 = 30, 2 = 60, 3 = 120. + public int VrrFpsCapIndex + { + get; + set + { + field = value; + OnPropertyChanged(); + } + } + public bool EnablePptc { get; set; } public bool EnableLowPowerPptc { get; set; } @@ -743,9 +782,23 @@ namespace Ryujinx.Ava.UI.ViewModels MatchSystemTime = config.System.MatchSystemTime; - EnableCustomVSyncInterval = config.Graphics.EnableCustomVSyncInterval; CustomVSyncInterval = config.Graphics.CustomVSyncInterval; - VSyncMode = config.Graphics.VSyncMode; + + // VSync / VRR (Roofer clean UI): set the backing field directly so VRR loads (the + // legacy VSyncMode setter rejects it), and derive the VRR cap picker from the stored + // custom-interval values. + _vSyncMode = config.Graphics.VSyncMode.Value; + VrrFpsCapIndex = config.Graphics.EnableCustomVSyncInterval.Value + ? config.Graphics.CustomVSyncInterval.Value switch + { + 30 => 1, + 60 => 2, + 120 => 3, + _ => 0, + } + : 0; + OnPropertyChanged(nameof(VSyncModeIndex)); + OnPropertyChanged(nameof(ShowVrrCapPicker)); EnableFsIntegrityChecks = config.System.EnableFsIntegrityChecks; DramSize = config.System.DramSize; IgnoreMissingServices = config.System.IgnoreMissingServices; @@ -879,9 +932,22 @@ namespace Ryujinx.Ava.UI.ViewModels config.System.TickScalar.Value = TurboMultiplier; // Graphics - config.Graphics.VSyncMode.Value = VSyncMode; - config.Graphics.EnableCustomVSyncInterval.Value = EnableCustomVSyncInterval; - config.Graphics.CustomVSyncInterval.Value = CustomVSyncInterval; + config.Graphics.VSyncMode.Value = _vSyncMode; + + // VRR FPS cap -> reuse the custom-interval plumbing the engine already reads + // (EnableCustomVSyncInterval = cap on, CustomVSyncInterval = the capped fps). + bool vrrCapOn = _vSyncMode == VSyncMode.Vrr && VrrFpsCapIndex > 0; + config.Graphics.EnableCustomVSyncInterval.Value = vrrCapOn; + if (vrrCapOn) + { + config.Graphics.CustomVSyncInterval.Value = VrrFpsCapIndex switch + { + 1 => 30, + 2 => 60, + 3 => 120, + _ => 60, + }; + } config.Graphics.GraphicsBackend.Value = (GraphicsBackend)GraphicsBackendIndex; config.Graphics.PreferredGpu.Value = _gpuIds.ElementAtOrDefault(PreferredGpuIndex); config.Graphics.EnableShaderCache.Value = EnableShaderCache; diff --git a/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml index 2414fb7f6..cd0fded02 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsSystemView.axaml @@ -178,21 +178,7 @@ ToolTip.Tip="{ext:Locale SettingsTabSystemVSyncModeTooltip}" Width="250" /> - - - - - @@ -200,32 +186,26 @@ Content="{ext:Locale SettingsTabSystemVSyncModeSwitch}" /> + - - - + + + + + + - - -