mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-27 12:07:10 +01:00
Co-authored-by: wendersonrodrigues <wendersonrodrigues@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1795 lines
86 KiB
C++
1795 lines
86 KiB
C++
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See settings.h.
|
|
#include "settings.h"
|
|
#include <user/paths.h>
|
|
#include <game/language.h> // Settings > Game > Language: the languages
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cctype>
|
|
#include <cerrno>
|
|
#include <chrono>
|
|
#include <cstddef>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <format>
|
|
#include <iterator>
|
|
#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;
|
|
|
|
// Settings > Game > Language: game/language.h's table, in its order.
|
|
constexpr auto kLanguageEntries = [] {
|
|
std::array<EnumEntry, std::size(game::language::kLanguages)> entries {};
|
|
for (size_t i = 0; i < entries.size(); i++)
|
|
entries[i] = { game::language::kLanguages[i].id, game::language::kLanguages[i].token, game::language::kLanguages[i].name };
|
|
return entries;
|
|
}();
|
|
// The game folder can't be reached on iPhone and iPad, and the
|
|
// installed files decide what a language gives (the note under the
|
|
// row): the row isn't shown there.
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
constexpr bool kLanguageHidden = true;
|
|
#else
|
|
constexpr bool kLanguageHidden = false;
|
|
#endif
|
|
|
|
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" },
|
|
};
|
|
|
|
// Settings > Wheel > Steering Range: the degrees of the wheel's
|
|
// turn that reach full lock, of the 900 a G29 and its family turn
|
|
// (hid/wheel_map.h). 0: all of it, whatever the wheel turns.
|
|
constexpr EnumEntry kSteeringRangeEntries[] = {
|
|
{ 180, "180", "180" },
|
|
{ 270, "270", "270" },
|
|
{ 360, "360", "360" },
|
|
{ 450, "450", "450" },
|
|
{ 540, "540", "540" },
|
|
{ 720, "720", "720" },
|
|
{ 900, "900", "900" },
|
|
{ 0, "all", "All" },
|
|
};
|
|
|
|
// One of the wheel's axes, as the device numbers them: what
|
|
// Settings > Wheel > Calibrate assigned. Hidden (the page's own row
|
|
// shows them, and shows them being pressed), and -1 by default:
|
|
// hid/wheel_map.h's Detect then works it out from the device.
|
|
constexpr Option WheelAxis(Id id, const char* key, const char* label, const char* env)
|
|
{
|
|
return { .id = id, .category = Category::Wheel, .type = Type::Int, .apply = Apply::Live, .hidden = true,
|
|
.table = "wheel", .key = key, .label = label,
|
|
.description = "Which of the wheel's axes this is, as the device numbers them. -1: from the device.",
|
|
.env = env, .defaultValue = -1, .min = -1, .max = 31, .step = 1 };
|
|
}
|
|
|
|
// One end of a pedal's travel, as Settings > Wheel > Calibrate
|
|
// measured it (hidden). Equal ends: not measured.
|
|
constexpr Option WheelPoint(Id id, const char* key, const char* label, const char* env)
|
|
{
|
|
return { .id = id, .category = Category::Wheel, .type = Type::Int, .apply = Apply::Live, .hidden = true,
|
|
.table = "wheel", .key = key, .label = label,
|
|
.description = "What the pedal's axis reads there, as Calibrate measured it.",
|
|
.env = env, .defaultValue = 0, .min = -32768, .max = 32767, .step = 1 };
|
|
}
|
|
|
|
// The joystick button Settings > Wheel > Calibrate gave a pad
|
|
// control (hidden). -1: none, and the wheel's model decides.
|
|
constexpr Option WheelButton(Id id, const char* key, const char* label, const char* env)
|
|
{
|
|
return { .id = id, .category = Category::Wheel, .type = Type::Int, .apply = Apply::Live, .hidden = true,
|
|
.table = "wheel", .key = key, .label = label,
|
|
.description = "Which of the wheel's buttons this control is, as Calibrate recorded it.",
|
|
.env = env, .defaultValue = -1, .min = -1, .max = 255, .step = 1 };
|
|
}
|
|
|
|
// 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 },
|
|
// HFR (game/hfr.h): pictures between the 60 steps. At launch: the
|
|
// game's command buffers are sized for it when its device is made.
|
|
{ .id = Id::SmoothMotion, .category = Category::Display, .type = Type::Bool, .apply = Apply::Restart,
|
|
.table = "video", .key = "smooth_motion", .label = "Smooth Motion",
|
|
.description = "Up to 120 fps on a 120 Hz screen (ProMotion): the game still runs at 60 steps a second, and a picture between each two is drawn from the poses either side. A little more delay and power. Heavy scenes drop to an even 60 for a while; off at 30 fps.",
|
|
.env = "NFSMW_HFR", .defaultValue = 0 },
|
|
{ .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. On a screen of another shape filled by Fill Screen, each step renders that shape: 1x on a Steam Deck is its own 1280x800. Auto: from the screen (1440p: 2x; a Steam Deck: its 1280x800).",
|
|
.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, and the knocks a steering wheel's force feedback passes on (Settings > Wheel).",
|
|
.env = "NFSMW_VIBRATION", .defaultValue = 1 },
|
|
// Wheel (Settings > Wheel; hid/wheel_map.h and hid/wheel.cpp
|
|
// read them). The page shows only while a wheel is connected.
|
|
{ .id = Id::Wheel, .category = Category::Wheel, .type = Type::Bool, .apply = Apply::Live,
|
|
.table = "wheel", .key = "enabled", .label = "Steering Wheel",
|
|
.description = "Drive with a connected USB steering wheel. Off: the game ignores it and a controller or the keyboard plays, but it still works these menus.",
|
|
.env = "NFSMW_WHEEL", .defaultValue = 1 },
|
|
{ .id = Id::SteeringRange, .category = Category::Wheel, .type = Type::Enum, .apply = Apply::Live,
|
|
.table = "wheel", .key = "steering_range", .label = "Steering Range",
|
|
.description = "How far you turn the wheel, lock to lock, for the car's full lock. 360 suits this game, which was made for a pad: half a turn each way. More is slower to steer; All uses the whole turn. The degrees assume a 900-degree wheel, as the G29 is.",
|
|
.env = "NFSMW_STEERING_RANGE", .defaultValue = 360, .min = 0, .max = 900, .entries = kSteeringRangeEntries },
|
|
{ .id = Id::InvertSteering, .category = Category::Wheel, .type = Type::Bool, .apply = Apply::Live,
|
|
.table = "wheel", .key = "invert_steering", .label = "Invert Steering",
|
|
.description = "For a wheel whose axis runs the other way: turning left steers left again. Calibrate sets this for you.",
|
|
.env = "NFSMW_INVERT_STEERING", .defaultValue = 0 },
|
|
{ .id = Id::SteeringDeadzone, .category = Category::Wheel, .type = Type::Float, .apply = Apply::Live,
|
|
.table = "wheel", .key = "steering_deadzone", .label = "Steering Deadzone",
|
|
.description = "How far the wheel turns before the game sees it. A wheel needs none; raise it only if the car pulls to one side.",
|
|
.env = "NFSMW_STEERING_DEADZONE", .defaultValue = 0, .min = 0, .max = 0.3, .step = 0.01, .unit = "%" },
|
|
{ .id = Id::SteeringResponse, .category = Category::Wheel, .type = Type::Float, .apply = Apply::Live,
|
|
.table = "wheel", .key = "steering_response", .label = "Steering Response",
|
|
.description = "1.00 steers in step with the wheel, and suits most. Above 1.00 calms the centre for holding a straight, and sharpens the last of the turn. Below 1.00 makes the centre twitchy: at 0.50 the first tenth of the steering is within 2 degrees of straight.",
|
|
.env = "NFSMW_STEERING_RESPONSE", .defaultValue = 1.0, .min = 0.5, .max = 2.0, .step = 0.05 },
|
|
{ .id = Id::ForceFeedback, .category = Category::Wheel, .type = Type::Int, .apply = Apply::Live,
|
|
.table = "wheel", .key = "force_feedback", .label = "Force Feedback",
|
|
.description = "How strongly the knocks the game asks for reach the wheel. Needs a wheel whose force feedback this system drives; Vibration (Input) turns it off too.",
|
|
.env = "NFSMW_FORCE_FEEDBACK", .defaultValue = 100, .min = 0, .max = 100, .step = 5, .unit = "%" },
|
|
{ .id = Id::CenteringForce, .category = Category::Wheel, .type = Type::Int, .apply = Apply::Live,
|
|
.table = "wheel", .key = "centering_force", .label = "Centering Force",
|
|
.description = "How hard the wheel pulls back to straight ahead. A wheel with no spring of its own needs this; it needs force feedback too.",
|
|
.env = "NFSMW_WHEEL_CENTERING", .defaultValue = 50, .min = 0, .max = 100, .step = 5, .unit = "%" },
|
|
// The axes Settings > Wheel > Calibrate assigned, as the device
|
|
// numbers them; -1: work them out from the device itself
|
|
// (hid/wheel_map.h's Detect).
|
|
WheelAxis(Id::WheelSteerAxis, "steer_axis", "Steering Axis", "NFSMW_WHEEL_STEER_AXIS"),
|
|
WheelAxis(Id::WheelThrottleAxis, "throttle_axis", "Throttle Axis", "NFSMW_WHEEL_THROTTLE_AXIS"),
|
|
WheelAxis(Id::WheelBrakeAxis, "brake_axis", "Brake Axis", "NFSMW_WHEEL_BRAKE_AXIS"),
|
|
WheelAxis(Id::WheelClutchAxis, "clutch_axis", "Clutch Axis", "NFSMW_WHEEL_CLUTCH_AXIS"),
|
|
WheelPoint(Id::ThrottleRest, "throttle_rest", "Throttle at Rest", "NFSMW_WHEEL_THROTTLE_REST"),
|
|
WheelPoint(Id::ThrottleFull, "throttle_full", "Throttle Pressed", "NFSMW_WHEEL_THROTTLE_FULL"),
|
|
WheelPoint(Id::BrakeRest, "brake_rest", "Brake at Rest", "NFSMW_WHEEL_BRAKE_REST"),
|
|
WheelPoint(Id::BrakeFull, "brake_full", "Brake Pressed", "NFSMW_WHEEL_BRAKE_FULL"),
|
|
WheelPoint(Id::ClutchRest, "clutch_rest", "Clutch at Rest", "NFSMW_WHEEL_CLUTCH_REST"),
|
|
WheelPoint(Id::ClutchFull, "clutch_full", "Clutch Pressed", "NFSMW_WHEEL_CLUTCH_FULL"),
|
|
WheelPoint(Id::SteerLeft, "steer_left", "Steering Full Left", "NFSMW_WHEEL_STEER_LEFT"),
|
|
WheelPoint(Id::SteerRight, "steer_right", "Steering Full Right", "NFSMW_WHEEL_STEER_RIGHT"),
|
|
WheelButton(Id::ButtonA, "button_a", "A Button", "NFSMW_WHEEL_BUTTON_A"),
|
|
WheelButton(Id::ButtonB, "button_b", "B Button", "NFSMW_WHEEL_BUTTON_B"),
|
|
WheelButton(Id::ButtonX, "button_x", "X Button", "NFSMW_WHEEL_BUTTON_X"),
|
|
WheelButton(Id::ButtonY, "button_y", "Y Button", "NFSMW_WHEEL_BUTTON_Y"),
|
|
WheelButton(Id::ButtonLB, "button_lb", "LB Button", "NFSMW_WHEEL_BUTTON_LB"),
|
|
WheelButton(Id::ButtonRB, "button_rb", "RB Button", "NFSMW_WHEEL_BUTTON_RB"),
|
|
WheelButton(Id::ButtonBack, "button_back", "Back Button", "NFSMW_WHEEL_BUTTON_BACK"),
|
|
WheelButton(Id::ButtonStart, "button_start", "Start Button", "NFSMW_WHEEL_BUTTON_START"),
|
|
WheelButton(Id::ButtonLS, "button_ls", "Left Stick Click", "NFSMW_WHEEL_BUTTON_LS"),
|
|
WheelButton(Id::ButtonRS, "button_rs", "Right Stick Click", "NFSMW_WHEEL_BUTTON_RS"),
|
|
WheelButton(Id::ButtonShiftUp, "button_shift_up", "Shift Up", "NFSMW_WHEEL_BUTTON_SHIFT_UP"),
|
|
WheelButton(Id::ButtonShiftDown, "button_shift_down", "Shift Down", "NFSMW_WHEEL_BUTTON_SHIFT_DOWN"),
|
|
{ .id = Id::WheelSettingsVersion, .category = Category::Wheel, .type = Type::Int, .apply = Apply::Live, .hidden = true,
|
|
.table = "wheel", .key = "settings_version", .label = "Wheel Settings Version",
|
|
.description = "Which steering these Wheel settings were made with: 0 before the game's own deadzone was stepped over, 1 since.",
|
|
.env = "NFSMW_WHEEL_SETTINGS_VERSION", .defaultValue = 0, .min = 0, .max = 1, .step = 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 },
|
|
// Game
|
|
{ .id = Id::GameLanguage, .category = Category::Game, .type = Type::Enum, .apply = Apply::Restart, .hidden = kLanguageHidden,
|
|
.table = "game", .key = "language", .label = "Language",
|
|
.description = "The language of the game's menus and race text: English (the USA disc's), or French, German, Italian, Spanish, Dutch, Swedish, Danish, Polish or Finnish, from the same disc's files. The movies and the speech stay in English, as the disc has them.",
|
|
.env = "NFSMW_LANGUAGE", .defaultValue = double(game::language::kEnglish), .entries = kLanguageEntries },
|
|
};
|
|
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
|
|
|
|
// refresh_notices: the display rates (whole Hz) the refresh-rate
|
|
// notice was closed for (video/refresh_notice.h), "90,144".
|
|
constexpr const char* kRefreshNoticesKey = "refresh_notices";
|
|
std::vector<int>& RefreshNoticesList()
|
|
{
|
|
static std::vector<int> list;
|
|
return list;
|
|
}
|
|
|
|
// xbox_usb_rumble_note: the "no rumble over USB" note for an Xbox
|
|
// controller on a Mac (hid/rumble_note.h) was closed.
|
|
constexpr const char* kRumbleNoteKey = "xbox_usb_rumble_note";
|
|
std::atomic<bool> s_rumbleNoteShown{ false };
|
|
|
|
std::vector<int> ParseRates(std::string_view text)
|
|
{
|
|
std::vector<int> out;
|
|
while (!text.empty())
|
|
{
|
|
size_t comma = text.find(',');
|
|
std::string item(text.substr(0, comma));
|
|
text = comma == std::string_view::npos ? std::string_view() : text.substr(comma + 1);
|
|
char* end = nullptr;
|
|
long hz = std::strtol(item.c_str(), &end, 10);
|
|
if (end != item.c_str() && hz > 0 && hz < 1000 && std::find(out.begin(), out.end(), int(hz)) == out.end())
|
|
out.push_back(int(hz));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::string AppTable(const std::string& lastVersion, const std::vector<int>& notices)
|
|
{
|
|
// Only what a version looks like (a hand edit could hold a quote
|
|
// or a newline, which would break the file).
|
|
std::string version;
|
|
if (!(lastVersion.empty() || lastVersion.size() > 64 ||
|
|
!std::all_of(lastVersion.begin(), lastVersion.end(), [](char c) { return std::isalnum(uint8_t(c)) || c == '.' || c == '-' || c == '+'; })))
|
|
version = std::format("# The SpeedBreaker version that ran last (the game keeps this up to date).\n{} = \"{}\"\n",
|
|
kLastVersionKey, lastVersion);
|
|
std::string rates;
|
|
for (int hz : notices)
|
|
rates += (rates.empty() ? "" : ",") + std::to_string(hz);
|
|
if (!rates.empty())
|
|
rates = std::format("# Screen refresh rates (Hz) the refresh-rate notice was closed for: it isn't shown for them again.\n"
|
|
"{} = \"{}\"\n", kRefreshNoticesKey, rates);
|
|
std::string rumble;
|
|
if (s_rumbleNoteShown.load(std::memory_order_relaxed))
|
|
rumble = std::format("# The \"no rumble over USB\" note for an Xbox controller on a Mac was closed: it isn't shown again.\n"
|
|
"{} = true\n", kRumbleNoteKey);
|
|
if (version.empty() && rates.empty() && rumble.empty())
|
|
return "";
|
|
return std::format("\n[{}]\n{}{}{}", kAppTable, version, rates, rumble);
|
|
}
|
|
|
|
// `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::vector<int> notices;
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
lastVersion = LastVersionText();
|
|
notices = RefreshNoticesList();
|
|
}
|
|
return out + AppTable(lastVersion, notices);
|
|
}
|
|
|
|
// 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::Wheel: return "Wheel";
|
|
case Category::Game: return "Game";
|
|
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;
|
|
std::vector<int> notices;
|
|
bool rumbleNote = false;
|
|
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() == kRefreshNoticesKey)
|
|
{
|
|
if (auto v = node.as_string())
|
|
notices = ParseRates(v->get());
|
|
else
|
|
fprintf(stderr, "[settings] %s: %s = %s isn't a list of rates; ignored\n", path.filename().c_str(), name.c_str(),
|
|
NodeText(node).c_str());
|
|
continue;
|
|
}
|
|
if (tableKey.str() == kAppTable && key.str() == kRumbleNoteKey)
|
|
{
|
|
if (auto v = node.as_boolean())
|
|
rumbleNote = v->get();
|
|
else
|
|
fprintf(stderr, "[settings] %s: %s = %s isn't true or false; ignored\n", path.filename().c_str(), name.c_str(),
|
|
NodeText(node).c_str());
|
|
continue;
|
|
}
|
|
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;
|
|
RefreshNoticesList() = notices;
|
|
s_rumbleNoteShown.store(rumbleNote, std::memory_order_relaxed);
|
|
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);
|
|
std::vector<int> notices;
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
notices = RefreshNoticesList();
|
|
}
|
|
if (std::string failed = WriteAtomically(path, WithoutAppTable(text) + AppTable(recorded, notices)); !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;
|
|
}
|
|
|
|
bool RefreshNoticeShown(int hz)
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
const std::vector<int>& list = RefreshNoticesList();
|
|
return std::find(list.begin(), list.end(), hz) != list.end();
|
|
}
|
|
|
|
bool RecordRefreshNotice(int hz)
|
|
{
|
|
std::lock_guard saveLock(s_saveMutex);
|
|
std::filesystem::path path;
|
|
std::string lastVersion;
|
|
std::vector<int> notices;
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
notices = RefreshNoticesList();
|
|
if (std::find(notices.begin(), notices.end(), hz) != notices.end())
|
|
return true;
|
|
notices.push_back(hz);
|
|
RefreshNoticesList() = notices; // this run doesn't show it again, saved or not
|
|
if (s_fileFound && !s_fileLoaded)
|
|
{
|
|
fprintf(stderr, "[settings] the refresh notice's %d Hz isn't recorded: settings.toml didn't load\n", hz);
|
|
return false;
|
|
}
|
|
path = LoadedPath().empty() ? DefaultPath() : LoadedPath();
|
|
lastVersion = LastVersionText();
|
|
}
|
|
// Only the [app] table changes, as for RecordVersion.
|
|
std::string text, error;
|
|
ReadStatus status = ReadFile(path, text, error);
|
|
if (status == ReadStatus::Failed)
|
|
{
|
|
fprintf(stderr, "[settings] the refresh notice's %d Hz isn't recorded: can't read %s: %s\n", hz, 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";
|
|
if (std::string failed = WriteAtomically(path, WithoutAppTable(text) + AppTable(lastVersion, notices)); !failed.empty())
|
|
{
|
|
fprintf(stderr, "[settings] the refresh notice's %d Hz isn't recorded: %s\n", hz, failed.c_str());
|
|
return false;
|
|
}
|
|
std::lock_guard lock(s_mutex);
|
|
s_fileFound = s_fileLoaded = true;
|
|
if (LoadedPath().empty())
|
|
LoadedPath() = path;
|
|
return true;
|
|
}
|
|
|
|
bool RumbleNoteShown()
|
|
{
|
|
return s_rumbleNoteShown.load(std::memory_order_relaxed);
|
|
}
|
|
|
|
bool RecordRumbleNote()
|
|
{
|
|
std::lock_guard saveLock(s_saveMutex);
|
|
std::filesystem::path path;
|
|
std::string lastVersion;
|
|
std::vector<int> notices;
|
|
{
|
|
std::lock_guard lock(s_mutex);
|
|
if (s_rumbleNoteShown.exchange(true, std::memory_order_relaxed))
|
|
return true; // this run doesn't show it again, saved or not
|
|
if (s_fileFound && !s_fileLoaded)
|
|
{
|
|
fprintf(stderr, "[settings] the rumble note isn't recorded: settings.toml didn't load\n");
|
|
return false;
|
|
}
|
|
path = LoadedPath().empty() ? DefaultPath() : LoadedPath();
|
|
lastVersion = LastVersionText();
|
|
notices = RefreshNoticesList();
|
|
}
|
|
// Only the [app] table changes, as for RecordVersion.
|
|
std::string text, error;
|
|
ReadStatus status = ReadFile(path, text, error);
|
|
if (status == ReadStatus::Failed)
|
|
{
|
|
fprintf(stderr, "[settings] the rumble note 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";
|
|
if (std::string failed = WriteAtomically(path, WithoutAppTable(text) + AppTable(lastVersion, notices)); !failed.empty())
|
|
{
|
|
fprintf(stderr, "[settings] the rumble note isn't recorded: %s\n", failed.c_str());
|
|
return false;
|
|
}
|
|
std::lock_guard lock(s_mutex);
|
|
s_fileFound = s_fileLoaded = true;
|
|
if (LoadedPath().empty())
|
|
LoadedPath() = path;
|
|
return true;
|
|
}
|
|
}
|