diff --git a/CMakeLists.txt b/CMakeLists.txt index e0cf678839..9e2be043ee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,6 +69,12 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(cxxopts) +FetchContent_Declare(json + URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz + URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +FetchContent_MakeAvailable(json) include(files.cmake) @@ -108,7 +114,7 @@ target_include_directories(game_debug PUBLIC extern ${CMAKE_SOURCE_DIR}/build/${DUSK_TP_VERSION}/include build/${DUSK_TP_VERSION}/include) -target_link_libraries(game_debug PUBLIC aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd aurora::card) +target_link_libraries(game_debug PUBLIC aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd aurora::card nlohmann_json::nlohmann_json) set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) add_library(game SHARED ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${SSYSTEM_FILES} ${JSYSTEM_FILES} ${REL_FILES} ${DUSK_FILES} ${DOLPHIN_FILES} diff --git a/files.cmake b/files.cmake index 26cae0f1a9..8a9e7be4d5 100644 --- a/files.cmake +++ b/files.cmake @@ -1332,16 +1332,22 @@ set(DOLPHIN_FILES set(DUSK_FILES include/dusk/endian_gx.hpp + include/dusk/config.hpp + include/dusk/settings.hpp src/d/actor/d_a_alink_dusk.cpp src/dusk/asserts.cpp + src/dusk/config.cpp + src/dusk/settings.cpp src/dusk/logging.cpp src/dusk/layout.cpp src/dusk/stubs.cpp src/dusk/endian.cpp src/dusk/extras.c src/dusk/extras.cpp + src/dusk/io.cpp src/dusk/globals.cpp #src/dusk/m_Do_ext_dusk.cpp + src/dusk/imgui/ImGuiConfig.hpp src/dusk/imgui/ImGuiConsole.hpp src/dusk/imgui/ImGuiConsole.cpp src/dusk/imgui/ImGuiMenuGame.cpp diff --git a/include/dusk/appname.hpp b/include/dusk/appname.hpp new file mode 100644 index 0000000000..d5d5f40083 --- /dev/null +++ b/include/dusk/appname.hpp @@ -0,0 +1,20 @@ +#ifndef DUSK_APPNAME_HPP +#define DUSK_APPNAME_HPP + +namespace dusk { + /** + * \brief The internal application name for the game. + * + * This gets used for file paths and such, and cannot be changed! + */ + constexpr auto AppName = "Dusk"; + + /** + * \brief The internal organization name for the game. + * + * This gets used for file paths and such, and cannot be changed! + */ + constexpr auto OrgName = "TwilitRealm"; +} + +#endif // DUSK_APPNAME_HPP diff --git a/include/dusk/config.hpp b/include/dusk/config.hpp new file mode 100644 index 0000000000..5cd0807b3b --- /dev/null +++ b/include/dusk/config.hpp @@ -0,0 +1,114 @@ +#ifndef DUSK_CONFIG_HPP +#define DUSK_CONFIG_HPP + +#include +#include "nlohmann/json.hpp" +#include "config_var.hpp" + +namespace dusk::config { + +/** + * \brief Base class containing virtual functions used for save/load of CVars. + */ +class ConfigImplBase { +protected: + virtual ~ConfigImplBase() = default; + +public: + /** + * \brief Load a JSON value into a CVar at the Value layer. + */ + virtual void loadFromJson(ConfigVarBase& cVar, const nlohmann::json& jsonValue) const = 0; + + /** + * \brief Load a simple launch argument into the CVar at the Override layer. + */ + virtual void loadFromArg(ConfigVarBase& cVar, std::string_view stringValue) const = 0; + + /** + * \brief Dump the value contained in the CVar to JSON. + */ + [[nodiscard]] virtual nlohmann::json dumpToJson(const ConfigVarBase& cVar) const = 0; +}; + +template +class ConfigImpl : public ConfigImplBase { + // Just downcasting the references... + void loadFromJson(ConfigVarBase& cVar, const nlohmann::json& jsonValue) const final { + assert(typeid(cVar) == typeid(ConfigVar)); + loadFromJson(dynamic_cast&>(cVar), jsonValue); + } + + void loadFromArg(ConfigVarBase& cVar, std::string_view stringValue) const final { + assert(typeid(cVar) == typeid(ConfigVar)); + loadFromArg(dynamic_cast&>(cVar), stringValue); + } + + [[nodiscard]] nlohmann::json dumpToJson(const ConfigVarBase& cVar) const final { + assert(typeid(cVar) == typeid(ConfigVar)); + return dumpToJson(dynamic_cast&>(cVar)); + } + + /** + * \brief Load a JSON value into a CVar at the Value layer. + */ + static void loadFromJson(ConfigVar& cVar, const nlohmann::json& jsonValue); + + /** + * \brief Load a simple launch argument into the CVar at the Override layer. + */ + static void loadFromArg(ConfigVar& cVar, std::string_view stringValue); + + /** + * \brief Dump the value contained in the CVar to JSON. + */ + [[nodiscard]] static nlohmann::json dumpToJson(const ConfigVar& cVar); +}; + +/** + * \brief Thrown by config loading functions if the value provided is invalid for the CVar. + */ +class InvalidConfigError : public std::runtime_error { +public: + explicit InvalidConfigError(const char* what) : runtime_error(what) {} +}; + +/** + * \brief Register a CVar to make the config system aware of it. + * + * This must be done on startup *before* config has been loaded. + */ +void Register(ConfigVarBase& configVar); + +/** + * \brief Indicate that all registrations have happened and everything should lock in. + */ +void FinishRegistration(); + +/** + * \brief Load config from the standard user preferences location. + */ +void LoadFromUserPreferences(); +void LoadFromFileName(const char* path); + +/** + * \brief Save the config to file. + */ +void Save(); + +/** + * \brief Get a registered CVar by name. + * + * @return null if the CVar does not exist. + */ +ConfigVarBase* GetConfigVar(std::string_view name); + +template +const ConfigImplBase* GetConfigImpl() { + static ConfigImpl config; + return &config; +} + +} // namespace dusk::config + +#endif diff --git a/include/dusk/config_var.hpp b/include/dusk/config_var.hpp new file mode 100644 index 0000000000..b775aa0584 --- /dev/null +++ b/include/dusk/config_var.hpp @@ -0,0 +1,228 @@ +#ifndef DUSK_CONFIG_VAR_HPP +#define DUSK_CONFIG_VAR_HPP + +#include "dolphin/types.h" +#include +#include +#include + +/** + * The configuration system. + * + * Configuration works via "configuration variables" aka "CVars". Each stores a single value that + * may be individually written to/from a configuration file. + * + * CVars, like ogres, have layers. Higher layers (e.g. a set value) override lower layers + * (e.g. the default value). + * + * To define a CVar, simply make a global variable of type ConfigVar, + * and make sure Register() is called on it during program startup. + * + * config_var.hpp contains the simplest "just the configuration vars themselves". + * This should be safe to include for files that need to *access* configuration, + * without blowing up compile times on implementation details. + * + * config.hpp on the other hand contains far more calls for mutating, loading, and defining CVars. + */ +namespace dusk::config { + +/** + * \brief Layers that a configuration variable can currently be at. + * + * A configuration variable can be on one of multiple *layers*, which determines where + * the current value is coming from. + */ +enum class ConfigVarLayer : u8 { + /** + * The CVar is at the default value defined by the application code. + */ + Default, + + /** + * The CVar has been modified by the user and may be saved to config. + */ + Value, + + /** + * The CVar is modified by launch argument, overruling the normal config value. + * Will not get saved to config. + */ + Override, +}; + +class ConfigImplBase; + +/** + * Base class that all CVars inherit from. + * You want the templated ConfigVar instead for actual usage. + */ +class ConfigVarBase { +protected: + /** + * The name of this CVar, used in the configuration file. + */ + const char* name; + + /** + * Whether this CVar has been registered with the global managing logic. + * If this is not done, it is not functional. + */ + bool registered; + + /** + * The layer this CVar is at. + */ + ConfigVarLayer layer; + + /** + * Pointer to an implementation struct for various load/save calls. + */ + const ConfigImplBase* impl; + + ConfigVarBase(const char* name, const ConfigImplBase* impl); + virtual ~ConfigVarBase() = default; + + /** + * Check that the CVar is registered, aborting if this is not the case. + */ + void checkRegistered() const { + if (!registered) + abort(); + } + +public: + /** + * Get the name of this CVar, used in the configuration file. + */ + [[nodiscard]] const char* getName() const noexcept; + + /** + * Get the pointer to the implementation struct. + */ + [[nodiscard]] const ConfigImplBase* getImpl() const noexcept; + + /** + * Get the layer this CVar is currently at. + */ + [[nodiscard]] constexpr ConfigVarLayer getLayer() const noexcept { + return layer; + } + + /** + * Mark this CVar as being registered with the central save/load logic. + * This is necessary to make it legal to access. + */ + void markRegistered(); +}; + +template +concept ConfigValueInteger = + std::is_same_v + || std::is_same_v + || std::is_same_v + || std::is_same_v + || std::is_same_v + || std::is_same_v + || std::is_same_v + || std::is_same_v; + +/** + * \brief Concept that defines the legal set of types that can be used for CVar values. + * + * Valid types cannot be cv-qualified and must be basic primitive types (int, float, bool), + * strings, or enums of the basic primitives. + */ +template +concept ConfigValue = + !std::is_const_v + && !std::is_volatile_v + && (std::is_same_v + || ConfigValueInteger + || std::is_same_v + || std::is_same_v + || std::is_same_v + || (std::is_enum_v && ConfigValueInteger>)); + +template +const ConfigImplBase* GetConfigImpl(); + +/** + * \brief A CVar storing values. + * + * @tparam T The type of value stored in the CVar. + */ +template +class ConfigVar : public ConfigVarBase { + T defaultValue; + T value; + T overrideValue; + +public: + /** + * \brief Construct a CVar. + * + * @param name The name of this CVar. Must be unique. + * @param arg Arguments to forward to construct the default value. + */ + template + explicit ConfigVar(const char* name, Args&&... arg) + : ConfigVarBase(name, GetConfigImpl()), defaultValue(std::forward(arg)...), + value(), overrideValue() {} + + /** + * \brief Get the current value of the CVar. + */ + [[nodiscard]] constexpr const T& getValue() const noexcept { + checkRegistered(); + switch (layer) { + case ConfigVarLayer::Default: + return defaultValue; + case ConfigVarLayer::Value: + return value; + case ConfigVarLayer::Override: + return overrideValue; + default: + abort(); + } + } + + /** + * \brief Change the value of a CVar. + * + * The new value is always stored at the Value layer. + * + * @param newValue The new value the CVar will get. + * @param replaceOverride If true, clear an existing override layer if there is one. + * If this is false and there is an override layer, + * the result of getValue() will not change immediately. + */ + void setValue(T newValue, bool replaceOverride = true) { + checkRegistered(); + value = std::move(newValue); + + if (replaceOverride) { + overrideValue = {}; + layer = ConfigVarLayer::Value; + } else if (layer != ConfigVarLayer::Override) { + layer = ConfigVarLayer::Value; + } + } + + /** + * \brief Give a CVar an override value. + * + * This overrides (but does not replace) the apparent set value of this CVar. + * The overriden value will not get saved to config. + * + * @param newValue The new value the CVar will get. + */ + void setOverrideValue(T newValue) { + checkRegistered(); + overrideValue = std::move(newValue); + layer = ConfigVarLayer::Override; + } +}; + +} + +#endif // DUSK_CONFIG_VAR_HPP diff --git a/include/dusk/io.hpp b/include/dusk/io.hpp new file mode 100644 index 0000000000..fb71a77d68 --- /dev/null +++ b/include/dusk/io.hpp @@ -0,0 +1,75 @@ +#ifndef DUSK_IO_HPP +#define DUSK_IO_HPP + +#include + +// I can't believe it's 2026 and neither SDL (no error codes) nor +// C++ (no error codes) have a file system API functional enough for me to use. +// Here you go, this one's inspired by C#. I only wrote the functions I need. + +namespace dusk::io { + +/** + * \brief A simple file stream wrapping cstdio FILE*. + * + * Methods on this class throw appropriate C++ exceptions when an error occurs. + */ +class FileStream { + void* file; + +public: + FileStream() noexcept; + + /** + * \brief Take ownership of a FILE* handle. + */ + explicit FileStream(void* file); + FileStream(const FileStream& other) = delete; + FileStream(FileStream&& other) noexcept; + + ~FileStream(); + + /** + * \brief Open a file for reading at the given path. + */ + static FileStream OpenRead(const char* utf8Path); + + /** + * \brief Create a file for writing. + * + * If there is an existing file, its contents are demolished. + */ + static FileStream Create(const char* utf8Path); + + /** + * \brief Read the byte contents of a file directly into a vector. + */ + static std::vector ReadAllBytes(const char* utf8Path); + + /** + * \brief Read the byte contents of a file directly into a vector. + */ + static void WriteAllText(const char* utf8Path, std::string_view text); + + /** + * \brief Read the remaining contents of the file directly into a vector. + */ + std::vector ReadFull(); + + /** + * Get direct access to the underlying FILE* handle. + */ + [[nodiscard]] void* GetFileHandle() const noexcept { + return file; + } + + /** + * Write data to the file. + */ + void Write(const char* data, size_t dataLen); +}; + +} + + +#endif // DUSK_IO_HPP diff --git a/include/dusk/scope.hpp b/include/dusk/scope.hpp new file mode 100644 index 0000000000..e1cfcb9a58 --- /dev/null +++ b/include/dusk/scope.hpp @@ -0,0 +1,29 @@ +#ifndef DUSK_SCOPE_HPP +#define DUSK_SCOPE_HPP + +namespace dusk { + +/** + * A simple value wrapper that will destroy the value at the end of its scope. + * @tparam T The type of value contained. + * @tparam Destructor The type of function used to destroy the value. + */ +template + struct ScopeValue { + T value; + Destructor destructor; + + explicit ScopeValue(T value, Destructor destructor) : value(value), destructor(destructor) { + } + + ~ScopeValue() { + destructor(value); + } + + constexpr operator T&() const noexcept { + return value; + } + }; +} + +#endif // DUSK_SCOPE_HPP diff --git a/include/dusk/settings.hpp b/include/dusk/settings.hpp new file mode 100644 index 0000000000..79c9981647 --- /dev/null +++ b/include/dusk/settings.hpp @@ -0,0 +1,24 @@ +#ifndef DUSK_SETTINGS_HPP +#define DUSK_SETTINGS_HPP + +#include "config_var.hpp" + +namespace dusk::settings { + using namespace dusk::config; + + namespace enhancements { + extern ConfigVar FastIronBoots; + extern ConfigVar InvertCameraXAxis; + extern ConfigVar QuickTransform; + extern ConfigVar RestoreWiiGlitches; + extern ConfigVar EnableBloom; + extern ConfigVar UseWaterProjectionOffset; + extern ConfigVar MirrorMode; + + void Register(); + } + + void Register(); +} + +#endif // DUSK_SETTINGS_HPP diff --git a/include/m_Do/m_Do_controller_pad.h b/include/m_Do/m_Do_controller_pad.h index b8cee23ca3..57de651bc0 100644 --- a/include/m_Do/m_Do_controller_pad.h +++ b/include/m_Do/m_Do_controller_pad.h @@ -3,6 +3,7 @@ #include "JSystem/JUtility/JUTGamePad.h" #include "SSystem/SComponent/c_API_controller_pad.h" +#include "dusk/settings.hpp" #include "dusk/imgui/ImGuiMenuEnhancements.hpp" @@ -57,7 +58,7 @@ public: static s16 getStickAngle3D(u32 pad) { #if TARGET_PC - if (dusk::ImGuiMenuEnhancements::m_enhancements.mirrorMode) { + if (dusk::settings::enhancements::MirrorMode.getValue()) { return -getCpadInfo(pad).mMainStickAngle; } else { return getCpadInfo(pad).mMainStickAngle; @@ -69,7 +70,7 @@ public: static f32 getSubStickX3D(u32 pad) { #if TARGET_PC - if (dusk::ImGuiMenuEnhancements::m_enhancements.mirrorMode) { + if (dusk::settings::enhancements::MirrorMode.getValue()) { return -getCpadInfo(pad).mCStickPosX; } else { return getCpadInfo(pad).mCStickPosX; diff --git a/src/d/actor/d_a_alink.cpp b/src/d/actor/d_a_alink.cpp index 2c5bcf46cd..f847fde1cb 100644 --- a/src/d/actor/d_a_alink.cpp +++ b/src/d/actor/d_a_alink.cpp @@ -54,6 +54,8 @@ #include "res/Object/Alink.h" #include +#include "dusk/settings.hpp" + static int daAlink_Create(fopAc_ac_c* i_this); static int daAlink_Delete(daAlink_c* i_this); static int daAlink_Execute(daAlink_c* i_this); @@ -7510,7 +7512,7 @@ void daAlink_c::setBlendMoveAnime(f32 i_morf) { BOOL sp24 = checkEventRun(); BOOL sp20 = checkBootsMoveAnime(1); #if TARGET_PC - if (dusk::ImGuiMenuEnhancements::m_enhancements.fastIronBoots) { + if (dusk::settings::enhancements::FastIronBoots.getValue()) { sp20 = FALSE; } #endif @@ -9475,7 +9477,7 @@ void daAlink_c::setStickData() { mHeavySpeedMultiplier = mpHIO->mItem.mIronBoots.m.mInputFactor; } #if TARGET_PC - if (dusk::ImGuiMenuEnhancements::m_enhancements.fastIronBoots) { + if (dusk::settings::enhancements::FastIronBoots.getValue()) { mHeavySpeedMultiplier = 1.0f; } #endif @@ -9487,7 +9489,7 @@ void daAlink_c::setStickData() { mHeavySpeedMultiplier = mpHIO->mItem.mIronBoots.m.mWaterInputFactor; } #if TARGET_PC - if (dusk::ImGuiMenuEnhancements::m_enhancements.fastIronBoots) { + if (dusk::settings::enhancements::FastIronBoots.getValue()) { mHeavySpeedMultiplier = 1.0f; } #endif diff --git a/src/d/actor/d_a_alink_demo.inc b/src/d/actor/d_a_alink_demo.inc index 7acccbde13..936b0128d5 100644 --- a/src/d/actor/d_a_alink_demo.inc +++ b/src/d/actor/d_a_alink_demo.inc @@ -4301,7 +4301,7 @@ bool daAlink_c::checkAcceptWarp() { */ if (mLinkAcch.ChkGroundHit() && !checkModeFlg(MODE_PLAYER_FLY) #if TARGET_PC - && (dusk::ImGuiMenuEnhancements::m_enhancements.restoreWiiGlitches || !checkNoResetFlg0(FLG0_WATER_IN_MOVE)) + && (dusk::settings::enhancements::RestoreWiiGlitches.getValue() || !checkNoResetFlg0(FLG0_WATER_IN_MOVE)) #elif VERSION != VERSION_WII_USA_R0 && !checkNoResetFlg0(FLG0_WATER_IN_MOVE) #endif @@ -4312,7 +4312,7 @@ bool daAlink_c::checkAcceptWarp() { */ if ( #if TARGET_PC - (dusk::ImGuiMenuEnhancements::m_enhancements.restoreWiiGlitches || !getSlidePolygon(&plane)) && + (dusk::settings::enhancements::RestoreWiiGlitches.getValue() || !getSlidePolygon(&plane)) && #elif VERSION != VERSION_WII_USA_R0 !getSlidePolygon(&plane) && #endif diff --git a/src/d/actor/d_a_alink_dusk.cpp b/src/d/actor/d_a_alink_dusk.cpp index 47501d4f3d..f49443a0d0 100644 --- a/src/d/actor/d_a_alink_dusk.cpp +++ b/src/d/actor/d_a_alink_dusk.cpp @@ -6,7 +6,7 @@ #include "dusk/imgui/ImGuiMenuEnhancements.hpp" void daAlink_c::handleQuickTransform() { - if (!dusk::ImGuiMenuEnhancements::m_enhancements.quickTransform) { + if (!dusk::settings::enhancements::QuickTransform.getValue()) { return; } diff --git a/src/d/actor/d_a_alink_hvyboots.inc b/src/d/actor/d_a_alink_hvyboots.inc index ed52ffcb36..af00c28b1b 100644 --- a/src/d/actor/d_a_alink_hvyboots.inc +++ b/src/d/actor/d_a_alink_hvyboots.inc @@ -351,7 +351,7 @@ int daAlink_c::procMagneBootsFly() { */ if (dComIfG_Bgsp().ChkPolySafe(mPolyInfo2) #if TARGET_PC - && (dusk::ImGuiMenuEnhancements::m_enhancements.restoreWiiGlitches || checkEquipHeavyBoots()) + && (dusk::settings::enhancements::RestoreWiiGlitches.getValue() || checkEquipHeavyBoots()) #elif PLATFORM_GCN || VERSION == VERSION_WII_KOR && checkEquipHeavyBoots() #endif diff --git a/src/d/d_camera.cpp b/src/d/d_camera.cpp index 59ed1d2c11..6e63d8b940 100644 --- a/src/d/d_camera.cpp +++ b/src/d/d_camera.cpp @@ -766,7 +766,7 @@ void dCamera_c::updatePad() { var_f31 = mDoCPd_c::getSubStickX3D(mPadID); #if TARGET_PC - if (dusk::ImGuiMenuEnhancements::m_enhancements.invertCameraXAxis) { + if (dusk::settings::enhancements::InvertCameraXAxis.getValue()) { var_f31 *= -1.0f; } #endif diff --git a/src/d/d_kankyo.cpp b/src/d/d_kankyo.cpp index a61219ffac..e772098eae 100644 --- a/src/d/d_kankyo.cpp +++ b/src/d/d_kankyo.cpp @@ -11381,7 +11381,7 @@ void dKy_bg_MAxx_proc(void* bg_model_p) { C_MTXLightPerspective(sp1D8, dComIfGd_getView()->fovy, camera_p->view.aspect, 1.0f, 1.0f, #if TARGET_PC - dusk::ImGuiMenuEnhancements::m_enhancements.useWaterProjectionOffset ? -0.01f : 0.0f, 0.0f); + dusk::settings::enhancements::UseWaterProjectionOffset.getValue() ? -0.01f : 0.0f, 0.0f); #else -0.01f, 0.0f); #endif diff --git a/src/dusk/config.cpp b/src/dusk/config.cpp new file mode 100644 index 0000000000..aeafb5ab56 --- /dev/null +++ b/src/dusk/config.cpp @@ -0,0 +1,230 @@ +#include "dusk/config.hpp" +#include "SDL3/SDL_filesystem.h" +#include "SDL3/SDL_iostream.h" +#include "dusk/appname.hpp" +#include "dusk/scope.hpp" +#include "fmt/format.h" +#include "nlohmann/json.hpp" +#include "absl/container/flat_hash_map.h" + +#include "aurora/lib/logging.hpp" +#include "dusk/io.hpp" + +#include +#include + +using namespace dusk::config; + +constexpr auto ConfigFileName = "config.json"; + +using json = nlohmann::json; + +aurora::Module DuskConfigLog("dusk::config"); + +static absl::flat_hash_map RegisteredConfigVars; +static bool RegistrationDone; + +static std::string GetConfigJsonPath() { + dusk::ScopeValue folder(SDL_GetPrefPath(dusk::OrgName, dusk::AppName), SDL_free); + if (folder.value == nullptr) { + DuskConfigLog.error("Failed to get user preference path: %s", SDL_GetError()); + return ""; + } + + return fmt::format("{}{}", folder.value, ConfigFileName); +} + +ConfigVarBase::ConfigVarBase(const char* name, const ConfigImplBase* impl) : name(name), registered(false), layer(ConfigVarLayer::Default), impl(impl) { +} + +const char* ConfigVarBase::getName() const noexcept { + return name; +} + +const ConfigImplBase* ConfigVarBase::getImpl() const noexcept { + return impl; +} + +template +void ConfigImpl::loadFromJson(ConfigVar& cVar, const json& jsonValue) { + cVar.setValue(jsonValue.get(), false); +} + +template +nlohmann::json ConfigImpl::dumpToJson(const ConfigVar& cVar) { + return cVar.getValue(); +} + +template requires std::is_integral_v && std::is_signed_v +static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { + const std::string str(stringValue); + const auto result = std::stoll(str); + if (result >= std::numeric_limits::min() && result <= std::numeric_limits::max()) { + cVar.setOverrideValue(result); + } else { + throw std::out_of_range("Value is too large"); + } +} + +template requires std::is_integral_v && std::is_unsigned_v +static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { + const std::string str(stringValue); + const auto result = std::stoull(str); + if (result <= std::numeric_limits::max()) { + cVar.setOverrideValue(result); + } else { + throw std::out_of_range("Value is too large"); + } +} + +static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { + const std::string str(stringValue); + const auto result = std::stof(str); + cVar.setOverrideValue(result); +} + +static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { + const std::string str(stringValue); + const auto result = std::stod(str); + cVar.setOverrideValue(result); +} + +static void loadFromArgImpl(ConfigVar& cVar, const std::string_view stringValue) { + cVar.setOverrideValue(std::string(stringValue)); +} + +template +void ConfigImpl::loadFromArg(ConfigVar& cVar, const std::string_view stringValue) { + loadFromArgImpl(cVar, stringValue); +} + +template<> +void ConfigImpl::loadFromArg(ConfigVar& cVar, const std::string_view stringValue) { + if (stringValue == "1" || stringValue == "TRUE" || stringValue == "true" || stringValue == "True") { + cVar.setOverrideValue(true); + } else if (stringValue == "0" || stringValue == "FALSE" || stringValue == "false" || stringValue == "False") { + cVar.setOverrideValue(false); + } else { + throw InvalidConfigError("Value cannot be parsed as boolean"); + } +} + +// My IDE is convinced this namespace is necessary. It shouldn't be AFAICT? +namespace dusk::config { + template class ConfigImpl; + template class ConfigImpl; + template class ConfigImpl; + template class ConfigImpl; + template class ConfigImpl; + template class ConfigImpl; + template class ConfigImpl; + template class ConfigImpl; + template class ConfigImpl; + template class ConfigImpl; + template class ConfigImpl; + template class ConfigImpl; +} + +void dusk::config::Register(ConfigVarBase& configVar) { + const auto& name = configVar.getName(); + if (RegistrationDone) { + DuskConfigLog.fatal("Tried to register CVar {} after registrations closed!", name); + } + + if (RegisteredConfigVars.contains(name)) { + DuskConfigLog.fatal("Tried to register CVar {} twice!", name); + } + + RegisteredConfigVars[name] = &configVar; + configVar.markRegistered(); +} + +void ConfigVarBase::markRegistered() { + if (registered) + abort(); + + registered = true; +} + +void dusk::config::FinishRegistration() { + RegistrationDone = true; +} + +void dusk::config::LoadFromUserPreferences() { + const auto configPath = GetConfigJsonPath(); + if (configPath.empty()) { + return; + } + LoadFromFileName(configPath.c_str()); +} + +static void LoadFromPath(const char* path) { + auto data = dusk::io::FileStream::ReadAllBytes(path); + + json j = json::parse(data); + if (!j.is_object()) { + DuskConfigLog.error("Config JSON is not an object!"); + return; + } + + for (const auto& el : j.items()) { + const auto& key = el.key(); + auto configVar = RegisteredConfigVars.find(key); + if (configVar == RegisteredConfigVars.end()) { + DuskConfigLog.error("Unknown key '{}' found in config!", key); + continue; + } + + try { + configVar->second->getImpl()->loadFromJson(*configVar->second, el.value()); + } catch (std::exception& e) { + DuskConfigLog.error("Failed to load key '{}' from config: {}", key, e.what()); + } + } +} + +void dusk::config::LoadFromFileName(const char* path) { + if (!RegistrationDone) { + DuskConfigLog.fatal("Registration not finished yet!"); + } + + DuskConfigLog.info("Loading config from '{}'", path); + + try { + LoadFromPath(path); + } catch (const std::system_error& e) { + if (e.code() == std::errc::no_such_file_or_directory) { + DuskConfigLog.info("Config file did not exist, staying with defaults"); + } else { + DuskConfigLog.error("Failed to load from config! {}", e.what()); + } + } +} + +void dusk::config::Save() { + const auto configPath = GetConfigJsonPath(); + if (configPath.empty()) { + return; + } + + DuskConfigLog.info("Saving config to '{}'", configPath); + + json j; + + for (const auto& pair : RegisteredConfigVars) { + if (pair.second->getLayer() == ConfigVarLayer::Value) { + j[pair.first] = pair.second->getImpl()->dumpToJson(*pair.second); + } + } + + io::FileStream::WriteAllText(configPath.c_str(), j.dump(4)); +} + +ConfigVarBase* dusk::config::GetConfigVar(std::string_view name) { + const auto configVar = RegisteredConfigVars.find(name); + if (configVar != RegisteredConfigVars.end()) { + return configVar->second; + } + + return nullptr; +} \ No newline at end of file diff --git a/src/dusk/imgui/ImGuiConfig.hpp b/src/dusk/imgui/ImGuiConfig.hpp new file mode 100644 index 0000000000..c4ecb61d77 --- /dev/null +++ b/src/dusk/imgui/ImGuiConfig.hpp @@ -0,0 +1,17 @@ +#ifndef DUSK_IMGUICONFIG_HPP +#define DUSK_IMGUICONFIG_HPP + +#include "dusk/config.hpp" +#include "imgui.h" + +namespace dusk::config { + inline void ImguiCheckbox(const char* title, ConfigVar& var) { + bool copy = var.getValue(); + if (ImGui::Checkbox(title, ©)) { + var.setValue(copy); + Save(); + } + } +} + +#endif // DUSK_IMGUICONFIG_HPP diff --git a/src/dusk/imgui/ImGuiMenuEnhancements.cpp b/src/dusk/imgui/ImGuiMenuEnhancements.cpp index 5ea315d8ec..c582bc2335 100644 --- a/src/dusk/imgui/ImGuiMenuEnhancements.cpp +++ b/src/dusk/imgui/ImGuiMenuEnhancements.cpp @@ -4,43 +4,36 @@ #include "ImGuiConsole.hpp" #include "ImGuiMenuEnhancements.hpp" +#include "ImGuiConfig.hpp" +#include "dusk/settings.hpp" #include namespace dusk { - EnhancementsSettings ImGuiMenuEnhancements::m_enhancements = { - .fastIronBoots = false, - .invertCameraXAxis = false, - .restoreWiiGlitches = false, - .enableBloom = true, - .useWaterProjectionOffset = false, - .mirrorMode = false, - }; - ImGuiMenuEnhancements::ImGuiMenuEnhancements() {} void ImGuiMenuEnhancements::draw() { if (ImGui::BeginMenu("Enhancements")) { if (ImGui::BeginMenu("Quality of Life")) { - ImGui::Checkbox("Fast Iron Boots", &m_enhancements.fastIronBoots); - ImGui::Checkbox("Invert Camera X Axis", &m_enhancements.invertCameraXAxis); - ImGui::Checkbox("Quick Transform (R+Y)", &m_enhancements.quickTransform); + config::ImguiCheckbox("Fast Iron Boots", settings::enhancements::FastIronBoots); + config::ImguiCheckbox("Invert Camera X Axis", settings::enhancements::InvertCameraXAxis); + config::ImguiCheckbox("Quick Transform (R+Y)", settings::enhancements::QuickTransform); ImGui::EndMenu(); } if (ImGui::BeginMenu("Graphics")) { - ImGui::Checkbox("Native Bloom", &m_enhancements.enableBloom); - ImGui::Checkbox("Water Projection Offset", &m_enhancements.useWaterProjectionOffset); + config::ImguiCheckbox("Native Bloom", settings::enhancements::EnableBloom); + config::ImguiCheckbox("Water Projection Offset", settings::enhancements::UseWaterProjectionOffset); if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Adds GC-specific -0.01 transS offset\n" "that causes ~6px ghost artifacts in water reflections"); } - ImGui::Checkbox("Mirror Mode", &m_enhancements.mirrorMode); + config::ImguiCheckbox("Mirror Mode", settings::enhancements::MirrorMode); ImGui::EndMenu(); } if (ImGui::BeginMenu("Restorations")) { - ImGui::Checkbox("Restore Wii 1.0 Glitches", &m_enhancements.restoreWiiGlitches); + config::ImguiCheckbox("Restore Wii 1.0 Glitches", settings::enhancements::RestoreWiiGlitches); if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Restores patched glitches from Wii USA 1.0, the first released version"); } diff --git a/src/dusk/imgui/ImGuiMenuEnhancements.hpp b/src/dusk/imgui/ImGuiMenuEnhancements.hpp index 0238317ce4..f40baaad65 100644 --- a/src/dusk/imgui/ImGuiMenuEnhancements.hpp +++ b/src/dusk/imgui/ImGuiMenuEnhancements.hpp @@ -8,22 +8,10 @@ #include "imgui.h" namespace dusk { - struct EnhancementsSettings { - bool fastIronBoots; - bool invertCameraXAxis; - bool quickTransform; - bool restoreWiiGlitches; - bool enableBloom; - bool useWaterProjectionOffset; - bool mirrorMode; - }; - class ImGuiMenuEnhancements { public: ImGuiMenuEnhancements(); void draw(); - - static EnhancementsSettings m_enhancements; }; } diff --git a/src/dusk/io.cpp b/src/dusk/io.cpp new file mode 100644 index 0000000000..f930c2ceaa --- /dev/null +++ b/src/dusk/io.cpp @@ -0,0 +1,143 @@ +#include "dusk/io.hpp" +#include +#include + +using namespace dusk::io; + +#if _WIN32 +#define MODE_TYPE wchar_t +#define MODE(val) L##val +#define ftell(file) _ftelli64(file) +#define fseek(a, b, c) _fseeki64(a, b, c) +#else +#define MODE_TYPE char +#define MODE(val) ##val +#endif + +static FILE* ThrowIfNotOpen(const FileStream& file) { + if (!file.GetFileHandle()) { + throw std::runtime_error("Invalid file handle!"); + } + + return static_cast(file.GetFileHandle()); +} + +[[noreturn]] static void ThrowForError(int code) { + throw std::system_error(std::make_error_code(static_cast(code))); +} + +static FILE* OpenCore(const std::filesystem::path& path, const MODE_TYPE* mode) { + FILE* file; + + int err; +#if _WIN32 + static_assert(std::is_same_v); + err = _wfopen_s(&file, path.c_str(), mode); +#else + errno = 0; + static_assert(std::is_same_v); + file = fopen(path.c_str(), mode); + err = errno; +#endif + + if (!file) { + ThrowForError(err); + } + + return file; +} + +static FILE* OpenCore(const char* path, const MODE_TYPE* mode) { + return OpenCore(reinterpret_cast(path), mode); +} + +FileStream::FileStream() noexcept : file(nullptr) { +} + +FileStream::FileStream(void* file) : file(file) { + if (!file) { + CRASH("Invalid file handle"); + } +} + +FileStream::FileStream(FileStream&& other) noexcept { + file = other.file; + other.file = nullptr; +} + +FileStream::~FileStream() { + if (file) + fclose(static_cast(file)); +} + +FileStream FileStream::OpenRead(const char* utf8Path) { + return FileStream(OpenCore(utf8Path, MODE("rb"))); +} + +FileStream FileStream::Create(const char* utf8Path) { + return FileStream(OpenCore(utf8Path, MODE("wb"))); +} + +std::vector FileStream::ReadFull() { + const auto fileHandle = ThrowIfNotOpen(*this); + + std::vector result; + + const auto startPos = ftell(fileHandle); + if (startPos == -1) { + ThrowForError(errno); + } + + auto seekRes = fseek(fileHandle, 0, SEEK_END); + if (seekRes != 0) { + ThrowForError(errno); + } + + const auto endPos = ftell(fileHandle); + if (endPos == -1) { + ThrowForError(errno); + } + + seekRes = fseek(fileHandle, startPos, SEEK_SET); + if (seekRes != 0) { + ThrowForError(errno); + } + + result.resize(endPos - startPos); + + if (result.empty()) { + return result; + } + + auto ret = fread(result.data(), 1, result.size(), fileHandle); + if (ret < result.size()) { + int err = errno; + // Error or EOF + if (feof(fileHandle)) { + result.resize(ret); + } else { + ThrowForError(err); + } + } + + return result; +} + +std::vector FileStream::ReadAllBytes(const char* utf8Path) { + auto handle = OpenRead(utf8Path); + return std::move(handle.ReadFull()); +} + +void FileStream::Write(const char* data, size_t dataLen) { + FILE* fileHandle = ThrowIfNotOpen(*this); + + const auto ret = fwrite(data, 1, dataLen, fileHandle); + if (ret < dataLen) { + ThrowForError(errno); + } +} + +void FileStream::WriteAllText(const char* utf8Path, const std::string_view text) { + auto handle = Create(utf8Path); + handle.Write(text.data(), text.size()); +} diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp new file mode 100644 index 0000000000..2be800b6a5 --- /dev/null +++ b/src/dusk/settings.cpp @@ -0,0 +1,28 @@ +#include "dusk/settings.hpp" +#include "dusk/config.hpp" + +namespace dusk::settings::enhancements { + ConfigVar FastIronBoots("enhancements.fast_iron_boots", false); + ConfigVar InvertCameraXAxis("enhancements.invert_camera_x_axis", false); + ConfigVar QuickTransform("enhancements.quick_transform", false); + ConfigVar RestoreWiiGlitches("enhancements.restore_wii_glitches", false); + ConfigVar EnableBloom("enhancements.enable_bloom", true); + ConfigVar UseWaterProjectionOffset("enhancements.use_water_projection_offset", false); + ConfigVar MirrorMode("enhancements.mirror_mode", false); + + void Register() { + Register(FastIronBoots); + Register(InvertCameraXAxis); + Register(QuickTransform); + Register(RestoreWiiGlitches); + Register(EnableBloom); + Register(UseWaterProjectionOffset); + Register(MirrorMode); + } +} + +namespace dusk::settings { + void Register() { + enhancements::Register(); + } +} diff --git a/src/m_Do/m_Do_graphic.cpp b/src/m_Do/m_Do_graphic.cpp index 06d6efd4eb..8ecc4bbb8f 100644 --- a/src/m_Do/m_Do_graphic.cpp +++ b/src/m_Do/m_Do_graphic.cpp @@ -1163,7 +1163,7 @@ void mDoGph_gInf_c::bloom_c::remove() { void mDoGph_gInf_c::bloom_c::draw() { #if TARGET_PC - if (!dusk::ImGuiMenuEnhancements::m_enhancements.enableBloom) { + if (!dusk::settings::enhancements::EnableBloom.getValue()) { return; } #endif @@ -2113,7 +2113,7 @@ int mDoGph_Painter() { #endif #if TARGET_PC - if (dusk::ImGuiMenuEnhancements::m_enhancements.mirrorMode) + if (dusk::settings::enhancements::MirrorMode.getValue()) #elif PLATFORM_WII if (data_8053a730) #endif diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 03ff6b8c0f..b8d6fe9728 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -56,6 +56,8 @@ #include #include "cxxopts.hpp" +#include "dusk/config.hpp" +#include "dusk/settings.hpp" // --- GLOBALS --- s8 mDoMain::developmentMode = -1; @@ -225,10 +227,41 @@ static AuroraBackend ParseAuroraBackend(const std::string& value) { exit(1); } +static void ApplyCVarOverrides(const cxxopts::OptionValue& option) { + if (option.count() == 0) { + return; + } + + const auto& cVars = option.as>(); + for (const auto& cvarArg : cVars) { + const auto sep = cvarArg.find('='); + if (sep == std::string::npos) { + DuskLog.fatal("--cvar argument has no '=': '{}'", cvarArg); + continue; + } + + const auto name = std::string_view(cvarArg).substr(0, sep); + const auto value = std::string_view(cvarArg).substr(sep + 1); + + const auto cVar = dusk::config::GetConfigVar(name); + if (!cVar) { + DuskLog.fatal("Unknown --cvar name: '{}'", name); + } + + try { + cVar->getImpl()->loadFromArg(*cVar, value); + } catch (const std::exception& e) { + DuskLog.fatal("Unable to parse: '{}': {}", value, e.what()); + } + } +} + // ========================================================================= // PC ENTRY POINT // ========================================================================= int game_main(int argc, char* argv[]) { + dusk::settings::Register(); + cxxopts::ParseResult parsed_arg_options; try { @@ -238,7 +271,8 @@ int game_main(int argc, char* argv[]) { ("l,log-level", "Log level from " + std::to_string(AuroraLogLevel::LOG_DEBUG) + " to " + std::to_string(AuroraLogLevel::LOG_FATAL), cxxopts::value()->default_value("0")) ("h,help", "Print usage") ("dvd", "Path to DVD image file", cxxopts::value()->default_value("game.iso")) - ("backend", "Graphics API backend to use (auto, d3d11, d3d12, metal, vulkan, opengl, opengles, webgpu, null)", cxxopts::value()->default_value("auto")); + ("backend", "Graphics API backend to use (auto, d3d11, d3d12, metal, vulkan, opengl, opengles, webgpu, null)", cxxopts::value()->default_value("auto")) + ("cvar", "Override configuration variables without modifying config", cxxopts::value>()); arg_options.parse_positional({"dvd"}); arg_options.positional_help(""); @@ -257,6 +291,9 @@ int game_main(int argc, char* argv[]) { exit(1); } + dusk::config::LoadFromUserPreferences(); + ApplyCVarOverrides(parsed_arg_options["cvar"]); + AuroraConfig config{}; config.appName = "Dusk"; config.windowPosX = -1;