From 7720a2f08ba46b952b98b41fa3a4d120d4b42380 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Tue, 26 May 2026 16:47:17 +0200 Subject: [PATCH 1/2] Add frame limiter FPS offset Ref #177 --- docs/configuration.md | 25 +++- src/config.cpp | 3 + src/config.h | 3 + src/platform/windows/frame_limiter.cpp | 114 +++++++++++++----- src/platform/windows/rtss_integration.cpp | 24 ++-- src/platform/windows/rtss_integration.h | 2 +- .../AppEditConfigOverridesSection.vue | 6 +- .../assets/web/configs/configFieldSchema.ts | 1 + .../tabs/audiovideo/FrameLimiterStep.vue | 24 ++-- .../assets/web/public/assets/locale/en.json | 13 +- .../web/public/assets/locale/en_GB.json | 13 +- .../web/public/assets/locale/en_US.json | 14 ++- src_assets/common/assets/web/stores/config.ts | 1 + tests/frontend/config-field-schema.test.ts | 13 ++ 14 files changed, 195 insertions(+), 61 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0669a9a59..f6bf926cc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2634,7 +2634,7 @@ They appear in the Frame Limiter section of the settings UI. Description - Optional FPS limit to apply while streaming. Set to 0 to use the stream's requested FPS. + Optional FPS limit to apply while streaming. Set to 0 to use the stream's requested FPS minus @code{}frame_limiter_fps_offset@endcode. @@ -2649,6 +2649,29 @@ They appear in the Frame Limiter section of the settings UI. +### frame_limiter_fps_offset + + + + + + + + + + + + + + +
Description + FPS value subtracted from the stream's requested FPS when @code{}frame_limiter_fps_limit@endcode is 0. This keeps the stream container at the client-requested refresh while applying a lower host-side frame cap. +

+ For example, a 120 FPS stream with @code{}frame_limiter_fps_offset = 3@endcode applies a 117 FPS limiter. +
Default@code{}0@endcode
Example@code{} + frame_limiter_fps_offset = 3 + @endcode
+ ### rtss_install_path diff --git a/src/config.cpp b/src/config.cpp index 108415a83..bd1637554 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -901,6 +901,7 @@ namespace config { false, // enable "auto", // provider 0, // fps_limit + 0, // fps_offset false // disable_vsync }; @@ -1670,6 +1671,7 @@ namespace config { frame_limiter.provider = "auto"; } int_between_f(vars, "frame_limiter_fps_limit", frame_limiter.fps_limit, {0, 1000}); + int_between_f(vars, "frame_limiter_fps_offset", frame_limiter.fps_offset, {0, 1000}); bool_f(vars, "frame_limiter_disable_vsync", frame_limiter.disable_vsync); bool_f(vars, "rtss_disable_vsync_ullm", frame_limiter.disable_vsync); string_f(vars, "rtss_install_path", rtss.install_path); @@ -2175,6 +2177,7 @@ namespace config { "frame_limiter_enable", "frame_limiter_provider", "frame_limiter_fps_limit", + "frame_limiter_fps_offset", "rtss_frame_limit_type", "frame_limiter_disable_vsync", diff --git a/src/config.h b/src/config.h index a2bb82398..c4f0d0050 100644 --- a/src/config.h +++ b/src/config.h @@ -265,6 +265,9 @@ namespace config { // Optional FPS limit override. 0 uses the stream's requested FPS. int fps_limit {0}; + // Optional offset subtracted from the stream's requested FPS when fps_limit is 0. + int fps_offset {0}; + // When enabled, Sunshine forces the NVIDIA driver VSYNC setting to Off during streams when available. // When NVIDIA overrides are unavailable, the display helper falls back to the highest refresh rate instead. // Restores the previous VSYNC state when streaming stops. diff --git a/src/platform/windows/frame_limiter.cpp b/src/platform/windows/frame_limiter.cpp index 6ba825b28..4bfa0e8b4 100644 --- a/src/platform/windows/frame_limiter.cpp +++ b/src/platform/windows/frame_limiter.cpp @@ -15,8 +15,8 @@ #include #include #include - #include #include + #include #include #include @@ -29,13 +29,19 @@ namespace platf { bool g_nvcp_started = false; bool g_gen1_framegen_fix_active = false; bool g_gen2_framegen_fix_active = false; - int g_last_effective_limit = 0; bool g_prev_frame_limiter_enabled = false; std::string g_prev_frame_limiter_provider; bool g_prev_frame_limiter_provider_set = false; bool g_prev_disable_vsync = false; std::string g_prev_rtss_frame_limit_type; bool g_prev_rtss_frame_limit_type_set = false; + int g_last_rtss_limit_value = 0; + int g_last_rtss_limit_denominator = 1; + + struct rtss_limit_t { + int value = 0; + int denominator = 1; + }; frame_limiter_provider parse_provider(const std::string &value) { std::string normalized; @@ -100,6 +106,58 @@ namespace platf { return "front edge sync"; } + int apply_stream_fps_offset(int requested_fps, int offset) { + if (requested_fps <= 0 || offset <= 0) { + return requested_fps; + } + + return std::max(1, requested_fps - offset); + } + + void reduce_rtss_limit(rtss_limit_t &limit) { + if (limit.denominator <= 1 || limit.value <= 0) { + limit.denominator = 1; + return; + } + + if (limit.value % 1000 == 0) { + limit.value /= 1000; + limit.denominator = 1; + } else if (limit.value % 100 == 0) { + limit.value /= 100; + limit.denominator = 10; + } else if (limit.value % 10 == 0) { + limit.value /= 10; + limit.denominator = 100; + } + + int gcd_value = std::gcd(limit.value, limit.denominator); + if (gcd_value > 1) { + limit.value /= gcd_value; + limit.denominator /= gcd_value; + } + } + + rtss_limit_t apply_stream_fps_offset_scaled(int requested_fps, int requested_fps_scaled, int offset) { + if (requested_fps_scaled <= 0) { + return {apply_stream_fps_offset(requested_fps, offset), 1}; + } + + const int scaled_offset = offset > 0 ? offset * 1000 : 0; + int scaled_limit = requested_fps_scaled - scaled_offset; + if (offset > 0) { + scaled_limit = std::max(1000, scaled_limit); + } else { + scaled_limit = std::max(0, scaled_limit); + } + rtss_limit_t limit { + scaled_limit, + 1000 + }; + reduce_rtss_limit(limit); + return limit; + } + } // namespace const char *frame_limiter_provider_to_string(frame_limiter_provider provider) { @@ -170,40 +228,33 @@ namespace platf { const bool want_nv_vsync_override = (config::frame_limiter.disable_vsync || capture_fix_enabled) && nvidia_gpu_present && nvcp_ready; bool nvcp_already_invoked = false; - int effective_limit = (lossless_rtss_limit && *lossless_rtss_limit > 0) ? *lossless_rtss_limit : fps; + const bool using_lossless_rtss_limit = lossless_rtss_limit && *lossless_rtss_limit > 0; + const bool using_manual_fps_limit = config::frame_limiter.fps_limit > 0; + const bool using_stream_fps_limit = !using_lossless_rtss_limit && !using_manual_fps_limit; + + int effective_limit = using_lossless_rtss_limit ? *lossless_rtss_limit : fps; + if (using_stream_fps_limit) { + effective_limit = apply_stream_fps_offset(effective_limit, config::frame_limiter.fps_offset); + } if (config::frame_limiter.fps_limit > 0) { effective_limit = config::frame_limiter.fps_limit; } - g_last_effective_limit = effective_limit; int rtss_limit_value = effective_limit; int rtss_limit_denominator = 1; - if ((!lossless_rtss_limit || *lossless_rtss_limit <= 0) && config::frame_limiter.fps_limit <= 0) { - if (fps_scaled > 0) { - rtss_limit_value = fps_scaled; - rtss_limit_denominator = 1000; - - if (rtss_limit_value % 1000 == 0) { - rtss_limit_value /= 1000; - rtss_limit_denominator = 1; - } else if (rtss_limit_value % 100 == 0) { - rtss_limit_value /= 100; - rtss_limit_denominator = 10; - } else if (rtss_limit_value % 10 == 0) { - rtss_limit_value /= 10; - rtss_limit_denominator = 100; - } + if (using_stream_fps_limit) { + const auto rtss_limit = apply_stream_fps_offset_scaled(fps, fps_scaled, config::frame_limiter.fps_offset); + rtss_limit_value = rtss_limit.value; + rtss_limit_denominator = rtss_limit.denominator; + } + g_last_rtss_limit_value = rtss_limit_value; + g_last_rtss_limit_denominator = rtss_limit_denominator; - int gcd_value = std::gcd(rtss_limit_value, rtss_limit_denominator); - if (gcd_value > 1) { - rtss_limit_value /= gcd_value; - rtss_limit_denominator /= gcd_value; - } - } else { - rtss_limit_value = effective_limit; - rtss_limit_denominator = 1; - } + if (using_stream_fps_limit && config::frame_limiter.fps_offset > 0) { + BOOST_LOG(info) << "Frame limiter FPS offset applied: requested=" << fps + << ", offset=" << config::frame_limiter.fps_offset + << ", effective=" << effective_limit; } if (frame_limit_enabled) { @@ -374,15 +425,16 @@ namespace platf { g_active_provider = frame_limiter_provider::none; g_nvcp_started = false; - g_last_effective_limit = 0; + g_last_rtss_limit_value = 0; + g_last_rtss_limit_denominator = 1; } void frame_limiter_streaming_refresh() { - if (g_active_provider != frame_limiter_provider::rtss || g_last_effective_limit <= 0) { + if (g_active_provider != frame_limiter_provider::rtss || g_last_rtss_limit_value <= 0) { return; } - if (rtss_streaming_refresh(g_last_effective_limit)) { + if (rtss_streaming_refresh(g_last_rtss_limit_value, g_last_rtss_limit_denominator)) { BOOST_LOG(info) << "Frame limiter provider 'rtss' refreshed"; } } diff --git a/src/platform/windows/rtss_integration.cpp b/src/platform/windows/rtss_integration.cpp index af1727c13..d2714c3ce 100644 --- a/src/platform/windows/rtss_integration.cpp +++ b/src/platform/windows/rtss_integration.cpp @@ -1013,7 +1013,7 @@ namespace platf { return g_limit_active; } - bool rtss_streaming_refresh(int fps) { + bool rtss_streaming_refresh(int scaled_limit, int denominator) { if (!config::frame_limiter.enable) { return false; } @@ -1051,7 +1051,7 @@ namespace platf { } } - int current_denominator = 1; + int current_denominator = denominator > 0 ? denominator : 1; auto old_den = set_limit_denominator(g_rtss_root, current_denominator); if (old_den.has_value() && *old_den != current_denominator) { if (!g_original_denominator.has_value()) { @@ -1099,19 +1099,23 @@ namespace platf { } } - int scaled_limit = fps; - bool applied_limit = false; + int applied_limit = scaled_limit; + if (applied_limit < 0) { + applied_limit = 0; + } + bool limit_applied = false; if (g_hooks) { - set_profile_property_int("FramerateLimit", scaled_limit); - applied_limit = true; - } else if (write_profile_value_int(g_rtss_root, "FramerateLimit", scaled_limit)) { - applied_limit = true; + set_profile_property_int("FramerateLimit", applied_limit); + limit_applied = true; + } else if (write_profile_value_int(g_rtss_root, "FramerateLimit", applied_limit)) { + limit_applied = true; } - if (applied_limit) { + if (limit_applied) { g_limit_active = true; g_limit_modified = true; dirty = true; - BOOST_LOG(info) << "RTSS refreshed framerate limit=" << scaled_limit << " (denominator=" << current_denominator << ")"; + double limit_fps = current_denominator > 0 ? (double) applied_limit / current_denominator : 0.0; + BOOST_LOG(info) << "RTSS refreshed framerate limit=" << limit_fps << "Hz (raw=" << applied_limit << ", denominator=" << current_denominator << ")"; } if (dirty && !g_settings_dirty) { diff --git a/src/platform/windows/rtss_integration.h b/src/platform/windows/rtss_integration.h index 6f690b780..3ef57a641 100644 --- a/src/platform/windows/rtss_integration.h +++ b/src/platform/windows/rtss_integration.h @@ -26,7 +26,7 @@ namespace platf { bool rtss_streaming_start(int scaled_limit, int denominator); // Re-apply RTSS frame limit and related settings without resetting originals. - bool rtss_streaming_refresh(int fps); + bool rtss_streaming_refresh(int scaled_limit, int denominator); // Restore any RTSS settings modified at stream start. // If keep_process_running is true, Sunshine leaves RTSS running for pause/resume scenarios. diff --git a/src_assets/common/assets/web/components/app-edit/AppEditConfigOverridesSection.vue b/src_assets/common/assets/web/components/app-edit/AppEditConfigOverridesSection.vue index e9828ab9f..d3e26314e 100644 --- a/src_assets/common/assets/web/components/app-edit/AppEditConfigOverridesSection.vue +++ b/src_assets/common/assets/web/components/app-edit/AppEditConfigOverridesSection.vue @@ -921,6 +921,7 @@ const ALLOWED_OVERRIDE_KEYS = new Set([ 'frame_limiter_enable', 'frame_limiter_provider', 'frame_limiter_fps_limit', + 'frame_limiter_fps_offset', 'rtss_frame_limit_type', 'frame_limiter_disable_vsync', @@ -1772,7 +1773,10 @@ const BOOL_STRING_PAIRS = [ ['1', '0'], ] as const; -const NUMERIC_OVERRIDE_KEYS = new Set(['frame_limiter_fps_limit']); +const NUMERIC_OVERRIDE_KEYS = new Set([ + 'frame_limiter_fps_limit', + 'frame_limiter_fps_offset', +]); function boolPairFromValue(value: unknown): BoolPair | null { if (value === true || value === false) return { truthy: true, falsy: false }; diff --git a/src_assets/common/assets/web/configs/configFieldSchema.ts b/src_assets/common/assets/web/configs/configFieldSchema.ts index 584f233c5..26172fd96 100644 --- a/src_assets/common/assets/web/configs/configFieldSchema.ts +++ b/src_assets/common/assets/web/configs/configFieldSchema.ts @@ -51,6 +51,7 @@ const NUMBER_FIELD_OVERRIDES: Record> = { minimum_fps_target: { min: 0, max: 1000, placeholder: '0' }, nvenc_vbv_increase: { min: 0, max: 400, placeholder: '0' }, frame_limiter_fps_limit: { min: 0, max: 1000, step: 1, precision: 0, placeholder: '0' }, + frame_limiter_fps_offset: { min: 0, max: 1000, step: 1, precision: 0, placeholder: '0' }, }; function isFiniteNumber(value: unknown): value is number { diff --git a/src_assets/common/assets/web/configs/tabs/audiovideo/FrameLimiterStep.vue b/src_assets/common/assets/web/configs/tabs/audiovideo/FrameLimiterStep.vue index 2024b509a..be0091aac 100644 --- a/src_assets/common/assets/web/configs/tabs/audiovideo/FrameLimiterStep.vue +++ b/src_assets/common/assets/web/configs/tabs/audiovideo/FrameLimiterStep.vue @@ -310,13 +310,23 @@ onMounted(() => { /> - +
+ + + +
{ expect(field.kind).toBe('checkbox'); }); + + test('keeps frame limiter FPS offset as an integer number field', () => { + const field = getConfigFieldDefinition('frame_limiter_fps_offset', { + ...baseContext, + currentValue: 0, + defaultValue: 0, + }); + + expect(field.kind).toBe('number'); + expect(field.min).toBe(0); + expect(field.step).toBe(1); + expect(field.precision).toBe(0); + }); }); From f19edf3f00159d9a4f448a057e1ffbdf30a4749b Mon Sep 17 00:00:00 2001 From: kristofferR Date: Tue, 26 May 2026 17:09:44 +0200 Subject: [PATCH 2/2] Address frame limiter offset review feedback Ref #177 --- docs/configuration.md | 4 ++++ src/platform/windows/rtss_integration.cpp | 4 ++-- tests/frontend/config-field-schema.test.ts | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f6bf926cc..f463ca17b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2664,6 +2664,10 @@ They appear in the Frame Limiter section of the settings UI.
+ + + +
Default @code{}0@endcode
Range@code{}0-1000@endcode
Example @code{} diff --git a/src/platform/windows/rtss_integration.cpp b/src/platform/windows/rtss_integration.cpp index d2714c3ce..f1a73ba92 100644 --- a/src/platform/windows/rtss_integration.cpp +++ b/src/platform/windows/rtss_integration.cpp @@ -1019,7 +1019,7 @@ namespace platf { } if (!g_limit_active && !g_settings_dirty) { - return rtss_streaming_start(fps, 1); + return rtss_streaming_start(scaled_limit, denominator); } g_rtss_root = resolve_rtss_root(); @@ -1132,7 +1132,7 @@ namespace platf { g_recovery_file_owned = write_overrides_file(snapshot); } - return applied_limit; + return limit_applied; } void rtss_streaming_stop(bool keep_process_running) { diff --git a/tests/frontend/config-field-schema.test.ts b/tests/frontend/config-field-schema.test.ts index 6ee00a39d..e0cad4012 100644 --- a/tests/frontend/config-field-schema.test.ts +++ b/tests/frontend/config-field-schema.test.ts @@ -38,6 +38,7 @@ describe('configFieldSchema', () => { expect(field.kind).toBe('number'); expect(field.min).toBe(0); + expect(field.max).toBe(1000); expect(field.step).toBe(1); expect(field.precision).toBe(0); });