mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-17 21:01:26 +02:00
Co-authored-by: love-gamer <340996855+love-gamer@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1487 lines
67 KiB
C++
1487 lines
67 KiB
C++
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See settings.h.
|
|
#include "settings.h"
|
|
#include <user/paths.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cctype>
|
|
#include <cerrno>
|
|
#include <chrono>
|
|
#include <cstddef>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <format>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include <fcntl.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
#ifdef __APPLE__
|
|
#include <TargetConditionals.h> // TARGET_OS_IOS: __APPLE__ alone means macOS or iOS
|
|
#endif
|
|
|
|
#include "key_names.h" // the Key options: the keys and their names in the file
|
|
|
|
// toml++ (header-only), as vendored by XenonRecomp. The fallback path keeps
|
|
// this file building before runtime/CMakeLists.txt adds the include
|
|
// directory (the runtime globs every .cpp under runtime/).
|
|
// Its v3.4 literal operators warn under clang 20+ unless it's a system header.
|
|
#pragma clang diagnostic push
|
|
#pragma clang diagnostic ignored "-Wunknown-warning-option"
|
|
#pragma clang diagnostic ignored "-Wdeprecated-literal-operator"
|
|
#if __has_include(<toml++/toml.hpp>)
|
|
#include <toml++/toml.hpp>
|
|
#else
|
|
#include "../../tools/XenonRecomp/thirdparty/tomlplusplus/include/toml++/toml.hpp"
|
|
#endif
|
|
#pragma clang diagnostic pop
|
|
|
|
namespace settings
|
|
{
|
|
namespace
|
|
{
|
|
// FSR 1 isn't offered on iPhone and iPad: its EASU pass costs an
|
|
// Apple GPU 2-3x the bicubic pass, and a phone's 1x picture is
|
|
// enlarged about 2x every frame. Without the entry, "fsr1" in
|
|
// settings.toml or NFSMW_SCALING reads as unknown (bicubic), so the
|
|
// presenter never makes an FSR 1 shader or pipeline there (it makes
|
|
// them only for a frame whose pass is FSR 1: video/upscale.h).
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
constexpr bool kFsr1Offered = false;
|
|
#else
|
|
constexpr bool kFsr1Offered = true;
|
|
#endif
|
|
constexpr EnumEntry kScalingEntries[] = {
|
|
{ int32_t(ScalingMode::Bicubic), "bicubic", "Bicubic" },
|
|
{ int32_t(ScalingMode::Linear), "linear", "Bilinear" },
|
|
#if !(defined(__APPLE__) && TARGET_OS_IOS)
|
|
{ int32_t(ScalingMode::Fsr1), "fsr1", "FSR 1" },
|
|
#endif
|
|
};
|
|
|
|
// Frame Rate: the game's frames per second (60, or 30 on a GPU that
|
|
// can't hold 60: one guest vblank every other refresh, two world
|
|
// steps a frame, so the game keeps its speed). Auto switches between
|
|
// the two (video/frame_rate.h). First, as the other Autos are.
|
|
constexpr EnumEntry kFrameRateEntries[] = {
|
|
{ kFrameRateAuto, "auto", "Auto" },
|
|
{ 60, "60", "60 fps" },
|
|
{ 30, "30", "30 fps" },
|
|
};
|
|
|
|
// Screen Resolution (iOS): the drawables at this percentage of the
|
|
// screen's pixels each way (video/presenter.cpp's present scale).
|
|
constexpr EnumEntry kPresentScaleEntries[] = {
|
|
{ 100, "100", "Full" },
|
|
{ 75, "75", "75%" },
|
|
};
|
|
|
|
// An iOS app always has the whole screen (video::FullscreenLocked):
|
|
// the Fullscreen row would change nothing there.
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
constexpr bool kFullscreenHidden = true;
|
|
#else
|
|
constexpr bool kFullscreenHidden = false;
|
|
#endif
|
|
// Screen Resolution scales an iOS view (Core Animation scales it up
|
|
// to the screen); elsewhere the window's size is the player's.
|
|
constexpr bool kPresentScaleHidden = !kFullscreenHidden;
|
|
|
|
constexpr EnumEntry kAspectEntries[] = {
|
|
{ int32_t(AspectMode::Auto), "auto", "Fill Screen" },
|
|
{ int32_t(AspectMode::Widescreen), "16:9", "16:9" },
|
|
};
|
|
|
|
// The scale in half steps (settings.h); the tokens are what
|
|
// settings.toml and NFSMW_RESOLUTION hold, so older files read as
|
|
// before.
|
|
constexpr EnumEntry kResolutionEntries[] = {
|
|
{ kResolutionAuto, "auto", "Auto" },
|
|
{ 2, "1x", "1x (720p)" },
|
|
{ 3, "1.5x", "1.5x (1080p)" },
|
|
{ 4, "2x", "2x (1440p)" },
|
|
{ 6, "3x", "3x (2160p)" },
|
|
};
|
|
|
|
constexpr EnumEntry kAnisotropyEntries[] = {
|
|
{ kAnisotropyAuto, "auto", "Auto" },
|
|
{ 1, "off", "Off" },
|
|
{ 2, "2x", "2x" },
|
|
{ 4, "4x", "4x" },
|
|
{ 8, "8x", "8x" },
|
|
{ 16, "16x", "16x" },
|
|
{ kAnisotropyGame, "game", "Original" },
|
|
};
|
|
|
|
// A Controls row's key: the key (user/key_names.h, by its name
|
|
// there) that drives one Xbox 360 control. Two per control, the
|
|
// second named <key>_2 with the same label. No environment
|
|
// variable: 48 of them would clutter the file's comments for nobody.
|
|
constexpr Option KeyOption(Id id, const char* key, const char* label, const char* description, const char* token)
|
|
{
|
|
return { .id = id, .category = Category::Controls, .type = Type::Key, .apply = Apply::Live,
|
|
.table = "keys", .key = key, .label = label, .description = description, .env = nullptr,
|
|
.defaultValue = double(keynames::Code(token)) };
|
|
}
|
|
|
|
constexpr Option KeyOption2(Id id, const char* key, const char* label, const char* token)
|
|
{
|
|
return KeyOption(id, key, label, "A second key for the same control, or none.", token);
|
|
}
|
|
|
|
// A stored or offered value as a key code: kUnknown unless it's a
|
|
// whole number the key table knows (none included).
|
|
constexpr uint16_t KeyCode(double v)
|
|
{
|
|
if (!(v >= 0 && v < double(keynames::kUnknown)) || v != uint16_t(v))
|
|
return keynames::kUnknown;
|
|
return keynames::Known(uint16_t(v)) ? uint16_t(v) : keynames::kUnknown;
|
|
}
|
|
|
|
// Defaults are what the runtime did before settings existed, so an
|
|
// empty settings.toml changes nothing.
|
|
constexpr Option kOptions[] = {
|
|
// Video
|
|
{ .id = Id::Fullscreen, .category = Category::Display, .type = Type::Bool, .apply = Apply::Live, .hidden = kFullscreenHidden,
|
|
.table = "video", .key = "fullscreen", .label = "Fullscreen",
|
|
.description = "Fill the screen with a borderless window. Off: a resizable window.",
|
|
.env = "NFSMW_FULLSCREEN", .defaultValue = 0 },
|
|
{ .id = Id::VSync, .category = Category::Display, .type = Type::Bool, .apply = Apply::Live,
|
|
.table = "video", .key = "vsync", .label = "V-Sync",
|
|
.description = "Show each frame on a display refresh. Off can tear, but frames appear sooner.",
|
|
.env = "NFSMW_VSYNC", .defaultValue = 1 },
|
|
{ .id = Id::FrameRate, .category = Category::Display, .type = Type::Enum, .apply = Apply::Live,
|
|
.table = "video", .key = "frame_rate", .label = "Frame Rate",
|
|
.description = "Auto: 60, or 30 while the device is hot or can't hold 60. A steady 30 feels smoother than a shaky 60 (the Steam Frame's headset). The game keeps its speed.",
|
|
.env = "NFSMW_FRAME_RATE", .defaultValue = 60, .entries = kFrameRateEntries },
|
|
{ .id = Id::PresentScale, .category = Category::Display, .type = Type::Enum, .apply = Apply::Live, .hidden = kPresentScaleHidden,
|
|
.table = "video", .key = "present_scale", .label = "Screen Resolution",
|
|
.description = "75%: the picture and these menus at 3/4 of the screen's resolution each way, scaled up by iOS: a little softer, a little less GPU work.",
|
|
.env = "NFSMW_SCREEN_RESOLUTION", .defaultValue = 100, .entries = kPresentScaleEntries },
|
|
{ .id = Id::AntiAliasing, .category = Category::Graphics, .type = Type::Bool, .apply = Apply::Live,
|
|
.table = "video", .key = "anti_aliasing", .label = "Anti-Aliasing",
|
|
.description = "Smooth jagged edges (the 360 used 2x MSAA; this is an FXAA-style pass).",
|
|
.env = "NFSMW_AA", .defaultValue = 1 },
|
|
{ .id = Id::Scaling, .category = Category::Graphics, .type = Type::Enum, .apply = Apply::Live,
|
|
.table = "video", .key = "scaling", .label = "Upscaling",
|
|
.description = kFsr1Offered
|
|
? "How the game's picture is enlarged to the window. Bicubic is sharp, bilinear softer. FSR 1 (AMD FidelityFX Super Resolution 1) keeps curves, wires and diagonals smooth when the window is much larger than the Internal Resolution (1x on a 1440p or 4K screen), for a little more GPU time."
|
|
: "How the game's picture is enlarged to the screen. Bicubic is sharp, bilinear softer.",
|
|
.env = "NFSMW_SCALING", .defaultValue = double(ScalingMode::Bicubic), .entries = kScalingEntries },
|
|
{ .id = Id::AspectRatio, .category = Category::Display, .type = Type::Enum, .apply = Apply::Live,
|
|
.table = "video", .key = "aspect_ratio", .label = "Aspect Ratio",
|
|
.description = "Fill other screen shapes: a wider view on an ultrawide, a taller one on a Steam Deck or a 4:3 iPad, the HUD at 16:9. Or 16:9 with bars.",
|
|
.env = "NFSMW_ASPECT", .defaultValue = double(AspectMode::Auto), .entries = kAspectEntries },
|
|
// Brightness: a power curve on the game's picture, -5..+5 (video/display_tone.h).
|
|
{ .id = Id::Brightness, .category = Category::Display, .type = Type::Int, .apply = Apply::Live,
|
|
.table = "video", .key = "brightness", .label = "Brightness",
|
|
.description = "Lighter or darker shadows and mid-tones, a gentle step at a time, as a TV's brightness. Black and white stay put. 0 is the original picture.",
|
|
.env = "NFSMW_BRIGHTNESS", .defaultValue = 0, .min = -5, .max = 5, .step = 1 },
|
|
{ .id = Id::DisplayGamma, .category = Category::Display, .type = Type::Bool, .apply = Apply::Live,
|
|
.table = "video", .key = "xbox_display_gamma", .label = "Xbox 360 Display Gamma",
|
|
.description = "The gamma ramp the game itself sets for a real Xbox 360's TV output: a little deeper shadows and brighter mid-tones, as on the console. Off: the flatter picture of SpeedBreaker 0.1.3 and earlier.",
|
|
.env = "NFSMW_DISPLAY_GAMMA", .defaultValue = 1 },
|
|
{ .id = Id::Sharpening, .category = Category::Graphics, .type = Type::Float, .apply = Apply::Live,
|
|
.table = "video", .key = "sharpening", .label = "Sharpening",
|
|
.description = kFsr1Offered
|
|
? "Extra sharpness for bicubic and FSR 1 upscaling (for FSR 1, the share of its sharpening pass's full strength). 0 turns it off."
|
|
: "Extra sharpness for bicubic upscaling. 0 turns it off.",
|
|
.env = "NFSMW_SHARPEN", .defaultValue = 0.3, .min = 0, .max = 1, .step = 0.05 },
|
|
{ .id = Id::Anisotropy, .category = Category::Graphics, .type = Type::Enum, .apply = Apply::Live,
|
|
.table = "video", .key = "anisotropic_filtering", .label = "Anisotropic Filtering",
|
|
.description = "Keeps roads and walls sharp at a distance. Auto: 16x on a dedicated GPU or an M-series iPad, 4x on other integrated ones.",
|
|
.env = "NFSMW_ANISOTROPY", .defaultValue = kAnisotropyAuto, .min = 1, .max = 16, .entries = kAnisotropyEntries },
|
|
{ .id = Id::Mipmaps, .category = Category::Graphics, .type = Type::Bool, .apply = Apply::Live,
|
|
.table = "video", .key = "mipmaps", .label = "Mipmaps",
|
|
.description = "Use the game's smaller textures in the distance, so they don't shimmer.",
|
|
.env = "NFSMW_TEXTURE_MIPS", .defaultValue = 1 },
|
|
{ .id = Id::InternalResolution, .category = Category::Graphics, .type = Type::Enum, .apply = Apply::Live,
|
|
.table = "video", .key = "internal_resolution", .label = "Internal Resolution",
|
|
.description = "Render the 3D scene above the 360's 720p. 1.5x is 1080p, a little over half the pixels of 2x. Auto: from the screen (1440p: 2x, or 3x2 filling an ultrawide).",
|
|
.env = "NFSMW_RESOLUTION", .defaultValue = kResolutionAuto, .entries = kResolutionEntries },
|
|
{ .id = Id::WindowWidth, .category = Category::Display, .type = Type::Int, .apply = Apply::Restart, .hidden = true,
|
|
.table = "video", .key = "window_width", .label = "Window Width",
|
|
.description = "The window's width when the game starts.",
|
|
.env = "NFSMW_WINDOW", .envField = 1, .defaultValue = 1280, .min = 320, .max = 16384, .step = 1, .unit = "px" },
|
|
{ .id = Id::WindowHeight, .category = Category::Display, .type = Type::Int, .apply = Apply::Restart, .hidden = true,
|
|
.table = "video", .key = "window_height", .label = "Window Height",
|
|
.description = "The window's height when the game starts.",
|
|
.env = "NFSMW_WINDOW", .envField = 2, .defaultValue = 720, .min = 200, .max = 16384, .step = 1, .unit = "px" },
|
|
// Audio
|
|
{ .id = Id::MasterVolume, .category = Category::Audio, .type = Type::Int, .apply = Apply::Live,
|
|
.table = "audio", .key = "master_volume", .label = "Master Volume",
|
|
.description = "The volume of all game audio.",
|
|
.env = "NFSMW_VOLUME", .defaultValue = 100, .min = 0, .max = 100, .step = 5, .unit = "%" },
|
|
{ .id = Id::Mute, .category = Category::Audio, .type = Type::Bool, .apply = Apply::Live,
|
|
.table = "audio", .key = "mute", .label = "Mute",
|
|
.description = "Silence all game audio.",
|
|
.env = "NFSMW_MUTE", .defaultValue = 0 },
|
|
{ .id = Id::MuteInBackground, .category = Category::Audio, .type = Type::Bool, .apply = Apply::Live,
|
|
.table = "audio", .key = "mute_in_background", .label = "Mute in Background",
|
|
.description = "Silence the game while its window isn't focused.",
|
|
.env = "NFSMW_MUTE_UNFOCUSED", .defaultValue = 0 },
|
|
// Input
|
|
{ .id = Id::StickDeadzone, .category = Category::Input, .type = Type::Float, .apply = Apply::Live,
|
|
.table = "input", .key = "stick_deadzone", .label = "Stick Deadzone",
|
|
.description = "How far a stick moves before the game sees it. Raise it if the car steers by itself.",
|
|
.env = "NFSMW_DEADZONE", .defaultValue = 0.08, .min = 0, .max = 0.9, .step = 0.01, .unit = "%" },
|
|
{ .id = Id::TriggerDeadzone, .category = Category::Input, .type = Type::Float, .apply = Apply::Live,
|
|
.table = "input", .key = "trigger_deadzone", .label = "Trigger Deadzone",
|
|
.description = "How far a trigger is pressed before it counts.",
|
|
.env = "NFSMW_TRIGGER_DEADZONE", .defaultValue = 0.02, .min = 0, .max = 0.9, .step = 0.01, .unit = "%" },
|
|
{ .id = Id::Vibration, .category = Category::Input, .type = Type::Bool, .apply = Apply::Live,
|
|
.table = "input", .key = "vibration", .label = "Vibration",
|
|
.description = "Controller rumble.",
|
|
.env = "NFSMW_VIBRATION", .defaultValue = 1 },
|
|
// Controls (Settings > Controls; hid/key_map.h reads them). The
|
|
// defaults are v0.1.4's keys: the arrows steer as well as A
|
|
// and D, and are the D-pad; O/L/K/; are the right stick.
|
|
KeyOption(Id::KeyRightTrigger, "right_trigger", "Right Trigger", "Gas in the game's default layout.", "w"),
|
|
KeyOption2(Id::KeyRightTrigger2, "right_trigger_2", "Right Trigger", "none"),
|
|
KeyOption(Id::KeyLeftTrigger, "left_trigger", "Left Trigger", "Brake and reverse in the game's default layout.", "s"),
|
|
KeyOption2(Id::KeyLeftTrigger2, "left_trigger_2", "Left Trigger", "none"),
|
|
KeyOption(Id::KeyLeftStickLeft, "left_stick_left", "Left Stick Left", "Steer left.", "a"),
|
|
KeyOption2(Id::KeyLeftStickLeft2, "left_stick_left_2", "Left Stick Left", "left"),
|
|
KeyOption(Id::KeyLeftStickRight, "left_stick_right", "Left Stick Right", "Steer right.", "d"),
|
|
KeyOption2(Id::KeyLeftStickRight2, "left_stick_right_2", "Left Stick Right", "right"),
|
|
KeyOption(Id::KeyLeftStickUp, "left_stick_up", "Left Stick Up", "The left stick pushed up (menus).", "up"),
|
|
KeyOption2(Id::KeyLeftStickUp2, "left_stick_up_2", "Left Stick Up", "none"),
|
|
KeyOption(Id::KeyLeftStickDown, "left_stick_down", "Left Stick Down", "The left stick pulled down.", "down"),
|
|
KeyOption2(Id::KeyLeftStickDown2, "left_stick_down_2", "Left Stick Down", "none"),
|
|
KeyOption(Id::KeyA, "a", "A", "The A button.", "space"),
|
|
KeyOption2(Id::KeyA2, "a_2", "A", "none"),
|
|
KeyOption(Id::KeyB, "b", "B", "The B button.", "backspace"),
|
|
KeyOption2(Id::KeyB2, "b_2", "B", "none"),
|
|
KeyOption(Id::KeyX, "x", "X", "The X button.", "left_shift"),
|
|
KeyOption2(Id::KeyX2, "x_2", "X", "none"),
|
|
KeyOption(Id::KeyY, "y", "Y", "The Y button.", "left_ctrl"),
|
|
KeyOption2(Id::KeyY2, "y_2", "Y", "none"),
|
|
KeyOption(Id::KeyLB, "lb", "LB", "The left shoulder button.", "q"),
|
|
KeyOption2(Id::KeyLB2, "lb_2", "LB", "none"),
|
|
KeyOption(Id::KeyRB, "rb", "RB", "The right shoulder button.", "e"),
|
|
KeyOption2(Id::KeyRB2, "rb_2", "RB", "none"),
|
|
KeyOption(Id::KeyDpadUp, "dpad_up", "D-pad Up", "The D-pad, up.", "up"),
|
|
KeyOption2(Id::KeyDpadUp2, "dpad_up_2", "D-pad Up", "none"),
|
|
KeyOption(Id::KeyDpadDown, "dpad_down", "D-pad Down", "The D-pad, down.", "down"),
|
|
KeyOption2(Id::KeyDpadDown2, "dpad_down_2", "D-pad Down", "none"),
|
|
KeyOption(Id::KeyDpadLeft, "dpad_left", "D-pad Left", "The D-pad, left.", "left"),
|
|
KeyOption2(Id::KeyDpadLeft2, "dpad_left_2", "D-pad Left", "none"),
|
|
KeyOption(Id::KeyDpadRight, "dpad_right", "D-pad Right", "The D-pad, right.", "right"),
|
|
KeyOption2(Id::KeyDpadRight2, "dpad_right_2", "D-pad Right", "none"),
|
|
KeyOption(Id::KeyStart, "start", "Start", "Start: pause.", "return"),
|
|
KeyOption2(Id::KeyStart2, "start_2", "Start", "none"),
|
|
KeyOption(Id::KeyBack, "back", "Back", "Back.", "escape"),
|
|
KeyOption2(Id::KeyBack2, "back_2", "Back", "none"),
|
|
KeyOption(Id::KeyRightStickUp, "right_stick_up", "Right Stick Up", "The right stick pushed up.", "o"),
|
|
KeyOption2(Id::KeyRightStickUp2, "right_stick_up_2", "Right Stick Up", "none"),
|
|
KeyOption(Id::KeyRightStickDown, "right_stick_down", "Right Stick Down", "The right stick pulled down.", "l"),
|
|
KeyOption2(Id::KeyRightStickDown2, "right_stick_down_2", "Right Stick Down", "none"),
|
|
KeyOption(Id::KeyRightStickLeft, "right_stick_left", "Right Stick Left", "The right stick pushed left.", "k"),
|
|
KeyOption2(Id::KeyRightStickLeft2, "right_stick_left_2", "Right Stick Left", "none"),
|
|
KeyOption(Id::KeyRightStickRight, "right_stick_right", "Right Stick Right", "The right stick pushed right.", "semicolon"),
|
|
KeyOption2(Id::KeyRightStickRight2, "right_stick_right_2", "Right Stick Right", "none"),
|
|
KeyOption(Id::KeyLeftStickClick, "left_stick_click", "Left Stick Click", "Pressing the left stick in.", "z"),
|
|
KeyOption2(Id::KeyLeftStickClick2, "left_stick_click_2", "Left Stick Click", "none"),
|
|
KeyOption(Id::KeyRightStickClick, "right_stick_click", "Right Stick Click", "Pressing the right stick in.", "c"),
|
|
KeyOption2(Id::KeyRightStickClick2, "right_stick_click_2", "Right Stick Click", "none"),
|
|
// Advanced
|
|
{ .id = Id::AsyncShaders, .category = Category::Advanced, .type = Type::Bool, .apply = Apply::Live,
|
|
.table = "advanced", .key = "async_shaders", .label = "Asynchronous Shaders",
|
|
.description = "Skip an object for a frame or two while its shader compiles, instead of stuttering.",
|
|
.env = "NFSMW_ASYNC_PIPELINES", .defaultValue = 1 },
|
|
{ .id = Id::PerformanceOverlay, .category = Category::Advanced, .type = Type::Bool, .apply = Apply::Live,
|
|
.table = "advanced", .key = "performance_overlay", .label = "Performance Overlay",
|
|
.description = "Show the frame rate and frame time in a corner of the screen.",
|
|
.env = "NFSMW_PERF_OVERLAY", .defaultValue = 0 },
|
|
};
|
|
static_assert(std::size(kOptions) == kCount, "one row per settings::Id");
|
|
|
|
consteval bool TableIsConsistent()
|
|
{
|
|
for (size_t i = 0; i < kCount; i++)
|
|
{
|
|
const Option& o = kOptions[i];
|
|
if (size_t(o.id) != i)
|
|
return false; // rows in Id order
|
|
// A table's keys are contiguous, so Save writes each table once.
|
|
if (i > 0 && std::string_view(kOptions[i - 1].table) != o.table)
|
|
for (size_t j = 0; j < i; j++)
|
|
if (std::string_view(kOptions[j].table) == o.table)
|
|
return false;
|
|
bool numeric = o.type == Type::Int || o.type == Type::Float;
|
|
if (numeric && !(o.min < o.max && o.step > 0 && o.defaultValue >= o.min && o.defaultValue <= o.max))
|
|
return false;
|
|
if (o.type == Type::Bool && o.defaultValue != 0 && o.defaultValue != 1)
|
|
return false;
|
|
if (o.type == Type::Enum)
|
|
{
|
|
bool found = false;
|
|
for (const EnumEntry& e : o.entries)
|
|
found |= double(e.value) == o.defaultValue;
|
|
if (!found)
|
|
return false;
|
|
}
|
|
if (o.type == Type::Key && !(KeyCode(o.defaultValue) != keynames::kUnknown && o.min == 0 && o.max == 0 &&
|
|
o.step == 0 && o.entries.empty() && !o.hidden))
|
|
return false;
|
|
if ((o.env == nullptr) != (o.type == Type::Key) || o.label == nullptr || o.description == nullptr)
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
static_assert(TableIsConsistent());
|
|
|
|
// Writers (Set, Load, the first early read) hold s_mutex; the values
|
|
// themselves are atomics, so readers never lock.
|
|
std::mutex s_mutex;
|
|
std::atomic<double> s_stored[kCount]{}; // what settings.toml holds / the menu set
|
|
std::atomic<double> s_envValue[kCount]{}; // this run's environment override
|
|
std::atomic<bool> s_envSet[kCount]{};
|
|
double s_baseline[kCount]{}; // stored values as last loaded or saved (Dirty)
|
|
std::atomic<double> s_deviceDefault[kCount]{}; // SetDeviceDefault's, where s_hasDeviceDefault
|
|
std::atomic<bool> s_hasDeviceDefault[kCount]{};
|
|
bool s_frozen = false; // Restart options hold their launch values (first Load)
|
|
std::mutex s_saveMutex; // whole saves, so the last snapshot is the last file
|
|
|
|
// Option i's default on this device (DefaultValue).
|
|
double Default(size_t i)
|
|
{
|
|
return s_hasDeviceDefault[i].load(std::memory_order_acquire) ? s_deviceDefault[i].load(std::memory_order_relaxed)
|
|
: kOptions[i].defaultValue;
|
|
}
|
|
|
|
std::string& LastErrorText()
|
|
{
|
|
static std::string text; // under s_mutex; LastError()
|
|
return text;
|
|
}
|
|
|
|
std::filesystem::path& LoadedPath()
|
|
{
|
|
static std::filesystem::path path; // under s_mutex
|
|
return path;
|
|
}
|
|
|
|
// The [app] table: the game's own records, not options (the menu
|
|
// never shows them). last_version: the version that ran last
|
|
// (user/version_check.h). Under s_mutex.
|
|
constexpr const char* kAppTable = "app";
|
|
constexpr const char* kLastVersionKey = "last_version";
|
|
std::string& LastVersionText()
|
|
{
|
|
static std::string text;
|
|
return text;
|
|
}
|
|
bool s_fileFound = false; // the last Load found a settings.toml
|
|
bool s_fileLoaded = false; // ...and read and parsed it
|
|
|
|
std::string AppTable(const std::string& lastVersion)
|
|
{
|
|
// Only what a version looks like (a hand edit could hold a quote
|
|
// or a newline, which would break the file).
|
|
if (lastVersion.empty() || lastVersion.size() > 64 ||
|
|
!std::all_of(lastVersion.begin(), lastVersion.end(), [](char c) { return std::isalnum(uint8_t(c)) || c == '.' || c == '-' || c == '+'; }))
|
|
return "";
|
|
return std::format("\n[{}]\n# The SpeedBreaker version that ran last (the game keeps this up to date).\n{} = \"{}\"\n",
|
|
kAppTable, kLastVersionKey, lastVersion);
|
|
}
|
|
|
|
// `text` (a settings.toml) without its [app] table: from a line that
|
|
// is [app] to the next table's line (or the end).
|
|
std::string WithoutAppTable(std::string_view text)
|
|
{
|
|
std::string out;
|
|
bool inApp = false;
|
|
while (!text.empty())
|
|
{
|
|
size_t end = text.find('\n');
|
|
std::string_view line = text.substr(0, end == std::string_view::npos ? text.size() : end + 1);
|
|
text.remove_prefix(line.size());
|
|
std::string_view trimmed = line;
|
|
while (!trimmed.empty() && (trimmed.front() == ' ' || trimmed.front() == '\t'))
|
|
trimmed.remove_prefix(1);
|
|
if (trimmed.starts_with("["))
|
|
{
|
|
size_t close = trimmed.find(']');
|
|
std::string_view name = close == std::string_view::npos ? std::string_view() : trimmed.substr(1, close - 1);
|
|
while (!name.empty() && name.front() == ' ')
|
|
name.remove_prefix(1);
|
|
while (!name.empty() && name.back() == ' ')
|
|
name.remove_suffix(1);
|
|
inApp = name == kAppTable;
|
|
}
|
|
if (!inApp)
|
|
out += line;
|
|
}
|
|
while (out.size() >= 2 && out.ends_with("\n\n"))
|
|
out.pop_back();
|
|
return out;
|
|
}
|
|
|
|
std::mutex s_listenerMutex;
|
|
std::vector<std::pair<uint32_t, std::shared_ptr<const Listener>>> s_listeners;
|
|
uint32_t s_nextHandle = 1;
|
|
|
|
std::string_view Trim(std::string_view s)
|
|
{
|
|
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\n' || s.front() == '\r'))
|
|
s.remove_prefix(1);
|
|
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\n' || s.back() == '\r'))
|
|
s.remove_suffix(1);
|
|
return s;
|
|
}
|
|
|
|
bool EqualsIgnoreCase(std::string_view a, std::string_view b)
|
|
{
|
|
return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
|
|
return std::tolower(uint8_t(x)) == std::tolower(uint8_t(y));
|
|
});
|
|
}
|
|
|
|
// strtod over the whole text; no NaN or infinity.
|
|
std::optional<double> ParseNumber(std::string_view text)
|
|
{
|
|
std::string s(Trim(text));
|
|
if (s.empty())
|
|
return {};
|
|
char* end = nullptr;
|
|
errno = 0;
|
|
double v = std::strtod(s.c_str(), &end);
|
|
if (end != s.c_str() + s.size() || errno == ERANGE || !std::isfinite(v))
|
|
return {};
|
|
return v;
|
|
}
|
|
|
|
std::optional<double> ParseBool(std::string_view text)
|
|
{
|
|
text = Trim(text);
|
|
for (const char* t : { "true", "on", "yes" })
|
|
if (EqualsIgnoreCase(text, t))
|
|
return 1.0;
|
|
for (const char* f : { "false", "off", "no" })
|
|
if (EqualsIgnoreCase(text, f))
|
|
return 0.0;
|
|
// Numbers as the old getenv checks read them: 0 off, anything else on.
|
|
if (auto n = ParseNumber(text))
|
|
return *n != 0.0 ? 1.0 : 0.0;
|
|
return {};
|
|
}
|
|
|
|
const EnumEntry* FindEntry(const Option& o, double value)
|
|
{
|
|
for (const EnumEntry& e : o.entries)
|
|
if (double(e.value) == value)
|
|
return &e;
|
|
return nullptr;
|
|
}
|
|
|
|
const EnumEntry* FindToken(const Option& o, std::string_view token)
|
|
{
|
|
token = Trim(token);
|
|
for (const EnumEntry& e : o.entries)
|
|
if (EqualsIgnoreCase(token, e.token))
|
|
return &e;
|
|
return nullptr;
|
|
}
|
|
|
|
// A number for a numeric enum (anisotropy): clamped to its range,
|
|
// then the entry at or below it, as NFSMW_ANISOTROPY=<1..16> read.
|
|
std::optional<double> EnumFromNumber(const Option& o, double n)
|
|
{
|
|
if (!(o.min < o.max))
|
|
return {}; // tokens only
|
|
n = std::clamp(std::round(n), o.min, o.max);
|
|
const EnumEntry* best = nullptr;
|
|
for (const EnumEntry& e : o.entries)
|
|
if (e.value >= o.min && e.value <= n && (!best || e.value > best->value))
|
|
best = &e;
|
|
if (!best)
|
|
return {};
|
|
return double(best->value);
|
|
}
|
|
|
|
// A value this option can hold: Bool 0/1, Int rounded and clamped,
|
|
// Float clamped and rounded to 1e-4 (a menu slider's float steps
|
|
// then save as 0.35, not 0.3499999940395355), Enum an entry's value.
|
|
// Adding 0.0 turns -0.0 (a file's "-0.0", NFSMW_DEADZONE=-0) into 0,
|
|
// which would otherwise be saved as "-0.0" and shown as "-0%".
|
|
std::optional<double> Normalize(const Option& o, double v)
|
|
{
|
|
if (std::isnan(v))
|
|
return {};
|
|
switch (o.type)
|
|
{
|
|
case Type::Bool:
|
|
return v != 0.0 ? 1.0 : 0.0;
|
|
case Type::Int:
|
|
return std::clamp(std::round(v), o.min, o.max) + 0.0;
|
|
case Type::Float:
|
|
return std::clamp(std::round(std::clamp(v, o.min, o.max) * 1e4) / 1e4, o.min, o.max) + 0.0;
|
|
case Type::Enum:
|
|
if (const EnumEntry* e = FindEntry(o, v))
|
|
return double(e->value);
|
|
return {};
|
|
case Type::Key:
|
|
// No key, or a key of the table: the file holds its name.
|
|
if (KeyCode(v) == keynames::kUnknown)
|
|
return {};
|
|
return v;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
// What the option accepts, for log lines and the file's comments.
|
|
std::string RangeText(const Option& o)
|
|
{
|
|
switch (o.type)
|
|
{
|
|
case Type::Bool:
|
|
return "true or false";
|
|
case Type::Int:
|
|
return std::format("a whole number from {} to {}", o.min, o.max);
|
|
case Type::Float:
|
|
return std::format("a number from {} to {}", o.min, o.max);
|
|
case Type::Enum:
|
|
{
|
|
std::string text = "one of ";
|
|
for (size_t e = 0; e < o.entries.size(); e++)
|
|
text += std::format("{}\"{}\"", e ? ", " : "", o.entries[e].token);
|
|
if (o.min < o.max)
|
|
text += std::format(", or a number from {} to {}", o.min, o.max);
|
|
return text;
|
|
}
|
|
case Type::Key:
|
|
return "a key name such as \"w\", \"left_shift\" or \"keypad_1\", or \"none\" (user/key_names.h lists them)";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// Text (the environment, or a string in the file) as a value of the
|
|
// option's kind, not yet clamped: Bool 0/1, Int and Float the
|
|
// number, Enum an entry's value ("16x", "linear", or a number for a
|
|
// numeric enum).
|
|
std::optional<double> RawFromText(const Option& o, std::string_view text)
|
|
{
|
|
switch (o.type)
|
|
{
|
|
case Type::Bool:
|
|
return ParseBool(text);
|
|
case Type::Int:
|
|
case Type::Float:
|
|
return ParseNumber(text);
|
|
case Type::Enum:
|
|
if (const EnumEntry* e = FindToken(o, text))
|
|
return double(e->value);
|
|
if (auto n = ParseNumber(text))
|
|
return EnumFromNumber(o, *n);
|
|
return {};
|
|
case Type::Key:
|
|
if (uint16_t code = keynames::Code(text); code != keynames::kUnknown)
|
|
return double(code);
|
|
return {};
|
|
}
|
|
return {};
|
|
}
|
|
|
|
// A TOML value, the same way: strings through RawFromText ("on",
|
|
// "16x"), numbers and booleans as they are.
|
|
std::optional<double> RawFromToml(const Option& o, const toml::node& node)
|
|
{
|
|
if (auto s = node.as_string())
|
|
return RawFromText(o, s->get());
|
|
if (auto b = node.as_boolean())
|
|
return o.type == Type::Bool ? std::optional<double>(b->get() ? 1.0 : 0.0) : std::nullopt;
|
|
std::optional<double> n;
|
|
if (auto i = node.as_integer())
|
|
n = double(i->get());
|
|
else if (auto f = node.as_floating_point(); f && std::isfinite(f->get()))
|
|
n = f->get();
|
|
if (!n)
|
|
return {};
|
|
switch (o.type)
|
|
{
|
|
case Type::Bool:
|
|
return *n != 0.0 ? 1.0 : 0.0;
|
|
case Type::Int:
|
|
case Type::Float:
|
|
return n;
|
|
case Type::Enum:
|
|
return EnumFromNumber(o, *n);
|
|
case Type::Key:
|
|
return {}; // names only
|
|
}
|
|
return {};
|
|
}
|
|
|
|
// A raw value normalised (clamped, rounded), with a log line naming
|
|
// `source` when it couldn't be used or had to be changed.
|
|
std::optional<double> Accept(const Option& o, std::optional<double> raw, const std::string& source, bool log)
|
|
{
|
|
if (!raw)
|
|
{
|
|
if (log)
|
|
fprintf(stderr, "[settings] %s: not %s; ignored\n", source.c_str(), RangeText(o).c_str());
|
|
return {};
|
|
}
|
|
std::optional<double> v = Normalize(o, *raw);
|
|
if (log && v && (o.type == Type::Int || o.type == Type::Float) && std::abs(*v - *raw) > 1e-4)
|
|
{
|
|
if (*raw < o.min || *raw > o.max)
|
|
fprintf(stderr, "[settings] %s: out of range (%s to %s); using %s\n", source.c_str(),
|
|
std::format("{}", o.min).c_str(), std::format("{}", o.max).c_str(), std::format("{}", *v).c_str());
|
|
else
|
|
fprintf(stderr, "[settings] %s: rounded to %s\n", source.c_str(), std::format("{}", *v).c_str());
|
|
}
|
|
return v;
|
|
}
|
|
|
|
std::string NodeText(const toml::node& node)
|
|
{
|
|
if (auto b = node.as_boolean())
|
|
return b->get() ? "true" : "false";
|
|
if (auto i = node.as_integer())
|
|
return std::format("{}", i->get());
|
|
if (auto f = node.as_floating_point())
|
|
return std::format("{}", f->get());
|
|
if (auto s = node.as_string())
|
|
return std::format("\"{}\"", s->get());
|
|
if (node.is_table())
|
|
return "(a table)";
|
|
if (node.is_array())
|
|
return "(an array)";
|
|
return "(a date or time)";
|
|
}
|
|
|
|
// The n-th (from 1) 'x'-separated field: NFSMW_WINDOW=3440x1440.
|
|
std::optional<std::string_view> EnvField(std::string_view value, int field)
|
|
{
|
|
if (field <= 0)
|
|
return value;
|
|
for (int n = 1; ; n++)
|
|
{
|
|
size_t x = value.find_first_of("xX");
|
|
if (n == field)
|
|
return value.substr(0, x);
|
|
if (x == std::string_view::npos)
|
|
return {};
|
|
value.remove_prefix(x + 1);
|
|
}
|
|
}
|
|
|
|
// Reads option i's environment variable into s_envValue/s_envSet.
|
|
// Under s_mutex. The runtime never calls setenv, so getenv is safe.
|
|
void ReadEnvLocked(size_t i, bool log)
|
|
{
|
|
const Option& o = kOptions[i];
|
|
s_envSet[i].store(false, std::memory_order_relaxed);
|
|
if (!o.env)
|
|
return; // a Key option: the file alone
|
|
const char* v = std::getenv(o.env);
|
|
if (!v)
|
|
return;
|
|
std::optional<std::string_view> field = EnvField(v, o.envField);
|
|
if (!field)
|
|
return;
|
|
std::string source = std::format("{}={} ({}.{})", o.env, v, o.table, o.key);
|
|
// Quiet before Load(): Load() reads it again and logs.
|
|
std::optional<double> value = Accept(o, RawFromText(o, *field), source, log);
|
|
if (!value)
|
|
return;
|
|
s_envValue[i].store(*value, std::memory_order_relaxed);
|
|
s_envSet[i].store(true, std::memory_order_relaxed);
|
|
if (log)
|
|
fprintf(stderr, "[settings] %s overrides %s.%s for this run\n", o.env, o.table, o.key);
|
|
}
|
|
|
|
// Publishes option i's effective value. Under s_mutex. Returns
|
|
// whether it changed.
|
|
bool RecomputeLocked(size_t i)
|
|
{
|
|
if (kOptions[i].apply == Apply::Restart && s_frozen)
|
|
return false; // keeps its launch value until the next run
|
|
double v = s_envSet[i].load(std::memory_order_relaxed) ? s_envValue[i].load(std::memory_order_relaxed)
|
|
: s_stored[i].load(std::memory_order_relaxed);
|
|
return detail::g_effective[i].exchange(v, std::memory_order_relaxed) != v;
|
|
}
|
|
|
|
// Defaults plus the environment, for anything that runs before
|
|
// Load(). Under s_mutex.
|
|
void EnsureInitLocked(const char* reason)
|
|
{
|
|
if (detail::g_state.load(std::memory_order_relaxed) != 0)
|
|
return;
|
|
fprintf(stderr, "[settings] %s before settings::Load(): the environment and defaults only\n", reason);
|
|
for (size_t i = 0; i < kCount; i++)
|
|
{
|
|
s_stored[i].store(Default(i), std::memory_order_relaxed);
|
|
s_baseline[i] = Default(i);
|
|
ReadEnvLocked(i, false);
|
|
RecomputeLocked(i);
|
|
}
|
|
detail::g_generation.fetch_add(1, std::memory_order_release);
|
|
detail::g_state.store(1, std::memory_order_release);
|
|
}
|
|
|
|
void EnsureInit(const char* reason)
|
|
{
|
|
if (detail::g_state.load(std::memory_order_acquire) != 0)
|
|
return;
|
|
std::lock_guard lock(s_mutex);
|
|
EnsureInitLocked(reason);
|
|
}
|
|
|
|
void Notify(std::span<const Id> ids)
|
|
{
|
|
if (ids.empty())
|
|
return;
|
|
// Copied so a listener can Subscribe/Unsubscribe without deadlock.
|
|
std::vector<std::shared_ptr<const Listener>> listeners;
|
|
{
|
|
std::lock_guard lock(s_listenerMutex);
|
|
for (auto& [handle, listener] : s_listeners)
|
|
listeners.push_back(listener);
|
|
}
|
|
for (Id id : ids)
|
|
for (auto& listener : listeners)
|
|
(*listener)(id);
|
|
}
|
|
|
|
// settings.toml is ~3 KB; anything this big isn't one.
|
|
constexpr size_t kMaxFileSize = 1 << 20;
|
|
|
|
enum class ReadStatus { Ok, Missing, Failed };
|
|
|
|
// Reads a settings file into `out`; on Failed, `error` says why.
|
|
// Only a regular file: opening a FIFO blocks until a writer comes
|
|
// (the game would hang at launch), and /dev/zero never ends. O_NONBLOCK
|
|
// makes the open itself return at once; it changes nothing for reads
|
|
// of a regular file.
|
|
ReadStatus ReadFile(const std::filesystem::path& path, std::string& out, std::string& error)
|
|
{
|
|
int fd = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NONBLOCK);
|
|
if (fd < 0)
|
|
{
|
|
if (errno == ENOENT)
|
|
return ReadStatus::Missing;
|
|
error = strerror(errno);
|
|
return ReadStatus::Failed;
|
|
}
|
|
struct stat st;
|
|
if (fstat(fd, &st) != 0)
|
|
error = strerror(errno);
|
|
else if (S_ISDIR(st.st_mode))
|
|
error = "it's a folder";
|
|
else if (!S_ISREG(st.st_mode))
|
|
error = "it isn't a regular file";
|
|
else if (uint64_t(st.st_size) > kMaxFileSize)
|
|
error = std::format("it's {} KB, too big for a settings file", uint64_t(st.st_size) / 1024);
|
|
if (!error.empty())
|
|
{
|
|
close(fd);
|
|
return ReadStatus::Failed;
|
|
}
|
|
char buffer[4096];
|
|
for (;;)
|
|
{
|
|
ssize_t n = read(fd, buffer, sizeof(buffer));
|
|
if (n < 0 && errno == EINTR)
|
|
continue;
|
|
if (n < 0)
|
|
{
|
|
error = strerror(errno);
|
|
break;
|
|
}
|
|
if (n == 0)
|
|
break;
|
|
out.append(buffer, size_t(n));
|
|
if (out.size() > kMaxFileSize) // grew after the fstat
|
|
{
|
|
error = "it's too big for a settings file";
|
|
break;
|
|
}
|
|
}
|
|
close(fd);
|
|
return error.empty() ? ReadStatus::Ok : ReadStatus::Failed;
|
|
}
|
|
|
|
std::string TomlValue(const Option& o, double v)
|
|
{
|
|
switch (o.type)
|
|
{
|
|
case Type::Bool:
|
|
return v != 0.0 ? "true" : "false";
|
|
case Type::Int:
|
|
return std::format("{}", int64_t(v));
|
|
case Type::Float:
|
|
{
|
|
std::string s = std::format("{}", v); // shortest round trip
|
|
if (s.find_first_of(".e") == std::string::npos)
|
|
s += ".0"; // a TOML float, not an integer
|
|
return s;
|
|
}
|
|
case Type::Enum:
|
|
if (const EnumEntry* e = FindEntry(o, v))
|
|
return std::format("\"{}\"", e->token);
|
|
if (const EnumEntry* e = FindEntry(o, Default(size_t(o.id))))
|
|
return std::format("\"{}\"", e->token);
|
|
return std::format("\"{}\"", FindEntry(o, o.defaultValue)->token);
|
|
case Type::Key:
|
|
{
|
|
uint16_t code = KeyCode(v);
|
|
if (code == keynames::kUnknown)
|
|
code = KeyCode(o.defaultValue);
|
|
return std::format("\"{}\"", keynames::Token(code)); // [a-z0-9_]+: nothing to escape
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
std::string Serialize(const std::array<double, kCount>& values)
|
|
{
|
|
std::string out =
|
|
"# SpeedBreaker settings, written by the game's settings menu. Hand edits\n"
|
|
"# are fine while the game is closed; unknown keys are dropped on the next\n"
|
|
"# save. The environment variable after an option overrides it for one run\n"
|
|
"# without changing this file.\n";
|
|
const char* table = "";
|
|
for (size_t i = 0; i < kCount; i++)
|
|
{
|
|
const Option& o = kOptions[i];
|
|
if (std::string_view(o.table) != table)
|
|
{
|
|
table = o.table;
|
|
out += std::format("\n[{}]\n", table);
|
|
}
|
|
else
|
|
out += "\n";
|
|
std::string range = RangeText(o);
|
|
range[0] = char(std::toupper(uint8_t(range[0])));
|
|
std::string override = o.env ? std::format(" ({}{})", o.env, o.apply == Apply::Restart ? "; applies at the next launch" : "") : "";
|
|
out += std::format("# {}: {}\n# {}.{}\n", o.label, o.description, range, override);
|
|
out += std::format("{} = {}\n", o.key, TomlValue(o, values[i]));
|
|
}
|
|
std::string lastVersion;
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
lastVersion = LastVersionText();
|
|
}
|
|
return out + AppTable(lastVersion);
|
|
}
|
|
|
|
// Where a save of `path` goes: through a symlinked settings.toml
|
|
// (dotfiles kept elsewhere) to the file it points at, since a rename
|
|
// over the link would replace it with a plain file. A relative link
|
|
// is relative to its own directory, as the kernel reads it; a
|
|
// dangling one still names where the file belongs.
|
|
std::filesystem::path SaveTarget(const std::filesystem::path& path)
|
|
{
|
|
std::filesystem::path p = path;
|
|
for (int hops = 0; hops < 8; hops++) // a loop of links: the rename replaces the last one
|
|
{
|
|
std::error_code ec;
|
|
if (!std::filesystem::is_symlink(std::filesystem::symlink_status(p, ec)))
|
|
break;
|
|
std::filesystem::path link = std::filesystem::read_symlink(p, ec);
|
|
if (ec || link.empty())
|
|
break;
|
|
p = link.is_absolute() ? link : p.parent_path() / link;
|
|
}
|
|
return p;
|
|
}
|
|
|
|
// Removes what crashed saves left next to `target`: settings.toml.tmp
|
|
// (the name before per-process ones) and any settings.toml.<pid>.tmp
|
|
// a minute old. A save takes milliseconds, so a younger one may be
|
|
// another instance's save in progress.
|
|
void RemoveStaleTemps(const std::filesystem::path& target)
|
|
{
|
|
std::string prefix = target.filename().string() + ".";
|
|
std::filesystem::path dir = target.parent_path().empty() ? std::filesystem::path(".") : target.parent_path();
|
|
auto now = std::filesystem::file_time_type::clock::now();
|
|
std::error_code ec;
|
|
for (std::filesystem::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec))
|
|
{
|
|
std::string file = it->path().filename().string();
|
|
if (file != prefix + "tmp")
|
|
{
|
|
if (file.size() <= prefix.size() + 4 || !file.starts_with(prefix) || !file.ends_with(".tmp"))
|
|
continue;
|
|
std::string_view pid = std::string_view(file).substr(prefix.size(), file.size() - prefix.size() - 4);
|
|
if (!std::all_of(pid.begin(), pid.end(), [](char c) { return c >= '0' && c <= '9'; }))
|
|
continue;
|
|
std::error_code timeError;
|
|
auto written = std::filesystem::last_write_time(it->path(), timeError);
|
|
if (timeError || now - written < std::chrono::minutes(1))
|
|
continue;
|
|
}
|
|
std::error_code removeError;
|
|
if (std::filesystem::remove(it->path(), removeError))
|
|
fprintf(stderr, "[settings] removed %s, left by a save that didn't finish\n", file.c_str());
|
|
}
|
|
}
|
|
|
|
// Writes `path` so that it's always either the old file or the whole
|
|
// new one: a temporary file in the same directory (rename is atomic
|
|
// only within one file system), flushed to disk before the rename
|
|
// (else a crash can leave the renamed file empty), then the
|
|
// directory, so the rename itself survives a power cut. The
|
|
// temporary file is this process's own (settings.toml.<pid>.tmp,
|
|
// created exclusively): with one shared name, two instances of the
|
|
// game saving at once write into the same file, and one renames the
|
|
// other's half-written text into place. Returns "" or why it failed.
|
|
std::string WriteAtomically(const std::filesystem::path& path, std::string_view text)
|
|
{
|
|
std::filesystem::path target = SaveTarget(path);
|
|
std::error_code ec;
|
|
std::filesystem::path dir = target.parent_path();
|
|
if (!dir.empty())
|
|
{
|
|
std::filesystem::create_directories(dir, ec);
|
|
if (ec)
|
|
return std::format("can't create {}: {}", dir.string(), ec.message());
|
|
}
|
|
RemoveStaleTemps(target);
|
|
std::filesystem::path temp = target;
|
|
temp += std::format(".{}.tmp", long(getpid()));
|
|
int fd = open(temp.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0644);
|
|
if (fd < 0 && errno == EEXIST)
|
|
{
|
|
// Saves within a process are serialised, so this was left by
|
|
// a crashed process that had the same pid.
|
|
unlink(temp.c_str());
|
|
fd = open(temp.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0644);
|
|
}
|
|
if (fd < 0)
|
|
return std::format("can't write {}: {}", target.string(), strerror(errno));
|
|
int error = 0;
|
|
for (size_t done = 0; !error && done < text.size();)
|
|
{
|
|
ssize_t n = write(fd, text.data() + done, text.size() - done);
|
|
if (n > 0)
|
|
done += size_t(n);
|
|
else if (n < 0 && errno == EINTR)
|
|
continue;
|
|
else
|
|
error = n == 0 ? EIO : errno;
|
|
}
|
|
#ifdef __APPLE__
|
|
// fsync on macOS doesn't flush the drive's cache; F_FULLFSYNC does
|
|
// (not every file system supports it).
|
|
if (!error && fcntl(fd, F_FULLFSYNC) != 0 && fsync(fd) != 0)
|
|
error = errno;
|
|
#else
|
|
if (!error && fsync(fd) != 0)
|
|
error = errno;
|
|
#endif
|
|
if (close(fd) != 0 && !error)
|
|
error = errno;
|
|
if (error)
|
|
{
|
|
unlink(temp.c_str());
|
|
return std::format("can't write {}: {}", target.string(), strerror(error));
|
|
}
|
|
if (rename(temp.c_str(), target.c_str()) != 0)
|
|
{
|
|
error = errno;
|
|
unlink(temp.c_str());
|
|
return std::format("can't replace {}: {}", target.string(), strerror(error));
|
|
}
|
|
if (int dirFd = open(dir.empty() ? "." : dir.c_str(), O_RDONLY | O_CLOEXEC); dirFd >= 0)
|
|
{
|
|
fsync(dirFd); // best effort: some file systems refuse it on directories
|
|
close(dirFd);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
int Decimals(double step)
|
|
{
|
|
int d = 0;
|
|
for (double s = step; d < 6 && std::abs(s - std::round(s)) > 1e-9; s *= 10)
|
|
d++;
|
|
return d;
|
|
}
|
|
}
|
|
|
|
namespace detail
|
|
{
|
|
constinit std::atomic<uint8_t> g_state{ 0 };
|
|
constinit std::atomic<double> g_effective[kCount]{};
|
|
constinit std::atomic<uint64_t> g_generation{ 0 };
|
|
static_assert(std::atomic<double>::is_always_lock_free);
|
|
|
|
void InitBeforeLoad(Id first)
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
if (g_state.load(std::memory_order_relaxed) != 0)
|
|
return;
|
|
const Option& o = kOptions[size_t(first)];
|
|
EnsureInitLocked(std::format("{}.{} was read", o.table, o.key).c_str());
|
|
}
|
|
}
|
|
|
|
std::span<const Option> Options()
|
|
{
|
|
return kOptions;
|
|
}
|
|
|
|
const Option& Info(Id id)
|
|
{
|
|
return kOptions[size_t(id)];
|
|
}
|
|
|
|
const Option* Find(std::string_view table, std::string_view key)
|
|
{
|
|
for (const Option& o : kOptions)
|
|
if (table == o.table && key == o.key)
|
|
return &o;
|
|
return nullptr;
|
|
}
|
|
|
|
const char* CategoryName(Category category)
|
|
{
|
|
switch (category)
|
|
{
|
|
case Category::Display: return "Display";
|
|
case Category::Graphics: return "Graphics";
|
|
case Category::Audio: return "Audio";
|
|
case Category::Input: return "Input";
|
|
case Category::Controls: return "Controls";
|
|
case Category::Advanced: return "Advanced";
|
|
default: return "";
|
|
}
|
|
}
|
|
|
|
double GetStoredValue(Id id)
|
|
{
|
|
EnsureInit("a stored value was read");
|
|
return s_stored[size_t(id)].load(std::memory_order_relaxed);
|
|
}
|
|
|
|
bool IsDefault(Id id)
|
|
{
|
|
return GetStoredValue(id) == Default(size_t(id));
|
|
}
|
|
|
|
void SetDeviceDefault(Id id, double value, std::string_view why)
|
|
{
|
|
size_t i = size_t(id);
|
|
const Option& o = kOptions[i];
|
|
std::optional<double> v = Normalize(o, value);
|
|
if (!v)
|
|
return;
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
s_deviceDefault[i].store(*v, std::memory_order_relaxed);
|
|
s_hasDeviceDefault[i].store(true, std::memory_order_release);
|
|
}
|
|
fprintf(stderr, "[settings] %s.%s defaults to %s on this device (%s)%s\n", o.table, o.key, TomlValue(o, *v).c_str(),
|
|
std::string(why).c_str(), detail::g_state.load(std::memory_order_acquire) == 2 ? "; too late for this run's stored value" : "");
|
|
}
|
|
|
|
double DefaultValue(Id id)
|
|
{
|
|
return Default(size_t(id));
|
|
}
|
|
|
|
const char* EnvOverride(Id id)
|
|
{
|
|
EnsureInit("an override was checked");
|
|
return s_envSet[size_t(id)].load(std::memory_order_relaxed) ? kOptions[size_t(id)].env : nullptr;
|
|
}
|
|
|
|
bool RestartPending(Id id)
|
|
{
|
|
size_t i = size_t(id);
|
|
EnsureInit("a restart was checked");
|
|
return kOptions[i].apply == Apply::Restart && !s_envSet[i].load(std::memory_order_relaxed) &&
|
|
s_stored[i].load(std::memory_order_relaxed) != detail::g_effective[i].load(std::memory_order_relaxed);
|
|
}
|
|
|
|
bool AnyRestartPending()
|
|
{
|
|
for (const Option& o : kOptions)
|
|
if (!o.hidden && RestartPending(o.id))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
bool Dirty()
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
EnsureInitLocked("settings were checked for changes");
|
|
for (size_t i = 0; i < kCount; i++)
|
|
if (s_stored[i].load(std::memory_order_relaxed) != s_baseline[i])
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
SetResult Set(Id id, double value)
|
|
{
|
|
size_t i = size_t(id);
|
|
const Option& o = kOptions[i];
|
|
std::optional<double> v = Normalize(o, value);
|
|
if (!v)
|
|
return SetResult::Invalid;
|
|
bool changed;
|
|
SetResult result;
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
EnsureInitLocked("a setting was set");
|
|
if (s_stored[i].load(std::memory_order_relaxed) == *v)
|
|
return SetResult::Unchanged;
|
|
s_stored[i].store(*v, std::memory_order_relaxed);
|
|
changed = RecomputeLocked(i);
|
|
if (s_envSet[i].load(std::memory_order_relaxed))
|
|
result = SetResult::Overridden;
|
|
else
|
|
result = detail::g_effective[i].load(std::memory_order_relaxed) == *v ? SetResult::Applied : SetResult::Deferred;
|
|
detail::g_generation.fetch_add(1, std::memory_order_release);
|
|
}
|
|
if (changed)
|
|
Notify({ &id, 1 });
|
|
return result;
|
|
}
|
|
|
|
SetResult Reset(Id id)
|
|
{
|
|
return Set(id, Default(size_t(id)));
|
|
}
|
|
|
|
void ResetAll()
|
|
{
|
|
for (const Option& o : kOptions)
|
|
Reset(o.id);
|
|
}
|
|
|
|
double StepValue(Id id, double value, int delta)
|
|
{
|
|
const Option& o = kOptions[size_t(id)];
|
|
switch (o.type)
|
|
{
|
|
case Type::Bool:
|
|
return delta == 0 ? (value != 0.0 ? 1.0 : 0.0) : value != 0.0 ? 0.0 : 1.0;
|
|
case Type::Enum:
|
|
{
|
|
ptrdiff_t index = 0;
|
|
for (size_t e = 0; e < o.entries.size(); e++)
|
|
if (double(o.entries[e].value) == value)
|
|
index = ptrdiff_t(e);
|
|
index = std::clamp<ptrdiff_t>(index + delta, 0, ptrdiff_t(o.entries.size()) - 1);
|
|
return double(o.entries[size_t(index)].value);
|
|
}
|
|
case Type::Int:
|
|
case Type::Float:
|
|
{
|
|
double v = std::isnan(value) ? Default(size_t(id)) : std::clamp(value, o.min, o.max);
|
|
// Its place on the step grid, fractional between points. Within
|
|
// float error of a point (0.3 / 0.05 is 5.999...) it's on it.
|
|
double position = (v - o.min) / o.step;
|
|
if (double nearest = std::round(position); std::abs(position - nearest) < 1e-6)
|
|
position = nearest;
|
|
// Between points, the first press goes only to the next point
|
|
// that way: 58 goes to 60 or 55, not 65 or 50.
|
|
double index = delta > 0 ? std::floor(position) + delta : delta < 0 ? std::ceil(position) + delta : position;
|
|
return *Normalize(o, o.min + index * o.step); // not NaN: v and step are finite
|
|
}
|
|
case Type::Key:
|
|
return value; // a key has no neighbour: the menu takes a key press instead
|
|
}
|
|
return value;
|
|
}
|
|
|
|
std::string FormatValue(Id id, double value)
|
|
{
|
|
const Option& o = kOptions[size_t(id)];
|
|
std::string_view unit = o.unit ? o.unit : "";
|
|
switch (o.type)
|
|
{
|
|
case Type::Bool:
|
|
return value != 0.0 ? "On" : "Off";
|
|
case Type::Enum:
|
|
if (const EnumEntry* e = FindEntry(o, value))
|
|
return e->label;
|
|
return "?";
|
|
case Type::Key:
|
|
{
|
|
uint16_t code = KeyCode(value);
|
|
return code == keynames::kUnknown ? "?" : keynames::Token(code);
|
|
}
|
|
case Type::Int:
|
|
case Type::Float:
|
|
break;
|
|
}
|
|
// Clamped first: a value out of range would overflow the integer
|
|
// cast (undefined behaviour), and -0.0 would show as "-0".
|
|
if (std::isnan(value))
|
|
return "?";
|
|
value = std::clamp(value, o.min, o.max) + 0.0;
|
|
if (o.type == Type::Int)
|
|
{
|
|
int64_t n = std::llround(value);
|
|
if (unit == "%")
|
|
return std::format("{}%", n);
|
|
// A range around zero (Brightness) shows its sign: -2, 0, +2.
|
|
if (o.min < 0 && n > 0)
|
|
return unit.empty() ? std::format("+{}", n) : std::format("+{} {}", n, unit);
|
|
return unit.empty() ? std::format("{}", n) : std::format("{} {}", n, unit);
|
|
}
|
|
if (unit == "%")
|
|
return std::format("{:.{}f}%", value * 100, Decimals(o.step * 100));
|
|
return unit.empty() ? std::format("{:.{}f}", value, Decimals(o.step))
|
|
: std::format("{:.{}f} {}", value, Decimals(o.step), unit);
|
|
}
|
|
|
|
uint32_t Subscribe(Listener listener)
|
|
{
|
|
std::lock_guard lock(s_listenerMutex);
|
|
uint32_t handle = s_nextHandle++;
|
|
s_listeners.emplace_back(handle, std::make_shared<const Listener>(std::move(listener)));
|
|
return handle;
|
|
}
|
|
|
|
void Unsubscribe(uint32_t handle)
|
|
{
|
|
std::lock_guard lock(s_listenerMutex);
|
|
std::erase_if(s_listeners, [handle](const auto& l) { return l.first == handle; });
|
|
}
|
|
|
|
std::filesystem::path DefaultPath()
|
|
{
|
|
return GetUserPath() / "settings.toml";
|
|
}
|
|
|
|
bool Load()
|
|
{
|
|
return Load(DefaultPath());
|
|
}
|
|
|
|
bool Load(const std::filesystem::path& path)
|
|
{
|
|
// Read and parse outside the lock: readers never wait, and writers
|
|
// wait only for the values to be published.
|
|
std::array<std::optional<double>, kCount> fileValues;
|
|
std::string text, readError, lastError, lastVersion;
|
|
size_t fromFile = 0;
|
|
ReadStatus status = ReadFile(path, text, readError);
|
|
if (status == ReadStatus::Missing)
|
|
fprintf(stderr, "[settings] no %s yet: defaults\n", path.c_str());
|
|
else if (status == ReadStatus::Failed)
|
|
{
|
|
fprintf(stderr, "[settings] can't read %s: %s; defaults\n", path.c_str(), readError.c_str());
|
|
lastError = std::format("Couldn't read {}: {}. The settings are at their defaults.", path.string(), readError);
|
|
}
|
|
else
|
|
{
|
|
toml::table root;
|
|
std::string error;
|
|
toml::source_position where{};
|
|
#if TOML_EXCEPTIONS
|
|
try
|
|
{
|
|
root = toml::parse(text, path.string());
|
|
}
|
|
catch (const toml::parse_error& e)
|
|
{
|
|
error = e.description();
|
|
where = e.source().begin;
|
|
}
|
|
#else
|
|
toml::parse_result result = toml::parse(text, path.string());
|
|
if (result)
|
|
root = std::move(result).table();
|
|
else
|
|
{
|
|
error = result.error().description();
|
|
where = result.error().source().begin;
|
|
}
|
|
#endif
|
|
if (!error.empty())
|
|
{
|
|
// Keep the player's file: the next Save would overwrite it.
|
|
std::filesystem::path bad = path;
|
|
bad += ".bad";
|
|
std::error_code ec;
|
|
std::filesystem::copy_file(path, bad, std::filesystem::copy_options::overwrite_existing, ec);
|
|
fprintf(stderr, "[settings] %s doesn't parse: %s (line %u, column %u); defaults (kept as %s)\n", path.c_str(),
|
|
error.c_str(), unsigned(where.line), unsigned(where.column), ec ? "nothing: the copy failed" : bad.c_str());
|
|
lastError = std::format("{} has a mistake at line {}, column {} ({}). The settings are at their defaults; {}.",
|
|
path.string(), where.line, where.column, error,
|
|
ec ? "saving them will replace the file" : std::format("the file was kept as {}", bad.string()));
|
|
}
|
|
for (auto&& [tableKey, tableNode] : root)
|
|
{
|
|
const toml::table* table = tableNode.as_table();
|
|
if (!table)
|
|
{
|
|
fprintf(stderr, "[settings] %s: %s outside a table; ignored\n", path.filename().c_str(),
|
|
std::string(tableKey.str()).c_str());
|
|
continue;
|
|
}
|
|
for (auto&& [key, node] : *table)
|
|
{
|
|
std::string name = std::format("{}.{}", tableKey.str(), key.str());
|
|
if (tableKey.str() == kAppTable && key.str() == kLastVersionKey)
|
|
{
|
|
if (auto v = node.as_string())
|
|
lastVersion = v->get();
|
|
else
|
|
fprintf(stderr, "[settings] %s: %s = %s isn't a version; ignored\n", path.filename().c_str(), name.c_str(),
|
|
NodeText(node).c_str());
|
|
continue;
|
|
}
|
|
const Option* o = Find(tableKey.str(), key.str());
|
|
if (!o)
|
|
{
|
|
fprintf(stderr, "[settings] %s: unknown key %s; ignored\n", path.filename().c_str(), name.c_str());
|
|
continue;
|
|
}
|
|
std::string source = std::format("{}: {} = {}", path.filename().string(), name, NodeText(node));
|
|
if (auto v = Accept(*o, RawFromToml(*o, node), source, true))
|
|
{
|
|
fileValues[size_t(o->id)] = v;
|
|
fromFile++;
|
|
}
|
|
}
|
|
}
|
|
if (error.empty())
|
|
fprintf(stderr, "[settings] %s: %zu value%s from the file\n", path.c_str(), fromFile, fromFile == 1 ? "" : "s");
|
|
}
|
|
|
|
std::vector<Id> changed;
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
for (size_t i = 0; i < kCount; i++)
|
|
{
|
|
double stored = fileValues[i].value_or(Default(i));
|
|
s_stored[i].store(stored, std::memory_order_relaxed);
|
|
s_baseline[i] = stored;
|
|
ReadEnvLocked(i, true);
|
|
if (RecomputeLocked(i))
|
|
changed.push_back(Id(i));
|
|
}
|
|
s_frozen = true;
|
|
LoadedPath() = path;
|
|
LastErrorText() = lastError;
|
|
LastVersionText() = lastVersion;
|
|
s_fileFound = status != ReadStatus::Missing;
|
|
s_fileLoaded = status == ReadStatus::Ok && lastError.empty();
|
|
detail::g_generation.fetch_add(1, std::memory_order_release);
|
|
detail::g_state.store(2, std::memory_order_release);
|
|
}
|
|
Notify(changed);
|
|
return lastError.empty();
|
|
}
|
|
|
|
bool Save()
|
|
{
|
|
std::filesystem::path path;
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
path = LoadedPath();
|
|
}
|
|
return Save(path.empty() ? DefaultPath() : path);
|
|
}
|
|
|
|
bool Save(const std::filesystem::path& path)
|
|
{
|
|
std::lock_guard saveLock(s_saveMutex);
|
|
std::array<double, kCount> values;
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
EnsureInitLocked("settings were saved");
|
|
for (size_t i = 0; i < kCount; i++)
|
|
values[i] = s_stored[i].load(std::memory_order_relaxed);
|
|
}
|
|
std::string error = WriteAtomically(path, Serialize(values));
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
if (!error.empty())
|
|
LastErrorText() = std::format("Couldn't save the settings: {}.", error);
|
|
else
|
|
{
|
|
LastErrorText().clear();
|
|
std::copy(values.begin(), values.end(), s_baseline);
|
|
if (LoadedPath().empty())
|
|
LoadedPath() = path;
|
|
}
|
|
}
|
|
if (!error.empty())
|
|
{
|
|
fprintf(stderr, "[settings] %s\n", error.c_str());
|
|
return false;
|
|
}
|
|
fprintf(stderr, "[settings] saved %s\n", path.c_str());
|
|
return true;
|
|
}
|
|
|
|
std::string LastError()
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
return LastErrorText();
|
|
}
|
|
|
|
std::string LastVersion()
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
return LastVersionText();
|
|
}
|
|
|
|
bool FileFound()
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
return s_fileFound;
|
|
}
|
|
|
|
bool RecordVersion(std::string_view version)
|
|
{
|
|
std::lock_guard saveLock(s_saveMutex);
|
|
std::filesystem::path path;
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
if (LastVersionText() == version)
|
|
return true;
|
|
// A file that didn't load is the player's to fix (it was kept
|
|
// as .bad): rewriting it now would lose it before they can.
|
|
if (s_fileFound && !s_fileLoaded)
|
|
{
|
|
fprintf(stderr, "[settings] the version isn't recorded: settings.toml didn't load\n");
|
|
return false;
|
|
}
|
|
path = LoadedPath().empty() ? DefaultPath() : LoadedPath();
|
|
}
|
|
// Only the [app] table changes: the options stay as the file has
|
|
// them, so one the player never set keeps following its default (a
|
|
// full Save writes every option's value).
|
|
std::string text, error;
|
|
ReadStatus status = ReadFile(path, text, error);
|
|
if (status == ReadStatus::Failed)
|
|
{
|
|
fprintf(stderr, "[settings] the version isn't recorded: can't read %s: %s\n", path.c_str(), error.c_str());
|
|
return false;
|
|
}
|
|
if (status == ReadStatus::Missing)
|
|
text = "# SpeedBreaker settings. The settings menu writes every option here when one\n"
|
|
"# changes; until then each one keeps its default.\n";
|
|
std::string recorded(version);
|
|
if (std::string failed = WriteAtomically(path, WithoutAppTable(text) + AppTable(recorded)); !failed.empty())
|
|
{
|
|
fprintf(stderr, "[settings] the version isn't recorded: %s\n", failed.c_str());
|
|
return false;
|
|
}
|
|
std::lock_guard lock(s_mutex);
|
|
LastVersionText() = recorded;
|
|
s_fileFound = s_fileLoaded = true;
|
|
if (LoadedPath().empty())
|
|
LoadedPath() = path;
|
|
return true;
|
|
}
|
|
}
|