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]>
This commit is contained in:
Fabian WoltermannandClaude Opus 5 authored and GitHub committed 2026-09-10 13:14:18 +02:00
1 parent a6136507ff
commit 9ec157833b
15 files changed
+767 -79

No files matched your search

+5
View File
@@ -262,3 +262,8 @@ CMakePresets.json
!/docker/build
CMakeFiles/cmake.check_cache
CMakeCache.txt
# Written by the unittests binary at runtime
test/test_data/**/*_settings.ini
test/test_data/data/players/presets/asdf.json
test/test_data/pilotfile/data/asdf.hdp
+8 -9
View File
@@ -116,11 +116,9 @@ auto CockpitFovOption = options::OptionBuilder<float>("Graphics.CockpitFOV",
.category(std::make_pair("Graphics", 1825))
.range(0.436332f, 1.5708f)
.change_listener([](const float& val, bool) {
if (Use_cockpit_fov){
COCKPIT_ZOOM_DEFAULT = val;
} else {
COCKPIT_ZOOM_DEFAULT = VIEWER_ZOOM_DEFAULT;
}
// Graphics.CockpitFOVToggle has its own listener that re-applies this value, so the
// result is the same whichever of the two options the manager happens to load first.
COCKPIT_ZOOM_DEFAULT = Use_cockpit_fov ? fov_t(val) : VIEWER_ZOOM_DEFAULT;
return true;
})
.display(fov_display)
@@ -136,14 +134,15 @@ auto CockpitFOVToggleOption = options::OptionBuilder<bool>("Graphics.CockpitFOVT
std::pair<const char*, int>{"Whether or not to use a different FOV for cockpit rendering from normal rendering", 1839})
.category(std::make_pair("Graphics", 1825))
.default_func([]() {return cockpit_fov_toggle_default;})
// This does the work of bind_to(&Use_cockpit_fov) and then re-applies Graphics.CockpitFOV,
// because that option's listener needs Use_cockpit_fov to already hold its final value.
// Doing it here makes the pair of options independent of the order the manager loads them in.
.change_listener([](bool val, bool) {
if (!val) {
COCKPIT_ZOOM_DEFAULT = VIEWER_ZOOM_DEFAULT;
}
Use_cockpit_fov = val;
COCKPIT_ZOOM_DEFAULT = val ? fov_t(CockpitFovOption->getValue()) : VIEWER_ZOOM_DEFAULT;
return true; // This option will always persist so we never return false
})
.level(options::ExpertLevel::Advanced)
.bind_to(&Use_cockpit_fov)
.importance(61)
.parser(parse_cockpit_fov_toggle_func)
.finish();
+98 -24
View File
@@ -15,6 +15,7 @@
#include "globalincs/pstypes.h"
#include "globalincs/systemvars.h"
#include "globalincs/version.h"
#include "graphics/2d.h"
#include "graphics/openxr.h"
#include "graphics/shadows.h"
#include "hud/hudconfig.h"
@@ -1780,38 +1781,44 @@ bool SetCmdlineParams()
}
// d3d windowed
// We need to set both the raw global (for the legacy !Using_in_game_options path) and the
// Option<T> override (for the modern path) since either backend may be the one actually consulted.
if(window_arg.found()) {
// We need to set both values since we don't know if we are going to use the new config system
//options::OptionsManager::instance()->setOverride("Graphics.WindowMode", "0");
options::OptionsManager::instance()->setOverride("Graphics.WindowMode",
std::unique_ptr<json_t>(json_integer(static_cast<int>(os::ViewportState::Windowed))), "-window");
Cmdline_window = true;
}
if ( fullscreen_window_arg.found( ) )
{
//options::OptionsManager::instance()->setOverride("Graphics.WindowMode", "1");
options::OptionsManager::instance()->setOverride("Graphics.WindowMode",
std::unique_ptr<json_t>(json_integer(static_cast<int>(os::ViewportState::Borderless))),
"-fullscreen_window");
Cmdline_fullscreen_window = true;
Cmdline_window = false; /* Make sure no-one sets both */
}
if(render_res_arg.found() || deprecated_res_arg.found()){
const char* res_flag_name;
if (render_res_arg.found()) {
Cmdline_res = render_res_arg.str();
res_flag_name = "-render_res";
}
else {
Cmdline_res = deprecated_res_arg.str();
res_flag_name = "-res";
mprintf(("Deprecated -res argument. Use -render_res instead...\n"));
}
/*int width = 0;
int width = 0;
int height = 0;
if (sscanf(Cmdline_res, "%dx%d", &width, &height) == 2) {
SCP_string override;
sprintf(override, "{\"width\":%d,\"height\":%d}", width, height);
options::OptionsManager::instance()->setOverride("Graphics.Resolution", override);
options::OptionsManager::instance()->setOverride("Graphics.Resolution",
std::unique_ptr<json_t>(json_pack("{s:i, s:i}", "width", width, "height", height)), res_flag_name);
} else {
Warning(LOCATION, "Failed to parse -res parameter \"%s\". Must be in format \"<width>x<height>\".\n", Cmdline_res);
}*/
}
}
if(window_res_arg.found()){
int width = 0;
@@ -1829,15 +1836,17 @@ bool SetCmdlineParams()
}
if (hdr_nits_arg.found()) {
// presence of the flag alone forces HDR output on, overriding the in-game HDR setting
options::OptionsManager::instance()->setOverride("Graphics.HDR", "true");
options::OptionsManager::instance()->setOverride("Graphics.HDR",
std::unique_ptr<json_t>(json_true()), "-hdr");
if (hdr_nits_arg.has_param()) {
float paperwhite_nits = 0.0f, peak_nits = 0.0f;
if (sscanf(hdr_nits_arg.str(), "%f,%f", &paperwhite_nits, &peak_nits) == 2 && paperwhite_nits > 0.0f && peak_nits > 0.0f) {
// std::to_string always includes a decimal point, so this is a valid JSON real
// (json_unpack's "f" format rejects a bare integer like "400")
options::OptionsManager::instance()->setOverride("Graphics.HDRPaperWhite", std::to_string(paperwhite_nits));
options::OptionsManager::instance()->setOverride("Graphics.HDRPeakLuminance", std::to_string(peak_nits));
// json_real keeps these JSON reals; json_unpack's "f" format rejects a bare integer like "400"
options::OptionsManager::instance()->setOverride("Graphics.HDRPaperWhite",
std::unique_ptr<json_t>(json_real(paperwhite_nits)), "-hdr");
options::OptionsManager::instance()->setOverride("Graphics.HDRPeakLuminance",
std::unique_ptr<json_t>(json_real(peak_nits)), "-hdr");
} else {
Warning(LOCATION, "Failed to parse -hdr parameter \"%s\". Must be in format \"<paperwhite_nits>,<peak_nits>\".\n", hdr_nits_arg.str());
}
@@ -1935,17 +1944,20 @@ bool SetCmdlineParams()
if ( fov_arg.found() ) {
auto val = fov_arg.get_float();
if (val > 0.1) {
VIEWER_ZOOM_DEFAULT = val;
} else {
VIEWER_ZOOM_DEFAULT = DEFAULT_FOV;
if (val <= 0.1f) {
val = DEFAULT_FOV;
}
VIEWER_ZOOM_DEFAULT = val;
options::OptionsManager::instance()->setOverride("Graphics.FOV",
std::unique_ptr<json_t>(json_real(val)), "-fov");
}
if ( fov_cockpit_arg.found() ) {
auto val = fov_cockpit_arg.get_float();
if (val > 0.1) {
if (val > 0.1f) {
COCKPIT_ZOOM_DEFAULT = val;
options::OptionsManager::instance()->setOverride("Graphics.CockpitFOV",
std::unique_ptr<json_t>(json_real(val)), "-fov_cockpit");
}
else {
COCKPIT_ZOOM_DEFAULT = VIEWER_ZOOM_DEFAULT;
@@ -1968,13 +1980,18 @@ bool SetCmdlineParams()
Cmdline_fullscreen_window = false;
Cmdline_window = true; /* Make sure no-one sets both */
removeWindowModeOption();
// These replace the removeWindowModeOption()/removeVSyncOption() calls that used to hide these two
// options in VR. An override keeps them visible but disabled in the options menu, with the tooltip
// naming -vr as the reason, which tells the player more than the control simply not being there.
options::OptionsManager::instance()->setOverride("Graphics.WindowMode",
std::unique_ptr<json_t>(json_integer(static_cast<int>(os::ViewportState::Windowed))), "-vr");
// Turn off VSync, since we definitely don't want to be capped by the main monitor
// the OpenXR runtime should have a buffered wait which will behave, effectively,
// as a VSync to VR goggle driver
Gr_enable_vsync = false;
removeVSyncOption();
options::OptionsManager::instance()->setOverride("Graphics.VSync",
std::unique_ptr<json_t>(json_false()), "-vr");
openxr_prepare();
}
@@ -2029,6 +2046,8 @@ bool SetCmdlineParams()
if(no_vsync_arg.found() )
{
Gr_enable_vsync = false;
options::OptionsManager::instance()->setOverride("Graphics.VSync",
std::unique_ptr<json_t>(json_false()), "-no_vsync");
}
if ( normal_arg.found() ) {
@@ -2062,11 +2081,15 @@ bool SetCmdlineParams()
case 5:
Gr_aa_mode = AntiAliasMode::SMAA_High;
break;
case 6:
case 6:
Gr_aa_mode = AntiAliasMode::SMAA_Ultra;
break;
}
}
options::OptionsManager::instance()->setOverride("Graphics.AAMode",
std::unique_ptr<json_t>(json_integer(static_cast<int>(Gr_aa_mode))),
post_process_aa_preset_arg.found() ? "-aa_preset" : "-aa");
}
if (msaa_enabled_arg.found()) {
@@ -2082,6 +2105,9 @@ bool SetCmdlineParams()
Cmdline_msaa_enabled = 0;
break;
}
options::OptionsManager::instance()->setOverride("Graphics.MSAASamples",
std::unique_ptr<json_t>(json_integer(Cmdline_msaa_enabled)), "-msaa");
}
if ( glow_arg.found() )
@@ -2207,6 +2233,8 @@ bool SetCmdlineParams()
if ( softparticles_arg.found() )
{
Cmdline_softparticles = 1;
options::OptionsManager::instance()->setOverride("Graphics.SoftParticles",
std::unique_ptr<json_t>(json_true()), "-soft_particles");
}
if ( fb_explosions_arg.found() )
@@ -2214,14 +2242,31 @@ bool SetCmdlineParams()
Gr_framebuffer_effects.set(FramebufferEffects::Shockwaves, true);
}
if (fb_thrusters_arg.found())
if (fb_thrusters_arg.found())
{
Gr_framebuffer_effects.set(FramebufferEffects::Thrusters, true);
}
if (fb_explosions_arg.found() || fb_thrusters_arg.found()) {
SCP_string reason;
if (fb_explosions_arg.found()) {
reason = "-fb_explosions";
}
if (fb_thrusters_arg.found()) {
if (!reason.empty()) {
reason += " ";
}
reason += "-fb_thrusters";
}
options::OptionsManager::instance()->setOverride("Graphics.FramebufferEffects",
std::unique_ptr<json_t>(json_integer(static_cast<json_int_t>(Gr_framebuffer_effects.to_u64()))), reason);
}
if ( no_postprocess_arg.found() )
{
Gr_post_processing_enabled = false;
options::OptionsManager::instance()->setOverride("Graphics.PostProcessing",
std::unique_ptr<json_t>(json_false()), "-no_post_process");
}
if( reparse_mainhall_arg.found() )
@@ -2235,12 +2280,19 @@ bool SetCmdlineParams()
Shadow_quality_uses_mod_option = true;
Shadow_quality = ShadowQuality::Medium;
// Deliberately not calling setOverride() here: mod_table_init() (which runs after cmdline parsing but
// before OptionsManager::loadInitialValues()) may still adjust Shadow_quality via game_settings.tbl's
// "$Shadow Quality Default:" when Shadow_quality_uses_mod_option is set, and an override registered now
// would freeze in the Medium fallback and ignore that later adjustment. Graphics.Shadows' default_func
// reads Shadow_quality instead, so this value reaches the in-game options path that way, and the
// player keeps the ability to change it.
}
if( shadow_quality_arg.found() )
{
// set that we are not using default shadow quality level --wookieejedi
Shadow_quality_uses_mod_option = false;
Shadow_quality_uses_mod_option = false;
switch (shadow_quality_arg.get_int()) {
case 0:
@@ -2263,6 +2315,11 @@ bool SetCmdlineParams()
Shadow_quality = ShadowQuality::Disabled;
break;
}
// Unlike `-enable_shadows` alone, an explicit quality level is an unambiguous hard override that
// nothing else (mod defaults included) should be able to silently override.
options::OptionsManager::instance()->setOverride("Graphics.Shadows",
std::unique_ptr<json_t>(json_integer(static_cast<int>(Shadow_quality))), "-shadow_quality");
}
if( rt_shadows_arg.found() )
@@ -2271,21 +2328,34 @@ bool SetCmdlineParams()
// has no effect if the renderer/hardware doesn't support
// CAPABILITY_RAYTRACED_SHADOWS -- the CSM path is used either way.
Shadow_render_method = ShadowRenderMethod::Raytraced;
// No need to gate this on shadows_raytracing_supported(): that answer is only meaningful after
// gr_init(), which runs later, and shadows_remove_unsupported_options() then removes this option
// altogether on hardware that cannot raytrace, so the override never reaches the options menu there.
options::OptionsManager::instance()->setOverride("Graphics.ShadowRenderMethod",
std::unique_ptr<json_t>(json_integer(static_cast<int>(ShadowRenderMethod::Raytraced))), "-rt_shadows");
}
if( no_deferred_lighting_arg.found() )
{
Cmdline_no_deferred_lighting = 1;
options::OptionsManager::instance()->setOverride("Graphics.DeferredLighting",
std::unique_ptr<json_t>(json_false()), "-no_deferred");
}
if( deferred_lighting_cockpit_arg.found() )
{
Cmdline_deferred_lighting_cockpit = true;
options::OptionsManager::instance()->setOverride("Graphics.DeferredCockpitLighting",
std::unique_ptr<json_t>(json_true()), "-deferred_cockpit");
}
if (anisotropy_level_arg.found())
if (anisotropy_level_arg.found())
{
Cmdline_aniso_level = anisotropy_level_arg.get_int();
options::OptionsManager::instance()->setOverride("Graphics.Anisotropy",
std::unique_ptr<json_t>(json_real(static_cast<double>(Cmdline_aniso_level))), "-anisotropic_filter");
}
if (frame_profile_write_file.found())
@@ -2372,9 +2442,13 @@ bool SetCmdlineParams()
if (vulkan.found()) {
Cmdline_graphics_api = GraphicsAPI::Vulkan;
options::OptionsManager::instance()->setOverride("Graphics.RenderAPI",
std::unique_ptr<json_t>(json_integer(static_cast<int>(GraphicsAPI::Vulkan))), "-vulkan");
}
else if (opengl.found()) {
Cmdline_graphics_api = GraphicsAPI::OpenGL;
options::OptionsManager::instance()->setOverride("Graphics.RenderAPI",
std::unique_ptr<json_t>(json_integer(static_cast<int>(GraphicsAPI::OpenGL))), "-opengl");
}
//Deprecated flags - CommanderDJ
-2
View File
@@ -166,8 +166,6 @@ extern int Cmdline_multithreading;
enum class WeaponSpewType { NONE = 0, STANDARD, ALL };
extern WeaponSpewType Cmdline_spew_weapon_stats;
extern void removeVSyncOption();
extern void removeWindowModeOption();
extern void removeResolutionOption();
extern void removeResolutionVROption();
+5 -12
View File
@@ -200,20 +200,13 @@ SCP_string get_version_string()
#endif
// Lets get some more info in here
switch (gr_screen.mode) {
case GraphicsAPI::OpenGL:
str += " OpenGL";
#ifdef USE_OPENGL_ES
str += " ";
str += gr_render_api_name(gr_screen.mode);
#ifdef USE_OPENGL_ES
if (gr_screen.mode == GraphicsAPI::OpenGL) {
str += " ES";
#endif
break;
case GraphicsAPI::Vulkan:
str += " Vulkan";
break;
default:
str += " Unknown???";
break;
}
#endif
return str;
}
+79 -13
View File
@@ -221,6 +221,74 @@ const auto LightingOption __UNUSED = options::OptionBuilder<int>("Graphics.Light
.parser(parse_lighting_func)
.finish();
const char* gr_render_api_name(GraphicsAPI api)
{
switch (api) {
case GraphicsAPI::OpenGL:
return "OpenGL";
case GraphicsAPI::Vulkan:
return "Vulkan";
default:
return "Unknown???";
}
}
// The Graphics.RenderAPI option only exists in builds that actually support choosing a backend at runtime.
// In an OpenGL-only build there's nothing to choose between, so there's no point cluttering the options menu
// with a one-item dropdown -- gr_get_configured_render_api() just returns the fixed default in that case.
#ifdef WITH_VULKAN
static GraphicsAPI Gr_configured_render_api = GraphicsAPI::OpenGL;
static void parse_render_api_func()
{
SCP_string value;
stuff_string(value, F_NAME);
if (lcase_equal(value, "opengl")) {
Gr_configured_render_api = GraphicsAPI::OpenGL;
} else if (lcase_equal(value, "vulkan")) {
Gr_configured_render_api = GraphicsAPI::Vulkan;
} else {
error_display(0, "%s is an invalid render API", value.c_str());
}
}
static SCP_vector<GraphicsAPI> render_api_enumerator() { return {GraphicsAPI::OpenGL, GraphicsAPI::Vulkan}; }
static SCP_string render_api_display(const GraphicsAPI& api) { return gr_render_api_name(api); }
// Read directly via getValue() at the one call site that needs it (gr_init() itself), rather than bound to a
// global via a change listener -- like Resolution/Anisotropy, this can't take effect without a restart, so
// there is no "live" value to keep in sync.
// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton
static auto RenderAPIOption __UNUSED = options::OptionBuilder<GraphicsAPI>("Graphics.RenderAPI",
std::pair<const char*, int>{"Render API", -1},
std::pair<const char*, int>{"Selects the rendering backend used by the engine. Requires a restart to take effect.", -1})
.category(std::make_pair("Graphics", 1825))
.level(options::ExpertLevel::Advanced)
.enumerator(render_api_enumerator)
.display(render_api_display)
.flags({options::OptionFlags::ForceMultiValueSelection})
.default_func([]() { return Gr_configured_render_api; })
.importance(99)
.parser(parse_render_api_func)
.finish();
GraphicsAPI gr_get_configured_render_api()
{
return RenderAPIOption->getValue();
}
#else
GraphicsAPI gr_get_configured_render_api()
{
// No backend choice in an OpenGL-only build; Default resolves to OpenGL in gr_init().
return GraphicsAPI::Default;
}
#endif
os::ViewportState Gr_configured_window_state = os::ViewportState::Fullscreen;
static bool mode_change_func(os::ViewportState state, bool initial)
@@ -271,11 +339,6 @@ static auto WindowModeOption __UNUSED = options::OptionBuilder<os::ViewportState
.parser(parse_window_mode_func)
.finish();
void removeWindowModeOption()
{
options::OptionsManager::instance()->removeOption(WindowModeOption);
}
// coverity[GLOBAL_INIT_ORDER] -- safe; Hook::Factory() uses Meyers singleton
const std::shared_ptr<scripting::OverridableHook<>> OnFrameHook = scripting::OverridableHook<>::Factory(
"On Frame", "Called every frame as the last action before showing the frame result to the user.", {}, std::nullopt, CHA_ONFRAME);
@@ -841,7 +904,8 @@ static auto MSAAOption __UNUSED = options::OptionBuilder<int>("Graphics.MSAASamp
.level(options::ExpertLevel::Advanced)
.values({{0, {"Off", 1693}},
{4, {"4 Samples", 1694}},
{8, {"8 Samples", 1695}}})
{8, {"8 Samples", 1695}},
{16, {"16 Samples", 1935}}})
.default_func([]() { return Cmdline_msaa_enabled; } )
.bind_to_once(&Cmdline_msaa_enabled)
.importance(78)
@@ -897,14 +961,9 @@ static auto VSyncOption __UNUSED = options::OptionBuilder<bool>("Graphics.VSync"
.default_func([]() { return Gr_enable_vsync; })
.bind_to_once(&Gr_enable_vsync)
.importance(70)
.parser(parse_vsync_func)
.parser(parse_vsync_func)
.finish();
void removeVSyncOption()
{
options::OptionsManager::instance()->removeOption(VSyncOption);
}
bool Gr_enable_hdr = false;
bool Gr_hdr_output_active = false;
@@ -2009,7 +2068,14 @@ bool gr_init(std::unique_ptr<os::GraphicsOperations>&& graphicsOps, GraphicsAPI
height = res.height;
removeResolutionVROption();
}
//TODO set d_mode from Ingame Options if available here
// Pick the rendering backend from the in-game Graphics.RenderAPI option (or the mod's default
// settings table), but only where the caller did not ask for a specific one. FRED passes OpenGL and
// the unit tests pass Stub, and neither should be replaced by a player setting.
// A -vulkan/-opengl command line flag still wins below via Cmdline_graphics_api.
if (d_mode == GraphicsAPI::Default) {
d_mode = gr_get_configured_render_api();
}
} else if ( !Is_standalone ) {
// We cannot continue without this, quit, but try to help the user out first
ptr = os_config_read_string(nullptr, NOX("VideocardFs2open"), nullptr);
+8
View File
@@ -1054,6 +1054,14 @@ extern const char *Resolution_prefixes[GR_NUM_RESOLUTIONS];
extern bool gr_init(std::unique_ptr<os::GraphicsOperations>&& graphicsOps, GraphicsAPI d_mode = GraphicsAPI::Default,
int d_width = GR_DEFAULT, int d_height = GR_DEFAULT, int d_depth = GR_DEFAULT);
// The render API (GraphicsAPI::OpenGL/GraphicsAPI::Vulkan) selected via the "Graphics.RenderAPI" in-game option (or a mod's
// default settings table). The -vulkan/-opengl command line flags still win over this; see Cmdline_graphics_api.
extern GraphicsAPI gr_get_configured_render_api();
// The display name of a render API, e.g. "OpenGL". Returns "Unknown???" for GraphicsAPI values that are not
// a real backend, such as GraphicsAPI::Stub.
extern const char* gr_render_api_name(GraphicsAPI api);
extern void gr_screen_resize(int width, int height);
extern int gr_get_resolution_class(int width, int height);
+15 -3
View File
@@ -80,7 +80,12 @@ auto ShadowQualityOption = options::OptionBuilder<ShadowQuality>("Graphics.Shado
.change_listener([](ShadowQuality val, bool initial) {if (initial) {Shadow_quality = val;}return initial;})
.level(options::ExpertLevel::Advanced)
.category(std::make_pair("Graphics", 1825))
.default_func([]() { return ShadowQuality::Disabled; } )
// Return the global, like Graphics.VSync and Graphics.AAMode do, rather than a constant.
// By the time the options manager reads this, Shadow_quality already holds whatever
// -enable_shadows and game_settings.tbl's "$Shadow Quality Default:" put there, so a
// constant here would discard both. A value the player saved still wins over this
// default, and -shadow_quality still wins over that as a registered override.
.default_func([]() { return Shadow_quality; } )
.importance(80)
.parser(parse_shadow_quality_func)
.finish();
@@ -124,7 +129,11 @@ auto ShadowRenderMethodOption = options::OptionBuilder<ShadowRenderMethod>("Grap
.flags({options::OptionFlags::ForceMultiValueSelection})
.level(options::ExpertLevel::Advanced)
.category(std::make_pair("Graphics", 1825))
.default_func([]() { return ShadowRenderMethod::ShadowMap; })
// Return the global, for the same reason Graphics.Shadows above does: a constant here
// would discard the value parse_shadow_render_method_func() reads from the mod's
// "$Shadow Render Method:" table entry, because bind_to() writes this back to the
// global on the initial load.
.default_func([]() { return Shadow_render_method; })
.importance(79)
.parser(parse_shadow_render_method_func)
.finish();
@@ -173,7 +182,10 @@ auto RTShadowQualityOption = options::OptionBuilder<RTShadowQuality>("Graphics.R
.importance(77)
.finish();
int Max_rt_shadow_local_lights = 3;
// 4 is both the option's default and the smallest value its enumerator offers, so start there. The
// in-game-options path already set 4 through the option's default_func; this makes the legacy path,
// which never loads the option, agree instead of leaving an out-of-range 3 in place.
int Max_rt_shadow_local_lights = 4;
// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton
auto MaxRtShadowLocalLightsOption = options::OptionBuilder<int>("Graphics.MaxRtShadowLocalLights",
+21 -7
View File
@@ -65,7 +65,7 @@ bool *Lcl_unexpected_tstring_check = nullptr;
// NOTE: with map storage of XSTR strings, the indexes no longer need to be contiguous,
// but internal strings should still increment XSTR_SIZE to avoid collisions.
// retail XSTR_SIZE = 1570
// #define XSTR_SIZE 1933 // This is the next available ID
// #define XSTR_SIZE 1936 // This is the next available ID
// struct to allow for strings.tbl-determined x offset
// offset is 0 for english, by default
@@ -217,21 +217,35 @@ void lcl_init(int lang_init)
int lang = -1;
if (lang_init < 0) {
// first we start with any persisted in-game option choice
// Resolve the -lang command line flag once, and register it as an override *before* the in-game
// option below reads its value, so that the modern (Using_in_game_options) path gets the
// cmdline-selected language straight out of the option lookup.
int cmdline_lang = -1;
if (!Cmdline_lang.empty()) {
cmdline_lang = lcl_find_lang_index_by_name(Cmdline_lang);
if (Lcl_languages.in_bounds(cmdline_lang)) {
options::OptionsManager::instance()->setOverride("Game.Language",
std::unique_ptr<json_t>(language_serializer(cmdline_lang)), "-lang");
} else {
cmdline_lang = -1;
Warning(LOCATION, "Unrecognized -lang value \"%s\"; ignoring.", Cmdline_lang.c_str());
}
}
// first we start with any persisted in-game option choice (or the -lang override registered above)
if (Using_in_game_options) {
lang = LanguageOption->getValue();
// make sure the language index is valid for the current mod
if (!SCP_vector_inbounds(Lcl_languages, lang)) {
if (!Lcl_languages.in_bounds(lang)) {
lang = -1;
}
}
// now try the the commandline
// now try the commandline. This is still reachable on the modern path too: the option's
// deserializer returns -1 when the persisted language no longer exists in the current mod.
if (lang < 0) {
if (!Cmdline_lang.empty()) {
lang = lcl_find_lang_index_by_name(Cmdline_lang);
}
lang = cmdline_lang;
}
// still nothing, so go to the ini file
+42 -4
View File
@@ -39,12 +39,14 @@ OptionsManager* options::OptionsManager::instance()
//Gets the value of an option from the Config using the option key
std::optional<std::unique_ptr<json_t>> OptionsManager::getValueFromConfig(const SCP_string& key) const
{
// An override always wins. The options UI disables the control for an overridden option, and
// setConfigValue() rejects writes to one, so no edit can be pending here anyway.
auto override_iter = _config_overrides.find(key);
if (override_iter != _config_overrides.end()) {
// We return a reference to an existing object so we need to increment the reference count
json_incref(override_iter->second.get());
json_incref(override_iter->second.value.get());
// coverity[multiple_init_smart_ptr:FALSE] - according to m!m, this is most likely a false positive: "I think Coverity does not understand the usage of default_delete" in jansson.h, line 23
return std::unique_ptr<json_t>(override_iter->second.get());
return std::unique_ptr<json_t>(override_iter->second.value.get());
}
auto changed_iter = _changed_values.find(key);
@@ -79,19 +81,55 @@ std::optional<std::unique_ptr<json_t>> OptionsManager::getValueFromConfig(const
//Sets the value for the option
void OptionsManager::setConfigValue(const SCP_string& key,std::unique_ptr<json_t>&& value)
{
// An overridden option is not editable. Accepting the edit here would write it to the config file and
// live-apply it through valueChanged(), but getValue() would keep returning the override, so the game
// and the options menu would disagree for the rest of the session.
if (isOverridden(key)) {
return;
}
_changed_values[key] = std::move(value);
}
//Provides a method for overriding a built-in option setting
//Generally used for commandline settings
void OptionsManager::setOverride(const SCP_string& key, const SCP_string& json)
void OptionsManager::setOverride(const SCP_string& key, const SCP_string& json, const SCP_string& reason)
{
json_error_t err;
auto el = json_loads(json.c_str(), JSON_DECODE_ANY, &err);
if (el == nullptr) {
return;
}
_config_overrides.emplace(key, std::unique_ptr<json_t>(el));
setOverride(key, std::unique_ptr<json_t>(el), reason);
}
void OptionsManager::setOverride(const SCP_string& key, std::unique_ptr<json_t>&& value, const SCP_string& reason)
{
if (value == nullptr) {
return;
}
// insert_or_assign (rather than emplace) so a later override for the same key, e.g. from a second cmdline
// flag targeting the same option, replaces the earlier one instead of being silently ignored.
_config_overrides.insert_or_assign(key, OverrideEntry{std::move(value), reason});
// An override wins over a pending edit, so drop any edit that is already pending for this key.
_changed_values.erase(key);
}
void OptionsManager::clearOverrides() { _config_overrides.clear(); }
bool OptionsManager::isOverridden(const SCP_string& key) const
{
return _config_overrides.find(key) != _config_overrides.end();
}
std::optional<SCP_string> OptionsManager::getOverrideReason(const SCP_string& key) const
{
auto iter = _config_overrides.find(key);
if (iter == _config_overrides.end()) {
return std::nullopt;
}
return iter->second.reason;
}
//Adds an option to the options vector
+23 -2
View File
@@ -13,7 +13,14 @@ class OptionBase;
class OptionsManager {
OptionsManager();
SCP_unordered_map<SCP_string, std::unique_ptr<json_t>> _config_overrides;
// A value forced by something outside of the options system itself (currently only the command line).
// This takes priority over the persisted config value and over any in-session edit. The options UI shows
// an overridden option as a disabled control with the reason in its tooltip.
struct OverrideEntry {
std::unique_ptr<json_t> value;
SCP_string reason; // human-readable source, e.g. "-no_vsync", shown in the options UI
};
SCP_unordered_map<SCP_string, OverrideEntry> _config_overrides;
SCP_unordered_map<SCP_string, std::unique_ptr<json_t>> _changed_values;
@@ -34,7 +41,21 @@ class OptionsManager {
void setConfigValue(const SCP_string& key, std::unique_ptr<json_t>&& value);
void setOverride(const SCP_string& key, const SCP_string& json);
void setOverride(const SCP_string& key, const SCP_string& json, const SCP_string& reason);
// Overload that takes an already-built JSON value. Prefer this one: it lets a caller reuse the option's
// own serializer instead of formatting the JSON by hand, which can produce a value the option's
// deserializer then rejects.
void setOverride(const SCP_string& key, std::unique_ptr<json_t>&& value, const SCP_string& reason);
// Returns true if this key currently has an override.
bool isOverridden(const SCP_string& key) const;
// Returns the reason string passed to setOverride() for this key, if it is currently overridden.
std::optional<SCP_string> getOverrideReason(const SCP_string& key) const;
// Removes all overrides. For tests, so that one test's overrides do not leak into the next.
void clearOverrides();
const OptionBase* addOption(std::shared_ptr<const OptionBase>&& option);
+29 -3
View File
@@ -33,7 +33,13 @@ void OptUi::build_options_list(const char* category) const
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();
@@ -116,9 +122,29 @@ void OptUi::build_options_list(const char* category) const
}
}
// 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 (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayNormal | ImGuiHoveredFlags_NoSharedDelay))
ImGui::SetTooltip("%s", thisOpt->getDescription().c_str());
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());
}
}
}
}
}
+22
View File
@@ -250,6 +250,28 @@ ADE_FUNC(getValidValues,
return ade_set_args(L, "t", &table);
}
ADE_VIRTVAR(OverrideReason,
l_Option,
nullptr,
"If this option's value is currently forced by something outside the options system (e.g. a command line "
"flag), this is a human-readable description of the source. nil if the option is not currently overridden.",
"string",
"The override reason, or nil if not overridden or on error")
{
option_h* opt;
if (!ade_get_args(L, "o", l_Option.GetPtr(&opt))) {
return ADE_RETURN_NIL;
}
if (!opt->isValid()) {
return ADE_RETURN_NIL;
}
auto reason = options::OptionsManager::instance()->getOverrideReason(opt->get()->getConfigKey());
if (!reason.has_value()) {
return ADE_RETURN_NIL;
}
return ade_set_args(L, "s", reason->c_str());
}
ADE_FUNC(persistChanges, l_Option, nullptr,
"Immediately persists any changes made to this specific option.", "boolean",
"true if the change was applied successfully, false otherwise. nil on error.")
+408
View File
@@ -0,0 +1,408 @@
#include "options/OptionsManager.h"
#include "options/Option.h"
#include "graphics/2d.h"
#include "graphics/shadows.h"
#include "localization/localize.h"
#include "math/floating.h"
#include "util/FSTestFixture.h"
#include <gtest/gtest.h>
using namespace options;
// OptionsManager is a process-wide singleton, so overrides and pending edits set by one test would
// otherwise stay visible to every test that runs after it in the same binary. Clear both after each test.
class OptionsManagerOverride : public ::testing::Test {
protected:
void TearDown() override
{
OptionsManager::instance()->clearOverrides();
OptionsManager::instance()->discardChanges();
}
};
// Probe: real Option<T> instances are defined via static initializers scattered across many translation
// units in libcode.a. Confirm the linker actually pulled at least one of them into the unittests binary
// (rather than static init being stripped as unreferenced) before trusting any test that exercises a real
// option's deserializer.
TEST_F(OptionsManagerOverride, RealOptionsAreRegistered)
{
auto* opt = OptionsManager::instance()->getOptionByKey("Graphics.VSync");
ASSERT_NE(opt, nullptr) << "Graphics.VSync was not registered -- static Option<T> initializers are not "
"linked into this test binary, so override round-trip tests against real "
"options are not possible here.";
}
// Regression coverage for the cmdline-priority-rework: OptionsManager::getValueFromConfig() must resolve a
// command-line override ahead of both a persisted ini value and any in-session edit, and setConfigValue()
// must refuse to record an edit for an overridden key at all. The options UI relies on this: it disables
// the control for an overridden option, so an accepted edit could only ever be a value the game ignores.
TEST_F(OptionsManagerOverride, OverrideWinsOverSessionEdit)
{
auto* mgr = OptionsManager::instance();
mgr->setOverride("Test.OverridePriority.Edited", "42", "-test_flag");
mgr->setConfigValue("Test.OverridePriority.Edited", std::unique_ptr<json_t>(json_integer(7)));
auto result = mgr->getValueFromConfig("Test.OverridePriority.Edited");
ASSERT_TRUE(result.has_value());
ASSERT_EQ(json_integer_value(result->get()), 42);
}
TEST_F(OptionsManagerOverride, OverrideDropsAnAlreadyPendingEdit)
{
auto* mgr = OptionsManager::instance();
// An edit recorded before the key was overridden must not survive the override either, so that
// persistChanges() cannot later write a value the game will never use.
mgr->setConfigValue("Test.OverridePriority.PreEdited", std::unique_ptr<json_t>(json_integer(7)));
mgr->setOverride("Test.OverridePriority.PreEdited", "42", "-test_flag");
auto result = mgr->getValueFromConfig("Test.OverridePriority.PreEdited");
ASSERT_TRUE(result.has_value());
ASSERT_EQ(json_integer_value(result->get()), 42);
}
TEST_F(OptionsManagerOverride, OverrideReasonIsQueryable)
{
auto* mgr = OptionsManager::instance();
ASSERT_FALSE(mgr->isOverridden("Test.OverridePriority.NeverSet"));
ASSERT_FALSE(mgr->getOverrideReason("Test.OverridePriority.NeverSet").has_value());
mgr->setOverride("Test.OverridePriority.Reason", "1", "-my_flag");
ASSERT_TRUE(mgr->isOverridden("Test.OverridePriority.Reason"));
auto reason = mgr->getOverrideReason("Test.OverridePriority.Reason");
ASSERT_TRUE(reason.has_value());
ASSERT_EQ(*reason, "-my_flag");
}
TEST_F(OptionsManagerOverride, LaterOverrideReplacesEarlierOne)
{
auto* mgr = OptionsManager::instance();
mgr->setOverride("Test.OverridePriority.Replaced", "1", "-first_flag");
mgr->setOverride("Test.OverridePriority.Replaced", "2", "-second_flag");
auto result = mgr->getValueFromConfig("Test.OverridePriority.Replaced");
ASSERT_TRUE(result.has_value());
ASSERT_EQ(json_integer_value(result->get()), 2);
auto reason = mgr->getOverrideReason("Test.OverridePriority.Replaced");
ASSERT_TRUE(reason.has_value());
ASSERT_EQ(*reason, "-second_flag");
}
// The tests above exercise getValueFromConfig() directly with synthetic keys and integer payloads. The
// tests below instead go through real, statically-registered Option<T> instances (confirmed live by the
// probe test above), each with the same JSON value cmdline.cpp builds for that option, so a malformed value
// shows up as a deserialization failure here instead of silently falling back to that option's default_func
// and masking the bug.
//
// These call the typed Option<T>::getValue() (via a static_cast down to the option's real, statically-known
// type) rather than getCurrentValueDescription(), because several options' display functions call XSTR() for
// localized display strings (e.g. "On"/"Off", the shadow quality tiers). XSTR() hits an Int3() unless
// lcl_xstr_init() ran first, and the OptionsManagerOverride fixture never runs it. Only FSTestFixture does,
// and Xstr_inited stays set for the rest of the process afterwards, so under --gtest_shuffle a display call
// from this fixture crashes or passes purely by test order. getValue() only exercises the deserializer, which
// is exactly the contract these tests are checking anyway. The one test that must go through a display
// function uses the OptionsManagerOverrideLocalized fixture below instead.
TEST_F(OptionsManagerOverride, BoolOverrideRoundTrips)
{
auto* opt = OptionsManager::instance()->getOptionByKey("Graphics.VSync");
ASSERT_NE(opt, nullptr);
auto* typedOpt = static_cast<const Option<bool>*>(opt);
auto* mgr = OptionsManager::instance();
// Exactly the values built in cmdline.cpp's -no_vsync handling.
mgr->setOverride("Graphics.VSync", std::unique_ptr<json_t>(json_false()), "-no_vsync");
ASSERT_FALSE(typedOpt->getValue());
mgr->setOverride("Graphics.VSync", std::unique_ptr<json_t>(json_true()), "-vsync");
ASSERT_TRUE(typedOpt->getValue());
}
TEST_F(OptionsManagerOverride, ResolutionObjectOverrideRoundTrips)
{
auto* opt = OptionsManager::instance()->getOptionByKey("Graphics.Resolution");
ASSERT_NE(opt, nullptr);
// ResolutionInfo is a file-local type in 2d.cpp, not reachable from here, so this test goes through
// getCurrentValueDescription() instead -- safe because resolution_display() is a plain sprintf with no
// XSTR() call.
// Exactly the value built in cmdline.cpp's -render_res handling.
OptionsManager::instance()->setOverride("Graphics.Resolution",
std::unique_ptr<json_t>(json_pack("{s:i, s:i}", "width", 1920, "height", 1080)), "-render_res");
ASSERT_EQ(opt->getCurrentValueDescription().display, "1920x1080");
}
TEST_F(OptionsManagerOverride, EnumIntOverrideRoundTrips)
{
auto* opt = OptionsManager::instance()->getOptionByKey("Graphics.Shadows");
ASSERT_NE(opt, nullptr);
auto* typedOpt = static_cast<const Option<ShadowQuality>*>(opt);
// Exactly the value built in cmdline.cpp's -shadow_quality handling.
OptionsManager::instance()->setOverride("Graphics.Shadows",
std::unique_ptr<json_t>(json_integer(static_cast<int>(ShadowQuality::Medium))), "-shadow_quality");
ASSERT_EQ(typedOpt->getValue(), ShadowQuality::Medium);
}
// Regression coverage for the -enable_shadows path: Graphics.Shadows' default_func must read the
// Shadow_quality global rather than return a constant. -enable_shadows and game_settings.tbl's
// "$Shadow Quality Default:" both write that global before the options manager loads its initial values,
// so a constant default_func would discard whatever they set.
TEST_F(OptionsManagerOverride, ShadowQualityDefaultFollowsTheGlobal)
{
auto* opt = OptionsManager::instance()->getOptionByKey("Graphics.Shadows");
ASSERT_NE(opt, nullptr);
auto* typedOpt = static_cast<const Option<ShadowQuality>*>(opt);
// Shadow_quality is a shared global, so restore it from a destructor: a failing assertion below must
// not leave a later test looking at this test's value.
class ShadowQualityGuard {
public:
ShadowQualityGuard() : _original(Shadow_quality) {}
~ShadowQualityGuard() { Shadow_quality = _original; }
private:
ShadowQuality _original;
};
ShadowQualityGuard guard;
// No override and no persisted ini value, so getValue() has to fall back to default_func().
Shadow_quality = ShadowQuality::Medium; // what -enable_shadows sets
ASSERT_EQ(typedOpt->getValue(), ShadowQuality::Medium);
Shadow_quality = ShadowQuality::Ultra; // what a mod table can set afterwards
ASSERT_EQ(typedOpt->getValue(), ShadowQuality::Ultra);
// An override still beats the default, which is what -shadow_quality relies on.
OptionsManager::instance()->setOverride("Graphics.Shadows",
std::unique_ptr<json_t>(json_integer(static_cast<int>(ShadowQuality::Low))), "-shadow_quality");
ASSERT_EQ(typedOpt->getValue(), ShadowQuality::Low);
}
// Same defect, same fix, one option over: Graphics.ShadowRenderMethod has a parser too, so a constant
// default_func would discard the mod table's "$Shadow Render Method:" value. bind_to() writes the option
// value back to the global on the initial load, which is what made the loss silent.
TEST_F(OptionsManagerOverride, ShadowRenderMethodDefaultFollowsTheGlobal)
{
// This option is not always present: gr_init() calls shadows_remove_unsupported_options(), which removes
// it when the renderer cannot raytrace. Every FSTestFixture test in this binary runs gr_init() with the
// Stub renderer, so by the time this test runs the option is usually already gone.
auto* opt = OptionsManager::instance()->getOptionByKey("Graphics.ShadowRenderMethod");
if (opt == nullptr) {
GTEST_SKIP() << "Graphics.ShadowRenderMethod was removed by shadows_remove_unsupported_options(), so "
"this binary cannot exercise its default_func.";
}
auto* typedOpt = static_cast<const Option<ShadowRenderMethod>*>(opt);
class ShadowRenderMethodGuard {
public:
ShadowRenderMethodGuard() : _original(Shadow_render_method) {}
~ShadowRenderMethodGuard() { Shadow_render_method = _original; }
private:
ShadowRenderMethod _original;
};
ShadowRenderMethodGuard guard;
// No override and no persisted ini value, so getValue() has to fall back to default_func().
Shadow_render_method = ShadowRenderMethod::Raytraced; // what a mod table can set
ASSERT_EQ(typedOpt->getValue(), ShadowRenderMethod::Raytraced);
Shadow_render_method = ShadowRenderMethod::ShadowMap;
ASSERT_EQ(typedOpt->getValue(), ShadowRenderMethod::ShadowMap);
// An override still beats the default, which is what -rt_shadows relies on.
OptionsManager::instance()->setOverride("Graphics.ShadowRenderMethod",
std::unique_ptr<json_t>(json_integer(static_cast<int>(ShadowRenderMethod::Raytraced))), "-rt_shadows");
ASSERT_EQ(typedOpt->getValue(), ShadowRenderMethod::Raytraced);
}
// Graphics.MSAASamples uses a MapValueDisplay, so the check below cannot avoid XSTR(): Option<T> builds the
// display string for every value inside getValidValues(), and toDescription() catches MapValueDisplay's throw
// for an unmapped value and falls back to the serialized JSON, so an XSTR-free reformulation could no longer
// tell a mapped value from an unmapped one. This fixture therefore runs the full FSTestFixture setup, which
// calls lcl_init() and lcl_xstr_init(), instead of relying on some earlier test to have done it.
class OptionsManagerOverrideLocalized : public test::FSTestFixture {
public:
OptionsManagerOverrideLocalized() : test::FSTestFixture(INIT_CFILE) { pushModDir("options"); }
protected:
void TearDown() override
{
OptionsManager::instance()->clearOverrides();
OptionsManager::instance()->discardChanges();
test::FSTestFixture::TearDown();
}
};
TEST_F(OptionsManagerOverrideLocalized, MsaaOverrideAcceptsEverySampleCountTheFlagAllows)
{
auto* opt = OptionsManager::instance()->getOptionByKey("Graphics.MSAASamples");
ASSERT_NE(opt, nullptr);
auto* typedOpt = static_cast<const Option<int>*>(opt);
// -msaa accepts 0, 4, 8 and 16, so every one of them must also be a valid value of the option.
// Otherwise MapValueDisplay throws for the sample count and printValues() reports the override as
// invalid at startup.
auto values = opt->getValidValues();
for (int samples : {0, 4, 8, 16}) {
OptionsManager::instance()->setOverride("Graphics.MSAASamples",
std::unique_ptr<json_t>(json_integer(samples)), "-msaa");
ASSERT_EQ(typedOpt->getValue(), samples);
SCP_string serialized = std::to_string(samples);
ASSERT_NE(std::find_if(values.begin(), values.end(),
[&serialized](const ValueDescription& val) { return val.serialized == serialized; }),
values.end())
<< "MSAA sample count " << samples << " is accepted by -msaa but is not a valid option value.";
}
}
// Graphics.RenderAPI only exists in builds compiled with Vulkan support (code/graphics/2d.cpp gates the whole
// option definition on #ifdef WITH_VULKAN) -- in an OpenGL-only build there's nothing to choose between, so
// gr_get_configured_render_api() just returns GraphicsAPI::Default and no option is registered at all.
#ifdef WITH_VULKAN
TEST_F(OptionsManagerOverride, RenderAPIOverrideRoundTrips)
{
auto* opt = OptionsManager::instance()->getOptionByKey("Graphics.RenderAPI");
ASSERT_NE(opt, nullptr);
auto* typedOpt = static_cast<const Option<GraphicsAPI>*>(opt);
// Exactly the value built in cmdline.cpp's -vulkan handling.
OptionsManager::instance()->setOverride("Graphics.RenderAPI",
std::unique_ptr<json_t>(json_integer(static_cast<int>(GraphicsAPI::Vulkan))), "-vulkan");
ASSERT_EQ(typedOpt->getValue(), GraphicsAPI::Vulkan);
ASSERT_EQ(gr_get_configured_render_api(), GraphicsAPI::Vulkan);
}
TEST_F(OptionsManagerOverride, RenderAPIMenuInitPathDoesNotThrow)
{
// Unlike VSync/Shadows/etc, RenderAPI's display function is plain text (no XSTR()), so -- unlike those --
// it's safe to exercise the exact calls ingame_options_init() makes when building the options menu
// (getValidValues() for the dropdown entries, getCurrentValueDescription() for the current selection),
// giving at least some coverage of the menu-construction path itself, not just the deserializer.
auto* opt = OptionsManager::instance()->getOptionByKey("Graphics.RenderAPI");
ASSERT_NE(opt, nullptr);
ASSERT_EQ(opt->getType(), OptionType::Selection);
ASSERT_EQ(opt->getValidValues().size(), 2u);
OptionsManager::instance()->setOverride("Graphics.RenderAPI",
std::unique_ptr<json_t>(json_integer(static_cast<int>(GraphicsAPI::OpenGL))), "-opengl");
ASSERT_EQ(opt->getCurrentValueDescription().display, "OpenGL");
OptionsManager::instance()->setOverride("Graphics.RenderAPI",
std::unique_ptr<json_t>(json_integer(static_cast<int>(GraphicsAPI::Vulkan))), "-vulkan");
ASSERT_EQ(opt->getCurrentValueDescription().display, "Vulkan");
}
#else
TEST_F(OptionsManagerOverride, RenderAPIIsAbsentWithoutVulkanSupport)
{
ASSERT_EQ(OptionsManager::instance()->getOptionByKey("Graphics.RenderAPI"), nullptr);
ASSERT_EQ(gr_get_configured_render_api(), GraphicsAPI::Default);
}
#endif
TEST_F(OptionsManagerOverride, FlagsetOverrideRoundTrips)
{
auto* opt = OptionsManager::instance()->getOptionByKey("Graphics.FramebufferEffects");
ASSERT_NE(opt, nullptr);
auto* typedOpt = static_cast<const Option<flagset<FramebufferEffects>>*>(opt);
// Exactly the value built in cmdline.cpp's -fb_explosions/-fb_thrusters handling (flagset::to_u64()).
flagset<FramebufferEffects> effects;
effects.set(FramebufferEffects::Thrusters, true);
effects.set(FramebufferEffects::Shockwaves, true);
OptionsManager::instance()->setOverride("Graphics.FramebufferEffects",
std::unique_ptr<json_t>(json_integer(static_cast<json_int_t>(effects.to_u64()))),
"-fb_explosions -fb_thrusters");
auto value = typedOpt->getValue();
ASSERT_TRUE(value[FramebufferEffects::Thrusters]);
ASSERT_TRUE(value[FramebufferEffects::Shockwaves]);
}
// Graphics.Anisotropy is only registered in builds with the OpenGL backend compiled in
// (its Option<float> lives in gropengltexture.cpp, which the CMake build excludes entirely
// when FSO_BUILD_WITH_OPENGL is off) -- so, like Graphics.RenderAPI above, round-trip
// coverage needs a build-config split rather than a single unconditional test.
#ifdef WITH_OPENGL
TEST_F(OptionsManagerOverride, FloatOverrideRoundTrips)
{
auto* opt = OptionsManager::instance()->getOptionByKey("Graphics.Anisotropy");
ASSERT_NE(opt, nullptr);
auto* typedOpt = static_cast<const Option<float>*>(opt);
// Exactly the value built in cmdline.cpp's -anisotropic_filter handling. json_real matters here: the
// option's deserializer wants a JSON real, and a whole number written as a bare integer would not be one.
OptionsManager::instance()->setOverride("Graphics.Anisotropy",
std::unique_ptr<json_t>(json_real(16.0)), "-anisotropic_filter");
ASSERT_FLOAT_EQ(typedOpt->getValue(), 16.0f);
}
#else
TEST_F(OptionsManagerOverride, AnisotropyIsAbsentWithoutOpenGLSupport)
{
ASSERT_EQ(OptionsManager::instance()->getOptionByKey("Graphics.Anisotropy"), nullptr);
}
#endif
TEST_F(OptionsManagerOverride, LanguageObjectOverrideRoundTrips)
{
auto* opt = OptionsManager::instance()->getOptionByKey("Game.Language");
ASSERT_NE(opt, nullptr);
auto* typedOpt = static_cast<const Option<int>*>(opt);
// Game.Language's real deserializer resolves a {"name","ext"} pair against Lcl_languages via
// lcl_find_lang_index_by_name(), rather than storing a plain index -- so unlike the other round-trip
// tests, this one needs at least one entry in that table to resolve against. A full lcl_init() is too
// heavy for this test binary, so a single throwaway entry is pushed directly and restored afterwards,
// since Lcl_languages is a shared global other tests may also touch. The restore runs from a destructor
// so that a failing assertion below cannot leak the throwaway entry into later tests.
class LanguagesGuard {
public:
LanguagesGuard() : _original(Lcl_languages) {}
~LanguagesGuard() { Lcl_languages = _original; }
private:
SCP_vector<lang_info> _original;
};
LanguagesGuard guard;
lang_info test_lang{};
strcpy_s(test_lang.lang_name, "OverrideRoundTripTestLang");
strcpy_s(test_lang.lang_ext, "orttl");
Lcl_languages.push_back(test_lang);
int expected_idx = sz2i(Lcl_languages.size()) - 1;
// Exactly the value built in localize.cpp's lcl_init() (language_serializer()'s {"name":..,"ext":..}).
// language_serializer() itself is not exported, so this test rebuilds the same object.
OptionsManager::instance()->setOverride("Game.Language",
std::unique_ptr<json_t>(
json_pack("{ssss}", "name", test_lang.lang_name, "ext", test_lang.lang_ext)),
"-lang");
ASSERT_EQ(typedOpt->getValue(), expected_idx);
}
+4
View File
@@ -49,6 +49,10 @@ add_file_folder("model"
model/test_modelread.cpp
)
add_file_folder("Options"
options/test_options_manager.cpp
)
add_file_folder("Parse"
parse/test_parselo.cpp
parse/test_replace.cpp