From 1b94e9339ac3d316283168e6d93445fa53c42bc6 Mon Sep 17 00:00:00 2001 From: Lukas Senionis Date: Sun, 12 Jan 2025 21:14:20 +0200 Subject: [PATCH] feat(display): add display mode remapping option (#3529) Co-authored-by: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> --- docs/configuration.md | 86 +++++++ src/config.cpp | 34 +++ src/config.h | 14 ++ src/display_device.cpp | 204 +++++++++++++++- src_assets/common/assets/web/config.html | 35 +-- .../assets/web/configs/tabs/AudioVideo.vue | 2 - .../assets/web/configs/tabs/General.vue | 13 +- .../tabs/audiovideo/DisplayDeviceOptions.vue | 108 ++++++++- .../tabs/audiovideo/DisplayModesSettings.vue | 5 - .../assets/web/public/assets/locale/en.json | 13 + tests/unit/test_display_device.cpp | 222 ++++++++++++++++++ 11 files changed, 701 insertions(+), 35 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 57bb6d9f25a..fb36e1593d6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1234,6 +1234,92 @@ editing the `conf` file in a text editor. Use the examples as reference. +### dd_mode_remapping + + + + + + + + + + + + + + +
Description + Remap the requested resolution and FPS to another display mode.
+ Depending on the [dd_resolution_option](#dd_resolution_option) and + [dd_refresh_rate_option](#dd_refresh_rate_option) values, the following mapping + groups are available: +
    +
  • `mixed` - both options are set to `auto`.
  • +
  • + `resolution_only` - only [dd_resolution_option](#dd_resolution_option) is set to `auto`. +
  • +
  • + `refresh_rate_only` - only [dd_refresh_rate_option](#dd_refresh_rate_option) is set to `auto`. +
  • +
+ For each of those groups, a list of fields can be configured to perform remapping: +
    +
  • + `requested_resolution` - resolution that needs to be matched in order to use this remapping entry. +
  • +
  • `requested_fps` - FPS that needs to be matched in order to use this remapping entry.
  • +
  • `final_resolution` - resolution value to be used if the entry was matched.
  • +
  • `final_refresh_rate` - refresh rate value to be used if the entry was matched.
  • +
+ If `requested_*` field is left empty, it will match everything.
+ If `final_*` field is left empty, the original value will not be remapped and either a requested, manual + or current value is used. However, at least one `final_*` must be set, otherwise the entry is considered + invalid.
+ @note{"Optimize game settings" must be enabled on client side for ANY entry with `resolution` + field to be considered.} + @note{First entry to be matched in the list is the one that will be used.} + @tip{`requested_resolution` and `final_resolution` can be omitted for `refresh_rate_only` group.} + @tip{`requested_fps` and `final_refresh_rate` can be omitted for `resolution_only` group.} + @note{Applies to Windows only.} +
Default@code{} + dd_mode_remapping = { + "mixed": [], + "resolution_only": [], + "refresh_rate_only": [] + } + @endcode +
Example@code{} + dd_mode_remapping = { + "mixed": [ + { + "requested_fps": "60", + "final_refresh_rate": "119.95", + "requested_resolution": "1920x1080", + "final_resolution": "2560x1440" + }, + { + "requested_fps": "60", + "final_refresh_rate": "120", + "requested_resolution": "", + "final_resolution": "" + } + ], + "resolution_only": [ + { + "requested_resolution": "1920x1080", + "final_resolution": "2560x1440" + } + ], + "refresh_rate_only": [ + { + "requested_fps": "60", + "final_refresh_rate": "119.95" + } + ] + }@endcode +
+ ### min_fps_factor diff --git a/src/config.cpp b/src/config.cpp index cf2b90a208c..25a2f51eb58 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -379,6 +379,38 @@ namespace config { #undef _CONVERT_2_ARG_ return video_t::dd_t::hdr_option_e::disabled; // Default to this if value is invalid } + + video_t::dd_t::mode_remapping_t + mode_remapping_from_view(const std::string_view value) { + const auto parse_entry_list { [](const auto &entry_list, auto &output_field) { + for (auto &[_, entry] : entry_list) { + auto requested_resolution = entry.template get_optional("requested_resolution"s); + auto requested_fps = entry.template get_optional("requested_fps"s); + auto final_resolution = entry.template get_optional("final_resolution"s); + auto final_refresh_rate = entry.template get_optional("final_refresh_rate"s); + + output_field.push_back(video_t::dd_t::mode_remapping_entry_t { + requested_resolution.value_or(""), + requested_fps.value_or(""), + final_resolution.value_or(""), + final_refresh_rate.value_or("") }); + } + } }; + + // We need to add a wrapping object to make it valid JSON, otherwise ptree cannot parse it. + std::stringstream json_stream; + json_stream << "{\"dd_mode_remapping\":" << value << "}"; + + boost::property_tree::ptree json_tree; + boost::property_tree::read_json(json_stream, json_tree); + + video_t::dd_t::mode_remapping_t output; + parse_entry_list(json_tree.get_child("dd_mode_remapping.mixed"), output.mixed); + parse_entry_list(json_tree.get_child("dd_mode_remapping.resolution_only"), output.resolution_only); + parse_entry_list(json_tree.get_child("dd_mode_remapping.refresh_rate_only"), output.refresh_rate_only); + + return output; + } } // namespace dd video_t video { @@ -447,6 +479,7 @@ namespace config { {}, // manual_refresh_rate video_t::dd_t::hdr_option_e::automatic, // hdr_option 3s, // config_revert_delay + {}, // mode_remapping {} // wa }, // display_device @@ -1105,6 +1138,7 @@ namespace config { video.dd.config_revert_delay = std::chrono::milliseconds { value }; } } + generic_f(vars, "dd_mode_remapping", video.dd.mode_remapping, dd::mode_remapping_from_view); bool_f(vars, "dd_wa_hdr_toggle", video.dd.wa.hdr_toggle); int_between_f(vars, "min_fps_factor", video.min_fps_factor, { 1, 3 }); diff --git a/src/config.h b/src/config.h index 7e1813e6324..c8fea38c9f0 100644 --- a/src/config.h +++ b/src/config.h @@ -110,6 +110,19 @@ namespace config { automatic ///< Change HDR settings and use the state requested by Moonlight. }; + struct mode_remapping_entry_t { + std::string requested_resolution; + std::string requested_fps; + std::string final_resolution; + std::string final_refresh_rate; + }; + + struct mode_remapping_t { + std::vector mixed; ///< To be used when `resolution_option` and `refresh_rate_option` is set to `automatic`. + std::vector resolution_only; ///< To be use when only `resolution_option` is set to `automatic`. + std::vector refresh_rate_only; ///< To be use when only `refresh_rate_option` is set to `automatic`. + }; + config_option_e configuration_option; resolution_option_e resolution_option; std::string manual_resolution; ///< Manual resolution in case `resolution_option == resolution_option_e::manual`. @@ -117,6 +130,7 @@ namespace config { std::string manual_refresh_rate; ///< Manual refresh rate in case `refresh_rate_option == refresh_rate_option_e::manual`. hdr_option_e hdr_option; std::chrono::milliseconds config_revert_delay; ///< Time to wait until settings are reverted (after stream ends/app exists). + mode_remapping_t mode_remapping; workarounds_t wa; } dd; diff --git a/src/display_device.cpp b/src/display_device.cpp index 6c2a869f671..e337e9a8b78 100644 --- a/src/display_device.cpp +++ b/src/display_device.cpp @@ -188,6 +188,7 @@ namespace display_device { * @brief Parse refresh rate value from the string. * @param input String to be parsed. * @param output Reference to output variable to fill in. + * @param allow_decimal_point Specify whether the decimal point is allowed or not. * @returns True on successful parsing (empty string allowed), false otherwise. * * @examples @@ -203,10 +204,10 @@ namespace display_device { * @examples_end */ bool - parse_refresh_rate_string(const std::string &input, std::optional &output) { + parse_refresh_rate_string(const std::string &input, std::optional &output, const bool allow_decimal_point = true) { static const auto is_zero { [](const auto &character) { return character == '0'; } }; const std::string trimmed_input { boost::algorithm::trim_copy(input) }; - const std::regex refresh_rate_regex { R"(^(\d+)(?:\.(\d+))?$)" }; + const std::regex refresh_rate_regex { allow_decimal_point ? R"(^(\d+)(?:\.(\d+))?$)" : R"(^(\d+)$)" }; if (std::smatch match; std::regex_match(trimmed_input, match, refresh_rate_regex)) { try { @@ -217,7 +218,7 @@ namespace display_device { } std::string trimmed_match_2; - if (match[2].matched) { + if (allow_decimal_point && match[2].matched) { trimmed_match_2 = boost::algorithm::trim_right_copy_if(match[2].str(), is_zero); } @@ -261,7 +262,7 @@ namespace display_device { return true; } - BOOST_LOG(error) << "Failed to parse refresh rate string " << trimmed_input << R"(. Must have a pattern of "123" or "123.456"!)"; + BOOST_LOG(error) << "Failed to parse refresh rate string " << trimmed_input << ". Must have a pattern of " << (allow_decimal_point ? R"("123" or "123.456")" : R"("123")") << "!"; } return false; @@ -435,6 +436,196 @@ namespace display_device { return std::nullopt; } + /** + * @brief Indicates which remapping fields and config structure shall be used. + */ + enum class remapping_type_e { + mixed, ///! Both reseolution and refresh rate may be remapped + resolution_only, ///! Only resolution will be remapped + refresh_rate_only ///! Only refresh rate will be remapped + }; + + /** + * @brief Determine the ramapping type from the user config. + * @param video_config User's video related configuration. + * @returns Enum value if remapping can be performed, null optional if remapping shall be skipped. + */ + std::optional + determine_remapping_type(const config::video_t &video_config) { + using dd_t = config::video_t::dd_t; + const bool auto_resolution { video_config.dd.resolution_option == dd_t::resolution_option_e::automatic }; + const bool auto_refresh_rate { video_config.dd.refresh_rate_option == dd_t::refresh_rate_option_e::automatic }; + + if (auto_resolution && auto_refresh_rate) { + return remapping_type_e::mixed; + } + + if (auto_resolution) { + return remapping_type_e::resolution_only; + } + + if (auto_refresh_rate) { + return remapping_type_e::refresh_rate_only; + } + + return std::nullopt; + } + + /** + * @brief Contains remapping data parsed from the string values. + */ + struct parsed_remapping_entry_t { + std::optional requested_resolution; + std::optional requested_fps; + std::optional final_resolution; + std::optional final_refresh_rate; + }; + + /** + * @brief Check if resolution is to be mapped based on remmaping type. + * @param type Remapping type to check. + * @returns True if resolution is to be mapped, false otherwise. + */ + bool + is_resolution_mapped(const remapping_type_e type) { + return type == remapping_type_e::resolution_only || type == remapping_type_e::mixed; + } + + /** + * @brief Check if FPS is to be mapped based on remmaping type. + * @param type Remapping type to check. + * @returns True if FPS is to be mapped, false otherwise. + */ + bool + is_fps_mapped(const remapping_type_e type) { + return type == remapping_type_e::refresh_rate_only || type == remapping_type_e::mixed; + } + + /** + * @brief Parse the remapping entry from the config into an internal structure. + * @param entry Entry to parse. + * @param type Specify which entry fields should be parsed. + * @returns Parsed structure or null optional if a necessary field could not be parsed. + */ + std::optional + parse_remapping_entry(const config::video_t::dd_t::mode_remapping_entry_t &entry, const remapping_type_e type) { + parsed_remapping_entry_t result {}; + + if (is_resolution_mapped(type) && (!parse_resolution_string(entry.requested_resolution, result.requested_resolution) || + !parse_resolution_string(entry.final_resolution, result.final_resolution))) { + return std::nullopt; + } + + if (is_fps_mapped(type) && (!parse_refresh_rate_string(entry.requested_fps, result.requested_fps, false) || + !parse_refresh_rate_string(entry.final_refresh_rate, result.final_refresh_rate))) { + return std::nullopt; + } + + return result; + } + + /** + * @brief Remap the the requested display mode based on the config. + * @param video_config User's video related configuration. + * @param session Session information. + * @param config A reference to a config object that will be modified on success. + * @returns True if the remapping was performed or skipped, false if remapping has failed due to invalid config. + * + * @examples + * const std::shared_ptr launch_session; + * const config::video_t &video_config { config::video }; + * + * SingleDisplayConfiguration config; + * const bool success = remap_display_mode_if_needed(video_config, *launch_session, config); + * @examples_end + */ + bool + remap_display_mode_if_needed(const config::video_t &video_config, const rtsp_stream::launch_session_t &session, SingleDisplayConfiguration &config) { + const auto remapping_type { determine_remapping_type(video_config) }; + if (!remapping_type) { + return true; + } + + const auto &remapping_list { [&]() { + using enum remapping_type_e; + + switch (*remapping_type) { + case resolution_only: + return video_config.dd.mode_remapping.resolution_only; + case refresh_rate_only: + return video_config.dd.mode_remapping.refresh_rate_only; + case mixed: + default: + return video_config.dd.mode_remapping.mixed; + } + }() }; + + if (remapping_list.empty()) { + BOOST_LOG(debug) << "No values are available for display mode remapping."; + return true; + } + BOOST_LOG(debug) << "Trying to remap display modes..."; + + const auto entry_to_string { [type = *remapping_type](const config::video_t::dd_t::mode_remapping_entry_t &entry) { + const bool mapping_resolution { is_resolution_mapped(type) }; + const bool mapping_fps { is_fps_mapped(type) }; + + // clang-format off + return (mapping_resolution ? " - requested resolution: "s + entry.requested_resolution + "\n" : "") + + (mapping_fps ? " - requested FPS: "s + entry.requested_fps + "\n" : "") + + (mapping_resolution ? " - final resolution: "s + entry.final_resolution + "\n" : "") + + (mapping_fps ? " - final refresh rate: "s + entry.final_refresh_rate : ""); + // clang-format on + } }; + + for (const auto &entry : remapping_list) { + const auto parsed_entry { parse_remapping_entry(entry, *remapping_type) }; + if (!parsed_entry) { + BOOST_LOG(error) << "Failed to parse remapping entry from:\n" + << entry_to_string(entry); + return false; + } + + if (!parsed_entry->final_resolution && !parsed_entry->final_refresh_rate) { + BOOST_LOG(error) << "At least one final value must be set for remapping display modes! Entry:\n" + << entry_to_string(entry); + return false; + } + + if (!session.enable_sops && (parsed_entry->requested_resolution || parsed_entry->final_resolution)) { + BOOST_LOG(warning) << R"(Skipping remapping entry, because the "Optimize game settings" is not set in the client! Entry:\n)" + << entry_to_string(entry); + continue; + } + + // Note: at this point config should already have parsed resolution set. + if (parsed_entry->requested_resolution && parsed_entry->requested_resolution != config.m_resolution) { + BOOST_LOG(verbose) << "Skipping remapping because requested resolutions do not match! Entry:\n" + << entry_to_string(entry); + continue; + } + + // Note: at this point config should already have parsed refresh rate set. + if (parsed_entry->requested_fps && parsed_entry->requested_fps != config.m_refresh_rate) { + BOOST_LOG(verbose) << "Skipping remapping because requested FPS do not match! Entry:\n" + << entry_to_string(entry); + continue; + } + + BOOST_LOG(info) << "Remapping requested display mode. Entry:\n" + << entry_to_string(entry); + if (parsed_entry->final_resolution) { + config.m_resolution = parsed_entry->final_resolution; + } + if (parsed_entry->final_refresh_rate) { + config.m_refresh_rate = parsed_entry->final_refresh_rate; + } + break; + } + + return true; + } + /** * @brief Construct a settings manager interface to manage display device settings. * @param persistence_filepath File location for saving persistent state. @@ -626,6 +817,11 @@ namespace display_device { return failed_to_parse_tag_t {}; } + if (!remap_display_mode_if_needed(video_config, session, config)) { + // Error already logged + return failed_to_parse_tag_t {}; + } + return config; } } // namespace display_device diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index 3eec36d7842..e6f4f2f7362 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -33,7 +33,6 @@

{{ $t('config.configuration') }}

@@ -127,7 +126,6 @@

{{ $t('config.configuration') }}

restarted: false, config: null, currentTab: "general", - global_prep_cmd: [], tabs: [ // TODO: Move the options to each Component instead, encapsulate. { id: "general", @@ -136,7 +134,7 @@

{{ $t('config.configuration') }}

"locale": "en", "sunshine_name": "", "min_log_level": 2, - "global_prep_cmd": "[]", + "global_prep_cmd": [], "notify_pre_releases": "disabled", }, }, @@ -178,6 +176,7 @@

{{ $t('config.configuration') }}

"dd_manual_refresh_rate": "", "dd_hdr_option": "auto", "dd_config_revert_delay": 3000, + "dd_mode_remapping": {"mixed": [], "resolution_only": [], "refresh_rate_only": []}, "dd_wa_hdr_toggle": "disabled", "min_fps_factor": 1, }, @@ -320,17 +319,23 @@

{{ $t('config.configuration') }}

// TODO: let each tab's Component handle it's own data instead of doing it here + // Parse the special options before population if available + const specialOptions = ["dd_mode_remapping", "global_prep_cmd"] + for (const optionKey of specialOptions) { + if (this.config.hasOwnProperty(optionKey)) { + this.config[optionKey] = JSON.parse(this.config[optionKey]); + } + } + // Populate default values from tabs options this.tabs.forEach(tab => { Object.keys(tab.options).forEach(optionKey => { if (this.config[optionKey] === undefined) { - this.config[optionKey] = tab.options[optionKey]; + // Make sure to copy by value + this.config[optionKey] = JSON.parse(JSON.stringify(tab.options[optionKey])); } }); }); - - this.config.global_prep_cmd = this.config.global_prep_cmd || []; - this.global_prep_cmd = JSON.parse(this.config.global_prep_cmd); }); }, methods: { @@ -338,26 +343,26 @@

{{ $t('config.configuration') }}

this.$forceUpdate() }, serialize() { - this.config.global_prep_cmd = JSON.stringify(this.global_prep_cmd); + let config = JSON.parse(JSON.stringify(this.config)); + config.global_prep_cmd = JSON.stringify(config.global_prep_cmd); + config.dd_mode_remapping = JSON.stringify(config.dd_mode_remapping); + return config; }, save() { this.saved = false; this.restarted = false; - this.serialize(); // create a temp copy of this.config to use for the post request - let config = JSON.parse(JSON.stringify(this.config)) + let config = this.serialize(); // delete default values from this.config this.tabs.forEach(tab => { Object.keys(tab.options).forEach(optionKey => { let delete_value = false - if (["global_prep_cmd"].includes(optionKey)) { - let config_value, default_value - - config_value = JSON.parse(config[optionKey]) - default_value = JSON.parse(tab.options[optionKey]) + if (["global_prep_cmd", "dd_mode_remapping"].includes(optionKey)) { + const config_value = config[optionKey] + const default_value = JSON.stringify(tab.options[optionKey]) if (config_value === default_value) { delete_value = true diff --git a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue index dfcc1c4e5d7..cfd7e373e02 100644 --- a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue +++ b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue @@ -11,7 +11,6 @@ import Checkbox from "../../Checkbox.vue"; const props = defineProps([ 'platform', 'config', - 'min_fps_factor', ]) const config = ref(props.config) @@ -95,7 +94,6 @@ const config = ref(props.config) diff --git a/src_assets/common/assets/web/configs/tabs/General.vue b/src_assets/common/assets/web/configs/tabs/General.vue index 74f43938dc8..d2dc739255f 100644 --- a/src_assets/common/assets/web/configs/tabs/General.vue +++ b/src_assets/common/assets/web/configs/tabs/General.vue @@ -4,12 +4,9 @@ import { ref } from 'vue' const props = defineProps({ platform: String, - config: Object, - globalPrepCmd: Array + config: Object }) - const config = ref(props.config) -const globalPrepCmd = ref(props.globalPrepCmd) function addCmd() { let template = { @@ -20,11 +17,11 @@ function addCmd() { if (props.platform === 'windows') { template = { ...template, elevated: false }; } - globalPrepCmd.value.push(template); + config.value.global_prep_cmd.push(template); } function removeCmd(index) { - globalPrepCmd.value.splice(index,1) + config.value.global_prep_cmd.splice(index,1) } @@ -83,7 +80,7 @@ function removeCmd(index) {
{{ $t('config.global_prep_cmd_desc') }}
-
+
@@ -95,7 +92,7 @@ function removeCmd(index) { - + diff --git a/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayDeviceOptions.vue b/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayDeviceOptions.vue index 379d1344bfc..ee2951733a7 100644 --- a/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayDeviceOptions.vue +++ b/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayDeviceOptions.vue @@ -7,8 +7,44 @@ const props = defineProps({ platform: String, config: Object }) - const config = ref(props.config) + +const REFRESH_RATE_ONLY = "refresh_rate_only" +const RESOLUTION_ONLY = "resolution_only" +const MIXED = "mixed" + +function canBeRemapped() { + return (config.value.dd_resolution_option === "auto" || config.value.dd_refresh_rate_option === "auto") + && config.value.dd_configuration_option !== "disabled"; +} + +function getRemappingType() { + // Assuming here that at least one setting is set to "auto" if other is not + if (config.value.dd_resolution_option !== "auto") { + return REFRESH_RATE_ONLY; + } + if (config.value.dd_refresh_rate_option !== "auto") { + return RESOLUTION_ONLY; + } + return MIXED; +} + +function addRemappingEntry() { + const type = getRemappingType(); + let template = {}; + + if (type !== RESOLUTION_ONLY) { + template["requested_fps"] = ""; + template["final_refresh_rate"] = ""; + } + + if (type !== REFRESH_RATE_ONLY) { + template["requested_resolution"] = ""; + template["final_resolution"] = ""; + } + + config.value.dd_mode_remapping[type].push(template); +}
{{ $t('_common.do_cmd') }}