Files
Fabian WoltermannandClaude Opus 5 9ec157833b Add command-line option overrides to options system (#7561)
* Add command-line option overrides to options system

- Extend `OptionsManager` to support storing and displaying reasons for overrides.
- Update related code paths to prioritize command-line overrides, ensure correct option resolution, and display override details in the UI.
- Add unit tests for override behavior and round-trip validation.
- Remove unused legacy functions (`removeVSyncOption`, `removeWindowModeOption`) in favor of the new system.

* Add unit tests for `Graphics.Anisotropy` option validation with and without OpenGL

* Add support for associating command-line option names to `OptionsManager` overrides

- Update HDR, shadow rendering, and graphics API option overrides to include their respective command-line flag names.
- Refactor `Graphics.RenderAPI` to use the `GraphicsAPI` enum instead of raw integers for better type safety and clarity.
- Ensure Vulkan support logic and fallback behavior function correctly with the updated options system.
- Update unit tests to validate override functionality and enum-based API configuration.

* Remove unused `removeVSyncOption` function

* Apply the PR review feedback for the command-line option overrides

Delete the unit-test run artifacts that this branch committed by mistake, and
add .gitignore rules for the files that the unittests binary writes.

Give an override priority over an in-session edit again. The options UI now
shows a disabled control for an overridden option, and setConfigValue()
refuses an edit for an overridden key. Before this change, a save wrote the
edit to the config file and applied it to the bound global, but getValue()
kept returning the override. The game and the options menu then showed
different values for the rest of the session.

Add a setOverride() overload that takes a JSON value. Every command-line flag
now builds its override with json_integer, json_real, json_true, json_false
or json_pack, in place of a hand-formatted string.

Make Graphics.CockpitFOV and Graphics.CockpitFOVToggle independent of the
order in which the options manager loads them, and remove the forced override
on the player's toggle preference. The toggle listener also replaces an
identical bind_to() call that made the listener dead code.

Add 16 to the values of Graphics.MSAASamples. The -msaa flag accepts 16, so an
override with that value made printValues() report an invalid value.

Keep the render API that the caller gives to gr_init(). Only a Default value
now comes from the Graphics.RenderAPI option, so FRED and the unit tests keep
the backend that they ask for.

Make the default_func of Graphics.Shadows and Graphics.ShadowRenderMethod
return their global. A constant discarded the value that -enable_shadows and
the mod table set. Set Max_rt_shadow_local_lights to 4, because 3 is not one
of the values that the option offers.

Also: resolve -lang one time, share the render API name with version.cpp,
correct the stale comments, and add unit tests with a fixture that clears the
overrides after each test.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UGh4ZM2u4oUGSbrP122Uyr

* Fix the order-dependent crash in the MSAA option test

The test `MsaaOverrideAcceptsEverySampleCountTheFlagAllows` calls
`getValidValues()`. `Option<T>` makes a display string for each value, and the
display function of `Graphics.MSAASamples` calls `XSTR()`. `XSTR()` runs
`Int3()` if `lcl_xstr_init()` did not run before. Only `FSTestFixture` runs it,
and `Xstr_inited` stays set for the remainder of the process. Thus the shuffled
test order decided if the test passed or if it stopped the test binary with
SIGILL.

Move that one test to a new fixture that derives from `FSTestFixture`. The
fixture initializes localization, and it also clears the overrides after each
test. Refresh the comment that told that the test binary never runs
`lcl_init()`.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UGh4ZM2u4oUGSbrP122Uyr

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-10 13:14:18 +02:00

394 lines
14 KiB
C++

#include "ingame_options_ui.h"
#include "options/OptionsManager.h"
#include "options/Option.h"
#include "options/Ingame_Options_internal.h"
#include "graphics/2d.h"
#include <cmath>
#include "osapi/outwnd.h"
#include "parse/parselo.h"
using namespace ImGui;
// This is the magic right here. It takes a category and searches for relevant options,
// building a control for each one as it goes. It's notable that binary and range options
// in ImGui require a variable reference. But since we're hooking in to the OptionsManager
// system here we can't get to the global references. So isntead we use a local one and when
// that one changes we notify OptionsManager that the option value has been changed and it
// handles updating the global values.
void OptUi::build_options_list(const char* category) const
{
auto& optionsList = options::OptionsManager::instance()->getOptions();
for (auto& option : optionsList) {
const options::OptionBase* thisOpt = option.get();
if (thisOpt->getFlags()[options::OptionFlags::RetailBuiltinOption]) {
continue;
}
if (!stricmp(thisOpt->getCategory().c_str(), category)) {
auto val = thisOpt->getCurrentValueDescription();
// An overridden option (currently only from a command line flag) is not editable: OptionsManager
// always returns the override, so an edit here would never take effect. Show the control greyed out
// with the override source in its tooltip instead of letting the player change a dead value.
auto overrideReason = options::OptionsManager::instance()->getOverrideReason(thisOpt->getConfigKey());
ImGui::BeginDisabled(overrideReason.has_value());
// Selectors
if (thisOpt->getType() == options::OptionType::Selection) {
auto values = thisOpt->getValidValues();
if ((values.size() == 2) && !(thisOpt->getFlags()[options::OptionFlags::ForceMultiValueSelection])) {
// On/Off options
int idx = getOptConfigurator()->get_binary_option_index(thisOpt->getTitle());
Assertion(SCP_vector_inbounds(getOptConfigurator()->binary_options, idx),
"Could not find binary option in ui list! Get a coder!");
if (ImGui::Checkbox(thisOpt->getTitle().c_str(), &getOptConfigurator()->binary_options[idx].second)) {
if (getOptConfigurator()->binary_options[idx].second) {
thisOpt->setValueDescription({val.display, "true"}); // OptionsManager stores values as serialized strings
getOptConfigurator()->options_changed = true;
} else {
thisOpt->setValueDescription({val.display, "false"}); // OptionsManager stores values as serialized strings
getOptConfigurator()->options_changed = true;
}
}
} else {
// Multi Select options
with_Combo(thisOpt->getTitle().c_str(), val.display.c_str())
{
for (int n = 0; n < static_cast<int>(values.size()); n++) {
bool is_selected = (values[n].serialized == val.serialized);
if (Selectable(values[n].display.c_str(), is_selected)) {
thisOpt->setValueDescription(values[n]);
getOptConfigurator()->options_changed = true;
}
if (is_selected) {
SetItemDefaultFocus();
}
}
}
}
// Range Sliders
} else if (thisOpt->getType() == options::OptionType::Range) {
std::pair<float, float> min_max = thisOpt->getRangeValues();
// Integer Ranges
if (thisOpt->getFlags()[options::OptionFlags::RangeTypeInteger]){
int idx = getOptConfigurator()->get_int_range_option_index(thisOpt->getTitle());
Assertion(SCP_vector_inbounds(getOptConfigurator()->range_int_options, idx),
"Could not find int range option in ui list! Get a coder!");
SliderInt(thisOpt->getTitle().c_str(),
&getOptConfigurator()->range_int_options[idx].second,
static_cast<int>(min_max.first),
static_cast<int>(min_max.second),
val.display.c_str());
if (IsItemEdited()) {
SCP_string newVal = std::to_string(getOptConfigurator()->range_int_options[idx].second); // OptionsManager stores values as serialized strings
thisOpt->setValueDescription({newVal.c_str(), newVal.c_str()}); // OptionsManager handles updating the display values if necessary
getOptConfigurator()->options_changed = true;
}
// Float Ranges
} else {
int idx = getOptConfigurator()->get_float_range_option_index(thisOpt->getTitle());
Assertion(SCP_vector_inbounds(getOptConfigurator()->range_float_options, idx),
"Could not find float range option in ui list! Get a coder!");
SliderFloat(thisOpt->getTitle().c_str(),
&getOptConfigurator()->range_float_options[idx].second,
min_max.first,
min_max.second,
val.display.c_str());
if (IsItemEdited()) {
SCP_string newVal = std::to_string(getOptConfigurator()->range_float_options[idx].second); // OptionsManager stores values as serialized strings
thisOpt->setValueDescription({newVal.c_str(), newVal.c_str()}); // OptionsManager handles updating the display values if necessary
getOptConfigurator()->options_changed = true;
}
}
}
// Capture hover state now, since the last ImGui item is still the control itself -- adding the
// override marker below would otherwise become the "last item" and break IsItemHovered() for the
// tooltip. AllowWhenDisabled is needed because the tooltip explains why the control is disabled.
bool isHovered = ImGui::IsItemHovered(ImGuiHoveredFlags_DelayNormal | ImGuiHoveredFlags_NoSharedDelay |
ImGuiHoveredFlags_AllowWhenDisabled);
// End the disabled scope before the marker so that the marker itself stays legible
ImGui::EndDisabled();
if (overrideReason.has_value()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", XSTR("(cmdline)", 1933));
}
// Add a tooltip with the option description on mouseover
if (isHovered) {
if (overrideReason.has_value()) {
ImGui::SetTooltip(XSTR("%s\n\nSet on the command line by %s; it cannot be changed here.", 1934),
thisOpt->getDescription().c_str(), overrideReason->c_str());
} else {
ImGui::SetTooltip("%s", thisOpt->getDescription().c_str());
}
}
}
}
}
// Build the main menu with selectors for each category and a way to save/discard/close
void OptUi::build_options_menu()
{
with_Menu(XSTR("SCP Options", 1830))
{
for (size_t i = 0; i < Option_categories.size(); i++) {
SCP_string title = Option_categories[i].first + " " + XSTR("Options", 1036);
MenuItem(title.c_str(), nullptr, &Option_categories[i].second);
}
Separator();
MenuItem(XSTR("HDR Calibration", -1), nullptr, &show_hdr_calibration);
Separator();
MenuItem(XSTR("Log Filters", -1), nullptr, &show_log_filters_dialog);
MenuItem(XSTR("Save & Close", 1818), XSTR("ESC", 160), &close_and_save);
MenuItem(XSTR("Discard & Close", 1819), nullptr, &close_and_discard);
}
}
// Build the toolbar and handle notifications when closing the ui via this menu
void OptUi::build_toolbar_entries()
{
with_MainMenuBar
{
build_options_menu();
}
if (close_and_save) {
getOptConfigurator()->maybe_persist_changes();
close_and_save = false;
getOptConfigurator()->options_changed = false;
}
if (close_and_discard) {
getOptConfigurator()->discard_changes();
close_and_discard = false;
getOptConfigurator()->options_changed = false;
getOptConfigurator()->notify_close();
}
}
// For each category create a rollout that contains the actual options
void OptUi::create_ui()
{
build_toolbar_entries();
for (const auto &cat : Option_categories) {
if (cat.second) {
SCP_string title = cat.first + " " + XSTR("Options", 1036);
with_Window(title.c_str())
{
build_options_list(cat.first.c_str());
ImGui::SetWindowSize(ImVec2(0, 0)); //Force window to fit content
// It would be nice if there was a way to auto-position windows to avoid overlap here
// but I don't see that functionality currently in ImGui, so we'll have to go without
}
}
}
if (show_log_filters_dialog) {
show_log_filters_window();
}
if (show_hdr_calibration) {
build_hdr_calibration_window();
}
}
// Shows which nprintf()/mprintf() log categories are currently enabled and, if any log
// lines have been captured this session, lets the player browse/search them.
void OptUi::show_log_filters_window()
{
with_Window(XSTR("Log Filters", -1), &show_log_filters_dialog)
{
TextUnformatted(XSTR("Categories are added to this list the first time they are logged this session.", -1));
Separator();
show_log_filter_list();
Separator();
TextUnformatted(XSTR("Log Output", -1));
show_log_output();
if (Button(XSTR("Clear Log", -1))) {
outwnd_clear_log_history();
}
}
}
// Split out of show_log_filters_window() so the with_Child() scope guard below doesn't
// shadow the enclosing with_Window()'s guard (imgui_sugar's scope-guard variable name is fixed,
// so nesting them directly in one function trips MSVC's /WX-promoted C4456).
void OptUi::show_log_filter_list()
{
with_Child("##LogFilterList", ImVec2(0, 150), true)
{
for (auto& filter : outwnd_get_filters()) {
bool enabled = filter.enabled;
if (Checkbox(filter.name, &enabled)) {
outwnd_set_filter_enabled(filter.name, enabled);
}
}
}
}
// See show_log_filter_list() for why this is a separate function.
void OptUi::show_log_output()
{
static char log_search[64] = "";
SetNextItemWidth(-1.0f);
InputTextWithHint("##LogSearch", XSTR("Search log...", -1), log_search, sizeof(log_search));
with_Child("##LogOutput", ImVec2(0, 300), true, ImGuiWindowFlags_HorizontalScrollbar)
{
auto& history = outwnd_get_log_history();
SCP_vector<size_t> visible;
visible.reserve(history.size());
for (size_t i = 0; i < history.size(); i++) {
if (log_search[0] == '\0' || stristr(history[i].text.c_str(), log_search) != nullptr ||
stristr(history[i].category.c_str(), log_search) != nullptr) {
visible.push_back(i);
}
}
ImGuiListClipper clipper;
clipper.Begin(static_cast<int>(visible.size()));
while (clipper.Step()) {
for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; row++) {
auto& entry = history[visible[static_cast<size_t>(row)]];
SCP_string line = entry.text;
while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) {
line.pop_back();
}
Text("[%s] %s", entry.category.c_str(), line.c_str());
}
}
if (GetScrollY() >= GetScrollMaxY()) {
SetScrollHereY(1.0f);
}
}
}
// A dedicated calibration window for tuning HDR paper white / peak luminance and
// sanity-checking the HDR10 output path with simple test patterns. The patches are
// authored in the same sRGB-encoded space the encode pass expects, so a value of
// 1.0 (white) maps to paper white on the display.
void OptUi::build_hdr_calibration_window()
{
with_Window(XSTR("HDR Calibration", -1), &show_hdr_calibration)
{
ImGui::SetWindowSize(ImVec2(560, 0), ImGuiCond_FirstUseEver);
// Output status
if (Gr_hdr_output_active) {
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "%s", XSTR("HDR10 output is ACTIVE.", -1));
} else if (Gr_enable_hdr) {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s",
XSTR("HDR is enabled but not active. Use the Vulkan renderer with an HDR-capable display and restart.", -1));
} else {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s",
XSTR("HDR output is disabled. Enable 'HDR Output' under Graphics options and restart.", -1));
}
Separator();
auto* mgr = options::OptionsManager::instance();
const options::OptionBase* pwOpt = mgr->getOptionByKey("Graphics.HDRPaperWhite");
const options::OptionBase* pkOpt = mgr->getOptionByKey("Graphics.HDRPeakLuminance");
// Live paper white slider, bound to the persistent option
if (pwOpt != nullptr) {
std::pair<float, float> rng = pwOpt->getRangeValues();
float v = Gr_hdr_paperwhite_nits;
if (SliderFloat(XSTR("Paper White (nits)", -1), &v, rng.first, rng.second, "%.0f")) {
SCP_string s = std::to_string(v);
pwOpt->setValueDescription({s, s});
getOptConfigurator()->options_changed = true;
}
Gr_hdr_paperwhite_nits = v;
}
// Live peak luminance slider, bound to the persistent option
if (pkOpt != nullptr) {
std::pair<float, float> rng = pkOpt->getRangeValues();
float v = Gr_hdr_peak_nits;
if (SliderFloat(XSTR("Peak Luminance (nits)", -1), &v, rng.first, rng.second, "%.0f")) {
SCP_string s = std::to_string(v);
pkOpt->setValueDescription({s, s});
getOptConfigurator()->options_changed = true;
}
Gr_hdr_peak_nits = v;
}
Separator();
ImGui::TextWrapped("%s",
XSTR("Adjust Paper White until the 100%% patch looks like a comfortable SDR white. "
"The grayscale ramp below is paper-white-relative.", -1));
auto* dl = ImGui::GetWindowDrawList();
const float paperwhite = (Gr_hdr_paperwhite_nits > 1.0f) ? Gr_hdr_paperwhite_nits : 1.0f;
const float pw = 40.0f, ph = 56.0f, gap = 4.0f;
// Paper-white-relative grayscale ramp. Each patch is encoded sRGB so that the
// HDR encode pass reproduces the requested nit level (clamped to paper white).
const float nit_targets[] = {2.0f, 5.0f, 10.0f, 20.0f, 40.0f, 80.0f, 120.0f, 160.0f, 200.0f};
const int n = static_cast<int>(sizeof(nit_targets) / sizeof(nit_targets[0]));
ImVec2 p = ImGui::GetCursorScreenPos();
for (int i = 0; i < n; ++i) {
float lin = nit_targets[i] / paperwhite;
if (lin > 1.0f) {
lin = 1.0f;
}
float enc = powf(lin, 1.0f / 2.2f);
int c = static_cast<int>(enc * 255.0f + 0.5f);
c = (c < 0) ? 0 : ((c > 255) ? 255 : c);
ImVec2 a(p.x + i * (pw + gap), p.y);
ImVec2 b(a.x + pw, a.y + ph);
dl->AddRectFilled(a, b, IM_COL32(c, c, c, 255));
}
ImGui::Dummy(ImVec2(n * (pw + gap), ph + 6));
// Primary color patches (full intensity == paper white) to sanity-check the
// BT.709 -> BT.2020 mapping when HDR is active.
ImVec2 cp = ImGui::GetCursorScreenPos();
const ImU32 prim[] = {IM_COL32(255, 0, 0, 255), IM_COL32(0, 255, 0, 255),
IM_COL32(0, 0, 255, 255), IM_COL32(255, 255, 255, 255)};
for (int i = 0; i < 4; ++i) {
ImVec2 a(cp.x + i * (pw + gap), cp.y);
ImVec2 b(a.x + pw, a.y + ph);
dl->AddRectFilled(a, b, prim[i]);
}
ImGui::Dummy(ImVec2(4 * (pw + gap), ph + 6));
ImGui::TextWrapped("%s",
XSTR("Note: these patches cannot exceed paper white, so tune Peak Luminance in-game by "
"raising it until bright highlights and explosions stop clipping.", -1));
}
}