diff --git a/docs/configuration.md b/docs/configuration.md
index 0669a9a59..f463ca17b 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,33 @@ 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 |
+
+
+ | Range |
+ @code{}0-1000@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..f1a73ba92 100644
--- a/src/platform/windows/rtss_integration.cpp
+++ b/src/platform/windows/rtss_integration.cpp
@@ -1013,13 +1013,13 @@ 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;
}
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();
@@ -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) {
@@ -1128,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/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.max).toBe(1000);
+ expect(field.step).toBe(1);
+ expect(field.precision).toBe(0);
+ });
});