diff --git a/CMakeLists.txt b/CMakeLists.txt index b6f50307bf..94f81059a5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -300,6 +300,19 @@ FetchContent_Declare(funchook set(FUNCHOOK_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(FUNCHOOK_BUILD_SHARED OFF CACHE BOOL "" FORCE) set(FUNCHOOK_INSTALL OFF CACHE BOOL "" FORCE) +# funchook auto-detects its CPU backend from CMAKE_SYSTEM_PROCESSOR (the build +# host). That's wrong when Apple cross-compiles to a different single target arch +# (e.g. an arm64 runner building x86_64), which makes it compile the wrong backend. +# funchook only auto-detects when FUNCHOOK_CPU is unset, so pre-set it from the +# real target arch. +if (APPLE AND CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_OSX_ARCHITECTURES MATCHES ";") + if (CMAKE_OSX_ARCHITECTURES MATCHES "x86_64|i.86") + set(FUNCHOOK_CPU x86) + elseif (CMAKE_OSX_ARCHITECTURES MATCHES "arm64|aarch64") + set(FUNCHOOK_CPU arm64) + endif () +endif () + FetchContent_MakeAvailable(cxxopts json miniz funchook) if (DUSK_ENABLE_SENTRY_NATIVE) @@ -559,6 +572,13 @@ endif () target_compile_definitions(${DUSK_MAIN_TARGET} PRIVATE ${GAME_COMPILE_DEFS} DUSK_BUILDING_GAME=1) target_include_directories(${DUSK_MAIN_TARGET} PRIVATE ${GAME_INCLUDE_DIRS}) + +# Keep game functions patchable by code-mod hooks (funchook): GCC's partial +# inlining splits functions into ".part.0" wrappers whose prologue funchook +# can't relocate (FUNCHOOK_ERROR_CANNOT_FIX_IP_RELATIVE), so hooks on them fail. +if (DUSK_ENABLE_CODE_MODS AND CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + target_compile_options(${DUSK_MAIN_TARGET} PRIVATE -fno-partial-inlining) +endif () target_link_libraries(${DUSK_MAIN_TARGET} PRIVATE aurora::main ${GAME_LIBS} ${JSYSTEM_LINK_LIBRARIES}) target_precompile_headers(${DUSK_MAIN_TARGET} PRIVATE "$<$:${CMAKE_CURRENT_LIST_DIR}/include/dusk_pch.hpp>") @@ -662,15 +682,15 @@ if (APPLE) set_target_properties( dusklight PROPERTIES MACOSX_BUNDLE TRUE - MACOSX_BUNDLE_BUNDLE_NAME ${DUSK_BUNDLE_NAME} - MACOSX_BUNDLE_GUI_IDENTIFIER ${DUSK_BUNDLE_IDENTIFIER} - MACOSX_BUNDLE_BUNDLE_VERSION ${DUSK_VERSION_STRING} - MACOSX_BUNDLE_SHORT_VERSION_STRING ${DUSK_SHORT_VERSION_STRING} - MACOSX_BUNDLE_INFO_PLIST ${DUSK_INFO_PLIST} + MACOSX_BUNDLE_BUNDLE_NAME "${DUSK_BUNDLE_NAME}" + MACOSX_BUNDLE_GUI_IDENTIFIER "${DUSK_BUNDLE_IDENTIFIER}" + MACOSX_BUNDLE_BUNDLE_VERSION "${DUSK_VERSION_STRING}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${DUSK_SHORT_VERSION_STRING}" + MACOSX_BUNDLE_INFO_PLIST "${DUSK_INFO_PLIST}" OUTPUT_NAME Dusklight XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES" XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "YES" - XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${DUSK_ENTITLEMENTS} + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${DUSK_ENTITLEMENTS}" XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME "YES" ) endif () 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..c9b93a0252 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include @@ -32,6 +34,24 @@ struct ModMetadata { std::string author; std::string description; bool hasCode; + int priority = 0; // load order; lower priority loads first + std::vector dependencies; // mod ids that must load before this one +}; + +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 { @@ -87,10 +107,19 @@ struct LoadedMod { std::string mod_path; std::string dir; + // Enabled state is owned by the game (persisted as a CVar), not the mod. + // `enabled` is the live mirror of cvarIsEnabled. std::unique_ptr> cvarIsEnabled; - - bool active = false; + bool enabled = true; + bool fromDir = true; // bundle source kind (dir vs .dusk), for hot-reload + bool active = false; // currently loaded + initialized bool load_failed = false; + std::string load_error; // reason shown in the UI when load_failed + + 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 +139,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 +154,22 @@ 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); + // True when every dependency is installed and currently active (loaded ok). + [[nodiscard]] bool depsSatisfied(const LoadedMod& mod); + 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; @@ -125,8 +177,22 @@ class ModLoader { void tryLoadDusk(const std::filesystem::path& modPath, bool fromDir); void tryLoadNativeMod(LoadedMod& mod); + void computeLoadOrder(); // reorder m_mods: dependencies first, then priority + bool checkDependencies(LoadedMod& mod); // verify deps active; sets load_error on fail 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); // settings values only (enabled is a CVar) + 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/hook_system.cpp b/src/dusk/hook_system.cpp index 620505822b..ffa4c3da83 100644 --- a/src/dusk/hook_system.cpp +++ b/src/dusk/hook_system.cpp @@ -1,5 +1,6 @@ #include "dusk/hook_system.hpp" #include "dusk/logging.h" +#include "dusk/mod_loader.hpp" #include #include @@ -78,6 +79,14 @@ void hookInstallByAddr(void* fn_addr, void* tramp_fn, void** orig_store) { DuskLog.warn( "HookSystem: funchook failed for {:p} (prepare={} install={})", fn_addr, prep, inst); funchook_destroy(fh); + // Surface the failure on the mod so it shows as failed (toast + UI marker) + // instead of silently running with a dead hook. + if (auto* mod = static_cast(modding::g_dusk_hook_current_mod)) { + mod->load_failed = true; + if (mod->load_error.empty()) { + mod->load_error = "a hook could not be installed (function not patchable)"; + } + } return; } diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index c6bdc0f51d..ecd59e7484 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -4,22 +4,59 @@ #include "mod_loader.hpp" #include +#include +#include #include +#include #include +#include #include +#include +#include #include "aurora/dvd.h" #include "dusk/config.hpp" #include "dusk/io.hpp" +#include "dusk/ui/ui.hpp" #include "miniz.h" #include "native_module.hpp" #include "nlohmann/json.hpp" +#if defined(__linux__) +#include +#include +#include +#elif defined(__APPLE__) +#include +#include +#include +#include +#include +#elif defined(_WIN32) +#include +#endif + +#if defined(__APPLE__) +#include +#if !TARGET_OS_OSX +// funchook (arm64) emits calls to the __clear_cache libcall to flush the +// instruction cache for its trampolines, but clang's compiler-rt builtins aren't +// linked on iOS/tvOS (macOS provides __clear_cache itself, hence guarded out). +// Provide it via Apple's instruction-cache invalidation. +#include +extern "C" void __clear_cache(void* start, void* end) { + sys_icache_invalidate(start, static_cast(end) - static_cast(start)); +} +#endif +#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,23 +70,280 @@ 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. +// 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; + +// A mod's enabled state is a game-owned CVar (so the game manages + saves it, +// not the mod). CVars can't be deleted until full shutdown, so orphan them. static std::vector> OrphanedConfigVars; +// The enabled CVar key embeds the mod id verbatim (ids are validated to a safe +// charset, so no escaping/mangling is needed). +static std::string modEnabledCVarName(std::string_view id) { + return fmt::format("mod.{}.enabled", id); +} + +// On-screen notification (uses the game's toast overlay) so mod reloads/errors +// are visible without watching the log. +static void modToast(const std::string& title, const std::string& content, int seconds) { + dusk::ui::push_toast({ + .type = "menu-notification", + .title = title, + .content = content, + .duration = std::chrono::seconds(seconds), + }); +} + +// ---- 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; + } +} + +#elif defined(__APPLE__) +// kqueue watcher (works on macOS + iOS; plain BSD, no CoreServices/Carbon). +static int g_kq = -1; +static std::vector g_watchedFds; +static constexpr uintptr_t kStopIdent = 1; + +static void watch_thread(std::string root) { + g_kq = kqueue(); + if (g_kq < 0) { + return; + } + // A user event we can trigger from stopWatcher() to wake the kevent() wait. + struct kevent stopEv; + EV_SET(&stopEv, kStopIdent, EVFILT_USER, EV_ADD | EV_CLEAR, 0, 0, nullptr); + kevent(g_kq, &stopEv, 1, nullptr, 0, nullptr); + + const auto addWatch = [&](const std::string& path) { + const int fd = open(path.c_str(), O_EVTONLY); + if (fd < 0) { + return; + } + struct kevent kev; + EV_SET(&kev, fd, EVFILT_VNODE, EV_ADD | EV_CLEAR, + NOTE_WRITE | NOTE_EXTEND | NOTE_DELETE | NOTE_RENAME | NOTE_ATTRIB, 0, nullptr); + if (kevent(g_kq, &kev, 1, nullptr, 0, nullptr) == 0) { + g_watchedFds.push_back(fd); + } else { + close(fd); + } + }; + + // Watch the mods root, each mod entry, and the files within each mod folder. + std::error_code ec; + if (fs::exists(root, ec)) { + addWatch(root); + for (const auto& e : fs::directory_iterator(root, ec)) { + addWatch(e.path().string()); + if (e.is_directory(ec)) { + for (const auto& f : fs::directory_iterator(e.path(), ec)) { + addWatch(f.path().string()); + } + } + } + } + + while (!g_watchStop.load()) { + struct kevent out; + const int n = kevent(g_kq, nullptr, 0, &out, 1, nullptr); + if (n < 0) { + if (errno == EINTR) { + continue; + } + break; + } + if (n == 0) { + continue; + } + if (out.filter == EVFILT_USER) { + break; // stop requested + } + g_libsChanged.store(true); + } + + for (const int fd : g_watchedFds) { + close(fd); + } + g_watchedFds.clear(); + close(g_kq); + g_kq = -1; +} + +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); + if (g_kq >= 0) { + struct kevent kev; + EV_SET(&kev, kStopIdent, EVFILT_USER, 0, NOTE_TRIGGER, 0, nullptr); + kevent(g_kq, &kev, 1, nullptr, 0, nullptr); + } + g_watchThread.join(); +} + +#else +// No file watcher on other platforms: mods still load and can be enabled/disabled +// + refreshed; rebuilt libraries just need a Refresh/restart. +static void startWatcher(const std::string&) {} +static void stopWatcher() {} +#endif + namespace dusk { 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,35 +392,40 @@ 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); + const int priority = j.value("priority", 0); + + std::vector deps; + if (auto it = j.find("dependencies"); it != j.end() && it->is_array()) { + for (const auto& d : *it) { + if (d.is_string()) { + deps.push_back(d.get()); + } + } + } validateModId(metaId); @@ -141,20 +440,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, priority, std::move(deps), }; } 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 +458,28 @@ 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; } - const fs::path cacheDir = m_modsDir / ".cache" / mod.metadata.id; std::error_code ec; + // Record the on-disk source file the watcher follows *before* attempting the + // load, so even a failed load is hot-reload-retried when the file is rebuilt. + 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); + + // 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; + fs::remove_all(cacheDir, ec); fs::create_directories(cacheDir, ec); const fs::path dllCachePath = cacheDir / fs::path(dllEntry).filename(); @@ -188,8 +488,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 +498,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,90 +531,413 @@ 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; + mod.lib_mtime = fs::last_write_time(mod.source_lib, ec); // refresh to the loaded mtime } -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; -} - -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); + 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; + } + // config.json holds only the mod's setting values; enabled is a CVar. + 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{{"settings", settings}}; // enabled is a game-owned CVar + 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 setting values + 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) { + mod.load_error.clear(); + 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; +} - 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::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; +} + +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()); + modToast("Mod reload failed", mod.metadata.name, 6); + return; + } + mod.native_status = NativeModStatus::Unknown; + tryLoadNativeMod(mod); + if (mod.native_status != NativeModStatus::Loaded) { + Log.error("hot-reload '{}': native load failed", mod.metadata.id); + modToast("Mod reload failed", mod.metadata.name, 6); + return; + } + const bool ok = initMod(mod); + Log.info("hot-reloaded '{}'", mod.metadata.id); + if (ok) { + modToast("Mod reloaded", mod.metadata.name, 3); + } else { + modToast("Mod reload failed", mod.metadata.name + " (init error)", 6); + } +} + +// ---- public lifecycle ------------------------------------------------------ + +// Reorder m_mods so every mod comes after its dependencies, breaking ties by +// priority (lower first) then id. Kahn's algorithm; cyclic mods are marked failed +// and appended. Only the unique_ptrs move -- LoadedMod addresses stay stable. +void ModLoader::computeLoadOrder() { + const std::size_t n = m_mods.size(); + if (n < 2) { + return; + } + std::unordered_map index; + for (std::size_t i = 0; i < n; ++i) { + index[m_mods[i]->metadata.id] = i; + } + std::vector indeg(n, 0); + std::vector> dependents(n); + for (std::size_t i = 0; i < n; ++i) { + for (const std::string& dep : m_mods[i]->metadata.dependencies) { + if (auto it = index.find(dep); it != index.end()) { + dependents[it->second].push_back(i); + indeg[i]++; + } + } + } + const auto better = [&](std::size_t a, std::size_t b) { + if (m_mods[a]->metadata.priority != m_mods[b]->metadata.priority) { + return m_mods[a]->metadata.priority < m_mods[b]->metadata.priority; + } + return m_mods[a]->metadata.id < m_mods[b]->metadata.id; + }; + std::vector ready; + for (std::size_t i = 0; i < n; ++i) { + if (indeg[i] == 0) { + ready.push_back(i); + } + } + std::vector order; + order.reserve(n); + while (!ready.empty()) { + auto best = std::min_element(ready.begin(), ready.end(), better); + const std::size_t i = *best; + ready.erase(best); + order.push_back(i); + for (const std::size_t j : dependents[i]) { + if (--indeg[j] == 0) { + ready.push_back(j); + } + } + } + if (order.size() < n) { // leftovers are in a dependency cycle + std::vector placed(n, false); + for (const std::size_t i : order) { + placed[i] = true; + } + std::vector leftover; + for (std::size_t i = 0; i < n; ++i) { + if (!placed[i]) { + leftover.push_back(i); + } + } + std::sort(leftover.begin(), leftover.end(), better); + for (const std::size_t i : leftover) { + m_mods[i]->load_failed = true; + m_mods[i]->load_error = "dependency cycle"; + order.push_back(i); + } + } + std::vector> reordered; + reordered.reserve(n); + for (const std::size_t i : order) { + reordered.push_back(std::move(m_mods[i])); + } + m_mods = std::move(reordered); +} + +bool ModLoader::depsSatisfied(const LoadedMod& mod) { + for (const std::string& dep : mod.metadata.dependencies) { + const LoadedMod* d = find(dep); + if (d == nullptr || !d->active) { + return false; + } + } + return true; +} + +bool ModLoader::checkDependencies(LoadedMod& mod) { + for (const std::string& dep : mod.metadata.dependencies) { + const LoadedMod* d = find(dep); + if (d == nullptr) { + mod.load_failed = true; + mod.load_error = fmt::format("missing dependency '{}'", dep); + return false; + } + if (!d->active) { + mod.load_failed = true; + mod.load_error = fmt::format("dependency '{}' not enabled", dep); + return false; + } + } + return true; } void ModLoader::init() { @@ -326,17 +946,16 @@ void ModLoader::init() { } 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 +971,45 @@ void ModLoader::init() { tryLoadDusk(entry.path(), entry.is_directory()); } + computeLoadOrder(); // dependencies first, then priority + + // 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; } + // Enabled state is a game-owned CVar; settings descriptors + saved values + // come from the per-mod config. + for (auto& mod : mods()) { + config::Register(*mod.cvarIsEnabled); + mod.enabled = mod.cvarIsEnabled->getValue(); + 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; + continue; } - } - - for (auto& mod : active_mods()) { - if (mod.native) { - buildAPI(mod); + if (mod.load_failed) { + continue; // already failed (e.g. dependency cycle) } - } - - for (auto& mod : active_mods()) { - if (!mod.native) { + if (!checkDependencies(mod)) { // deps load earlier in order, so this is final + Log.error("Mod '{}': {}", mod.metadata.id, mod.load_error); 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 +1020,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; @@ -416,30 +1032,164 @@ void ModLoader::tick() { } catch (const std::exception& e) { Log.error("exception in {}.mod_tick(): {} — disabling", mod.metadata.id, e.what()); mod.active = false; + modToast("Mod crashed (disabled)", mod.metadata.name, 6); } catch (...) { Log.error("unknown exception in {}.mod_tick() — disabling", mod.metadata.id); mod.active = false; + modToast("Mod crashed (disabled)", mod.metadata.name, 6); } } } -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 + } + + bool addedOverlay = false; + for (std::size_t i = before; i < m_mods.size(); ++i) { + LoadedMod& mod = *m_mods[i]; + config::Register(*mod.cvarIsEnabled); + mod.enabled = mod.cvarIsEnabled->getValue(); + parseSchema(mod); + readConfig(mod); + if (!mod.enabled) { + continue; + } + if (!checkDependencies(mod)) { + Log.error("Mod '{}': {}", mod.metadata.id, mod.load_error); + 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); OrphanedConfigVars.emplace_back(std::move(mod.cvarIsEnabled)); } - 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; + } + if (enabled && !checkDependencies(*m)) { + // Can't enable until dependencies are installed + enabled. Leave it off. + modToast("Can't enable mod", m->metadata.name + ": " + m->load_error, 6); + return; + } + m->enabled = enabled; + if (m->cvarIsEnabled) { + m->cvarIsEnabled->setValue(enabled); // game-owned, persisted by config::Save + } + + 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)) { + modToast("Mod failed to enable", m->metadata.name, 6); + } + } else { + m->active = true; + initOverlayFiles(); + } + } else { + unloadMod(*m); + } + config::Save(); +} + +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/modding/native_module.cpp b/src/dusk/modding/native_module.cpp index 82cbe501bf..49fd2bc70b 100644 --- a/src/dusk/modding/native_module.cpp +++ b/src/dusk/modding/native_module.cpp @@ -30,7 +30,9 @@ std::string pl_dlerror() { #else #include static void* pl_dlopen(const std::filesystem::path& p) { -#if defined(__linux__) + // RTLD_DEEPBIND is a glibc extension; it doesn't exist on Android (Bionic), + // macOS, or iOS even though they're all dlopen platforms. +#ifdef RTLD_DEEPBIND return dlopen(p.c_str(), RTLD_LAZY | RTLD_LOCAL | RTLD_DEEPBIND); #else return dlopen(p.c_str(), RTLD_LAZY | RTLD_LOCAL); diff --git a/src/dusk/ui/menu_bar.cpp b/src/dusk/ui/menu_bar.cpp index 79abae47bb..45098bf603 100644 --- a/src/dusk/ui/menu_bar.cpp +++ b/src/dusk/ui/menu_bar.cpp @@ -60,6 +60,15 @@ MenuBar::MenuBar() : Document(kDocumentSource), mRoot(mDocument->GetElementById( mTabBar->add_tab("Achievements", [this] { push(std::make_unique()); }); mTabBar->add_tab("Mods", [this] { push(std::make_unique()); }); + for (const auto& contribution : menu_contributions()) { + mTabBar->add_tab(contribution.label, [this, make = contribution.makeDocument] { + if (make) { + if (auto doc = make()) { + push(std::move(doc)); + } + } + }); + } mTabBar->add_tab("Reset", [this] { mTabBar->set_active_tab(-1); const auto dismiss = [](Modal& modal) { modal.pop(); }; diff --git a/src/dusk/ui/mods_window.cpp b/src/dusk/ui/mods_window.cpp index d7bd7c1880..bc4d69ece9 100644 --- a/src/dusk/ui/mods_window.cpp +++ b/src/dusk/ui/mods_window.cpp @@ -1,124 +1,171 @@ #include "mods_window.hpp" +#include +#include +#include + +#include "bool_button.hpp" #include "dusk/mod_loader.hpp" #include "fmt/format.h" +#include "number_button.hpp" #include "pane.hpp" namespace dusk::ui { -namespace { - -Rml::String build_mod_detail_rml(const dusk::LoadedMod& mod) { - const char* statusClass; - const char* statusText; - if (mod.load_failed) { - statusClass = "locked"; - statusText = "Failed"; - } else if (mod.active) { - statusClass = "unlocked"; - statusText = "Active"; - } else { - statusClass = ""; - statusText = "Disabled"; - } - - return fmt::format( - R"(
)" - R"(Version)" - R"({})" - R"(
)" - R"(
)" - R"(Author)" - R"({})" - R"(
)" - R"(
)" - R"(Status)" - R"({})" - R"(
)" - R"(
)" - R"(Path)" - R"({})" - R"(
)", - mod.metadata.version, - mod.metadata.author, - statusClass, statusText, - mod.mod_path - ); -} - -} // namespace ModsWindow::ModsWindow() { - const auto& mods = dusk::ModLoader::instance().mods(); - - if (mods.empty()) { - add_tab("Mods", [this](Rml::Element* content) { - auto& pane = add_child(content, Pane::Type::Uncontrolled); - pane.add_text("No mods installed."); - pane.finalize(); + add_tab("Mods", [this](Rml::Element* content) { + // Master-detail: left = list of mods + refresh, right = the selected + // mod's enable toggle, settings, and any custom panel it builds. + 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(); }); - return; - } - - for (ModIndex i = 0; i < mods.size(); ++i) { - mSnapshot.push_back({mods[i].active, mods[i].load_failed}); - - add_tab(mods[i].metadata.name, [this, i](Rml::Element* content) { - mActiveModIndex = static_cast(i); - - const auto& curMods = dusk::ModLoader::instance().mods(); - if (i >= curMods.size()) { - return; - } - const auto& mod = curMods[i]; - - auto& pane = add_child(content, Pane::Type::Uncontrolled); - pane.add_section("Details"); - pane.add_rml(build_mod_detail_rml(mod)); + 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; + } - if (!mod.metadata.description.empty()) { - pane.add_section("Description"); - pane.add_text(mod.metadata.description); + leftPane.add_section("Installed Mods"); + for (const dusk::LoadedMod& entry : modsView) { + const std::string id = entry.metadata.id; + // Mark mods that failed to load right in the list. + Rml::String label = entry.metadata.name; + if (entry.load_failed) { + label += " [failed]"; } + auto& item = leftPane.add_button(label); + + // Focusing a mod fills the right pane with its details + settings. + leftPane.register_control(item, rightPane, [this, id](Pane& pane) { + mFocusedModId = id; + auto& loader = dusk::ModLoader::instance(); + dusk::LoadedMod* mod = loader.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)); + + if (mod->load_failed && !mod->load_error.empty()) { + pane.add_rml(fmt::format("
Failed to load: {}", mod->load_error)); + } + + // Dependencies + their status. + if (!mod->metadata.dependencies.empty()) { + pane.add_section("Dependencies"); + for (const std::string& dep : mod->metadata.dependencies) { + const dusk::LoadedMod* d = loader.find(dep); + const char* status = (d == nullptr) ? "not installed" + : d->active ? "ready" + : "disabled"; + pane.add_rml(fmt::format("{} — {}", dep, status)); + } + } + + 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); + }, + // Can't enable a disabled mod whose dependencies aren't met. + .isDisabled = + [id] { + auto& l = dusk::ModLoader::instance(); + dusk::LoadedMod* m = l.find(id); + return m != nullptr && !m->enabled && !l.depsSatisfied(*m); + }, + .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, + }); + } + } + + // A mod may also build its own custom panel (e.g. live status). + for (const auto& cb : mod->tab_content) { + cb.build_fn(static_cast(pane.root()), cb.userdata); + } + }); + } + }); - for (const auto& cb : mod.tab_content) { - cb.build_fn(static_cast(pane.root()), cb.userdata); + for (const auto& tab : window_tab_contributions()) { + if (tab.target != WindowTabTarget::Mods) { + continue; + } + add_tab(tab.title, [this, b = tab.builder](Rml::Element* content) { + if (b) { + b(*this, content); } - - pane.finalize(); }); } } void ModsWindow::update() { - const auto& mods = dusk::ModLoader::instance().mods(); - - bool dirty = mods.size() != mSnapshot.size(); - if (!dirty) { - for (ModIndex i = 0; i < mods.size(); ++i) { - if (mods[i].active != mSnapshot[i].active || - mods[i].load_failed != mSnapshot[i].load_failed) - { - dirty = true; - break; + // Drive the focused mod's live UI callbacks (custom panels). + if (!mFocusedModId.empty()) { + if (dusk::LoadedMod* mod = dusk::ModLoader::instance().find(mFocusedModId)) { + for (const auto& cb : mod->tab_updates) { + cb.update_fn(cb.userdata); } } } - - if (dirty) { - mSnapshot.clear(); - for (const auto& mod : mods) { - mSnapshot.push_back({mod.active, mod.load_failed}); - } - refresh_active_tab(); - } - - if (mActiveModIndex >= 0 && static_cast(mActiveModIndex) < mods.size()) { - for (const auto& cb : mods[mActiveModIndex].tab_updates) { - cb.update_fn(cb.userdata); - } - } - Window::update(); } diff --git a/src/dusk/ui/mods_window.hpp b/src/dusk/ui/mods_window.hpp index ba550cbf25..9bffffdfa3 100644 --- a/src/dusk/ui/mods_window.hpp +++ b/src/dusk/ui/mods_window.hpp @@ -2,24 +2,20 @@ #include "window.hpp" -#include - -#include "dusk/mod_loader.hpp" +#include namespace dusk::ui { +// A dedicated master-detail Mods window: left = the list of mods + a refresh +// button, right = the selected mod's enable toggle, settings, and any custom +// panel it builds. Opened from the title screen and the in-game quick menu. class ModsWindow : public Window { public: ModsWindow(); void update() override; private: - struct ModSnapshot { - bool active; - bool load_failed; - }; - std::vector mSnapshot; - ModIndex mActiveModIndex = 0; + std::string mFocusedModId; // mod whose detail pane is shown (for tab_updates) }; } // namespace dusk::ui diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index 8c4d3cb206..72f46e75c0 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -8,6 +8,7 @@ #include "dusk/settings.h" #include "dusk/update_check.hpp" #include "modal.hpp" +#include "mods_window.hpp" #include "preset.hpp" #include "settings.hpp" #include "version.h" @@ -28,7 +29,6 @@ #include #include "m_Do/m_Do_MemCard.h" -#include "mods_window.hpp" namespace dusk::ui { namespace { @@ -734,6 +734,19 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB }); apply_intro_animation(mMenuButtons.back()->root(), "delay-3"); + for (const auto& contribution : menu_contributions()) { + mMenuButtons.push_back(std::make_unique