From 5da77437ae6bd8be5ab3131aff4eb8924ccee8fb Mon Sep 17 00:00:00 2001 From: DevL0rd Date: Sat, 6 Jun 2026 13:34:45 +0700 Subject: [PATCH 01/12] mods: declarative settings, hot-reload, and a Mods settings tab - Add define_settings/setting_get/setting_set to the mod API; the loader stores typed settings per mod, persists them + enabled state in mods/.config, and generates a schema so disabled mods still show their settings - Replace the per-mod enabled CVar with config.json; add live enable/disable - Add a file-watcher (inotify/Win32) hot-reload that reloads a single native mod in place when its library changes, plus refresh() for newly added mods - Add a master-detail 'Mods' tab to the settings window (mod list + Refresh, per-mod enable toggle and auto-generated setting controls) --- include/dusk/mod_api.h | 29 ++ include/dusk/mod_loader.hpp | 62 ++- src/dusk/modding/mod_loader.cpp | 742 ++++++++++++++++++++++------ src/dusk/modding/mod_loader_api.cpp | 19 + src/dusk/ui/settings.cpp | 101 ++++ 5 files changed, 810 insertions(+), 143 deletions(-) diff --git a/include/dusk/mod_api.h b/include/dusk/mod_api.h index af3b9571c9..89b05fcd1b 100644 --- a/include/dusk/mod_api.h +++ b/include/dusk/mod_api.h @@ -14,6 +14,26 @@ typedef void* DuskPanelHandle; typedef void* DuskElemHandle; +// A user-configurable setting a mod declares once (in mod_init) via +// define_settings. The host persists values per mod and renders a control for +// each in the game's Mods tab. +typedef enum DuskSettingType { + DUSK_SETTING_BOOL = 0, + DUSK_SETTING_INT = 1, + DUSK_SETTING_FLOAT = 2, +} DuskSettingType; + +typedef struct DuskSetting { + const char* key; // stable id, unique within the mod + const char* label; // display name + const char* help; // short description (may be NULL) + DuskSettingType type; + double default_value; + double min_value; // INT/FLOAT + double max_value; // INT/FLOAT + double step; // INT/FLOAT UI increment (0 -> sensible default) +} DuskSetting; + // Place this once at file scope in your mod to declare the minimum API version required. // The loader will refuse to initialize the mod if the engine's API version is older. #define DUSK_REQUIRE_API_VERSION \ @@ -55,6 +75,15 @@ struct DuskModAPIv1 { void (*service_publish)(const char* name, void* ptr); void* (*service_get)(const char* name); + + // Settings. Declare the calling mod's settings once from mod_init; the host + // stores them, overlays any saved values, and shows a control per setting in + // the Mods tab. setting_get/_set read/write the calling mod's live value by + // key. These resolve the "current mod" the same way the UI/log callbacks do, + // so call them from mod_init / mod_tick (cache values for use in hooks). + void (*define_settings)(const DuskSetting* settings, uint32_t count); + double (*setting_get)(const char* key); + void (*setting_set)(const char* key, double value); }; using DuskModAPI = DuskModAPIv1; diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index e475ab129e..fb36c38c66 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -2,11 +2,11 @@ #include #include +#include #include #include #include "dusk/mod_api.h" -#include "dusk/config_var.hpp" namespace dusk::modding { class ModBundle; @@ -34,6 +34,22 @@ struct ModMetadata { bool hasCode; }; +enum class SettingType { Bool, Int, Float }; + +// A mod's declared setting plus its live value. Built from the DuskSetting a mod +// passes to define_settings, with the saved value overlaid from config.json. +struct ModSetting { + std::string key; + std::string label; + std::string help; + SettingType type = SettingType::Bool; + double defaultValue = 0.0; + double minValue = 0.0; + double maxValue = 0.0; + double step = 0.0; + double value = 0.0; +}; + struct NativeMod { std::unique_ptr handle; DuskModAPI api{}; @@ -87,11 +103,16 @@ struct LoadedMod { std::string mod_path; std::string dir; - std::unique_ptr> cvarIsEnabled; - - bool active = false; + bool enabled = true; // user intent, persisted in config.json + bool fromDir = true; // bundle source kind (dir vs .dusk), for hot-reload + bool active = false; // currently loaded + initialized bool load_failed = false; + std::string source_lib; // on-disk file to watch for hot-reload + std::filesystem::file_time_type lib_mtime{}; // its mtime when last (re)loaded + + std::vector settings; + NativeModStatus native_status = NativeModStatus::None; std::unique_ptr native; @@ -110,6 +131,13 @@ class ModLoader { void tick(); void shutdown(); + // Hot-reload: cheap per-frame check that reloads any mod whose library file + // changed on disk (driven by a background file watcher). Called from tick(). + void update(); + + // Rescan the mods directory and load any newly-added mods (additive). + void refresh(); + [[nodiscard]] auto mods() const { return m_mods | std::views::transform([](const auto& m) -> LoadedMod& { return *m; }); } @@ -118,6 +146,20 @@ class ModLoader { return mods() | std::views::filter([](const auto& m) { return m.active; }); } + [[nodiscard]] const std::filesystem::path& modsDir() const { return m_modsDir; } + + // Lookup + live enable/disable + settings, addressed by mod id (used by UI). + LoadedMod* find(std::string_view id); + [[nodiscard]] bool isEnabled(std::string_view id); + void setEnabled(std::string_view id, bool enabled); + [[nodiscard]] double getSetting(std::string_view id, std::string_view key); + void setSetting(std::string_view id, std::string_view key, double value); + + // Called by the mod API on behalf of the calling (current) mod. + void modDefineSettings(LoadedMod& mod, const DuskSetting* arr, uint32_t count); + [[nodiscard]] double modGetSetting(LoadedMod& mod, const char* key); + void modSetSetting(LoadedMod& mod, const char* key, double value); + private: std::vector> m_mods; std::filesystem::path m_modsDir; @@ -127,6 +169,18 @@ class ModLoader { void tryLoadNativeMod(LoadedMod& mod); void buildAPI(LoadedMod& mod); void initOverlayFiles(); + + bool initMod(LoadedMod& mod); // buildAPI + fn_init (native already loaded) + void unloadMod(LoadedMod& mod); // clear hooks + fn_cleanup + unload native lib + void reloadMod(LoadedMod& mod); // hot-reload a single mod in place + + [[nodiscard]] std::filesystem::path configPath(const LoadedMod& mod) const; + [[nodiscard]] std::filesystem::path schemaPath(const LoadedMod& mod) const; + void readConfig(LoadedMod& mod); + void writeConfig(const LoadedMod& mod); + void writeSchema(const LoadedMod& mod); + void parseSchema(LoadedMod& mod); + void applySetting(LoadedMod& mod, std::string_view key, double value); }; using ModIndex = std::ranges::range_difference_t().mods())>; diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index c6bdc0f51d..67cc6474a1 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -4,22 +4,34 @@ #include "mod_loader.hpp" #include +#include #include #include +#include #include +#include #include "aurora/dvd.h" -#include "dusk/config.hpp" #include "dusk/io.hpp" #include "miniz.h" #include "native_module.hpp" #include "nlohmann/json.hpp" +#if defined(__linux__) +#include +#include +#include +#elif defined(_WIN32) +#include +#endif + static aurora::Module Log("dusk::modLoader"); using namespace dusk::modding; using namespace std::string_literals; using namespace std::string_view_literals; +using json = nlohmann::json; +namespace fs = std::filesystem; #if defined(_M_ARM64) || defined(__aarch64__) static constexpr std::string_view k_archSuffix = "_arm64"sv; @@ -33,9 +45,153 @@ static constexpr std::string_view k_archSuffix = ""sv; static dusk::ModLoader g_modLoader; -// We cannot delete config vars registered by mods until the game shuts down fully. -// Therefore, orphan them during shutdown. -static std::vector> OrphanedConfigVars; +// True only while a mod's mod_init() runs, so a mod touching its settings during +// init can't persist transient state to config.json. +static bool g_loadingMod = false; + +// ---- hot-reload file watcher ----------------------------------------------- +// A background thread waits on OS file-change events for the mods directory and +// just raises g_libsChanged. ModLoader::update() (game thread) reacts: dlopen/ +// dlclose and hook patching must run on the game thread, not the watcher thread. + +static std::atomic g_libsChanged{false}; +static std::thread g_watchThread; +static std::atomic g_watchStop{false}; + +#if defined(__linux__) +static int g_stopPipe[2] = {-1, -1}; + +static void watch_thread(std::string root) { + const int fd = inotify_init1(0); + if (fd < 0) { + return; + } + std::unordered_map watched; + const auto addWatch = [&](const std::string& dir) { + const int wd = inotify_add_watch(fd, dir.c_str(), IN_CLOSE_WRITE | IN_MOVED_TO | IN_CREATE); + if (wd >= 0) { + watched[wd] = dir; + } + }; + std::error_code ec; + if (fs::exists(root, ec)) { + addWatch(root); + for (const auto& e : fs::directory_iterator(root, ec)) { + if (e.is_directory(ec)) { + addWatch(e.path().string()); + } + } + } + + struct pollfd pfds[2]; + pfds[0] = {fd, POLLIN, 0}; + pfds[1] = {g_stopPipe[0], POLLIN, 0}; + alignas(struct inotify_event) char buf[8192]; + + while (!g_watchStop.load()) { + if (poll(pfds, 2, -1) < 0) { + if (errno == EINTR) { + continue; + } + break; + } + if (pfds[1].revents & POLLIN) { + break; // stop requested + } + if (!(pfds[0].revents & POLLIN)) { + continue; + } + const ssize_t len = read(fd, buf, sizeof(buf)); + for (char* p = buf; len > 0 && p < buf + len;) { + auto* ev = reinterpret_cast(p); + auto it = watched.find(ev->wd); + if (it != watched.end() && ev->len > 0 && (ev->mask & IN_ISDIR) && + (ev->mask & IN_CREATE)) { + addWatch(it->second + "/" + ev->name); // a mod folder (re)appeared + } + // React once a write has finished (IN_CLOSE_WRITE) or a file was moved + // into place (IN_MOVED_TO) -- never on IN_CREATE, which fires mid-write + // and would load a half-written library. + if (!(ev->mask & IN_ISDIR) && (ev->mask & (IN_CLOSE_WRITE | IN_MOVED_TO))) { + g_libsChanged.store(true); + } + p += sizeof(struct inotify_event) + ev->len; + } + } + + for (const auto& kv : watched) { + inotify_rm_watch(fd, kv.first); + } + close(fd); +} + +static void startWatcher(const std::string& root) { + if (pipe(g_stopPipe) != 0) { + return; + } + g_watchStop.store(false); + g_watchThread = std::thread(watch_thread, root); +} + +static void stopWatcher() { + if (!g_watchThread.joinable()) { + return; + } + g_watchStop.store(true); + const char c = 'x'; + [[maybe_unused]] ssize_t w = write(g_stopPipe[1], &c, 1); + g_watchThread.join(); + close(g_stopPipe[0]); + close(g_stopPipe[1]); + g_stopPipe[0] = g_stopPipe[1] = -1; +} + +#elif defined(_WIN32) +static HANDLE g_stopEvent = nullptr; + +static void watch_thread(std::string root) { + const HANDLE change = FindFirstChangeNotificationA( + root.c_str(), TRUE, FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_FILE_NAME); + if (change == INVALID_HANDLE_VALUE) { + return; + } + HANDLE handles[2] = {change, g_stopEvent}; + while (!g_watchStop.load()) { + const DWORD r = WaitForMultipleObjects(2, handles, FALSE, INFINITE); + if (r != WAIT_OBJECT_0) { + break; // stop event or error + } + g_libsChanged.store(true); + FindNextChangeNotification(change); + } + FindCloseChangeNotification(change); +} + +static void startWatcher(const std::string& root) { + g_stopEvent = CreateEventA(nullptr, TRUE, FALSE, nullptr); + g_watchStop.store(false); + g_watchThread = std::thread(watch_thread, root); +} + +static void stopWatcher() { + if (!g_watchThread.joinable()) { + return; + } + g_watchStop.store(true); + if (g_stopEvent) { + SetEvent(g_stopEvent); + } + g_watchThread.join(); + if (g_stopEvent) { + CloseHandle(g_stopEvent); + g_stopEvent = nullptr; + } +} + +#else +static void startWatcher(const std::string&) {} +static void stopWatcher() {} +#endif namespace dusk { @@ -43,13 +199,14 @@ ModLoader& ModLoader::instance() { return g_modLoader; } -static std::unique_ptr loadBundle(const std::filesystem::path& modPath, bool fromDir) { +// ---- bundle + native loading (unchanged core) ------------------------------ + +static std::unique_ptr loadBundle(const fs::path& modPath, bool fromDir) { if (fromDir) { return std::make_unique(modPath); - } else { - std::vector data = io::FileStream::ReadAllBytes(modPath); - return std::make_unique(std::move(data)); } + std::vector data = io::FileStream::ReadAllBytes(modPath); + return std::make_unique(std::move(data)); } struct DllLocateResult { @@ -98,34 +255,29 @@ static void validateModId(std::string_view const str) { lastWasPeriod = true; continue; } - lastWasPeriod = false; - if (chr == '_') - continue; - - if (chr >= '0' && chr <= '9') - continue; - - if (chr >= 'a' && chr <= 'z') - continue; - - if (chr >= 'A' && chr <= 'Z') + if (chr == '_' || (chr >= '0' && chr <= '9') || (chr >= 'a' && chr <= 'z') || + (chr >= 'A' && chr <= 'Z')) { continue; + } - throw InvalidModDataException(fmt::format("Invalid character '{}' in mod ID. Valid characters are period, underscore, and alphanumerics.", chr)); + throw InvalidModDataException(fmt::format( + "Invalid character '{}' in mod ID. Valid characters are period, underscore, and alphanumerics.", + chr)); } } -static ModMetadata loadMetadata(const std::filesystem::path& modPath, ModBundle& bundle) { +static ModMetadata loadMetadata(const fs::path& modPath, ModBundle& bundle) { const auto metaJson = bundle.readFile("mod.json"); - auto j = nlohmann::json::parse(metaJson); + auto j = json::parse(metaJson); std::string metaId = j.value("id", ""); std::string metaName = j.value("name", ""); std::string metaVersion = j.value("version", ""); std::string metaAuthor = j.value("author", ""); - std::string metaDescription = j.value("description", ""); + // Accept "description" (this branch) or "about" (our older mods) interchangeably. + std::string metaDescription = j.value("description", j.value("about", ""s)); const bool hasCode = j.value("has_code", false); validateModId(metaId); @@ -141,20 +293,15 @@ static ModMetadata loadMetadata(const std::filesystem::path& modPath, ModBundle& } return ModMetadata{ - std::move(metaId), - std::move(metaName), - std::move(metaVersion), - std::move(metaAuthor), - std::move(metaDescription), - hasCode, + std::move(metaId), std::move(metaName), std::move(metaVersion), + std::move(metaAuthor), std::move(metaDescription), hasCode, }; } template -bool checkDuplicateMod( - const ModMetadata& metadata, TIter mods) { - return std::ranges::any_of(mods, - [&](const LoadedMod& mod) { return mod.metadata.id == metadata.id; }); +static bool checkDuplicateMod(const ModMetadata& metadata, TIter mods) { + return std::ranges::any_of( + mods, [&](const LoadedMod& mod) { return mod.metadata.id == metadata.id; }); } void ModLoader::tryLoadNativeMod(LoadedMod& mod) { @@ -164,22 +311,22 @@ void ModLoader::tryLoadNativeMod(LoadedMod& mod) { return; } - namespace fs = std::filesystem; - auto [dllEntry, dllFallback] = LocateDllInBundle(*mod.bundle); if (dllEntry.empty()) { dllEntry = dllFallback; } if (dllEntry.empty()) { - Log.error( - "no *{} found in {} — skipping", NativeModule::LibraryExtension, mod.metadata.id); + Log.error("no *{} found in {} — skipping", NativeModule::LibraryExtension, mod.metadata.id); mod.native_status = NativeModStatus::ModMissingPlatform; return; } + // Fresh cache dir each load so hot-reload always maps a new inode (avoids + // dlopen returning the previously-loaded image). const fs::path cacheDir = m_modsDir / ".cache" / mod.metadata.id; std::error_code ec; + fs::remove_all(cacheDir, ec); fs::create_directories(cacheDir, ec); const fs::path dllCachePath = cacheDir / fs::path(dllEntry).filename(); @@ -188,8 +335,7 @@ void ModLoader::tryLoadNativeMod(LoadedMod& mod) { try { dllData = mod.bundle->readFile(dllEntry); } catch (const std::runtime_error& e) { - Log.error( - "failed to extract {} from {}", dllEntry, mod.metadata.id); + Log.error("failed to extract {} from {}", dllEntry, mod.metadata.id); return; } @@ -199,9 +345,7 @@ void ModLoader::tryLoadNativeMod(LoadedMod& mod) { Log.error("failed to write {}", io::fs_path_to_string(dllCachePath)); return; } - - out.write( - reinterpret_cast(dllData.data()), + out.write(reinterpret_cast(dllData.data()), static_cast(dllData.size())); } @@ -234,109 +378,327 @@ void ModLoader::tryLoadNativeMod(LoadedMod& mod) { mod.dir = io::fs_path_to_string(fs::absolute(cacheDir)); mod.native = std::move(nativeMod); mod.native_status = NativeModStatus::Loaded; -} - -static std::string escapeModIdForConfig(std::string_view const id) { - std::string buf; - - // Simple escaping. All characters in mod IDs literal, except for '.' and '_'. - // '.' -> '_', '_' -> '__' - for (char const chr : id) { - if (chr == '.') { - buf.push_back('_'); - } else if (chr == '_') { - buf.push_back('_'); - buf.push_back('_'); - } else { - buf.push_back(chr); - } - } - return buf; + // Remember the on-disk source file so the watcher can detect rebuilds. + mod.source_lib = + mod.fromDir ? io::fs_path_to_string(fs::path(mod.mod_path) / dllEntry) : mod.mod_path; + mod.lib_mtime = fs::last_write_time(mod.source_lib, ec); } -static std::string modEnabledCVarName(std::string_view const id) { - return fmt::format("mod.{}.enabled", escapeModIdForConfig(id)); -} - -void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) { - namespace fs = std::filesystem; - +void ModLoader::tryLoadDusk(const fs::path& modPath, bool fromDir) { std::unique_ptr bundle; try { bundle = loadBundle(modPath, fromDir); } catch (const std::runtime_error& e) { - Log.error("Failed to open {} bundle: {}", io::fs_path_to_string(modPath.filename()), e.what()); + Log.error( + "Failed to open {} bundle: {}", io::fs_path_to_string(modPath.filename()), e.what()); return; } ModMetadata metadata; - try - { + try { metadata = loadMetadata(modPath, *bundle); - } - catch (const std::runtime_error& e) { - Log.error( - "bad mod.json in {}: {}", io::fs_path_to_string(modPath.filename()), e.what()); + } catch (const std::runtime_error& e) { + Log.error("bad mod.json in {}: {}", io::fs_path_to_string(modPath.filename()), e.what()); return; } if (checkDuplicateMod(metadata, mods())) { - Log.error( - "mod with id '{}' already exists, not loading {}", - metadata.id, + Log.error("mod with id '{}' already exists, not loading {}", metadata.id, io::fs_path_to_string(modPath.filename())); return; } - const auto& inserted = m_mods.emplace_back(std::make_unique()); - - auto& mod = *inserted; - mod.active = true; + auto& mod = *m_mods.emplace_back(std::make_unique()); + mod.fromDir = fromDir; mod.mod_path = io::fs_path_to_string(fs::absolute(modPath)); mod.metadata = std::move(metadata); mod.bundle = std::move(bundle); - mod.cvarIsEnabled = std::make_unique>(modEnabledCVarName(mod.metadata.id), true); if (mod.metadata.hasCode) { mod.native_status = NativeModStatus::Unknown; tryLoadNativeMod(mod); - // Native mod lod failure DOES NOT block insertion into m_mods. - // We still want to be able to present the failed load in the UI! - + // A native load failure does not block insertion -- we still present the + // failed mod in the UI. if (mod.native_status != NativeModStatus::Loaded) { - Log.error("Native mod '{}' failed to load, disabling", metadata.id); - mod.active = false; + Log.error("Native mod '{}' failed to load", mod.metadata.id); + } + } + + Log.info("found '{}' ('{}') v{} by {} ({})", mod.metadata.name, mod.metadata.id, + mod.metadata.version, mod.metadata.author, io::fs_path_to_string(modPath.filename())); +} + +// ---- per-mod config.json (enabled + values) + settings schema -------------- + +static ModSetting* findSetting(LoadedMod& mod, std::string_view key) { + for (auto& s : mod.settings) { + if (s.key == key) { + return &s; + } + } + return nullptr; +} + +static const char* settingTypeName(SettingType type) { + switch (type) { + case SettingType::Bool: return "bool"; + case SettingType::Int: return "int"; + default: return "float"; + } +} + +static SettingType settingTypeFromName(std::string_view name) { + if (name == "bool") return SettingType::Bool; + if (name == "int") return SettingType::Int; + return SettingType::Float; +} + +fs::path ModLoader::configPath(const LoadedMod& mod) const { + return m_modsDir / ".config" / (mod.metadata.id + ".json"); +} + +fs::path ModLoader::schemaPath(const LoadedMod& mod) const { + return m_modsDir / ".config" / (mod.metadata.id + ".schema.json"); +} + +void ModLoader::readConfig(LoadedMod& mod) { + const fs::path path = configPath(mod); + std::error_code ec; + if (!fs::exists(path, ec)) { + return; // keep defaults + } + try { + const json j = json::parse(io::FileStream::ReadAllBytes(path)); + if (!j.is_object()) { + return; + } + if (auto it = j.find("enabled"); it != j.end() && it->is_boolean()) { + mod.enabled = it->get(); + } + if (auto settings = j.find("settings"); settings != j.end() && settings->is_object()) { + for (ModSetting& s : mod.settings) { + if (auto v = settings->find(s.key); v != settings->end() && v->is_number()) { + s.value = v->get(); + } + } + } + } catch (const std::exception& e) { + Log.warn("mod '{}': failed to read config.json: {}", mod.metadata.id, e.what()); + } +} + +void ModLoader::writeConfig(const LoadedMod& mod) { + json settings = json::object(); + for (const ModSetting& s : mod.settings) { + settings[s.key] = s.value; + } + const json j = json{{"enabled", mod.enabled}, {"settings", settings}}; + std::error_code ec; + fs::create_directories(configPath(mod).parent_path(), ec); + try { + io::FileStream::WriteAllText(configPath(mod), j.dump(4)); + } catch (const std::exception& e) { + Log.warn("mod '{}': failed to write config.json: {}", mod.metadata.id, e.what()); + } +} + +void ModLoader::writeSchema(const LoadedMod& mod) { + json arr = json::array(); + for (const ModSetting& s : mod.settings) { + arr.push_back(json{ + {"key", s.key}, + {"label", s.label}, + {"help", s.help}, + {"type", settingTypeName(s.type)}, + {"default", s.defaultValue}, + {"min", s.minValue}, + {"max", s.maxValue}, + {"step", s.step}, + }); + } + std::error_code ec; + fs::create_directories(schemaPath(mod).parent_path(), ec); + try { + io::FileStream::WriteAllText(schemaPath(mod), arr.dump(4)); + } catch (const std::exception& e) { + Log.warn("mod '{}': failed to write settings schema: {}", mod.metadata.id, e.what()); + } +} + +// Load a mod's declared settings from a schema written on a prior run, so the UI +// can show controls for a mod that isn't currently loaded. +void ModLoader::parseSchema(LoadedMod& mod) { + const fs::path path = schemaPath(mod); + std::error_code ec; + if (!fs::exists(path, ec)) { + return; + } + try { + const json arr = json::parse(io::FileStream::ReadAllBytes(path)); + if (!arr.is_array()) { + return; } + std::vector parsed; + for (const auto& e : arr) { + if (!e.is_object() || !e.contains("key")) { + continue; + } + ModSetting s; + s.key = e.value("key", ""); + s.label = e.value("label", s.key); + s.help = e.value("help", ""); + s.type = settingTypeFromName(e.value("type", "float")); + s.defaultValue = e.value("default", 0.0); + s.minValue = e.value("min", 0.0); + s.maxValue = e.value("max", 0.0); + s.step = e.value("step", 0.0); + s.value = s.defaultValue; + parsed.push_back(std::move(s)); + } + mod.settings = std::move(parsed); + } catch (const std::exception& e) { + Log.warn("mod '{}': failed to read settings schema: {}", mod.metadata.id, e.what()); } +} +void ModLoader::applySetting(LoadedMod& mod, std::string_view key, double value) { + ModSetting* s = findSetting(mod, key); + if (s == nullptr || s->value == value) { + return; + } + s->value = value; + if (g_loadingMod) { + return; // don't persist while the mod is still initializing + } + writeConfig(mod); +} + +// ---- mod API surface (operate on the calling mod) -------------------------- + +void ModLoader::modDefineSettings(LoadedMod& mod, const DuskSetting* arr, uint32_t count) { + std::vector rebuilt; + rebuilt.reserve(count); + for (uint32_t i = 0; i < count; ++i) { + const DuskSetting& src = arr[i]; + if (src.key == nullptr) { + continue; + } + ModSetting s; + s.key = src.key; + s.label = src.label ? src.label : src.key; + s.help = src.help ? src.help : ""; + s.type = static_cast(src.type); + s.defaultValue = src.default_value; + s.minValue = src.min_value; + s.maxValue = src.max_value; + s.step = src.step; + s.value = src.default_value; + // Preserve any live value for this key so a reload never resets settings. + if (const ModSetting* prev = findSetting(mod, s.key)) { + s.value = prev->value; + } + rebuilt.push_back(std::move(s)); + } + mod.settings = std::move(rebuilt); + readConfig(mod); // overlay saved values from config.json + writeSchema(mod); // (re)generate the schema for the UI +} + +double ModLoader::modGetSetting(LoadedMod& mod, const char* key) { + ModSetting* s = findSetting(mod, key ? key : ""); + return s ? s->value : 0.0; +} + +void ModLoader::modSetSetting(LoadedMod& mod, const char* key, double value) { + applySetting(mod, key ? key : "", value); +} + +// ---- load / unload / reload lifecycle -------------------------------------- + +bool ModLoader::initMod(LoadedMod& mod) { + if (!mod.native) { + return false; + } + buildAPI(mod); + + ModGuard guard(&mod); + g_loadingMod = true; + mod.load_failed = false; + bool ok = false; + try { + mod.native->fn_init(&mod.native->api); + ok = !mod.load_failed; + if (ok) { + Log.info("'{}' initialized", mod.metadata.id); + } else { + Log.error("'{}' failed to load due to hook conflicts", mod.metadata.id); + } + } catch (const std::exception& e) { + Log.error("exception in {}.mod_init(): {}", mod.metadata.id, e.what()); + } catch (...) { + Log.error("unknown exception in {}.mod_init()", mod.metadata.id); + } + g_loadingMod = false; + mod.active = ok; + return ok; +} + +void ModLoader::unloadMod(LoadedMod& mod) { + hookClearMod(&mod); + if (mod.native && mod.native->fn_cleanup) { + ModGuard guard(&mod); + try { + mod.native->fn_cleanup(&mod.native->api); + } catch (...) { + } + } + mod.tab_content.clear(); + mod.tab_updates.clear(); + mod.native.reset(); // dlclose + mod.active = false; + mod.load_failed = false; +} - Log.info( - "found '{}' ('{}') v{} by {} ({})", - mod.metadata.name, - mod.metadata.id, - mod.metadata.version, - mod.metadata.author, - io::fs_path_to_string(modPath.filename())); +void ModLoader::reloadMod(LoadedMod& mod) { + unloadMod(mod); + if (!mod.enabled) { + return; + } + try { + mod.bundle = loadBundle(mod.mod_path, mod.fromDir); + } catch (const std::runtime_error& e) { + Log.error("hot-reload '{}': can't reopen bundle: {}", mod.metadata.id, e.what()); + return; + } + mod.native_status = NativeModStatus::Unknown; + tryLoadNativeMod(mod); + if (mod.native_status != NativeModStatus::Loaded) { + Log.error("hot-reload '{}': native load failed", mod.metadata.id); + return; + } + initMod(mod); + Log.info("hot-reloaded '{}'", mod.metadata.id); } +// ---- public lifecycle ------------------------------------------------------ + void ModLoader::init() { if (m_initialized) { return; } m_initialized = true; - namespace fs = std::filesystem; if (!fs::is_directory(m_modsDir)) { - Log.info( - "mods directory '{}' not found — mod loading skipped", io::fs_path_to_string(m_modsDir)); + Log.info("mods directory '{}' not found — mod loading skipped", + io::fs_path_to_string(m_modsDir)); return; } std::error_code ec; std::vector entries; for (auto& e : fs::directory_iterator(m_modsDir, ec)) { - if (e.is_directory() && std::filesystem::exists(e.path() / "mod.json")) { + if (e.is_directory() && fs::exists(e.path() / "mod.json")) { entries.push_back(e); } else if (e.is_regular_file() && e.path().extension() == ".dusk") { entries.push_back(e); @@ -352,50 +714,33 @@ void ModLoader::init() { tryLoadDusk(entry.path(), entry.is_directory()); } + // Always start the watcher so newly-added mods can be picked up via Refresh + // and rebuilt libraries hot-reload. + startWatcher(io::fs_path_to_string(m_modsDir)); + if (m_mods.empty()) { Log.info("no mods found"); return; } + // Settings descriptors (from a prior run) + enabled state + saved values. + for (auto& mod : mods()) { + parseSchema(mod); + readConfig(mod); + } Log.info("initializing {} mod(s)...", m_mods.size()); for (auto& mod : mods()) { - Register(*mod.cvarIsEnabled); - - if (!mod.cvarIsEnabled->getValue()) { + if (!mod.enabled) { Log.info("Mod '{}' is disabled by config", mod.metadata.id); - mod.active = false; - } - } - - for (auto& mod : active_mods()) { - if (mod.native) { - buildAPI(mod); - } - } - - for (auto& mod : active_mods()) { - if (!mod.native) { continue; } - - Log.debug("Initializing '{}'", mod.metadata.id); - - ModGuard guard(&mod); - try { - mod.native->fn_init(&mod.native->api); - if (!mod.load_failed) { - Log.info("'{}' initialized", mod.metadata.id); - } else { - mod.active = false; - Log.error("'{}' failed to load due to hook conflicts", mod.metadata.id); + if (mod.metadata.hasCode) { + if (mod.native) { + initMod(mod); } - } catch (const std::exception& e) { - mod.active = false; - Log.error("exception in {}.mod_init(): {}", mod.metadata.id, e.what()); - } catch (...) { - mod.active = false; - Log.error("unknown exception in {}.mod_init()", mod.metadata.id); + } else { + mod.active = true; // non-code (overlay) mod } } @@ -406,6 +751,8 @@ void ModLoader::init() { } void ModLoader::tick() { + update(); // hot-reload check (cheap unless the watcher saw a change) + for (auto& mod : active_mods()) { if (!mod.native) { continue; @@ -423,23 +770,140 @@ void ModLoader::tick() { } } -void ModLoader::shutdown() { +void ModLoader::update() { + if (!g_libsChanged.exchange(false)) { + return; + } for (auto& mod : mods()) { - hookClearMod(&mod); - if (mod.native && mod.native->fn_cleanup) { - ModGuard guard(&mod); - try { - mod.native->fn_cleanup(&mod.native->api); - } catch (...) { - } + if (!mod.enabled || !mod.metadata.hasCode || mod.source_lib.empty()) { + continue; + } + std::error_code ec; + const fs::file_time_type t = fs::last_write_time(mod.source_lib, ec); + if (ec || t == mod.lib_mtime) { + continue; } + Log.info("mod '{}' library changed; hot-reloading", mod.metadata.id); + reloadMod(mod); + } +} + +void ModLoader::refresh() { + if (!fs::is_directory(m_modsDir)) { + return; + } + std::error_code ec; + const std::size_t before = m_mods.size(); + std::vector entries; + for (auto& e : fs::directory_iterator(m_modsDir, ec)) { + if (e.is_directory() && fs::exists(e.path() / "mod.json")) { + entries.push_back(e); + } else if (e.is_regular_file() && e.path().extension() == ".dusk") { + entries.push_back(e); + } + } + std::sort(entries.begin(), entries.end(), + [](const fs::directory_entry& a, const fs::directory_entry& b) { + return a.path().filename() < b.path().filename(); + }); + + for (auto& entry : entries) { + tryLoadDusk(entry.path(), entry.is_directory()); // skips ids already loaded + } - OrphanedConfigVars.emplace_back(std::move(mod.cvarIsEnabled)); + bool addedOverlay = false; + for (std::size_t i = before; i < m_mods.size(); ++i) { + LoadedMod& mod = *m_mods[i]; + parseSchema(mod); + readConfig(mod); + if (!mod.enabled) { + continue; + } + if (mod.metadata.hasCode) { + if (mod.native) { + initMod(mod); + } + } else { + mod.active = true; + addedOverlay = true; + } } + if (addedOverlay) { + initOverlayFiles(); + } + Log.info("refresh: {} new mod(s)", m_mods.size() - before); +} +void ModLoader::shutdown() { + stopWatcher(); + for (auto& mod : mods()) { + unloadMod(mod); + } m_mods.clear(); g_services.clear(); Log.info("all mods unloaded"); } +// ---- lookup + live enable/disable + settings (by id, for the UI) ----------- + +LoadedMod* ModLoader::find(std::string_view id) { + for (auto& m : m_mods) { + if (m->metadata.id == id) { + return m.get(); + } + } + return nullptr; +} + +bool ModLoader::isEnabled(std::string_view id) { + const LoadedMod* m = find(id); + return m != nullptr && m->enabled; +} + +void ModLoader::setEnabled(std::string_view id, bool enabled) { + LoadedMod* m = find(id); + if (m == nullptr || m->enabled == enabled) { + return; + } + m->enabled = enabled; + + if (enabled) { + if (m->metadata.hasCode) { + if (!m->native) { + try { + m->bundle = loadBundle(m->mod_path, m->fromDir); + m->native_status = NativeModStatus::Unknown; + tryLoadNativeMod(*m); + } catch (const std::runtime_error& e) { + Log.error("enable '{}': can't open bundle: {}", m->metadata.id, e.what()); + } + } + if (m->native) { + initMod(*m); + } + } else { + m->active = true; + initOverlayFiles(); + } + } else { + unloadMod(*m); + } + writeConfig(*m); +} + +double ModLoader::getSetting(std::string_view id, std::string_view key) { + LoadedMod* m = find(id); + if (m == nullptr) { + return 0.0; + } + ModSetting* s = findSetting(*m, key); + return s ? s->value : 0.0; +} + +void ModLoader::setSetting(std::string_view id, std::string_view key, double value) { + if (LoadedMod* m = find(id)) { + applySetting(*m, key, value); + } +} + } // namespace dusk diff --git a/src/dusk/modding/mod_loader_api.cpp b/src/dusk/modding/mod_loader_api.cpp index c5a2b8f496..edb068fb38 100644 --- a/src/dusk/modding/mod_loader_api.cpp +++ b/src/dusk/modding/mod_loader_api.cpp @@ -234,6 +234,22 @@ void* cb_service_get(const char* name) { return it != g_services.end() ? it->second : nullptr; } +void cb_define_settings(const DuskSetting* settings, uint32_t count) { + if (g_currentMod && settings) { + dusk::ModLoader::instance().modDefineSettings(*g_currentMod, settings, count); + } +} + +double cb_setting_get(const char* key) { + return g_currentMod ? dusk::ModLoader::instance().modGetSetting(*g_currentMod, key) : 0.0; +} + +void cb_setting_set(const char* key, double value) { + if (g_currentMod) { + dusk::ModLoader::instance().modSetSetting(*g_currentMod, key, value); + } +} + void api_hook_pre(void* addr, int32_t (*fn)(void* args)) { dusk::hookRegisterPre(addr, g_currentMod, fn); } @@ -280,6 +296,9 @@ void ModLoader::buildAPI(LoadedMod& mod) { native.api.hook_dispatch_post = hookDispatchPost; native.api.service_publish = cb_service_publish; native.api.service_get = cb_service_get; + native.api.define_settings = cb_define_settings; + native.api.setting_get = cb_setting_get; + native.api.setting_set = cb_setting_set; } } \ No newline at end of file diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index 927a30b8a3..e92a0ef583 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -14,6 +14,7 @@ #include "dusk/io.hpp" #include "dusk/livesplit.h" #include "dusk/discord_presence.hpp" +#include "dusk/mod_loader.hpp" #include "graphics_tuner.hpp" #include "m_Do/m_Do_main.h" #include "menu_bar.hpp" @@ -28,6 +29,9 @@ #include #include +#include +#include + #if DUSK_ENABLE_SENTRY_NATIVE #include "dusk/crash_reporting.h" #endif @@ -1486,6 +1490,103 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { "Recording Mode", "Disables the game HUD and all background music.

Useful for recording footage."); }); + + add_tab("Mods", [this](Rml::Element* content) { + // Master-detail: left = list of mods + a refresh button, right = the + // selected mod's enable toggle and settings (focusable/editable). + auto& leftPane = add_child(content, Pane::Type::Controlled); + auto& rightPane = add_child(content, Pane::Type::Controlled); + + leftPane.add_section("Mods"); + leftPane.add_button(Rml::String{"Refresh Mods"}).on_pressed([this] { + mDoAud_seStartMenu(kSoundItemChange); + dusk::ModLoader::instance().refresh(); + refresh_active_tab(); + }); + + auto modsView = dusk::ModLoader::instance().mods(); + if (std::ranges::empty(modsView)) { + leftPane.add_text("No mods installed."); + leftPane.add_rml( + "
Each mod is a folder (or .dusk package) inside the mods folder " + "in your Dusklight data folder, containing a library for your platform " + "(.so, .dll, or .dylib). Add one and press " + "Refresh Mods."); + return; + } + + leftPane.add_section("Installed Mods"); + for (const dusk::LoadedMod& entry : modsView) { + const std::string id = entry.metadata.id; + auto& item = leftPane.add_button(Rml::String{entry.metadata.name}); + + // Focusing a mod fills the right pane with its details + settings. + leftPane.register_control(item, rightPane, [id](Pane& pane) { + dusk::LoadedMod* mod = dusk::ModLoader::instance().find(id); + if (mod == nullptr) { + return; + } + pane.add_rml(fmt::format("{}
{}

Version: {}
Author: {}", + mod->metadata.name, + mod->metadata.description.empty() ? "No description provided." + : mod->metadata.description, + mod->metadata.version.empty() ? "?" : mod->metadata.version, + mod->metadata.author.empty() ? "Unknown" : mod->metadata.author)); + + pane.add_section("Options"); + pane.add_child(BoolButton::Props{ + .key = "Enabled", + .getValue = [id] { return dusk::ModLoader::instance().isEnabled(id); }, + .setValue = + [id](bool value) { + mDoAud_seStartMenu(kSoundItemChange); + dusk::ModLoader::instance().setEnabled(id, value); + }, + .isModified = [] { return false; }, + }); + + for (const dusk::ModSetting& setting : mod->settings) { + const std::string key = setting.key; + if (setting.type == dusk::SettingType::Bool) { + pane.add_child(BoolButton::Props{ + .key = Rml::String{setting.label}, + .getValue = + [id, key] { + return dusk::ModLoader::instance().getSetting(id, key) != 0.0; + }, + .setValue = + [id, key](bool value) { + mDoAud_seStartMenu(kSoundItemChange); + dusk::ModLoader::instance().setSetting(id, key, value ? 1.0 : 0.0); + }, + .isModified = [] { return false; }, + }); + } else { + const int step = + setting.step != 0.0 ? static_cast(setting.step) : 1; + pane.add_child(NumberButton::Props{ + .key = Rml::String{setting.label}, + .getValue = + [id, key] { + return static_cast( + std::lround(dusk::ModLoader::instance().getSetting(id, key))); + }, + .setValue = + [id, key](int value) { + mDoAud_seStartMenu(kSoundItemChange); + dusk::ModLoader::instance().setSetting( + id, key, static_cast(value)); + }, + .isModified = [] { return false; }, + .min = static_cast(setting.minValue), + .max = static_cast(setting.maxValue), + .step = step, + }); + } + } + }); + } + }); } void SettingsWindow::update() { From 8fd19baea044728472c46f18dc9b86958f29991e Mon Sep 17 00:00:00 2001 From: DevL0rd Date: Sat, 6 Jun 2026 13:53:44 +0700 Subject: [PATCH 02/12] mods: macOS hot-reload, drop home-menu Mods entry, move out cat_mod - Add an FSEvents file watcher for hot-reload on macOS (+ link CoreServices) - Remove the Mods button from the prelaunch/home menu (the Mods settings tab is the primary mod UI now) - Remove the bundled tools/cat_mod example (relocated to its own repo) --- CMakeLists.txt | 2 + src/dusk/modding/mod_loader.cpp | 55 +++++++ src/dusk/ui/prelaunch.cpp | 10 +- tools/cat_mod/CMakeLists.txt | 13 -- tools/cat_mod/mod.json | 6 - tools/cat_mod/res/.gitkeep | 0 tools/cat_mod/src/mod.cpp | 250 -------------------------------- 7 files changed, 58 insertions(+), 278 deletions(-) delete mode 100644 tools/cat_mod/CMakeLists.txt delete mode 100644 tools/cat_mod/mod.json delete mode 100644 tools/cat_mod/res/.gitkeep delete mode 100644 tools/cat_mod/src/mod.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b6f50307bf..3671b71a1f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -570,6 +570,8 @@ if(CMAKE_SYSTEM_NAME STREQUAL Linux) endif() if(APPLE) target_link_options(${DUSK_MAIN_TARGET} PRIVATE -Wl,-export_dynamic) + # FSEvents (mod hot-reload file watcher) + target_link_libraries(${DUSK_MAIN_TARGET} PRIVATE "-framework CoreServices") elseif(UNIX AND NOT ANDROID) target_link_options(${DUSK_MAIN_TARGET} PRIVATE -rdynamic) endif() diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index 67cc6474a1..52cf7e7e19 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -21,6 +22,8 @@ #include #include #include +#elif defined(__APPLE__) +#include #elif defined(_WIN32) #include #endif @@ -188,6 +191,58 @@ static void stopWatcher() { } } +#elif defined(__APPLE__) +static FSEventStreamRef g_stream = nullptr; +static CFRunLoopRef g_runLoop = nullptr; + +static void fsevents_cb(ConstFSEventStreamRef, void*, size_t, void*, + const FSEventStreamEventFlags*, const FSEventStreamEventId*) { + g_libsChanged.store(true); +} + +static void watch_thread(std::string root) { + CFStringRef cfRoot = CFStringCreateWithCString(nullptr, root.c_str(), kCFStringEncodingUTF8); + CFArrayRef paths = CFArrayCreate(nullptr, reinterpret_cast(&cfRoot), 1, + &kCFTypeArrayCallBacks); + FSEventStreamContext ctx = {0, nullptr, nullptr, nullptr, nullptr}; + g_stream = FSEventStreamCreate(nullptr, &fsevents_cb, &ctx, paths, + kFSEventStreamEventIdSinceNow, 0.2, kFSEventStreamCreateFlagFileEvents); + CFRelease(paths); + CFRelease(cfRoot); + if (g_stream == nullptr) { + return; + } + g_runLoop = CFRunLoopGetCurrent(); + FSEventStreamScheduleWithRunLoop(g_stream, g_runLoop, kCFRunLoopDefaultMode); + FSEventStreamStart(g_stream); + CFRunLoopRun(); // blocks until CFRunLoopStop from stopWatcher + FSEventStreamStop(g_stream); + FSEventStreamInvalidate(g_stream); + FSEventStreamRelease(g_stream); + g_stream = nullptr; + g_runLoop = nullptr; +} + +static void startWatcher(const std::string& root) { + g_watchStop.store(false); + g_watchThread = std::thread(watch_thread, root); +} + +static void stopWatcher() { + if (!g_watchThread.joinable()) { + return; + } + g_watchStop.store(true); + // Wait for the run loop to come up, then stop it so the thread can exit. + for (int i = 0; i < 200 && g_runLoop == nullptr; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + if (g_runLoop) { + CFRunLoopStop(g_runLoop); + } + g_watchThread.join(); +} + #else static void startWatcher(const std::string&) {} static void stopWatcher() {} diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index 8c4d3cb206..e9b9fbce01 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -28,7 +28,6 @@ #include #include "m_Do/m_Do_MemCard.h" -#include "mods_window.hpp" namespace dusk::ui { namespace { @@ -727,16 +726,9 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB }); apply_intro_animation(mMenuButtons.back()->root(), "delay-2"); - mMenuButtons.push_back(std::make_unique