From 22d906a248ceb41f167a1d59848662f1ce6fb0ed Mon Sep 17 00:00:00 2001 From: madeline Date: Mon, 20 Apr 2026 04:17:42 -0700 Subject: [PATCH 01/48] mod loader --- .gitignore | 2 + .vscode/launch.json | 4 +- CMakeLists.txt | 115 ++++++--- cmake/DuskModSDK.cmake | 59 +++++ files.cmake | 4 + include/d/d_com_inf_game.h | 10 +- include/dusk/gx_helper.h | 13 +- include/dusk/hook.hpp | 84 +++++++ include/dusk/hook_system.hpp | 18 ++ include/dusk/mod_api.h | 50 ++++ include/dusk/mod_loader.hpp | 64 +++++ include/global.h | 13 + include/m_Do/m_Do_controller_pad.h | 3 +- include/m_Do/m_Do_graphic.h | 2 +- src/DynamicLink.cpp | 6 + src/dusk/gx_helper.cpp | 13 + src/dusk/hook_system.cpp | 149 ++++++++++++ src/dusk/imgui/ImGuiConsole.cpp | 2 + src/dusk/imgui/ImGuiConsole.hpp | 2 + src/dusk/imgui/ImGuiMenuMods.cpp | 78 ++++++ src/dusk/imgui/ImGuiMenuMods.hpp | 15 ++ src/dusk/launcher_win32.cpp | 15 ++ src/dusk/main.cpp | 5 +- src/dusk/mod_loader.cpp | 377 +++++++++++++++++++++++++++++ src/f_ap/f_ap_game.cpp | 2 + src/m_Do/m_Do_main.cpp | 8 +- tools/mod_template/CMakeLists.txt | 18 ++ tools/mod_template/mod.json | 6 + tools/mod_template/res/text.txt | 2 + tools/mod_template/src/mod.cpp | 80 ++++++ 30 files changed, 1169 insertions(+), 50 deletions(-) create mode 100644 cmake/DuskModSDK.cmake create mode 100644 include/dusk/hook.hpp create mode 100644 include/dusk/hook_system.hpp create mode 100644 include/dusk/mod_api.h create mode 100644 include/dusk/mod_loader.hpp create mode 100644 src/dusk/gx_helper.cpp create mode 100644 src/dusk/hook_system.cpp create mode 100644 src/dusk/imgui/ImGuiMenuMods.cpp create mode 100644 src/dusk/imgui/ImGuiMenuMods.hpp create mode 100644 src/dusk/launcher_win32.cpp create mode 100644 src/dusk/mod_loader.cpp create mode 100644 tools/mod_template/CMakeLists.txt create mode 100644 tools/mod_template/mod.json create mode 100644 tools/mod_template/res/text.txt create mode 100644 tools/mod_template/src/mod.cpp diff --git a/.gitignore b/.gitignore index a1839ff34e..e3cbb3930f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ compile_commands.json pipeline_cache.bin extract + +*.dusk diff --git a/.vscode/launch.json b/.vscode/launch.json index 7090699dd9..087202b538 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,12 +6,12 @@ "type": "cppvsdbg", "request": "launch", "program": "${command:cmake.launchTargetPath}", - "args": ["-l", "1", "--dvd", "${workspaceRoot}/orig/GZ2E01/GZ2E01.iso", "--console"], + "args": ["-l", "1", "--dvd", "${workspaceRoot}/orig/GZ2E01/GZ2E01.iso", "--console", "--mods", "${workspaceRoot}/tools/mod_template/mods"], "MIMode": "gdb", "miDebuggerPath": "gdb", "symbolSearchPath": "${command:cmake.launchTargetPath}", "console": "integratedTerminal", - "cwd":"${workspaceRoot}" + "cwd":"${workspaceRoot}", } ] } diff --git a/CMakeLists.txt b/CMakeLists.txt index c99133738b..eb7e62076e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,26 +9,26 @@ endif () find_package(Git) if (GIT_FOUND) # make sure version information gets re-run when the current Git HEAD changes - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --git-path HEAD + execute_process(WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --git-path HEAD OUTPUT_VARIABLE dusk_git_head_filename OUTPUT_STRIP_TRAILING_WHITESPACE) set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_filename}") - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --symbolic-full-name HEAD + execute_process(WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --symbolic-full-name HEAD OUTPUT_VARIABLE dusk_git_head_symbolic OUTPUT_STRIP_TRAILING_WHITESPACE) - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + execute_process(WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --git-path ${dusk_git_head_symbolic} OUTPUT_VARIABLE dusk_git_head_symbolic_filename OUTPUT_STRIP_TRAILING_WHITESPACE) set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_symbolic_filename}") # defines DUSK_WC_REVISION - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse HEAD + execute_process(WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse HEAD OUTPUT_VARIABLE DUSK_WC_REVISION OUTPUT_STRIP_TRAILING_WHITESPACE) # defines DUSK_WC_DESCRIBE - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} describe --tags --long --dirty --match "v*" + execute_process(WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} describe --tags --long --dirty --match "v*" OUTPUT_VARIABLE DUSK_WC_DESCRIBE OUTPUT_STRIP_TRAILING_WHITESPACE) @@ -37,11 +37,11 @@ if (GIT_FOUND) string(REGEX REPLACE "-0$" "" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") # defines DUSK_WC_BRANCH - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --abbrev-ref HEAD + execute_process(WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} rev-parse --abbrev-ref HEAD OUTPUT_VARIABLE DUSK_WC_BRANCH OUTPUT_STRIP_TRAILING_WHITESPACE) # defines DUSK_WC_DATE - execute_process(WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} log -1 --format=%ad + execute_process(WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} COMMAND ${GIT_EXECUTABLE} log -1 --format=%ad OUTPUT_VARIABLE DUSK_WC_DATE OUTPUT_STRIP_TRAILING_WHITESPACE) else () @@ -135,7 +135,7 @@ if (DUSK_MOVIE_SUPPORT) -DWITH_JAVA=OFF ) if (CMAKE_TOOLCHAIN_FILE) - get_filename_component(_jpeg_toolchain_file "${CMAKE_TOOLCHAIN_FILE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}") + get_filename_component(_jpeg_toolchain_file "${CMAKE_TOOLCHAIN_FILE}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") list(APPEND _jpeg_cmake_args -DCMAKE_TOOLCHAIN_FILE=${_jpeg_toolchain_file}) endif () set(_jpeg_passthrough_vars @@ -221,7 +221,21 @@ FetchContent_Declare(json URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa DOWNLOAD_EXTRACT_TIMESTAMP TRUE ) -FetchContent_MakeAvailable(cxxopts json) +message(STATUS "dusk: Fetching miniz") +FetchContent_Declare(miniz + URL https://github.com/richgel999/miniz/releases/download/3.0.2/miniz-3.0.2.zip + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +message(STATUS "dusk: Fetching funchook") +FetchContent_Declare(funchook + GIT_REPOSITORY https://github.com/kubo/funchook.git + GIT_TAG v1.1.3 + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE +) +set(FUNCHOOK_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(FUNCHOOK_BUILD_SHARED OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(cxxopts json miniz funchook) if (DUSK_ENABLE_SENTRY_NATIVE) message(STATUS "dusk: Fetching sentry-native") @@ -263,7 +277,7 @@ else () string(TOLOWER CMAKE_SYSTEM_NAME PLATFORM_NAME) endif () -configure_file(${CMAKE_SOURCE_DIR}/version.h.in ${CMAKE_BINARY_DIR}/version.h) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/version.h) include(files.cmake) @@ -294,11 +308,19 @@ set(GAME_INCLUDE_DIRS libs/JSystem/include libs extern/aurora/include/dolphin + extern/aurora/include extern - ${CMAKE_BINARY_DIR}) + ${CMAKE_CURRENT_BINARY_DIR} + ${miniz_SOURCE_DIR}) + +# Interface target for mods and sub-projects to inherit game headers/defines +add_library(dusk_game_headers INTERFACE) +target_include_directories(dusk_game_headers INTERFACE ${GAME_INCLUDE_DIRS}) +target_compile_definitions(dusk_game_headers INTERFACE TARGET_PC=1) +target_link_libraries(dusk_game_headers INTERFACE TracyClient) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd - aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt) + aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt funchook-static) list(APPEND GAME_LIBS libzstd_static) @@ -341,13 +363,14 @@ add_library(game_debug OBJECT ${JSYSTEM_DEBUG_FILES} ${SSYSTEM_FILES} src/dusk/imgui/ImGuiAudio.cpp) # game_base is for all other game code files -add_library(game_base OBJECT ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${JSYSTEM_FILES} ${REL_FILES} ${DUSK_FILES} ${DOLPHIN_FILES}) +add_library(game_base OBJECT ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${JSYSTEM_FILES} ${REL_FILES} ${DUSK_FILES} ${DOLPHIN_FILES} + ${miniz_SOURCE_DIR}/miniz.c) -target_compile_definitions(game_debug PRIVATE ${GAME_COMPILE_DEFS} $<$:DEBUG=1> $<$:PARTIAL_DEBUG=1>) -target_compile_definitions(game_base PRIVATE ${GAME_COMPILE_DEFS} NDEBUG=1 NDEBUG_DEFINED=1 DEBUG_DEFINED=0 $<$:PARTIAL_DEBUG=1>) +target_compile_definitions(game_debug PRIVATE ${GAME_COMPILE_DEFS} DUSK_BUILDING_GAME=1 $<$:DEBUG=1> $<$:PARTIAL_DEBUG=1>) +target_compile_definitions(game_base PRIVATE ${GAME_COMPILE_DEFS} DUSK_BUILDING_GAME=1 NDEBUG=1 NDEBUG_DEFINED=1 DEBUG_DEFINED=0 $<$:PARTIAL_DEBUG=1>) # only apply PCH to game_base since not all headers are necessarily validated with DEBUG=1 -target_precompile_headers(game_base PRIVATE "$<$:${CMAKE_SOURCE_DIR}/include/dusk_pch.hpp>") +target_precompile_headers(game_base PRIVATE "$<$:${CMAKE_CURRENT_SOURCE_DIR}/include/dusk_pch.hpp>") target_include_directories(game_debug PRIVATE ${GAME_INCLUDE_DIRS}) target_include_directories(game_base PRIVATE ${GAME_INCLUDE_DIRS}) @@ -366,27 +389,56 @@ target_link_libraries(game PUBLIC ${GAME_LIBS}) if(ANDROID) add_library(dusk SHARED src/dusk/main.cpp) set_target_properties(dusk PROPERTIES OUTPUT_NAME main) -else () + set(DUSK_MAIN_TARGET dusk) +elseif(WIN32) + # Game DLL: mods link against dusk.lib. TARGET_OBJECTS lets WINDOWS_EXPORT_ALL_SYMBOLS see and export all game symbols + add_library(dusk_game SHARED + src/dusk/main.cpp + $ + $) + set_target_properties(dusk_game PROPERTIES + WINDOWS_EXPORT_ALL_SYMBOLS ON + OUTPUT_NAME dusk) + + # Launcher EXE + add_executable(dusk WIN32 src/dusk/launcher_win32.cpp) + target_link_libraries(dusk PRIVATE dusk_game) + target_include_directories(dusk PRIVATE include) + set(DUSK_MAIN_TARGET dusk_game) +else() add_executable(dusk src/dusk/main.cpp) -endif () + set(DUSK_MAIN_TARGET dusk) +endif() -target_compile_definitions(dusk PRIVATE TARGET_PC AVOID_UB=1 VERSION=0) -target_include_directories(dusk PRIVATE include) -target_link_libraries(dusk PRIVATE game aurora::main) +target_compile_definitions(${DUSK_MAIN_TARGET} PRIVATE TARGET_PC AVOID_UB=1 VERSION=0) +target_include_directories(${DUSK_MAIN_TARGET} PRIVATE include) +# Windows uses TARGET_OBJECTS directly so link GAME_LIBS instead of the static wrapper. +if(WIN32) + target_link_libraries(${DUSK_MAIN_TARGET} PRIVATE ${GAME_LIBS} aurora::main Psapi) +else() + target_link_libraries(${DUSK_MAIN_TARGET} PRIVATE game aurora::main) +endif() +if(CMAKE_SYSTEM_NAME STREQUAL Linux) + target_link_libraries(${DUSK_MAIN_TARGET} PRIVATE dl) +endif() +# -rdynamic lets mods call game functions directly +if(UNIX AND NOT ANDROID) + target_link_options(${DUSK_MAIN_TARGET} PRIVATE -rdynamic) +endif() if (TARGET crashpad_handler) - add_dependencies(dusk crashpad_handler) + add_dependencies(${DUSK_MAIN_TARGET} crashpad_handler) endif () if (ANDROID) # SDLActivity loads SDL_main via dlsym on Android. Since aurora::main is a static # archive, force an undefined reference so the linker keeps the SDL_main object. - target_link_options(dusk PRIVATE "-Wl,-u,SDL_main") + target_link_options(${DUSK_MAIN_TARGET} PRIVATE "-Wl,-u,SDL_main") endif () if (NOT APPLE) add_custom_command(TARGET dusk POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CMAKE_SOURCE_DIR}/res" + "${CMAKE_CURRENT_SOURCE_DIR}/res" "$/res" COMMENT "Copying resources" ) @@ -414,13 +466,13 @@ if (WIN32) configure_file(${DUSK_WINDOWS_RESOURCE_DIR}/dusk.rc.in ${DUSK_WINDOWS_RC} @ONLY) target_sources(dusk PRIVATE ${DUSK_WINDOWS_ICON_ICO} ${DUSK_WINDOWS_RC}) - set_target_properties(dusk PROPERTIES WIN32_EXECUTABLE TRUE) - if (MSVC) target_link_options(dusk PRIVATE /MANIFEST:NO) endif () endif () +include(cmake/DuskModSDK.cmake) + if (APPLE) if (IOS) set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios) @@ -470,7 +522,11 @@ if (IOS) endif () include(extern/aurora/cmake/AuroraCopyRuntimeDLLs.cmake) -aurora_copy_runtime_dlls(dusk) +if(WIN32) + aurora_copy_runtime_dlls(dusk dusk_game) +else() + aurora_copy_runtime_dlls(dusk) +endif() if (DUSK_SELECTED_OPT) if (CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") @@ -509,6 +565,9 @@ function(get_target_prefix target result_var) endif () endfunction() list(APPEND BINARY_TARGETS dusk) +if(WIN32) + list(APPEND BINARY_TARGETS dusk_game) +endif() set(EXTRA_TARGETS "") if (TARGET crashpad_handler) list(APPEND EXTRA_TARGETS crashpad_handler) @@ -516,7 +575,7 @@ endif () install(TARGETS ${BINARY_TARGETS} ${EXTRA_TARGETS} DESTINATION ${CMAKE_INSTALL_PREFIX}) aurora_install_runtime_dlls(dusk ${CMAKE_INSTALL_PREFIX}) if (NOT APPLE) - install(DIRECTORY ${CMAKE_SOURCE_DIR}/res DESTINATION ${CMAKE_INSTALL_PREFIX}) + install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/res DESTINATION ${CMAKE_INSTALL_PREFIX}) endif () if (CMAKE_BUILD_TYPE STREQUAL Debug OR CMAKE_BUILD_TYPE STREQUAL RelWithDebInfo) set(DEBUG_FILES_LIST "") diff --git a/cmake/DuskModSDK.cmake b/cmake/DuskModSDK.cmake new file mode 100644 index 0000000000..809c84426c --- /dev/null +++ b/cmake/DuskModSDK.cmake @@ -0,0 +1,59 @@ +# add_dusk_mod( SOURCES ... MOD_JSON [RES_DIR ]) +set(DUSK_MODS_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/mods" CACHE PATH "Directory to write .dusk packages into") + +function(add_dusk_mod target_name) + cmake_parse_arguments(ARG "" "MOD_JSON;RES_DIR" "SOURCES" ${ARGN}) + if(NOT ARG_MOD_JSON) + message(FATAL_ERROR "add_dusk_mod: MOD_JSON is required") + endif() + + add_library(${target_name} SHARED ${ARG_SOURCES}) + set_target_properties(${target_name} PROPERTIES PREFIX "" WINDOWS_EXPORT_ALL_SYMBOLS ON) + target_compile_features(${target_name} PRIVATE cxx_std_20) + target_link_libraries(${target_name} PRIVATE dusk_game_headers) + + if(APPLE) + target_link_options(${target_name} PRIVATE -undefined dynamic_lookup) + elseif(UNIX) + target_link_options(${target_name} PRIVATE -Wl,--allow-shlib-undefined) + elseif(WIN32) + target_link_libraries(${target_name} PRIVATE dusk_game) + if(MSVC) + target_link_options(${target_name} PRIVATE /INCREMENTAL:NO) + set_target_properties(${target_name} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreadedDLL") + endif() + endif() + + if(TARGET imgui) + if(WIN32) + target_link_libraries(${target_name} PRIVATE imgui) + else() + get_target_property(_inc imgui INTERFACE_INCLUDE_DIRECTORIES) + if(_inc) + target_include_directories(${target_name} PRIVATE ${_inc}) + endif() + endif() + endif() + + set(_stage "${CMAKE_CURRENT_BINARY_DIR}/${target_name}_stage") + set(_out "${DUSK_MODS_OUTPUT_DIR}/${target_name}.dusk") + file(MAKE_DIRECTORY "${_stage}") # must exist before POST_BUILD on Windows + + set(_zip_args "$" mod.json) + set(_extra_cmds "") + if(ARG_RES_DIR) + list(APPEND _zip_args res) + set(_extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/${ARG_RES_DIR}" "${_stage}/res") + endif() + + add_custom_command(TARGET ${target_name} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${_stage}" "${DUSK_MODS_OUTPUT_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" "${_stage}/$" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_CURRENT_SOURCE_DIR}/${ARG_MOD_JSON}" "${_stage}/mod.json" + ${_extra_cmds} + COMMAND ${CMAKE_COMMAND} -E tar cvf "${_out}" --format=zip ${_zip_args} + WORKING_DIRECTORY "${_stage}" + COMMENT "Packaging ${target_name} -> ${_out}" + ) +endfunction() diff --git a/files.cmake b/files.cmake index d43e98bf42..d315dbcafd 100644 --- a/files.cmake +++ b/files.cmake @@ -1387,4 +1387,8 @@ set(DUSK_FILES src/dusk/OSContext.cpp src/dusk/OSThread.cpp src/dusk/OSMutex.cpp + src/dusk/hook_system.cpp + src/dusk/mod_loader.cpp + src/dusk/imgui/ImGuiMenuMods.cpp + src/dusk/gx_helper.cpp ) diff --git a/include/d/d_com_inf_game.h b/include/d/d_com_inf_game.h index 2978eeef1a..91114ab1b1 100644 --- a/include/d/d_com_inf_game.h +++ b/include/d/d_com_inf_game.h @@ -1049,11 +1049,11 @@ class dComIfG_inf_c { STATIC_ASSERT(122384 == sizeof(dComIfG_inf_c)); -extern dComIfG_inf_c g_dComIfG_gameInfo; -extern GXColor g_blackColor; -extern GXColor g_clearColor; -extern GXColor g_whiteColor; -extern GXColor g_saftyWhiteColor; +DUSK_GAME_EXTERN dComIfG_inf_c g_dComIfG_gameInfo; +DUSK_GAME_EXTERN GXColor g_blackColor; +DUSK_GAME_EXTERN GXColor g_clearColor; +DUSK_GAME_EXTERN GXColor g_whiteColor; +DUSK_GAME_EXTERN GXColor g_saftyWhiteColor; int dComLbG_PhaseHandler(request_of_phase_process_class*, request_of_phase_process_fn*, void*); diff --git a/include/dusk/gx_helper.h b/include/dusk/gx_helper.h index 8f71cbdf26..9946d9116e 100644 --- a/include/dusk/gx_helper.h +++ b/include/dusk/gx_helper.h @@ -18,9 +18,8 @@ class GXTexObjRAII : public GXTexObj { public: GXTexObjRAII() : GXTexObj() {} - ~GXTexObjRAII() { GXDestroyTexObj(this); } - - void reset() { GXDestroyTexObj(this); } + ~GXTexObjRAII(); + void reset(); GXTexObjRAII(const GXTexObjRAII&) = delete; GXTexObjRAII& operator=(const GXTexObjRAII&) = delete; @@ -44,12 +43,8 @@ typedef GXTexObj TGXTexObj; #endif struct GXScopedDebugGroup { - explicit GXScopedDebugGroup(const char* text) { - GXPushDebugGroup(text); - } - ~GXScopedDebugGroup() { - GXPopDebugGroup(); - } + explicit GXScopedDebugGroup(const char* text); + ~GXScopedDebugGroup(); }; #define GX_AND_TRACY_SCOPED(name) GXScopedDebugGroup scope(name); ZoneScopedN(name); diff --git a/include/dusk/hook.hpp b/include/dusk/hook.hpp new file mode 100644 index 0000000000..eac28a7c4e --- /dev/null +++ b/include/dusk/hook.hpp @@ -0,0 +1,84 @@ +#pragma once +#include +#include +#include +#include "dusk/mod_api.h" + +namespace dusk { + +inline DuskModAPI* g_api = nullptr; +inline void init(DuskModAPI* api) { g_api = api; } + +template +T arg(void* args_raw, int n) noexcept { + void** a = static_cast(args_raw); + return *static_cast>>(a[n]); +} + +template +std::remove_reference_t& argRef(void* args_raw, int n) noexcept { + void** a = static_cast(args_raw); + return *static_cast>>(a[n]); +} + +template +void* mfpAddr(F fn) noexcept { + void* p = nullptr; + static_assert(sizeof(fn) >= sizeof(void*), "unexpected MFP size"); + std::memcpy(&p, &fn, sizeof(void*)); + return p; +} + +template +struct HookEntryBase { + static inline Orig g_orig = nullptr; + + static R trampoline(Self self, A... args) { + void* ptrs[] = {static_cast(std::addressof(self)), static_cast(std::addressof(args))...}; + const bool cancel = g_api->hook_dispatch_pre(mfpAddr(MFP), static_cast(ptrs)); + if constexpr (std::is_void_v) { + if (!cancel) g_orig(self, args...); + g_api->hook_dispatch_post(mfpAddr(MFP), static_cast(ptrs)); + } else { + R result{}; + if (!cancel) result = g_orig(self, args...); + g_api->hook_dispatch_post(mfpAddr(MFP), static_cast(ptrs)); + return result; + } + } +}; + +template +struct HookEntry; + +template +struct HookEntry : HookEntryBase {}; + +template +struct HookEntry : HookEntryBase {}; + +template +void hookAddPre(int32_t (*fn)(void* args)) { + using E = HookEntry; + g_api->hook_install(mfpAddr(MFP), reinterpret_cast(E::trampoline), + reinterpret_cast(&E::g_orig)); + g_api->hook_pre(mfpAddr(MFP), fn); +} + +template +void hookAddPost(void (*fn)(void* args)) { + using E = HookEntry; + g_api->hook_install(mfpAddr(MFP), reinterpret_cast(E::trampoline), + reinterpret_cast(&E::g_orig)); + g_api->hook_post(mfpAddr(MFP), fn); +} + +template +void hookSetReplace(void (*fn)(void* args)) { + using E = HookEntry; + g_api->hook_install(mfpAddr(MFP), reinterpret_cast(E::trampoline), + reinterpret_cast(&E::g_orig)); + g_api->hook_replace(mfpAddr(MFP), fn); +} + +} // namespace dusk diff --git a/include/dusk/hook_system.hpp b/include/dusk/hook_system.hpp new file mode 100644 index 0000000000..d2d3752f1d --- /dev/null +++ b/include/dusk/hook_system.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace dusk { + +void hookInstallByAddr(void* fn_addr, void* tramp_fn, void** orig_store); + +void hookRegisterPre (void* fn_addr, void* mod, int32_t (*fn)(void* args)); +void hookRegisterPost(void* fn_addr, void* mod, void (*fn)(void* args)); +void hookSetReplace (void* fn_addr, void* mod, void (*fn)(void* args)); + +bool hookDispatchPre (void* fn_addr, void* args); +void hookDispatchPost(void* fn_addr, void* args); + +void hookClearMod(void* mod); + +} // namespace dusk diff --git a/include/dusk/mod_api.h b/include/dusk/mod_api.h new file mode 100644 index 0000000000..ba80f7f4b6 --- /dev/null +++ b/include/dusk/mod_api.h @@ -0,0 +1,50 @@ +#ifndef DUSK_MOD_API_H +#define DUSK_MOD_API_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define DUSK_MOD_API_VERSION 1 + +#if defined(_WIN32) +# define DUSK_MOD_EXPORT __declspec(dllexport) +#else +# define DUSK_MOD_EXPORT __attribute__((visibility("default"))) +#endif + +typedef struct DuskModAPI { + uint32_t api_version; + const char* mod_dir; + + void (*log_info) (const char* fmt, ...); + void (*log_warn) (const char* fmt, ...); + void (*log_error)(const char* fmt, ...); + + void* (*load_resource)(const char* relative_path, size_t* out_size); + void (*free_resource)(void* data); + + void (*register_tab_content)(void (*draw_fn)(void* userdata), void* userdata); + void (*register_menu_item) (void (*draw_fn)(void* userdata), void* userdata); + + void (*hook_install)(void* fn_addr, void* tramp_fn, void** orig_store); + void (*hook_pre) (void* fn_addr, int32_t (*fn)(void* args)); + void (*hook_post) (void* fn_addr, void (*fn)(void* args)); + void (*hook_replace)(void* fn_addr, void (*fn)(void* args)); + + bool (*hook_dispatch_pre) (void* fn_addr, void* args); + void (*hook_dispatch_post)(void* fn_addr, void* args); +} DuskModAPI; + +void mod_init(DuskModAPI* api); +void mod_tick(DuskModAPI* api); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp new file mode 100644 index 0000000000..a5a73504b9 --- /dev/null +++ b/include/dusk/mod_loader.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include + +#include "dusk/mod_api.h" + +namespace dusk { + +struct ModDrawCallback { + void (*draw_fn)(void* userdata); + void* userdata; +}; + +struct LoadedMod { + std::string name; + std::string version; + std::string author; + std::string description; + std::string mod_path; + std::string dir; + + void* handle = nullptr; + bool active = false; + + using FnInit = void (*)(DuskModAPI*); + using FnTick = void (*)(DuskModAPI*); + using FnCleanup = void (*)(DuskModAPI*); + using FnSetImguiCtx = void (*)(void*); + + FnInit fn_init = nullptr; + FnTick fn_tick = nullptr; + FnCleanup fn_cleanup = nullptr; + FnSetImguiCtx fn_set_imgui_ctx = nullptr; + + DuskModAPI api{}; + + std::vector tab_content; + std::vector menu_items; +}; + +class ModLoader { +public: + static ModLoader& instance(); + + void setModsDir(std::filesystem::path dir) { m_modsDir = std::move(dir); } + void init(); + void tick(); + void shutdown(); + + const std::vector& mods() const { return m_mods; } + static void callDrawCallback(const LoadedMod& mod, const ModDrawCallback& cb); + +private: + std::vector m_mods; + std::filesystem::path m_modsDir = "mods"; + bool m_initialized = false; + + void tryLoadDusk(const std::filesystem::path& modPath); + void buildAPI(LoadedMod& mod); +}; + +} // namespace dusk diff --git a/include/global.h b/include/global.h index eda2a610ec..c1a06c49b1 100644 --- a/include/global.h +++ b/include/global.h @@ -104,6 +104,19 @@ inline int __builtin_clz(unsigned int v) { #endif +// Data symbols in dusk.dll need dllimport on the mod side +// DUSK_BUILDING_GAME is defined for the game build so the same headers work in both. +#if defined(TARGET_PC) && defined(_WIN32) && !defined(DUSK_BUILDING_GAME) +# define DUSK_GAME_EXTERN extern __declspec(dllimport) +# define DUSK_GAME_DATA __declspec(dllimport) +#elif defined(TARGET_PC) && defined(_WIN32) && defined(DUSK_BUILDING_GAME) +# define DUSK_GAME_EXTERN extern +# define DUSK_GAME_DATA __declspec(dllexport) +#else +# define DUSK_GAME_EXTERN extern +# define DUSK_GAME_DATA +#endif + #define FAST_DIV(x, n) (x >> (n / 2)) #define SQUARE(x) ((x) * (x)) diff --git a/include/m_Do/m_Do_controller_pad.h b/include/m_Do/m_Do_controller_pad.h index e187efd17d..5818875c0f 100644 --- a/include/m_Do/m_Do_controller_pad.h +++ b/include/m_Do/m_Do_controller_pad.h @@ -4,6 +4,7 @@ #include "JSystem/JUtility/JUTGamePad.h" #include "SSystem/SComponent/c_API_controller_pad.h" #include "dusk/settings.h" +#include "global.h" // Controller Ports 1 - 4 enum { PAD_1, PAD_2, PAD_3, PAD_4 }; @@ -94,7 +95,7 @@ class mDoCPd_c { static void stopMotorWaveHard(u32 pad) { return m_gamePad[pad]->stopMotorWaveHard(); } static JUTGamePad* m_gamePad[4]; - static interface_of_controller_pad m_cpadInfo[4]; + static DUSK_GAME_DATA interface_of_controller_pad m_cpadInfo[4]; static interface_of_controller_pad m_debugCpadInfo[4]; }; diff --git a/include/m_Do/m_Do_graphic.h b/include/m_Do/m_Do_graphic.h index 5d29049520..2140aa90f7 100644 --- a/include/m_Do/m_Do_graphic.h +++ b/include/m_Do/m_Do_graphic.h @@ -371,6 +371,7 @@ class mDoGph_gInf_c { static int m_height; static f32 m_heightF; static f32 m_widthF; + #endif #if TARGET_PC static f32 m_safeMinXF; @@ -380,7 +381,6 @@ class mDoGph_gInf_c { static f32 m_safeWidthF; static f32 m_safeHeightF; #endif - #endif }; #endif /* M_DO_M_DO_GRAPHIC_H */ diff --git a/src/DynamicLink.cpp b/src/DynamicLink.cpp index dae7da52de..bf7ad3dfeb 100644 --- a/src/DynamicLink.cpp +++ b/src/DynamicLink.cpp @@ -142,6 +142,12 @@ DynamicModuleControl::DynamicModuleControl(char const* name) { } #endif +#if TARGET_PC +// dump() is declared but its definition is inside #if !TARGET_PC above; stub it out. +void DynamicModuleControlBase::dump() {} +void DynamicModuleControlBase::dump(char*) {} +#endif + u32 DynamicModuleControl::sAllocBytes; JKRArchive* DynamicModuleControl::sArchive; diff --git a/src/dusk/gx_helper.cpp b/src/dusk/gx_helper.cpp new file mode 100644 index 0000000000..9f9e321d15 --- /dev/null +++ b/src/dusk/gx_helper.cpp @@ -0,0 +1,13 @@ +#include "dusk/gx_helper.h" + +#ifdef TARGET_PC +GXTexObjRAII::~GXTexObjRAII() { GXDestroyTexObj(this); } +void GXTexObjRAII::reset() { GXDestroyTexObj(this); } +#endif + +GXScopedDebugGroup::GXScopedDebugGroup(const char* text) { + GXPushDebugGroup(text); +} +GXScopedDebugGroup::~GXScopedDebugGroup() { + GXPopDebugGroup(); +} diff --git a/src/dusk/hook_system.cpp b/src/dusk/hook_system.cpp new file mode 100644 index 0000000000..6d0f93df4c --- /dev/null +++ b/src/dusk/hook_system.cpp @@ -0,0 +1,149 @@ +#include "dusk/hook_system.hpp" +#include "dusk/logging.h" + +#include +#include +#include +#include +#include + +namespace dusk { + +extern void* g_dusk_hook_current_mod; + +struct PreHookFn { + void* mod; + int32_t (*fn)(void* args); +}; +struct VoidHookFn { + void* mod; + void (*fn)(void* args); +}; + +struct HookSlot { + std::vector pre; + VoidHookFn replace = {}; + std::vector post; +}; + +static std::unordered_map& registry() { + static std::unordered_map s; + return s; +} +static std::unordered_map& installed() { + static std::unordered_map s; + return s; +} + +// Follow E9/FF25 chains to skip MSVC incremental-link and import stubs +static void* resolveImportThunk(void* addr) { +#if _WIN32 + for (int i = 0; i < 8; ++i) { + const auto* p = static_cast(addr); + if (p[0] == 0xFF && p[1] == 0x25) { + int32_t offset; + std::memcpy(&offset, p + 2, 4); + addr = const_cast(*reinterpret_cast(p + 6 + offset)); + break; + } else if (p[0] == 0xE9) { + int32_t offset; + std::memcpy(&offset, p + 1, 4); + addr = const_cast(p) + 5 + offset; + } else + break; + } +#endif + return addr; +} + +struct ModGuard { + void* prev; + explicit ModGuard(void* mod) : prev(g_dusk_hook_current_mod) { g_dusk_hook_current_mod = mod; } + ~ModGuard() { g_dusk_hook_current_mod = prev; } +}; + +void hookInstallByAddr(void* fn_addr, void* tramp_fn, void** orig_store) { + fn_addr = resolveImportThunk(fn_addr); + auto key = reinterpret_cast(fn_addr); + auto it = installed().find(key); + if (it != installed().end()) { + *orig_store = it->second; + return; + } + + funchook_t* fh = funchook_create(); + void* fn = fn_addr; + int prep = funchook_prepare(fh, &fn, tramp_fn); + int inst = (prep == 0) ? funchook_install(fh, 0) : -1; + if (prep != 0 || inst != 0) { + DuskLog.warn("HookSystem: funchook failed for {:p} (prepare={} install={})", fn_addr, prep, + inst); + funchook_destroy(fh); + return; + } + + funchook_destroy(fh); + installed()[key] = fn; + *orig_store = fn; +} + +bool hookDispatchPre(void* fn_addr, void* args) { + auto it = registry().find(reinterpret_cast(fn_addr)); + if (it == registry().end()) + return false; + auto& slot = it->second; + for (auto& h : slot.pre) { + ModGuard g(h.mod); + if (h.fn(args) != 0) + return true; + } + if (slot.replace.fn) { + ModGuard g(slot.replace.mod); + slot.replace.fn(args); + return true; + } + return false; +} + +void hookDispatchPost(void* fn_addr, void* args) { + auto it = registry().find(reinterpret_cast(fn_addr)); + if (it == registry().end()) + return; + for (auto& h : it->second.post) { + if (h.fn) { + ModGuard g(h.mod); + h.fn(args); + } + } +} + +void hookRegisterPre(void* fn_addr, void* mod, int32_t (*fn)(void* args)) { + registry()[reinterpret_cast(fn_addr)].pre.push_back({mod, fn}); +} + +void hookRegisterPost(void* fn_addr, void* mod, void (*fn)(void* args)) { + registry()[reinterpret_cast(fn_addr)].post.push_back({mod, fn}); +} + +void hookSetReplace(void* fn_addr, void* mod, void (*fn)(void* args)) { + auto& slot = registry()[reinterpret_cast(fn_addr)]; + if (slot.replace.fn) + DuskLog.warn("HookSystem: replace hook for {} already set — overwriting", fn_addr); + slot.replace = {mod, fn}; +} + +void hookClearMod(void* mod) { + for (auto& [addr, slot] : registry()) { + auto erase = [&](auto& v) { + v.erase( + std::remove_if(v.begin(), v.end(), [mod](const auto& h) { return h.mod == mod; }), + v.end()); + }; + erase(slot.pre); + erase(slot.post); + if (slot.replace.mod == mod) + slot.replace = {}; + } +} + +} // namespace dusk diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index f5e25277c7..5785ee0fdb 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -322,6 +322,7 @@ namespace dusk { if (showMenu && ImGui::BeginMainMenuBar()) { m_menuGame.draw(); m_menuEnhancements.draw(); + m_menuMods.draw(); m_menuTools.draw(); const auto fpsLabel = @@ -365,6 +366,7 @@ namespace dusk { m_menuTools.ShowPlayerInfo(); m_menuTools.ShowAudioDebug(); m_menuTools.ShowSaveEditor(); + m_menuMods.showModsWindow(); } m_menuTools.ShowStateShare(); DuskDebugPad(); // temporary, remove later diff --git a/src/dusk/imgui/ImGuiConsole.hpp b/src/dusk/imgui/ImGuiConsole.hpp index 70c5184d0d..6400b274c1 100644 --- a/src/dusk/imgui/ImGuiConsole.hpp +++ b/src/dusk/imgui/ImGuiConsole.hpp @@ -11,6 +11,7 @@ #include "ImGuiFirstRunPreset.hpp" #include "ImGuiMenuEnhancements.hpp" #include "ImGuiMenuGame.hpp" +#include "ImGuiMenuMods.hpp" #include "ImGuiMenuTools.hpp" #include "ImGuiPreLaunchWindow.hpp" #include "imgui.h" @@ -53,6 +54,7 @@ class ImGuiConsole { ImGuiFirstRunPreset m_firstRunPreset; ImGuiMenuGame m_menuGame; ImGuiMenuEnhancements m_menuEnhancements; + ImGuiMenuMods m_menuMods; ImGuiPreLaunchWindow m_preLaunchWindow; // Keep always last diff --git a/src/dusk/imgui/ImGuiMenuMods.cpp b/src/dusk/imgui/ImGuiMenuMods.cpp new file mode 100644 index 0000000000..c7199fbf13 --- /dev/null +++ b/src/dusk/imgui/ImGuiMenuMods.cpp @@ -0,0 +1,78 @@ +#include "ImGuiMenuMods.hpp" + +#include "ImGuiConsole.hpp" +#include "dusk/mod_loader.hpp" +#include "imgui.h" + +namespace dusk { + +void ImGuiMenuMods::draw() { + const auto& mods = ModLoader::instance().mods(); + if (mods.empty()) return; + + if (ImGui::BeginMenu("Mods")) { + if (ImGui::MenuItem("Mod Manager", nullptr, m_showWindow)) { + m_showWindow = !m_showWindow; + } + + for (const auto& mod : mods) { + if (mod.menu_items.empty()) continue; + ImGui::Separator(); + if (ImGui::BeginMenu(mod.name.c_str())) { + for (const auto& item : mod.menu_items) { + ModLoader::callDrawCallback(mod, item); + } + ImGui::EndMenu(); + } + } + + ImGui::EndMenu(); + } +} + +void ImGuiMenuMods::showModsWindow() { + if (!m_showWindow) return; + + ImGui::SetNextWindowSize(ImVec2(520, 420), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Mod Manager", &m_showWindow)) { + ImGui::End(); + return; + } + + const auto& mods = ModLoader::instance().mods(); + if (mods.empty()) { + ImGuiTextCenter("No mods loaded."); + ImGui::End(); + return; + } + + if (ImGui::BeginTabBar("##ModsOuter")) { + for (const auto& mod : mods) { + const std::string tabLabel = mod.name + (mod.active ? "" : " [disabled]"); + + if (ImGui::BeginTabItem(tabLabel.c_str())) { + ImGui::Text("Version: %s", mod.version.c_str()); + ImGui::Text("Author: %s", mod.author.c_str()); + ImGui::Text("Status: %s", mod.active ? "Active" : "Disabled"); + ImGui::Text("Path: %s", mod.mod_path.c_str()); + + if (!mod.description.empty()) { + ImGui::Separator(); + ImGui::TextWrapped("%s", mod.description.c_str()); + } + + for (const auto& cb : mod.tab_content) { + ImGui::Separator(); + ModLoader::callDrawCallback(mod, cb); + } + + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + + ImGui::End(); +} + +} // namespace dusk diff --git a/src/dusk/imgui/ImGuiMenuMods.hpp b/src/dusk/imgui/ImGuiMenuMods.hpp new file mode 100644 index 0000000000..09a635fe7f --- /dev/null +++ b/src/dusk/imgui/ImGuiMenuMods.hpp @@ -0,0 +1,15 @@ +#pragma once + +namespace dusk { + +class ImGuiMenuMods { +public: + void draw(); + + void showModsWindow(); + +private: + bool m_showWindow = false; +}; + +} // namespace dusk diff --git a/src/dusk/launcher_win32.cpp b/src/dusk/launcher_win32.cpp new file mode 100644 index 0000000000..c434febd78 --- /dev/null +++ b/src/dusk/launcher_win32.cpp @@ -0,0 +1,15 @@ +/** + * Thin Windows launcher EXE. The game lives in dusk.dll, this just forwards + * the Windows entry point to it. Keeping the game as a DLL lets mod .dll + * files link against dusk.lib and resolve all game symbols at load time. + */ + +#define WIN32_LEAN_AND_MEAN +#include + +// see src/dusk/main.cpp +extern "C" int WINAPI dusk_WinMain(HINSTANCE hInst, HINSTANCE hPrev, PWSTR cmd, int show); + +int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE hPrev, PWSTR cmd, int show) { + return dusk_WinMain(hInst, hPrev, cmd, show); +} diff --git a/src/dusk/main.cpp b/src/dusk/main.cpp index 22cd5a9fc6..79244cb203 100644 --- a/src/dusk/main.cpp +++ b/src/dusk/main.cpp @@ -1,5 +1,5 @@ #if _WIN32 -#define WINDOWS_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN #include #include #endif @@ -120,7 +120,8 @@ int main(int argc, char* argv[]) { } #if _WIN32 -int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) { +// Entry point called by the launcher executable. +extern "C" int WINAPI dusk_WinMain(HINSTANCE, HINSTANCE, PWSTR, int) { return RunWindowsGuiEntryPoint(); } #endif diff --git a/src/dusk/mod_loader.cpp b/src/dusk/mod_loader.cpp new file mode 100644 index 0000000000..d81163b4ee --- /dev/null +++ b/src/dusk/mod_loader.cpp @@ -0,0 +1,377 @@ +#include "dusk/mod_loader.hpp" +#include "dusk/hook_system.hpp" +#include "dusk/logging.h" + +#include +#include +#include +#include + +#include "imgui.h" +#include "miniz.h" +#include "nlohmann/json.hpp" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include + +static void* pl_dlopen(const std::filesystem::path& p) { + return LoadLibraryW(p.wstring().c_str()); +} +static void* pl_dlsym(void* h, const char* name) { + return reinterpret_cast(GetProcAddress(static_cast(h), name)); +} +static void pl_dlclose(void* h) { + FreeLibrary(static_cast(h)); +} +static std::string pl_dlerror() { + char buf[256]{}; + FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, + GetLastError(), 0, buf, sizeof(buf), nullptr); + std::string s = buf; + while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) + s.pop_back(); + return s; +} +static constexpr const char* k_libExt = ".dll"; + +#else +#include +static void* pl_dlopen(const std::filesystem::path& p) { + return dlopen(p.c_str(), RTLD_LAZY | RTLD_LOCAL); +} +static void* pl_dlsym(void* h, const char* name) { + return dlsym(h, name); +} +static void pl_dlclose(void* h) { + dlclose(h); +} +static std::string pl_dlerror() { + const char* e = dlerror(); + return e ? e : "(unknown error)"; +} +#if defined(__APPLE__) +static constexpr const char* k_libExt = ".dylib"; +#else +static constexpr const char* k_libExt = ".so"; +#endif +#endif + +static dusk::LoadedMod* g_currentMod = nullptr; + +namespace dusk { +void* g_dusk_hook_current_mod = nullptr; +} + +struct ModGuard { + explicit ModGuard(dusk::LoadedMod* m) { + g_currentMod = m; + dusk::g_dusk_hook_current_mod = m; + } + ~ModGuard() { + g_currentMod = nullptr; + dusk::g_dusk_hook_current_mod = nullptr; + } +}; + +static const char* modName() { + return g_currentMod ? g_currentMod->name.c_str() : "mod"; +} + +static void cb_log_info(const char* fmt, ...) { + va_list ap, ap2; va_start(ap, fmt); va_copy(ap2, ap); + std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); va_end(ap2); + vsnprintf(s.data(), s.size() + 1, fmt, ap); va_end(ap); + DuskLog.info("[{}] {}", modName(), s); +} + +static void cb_log_warn(const char* fmt, ...) { + va_list ap, ap2; va_start(ap, fmt); va_copy(ap2, ap); + std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); va_end(ap2); + vsnprintf(s.data(), s.size() + 1, fmt, ap); va_end(ap); + DuskLog.warn("[{}] {}", modName(), s); +} + +static void cb_log_error(const char* fmt, ...) { + va_list ap, ap2; va_start(ap, fmt); va_copy(ap2, ap); + std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); va_end(ap2); + vsnprintf(s.data(), s.size() + 1, fmt, ap); va_end(ap); + DuskLog.error("[{}] {}", modName(), s); +} + +static void* cb_load_resource(const char* relative_path, size_t* out_size) { + if (out_size) + *out_size = 0; + if (!g_currentMod || !relative_path) + return nullptr; + + mz_zip_archive zip{}; + if (!mz_zip_reader_init_file(&zip, g_currentMod->mod_path.c_str(), 0)) { + DuskLog.warn("[{}] load_resource: could not open {}", g_currentMod->name, + g_currentMod->mod_path); + return nullptr; + } + std::string entry = std::string("res/") + relative_path; + size_t sz = 0; + void* data = mz_zip_reader_extract_file_to_heap(&zip, entry.c_str(), &sz, 0); + mz_zip_reader_end(&zip); + if (!data) { + DuskLog.warn("[{}] load_resource: '{}' not found in zip", g_currentMod->name, entry); + return nullptr; + } + if (out_size) + *out_size = sz; + return data; +} + +static void cb_free_resource(void* data) { + mz_free(data); +} + +static void cb_register_tab_content(void (*draw_fn)(void*), void* userdata) { + if (g_currentMod && draw_fn) + g_currentMod->tab_content.push_back({draw_fn, userdata}); +} + +static void cb_register_menu_item(void (*draw_fn)(void*), void* userdata) { + if (g_currentMod && draw_fn) + g_currentMod->menu_items.push_back({draw_fn, userdata}); +} + +static void api_hook_pre(void* addr, int32_t (*fn)(void* args)) { + dusk::hookRegisterPre(addr, g_currentMod, fn); +} + +static void api_hook_post(void* addr, void (*fn)(void* args)) { + dusk::hookRegisterPost(addr, g_currentMod, fn); +} + +static void api_hook_replace(void* addr, void (*fn)(void* args)) { + dusk::hookSetReplace(addr, g_currentMod, fn); +} + +namespace dusk { + +ModLoader& ModLoader::instance() { + static ModLoader inst; + return inst; +} + +void ModLoader::buildAPI(LoadedMod& mod) { + mod.api.api_version = DUSK_MOD_API_VERSION; + mod.api.mod_dir = mod.dir.c_str(); + mod.api.log_info = cb_log_info; + mod.api.log_warn = cb_log_warn; + mod.api.log_error = cb_log_error; + mod.api.load_resource = cb_load_resource; + mod.api.free_resource = cb_free_resource; + mod.api.register_tab_content = cb_register_tab_content; + mod.api.register_menu_item = cb_register_menu_item; + mod.api.hook_install = hookInstallByAddr; + mod.api.hook_pre = api_hook_pre; + mod.api.hook_post = api_hook_post; + mod.api.hook_replace = api_hook_replace; + mod.api.hook_dispatch_pre = hookDispatchPre; + mod.api.hook_dispatch_post = hookDispatchPost; +} + +void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { + namespace fs = std::filesystem; + + std::string metaName, metaVersion, metaAuthor, metaDescription; + { + mz_zip_archive zip{}; + if (mz_zip_reader_init_file(&zip, modPath.string().c_str(), 0)) { + size_t jsonSize = 0; + void* jsonData = mz_zip_reader_extract_file_to_heap(&zip, "mod.json", &jsonSize, 0); + mz_zip_reader_end(&zip); + if (jsonData) { + try { + std::string jsonStr(static_cast(jsonData), jsonSize); + mz_free(jsonData); + jsonData = nullptr; + auto j = nlohmann::json::parse(jsonStr); + metaName = j.value("name", ""); + metaVersion = j.value("version", ""); + metaAuthor = j.value("author", ""); + metaDescription = j.value("description", ""); + } catch (const std::exception& e) { + mz_free(jsonData); + DuskLog.warn("ModLoader: bad mod.json in {}: {}", modPath.filename().string(), + e.what()); + } + } + } + } + + mz_zip_archive zip{}; + if (!mz_zip_reader_init_file(&zip, modPath.string().c_str(), 0)) { + DuskLog.error("ModLoader: failed to open {}", modPath.filename().string()); + return; + } + + std::string dllEntry; + for (mz_uint i = 0, n = mz_zip_reader_get_num_files(&zip); i < n; ++i) { + mz_zip_archive_file_stat stat{}; + if (!mz_zip_reader_file_stat(&zip, i, &stat)) + continue; + if (mz_zip_reader_is_file_a_directory(&zip, i)) + continue; + if (fs::path(stat.m_filename).extension() == k_libExt) { + dllEntry = stat.m_filename; + break; + } + } + + if (dllEntry.empty()) { + mz_zip_reader_end(&zip); + DuskLog.warn("ModLoader: no *{} found in {} — skipping", k_libExt, + modPath.filename().string()); + return; + } + + const fs::path cacheDir = fs::path("mods") / ".cache" / modPath.stem(); + std::error_code ec; + fs::create_directories(cacheDir, ec); + + const fs::path dllCachePath = cacheDir / fs::path(dllEntry).filename(); + if (!mz_zip_reader_extract_file_to_file(&zip, dllEntry.c_str(), dllCachePath.string().c_str(), + 0)) + { + mz_zip_reader_end(&zip); + DuskLog.error("ModLoader: failed to extract {} from {}", dllEntry, + modPath.filename().string()); + return; + } + mz_zip_reader_end(&zip); + + void* handle = pl_dlopen(dllCachePath); + if (!handle) { + DuskLog.error("ModLoader: failed to open {}: {}", dllCachePath.string(), pl_dlerror()); + return; + } + + LoadedMod mod; + mod.mod_path = fs::absolute(modPath).string(); + mod.dir = fs::absolute(cacheDir).string(); + mod.handle = handle; + mod.fn_init = reinterpret_cast(pl_dlsym(handle, "mod_init")); + mod.fn_tick = reinterpret_cast(pl_dlsym(handle, "mod_tick")); + mod.fn_cleanup = reinterpret_cast(pl_dlsym(handle, "mod_cleanup")); + mod.fn_set_imgui_ctx = + reinterpret_cast(pl_dlsym(handle, "dusk_mod_set_imgui_ctx")); + + if (!mod.fn_init || !mod.fn_tick) { + DuskLog.error("ModLoader: {} missing mod_init or mod_tick — skipping", + fs::path(dllEntry).filename().string()); + pl_dlclose(handle); + return; + } + + mod.name = metaName.empty() ? modPath.stem().string() : metaName; + mod.version = metaVersion.empty() ? "?" : metaVersion; + mod.author = metaAuthor.empty() ? "unknown" : metaAuthor; + mod.description = metaDescription; + + m_mods.push_back(std::move(mod)); + DuskLog.info("ModLoader: found '{}' v{} by {} ({})", m_mods.back().name, m_mods.back().version, + m_mods.back().author, modPath.filename().string()); +} + +void ModLoader::init() { + if (m_initialized) + return; + m_initialized = true; + + namespace fs = std::filesystem; + if (!fs::exists(m_modsDir)) { + DuskLog.info("ModLoader: mods directory '{}' not found — mod loading skipped", + m_modsDir.string()); + return; + } + + std::error_code ec; + std::vector entries; + for (auto& e : fs::directory_iterator(m_modsDir, ec)) + 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()); + + if (m_mods.empty()) { + DuskLog.info("ModLoader: no mods found"); + return; + } + + DuskLog.info("ModLoader: initializing {} mod(s)...", m_mods.size()); + for (auto& mod : m_mods) + buildAPI(mod); + + for (auto& mod : m_mods) { + ModGuard guard(&mod); + try { + mod.fn_init(&mod.api); + mod.active = true; + DuskLog.info("ModLoader: '{}' initialized", mod.name); + } catch (const std::exception& e) { + DuskLog.error("ModLoader: exception in {}.mod_init(): {}", mod.name, e.what()); + } catch (...) { + DuskLog.error("ModLoader: unknown exception in {}.mod_init()", mod.name); + } + } + + auto active = + std::count_if(m_mods.begin(), m_mods.end(), [](const LoadedMod& m) { return m.active; }); + DuskLog.info("ModLoader: {}/{} mod(s) active", active, m_mods.size()); +} + +void ModLoader::tick() { + for (auto& mod : m_mods) { + if (!mod.active) + continue; + ModGuard guard(&mod); + try { + mod.fn_tick(&mod.api); + } catch (const std::exception& e) { + DuskLog.error("ModLoader: exception in {}.mod_tick(): {} — disabling", mod.name, + e.what()); + mod.active = false; + } catch (...) { + DuskLog.error("ModLoader: unknown exception in {}.mod_tick() — disabling", mod.name); + mod.active = false; + } + } +} + +void ModLoader::shutdown() { + for (auto& mod : m_mods) { + hookClearMod(&mod); + if (mod.fn_cleanup) { + ModGuard guard(&mod); + try { + mod.fn_cleanup(&mod.api); + } catch (...) { + } + } + if (mod.handle) { + pl_dlclose(mod.handle); + mod.handle = nullptr; + } + } + m_mods.clear(); + DuskLog.info("ModLoader: all mods unloaded"); +} + +void ModLoader::callDrawCallback(const LoadedMod& mod, const ModDrawCallback& cb) { + if (mod.fn_set_imgui_ctx) + mod.fn_set_imgui_ctx(ImGui::GetCurrentContext()); + cb.draw_fn(cb.userdata); +} + +} // namespace dusk diff --git a/src/f_ap/f_ap_game.cpp b/src/f_ap/f_ap_game.cpp index 46b277f421..6d9c5a22bd 100644 --- a/src/f_ap/f_ap_game.cpp +++ b/src/f_ap/f_ap_game.cpp @@ -16,6 +16,7 @@ #include "d/d_tresure.h" #include "dusk/frame_interpolation.h" #include "dusk/logging.h" +#include "dusk/mod_loader.hpp" #include "f_op/f_op_camera_mng.h" #include "f_op/f_op_draw_tag.h" #include "f_op/f_op_overlap_mng.h" @@ -812,6 +813,7 @@ void fapGm_Execute() { fpcM_ManagementFunc(NULL, fapGm_After); #endif cCt_Counter(0); + dusk::ModLoader::instance().tick(); } fapGm_HIO_c g_HIO; diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index cde7014411..b560169445 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -53,6 +53,7 @@ #include "dusk/game_clock.h" #include "dusk/gyro.h" #include "dusk/imgui/ImGuiEngine.hpp" +#include "dusk/mod_loader.hpp" #include "dusk/logging.h" #include "dusk/main.h" #include "dusk/imgui/ImGuiConsole.hpp" @@ -190,6 +191,8 @@ void main01(void) { OSReport("Calling cDyl_InitAsync()...\n"); cDyl_InitAsync(); + dusk::ModLoader::instance().init(); + g_mDoAud_audioHeap = JKRCreateSolidHeap(audioHeapSize, JKRGetCurrentHeap(), false); JKRHEAP_NAME(g_mDoAud_audioHeap, "g_mDoAud_audioHeap"); @@ -292,6 +295,7 @@ void main01(void) { } while (dusk::IsRunning); exit:; + dusk::ModLoader::instance().shutdown(); } static bool IsBackendAvailable(AuroraBackend backend) { @@ -479,6 +483,7 @@ int game_main(int argc, char* argv[]) { ("h,help", "Print usage") ("console", "Show the Windows console window for logs", cxxopts::value()->default_value("false")->implicit_value("true")) ("dvd", "Path to DVD image file", cxxopts::value()) + ("mods", "Path to mods directory", cxxopts::value()->default_value("mods")) ("backend", "Graphics API backend to use (auto, d3d12, metal, vulkan, null)", cxxopts::value()) ("cvar", "Override configuration variables without modifying config", cxxopts::value>()); @@ -596,9 +601,8 @@ int game_main(int argc, char* argv[]) { mDoMain::developmentMode = 1; // Force Dev Mode for Debugging mDoDvdThd::SyncWidthSound = false; + dusk::ModLoader::instance().setModsDir(parsed_arg_options["mods"].as()); OSReport("Starting main01 (Game Loop)...\n"); - - main01(); dusk::ShutdownCrashReporting(); diff --git a/tools/mod_template/CMakeLists.txt b/tools/mod_template/CMakeLists.txt new file mode 100644 index 0000000000..427b6c4ba7 --- /dev/null +++ b/tools/mod_template/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.25) +project(my_mod CXX) + +# Path to the dusk source root. +# Set this to your dusk submodule: +# set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dusk") +set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to dusk source root") + +add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL) + +# Output .dusk packages next to the build directory by default. +set(DUSK_MODS_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/mods" CACHE PATH "Directory to write .dusk packages into") + +add_dusk_mod(template_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res +) diff --git a/tools/mod_template/mod.json b/tools/mod_template/mod.json new file mode 100644 index 0000000000..0ebe9f5176 --- /dev/null +++ b/tools/mod_template/mod.json @@ -0,0 +1,6 @@ +{ + "name": "Template Mod", + "version": "1.0.0", + "author": "Maddie", + "description": "An example Dusk mod" +} diff --git a/tools/mod_template/res/text.txt b/tools/mod_template/res/text.txt new file mode 100644 index 0000000000..7753e7b422 --- /dev/null +++ b/tools/mod_template/res/text.txt @@ -0,0 +1,2 @@ +This text has been loaded from the mods resources! +Press R to rotate Link! diff --git a/tools/mod_template/src/mod.cpp b/tools/mod_template/src/mod.cpp new file mode 100644 index 0000000000..34b820cdcc --- /dev/null +++ b/tools/mod_template/src/mod.cpp @@ -0,0 +1,80 @@ +#include "d/actor/d_a_alink.h" +#include "dusk/hook.hpp" +#include "dusk/mod_api.h" +#include "imgui.h" +#include "m_Do/m_Do_controller_pad.h" + +#include + +static int TickCount = 0; +static std::string TextContents; + +static int32_t on_posMove_pre(void* args) { + if (!mDoCPd_c::getHoldR(PAD_1)) + return 0; + daAlink_c* link = dusk::arg(args, 0); + link->shape_angle.y -= 2048; + dusk::g_api->log_info("ROTATING %d", link->shape_angle.y); + return 0; +} + +static void DrawTabContent(void*) { + daAlink_c* link = daAlink_getAlinkActorClass(); + if (link) { + ImGui::Text("Y angle: %d", (int)link->shape_angle.y); + ImGui::Spacing(); + if (ImGui::Button("Reset rotation")) { + link->shape_angle.y = 0; + } + } + if (!TextContents.empty()) { + ImGui::Separator(); + ImGui::TextUnformatted(TextContents.c_str()); + } +} + +static void DrawMenuItem(void*) { + if (ImGui::MenuItem("Reset rotation")) { + daAlink_c* link = daAlink_getAlinkActorClass(); + if (link) { + link->shape_angle.y = 0; + } + } +} + +extern "C" { + +void dusk_mod_set_imgui_ctx(void* ctx) { + ImGui::SetCurrentContext(static_cast(ctx)); +} + +void mod_init(DuskModAPI* api) { + api->log_info("Test Mod initializing..."); + + dusk::init(api); + dusk::hookAddPre<&daAlink_c::posMove>(on_posMove_pre); + + size_t size = 0; + void* data = api->load_resource("text.txt", &size); + if (data) { + TextContents.assign(static_cast(data), size); + api->free_resource(data); + api->log_info("Loaded text.txt (%zu bytes)", size); + } else { + api->log_warn("Failed to load text.txt"); + } + + api->register_tab_content(DrawTabContent, nullptr); + api->register_menu_item(DrawMenuItem, nullptr); + api->log_info("Test Mod ready. Mod folder: %s", api->mod_dir); +} + +void mod_tick(DuskModAPI* api) { + ++TickCount; +} + +void mod_cleanup(DuskModAPI* api) { + api->log_info("Test Mod unloading after %d ticks.", TickCount); +} + +} From 507616015e41a918749588f8955366a291429fec Mon Sep 17 00:00:00 2001 From: madeline Date: Mon, 20 Apr 2026 04:25:17 -0700 Subject: [PATCH 02/48] fix CI cmake --- CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 016066d1da..83e633252f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -235,6 +235,8 @@ FetchContent_Declare(funchook ) set(FUNCHOOK_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(FUNCHOOK_BUILD_SHARED OFF CACHE BOOL "" FORCE) +# funchook bundles capstone which uses cmake_minimum_required < 3.5 +set(CMAKE_POLICY_VERSION_MINIMUM 3.5) FetchContent_MakeAvailable(cxxopts json miniz funchook) if (DUSK_ENABLE_SENTRY_NATIVE) From 3597cb1bd669e6a425d43a5eaecc9523b85d3b71 Mon Sep 17 00:00:00 2001 From: madeline Date: Mon, 20 Apr 2026 05:22:03 -0700 Subject: [PATCH 03/48] incredibly scuffed funchook patching --- CMakeLists.txt | 12 ++++++++++-- cmake/fix_capstone_policy.cmake | 7 +++++++ cmake/patch_funchook.cmake | 11 +++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 cmake/fix_capstone_policy.cmake create mode 100644 cmake/patch_funchook.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 83e633252f..92d5b92441 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -215,28 +215,36 @@ FetchContent_Declare(cxxopts URL_HASH SHA256=3bfc70542c521d4b55a46429d808178916a579b28d048bd8c727ee76c39e2072 DOWNLOAD_EXTRACT_TIMESTAMP TRUE ) + message(STATUS "dusk: Fetching nlohmann/json") FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa DOWNLOAD_EXTRACT_TIMESTAMP TRUE ) + message(STATUS "dusk: Fetching miniz") FetchContent_Declare(miniz URL https://github.com/richgel999/miniz/releases/download/3.0.2/miniz-3.0.2.zip DOWNLOAD_EXTRACT_TIMESTAMP TRUE ) + message(STATUS "dusk: Fetching funchook") +# cmake/patch_funchook.cmake patches funchook's cmake/capstone.cmake.in to inject a +# PATCH_COMMAND into capstone's inner ExternalProject. That PATCH_COMMAND runs +# cmake/fix_capstone_policy.cmake after capstone is cloned, which removes the +# cmake_policy(SET CMP0048 OLD) line that CMake >= 3.27 rejects. +# This is incredibly scuffed and we should probably think of a better way to do this +set(CAPSTONE_FIX_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/cmake/fix_capstone_policy.cmake") FetchContent_Declare(funchook GIT_REPOSITORY https://github.com/kubo/funchook.git GIT_TAG v1.1.3 GIT_SHALLOW TRUE GIT_PROGRESS TRUE + PATCH_COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR= -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/patch_funchook.cmake ) set(FUNCHOOK_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(FUNCHOOK_BUILD_SHARED OFF CACHE BOOL "" FORCE) -# funchook bundles capstone which uses cmake_minimum_required < 3.5 -set(CMAKE_POLICY_VERSION_MINIMUM 3.5) FetchContent_MakeAvailable(cxxopts json miniz funchook) if (DUSK_ENABLE_SENTRY_NATIVE) diff --git a/cmake/fix_capstone_policy.cmake b/cmake/fix_capstone_policy.cmake new file mode 100644 index 0000000000..0ff25f82a1 --- /dev/null +++ b/cmake/fix_capstone_policy.cmake @@ -0,0 +1,7 @@ +# Removes cmake_policy(SET CMP0048 OLD) from capstone 4.0.2 CMakeLists.txt +file(READ "${DIR}/CMakeLists.txt" _content) +string(REPLACE + "cmake_policy(SET CMP0048 OLD)" + "# cmake_policy(SET CMP0048 OLD)" + _content "${_content}") +file(WRITE "${DIR}/CMakeLists.txt" "${_content}") diff --git a/cmake/patch_funchook.cmake b/cmake/patch_funchook.cmake new file mode 100644 index 0000000000..0026861d33 --- /dev/null +++ b/cmake/patch_funchook.cmake @@ -0,0 +1,11 @@ +file(READ "${SOURCE_DIR}/cmake/capstone.cmake.in" _content) + +# Insert PATCH_COMMAND before CONFIGURE_COMMAND in the ExternalProject_Add. +# Bracket args prevent cmake from substituting ${...} while writing this file. +string(REPLACE + " CONFIGURE_COMMAND \"\"" + [=[ PATCH_COMMAND "${CMAKE_COMMAND}" -DDIR="${CMAKE_CURRENT_BINARY_DIR}/capstone-src" -P "${CAPSTONE_FIX_SCRIPT}" + CONFIGURE_COMMAND ""]=] + _content "${_content}") + +file(WRITE "${SOURCE_DIR}/cmake/capstone.cmake.in" "${_content}") From 8356eff4cec0e3fed3120db18a22a92848c10075 Mon Sep 17 00:00:00 2001 From: madeline Date: Mon, 20 Apr 2026 05:24:46 -0700 Subject: [PATCH 04/48] fix apple ci --- cmake/patch_funchook.cmake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmake/patch_funchook.cmake b/cmake/patch_funchook.cmake index 0026861d33..c0de59160e 100644 --- a/cmake/patch_funchook.cmake +++ b/cmake/patch_funchook.cmake @@ -4,7 +4,7 @@ file(READ "${SOURCE_DIR}/cmake/capstone.cmake.in" _content) # Bracket args prevent cmake from substituting ${...} while writing this file. string(REPLACE " CONFIGURE_COMMAND \"\"" - [=[ PATCH_COMMAND "${CMAKE_COMMAND}" -DDIR="${CMAKE_CURRENT_BINARY_DIR}/capstone-src" -P "${CAPSTONE_FIX_SCRIPT}" + [=[ PATCH_COMMAND "${CMAKE_COMMAND}" -DDIR=${CMAKE_CURRENT_BINARY_DIR}/capstone-src -P "${CAPSTONE_FIX_SCRIPT}" CONFIGURE_COMMAND ""]=] _content "${_content}") From e7081f770afd7714bbb510e526ebdd5ef0ce9e65 Mon Sep 17 00:00:00 2001 From: madeline Date: Mon, 20 Apr 2026 07:52:35 -0700 Subject: [PATCH 05/48] modding.md, test mod, template mod, better imgui context --- .vscode/launch.json | 2 +- cmake/DuskModSDK.cmake | 3 +- cmake/dusk_imgui_ctx.cpp | 5 + docs/modding.md | 309 ++++++++++++++++++++++++++++++++ tools/mod_template/res/.gitkeep | 0 tools/mod_template/res/text.txt | 2 - tools/mod_template/src/mod.cpp | 66 +------ tools/mod_test/CMakeLists.txt | 13 ++ tools/mod_test/mod.json | 6 + tools/mod_test/res/hello.txt | 1 + tools/mod_test/src/mod.cpp | 154 ++++++++++++++++ 11 files changed, 493 insertions(+), 68 deletions(-) create mode 100644 cmake/dusk_imgui_ctx.cpp create mode 100644 docs/modding.md create mode 100644 tools/mod_template/res/.gitkeep delete mode 100644 tools/mod_template/res/text.txt create mode 100644 tools/mod_test/CMakeLists.txt create mode 100644 tools/mod_test/mod.json create mode 100644 tools/mod_test/res/hello.txt create mode 100644 tools/mod_test/src/mod.cpp diff --git a/.vscode/launch.json b/.vscode/launch.json index 087202b538..9d05bc1ce5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "cppvsdbg", "request": "launch", "program": "${command:cmake.launchTargetPath}", - "args": ["-l", "1", "--dvd", "${workspaceRoot}/orig/GZ2E01/GZ2E01.iso", "--console", "--mods", "${workspaceRoot}/tools/mod_template/mods"], + "args": ["-l", "1", "--dvd", "${workspaceRoot}/orig/GZ2E01/GZ2E01.iso", "--console", "--mods", "${workspaceRoot}/mods"], "MIMode": "gdb", "miDebuggerPath": "gdb", "symbolSearchPath": "${command:cmake.launchTargetPath}", diff --git a/cmake/DuskModSDK.cmake b/cmake/DuskModSDK.cmake index 809c84426c..9ae8e92b93 100644 --- a/cmake/DuskModSDK.cmake +++ b/cmake/DuskModSDK.cmake @@ -7,7 +7,8 @@ function(add_dusk_mod target_name) message(FATAL_ERROR "add_dusk_mod: MOD_JSON is required") endif() - add_library(${target_name} SHARED ${ARG_SOURCES}) + add_library(${target_name} SHARED ${ARG_SOURCES} + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/dusk_imgui_ctx.cpp") set_target_properties(${target_name} PROPERTIES PREFIX "" WINDOWS_EXPORT_ALL_SYMBOLS ON) target_compile_features(${target_name} PRIVATE cxx_std_20) target_link_libraries(${target_name} PRIVATE dusk_game_headers) diff --git a/cmake/dusk_imgui_ctx.cpp b/cmake/dusk_imgui_ctx.cpp new file mode 100644 index 0000000000..48848ffcee --- /dev/null +++ b/cmake/dusk_imgui_ctx.cpp @@ -0,0 +1,5 @@ +#include "imgui.h" + +extern "C" void dusk_mod_set_imgui_ctx(void* ctx) { + ImGui::SetCurrentContext(static_cast(ctx)); +} diff --git a/docs/modding.md b/docs/modding.md new file mode 100644 index 0000000000..6d3ec54075 --- /dev/null +++ b/docs/modding.md @@ -0,0 +1,309 @@ +# Dusk Mod API + +Mods are shared libraries packaged into a `.dusk` zip archive. The loader scans the `mods/` directory at startup, extracts each library, and calls your exports each frame. + +## Table of Contents + +1. [Getting Started](#getting-started) +2. [mod.json](#modjson) +3. [Required Exports](#required-exports) +4. [DuskModAPI Reference](#duskmodapi-reference) +5. [Logging](#logging) +6. [Loading Resources](#loading-resources) +7. [ImGui Integration](#imgui-integration) +8. [Hooking Game Functions](#hooking-game-functions) + - [Pre-hooks](#pre-hooks) + - [Post-hooks](#post-hooks) + - [Replace hooks](#replace-hooks) + - [Reading and writing arguments](#reading-and-writing-arguments) + - [Raw C hook API](#raw-c-hook-api) +9. [Full Example](#full-example) + +--- + +## Getting Started + +Fork the [mod template](../tools/mod_template/), it is a self-contained CMake project that references dusk as a subdirectory. + +``` +my_mod/ +├── CMakeLists.txt +├── mod.json +├── src/mod.cpp +└── res/ (optional bundled resources) +``` + +**CMakeLists.txt:** + +```cmake +cmake_minimum_required(VERSION 3.25) +project(my_mod CXX) + +set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dusk" CACHE PATH "Path to dusk source root") +add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL) + +add_dusk_mod(my_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res # optional +) +``` + +After building, `my_mod.dusk` is placed in `mods/` next to the project root (`DUSK_MODS_OUTPUT_DIR` cache variable). Copy it to the game's `mods/` folder and launch. + +The `.dusk` archive is a standard zip containing `mod.json`, the compiled library, and an optional `res/` tree. `add_dusk_mod()` creates it automatically. + +--- + +## mod.json + +```json +{ + "name": "My Mod", + "version": "1.0.0", + "author": "Your Name", + "description": "A short description shown in the mod manager." +} +``` + +All fields are optional but recommended. `name` falls back to the filename, `version` to `"?"`. + +--- + +## Required Exports + +```cpp +#include "dusk/mod_api.h" + +extern "C" { + +void mod_init (DuskModAPI* api); // required — called once at startup +void mod_tick (DuskModAPI* api); // required — called every frame +void mod_cleanup(DuskModAPI* api); // optional — called on shutdown + +} +``` + +--- + +## DuskModAPI Reference + +The `api` pointer is valid for the lifetime of the mod. When using `hook.hpp`, call `dusk::init(api)` once and `dusk::g_api` is set for you. + +| Field | Description | +|-------|-------------| +| `api_version` | ABI version, check against `DUSK_MOD_API_VERSION` if needed | +| `mod_dir` | Absolute path to the extracted mod cache directory | +| `log_info` / `log_warn` / `log_error` | `printf`-style logging, prefixed with the mod name | +| `load_resource` / `free_resource` | Load files from the `res/` tree in the `.dusk` archive | +| `register_tab_content` | Add a panel to the mod manager's per-mod tab | +| `register_menu_item` | Add an item to the quick-access menu | +| `hook_install` / `hook_pre` / `hook_post` / `hook_replace` | Low-level hook registration (see [Raw C hook API](#raw-c-hook-api)) | +| `hook_dispatch_pre` / `hook_dispatch_post` | Called by the trampoline, do not call directly | + +--- + +## Logging + +```cpp +api->log_info("Player health: %d", hp); +api->log_warn("Something looks wrong"); +api->log_error("Fatal: %s", msg); +``` + +Output appears in the dusk console as `[My Mod] ...` + +The format string is `printf`-compatible. + +--- + +## Loading Resources + +```cpp +size_t size = 0; +void* data = api->load_resource("config.txt", &size); +if (data) { + std::string text(static_cast(data), size); + api->free_resource(data); +} +``` + +- Path is relative to `res/`, pass `"config.txt"` not `"res/config.txt"` +- Always call `free_resource`, the buffer is owned by miniz +- For writable storage, write files under `api->mod_dir` + +--- + +## ImGui Integration + +**Tab content:** shown in the mod's panel in the Mods window, called every frame while visible: + +```cpp +static void DrawPanel(void* userdata) { + ImGui::Text("Hello!"); +} +api->register_tab_content(DrawPanel, nullptr); +``` + +Pass a pointer through `userdata` if your callback needs state: + +```cpp +api->register_tab_content(DrawPanel, &g_state); +``` + +**Menu items:** added to the quick-access menu. Use `ImGui::MenuItem`, `ImGui::Separator`, etc.: + +```cpp +static void DrawMenuEntry(void*) { + if (ImGui::MenuItem("Reset rotation")) { ... } +} +api->register_menu_item(DrawMenuEntry, nullptr); +``` + +--- + +## Hooking Game Functions + +Call `dusk::init(api)` first. + +```cpp +#include "dusk/hook.hpp" + +extern "C" void mod_init(DuskModAPI* api) { + dusk::init(api); + dusk::hookAddPre<&ClassName::Method>(callback); +} +``` + +The trampoline is installed once per address. Multiple mods can register pre/post callbacks for the same function independently. + +### Pre-hooks + +Run before the original. Return `0` to let it proceed, non-zero to cancel it. Post-hooks still run either way. + +```cpp +static int32_t on_posMove_pre(void* args) { + daAlink_c* link = dusk::arg(args, 0); // this + if (link->shape_angle.y > 10000) + return 1; // cancel + return 0; +} +dusk::hookAddPre<&daAlink_c::posMove>(on_posMove_pre); +``` + +### Post-hooks + +Run after the original (or replace-hook). + +```cpp +static void on_posMove_post(void* args) { + daAlink_c* link = dusk::arg(args, 0); + dusk::g_api->log_info("New Y angle: %d", (int)link->shape_angle.y); +} +dusk::hookAddPost<&daAlink_c::posMove>(on_posMove_post); +``` + +### Replace hooks + +Completely substitutes the original. Only one replace-hook per function, a second install overwrites with a warning. + +```cpp +static void on_posMove_replace(void* args) { + daAlink_c* link = dusk::arg(args, 0); + link->shape_angle.y += 100; +} +dusk::hookSetReplace<&daAlink_c::posMove>(on_posMove_replace); +``` + +To call the original from inside a replace-hook: + +```cpp +using Entry = dusk::HookEntry<&daAlink_c::posMove>; + +static void on_posMove_replace(void* args) { + daAlink_c* link = dusk::arg(args, 0); + link->shape_angle.y = 0; + Entry::g_orig(link); +} +``` + +### Reading and writing arguments + +`args` is a `void*[N]` array. Index `0` is `this`, subsequent indices are parameters in declaration order. + +```cpp +T value = dusk::arg (args, n); // copy +T& ref = dusk::argRef(args, n); // reference (read/write) +``` + +**Example:** halve incoming damage + +```cpp +// void daEnemy_c::takeDamage(int amount, daActor_c* source) +static int32_t on_takeDamage_pre(void* args) { + dusk::argRef(args, 1) /= 2; + return 0; +} +dusk::hookAddPre<&daEnemy_c::takeDamage>(on_takeDamage_pre); +``` + +For reference parameters (e.g. `const cXyz& pos`), use `argRef` to get a direct reference. + + +--- + +## Full Example + +```cpp +#include "d/actor/d_a_alink.h" +#include "dusk/hook.hpp" +#include "dusk/mod_api.h" +#include "imgui.h" +#include "m_Do/m_Do_controller_pad.h" + +static int g_ticks = 0; + +static int32_t on_posMove_pre(void* args) { + daAlink_c* link = dusk::arg(args, 0); + if (mDoCPd_c::getHoldR(PAD_1)) { + link->shape_angle.y -= 2048; + } + return 0; +} + +static void DrawPanel(void*) { + daAlink_c* link = daAlink_getAlinkActorClass(); + ImGui::Text("Ticks: %d", g_ticks); + if (link) { + ImGui::Text("Y angle: %d", (int)link->shape_angle.y); + if (ImGui::Button("Reset rotation")) { + link->shape_angle.y = 0; + } + } +} + +static void DrawMenuEntry(void*) { + daAlink_c* link = daAlink_getAlinkActorClass(); + if (ImGui::MenuItem("Reset rotation", nullptr, false, link != nullptr)) { + link->shape_angle.y = 0; + } +} + +extern "C" { + +void mod_init(DuskModAPI* api) { + dusk::init(api); + dusk::hookAddPre<&daAlink_c::posMove>(on_posMove_pre); + api->register_tab_content(DrawPanel, nullptr); + api->register_menu_item(DrawMenuEntry, nullptr); +} + +void mod_tick(DuskModAPI* api) { + ++g_ticks; +} + +void mod_cleanup(DuskModAPI* api) { + api->log_info("Unloaded after %d ticks.", g_ticks); +} +} +``` diff --git a/tools/mod_template/res/.gitkeep b/tools/mod_template/res/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/mod_template/res/text.txt b/tools/mod_template/res/text.txt deleted file mode 100644 index 7753e7b422..0000000000 --- a/tools/mod_template/res/text.txt +++ /dev/null @@ -1,2 +0,0 @@ -This text has been loaded from the mods resources! -Press R to rotate Link! diff --git a/tools/mod_template/src/mod.cpp b/tools/mod_template/src/mod.cpp index 34b820cdcc..bca66181b6 100644 --- a/tools/mod_template/src/mod.cpp +++ b/tools/mod_template/src/mod.cpp @@ -1,80 +1,18 @@ -#include "d/actor/d_a_alink.h" #include "dusk/hook.hpp" #include "dusk/mod_api.h" -#include "imgui.h" -#include "m_Do/m_Do_controller_pad.h" - -#include - -static int TickCount = 0; -static std::string TextContents; - -static int32_t on_posMove_pre(void* args) { - if (!mDoCPd_c::getHoldR(PAD_1)) - return 0; - daAlink_c* link = dusk::arg(args, 0); - link->shape_angle.y -= 2048; - dusk::g_api->log_info("ROTATING %d", link->shape_angle.y); - return 0; -} - -static void DrawTabContent(void*) { - daAlink_c* link = daAlink_getAlinkActorClass(); - if (link) { - ImGui::Text("Y angle: %d", (int)link->shape_angle.y); - ImGui::Spacing(); - if (ImGui::Button("Reset rotation")) { - link->shape_angle.y = 0; - } - } - if (!TextContents.empty()) { - ImGui::Separator(); - ImGui::TextUnformatted(TextContents.c_str()); - } -} - -static void DrawMenuItem(void*) { - if (ImGui::MenuItem("Reset rotation")) { - daAlink_c* link = daAlink_getAlinkActorClass(); - if (link) { - link->shape_angle.y = 0; - } - } -} extern "C" { -void dusk_mod_set_imgui_ctx(void* ctx) { - ImGui::SetCurrentContext(static_cast(ctx)); -} - void mod_init(DuskModAPI* api) { - api->log_info("Test Mod initializing..."); - dusk::init(api); - dusk::hookAddPre<&daAlink_c::posMove>(on_posMove_pre); - - size_t size = 0; - void* data = api->load_resource("text.txt", &size); - if (data) { - TextContents.assign(static_cast(data), size); - api->free_resource(data); - api->log_info("Loaded text.txt (%zu bytes)", size); - } else { - api->log_warn("Failed to load text.txt"); - } - - api->register_tab_content(DrawTabContent, nullptr); - api->register_menu_item(DrawMenuItem, nullptr); - api->log_info("Test Mod ready. Mod folder: %s", api->mod_dir); } void mod_tick(DuskModAPI* api) { - ++TickCount; + (void)api; } void mod_cleanup(DuskModAPI* api) { - api->log_info("Test Mod unloading after %d ticks.", TickCount); + (void)api; } } diff --git a/tools/mod_test/CMakeLists.txt b/tools/mod_test/CMakeLists.txt new file mode 100644 index 0000000000..4c1fcf6332 --- /dev/null +++ b/tools/mod_test/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.25) +project(mod_test CXX) + +set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to dusk source root") +add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL) + +set(DUSK_MODS_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/mods" CACHE PATH "Directory to write .dusk packages into") + +add_dusk_mod(mod_test + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res +) diff --git a/tools/mod_test/mod.json b/tools/mod_test/mod.json new file mode 100644 index 0000000000..0a168a6c22 --- /dev/null +++ b/tools/mod_test/mod.json @@ -0,0 +1,6 @@ +{ + "name": "API Test Mod", + "version": "1.0.0", + "author": "dusk", + "description": "Exercises every feature of the Dusk mod API." +} diff --git a/tools/mod_test/res/hello.txt b/tools/mod_test/res/hello.txt new file mode 100644 index 0000000000..0fa772fe99 --- /dev/null +++ b/tools/mod_test/res/hello.txt @@ -0,0 +1 @@ +Hello from the mod archive! diff --git a/tools/mod_test/src/mod.cpp b/tools/mod_test/src/mod.cpp new file mode 100644 index 0000000000..44de2f5020 --- /dev/null +++ b/tools/mod_test/src/mod.cpp @@ -0,0 +1,154 @@ +// Tests every feature of the Dusk mod API. Results shown in the mod tab. + +#include "d/actor/d_a_alink.h" +#include "dusk/hook.hpp" +#include "dusk/mod_api.h" +#include "imgui.h" +#include "m_Do/m_Do_controller_pad.h" + +#include +#include + +static int g_ticks = 0; +static bool g_pre_fired = false; +static bool g_post_fired = false; +static bool g_replace_fired = false; +static bool g_arg_write_ok = false; +static int g_pre_cancel_count = 0; +static int g_post_count = 0; +static bool g_resource_ok = false; +static std::string g_resource_text; +static char g_mod_dir_snippet[64] = {}; + +// Pre-hook on posMove. Hold L to test argRef write and cancellation. +static int32_t on_posMove_pre(void* args) { + g_pre_fired = true; + if (mDoCPd_c::getHoldL(PAD_1)) { + dusk::argRef(args, 0)->shape_angle.y = 0; + g_arg_write_ok = true; + ++g_pre_cancel_count; + return 1; // cancel + } + return 0; +} + +// Post-hook on posMove. Fires even when the pre-hook cancelled. +static void on_posMove_post(void* args) { + g_post_fired = true; + ++g_post_count; + (void)args; +} + +// Replace-hook on execute. Calls through to the original so gameplay is unaffected. +using ExecuteEntry = dusk::HookEntry<&daAlink_c::execute>; +static void on_execute_replace(void* args) { + g_replace_fired = true; + ExecuteEntry::g_orig(dusk::arg(args, 0)); +} + +static void DrawPanel(void*) { + auto status = [](const char* label, bool ok) { + ImGui::TextColored(ok ? ImVec4(0, 1, 0, 1) : ImVec4(1, 0.35f, 0.35f, 1), + ok ? "[PASS]" : "[WAIT]"); + ImGui::SameLine(); + ImGui::Text("%s", label); + }; + + ImGui::SeparatorText("Hooks"); + status("pre-hook fired (posMove)", g_pre_fired); + status("post-hook fired (posMove)", g_post_fired); + status("replace-hook fired (execute)", g_replace_fired); + status("argRef write + pre cancel (hold L)", g_arg_write_ok); + ImGui::Text(" pre cancels: %d post calls: %d", g_pre_cancel_count, g_post_count); + + ImGui::SeparatorText("Resources"); + status("load_resource (hello.txt)", g_resource_ok); + if (!g_resource_text.empty()) + ImGui::TextWrapped(" \"%s\"", g_resource_text.c_str()); + + ImGui::SeparatorText("API Fields"); + status("mod_dir non-empty", g_mod_dir_snippet[0] != '\0'); + ImGui::TextWrapped(" %s", g_mod_dir_snippet); + + ImGui::Spacing(); + ImGui::Separator(); + if (ImGui::Button("Reset results")) { + g_pre_fired = false; + g_post_fired = false; + g_replace_fired = false; + g_arg_write_ok = false; + g_pre_cancel_count = 0; + g_post_count = 0; + } + daAlink_c* link = daAlink_getAlinkActorClass(); + if (link) { + ImGui::SameLine(); + ImGui::Text("(Link y angle: %d)", (int)link->shape_angle.y); + } +} + +static void DrawMenuEntry(void*) { + if (ImGui::MenuItem("Test: log all levels")) { + dusk::g_api->log_info("log_info test"); + dusk::g_api->log_warn("log_warn test"); + dusk::g_api->log_error("log_error test"); + } + if (ImGui::MenuItem("Test: reset Link y angle")) { + daAlink_c* link = daAlink_getAlinkActorClass(); + if (link) link->shape_angle.y = 0; + } +} + +extern "C" { + +void mod_init(DuskModAPI* api) { + dusk::init(api); + + api->log_info("mod_test initializing"); + api->log_warn("log_warn smoke test"); + api->log_error("log_error smoke test"); + + std::snprintf(g_mod_dir_snippet, sizeof(g_mod_dir_snippet), "%.60s", api->mod_dir); + + size_t size = 0; + void* data = api->load_resource("hello.txt", &size); + if (data) { + g_resource_text.assign(static_cast(data), size); + while (!g_resource_text.empty() && g_resource_text.back() == '\n') + g_resource_text.pop_back(); + api->free_resource(data); + g_resource_ok = true; + api->log_info("load_resource OK: \"%s\"", g_resource_text.c_str()); + } else { + api->log_error("load_resource FAILED for hello.txt"); + } + + // Missing file should return nullptr gracefully. + void* missing = api->load_resource("does_not_exist.bin", nullptr); + if (!missing) + api->log_info("load_resource missing-file: correctly returned nullptr"); + else { + api->log_error("load_resource missing-file: unexpectedly returned data"); + api->free_resource(missing); + } + + dusk::hookAddPre <&daAlink_c::posMove>(on_posMove_pre); + dusk::hookAddPost<&daAlink_c::posMove>(on_posMove_post); + dusk::hookSetReplace<&daAlink_c::execute>(on_execute_replace); + + api->register_tab_content(DrawPanel, nullptr); + api->register_menu_item(DrawMenuEntry, nullptr); + + api->log_info("mod_test ready"); +} + +void mod_tick(DuskModAPI* api) { + ++g_ticks; + (void)api; +} + +void mod_cleanup(DuskModAPI* api) { + api->log_info("mod_test unloaded after %d ticks", g_ticks); +} + +} From 394627cd47c9bd09494305efd01e7f31c97fb5aa Mon Sep 17 00:00:00 2001 From: madeline Date: Mon, 20 Apr 2026 07:56:09 -0700 Subject: [PATCH 06/48] remove low level hook info --- docs/modding.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/modding.md b/docs/modding.md index 6d3ec54075..5c1ebbdb45 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -98,7 +98,6 @@ The `api` pointer is valid for the lifetime of the mod. When using `hook.hpp`, c | `load_resource` / `free_resource` | Load files from the `res/` tree in the `.dusk` archive | | `register_tab_content` | Add a panel to the mod manager's per-mod tab | | `register_menu_item` | Add an item to the quick-access menu | -| `hook_install` / `hook_pre` / `hook_post` / `hook_replace` | Low-level hook registration (see [Raw C hook API](#raw-c-hook-api)) | | `hook_dispatch_pre` / `hook_dispatch_post` | Called by the trampoline, do not call directly | --- From 02a4e213e314bb23d405d3ab624b8d51e0a382d9 Mon Sep 17 00:00:00 2001 From: madeline Date: Mon, 20 Apr 2026 07:57:21 -0700 Subject: [PATCH 07/48] again --- docs/modding.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/modding.md b/docs/modding.md index 5c1ebbdb45..7d8941f94d 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -16,7 +16,6 @@ Mods are shared libraries packaged into a `.dusk` zip archive. The loader scans - [Post-hooks](#post-hooks) - [Replace hooks](#replace-hooks) - [Reading and writing arguments](#reading-and-writing-arguments) - - [Raw C hook API](#raw-c-hook-api) 9. [Full Example](#full-example) --- From e5c7dfdedd8945f31fae87882d4d52f47884d6c4 Mon Sep 17 00:00:00 2001 From: madeline Date: Mon, 20 Apr 2026 08:04:05 -0700 Subject: [PATCH 08/48] maybe fix apple ci idk this shit is giga fucked --- cmake/fix_capstone_policy.cmake | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmake/fix_capstone_policy.cmake b/cmake/fix_capstone_policy.cmake index 0ff25f82a1..3d7a3ca4bc 100644 --- a/cmake/fix_capstone_policy.cmake +++ b/cmake/fix_capstone_policy.cmake @@ -1,7 +1,13 @@ -# Removes cmake_policy(SET CMP0048 OLD) from capstone 4.0.2 CMakeLists.txt +# Patches capstone's CMakeLists.txt for compatibility with CMake >= 4.0: +# - Removes cmake_policy(SET CMP0048 OLD) (rejected by CMake >= 3.27) +# - Bumps cmake_minimum_required to 3.5 (CMake >= 4.0 dropped < 3.5 support) file(READ "${DIR}/CMakeLists.txt" _content) string(REPLACE "cmake_policy(SET CMP0048 OLD)" "# cmake_policy(SET CMP0048 OLD)" _content "${_content}") +string(REGEX REPLACE + "cmake_minimum_required\\(VERSION [0-9]+\\.[0-9]+(\\.[0-9]+)?\\)" + "cmake_minimum_required(VERSION 3.5)" + _content "${_content}") file(WRITE "${DIR}/CMakeLists.txt" "${_content}") From ba906150d48ac87e22eb3e115fde5e48b0f7f2be Mon Sep 17 00:00:00 2001 From: madeline Date: Mon, 20 Apr 2026 08:20:08 -0700 Subject: [PATCH 09/48] another attempt --- cmake/fix_capstone_policy.cmake | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmake/fix_capstone_policy.cmake b/cmake/fix_capstone_policy.cmake index 3d7a3ca4bc..fa8a1dd160 100644 --- a/cmake/fix_capstone_policy.cmake +++ b/cmake/fix_capstone_policy.cmake @@ -1,13 +1,13 @@ # Patches capstone's CMakeLists.txt for compatibility with CMake >= 4.0: +# - Bumps cmake_minimum_required to 3.10 (CMake >= 4.0 dropped < 3.5 support; < 3.10 warns) # - Removes cmake_policy(SET CMP0048 OLD) (rejected by CMake >= 3.27) -# - Bumps cmake_minimum_required to 3.5 (CMake >= 4.0 dropped < 3.5 support) file(READ "${DIR}/CMakeLists.txt" _content) -string(REPLACE - "cmake_policy(SET CMP0048 OLD)" - "# cmake_policy(SET CMP0048 OLD)" +string(REGEX REPLACE + "cmake_minimum_required[ \t]*\\([ \t]*VERSION[ \t]+[0-9]+\\.[0-9]+(\\.[0-9]+)?[ \t]*\\)" + "cmake_minimum_required(VERSION 3.10)" _content "${_content}") string(REGEX REPLACE - "cmake_minimum_required\\(VERSION [0-9]+\\.[0-9]+(\\.[0-9]+)?\\)" - "cmake_minimum_required(VERSION 3.5)" + "cmake_policy[ \t]*\\([ \t]*SET[ \t]+CMP0048[ \t]+OLD[ \t]*\\)" + "# cmake_policy(SET CMP0048 OLD)" _content "${_content}") file(WRITE "${DIR}/CMakeLists.txt" "${_content}") From 5dcbca392d42926cfcdffd302835207bd26ad489 Mon Sep 17 00:00:00 2001 From: madeline Date: Mon, 20 Apr 2026 09:51:33 -0700 Subject: [PATCH 10/48] fix turbo key getting stuck on --- src/dusk/imgui/ImGuiConsole.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index 5785ee0fdb..1531b46af3 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -280,7 +280,8 @@ namespace dusk { } void ImGuiConsole::UpdateSettings() { - getTransientSettings().skipFrameRateLimit = getSettings().game.enableTurboKeybind && ImGui::IsKeyDown(ImGuiKey_Tab); + getTransientSettings().skipFrameRateLimit = getSettings().game.enableTurboKeybind && + SDL_GetKeyboardState(nullptr)[SDL_SCANCODE_TAB]; if (dusk::frame_interp::get_ui_tick_pending() && mDoMain::developmentMode == 1 && (mDoCPd_c::getHold(PAD_1) & (PAD_TRIGGER_R | PAD_TRIGGER_L)) == (PAD_TRIGGER_R | PAD_TRIGGER_L) && mDoCPd_c::getTrigY(PAD_1)) { getTransientSettings().moveLinkActive = !getTransientSettings().moveLinkActive; From 9d10a4832971014e16250a0013287938f5704c22 Mon Sep 17 00:00:00 2001 From: madeline Date: Thu, 23 Apr 2026 03:07:00 -0700 Subject: [PATCH 11/48] i fugged up the merge --- src/dusk/imgui/ImGuiConsole.hpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/dusk/imgui/ImGuiConsole.hpp b/src/dusk/imgui/ImGuiConsole.hpp index da01d1c4eb..932b4ec951 100644 --- a/src/dusk/imgui/ImGuiConsole.hpp +++ b/src/dusk/imgui/ImGuiConsole.hpp @@ -52,7 +52,6 @@ class ImGuiConsole { ImGuiFirstRunPreset m_firstRunPreset; ImGuiMenuGame m_menuGame; - ImGuiMenuEnhancements m_menuEnhancements; ImGuiMenuMods m_menuMods; ImGuiPreLaunchWindow m_preLaunchWindow; From 975ab1dc5408ed870f7b92ab0c883a4dd96949d0 Mon Sep 17 00:00:00 2001 From: madeline Date: Thu, 23 Apr 2026 03:07:44 -0700 Subject: [PATCH 12/48] i fugged up the merge --- src/dusk/imgui/ImGuiConsole.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index 68e3808194..5a15b0a1f4 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -329,7 +329,6 @@ namespace dusk { ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); if (showMenu && ImGui::BeginMainMenuBar()) { m_menuGame.draw(); - m_menuEnhancements.draw(); m_menuMods.draw(); m_menuTools.draw(); From 53573eb79512c101f3249e944b38a966926ab406 Mon Sep 17 00:00:00 2001 From: madeline Date: Thu, 23 Apr 2026 04:49:21 -0700 Subject: [PATCH 13/48] inter mod communication --- docs/modding.md | 38 +++++++++++++++++++++++++++++++++++++- include/dusk/mod_api.h | 3 +++ src/dusk/mod_loader.cpp | 19 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/modding.md b/docs/modding.md index bae5a2d84c..406a632229 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -16,7 +16,8 @@ Mods are shared libraries packaged into a `.dusk` zip archive. The loader scans - [Post-hooks](#post-hooks) - [Replace hooks](#replace-hooks) - [Reading and writing arguments](#reading-and-writing-arguments) -9. [Full Example](#full-example) +9. [Inter-Mod Communication](#inter-mod-communication) +10. [Full Example](#full-example) --- @@ -98,6 +99,8 @@ The `api` pointer is valid for the lifetime of the mod. When using `hook.hpp`, c | `register_tab_content` | Add a panel to the mod manager's per-mod tab | | `register_menu_item` | Add an item to the quick-access menu | | `hook_dispatch_pre` / `hook_dispatch_post` | Called by the trampoline, do not call directly | +| `service_publish` | Register a named pointer in the global service registry | +| `service_get` | Look up a named pointer registered by another mod | --- @@ -248,6 +251,39 @@ dusk::hookAddPre<&daEnemy_c::takeDamage>(on_takeDamage_pre); For reference parameters (e.g. `const cXyz& pos`), use `argRef` to get a direct reference. +--- + +## Inter-Mod Communication + +Mods can expose a public API to each other through a global service registry. The convention for names is `"mod_name/service_name"`. + +**Mod A — publishing:** + +```cpp +struct MyModAPI { + void (*do_thing)(int value); +}; + +static void my_do_thing(int value) { ... } +static MyModAPI g_api = { my_do_thing }; + +extern "C" void mod_init(DuskModAPI* api) { + api->service_publish("my_mod/api", &g_api); +} +``` + +**Mod B — consuming:** + +```cpp +#include "my_mod_api.h" + +static MyModAPI* g_my_mod = nullptr; + +extern "C" void mod_init(DuskModAPI* api) { + g_my_mod = static_cast(api->service_get("my_mod/api")); +} +``` + --- ## Full Example diff --git a/include/dusk/mod_api.h b/include/dusk/mod_api.h index ba80f7f4b6..6d7d75414d 100644 --- a/include/dusk/mod_api.h +++ b/include/dusk/mod_api.h @@ -38,6 +38,9 @@ typedef struct DuskModAPI { bool (*hook_dispatch_pre) (void* fn_addr, void* args); void (*hook_dispatch_post)(void* fn_addr, void* args); + + void (*service_publish)(const char* name, void* ptr); + void* (*service_get) (const char* name); } DuskModAPI; void mod_init(DuskModAPI* api); diff --git a/src/dusk/mod_loader.cpp b/src/dusk/mod_loader.cpp index d81163b4ee..6094e2cb0c 100644 --- a/src/dusk/mod_loader.cpp +++ b/src/dusk/mod_loader.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "imgui.h" #include "miniz.h" @@ -59,6 +60,7 @@ static constexpr const char* k_libExt = ".so"; #endif static dusk::LoadedMod* g_currentMod = nullptr; +static std::unordered_map g_services; namespace dusk { void* g_dusk_hook_current_mod = nullptr; @@ -134,6 +136,20 @@ static void cb_register_tab_content(void (*draw_fn)(void*), void* userdata) { g_currentMod->tab_content.push_back({draw_fn, userdata}); } +static void cb_service_publish(const char* name, void* ptr) { + if (name) { + g_services[name] = ptr; + } +} + +static void* cb_service_get(const char* name) { + if (!name) { + return nullptr; + } + auto it = g_services.find(name); + return it != g_services.end() ? it->second : nullptr; +} + static void cb_register_menu_item(void (*draw_fn)(void*), void* userdata) { if (g_currentMod && draw_fn) g_currentMod->menu_items.push_back({draw_fn, userdata}); @@ -174,6 +190,8 @@ void ModLoader::buildAPI(LoadedMod& mod) { mod.api.hook_replace = api_hook_replace; mod.api.hook_dispatch_pre = hookDispatchPre; mod.api.hook_dispatch_post = hookDispatchPost; + mod.api.service_publish = cb_service_publish; + mod.api.service_get = cb_service_get; } void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { @@ -365,6 +383,7 @@ void ModLoader::shutdown() { } } m_mods.clear(); + g_services.clear(); DuskLog.info("ModLoader: all mods unloaded"); } From 99fb2b89ce2012fe9cd3b249c8dcf9d78a835a5d Mon Sep 17 00:00:00 2001 From: madeline Date: Thu, 23 Apr 2026 04:52:16 -0700 Subject: [PATCH 14/48] DUSK_REQUIRE_API_VERSION --- docs/modding.md | 4 ++++ include/dusk/mod_api.h | 5 +++++ src/dusk/mod_loader.cpp | 8 ++++++++ 3 files changed, 17 insertions(+) diff --git a/docs/modding.md b/docs/modding.md index 406a632229..bdb8618456 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -75,6 +75,8 @@ All fields are optional but recommended. `name` falls back to the filename, `ver ```cpp #include "dusk/mod_api.h" +DUSK_REQUIRE_API_VERSION // declares mod_api_version; loader rejects the mod if the engine is older + extern "C" { void mod_init (DuskModAPI* api); // required, called once at startup @@ -84,6 +86,8 @@ void mod_cleanup(DuskModAPI* api); // optional, called on shutdown } ``` +`DUSK_REQUIRE_API_VERSION` is optional but recommended. When present, the loader will refuse to initialize the mod if its API version doesn't exactly match the engine's. + --- ## DuskModAPI Reference diff --git a/include/dusk/mod_api.h b/include/dusk/mod_api.h index 6d7d75414d..8fa2f557f7 100644 --- a/include/dusk/mod_api.h +++ b/include/dusk/mod_api.h @@ -17,6 +17,11 @@ extern "C" { # define DUSK_MOD_EXPORT __attribute__((visibility("default"))) #endif +// 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 \ + DUSK_MOD_EXPORT uint32_t mod_api_version = DUSK_MOD_API_VERSION; + typedef struct DuskModAPI { uint32_t api_version; const char* mod_dir; diff --git a/src/dusk/mod_loader.cpp b/src/dusk/mod_loader.cpp index 6094e2cb0c..416e6c15e7 100644 --- a/src/dusk/mod_loader.cpp +++ b/src/dusk/mod_loader.cpp @@ -274,6 +274,14 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { mod.mod_path = fs::absolute(modPath).string(); mod.dir = fs::absolute(cacheDir).string(); mod.handle = handle; + auto* mod_api_ver = reinterpret_cast(pl_dlsym(handle, "mod_api_version")); + if (mod_api_ver && *mod_api_ver != DUSK_MOD_API_VERSION) { + DuskLog.error("ModLoader: {} expects API v{} but engine is v{}, skipping", + fs::path(dllEntry).filename().string(), *mod_api_ver, DUSK_MOD_API_VERSION); + pl_dlclose(handle); + return; + } + mod.fn_init = reinterpret_cast(pl_dlsym(handle, "mod_init")); mod.fn_tick = reinterpret_cast(pl_dlsym(handle, "mod_tick")); mod.fn_cleanup = reinterpret_cast(pl_dlsym(handle, "mod_cleanup")); From fb08cfcc6be975a5fdb1e6bbb996986083232af0 Mon Sep 17 00:00:00 2001 From: madeline Date: Thu, 23 Apr 2026 05:17:15 -0700 Subject: [PATCH 15/48] handle hook conflicts --- include/dusk/hook_system.hpp | 4 ++-- include/dusk/mod_loader.hpp | 5 +++-- src/dusk/hook_system.cpp | 18 ++++++++++++------ src/dusk/imgui/ImGuiMenuMods.cpp | 18 +++++++++++++----- src/dusk/mod_loader.cpp | 16 ++++++++++++---- 5 files changed, 42 insertions(+), 19 deletions(-) diff --git a/include/dusk/hook_system.hpp b/include/dusk/hook_system.hpp index d2d3752f1d..c173a3c44b 100644 --- a/include/dusk/hook_system.hpp +++ b/include/dusk/hook_system.hpp @@ -7,8 +7,8 @@ namespace dusk { void hookInstallByAddr(void* fn_addr, void* tramp_fn, void** orig_store); void hookRegisterPre (void* fn_addr, void* mod, int32_t (*fn)(void* args)); -void hookRegisterPost(void* fn_addr, void* mod, void (*fn)(void* args)); -void hookSetReplace (void* fn_addr, void* mod, void (*fn)(void* args)); +void hookRegisterPost(void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args)); +bool hookSetReplace (void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args)); bool hookDispatchPre (void* fn_addr, void* args); void hookDispatchPost(void* fn_addr, void* args); diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index a5a73504b9..72cb268747 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -21,8 +21,9 @@ struct LoadedMod { std::string mod_path; std::string dir; - void* handle = nullptr; - bool active = false; + void* handle = nullptr; + bool active = false; + bool load_failed = false; using FnInit = void (*)(DuskModAPI*); using FnTick = void (*)(DuskModAPI*); diff --git a/src/dusk/hook_system.cpp b/src/dusk/hook_system.cpp index 6d0f93df4c..54189dfcd5 100644 --- a/src/dusk/hook_system.cpp +++ b/src/dusk/hook_system.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -17,6 +18,7 @@ struct PreHookFn { }; struct VoidHookFn { void* mod; + const char* mod_name; void (*fn)(void* args); }; @@ -121,15 +123,19 @@ void hookRegisterPre(void* fn_addr, void* mod, int32_t (*fn)(void* args)) { registry()[reinterpret_cast(fn_addr)].pre.push_back({mod, fn}); } -void hookRegisterPost(void* fn_addr, void* mod, void (*fn)(void* args)) { - registry()[reinterpret_cast(fn_addr)].post.push_back({mod, fn}); +void hookRegisterPost(void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args)) { + registry()[reinterpret_cast(fn_addr)].post.push_back({mod, mod_name, fn}); } -void hookSetReplace(void* fn_addr, void* mod, void (*fn)(void* args)) { +bool hookSetReplace(void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args)) { auto& slot = registry()[reinterpret_cast(fn_addr)]; - if (slot.replace.fn) - DuskLog.warn("HookSystem: replace hook for {} already set — overwriting", fn_addr); - slot.replace = {mod, fn}; + if (slot.replace.fn) { + DuskLog.error("HookSystem: '{}' conflicts with '{}', both replace the same function", + mod_name, slot.replace.mod_name); + return false; + } + slot.replace = {mod, mod_name, fn}; + return true; } void hookClearMod(void* mod) { diff --git a/src/dusk/imgui/ImGuiMenuMods.cpp b/src/dusk/imgui/ImGuiMenuMods.cpp index c7199fbf13..07d362696f 100644 --- a/src/dusk/imgui/ImGuiMenuMods.cpp +++ b/src/dusk/imgui/ImGuiMenuMods.cpp @@ -48,12 +48,18 @@ void ImGuiMenuMods::showModsWindow() { if (ImGui::BeginTabBar("##ModsOuter")) { for (const auto& mod : mods) { - const std::string tabLabel = mod.name + (mod.active ? "" : " [disabled]"); + const std::string tabLabel = mod.name + (mod.load_failed ? " [failed]" : mod.active ? "" : " [disabled]"); if (ImGui::BeginTabItem(tabLabel.c_str())) { ImGui::Text("Version: %s", mod.version.c_str()); ImGui::Text("Author: %s", mod.author.c_str()); - ImGui::Text("Status: %s", mod.active ? "Active" : "Disabled"); + + if (mod.load_failed) { + ImGui::TextColored(ImVec4(1.f, 0.3f, 0.3f, 1.f), "Status: Failed to load"); + } else { + ImGui::Text("Status: %s", mod.active ? "Active" : "Disabled"); + } + ImGui::Text("Path: %s", mod.mod_path.c_str()); if (!mod.description.empty()) { @@ -61,9 +67,11 @@ void ImGuiMenuMods::showModsWindow() { ImGui::TextWrapped("%s", mod.description.c_str()); } - for (const auto& cb : mod.tab_content) { - ImGui::Separator(); - ModLoader::callDrawCallback(mod, cb); + if (!mod.load_failed) { + for (const auto& cb : mod.tab_content) { + ImGui::Separator(); + ModLoader::callDrawCallback(mod, cb); + } } ImGui::EndTabItem(); diff --git a/src/dusk/mod_loader.cpp b/src/dusk/mod_loader.cpp index 416e6c15e7..4df11baed2 100644 --- a/src/dusk/mod_loader.cpp +++ b/src/dusk/mod_loader.cpp @@ -160,11 +160,15 @@ static void api_hook_pre(void* addr, int32_t (*fn)(void* args)) { } static void api_hook_post(void* addr, void (*fn)(void* args)) { - dusk::hookRegisterPost(addr, g_currentMod, fn); + dusk::hookRegisterPost(addr, g_currentMod, modName(), fn); } static void api_hook_replace(void* addr, void (*fn)(void* args)) { - dusk::hookSetReplace(addr, g_currentMod, fn); + if (!dusk::hookSetReplace(addr, g_currentMod, modName(), fn)) { + if (g_currentMod) { + g_currentMod->load_failed = true; + } + } } namespace dusk { @@ -343,8 +347,12 @@ void ModLoader::init() { ModGuard guard(&mod); try { mod.fn_init(&mod.api); - mod.active = true; - DuskLog.info("ModLoader: '{}' initialized", mod.name); + if (!mod.load_failed) { + mod.active = true; + DuskLog.info("ModLoader: '{}' initialized", mod.name); + } else { + DuskLog.error("ModLoader: '{}' failed to load due to hook conflicts", mod.name); + } } catch (const std::exception& e) { DuskLog.error("ModLoader: exception in {}.mod_init(): {}", mod.name, e.what()); } catch (...) { From 3281c64a55f02d9c75140fecddc6c7fa4a562104 Mon Sep 17 00:00:00 2001 From: madeline Date: Thu, 23 Apr 2026 06:01:28 -0700 Subject: [PATCH 16/48] more precise link debug info --- src/dusk/imgui/ImGuiMenuTools.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/dusk/imgui/ImGuiMenuTools.cpp b/src/dusk/imgui/ImGuiMenuTools.cpp index 97f5b5a8d3..00a03e635b 100644 --- a/src/dusk/imgui/ImGuiMenuTools.cpp +++ b/src/dusk/imgui/ImGuiMenuTools.cpp @@ -210,7 +210,7 @@ namespace dusk { ImGui::Text("Link"); ImGuiStringViewText( player != nullptr - ? fmt::format("Position: {: .2f}, {: .2f}, {: .2f}\n", player->current.pos.x, player->current.pos.y, player->current.pos.z) + ? fmt::format("Position: {: .4f}, {: .4f}, {: .4f}\n", player->current.pos.x, player->current.pos.y, player->current.pos.z) : "Position: ?, ?, ?\n" ); @@ -222,7 +222,7 @@ namespace dusk { ImGuiStringViewText( player != nullptr - ? fmt::format("Speed: {0}\n", player->speedF) + ? fmt::format("Speed: {: .4f}\n", player->speedF) : "Speed: ?\n" ); @@ -230,7 +230,7 @@ namespace dusk { ImGui::Text("Epona"); ImGuiStringViewText( horse != nullptr - ? fmt::format("Position: {: .2f}, {: .2f}, {: .2f}\n", horse->current.pos.x, horse->current.pos.y, horse->current.pos.z) + ? fmt::format("Position: {: .4f}, {: .4f}, {: .4f}\n", horse->current.pos.x, horse->current.pos.y, horse->current.pos.z) : "Position: ?, ?, ?\n" ); @@ -242,7 +242,7 @@ namespace dusk { ImGuiStringViewText( horse != nullptr - ? fmt::format("Speed: {0}\n", horse->speedF) + ? fmt::format("Speed: {: .4f}\n", horse->speedF) : "Speed: ?\n" ); From e25a1f3ef66b947e375cfecad5a7a03f9f097573 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 24 Apr 2026 09:52:04 -0600 Subject: [PATCH 17/48] Move mods to config dir & updates for macOS --- CMakeLists.txt | 3 +++ docs/modding.md | 4 ++++ include/dusk/mod_loader.hpp | 2 +- platforms/macos/Dusk.entitlements | 8 ++++++++ src/dusk/mod_loader.cpp | 4 ++-- src/m_Do/m_Do_main.cpp | 12 ++++++++++-- 6 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 platforms/macos/Dusk.entitlements diff --git a/CMakeLists.txt b/CMakeLists.txt index bf7a667426..7361d370de 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -549,6 +549,7 @@ if (APPLE) set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/tvos) else () set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/macos) + set(DUSK_ENTITLEMENTS ${DUSK_RESOURCE_DIR}/Dusk.entitlements) endif () set(DUSK_INFO_PLIST ${DUSK_RESOURCE_DIR}/Info.plist.in) file(GLOB_RECURSE DUSK_RESOURCE_FILES @@ -579,6 +580,8 @@ if (APPLE) OUTPUT_NAME Dusk XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES" XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "YES" + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${DUSK_ENTITLEMENTS} + XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME "YES" ) endif () diff --git a/docs/modding.md b/docs/modding.md index bdb8618456..d053f66ded 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -51,6 +51,10 @@ add_dusk_mod(my_mod After building, `my_mod.dusk` is placed in `mods/` next to the project root (`DUSK_MODS_OUTPUT_DIR` cache variable). Copy it to the game's `mods/` folder and launch. +- Windows: `%APPDATA%\TwilitRealm\Dusk\mods` +- Linux: `~/.local/share/TwilitRealm/Dusk/mods` +- macOS: `~/Library/Application Support/TwilitRealm/Dusk/mods` + The `.dusk` archive is a standard zip containing `mod.json`, the compiled library, and an optional `res/` tree. `add_dusk_mod()` creates it automatically. --- diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 72cb268747..5500facd72 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -55,7 +55,7 @@ class ModLoader { private: std::vector m_mods; - std::filesystem::path m_modsDir = "mods"; + std::filesystem::path m_modsDir; bool m_initialized = false; void tryLoadDusk(const std::filesystem::path& modPath); diff --git a/platforms/macos/Dusk.entitlements b/platforms/macos/Dusk.entitlements new file mode 100644 index 0000000000..123d12a53e --- /dev/null +++ b/platforms/macos/Dusk.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/src/dusk/mod_loader.cpp b/src/dusk/mod_loader.cpp index 4df11baed2..8fcd6b6635 100644 --- a/src/dusk/mod_loader.cpp +++ b/src/dusk/mod_loader.cpp @@ -253,7 +253,7 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { return; } - const fs::path cacheDir = fs::path("mods") / ".cache" / modPath.stem(); + const fs::path cacheDir = m_modsDir / ".cache" / modPath.stem(); std::error_code ec; fs::create_directories(cacheDir, ec); @@ -315,7 +315,7 @@ void ModLoader::init() { m_initialized = true; namespace fs = std::filesystem; - if (!fs::exists(m_modsDir)) { + if (!fs::is_directory(m_modsDir)) { DuskLog.info("ModLoader: mods directory '{}' not found — mod loading skipped", m_modsDir.string()); return; diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 413503cf29..50f5064225 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -493,7 +493,7 @@ int game_main(int argc, char* argv[]) { ("h,help", "Print usage") ("console", "Show the Windows console window for logs", cxxopts::value()->default_value("false")->implicit_value("true")) ("dvd", "Path to DVD image file", cxxopts::value()) - ("mods", "Path to mods directory", cxxopts::value()->default_value("mods")) + ("mods", "Path to mods directory", cxxopts::value()) ("backend", "Graphics API backend to use (auto, d3d12, metal, vulkan, null)", cxxopts::value()) ("cvar", "Override configuration variables without modifying config", cxxopts::value>()); @@ -619,7 +619,15 @@ int game_main(int argc, char* argv[]) { mDoMain::developmentMode = 1; // Force Dev Mode for Debugging mDoDvdThd::SyncWidthSound = false; - dusk::ModLoader::instance().setModsDir(parsed_arg_options["mods"].as()); + // Setup mods + if (parsed_arg_options.contains("mods") && + !parsed_arg_options["mods"].as().empty()) + { + dusk::ModLoader::instance().setModsDir(parsed_arg_options["mods"].as()); + } else { + dusk::ModLoader::instance().setModsDir(dusk::ConfigPath / "mods"); + } + OSReport("Starting main01 (Game Loop)...\n"); main01(); From c042de8a558ec7842b264affbcb443f80040e4b1 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 24 Apr 2026 10:12:52 -0600 Subject: [PATCH 18/48] Restore DUSK_BUILDING_GAME=1 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7361d370de..d4bc71679d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -477,7 +477,7 @@ else () set(DUSK_MAIN_TARGET dusk) endif () -target_compile_definitions(${DUSK_MAIN_TARGET} PRIVATE ${GAME_COMPILE_DEFS}) +target_compile_definitions(${DUSK_MAIN_TARGET} PRIVATE ${GAME_COMPILE_DEFS} DUSK_BUILDING_GAME=1) target_include_directories(${DUSK_MAIN_TARGET} PRIVATE ${GAME_INCLUDE_DIRS}) target_link_libraries(${DUSK_MAIN_TARGET} PRIVATE aurora::main ${GAME_LIBS} ${JSYSTEM_LINK_LIBRARIES}) target_precompile_headers(${DUSK_MAIN_TARGET} PRIVATE "$<$:${CMAKE_SOURCE_DIR}/include/dusk_pch.hpp>") From 1fb5d1ee2aa1452cfd60647177586b352b47bccb Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 24 Apr 2026 10:29:05 -0600 Subject: [PATCH 19/48] Set FUNCHOOK_INSTALL=OFF --- CMakeLists.txt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d4bc71679d..9004ab346d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -219,6 +219,7 @@ FetchContent_Declare(cxxopts URL https://github.com/jarro2783/cxxopts/archive/refs/tags/v3.3.1.tar.gz URL_HASH SHA256=3bfc70542c521d4b55a46429d808178916a579b28d048bd8c727ee76c39e2072 DOWNLOAD_EXTRACT_TIMESTAMP TRUE + EXCLUDE_FROM_ALL ) message(STATUS "dusk: Fetching nlohmann/json") @@ -226,12 +227,14 @@ FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa DOWNLOAD_EXTRACT_TIMESTAMP TRUE + EXCLUDE_FROM_ALL ) message(STATUS "dusk: Fetching miniz") FetchContent_Declare(miniz URL https://github.com/richgel999/miniz/releases/download/3.0.2/miniz-3.0.2.zip DOWNLOAD_EXTRACT_TIMESTAMP TRUE + EXCLUDE_FROM_ALL ) message(STATUS "dusk: Fetching funchook") @@ -247,9 +250,11 @@ FetchContent_Declare(funchook GIT_SHALLOW TRUE GIT_PROGRESS TRUE PATCH_COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR= -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/patch_funchook.cmake + EXCLUDE_FROM_ALL ) set(FUNCHOOK_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(FUNCHOOK_BUILD_SHARED OFF CACHE BOOL "" FORCE) +set(FUNCHOOK_INSTALL OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(cxxopts json miniz funchook) if (DUSK_ENABLE_SENTRY_NATIVE) @@ -644,7 +649,16 @@ set(EXTRA_TARGETS "") if (TARGET crashpad_handler) list(APPEND EXTRA_TARGETS crashpad_handler) endif () -install(TARGETS ${BINARY_TARGETS} ${EXTRA_TARGETS} DESTINATION ${CMAKE_INSTALL_PREFIX}) +if (WIN32) + # Install the launcher and game DLL, but skip the DLL import library. + install(TARGETS ${BINARY_TARGETS} ${EXTRA_TARGETS} + RUNTIME DESTINATION ${CMAKE_INSTALL_PREFIX} + LIBRARY DESTINATION ${CMAKE_INSTALL_PREFIX} + BUNDLE DESTINATION ${CMAKE_INSTALL_PREFIX} + ) +else () + install(TARGETS ${BINARY_TARGETS} ${EXTRA_TARGETS} DESTINATION ${CMAKE_INSTALL_PREFIX}) +endif () aurora_install_runtime_dlls(dusk ${CMAKE_INSTALL_PREFIX}) if (NOT APPLE) install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/res DESTINATION ${CMAKE_INSTALL_PREFIX}) From 0d6b47ac73c1de320d4bca09cc0fb60ca5568c93 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 24 Apr 2026 11:56:31 -0600 Subject: [PATCH 20/48] Move mods menu after tools --- src/dusk/imgui/ImGuiConsole.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index 5a15b0a1f4..bed1ee1a3f 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -329,8 +329,8 @@ namespace dusk { ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); if (showMenu && ImGui::BeginMainMenuBar()) { m_menuGame.draw(); - m_menuMods.draw(); m_menuTools.draw(); + m_menuMods.draw(); const auto fpsLabel = fmt::format(FMT_STRING("FPS: {:.2f}\n"), ImGui::GetIO().Framerate); From 5bead49902806e8bc613eb7786951827f204aae8 Mon Sep 17 00:00:00 2001 From: madeline Date: Sun, 10 May 2026 18:07:05 -0700 Subject: [PATCH 21/48] fix pch conflict --- CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index f620de430b..f37edece8c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -106,6 +106,12 @@ set(AURORA_ENABLE_RMLUI ON CACHE BOOL "Enable RmlUi UI support" FORCE) add_subdirectory(extern/aurora EXCLUDE_FROM_ALL) target_compile_definitions(aurora_mtx PRIVATE MTX_USE_PS=1) +# rmlui_core uses its own PCH which creates a duplicate PCH marker symbol when linked +# Disabling rmlui's PCH removes the conflicting marker and lets the link succeed +if (MSVC AND TARGET rmlui_core) + set_target_properties(rmlui_core PROPERTIES DISABLE_PRECOMPILE_HEADERS ON) +endif () + add_subdirectory(libs/freeverb) option(DUSK_BUILD_WARNINGS "Enable compiler warnings (off by default)") From b2871054a66cd80aaee0a8f8012efa62b5a1bb21 Mon Sep 17 00:00:00 2001 From: madeline Date: Mon, 11 May 2026 02:55:11 -0700 Subject: [PATCH 22/48] address review, rmlui, better api, catmod --- CMakeLists.txt | 12 +- cmake/DuskModSDK.cmake | 15 +- cmake/dusk_imgui_ctx.cpp | 5 - files.cmake | 3 +- include/Z2AudioLib/Z2AudioMgr.h | 2 +- include/d/d_meter2_info.h | 5 +- include/dusk/hook.hpp | 48 ++- include/dusk/hook_system.hpp | 8 +- include/dusk/mod_api.h | 75 ++-- include/dusk/mod_loader.hpp | 45 +- include/dusk/mod_utils.h | 28 ++ include/global.h | 2 +- include/m_Do/m_Do_audio.h | 2 +- .../include/JSystem/JMath/JMATrigonometric.h | 7 +- libs/JSystem/src/JMath/JMATrigonometric.cpp | 6 +- res/rml/window.rcss | 29 ++ src/Z2AudioLib/Z2AudioMgr.cpp | 2 +- src/d/d_meter2_info.cpp | 2 +- src/dusk/gx_helper.cpp | 2 - src/dusk/hook_system.cpp | 67 ++- src/dusk/imgui/ImGuiConsole.cpp | 2 - src/dusk/imgui/ImGuiConsole.hpp | 2 - src/dusk/imgui/ImGuiMenuMods.cpp | 86 ---- src/dusk/imgui/ImGuiMenuMods.hpp | 15 - src/dusk/mod_loader.cpp | 397 ++++++++++++++---- src/dusk/ui/menu_bar.cpp | 2 + src/dusk/ui/mods_window.cpp | 125 ++++++ src/dusk/ui/mods_window.hpp | 23 + src/f_ap/f_ap_game.cpp | 2 +- src/m_Do/m_Do_audio.cpp | 2 +- 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 +++++++++++ tools/mod_test/src/mod.cpp | 174 ++++---- 35 files changed, 1067 insertions(+), 397 deletions(-) delete mode 100644 cmake/dusk_imgui_ctx.cpp create mode 100644 include/dusk/mod_utils.h delete mode 100644 src/dusk/imgui/ImGuiMenuMods.cpp delete mode 100644 src/dusk/imgui/ImGuiMenuMods.hpp create mode 100644 src/dusk/ui/mods_window.cpp create mode 100644 src/dusk/ui/mods_window.hpp create mode 100644 tools/cat_mod/CMakeLists.txt create mode 100644 tools/cat_mod/mod.json create mode 100644 tools/cat_mod/res/.gitkeep create mode 100644 tools/cat_mod/src/mod.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f37edece8c..7f05b4ca21 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,7 @@ if (MSVC AND TARGET rmlui_core) set_target_properties(rmlui_core PROPERTIES DISABLE_PRECOMPILE_HEADERS ON) endif () + add_subdirectory(libs/freeverb) option(DUSK_BUILD_WARNINGS "Enable compiler warnings (off by default)") @@ -457,6 +458,7 @@ set_source_files_properties( foreach(jsystem_lib IN LISTS JSYSTEM_LIBRARIES) target_compile_definitions(${jsystem_lib} PRIVATE ${GAME_COMPILE_DEFS} + DUSK_BUILDING_GAME=1 $<$:DEBUG=1> $<$:PARTIAL_DEBUG=1> ) @@ -481,7 +483,8 @@ elseif(WIN32) add_library(dusk_game SHARED ${DUSK_FILES}) set_target_properties(dusk_game PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON - OUTPUT_NAME dusk) + OUTPUT_NAME dusk + PDB_NAME dusk_game) add_executable(dusk WIN32 src/dusk/launcher_win32.cpp) target_link_libraries(dusk PRIVATE dusk_game) @@ -492,10 +495,15 @@ else () set(DUSK_MAIN_TARGET dusk) endif () +if (WIN32 AND TARGET imgui) + target_compile_definitions(imgui PRIVATE "IMGUI_API=__declspec(dllexport)") + target_sources(${DUSK_MAIN_TARGET} PRIVATE $) +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}) target_link_libraries(${DUSK_MAIN_TARGET} PRIVATE aurora::main ${GAME_LIBS} ${JSYSTEM_LINK_LIBRARIES}) -target_precompile_headers(${DUSK_MAIN_TARGET} PRIVATE "$<$:${CMAKE_SOURCE_DIR}/include/dusk_pch.hpp>") +target_precompile_headers(${DUSK_MAIN_TARGET} PRIVATE "$<$:${CMAKE_CURRENT_LIST_DIR}/include/dusk_pch.hpp>") if(WIN32) target_link_libraries(${DUSK_MAIN_TARGET} PRIVATE Psapi) diff --git a/cmake/DuskModSDK.cmake b/cmake/DuskModSDK.cmake index 9ae8e92b93..286725ae01 100644 --- a/cmake/DuskModSDK.cmake +++ b/cmake/DuskModSDK.cmake @@ -7,8 +7,7 @@ function(add_dusk_mod target_name) message(FATAL_ERROR "add_dusk_mod: MOD_JSON is required") endif() - add_library(${target_name} SHARED ${ARG_SOURCES} - "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/dusk_imgui_ctx.cpp") + add_library(${target_name} SHARED ${ARG_SOURCES}) set_target_properties(${target_name} PROPERTIES PREFIX "" WINDOWS_EXPORT_ALL_SYMBOLS ON) target_compile_features(${target_name} PRIVATE cxx_std_20) target_link_libraries(${target_name} PRIVATE dusk_game_headers) @@ -21,20 +20,10 @@ function(add_dusk_mod target_name) target_link_libraries(${target_name} PRIVATE dusk_game) if(MSVC) target_link_options(${target_name} PRIVATE /INCREMENTAL:NO) - set_target_properties(${target_name} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreadedDLL") + set_target_properties(${target_name} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") endif() endif() - if(TARGET imgui) - if(WIN32) - target_link_libraries(${target_name} PRIVATE imgui) - else() - get_target_property(_inc imgui INTERFACE_INCLUDE_DIRECTORIES) - if(_inc) - target_include_directories(${target_name} PRIVATE ${_inc}) - endif() - endif() - endif() set(_stage "${CMAKE_CURRENT_BINARY_DIR}/${target_name}_stage") set(_out "${DUSK_MODS_OUTPUT_DIR}/${target_name}.dusk") diff --git a/cmake/dusk_imgui_ctx.cpp b/cmake/dusk_imgui_ctx.cpp deleted file mode 100644 index 48848ffcee..0000000000 --- a/cmake/dusk_imgui_ctx.cpp +++ /dev/null @@ -1,5 +0,0 @@ -#include "imgui.h" - -extern "C" void dusk_mod_set_imgui_ctx(void* ctx) { - ImGui::SetCurrentContext(static_cast(ctx)); -} diff --git a/files.cmake b/files.cmake index b141291e7b..45e6518d0c 100644 --- a/files.cmake +++ b/files.cmake @@ -1491,6 +1491,8 @@ set(DUSK_FILES src/dusk/ui/pane.hpp src/dusk/ui/menu_bar.cpp src/dusk/ui/menu_bar.hpp + src/dusk/ui/mods_window.cpp + src/dusk/ui/mods_window.hpp src/dusk/ui/prelaunch.cpp src/dusk/ui/prelaunch.hpp src/dusk/ui/preset.cpp @@ -1517,7 +1519,6 @@ set(DUSK_FILES src/dusk/OSMutex.cpp src/dusk/hook_system.cpp src/dusk/mod_loader.cpp - src/dusk/imgui/ImGuiMenuMods.cpp src/dusk/gx_helper.cpp src/dusk/discord.cpp src/dusk/discord.hpp diff --git a/include/Z2AudioLib/Z2AudioMgr.h b/include/Z2AudioLib/Z2AudioMgr.h index e543bf6061..020eb7681a 100644 --- a/include/Z2AudioLib/Z2AudioMgr.h +++ b/include/Z2AudioLib/Z2AudioMgr.h @@ -34,7 +34,7 @@ class Z2AudioMgr : public Z2SeMgr, public Z2SeqMgr, public Z2SceneMgr, public Z2 bool isResetting() { return mResettingFlag; } static Z2AudioMgr* getInterface() { return mAudioMgrPtr; } - static Z2AudioMgr* mAudioMgrPtr; + static DUSK_GAME_DATA Z2AudioMgr* mAudioMgrPtr; /* 0x0514 */ virtual bool startSound(JAISoundID soundID, JAISoundHandle* handle, const JGeometry::TVec3* posPtr); /* 0x0518 */ bool mResettingFlag; diff --git a/include/d/d_meter2_info.h b/include/d/d_meter2_info.h index 51f07f4dc3..a941d68cb9 100644 --- a/include/d/d_meter2_info.h +++ b/include/d/d_meter2_info.h @@ -2,6 +2,7 @@ #define D_METER_D_METER2_INFO_H #include "SSystem/SComponent/c_xyz.h" +#include "global.h" class CPaneMgr; class J2DTextBox; @@ -301,7 +302,7 @@ class dMeter2Info_c { /* 0xF3 */ u8 unk_0xf3[5]; }; -extern dMeter2Info_c g_meter2_info; +DUSK_GAME_EXTERN dMeter2Info_c g_meter2_info; void dMeter2Info_setSword(u8 i_itemId, bool i_offItemBit); void dMeter2Info_setCloth(u8 i_clothId, bool i_offItemBit); @@ -849,6 +850,8 @@ inline void dMeter2Info_setFloatingMessage(u16 i_msgID, s16 i_msgTimer, bool i_w g_meter2_info.setFloatingMessage(i_msgID, i_msgTimer, i_wakuVisible); } +// Show a custom text notification using the floating-message HUD display. + inline void dMeter2Info_setMiniGameCount(s8 i_count) { g_meter2_info.setMiniGameCount(i_count); } diff --git a/include/dusk/hook.hpp b/include/dusk/hook.hpp index eac28a7c4e..5725e07e29 100644 --- a/include/dusk/hook.hpp +++ b/include/dusk/hook.hpp @@ -35,19 +35,54 @@ struct HookEntryBase { static R trampoline(Self self, A... args) { void* ptrs[] = {static_cast(std::addressof(self)), static_cast(std::addressof(args))...}; - const bool cancel = g_api->hook_dispatch_pre(mfpAddr(MFP), static_cast(ptrs)); if constexpr (std::is_void_v) { + const bool cancel = g_api->hook_dispatch_pre(mfpAddr(MFP), static_cast(ptrs), nullptr); if (!cancel) g_orig(self, args...); - g_api->hook_dispatch_post(mfpAddr(MFP), static_cast(ptrs)); + g_api->hook_dispatch_post(mfpAddr(MFP), static_cast(ptrs), nullptr); } else { R result{}; + const bool cancel = g_api->hook_dispatch_pre(mfpAddr(MFP), static_cast(ptrs), static_cast(std::addressof(result))); if (!cancel) result = g_orig(self, args...); - g_api->hook_dispatch_post(mfpAddr(MFP), static_cast(ptrs)); + g_api->hook_dispatch_post(mfpAddr(MFP), static_cast(ptrs), static_cast(std::addressof(result))); return result; } } }; +template +struct HookEntryFreeBase { + static inline Orig g_orig = nullptr; + + static R trampoline(A... args) { + if constexpr (sizeof...(A) == 0) { + if constexpr (std::is_void_v) { + const bool cancel = g_api->hook_dispatch_pre(mfpAddr(FP), nullptr, nullptr); + if (!cancel) g_orig(args...); + g_api->hook_dispatch_post(mfpAddr(FP), nullptr, nullptr); + } else { + R result{}; + const bool cancel = g_api->hook_dispatch_pre(mfpAddr(FP), nullptr, static_cast(std::addressof(result))); + if (!cancel) result = g_orig(args...); + g_api->hook_dispatch_post(mfpAddr(FP), nullptr, static_cast(std::addressof(result))); + return result; + } + } else { + void* ptrs[] = {static_cast(std::addressof(args))...}; + if constexpr (std::is_void_v) { + const bool cancel = g_api->hook_dispatch_pre(mfpAddr(FP), static_cast(ptrs), nullptr); + if (!cancel) g_orig(args...); + g_api->hook_dispatch_post(mfpAddr(FP), static_cast(ptrs), nullptr); + } else { + R result{}; + const bool cancel = g_api->hook_dispatch_pre(mfpAddr(FP), static_cast(ptrs), static_cast(std::addressof(result))); + if (!cancel) result = g_orig(args...); + g_api->hook_dispatch_post(mfpAddr(FP), static_cast(ptrs), static_cast(std::addressof(result))); + return result; + } + } + } +}; + template struct HookEntry; @@ -57,6 +92,9 @@ struct HookEntry : HookEntryBase {}; template struct HookEntry : HookEntryBase {}; +template +struct HookEntry : HookEntryFreeBase {}; + template void hookAddPre(int32_t (*fn)(void* args)) { using E = HookEntry; @@ -66,7 +104,7 @@ void hookAddPre(int32_t (*fn)(void* args)) { } template -void hookAddPost(void (*fn)(void* args)) { +void hookAddPost(void (*fn)(void* args, void* retval)) { using E = HookEntry; g_api->hook_install(mfpAddr(MFP), reinterpret_cast(E::trampoline), reinterpret_cast(&E::g_orig)); @@ -74,7 +112,7 @@ void hookAddPost(void (*fn)(void* args)) { } template -void hookSetReplace(void (*fn)(void* args)) { +void hookSetReplace(void (*fn)(void* args, void* retval)) { using E = HookEntry; g_api->hook_install(mfpAddr(MFP), reinterpret_cast(E::trampoline), reinterpret_cast(&E::g_orig)); diff --git a/include/dusk/hook_system.hpp b/include/dusk/hook_system.hpp index c173a3c44b..ffd32c318e 100644 --- a/include/dusk/hook_system.hpp +++ b/include/dusk/hook_system.hpp @@ -7,11 +7,11 @@ namespace dusk { void hookInstallByAddr(void* fn_addr, void* tramp_fn, void** orig_store); void hookRegisterPre (void* fn_addr, void* mod, int32_t (*fn)(void* args)); -void hookRegisterPost(void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args)); -bool hookSetReplace (void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args)); +void hookRegisterPost(void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args, void* retval)); +bool hookSetReplace (void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args, void* retval)); -bool hookDispatchPre (void* fn_addr, void* args); -void hookDispatchPost(void* fn_addr, void* args); +bool hookDispatchPre (void* fn_addr, void* args, void* retval); +void hookDispatchPost(void* fn_addr, void* args, void* retval); void hookClearMod(void* mod); diff --git a/include/dusk/mod_api.h b/include/dusk/mod_api.h index 8fa2f557f7..af3b9571c9 100644 --- a/include/dusk/mod_api.h +++ b/include/dusk/mod_api.h @@ -1,58 +1,65 @@ -#ifndef DUSK_MOD_API_H -#define DUSK_MOD_API_H +#pragma once -#include -#include -#include +#include +#include -#ifdef __cplusplus -extern "C" { +#if defined(_WIN32) +#define DUSK_MOD_EXPORT __declspec(dllexport) +#else +#define DUSK_MOD_EXPORT __attribute__((visibility("default"))) #endif #define DUSK_MOD_API_VERSION 1 -#if defined(_WIN32) -# define DUSK_MOD_EXPORT __declspec(dllexport) -#else -# define DUSK_MOD_EXPORT __attribute__((visibility("default"))) -#endif +typedef void* DuskPanelHandle; +typedef void* DuskElemHandle; // 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 \ - DUSK_MOD_EXPORT uint32_t mod_api_version = DUSK_MOD_API_VERSION; +#define DUSK_REQUIRE_API_VERSION \ + extern "C" DUSK_MOD_EXPORT uint32_t mod_api_version = DUSK_MOD_API_VERSION; -typedef struct DuskModAPI { - uint32_t api_version; +struct DuskModAPIv1 { + uint32_t api_version; const char* mod_dir; - void (*log_info) (const char* fmt, ...); - void (*log_warn) (const char* fmt, ...); + void (*log_info)(const char* fmt, ...); + void (*log_warn)(const char* fmt, ...); void (*log_error)(const char* fmt, ...); void* (*load_resource)(const char* relative_path, size_t* out_size); - void (*free_resource)(void* data); + void (*free_resource)(void* data); + + void (*register_tab_content)( + void (*build_fn)(DuskPanelHandle panel, void* userdata), void* userdata); + void (*register_tab_update)(void (*update_fn)(void* userdata), void* userdata); - void (*register_tab_content)(void (*draw_fn)(void* userdata), void* userdata); - void (*register_menu_item) (void (*draw_fn)(void* userdata), void* userdata); + void (*panel_add_section)(DuskPanelHandle panel, const char* text); + void (*panel_add_button)( + DuskPanelHandle panel, const char* label, void (*cb)(void* userdata), void* userdata); + DuskElemHandle (*panel_add_badge_row)(DuskPanelHandle panel, const char* label, int ok); + DuskElemHandle (*panel_add_dyn_text)(DuskPanelHandle panel, const char* text); + DuskElemHandle (*panel_add_progress)(DuskPanelHandle panel, float value); + + void (*elem_set_badge)(DuskElemHandle elem, int ok); + void (*elem_set_text)(DuskElemHandle elem, const char* text); + void (*elem_set_progress)(DuskElemHandle elem, float value); void (*hook_install)(void* fn_addr, void* tramp_fn, void** orig_store); - void (*hook_pre) (void* fn_addr, int32_t (*fn)(void* args)); - void (*hook_post) (void* fn_addr, void (*fn)(void* args)); - void (*hook_replace)(void* fn_addr, void (*fn)(void* args)); + void (*hook_pre)(void* fn_addr, int32_t (*fn)(void* args)); + void (*hook_post)(void* fn_addr, void (*fn)(void* args, void* retval)); + void (*hook_replace)(void* fn_addr, void (*fn)(void* args, void* retval)); + + bool (*hook_dispatch_pre)(void* fn_addr, void* args, void* retval); + void (*hook_dispatch_post)(void* fn_addr, void* args, void* retval); - bool (*hook_dispatch_pre) (void* fn_addr, void* args); - void (*hook_dispatch_post)(void* fn_addr, void* args); + void (*service_publish)(const char* name, void* ptr); + void* (*service_get)(const char* name); +}; - void (*service_publish)(const char* name, void* ptr); - void* (*service_get) (const char* name); -} DuskModAPI; +using DuskModAPI = DuskModAPIv1; +extern "C" { void mod_init(DuskModAPI* api); void mod_tick(DuskModAPI* api); - -#ifdef __cplusplus } -#endif - -#endif diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 5500facd72..f9c488ce01 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -5,11 +5,17 @@ #include #include "dusk/mod_api.h" +#include "miniz.h" namespace dusk { -struct ModDrawCallback { - void (*draw_fn)(void* userdata); +struct RmlTabContentCallback { + void (*build_fn)(void* panel, void* userdata); + void* userdata; +}; + +struct RmlTabUpdateCallback { + void (*update_fn)(void* userdata); void* userdata; }; @@ -21,24 +27,26 @@ struct LoadedMod { std::string mod_path; std::string dir; - void* handle = nullptr; - bool active = false; - bool load_failed = false; + void* handle = nullptr; + bool active = false; + bool load_failed = false; - using FnInit = void (*)(DuskModAPI*); - using FnTick = void (*)(DuskModAPI*); - using FnCleanup = void (*)(DuskModAPI*); - using FnSetImguiCtx = void (*)(void*); + using FnInit = void (*)(DuskModAPI*); + using FnTick = void (*)(DuskModAPI*); + using FnCleanup = void (*)(DuskModAPI*); - FnInit fn_init = nullptr; - FnTick fn_tick = nullptr; - FnCleanup fn_cleanup = nullptr; - FnSetImguiCtx fn_set_imgui_ctx = nullptr; + FnInit fn_init = nullptr; + FnTick fn_tick = nullptr; + FnCleanup fn_cleanup = nullptr; DuskModAPI api{}; - std::vector tab_content; - std::vector menu_items; + std::vector zip_data; + mz_zip_archive res_zip{}; + bool res_zip_open = false; + + std::vector tab_content; + std::vector tab_updates; }; class ModLoader { @@ -51,15 +59,14 @@ class ModLoader { void shutdown(); const std::vector& mods() const { return m_mods; } - static void callDrawCallback(const LoadedMod& mod, const ModDrawCallback& cb); private: std::vector m_mods; - std::filesystem::path m_modsDir; - bool m_initialized = false; + std::filesystem::path m_modsDir; + bool m_initialized = false; void tryLoadDusk(const std::filesystem::path& modPath); void buildAPI(LoadedMod& mod); }; -} // namespace dusk +} // namespace dusk diff --git a/include/dusk/mod_utils.h b/include/dusk/mod_utils.h new file mode 100644 index 0000000000..9dc7efa533 --- /dev/null +++ b/include/dusk/mod_utils.h @@ -0,0 +1,28 @@ +#pragma once + +#include "f_op/f_op_actor_mng.h" +#include "f_pc/f_pc_layer.h" +#include "f_pc/f_pc_manager.h" +#include "f_pc/f_pc_node.h" +#include "m_Do/m_Do_controller_pad.h" + +// Remove a button from this frame's trigger state so the game won't see it +// Call after detecting a combo in mod_tick to prevent double-processing +inline void consumeInput(u32 pad, u32 buttonMask) { + mDoCPd_c::getCpadInfo(pad).mPressedButtonFlags &= ~buttonMask; +} + +// Spawn an actor in the play scene layer +// calling fopAcM_create directly outside game simulation context creates the actor in the wrong +// layer, corrupting its first-frame rendering setup +inline fpc_ProcID fopAcM_createInPlayScene(s16 proc_name, u32 params, const cXyz* pos, int room_no, + const csXyz* angle, const cXyz* scale, s8 argument) { + layer_class* savedLayer = fpcLy_CurrentLayer(); + base_process_class* playScene = fpcM_SearchByName(fpcNm_PLAY_SCENE_e); + if (playScene != nullptr) { + fpcLy_SetCurrentLayer(&((process_node_class*)playScene)->layer); + } + fpc_ProcID result = fopAcM_create(proc_name, params, pos, room_no, angle, scale, argument); + fpcLy_SetCurrentLayer(savedLayer); + return result; +} diff --git a/include/global.h b/include/global.h index 11415b0737..4cc57e2a2c 100644 --- a/include/global.h +++ b/include/global.h @@ -120,7 +120,7 @@ inline int __builtin_clz(unsigned int v) { # define DUSK_GAME_EXTERN extern __declspec(dllimport) # define DUSK_GAME_DATA __declspec(dllimport) #elif defined(TARGET_PC) && defined(_WIN32) && defined(DUSK_BUILDING_GAME) -# define DUSK_GAME_EXTERN extern +# define DUSK_GAME_EXTERN extern __declspec(dllexport) # define DUSK_GAME_DATA __declspec(dllexport) #else # define DUSK_GAME_EXTERN extern diff --git a/include/m_Do/m_Do_audio.h b/include/m_Do/m_Do_audio.h index 4bc2f20c94..db341f3327 100644 --- a/include/m_Do/m_Do_audio.h +++ b/include/m_Do/m_Do_audio.h @@ -33,7 +33,7 @@ class mDoAud_zelAudio_c : public Z2AudioMgr { static void onBgmSet() { mBgmSet = true; } static void offBgmSet() { mBgmSet = false; } - static u8 mInitFlag; + static DUSK_GAME_DATA u8 mInitFlag; static u8 mResetFlag; static u8 mBgmSet; }; diff --git a/libs/JSystem/include/JSystem/JMath/JMATrigonometric.h b/libs/JSystem/include/JSystem/JMath/JMATrigonometric.h index e51296ba3b..b53cd97326 100644 --- a/libs/JSystem/include/JSystem/JMath/JMATrigonometric.h +++ b/libs/JSystem/include/JSystem/JMath/JMATrigonometric.h @@ -4,6 +4,7 @@ #include #include #include +#include "global.h" #ifdef __cplusplus extern "C" { @@ -141,9 +142,9 @@ struct TAsinAcosTable { } }; -extern TSinCosTable<13, f32> sincosTable_; -extern TAtanTable<1024, f32> atanTable_; -extern TAsinAcosTable<1024, f32> asinAcosTable_; +DUSK_GAME_EXTERN TSinCosTable<13, f32> sincosTable_; +DUSK_GAME_EXTERN TAtanTable<1024, f32> atanTable_; +DUSK_GAME_EXTERN TAsinAcosTable<1024, f32> asinAcosTable_; inline f32 acosDegree(f32 x) { return asinAcosTable_.acosDegree(x); diff --git a/libs/JSystem/src/JMath/JMATrigonometric.cpp b/libs/JSystem/src/JMath/JMATrigonometric.cpp index 5d394ac9c2..8ebdb57ebc 100644 --- a/libs/JSystem/src/JMath/JMATrigonometric.cpp +++ b/libs/JSystem/src/JMath/JMATrigonometric.cpp @@ -16,10 +16,10 @@ inline f64 getConst2() { return 9.765625E-4; } -TSinCosTable<13, f32> sincosTable_ ATTRIBUTE_ALIGN(32); +DUSK_GAME_DATA TSinCosTable<13, f32> sincosTable_ ATTRIBUTE_ALIGN(32); -TAtanTable<1024, f32> atanTable_ ATTRIBUTE_ALIGN(32); +DUSK_GAME_DATA TAtanTable<1024, f32> atanTable_ ATTRIBUTE_ALIGN(32); -TAsinAcosTable<1024, f32> asinAcosTable_ ATTRIBUTE_ALIGN(32); +DUSK_GAME_DATA TAsinAcosTable<1024, f32> asinAcosTable_ ATTRIBUTE_ALIGN(32); } // namespace JMath diff --git a/res/rml/window.rcss b/res/rml/window.rcss index 45473f312f..286301fd89 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -380,6 +380,11 @@ progress.progress-ongoing fill { border-radius: 3dp; } +progress.progress-health fill { + background-color: #cc3322; + border-radius: 3dp; +} + button.achievement-clear { flex: 0 0 auto; align-self: center; @@ -494,6 +499,30 @@ progress.verification-progress-bar { color: rgba(224, 219, 200, 65%); } +.mod-info-row { + display: flex; + align-items: center; + gap: 12dp; + padding: 4dp 0; +} + +.mod-info-label { + font-family: "Fira Sans Condensed"; + font-weight: bold; + opacity: 0.55; + flex: 0 0 80dp; +} + +.mod-info-value { + flex: 1 1 0; +} + +.mod-path { + font-size: 14dp; + word-break: break-all; + opacity: 0.7; +} + .modal-actions { display: flex; flex-direction: row; diff --git a/src/Z2AudioLib/Z2AudioMgr.cpp b/src/Z2AudioLib/Z2AudioMgr.cpp index d6d78fb4b7..2af3d39662 100644 --- a/src/Z2AudioLib/Z2AudioMgr.cpp +++ b/src/Z2AudioLib/Z2AudioMgr.cpp @@ -19,7 +19,7 @@ #include "Z2AudioCS/Z2AudioCS.h" #endif -Z2AudioMgr* Z2AudioMgr::mAudioMgrPtr; +DUSK_GAME_DATA Z2AudioMgr* Z2AudioMgr::mAudioMgrPtr; u8 gMuffleOutOfRangeMic = false; Z2AudioMgr::Z2AudioMgr() : mSoundStarter(true) { diff --git a/src/d/d_meter2_info.cpp b/src/d/d_meter2_info.cpp index bb533e6370..ba527c7685 100644 --- a/src/d/d_meter2_info.cpp +++ b/src/d/d_meter2_info.cpp @@ -592,7 +592,7 @@ BOOL dMeter2Info_c::isDirectUseItem(int param_0) { return (mDirectUseItem & (u8)(1 << param_0)) ? TRUE : FALSE; } -dMeter2Info_c g_meter2_info; +DUSK_GAME_DATA dMeter2Info_c g_meter2_info; int dMeter2Info_c::setMeterString(s32 i_string) { if (mMeterString != 0) { diff --git a/src/dusk/gx_helper.cpp b/src/dusk/gx_helper.cpp index 9f9e321d15..9c7c91b69a 100644 --- a/src/dusk/gx_helper.cpp +++ b/src/dusk/gx_helper.cpp @@ -1,9 +1,7 @@ #include "dusk/gx_helper.h" -#ifdef TARGET_PC GXTexObjRAII::~GXTexObjRAII() { GXDestroyTexObj(this); } void GXTexObjRAII::reset() { GXDestroyTexObj(this); } -#endif GXScopedDebugGroup::GXScopedDebugGroup(const char* text) { GXPushDebugGroup(text); diff --git a/src/dusk/hook_system.cpp b/src/dusk/hook_system.cpp index 54189dfcd5..10c2ddf17d 100644 --- a/src/dusk/hook_system.cpp +++ b/src/dusk/hook_system.cpp @@ -10,7 +10,7 @@ namespace dusk { -extern void* g_dusk_hook_current_mod; +extern thread_local void* g_dusk_hook_current_mod; struct PreHookFn { void* mod; @@ -19,7 +19,7 @@ struct PreHookFn { struct VoidHookFn { void* mod; const char* mod_name; - void (*fn)(void* args); + void (*fn)(void* args, void* retval); }; struct HookSlot { @@ -28,18 +28,12 @@ struct HookSlot { std::vector post; }; -static std::unordered_map& registry() { - static std::unordered_map s; - return s; -} -static std::unordered_map& installed() { - static std::unordered_map s; - return s; -} +static std::unordered_map s_registry; +static std::unordered_map s_installed; // Follow E9/FF25 chains to skip MSVC incremental-link and import stubs static void* resolveImportThunk(void* addr) { -#if _WIN32 +#if defined(_WIN32) && (defined(_M_X64) || defined(__x86_64__)) for (int i = 0; i < 8; ++i) { const auto* p = static_cast(addr); if (p[0] == 0xFF && p[1] == 0x25) { @@ -51,8 +45,9 @@ static void* resolveImportThunk(void* addr) { int32_t offset; std::memcpy(&offset, p + 1, 4); addr = const_cast(p) + 5 + offset; - } else + } else { break; + } } #endif return addr; @@ -67,8 +62,8 @@ struct ModGuard { void hookInstallByAddr(void* fn_addr, void* tramp_fn, void** orig_store) { fn_addr = resolveImportThunk(fn_addr); auto key = reinterpret_cast(fn_addr); - auto it = installed().find(key); - if (it != installed().end()) { + auto it = s_installed.find(key); + if (it != s_installed.end()) { *orig_store = it->second; return; } @@ -78,60 +73,63 @@ void hookInstallByAddr(void* fn_addr, void* tramp_fn, void** orig_store) { int prep = funchook_prepare(fh, &fn, tramp_fn); int inst = (prep == 0) ? funchook_install(fh, 0) : -1; if (prep != 0 || inst != 0) { - DuskLog.warn("HookSystem: funchook failed for {:p} (prepare={} install={})", fn_addr, prep, - inst); + DuskLog.warn( + "HookSystem: funchook failed for {:p} (prepare={} install={})", fn_addr, prep, inst); funchook_destroy(fh); return; } funchook_destroy(fh); - installed()[key] = fn; + s_installed[key] = fn; *orig_store = fn; } -bool hookDispatchPre(void* fn_addr, void* args) { - auto it = registry().find(reinterpret_cast(fn_addr)); - if (it == registry().end()) +bool hookDispatchPre(void* fn_addr, void* args, void* retval) { + auto it = s_registry.find(reinterpret_cast(fn_addr)); + if (it == s_registry.end()) { return false; + } auto& slot = it->second; for (auto& h : slot.pre) { ModGuard g(h.mod); - if (h.fn(args) != 0) + if (h.fn(args) != 0) { return true; + } } if (slot.replace.fn) { ModGuard g(slot.replace.mod); - slot.replace.fn(args); + slot.replace.fn(args, retval); return true; } return false; } -void hookDispatchPost(void* fn_addr, void* args) { - auto it = registry().find(reinterpret_cast(fn_addr)); - if (it == registry().end()) +void hookDispatchPost(void* fn_addr, void* args, void* retval) { + auto it = s_registry.find(reinterpret_cast(fn_addr)); + if (it == s_registry.end()) { return; + } for (auto& h : it->second.post) { if (h.fn) { ModGuard g(h.mod); - h.fn(args); + h.fn(args, retval); } } } void hookRegisterPre(void* fn_addr, void* mod, int32_t (*fn)(void* args)) { - registry()[reinterpret_cast(fn_addr)].pre.push_back({mod, fn}); + s_registry[reinterpret_cast(fn_addr)].pre.push_back({mod, fn}); } -void hookRegisterPost(void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args)) { - registry()[reinterpret_cast(fn_addr)].post.push_back({mod, mod_name, fn}); +void hookRegisterPost(void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args, void* retval)) { + s_registry[reinterpret_cast(fn_addr)].post.push_back({mod, mod_name, fn}); } -bool hookSetReplace(void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args)) { - auto& slot = registry()[reinterpret_cast(fn_addr)]; +bool hookSetReplace(void* fn_addr, void* mod, const char* mod_name, void (*fn)(void* args, void* retval)) { + auto& slot = s_registry[reinterpret_cast(fn_addr)]; if (slot.replace.fn) { DuskLog.error("HookSystem: '{}' conflicts with '{}', both replace the same function", - mod_name, slot.replace.mod_name); + mod_name, slot.replace.mod_name); return false; } slot.replace = {mod, mod_name, fn}; @@ -139,7 +137,7 @@ bool hookSetReplace(void* fn_addr, void* mod, const char* mod_name, void (*fn)(v } void hookClearMod(void* mod) { - for (auto& [addr, slot] : registry()) { + for (auto& [addr, slot] : s_registry) { auto erase = [&](auto& v) { v.erase( std::remove_if(v.begin(), v.end(), [mod](const auto& h) { return h.mod == mod; }), @@ -147,8 +145,9 @@ void hookClearMod(void* mod) { }; erase(slot.pre); erase(slot.post); - if (slot.replace.mod == mod) + if (slot.replace.mod == mod) { slot.replace = {}; + } } } diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index f1246bbcb8..94ba944899 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -276,7 +276,6 @@ namespace dusk { if (showMenu && ImGui::BeginMainMenuBar()) { m_menuGame.draw(); m_menuTools.draw(); - m_menuMods.draw(); ImGui::EndMainMenuBar(); } @@ -376,7 +375,6 @@ namespace dusk { m_menuTools.ShowPlayerInfo(); m_menuTools.ShowAudioDebug(); m_menuTools.ShowSaveEditor(); - m_menuMods.showModsWindow(); m_menuTools.ShowStateShare(); m_menuTools.ShowActorSpawner(); } diff --git a/src/dusk/imgui/ImGuiConsole.hpp b/src/dusk/imgui/ImGuiConsole.hpp index 8c9d6b1929..6362a146b6 100644 --- a/src/dusk/imgui/ImGuiConsole.hpp +++ b/src/dusk/imgui/ImGuiConsole.hpp @@ -8,7 +8,6 @@ #include #include "ImGuiMenuGame.hpp" -#include "ImGuiMenuMods.hpp" #include "ImGuiMenuTools.hpp" #include "dusk/main.h" #include "imgui.h" @@ -46,7 +45,6 @@ class ImGuiConsole { std::deque m_toasts; ImGuiMenuGame m_menuGame; - ImGuiMenuMods m_menuMods; // Keep always last ImGuiMenuTools m_menuTools; diff --git a/src/dusk/imgui/ImGuiMenuMods.cpp b/src/dusk/imgui/ImGuiMenuMods.cpp deleted file mode 100644 index 07d362696f..0000000000 --- a/src/dusk/imgui/ImGuiMenuMods.cpp +++ /dev/null @@ -1,86 +0,0 @@ -#include "ImGuiMenuMods.hpp" - -#include "ImGuiConsole.hpp" -#include "dusk/mod_loader.hpp" -#include "imgui.h" - -namespace dusk { - -void ImGuiMenuMods::draw() { - const auto& mods = ModLoader::instance().mods(); - if (mods.empty()) return; - - if (ImGui::BeginMenu("Mods")) { - if (ImGui::MenuItem("Mod Manager", nullptr, m_showWindow)) { - m_showWindow = !m_showWindow; - } - - for (const auto& mod : mods) { - if (mod.menu_items.empty()) continue; - ImGui::Separator(); - if (ImGui::BeginMenu(mod.name.c_str())) { - for (const auto& item : mod.menu_items) { - ModLoader::callDrawCallback(mod, item); - } - ImGui::EndMenu(); - } - } - - ImGui::EndMenu(); - } -} - -void ImGuiMenuMods::showModsWindow() { - if (!m_showWindow) return; - - ImGui::SetNextWindowSize(ImVec2(520, 420), ImGuiCond_FirstUseEver); - if (!ImGui::Begin("Mod Manager", &m_showWindow)) { - ImGui::End(); - return; - } - - const auto& mods = ModLoader::instance().mods(); - if (mods.empty()) { - ImGuiTextCenter("No mods loaded."); - ImGui::End(); - return; - } - - if (ImGui::BeginTabBar("##ModsOuter")) { - for (const auto& mod : mods) { - const std::string tabLabel = mod.name + (mod.load_failed ? " [failed]" : mod.active ? "" : " [disabled]"); - - if (ImGui::BeginTabItem(tabLabel.c_str())) { - ImGui::Text("Version: %s", mod.version.c_str()); - ImGui::Text("Author: %s", mod.author.c_str()); - - if (mod.load_failed) { - ImGui::TextColored(ImVec4(1.f, 0.3f, 0.3f, 1.f), "Status: Failed to load"); - } else { - ImGui::Text("Status: %s", mod.active ? "Active" : "Disabled"); - } - - ImGui::Text("Path: %s", mod.mod_path.c_str()); - - if (!mod.description.empty()) { - ImGui::Separator(); - ImGui::TextWrapped("%s", mod.description.c_str()); - } - - if (!mod.load_failed) { - for (const auto& cb : mod.tab_content) { - ImGui::Separator(); - ModLoader::callDrawCallback(mod, cb); - } - } - - ImGui::EndTabItem(); - } - } - ImGui::EndTabBar(); - } - - ImGui::End(); -} - -} // namespace dusk diff --git a/src/dusk/imgui/ImGuiMenuMods.hpp b/src/dusk/imgui/ImGuiMenuMods.hpp deleted file mode 100644 index 09a635fe7f..0000000000 --- a/src/dusk/imgui/ImGuiMenuMods.hpp +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once - -namespace dusk { - -class ImGuiMenuMods { -public: - void draw(); - - void showModsWindow(); - -private: - bool m_showWindow = false; -}; - -} // namespace dusk diff --git a/src/dusk/mod_loader.cpp b/src/dusk/mod_loader.cpp index 8fcd6b6635..34384d6366 100644 --- a/src/dusk/mod_loader.cpp +++ b/src/dusk/mod_loader.cpp @@ -2,13 +2,16 @@ #include "dusk/hook_system.hpp" #include "dusk/logging.h" +#include + + #include #include +#include #include #include #include -#include "imgui.h" #include "miniz.h" #include "nlohmann/json.hpp" @@ -29,10 +32,11 @@ static void pl_dlclose(void* h) { static std::string pl_dlerror() { char buf[256]{}; FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, - GetLastError(), 0, buf, sizeof(buf), nullptr); + GetLastError(), 0, buf, sizeof(buf), nullptr); std::string s = buf; - while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) + while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) { s.pop_back(); + } return s; } static constexpr const char* k_libExt = ".dll"; @@ -40,7 +44,11 @@ static constexpr const char* k_libExt = ".dll"; #else #include static void* pl_dlopen(const std::filesystem::path& p) { +#if defined(__linux__) + return dlopen(p.c_str(), RTLD_LAZY | RTLD_LOCAL | RTLD_DEEPBIND); +#else return dlopen(p.c_str(), RTLD_LAZY | RTLD_LOCAL); +#endif } static void* pl_dlsym(void* h, const char* name) { return dlsym(h, name); @@ -59,11 +67,30 @@ static constexpr const char* k_libExt = ".so"; #endif #endif -static dusk::LoadedMod* g_currentMod = nullptr; +#if defined(_M_ARM64) || defined(__aarch64__) +static constexpr std::string_view k_archSuffix = "_arm64"; +#elif defined(_M_X64) || defined(__x86_64__) +static constexpr std::string_view k_archSuffix = "_x64"; +#elif defined(_M_IX86) || defined(__i386__) +static constexpr std::string_view k_archSuffix = "_x86"; +#else +static constexpr std::string_view k_archSuffix = ""; +#endif + +static FILE* fs_fopen(const std::filesystem::path& p, const char* mode) { +#if defined(_WIN32) + std::wstring wmode(mode, mode + strlen(mode)); + return _wfopen(p.wstring().c_str(), wmode.c_str()); +#else + return fopen(p.c_str(), mode); +#endif +} + +static thread_local dusk::LoadedMod* g_currentMod = nullptr; static std::unordered_map g_services; namespace dusk { -void* g_dusk_hook_current_mod = nullptr; +thread_local void* g_dusk_hook_current_mod = nullptr; } struct ModGuard { @@ -82,48 +109,61 @@ static const char* modName() { } static void cb_log_info(const char* fmt, ...) { - va_list ap, ap2; va_start(ap, fmt); va_copy(ap2, ap); - std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); va_end(ap2); - vsnprintf(s.data(), s.size() + 1, fmt, ap); va_end(ap); + va_list ap, ap2; + va_start(ap, fmt); + va_copy(ap2, ap); + std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); + va_end(ap2); + vsnprintf(s.data(), s.size() + 1, fmt, ap); + va_end(ap); DuskLog.info("[{}] {}", modName(), s); } static void cb_log_warn(const char* fmt, ...) { - va_list ap, ap2; va_start(ap, fmt); va_copy(ap2, ap); - std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); va_end(ap2); - vsnprintf(s.data(), s.size() + 1, fmt, ap); va_end(ap); + va_list ap, ap2; + va_start(ap, fmt); + va_copy(ap2, ap); + std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); + va_end(ap2); + vsnprintf(s.data(), s.size() + 1, fmt, ap); + va_end(ap); DuskLog.warn("[{}] {}", modName(), s); } static void cb_log_error(const char* fmt, ...) { - va_list ap, ap2; va_start(ap, fmt); va_copy(ap2, ap); - std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); va_end(ap2); - vsnprintf(s.data(), s.size() + 1, fmt, ap); va_end(ap); + va_list ap, ap2; + va_start(ap, fmt); + va_copy(ap2, ap); + std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); + va_end(ap2); + vsnprintf(s.data(), s.size() + 1, fmt, ap); + va_end(ap); DuskLog.error("[{}] {}", modName(), s); } static void* cb_load_resource(const char* relative_path, size_t* out_size) { - if (out_size) + if (out_size) { *out_size = 0; - if (!g_currentMod || !relative_path) + } + if (!g_currentMod || !relative_path) { + DuskLog.error("load_resource: called outside mod context or with null path"); return nullptr; - - mz_zip_archive zip{}; - if (!mz_zip_reader_init_file(&zip, g_currentMod->mod_path.c_str(), 0)) { - DuskLog.warn("[{}] load_resource: could not open {}", g_currentMod->name, - g_currentMod->mod_path); + } + if (!g_currentMod->res_zip_open) { + DuskLog.error("[{}] load_resource: zip not available", g_currentMod->name); return nullptr; } + std::string entry = std::string("res/") + relative_path; size_t sz = 0; - void* data = mz_zip_reader_extract_file_to_heap(&zip, entry.c_str(), &sz, 0); - mz_zip_reader_end(&zip); + void* data = mz_zip_reader_extract_file_to_heap(&g_currentMod->res_zip, entry.c_str(), &sz, 0); if (!data) { - DuskLog.warn("[{}] load_resource: '{}' not found in zip", g_currentMod->name, entry); + DuskLog.error("[{}] load_resource: '{}' not found in zip", g_currentMod->name, entry); return nullptr; } - if (out_size) + if (out_size) { *out_size = sz; + } return data; } @@ -131,15 +171,151 @@ static void cb_free_resource(void* data) { mz_free(data); } -static void cb_register_tab_content(void (*draw_fn)(void*), void* userdata) { - if (g_currentMod && draw_fn) - g_currentMod->tab_content.push_back({draw_fn, userdata}); +namespace { + +class ModClickListener : public Rml::EventListener { +public: + ModClickListener(void (*cb)(void*), void* ud) : m_cb(cb), m_ud(ud) {} + void ProcessEvent(Rml::Event&) override { m_cb(m_ud); } + void OnDetach(Rml::Element*) override { delete this; } +private: + void (*m_cb)(void*); + void* m_ud; +}; + +static std::string escape_rml(const char* text) { + std::string out; + for (const char* p = text; *p; ++p) { + switch (*p) { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + default: out += *p; break; + } + } + return out; +} + +} + +static void cb_panel_add_section(DuskPanelHandle panel, const char* text) { + auto* pane = static_cast(panel); + if (!pane || !text) { + return; + } + auto el = pane->GetOwnerDocument()->CreateElement("div"); + el->SetClass("section-heading", true); + el->SetInnerRML(escape_rml(text)); + pane->AppendChild(std::move(el)); +} + +static void cb_panel_add_button(DuskPanelHandle panel, const char* label, + void (*cb)(void*), void* userdata) { + auto* pane = static_cast(panel); + if (!pane || !label || !cb) { + return; + } + auto btn = pane->GetOwnerDocument()->CreateElement("button"); + btn->SetInnerRML(escape_rml(label)); + btn->AddEventListener(Rml::EventId::Click, new ModClickListener(cb, userdata)); + pane->AppendChild(std::move(btn)); +} + +static DuskElemHandle cb_panel_add_badge_row(DuskPanelHandle panel, const char* label, int ok) { + auto* pane = static_cast(panel); + if (!pane || !label) { + return nullptr; + } + auto* doc = pane->GetOwnerDocument(); + + auto row = doc->CreateElement("div"); + row->SetClass("mod-info-row", true); + + auto badge = doc->CreateElement("span"); + badge->SetClass("achievement-badge", true); + badge->SetClass(ok ? "unlocked" : "locked", true); + badge->SetInnerRML(ok ? "PASS" : "WAIT"); + Rml::Element* badgePtr = row->AppendChild(std::move(badge)); + + auto lbl = doc->CreateElement("span"); + lbl->SetClass("mod-info-value", true); + lbl->SetInnerRML(escape_rml(label)); + row->AppendChild(std::move(lbl)); + + pane->AppendChild(std::move(row)); + return static_cast(badgePtr); +} + +static DuskElemHandle cb_panel_add_dyn_text(DuskPanelHandle panel, const char* text) { + auto* pane = static_cast(panel); + if (!pane) { + return nullptr; + } + auto el = pane->GetOwnerDocument()->CreateElement("div"); + el->SetInnerRML(text ? escape_rml(text) : std::string{}); + Rml::Element* ptr = pane->AppendChild(std::move(el)); + return static_cast(ptr); +} + +static void cb_elem_set_badge(DuskElemHandle elem, int ok) { + auto* el = static_cast(elem); + if (!el) { + return; + } + el->SetClass("unlocked", ok != 0); + el->SetClass("locked", ok == 0); + el->SetInnerRML(ok ? "PASS" : "WAIT"); +} + +static void cb_elem_set_text(DuskElemHandle elem, const char* text) { + auto* el = static_cast(elem); + if (!el || !text) { + return; + } + el->SetInnerRML(escape_rml(text)); +} + +static DuskElemHandle cb_panel_add_progress(DuskPanelHandle panel, float value) { + auto* pane = static_cast(panel); + if (!pane) { + return nullptr; + } + auto el = pane->GetOwnerDocument()->CreateElement("progress"); + el->SetClass("progress-health", true); + el->SetAttribute("value", value); + Rml::Element* ptr = pane->AppendChild(std::move(el)); + return static_cast(ptr); +} + +static void cb_elem_set_progress(DuskElemHandle elem, float value) { + auto* el = static_cast(elem); + if (!el) { + return; + } + el->SetAttribute("value", value); +} + +static void cb_register_tab_content(void (*build_fn)(void*, void*), void* userdata) { + if (g_currentMod && build_fn) { + g_currentMod->tab_content.push_back({build_fn, userdata}); + } +} + +static void cb_register_tab_update(void (*update_fn)(void*), void* userdata) { + if (g_currentMod && update_fn) { + g_currentMod->tab_updates.push_back({update_fn, userdata}); + } } static void cb_service_publish(const char* name, void* ptr) { - if (name) { - g_services[name] = ptr; + if (!name) { + return; } + if (g_services.count(name)) { + DuskLog.error( + "[{}] service_publish: '{}' already published by another mod", modName(), name); + } + g_services[name] = ptr; } static void* cb_service_get(const char* name) { @@ -150,20 +326,15 @@ static void* cb_service_get(const char* name) { return it != g_services.end() ? it->second : nullptr; } -static void cb_register_menu_item(void (*draw_fn)(void*), void* userdata) { - if (g_currentMod && draw_fn) - g_currentMod->menu_items.push_back({draw_fn, userdata}); -} - static void api_hook_pre(void* addr, int32_t (*fn)(void* args)) { dusk::hookRegisterPre(addr, g_currentMod, fn); } -static void api_hook_post(void* addr, void (*fn)(void* args)) { +static void api_hook_post(void* addr, void (*fn)(void* args, void* retval)) { dusk::hookRegisterPost(addr, g_currentMod, modName(), fn); } -static void api_hook_replace(void* addr, void (*fn)(void* args)) { +static void api_hook_replace(void* addr, void (*fn)(void* args, void* retval)) { if (!dusk::hookSetReplace(addr, g_currentMod, modName(), fn)) { if (g_currentMod) { g_currentMod->load_failed = true; @@ -171,11 +342,12 @@ static void api_hook_replace(void* addr, void (*fn)(void* args)) { } } +static dusk::ModLoader g_modLoader; + namespace dusk { ModLoader& ModLoader::instance() { - static ModLoader inst; - return inst; + return g_modLoader; } void ModLoader::buildAPI(LoadedMod& mod) { @@ -187,7 +359,15 @@ void ModLoader::buildAPI(LoadedMod& mod) { mod.api.load_resource = cb_load_resource; mod.api.free_resource = cb_free_resource; mod.api.register_tab_content = cb_register_tab_content; - mod.api.register_menu_item = cb_register_menu_item; + mod.api.register_tab_update = cb_register_tab_update; + mod.api.panel_add_section = cb_panel_add_section; + mod.api.panel_add_button = cb_panel_add_button; + mod.api.panel_add_badge_row = cb_panel_add_badge_row; + mod.api.panel_add_dyn_text = cb_panel_add_dyn_text; + mod.api.elem_set_badge = cb_elem_set_badge; + mod.api.elem_set_text = cb_elem_set_text; + mod.api.panel_add_progress = cb_panel_add_progress; + mod.api.elem_set_progress = cb_elem_set_progress; mod.api.hook_install = hookInstallByAddr; mod.api.hook_pre = api_hook_pre; mod.api.hook_post = api_hook_post; @@ -195,16 +375,31 @@ void ModLoader::buildAPI(LoadedMod& mod) { mod.api.hook_dispatch_pre = hookDispatchPre; mod.api.hook_dispatch_post = hookDispatchPost; mod.api.service_publish = cb_service_publish; - mod.api.service_get = cb_service_get; + mod.api.service_get = cb_service_get; } void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { namespace fs = std::filesystem; + std::vector zipBytes; + { + FILE* f = fs_fopen(modPath, "rb"); + if (!f) { + DuskLog.error("ModLoader: failed to open {}", modPath.filename().string()); + return; + } + fseek(f, 0, SEEK_END); + long fsize = ftell(f); + fseek(f, 0, SEEK_SET); + zipBytes.resize(static_cast(fsize)); + fread(zipBytes.data(), 1, zipBytes.size(), f); + fclose(f); + } + std::string metaName, metaVersion, metaAuthor, metaDescription; { mz_zip_archive zip{}; - if (mz_zip_reader_init_file(&zip, modPath.string().c_str(), 0)) { + if (mz_zip_reader_init_mem(&zip, zipBytes.data(), zipBytes.size(), 0)) { size_t jsonSize = 0; void* jsonData = mz_zip_reader_extract_file_to_heap(&zip, "mod.json", &jsonSize, 0); mz_zip_reader_end(&zip); @@ -220,36 +415,46 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { metaDescription = j.value("description", ""); } catch (const std::exception& e) { mz_free(jsonData); - DuskLog.warn("ModLoader: bad mod.json in {}: {}", modPath.filename().string(), - e.what()); + DuskLog.warn( + "ModLoader: bad mod.json in {}: {}", modPath.filename().string(), e.what()); } } } } mz_zip_archive zip{}; - if (!mz_zip_reader_init_file(&zip, modPath.string().c_str(), 0)) { + if (!mz_zip_reader_init_mem(&zip, zipBytes.data(), zipBytes.size(), 0)) { DuskLog.error("ModLoader: failed to open {}", modPath.filename().string()); return; } - std::string dllEntry; + std::string dllEntry, dllFallback; for (mz_uint i = 0, n = mz_zip_reader_get_num_files(&zip); i < n; ++i) { mz_zip_archive_file_stat stat{}; - if (!mz_zip_reader_file_stat(&zip, i, &stat)) + if (!mz_zip_reader_file_stat(&zip, i, &stat)) { continue; - if (mz_zip_reader_is_file_a_directory(&zip, i)) + } + if (mz_zip_reader_is_file_a_directory(&zip, i)) { continue; - if (fs::path(stat.m_filename).extension() == k_libExt) { - dllEntry = stat.m_filename; - break; } + fs::path fname(stat.m_filename); + if (fname.extension() == k_libExt) { + if (!k_archSuffix.empty() && fname.stem().string().ends_with(k_archSuffix)) { + dllEntry = stat.m_filename; + break; + } else if (dllFallback.empty()) { + dllFallback = stat.m_filename; + } + } + } + if (dllEntry.empty()) { + dllEntry = dllFallback; } if (dllEntry.empty()) { mz_zip_reader_end(&zip); - DuskLog.warn("ModLoader: no *{} found in {} — skipping", k_libExt, - modPath.filename().string()); + DuskLog.warn( + "ModLoader: no *{} found in {} — skipping", k_libExt, modPath.filename().string()); return; } @@ -258,15 +463,28 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { fs::create_directories(cacheDir, ec); const fs::path dllCachePath = cacheDir / fs::path(dllEntry).filename(); - if (!mz_zip_reader_extract_file_to_file(&zip, dllEntry.c_str(), dllCachePath.string().c_str(), - 0)) - { - mz_zip_reader_end(&zip); - DuskLog.error("ModLoader: failed to extract {} from {}", dllEntry, - modPath.filename().string()); + + size_t dllSize = 0; + void* dllData = mz_zip_reader_extract_file_to_heap(&zip, dllEntry.c_str(), &dllSize, 0); + mz_zip_reader_end(&zip); + + if (!dllData) { + DuskLog.error( + "ModLoader: failed to extract {} from {}", dllEntry, modPath.filename().string()); return; } - mz_zip_reader_end(&zip); + { + FILE* out = fs_fopen(dllCachePath, "wb"); + if (out) { + fwrite(dllData, 1, dllSize, out); + fclose(out); + } else { + mz_free(dllData); + DuskLog.error("ModLoader: failed to write {}", dllCachePath.string()); + return; + } + } + mz_free(dllData); void* handle = pl_dlopen(dllCachePath); if (!handle) { @@ -281,7 +499,7 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { auto* mod_api_ver = reinterpret_cast(pl_dlsym(handle, "mod_api_version")); if (mod_api_ver && *mod_api_ver != DUSK_MOD_API_VERSION) { DuskLog.error("ModLoader: {} expects API v{} but engine is v{}, skipping", - fs::path(dllEntry).filename().string(), *mod_api_ver, DUSK_MOD_API_VERSION); + fs::path(dllEntry).filename().string(), *mod_api_ver, DUSK_MOD_API_VERSION); pl_dlclose(handle); return; } @@ -289,12 +507,10 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { mod.fn_init = reinterpret_cast(pl_dlsym(handle, "mod_init")); mod.fn_tick = reinterpret_cast(pl_dlsym(handle, "mod_tick")); mod.fn_cleanup = reinterpret_cast(pl_dlsym(handle, "mod_cleanup")); - mod.fn_set_imgui_ctx = - reinterpret_cast(pl_dlsym(handle, "dusk_mod_set_imgui_ctx")); if (!mod.fn_init || !mod.fn_tick) { DuskLog.error("ModLoader: {} missing mod_init or mod_tick — skipping", - fs::path(dllEntry).filename().string()); + fs::path(dllEntry).filename().string()); pl_dlclose(handle); return; } @@ -304,35 +520,47 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { mod.author = metaAuthor.empty() ? "unknown" : metaAuthor; mod.description = metaDescription; + mod.zip_data = std::move(zipBytes); m_mods.push_back(std::move(mod)); + { + LoadedMod& stored = m_mods.back(); + if (mz_zip_reader_init_mem(&stored.res_zip, stored.zip_data.data(), stored.zip_data.size(), 0)) { + stored.res_zip_open = true; + } + } DuskLog.info("ModLoader: found '{}' v{} by {} ({})", m_mods.back().name, m_mods.back().version, - m_mods.back().author, modPath.filename().string()); + m_mods.back().author, modPath.filename().string()); } void ModLoader::init() { - if (m_initialized) + if (m_initialized) { return; + } m_initialized = true; namespace fs = std::filesystem; if (!fs::is_directory(m_modsDir)) { - DuskLog.info("ModLoader: mods directory '{}' not found — mod loading skipped", - m_modsDir.string()); + DuskLog.info( + "ModLoader: mods directory '{}' not found — mod loading skipped", m_modsDir.string()); return; } std::error_code ec; std::vector entries; - for (auto& e : fs::directory_iterator(m_modsDir, ec)) - if (e.is_regular_file() && e.path().extension() == ".dusk") + for (auto& e : fs::directory_iterator(m_modsDir, ec)) { + 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(); - }); + [](const fs::directory_entry& a, const fs::directory_entry& b) { + return a.path().filename() < b.path().filename(); + }); - for (auto& entry : entries) + m_mods.reserve(entries.size()); + for (auto& entry : entries) { tryLoadDusk(entry.path()); + } if (m_mods.empty()) { DuskLog.info("ModLoader: no mods found"); @@ -340,8 +568,9 @@ void ModLoader::init() { } DuskLog.info("ModLoader: initializing {} mod(s)...", m_mods.size()); - for (auto& mod : m_mods) + for (auto& mod : m_mods) { buildAPI(mod); + } for (auto& mod : m_mods) { ModGuard guard(&mod); @@ -367,14 +596,15 @@ void ModLoader::init() { void ModLoader::tick() { for (auto& mod : m_mods) { - if (!mod.active) + if (!mod.active) { continue; + } ModGuard guard(&mod); try { mod.fn_tick(&mod.api); } catch (const std::exception& e) { - DuskLog.error("ModLoader: exception in {}.mod_tick(): {} — disabling", mod.name, - e.what()); + DuskLog.error( + "ModLoader: exception in {}.mod_tick(): {} — disabling", mod.name, e.what()); mod.active = false; } catch (...) { DuskLog.error("ModLoader: unknown exception in {}.mod_tick() — disabling", mod.name); @@ -393,6 +623,11 @@ void ModLoader::shutdown() { } catch (...) { } } + if (mod.res_zip_open) { + mz_zip_reader_end(&mod.res_zip); + mod.res_zip_open = false; + } + mod.zip_data.clear(); if (mod.handle) { pl_dlclose(mod.handle); mod.handle = nullptr; @@ -403,10 +638,4 @@ void ModLoader::shutdown() { DuskLog.info("ModLoader: all mods unloaded"); } -void ModLoader::callDrawCallback(const LoadedMod& mod, const ModDrawCallback& cb) { - if (mod.fn_set_imgui_ctx) - mod.fn_set_imgui_ctx(ImGui::GetCurrentContext()); - cb.draw_fn(cb.userdata); -} - } // namespace dusk diff --git a/src/dusk/ui/menu_bar.cpp b/src/dusk/ui/menu_bar.cpp index ea791d745b..e3ecb0ba3f 100644 --- a/src/dusk/ui/menu_bar.cpp +++ b/src/dusk/ui/menu_bar.cpp @@ -14,6 +14,7 @@ #include "f_pc/f_pc_name.h" #include "imgui.h" #include "modal.hpp" +#include "mods_window.hpp" #include "settings.hpp" #include "ui.hpp" #include "window.hpp" @@ -58,6 +59,7 @@ 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()); }); 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 new file mode 100644 index 0000000000..6f4c1b70ab --- /dev/null +++ b/src/dusk/ui/mods_window.cpp @@ -0,0 +1,125 @@ +#include "mods_window.hpp" + +#include "dusk/mod_loader.hpp" +#include "fmt/format.h" +#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.version, + mod.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(); + }); + return; + } + + for (size_t i = 0; i < mods.size(); ++i) { + mSnapshot.push_back({mods[i].active, mods[i].load_failed}); + + add_tab(mods[i].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)); + + if (!mod.description.empty()) { + pane.add_section("Description"); + pane.add_text(mod.description); + } + + for (const auto& cb : mod.tab_content) { + cb.build_fn(static_cast(pane.root()), cb.userdata); + } + + pane.finalize(); + }); + } +} + +void ModsWindow::update() { + const auto& mods = dusk::ModLoader::instance().mods(); + + bool dirty = mods.size() != mSnapshot.size(); + if (!dirty) { + for (size_t i = 0; i < mods.size(); ++i) { + if (mods[i].active != mSnapshot[i].active || + mods[i].load_failed != mSnapshot[i].load_failed) + { + dirty = true; + break; + } + } + } + + 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(); +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/mods_window.hpp b/src/dusk/ui/mods_window.hpp new file mode 100644 index 0000000000..5f10bc8087 --- /dev/null +++ b/src/dusk/ui/mods_window.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "window.hpp" + +#include + +namespace dusk::ui { + +class ModsWindow : public Window { +public: + ModsWindow(); + void update() override; + +private: + struct ModSnapshot { + bool active; + bool load_failed; + }; + std::vector mSnapshot; + int mActiveModIndex = 0; +}; + +} // namespace dusk::ui diff --git a/src/f_ap/f_ap_game.cpp b/src/f_ap/f_ap_game.cpp index b44de52db4..92b48a74c7 100644 --- a/src/f_ap/f_ap_game.cpp +++ b/src/f_ap/f_ap_game.cpp @@ -827,6 +827,7 @@ void fapGm_Execute() { #if TARGET_PC duskExecute(); + dusk::ModLoader::instance().tick(); #endif #ifdef TARGET_PC @@ -836,7 +837,6 @@ void fapGm_Execute() { #endif cCt_Counter(0); - dusk::ModLoader::instance().tick(); #ifdef TARGET_PC dusk::speedrun::onGameFrame(); dusk::AchievementSystem::get().tick(); diff --git a/src/m_Do/m_Do_audio.cpp b/src/m_Do/m_Do_audio.cpp index fc44eb7fb6..cb4af26198 100644 --- a/src/m_Do/m_Do_audio.cpp +++ b/src/m_Do/m_Do_audio.cpp @@ -19,7 +19,7 @@ #include #endif -u8 mDoAud_zelAudio_c::mInitFlag; +DUSK_GAME_DATA u8 mDoAud_zelAudio_c::mInitFlag; u8 mDoAud_zelAudio_c::mResetFlag; diff --git a/tools/cat_mod/CMakeLists.txt b/tools/cat_mod/CMakeLists.txt new file mode 100644 index 0000000000..71d5413d55 --- /dev/null +++ b/tools/cat_mod/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.25) +project(cat_carry_mod CXX) + +set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to dusk source root") +add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL) + +set(DUSK_MODS_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/mods" CACHE PATH "Directory to write .dusk packages into") + +add_dusk_mod(cat_carry_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res +) diff --git a/tools/cat_mod/mod.json b/tools/cat_mod/mod.json new file mode 100644 index 0000000000..a65c6e6e7f --- /dev/null +++ b/tools/cat_mod/mod.json @@ -0,0 +1,6 @@ +{ + "name": "Chloe the Cat", + "version": "1.0.0", + "author": "Maddie", + "description": "Carry your new kitty companion Chloe throughout your adventure, but don't let her die!" +} diff --git a/tools/cat_mod/res/.gitkeep b/tools/cat_mod/res/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/cat_mod/src/mod.cpp b/tools/cat_mod/src/mod.cpp new file mode 100644 index 0000000000..7ef0f5fcee --- /dev/null +++ b/tools/cat_mod/src/mod.cpp @@ -0,0 +1,250 @@ +#include "d/actor/d_a_alink.h" +#include "d/actor/d_a_npc_ne.h" +#include "d/d_com_inf_game.h" +#include "d/d_meter2_info.h" +#include "d/d_msg_object.h" +#include "dusk/hook.hpp" +#include "dusk/mod_api.h" +#include "dusk/mod_utils.h" +#include "f_op/f_op_actor.h" +#include "f_op/f_op_actor_mng.h" +#include "f_op/f_op_overlap_mng.h" +#include "m_Do/m_Do_audio.h" +#include "m_Do/m_Do_controller_pad.h" + +#include +#include + +static constexpr s16 ACTOR_NPC_NE = 269; +static constexpr u16 NOTIFY_MSG_ID = 0xFFFE; +static const char* DEATH_MSG_TEXT = "It seems Chloe has died..."; + +using GetStringEntry = dusk::HookEntry<&dMsgObject_c::getString>; + +static void on_getString_post(void* args, void* retval) { + if (dusk::arg(args, 0) != NOTIFY_MSG_ID) { + return; + } + + strcpy(dusk::arg(args, 5), DEATH_MSG_TEXT); + strcpy(dusk::arg(args, 7), DEATH_MSG_TEXT); + + if (retval) { + *static_cast(retval) = true; + } +} +static constexpr int CAT_MAX_HP = 1; + +static fpc_ProcID s_cat_id = fpcM_ERROR_PROCESS_ID_e; +static int s_cat_hp = CAT_MAX_HP; +static bool s_cat_dead = false; + +static bool s_summon_carry = false; + +static bool s_has_spawn = false; +static cXyz s_spawn_pos = {}; +static s8 s_spawn_room = -1; +static char s_spawn_stage[8] = {}; + +static DuskElemHandle s_el_hp = nullptr; +static DuskElemHandle s_el_hp_bar = nullptr; +static DuskElemHandle s_el_status = nullptr; + +static fopAc_ac_c* getCat() { + if (s_cat_id == fpcM_ERROR_PROCESS_ID_e) { + return nullptr; + } + fopAc_ac_c* cat = fopAcM_SearchByID(s_cat_id); + if (!cat) { + s_cat_id = fpcM_ERROR_PROCESS_ID_e; + } + return cat; +} + +static void killCat() { + fopAc_ac_c* cat = getCat(); + if (cat) { + fopAcM_delete(cat); + s_cat_id = fpcM_ERROR_PROCESS_ID_e; + } + mDoAud_seStartMenu(Z2SE_CAT_CRY_ANNOY); + s_cat_dead = true; + dMeter2Info_setFloatingMessage(NOTIFY_MSG_ID, 150, false); + dusk::g_api->log_info("cat_mod: the cat has died"); +} + +static bool inSpawnStage() { + return strncmp(dComIfGp_getStartStageName(), s_spawn_stage, sizeof(s_spawn_stage)) == 0; +} + +static void spawnCat(bool carry = false) { + if (s_cat_dead || dComIfGp_event_runCheck()) { + return; + } + daAlink_c* link = daAlink_getAlinkActorClass(); + if (!link) { + return; + } + + cXyz pos; + s8 roomNo; + csXyz angle = {}; + if (s_has_spawn && inSpawnStage()) { + pos = s_spawn_pos; + roomNo = s_spawn_room; + } else { + f32 yaw = link->shape_angle.y; + pos = link->current.pos; + pos.x += cM_ssin(yaw) * 30.0f; + pos.z += cM_scos(yaw) * 30.0f; + roomNo = link->current.roomNo; + angle.y = (s16)(link->shape_angle.y + (s16)0x8000); + } + cXyz scale = {1.0f, 1.0f, 1.0f}; + + s_cat_id = fopAcM_createInPlayScene( + ACTOR_NPC_NE, -1, &pos, roomNo, &angle, &scale, -1); + + if (s_cat_id != fpcM_ERROR_PROCESS_ID_e) { + dusk::g_api->log_info("cat_mod: cat spawned (hp %d/%d)", s_cat_hp, CAT_MAX_HP); + s_summon_carry = carry; + } +} + +static void on_setDamagePoint_post(void* args, void* /*retval*/) { + if (s_cat_dead) { + return; + } + int dmg = dusk::arg(args, 1); + if (dmg <= 0) { + return; + } + fopAc_ac_c* cat = getCat(); + bool cat_free = cat != nullptr && !fopAcM_checkCarryNow(cat); + if (cat_free) { + return; + } + s_cat_hp -= dmg; + dusk::g_api->log_info("cat_mod: cat took %d damage (hp %d/%d)", dmg, s_cat_hp, CAT_MAX_HP); + if (s_cat_hp <= 0) { + s_cat_hp = 0; + killCat(); + } + else { + mDoAud_seStartMenu(Z2SE_CAT_CRY_CARRY); + } +} + +static void BuildPanel(DuskPanelHandle panel, void*) { + DuskModAPI* api = dusk::g_api; + api->panel_add_section(panel, "Cat"); + s_el_status = api->panel_add_dyn_text(panel, s_cat_dead ? "Dead" : "Alive"); + + float fraction = static_cast(s_cat_hp) / CAT_MAX_HP; + s_el_hp_bar = api->panel_add_progress(panel, fraction); + + char buf[32]; + snprintf(buf, sizeof(buf), "%d / %d HP", s_cat_hp, CAT_MAX_HP); + s_el_hp = api->panel_add_dyn_text(panel, buf); +} + +static void UpdatePanel(void*) { + DuskModAPI* api = dusk::g_api; + api->elem_set_text(s_el_status, s_cat_dead ? "Dead" : "Alive"); + + float fraction = static_cast(s_cat_hp) / CAT_MAX_HP; + api->elem_set_progress(s_el_hp_bar, fraction); + + char buf[32]; + snprintf(buf, sizeof(buf), "%d / %d HP", s_cat_hp, CAT_MAX_HP); + api->elem_set_text(s_el_hp, buf); +} + +extern "C" { + +void mod_init(DuskModAPI* api) { + dusk::init(api); + dusk::hookAddPost<&dMsgObject_c::getString>(on_getString_post); + dusk::hookAddPost<&daAlink_c::setDamagePoint>(on_setDamagePoint_post); + api->register_tab_content(BuildPanel, nullptr); + api->register_tab_update(UpdatePanel, nullptr); + api->log_info("cat_mod: ready"); +} + +void mod_tick(DuskModAPI* api) { + (void)api; + + if (s_cat_dead) { + return; + } + + fopAc_ac_c* cat = getCat(); + + // Load zone detected: dismiss cat into inventory before the area unloads. + if (cat && fopAcM_checkCarryNow(cat) && fopOvlpM_IsDoingReq()) { + fopAcM_delete(cat); + s_cat_id = fpcM_ERROR_PROCESS_ID_e; + s_has_spawn = false; + daAlink_c* link = daAlink_getAlinkActorClass(); + if (link) { + link->procPreActionUnequipInit(0, nullptr); + } + return; + } + + if (!cat) { + if (s_has_spawn && inSpawnStage() && !dComIfGp_event_runCheck()) { + spawnCat(); + } else if (mDoCPd_c::getHoldR(PAD_1) && mDoCPd_c::getTrigZ(PAD_1)) { + consumeInput(PAD_1, PAD_TRIGGER_Z); + spawnCat(true); + } + return; + } + + if (s_summon_carry) { + s_summon_carry = false; + daAlink_c* link = daAlink_getAlinkActorClass(); + if (link) { + link->field_0x27f4 = cat; + link->procGrabReadyInit(); + } + } + + if (!fopAcM_checkCarryNow(cat)) { + memcpy(s_spawn_stage, dComIfGp_getStartStageName(), sizeof(s_spawn_stage)); + s_spawn_room = cat->current.roomNo; + s_spawn_pos = cat->current.pos; + s_has_spawn = true; + } + + npc_ne_class* ne = static_cast(cat); + ne->mBehavior = npc_ne_class::BHV_TAME; + ne->mNoFollow = 0; + ne->mTexture = 0; + ne->mBtkFrame = 0; + + if (mDoCPd_c::getHoldR(PAD_1) && mDoCPd_c::getTrigZ(PAD_1) && fopAcM_checkCarryNow(cat)) { + consumeInput(PAD_1, PAD_TRIGGER_Z); + fopAcM_delete(cat); + s_cat_id = fpcM_ERROR_PROCESS_ID_e; + s_has_spawn = false; + daAlink_c* link = daAlink_getAlinkActorClass(); + if (link) { + link->procPreActionUnequipInit(0, nullptr); + } + } +} + +void mod_cleanup(DuskModAPI* api) { + (void)api; + s_cat_id = fpcM_ERROR_PROCESS_ID_e; + s_cat_hp = CAT_MAX_HP; + s_cat_dead = false; + s_summon_carry = false; + s_el_hp = nullptr; + s_el_hp_bar = nullptr; + s_el_status = nullptr; +} + +} diff --git a/tools/mod_test/src/mod.cpp b/tools/mod_test/src/mod.cpp index 44de2f5020..83559c83e0 100644 --- a/tools/mod_test/src/mod.cpp +++ b/tools/mod_test/src/mod.cpp @@ -3,23 +3,30 @@ #include "d/actor/d_a_alink.h" #include "dusk/hook.hpp" #include "dusk/mod_api.h" -#include "imgui.h" #include "m_Do/m_Do_controller_pad.h" #include -#include - -static int g_ticks = 0; -static bool g_pre_fired = false; -static bool g_post_fired = false; -static bool g_replace_fired = false; -static bool g_arg_write_ok = false; -static int g_pre_cancel_count = 0; -static int g_post_count = 0; -static bool g_resource_ok = false; -static std::string g_resource_text; +#include + +static int g_ticks = 0; +static bool g_pre_fired = false; +static bool g_post_fired = false; +static bool g_replace_fired = false; +static bool g_arg_write_ok = false; +static int g_pre_cancel_count = 0; +static int g_post_count = 0; +static bool g_resource_ok = false; +static char g_resource_text[256] = {}; static char g_mod_dir_snippet[64] = {}; +static DuskElemHandle g_el_pre_badge = nullptr; +static DuskElemHandle g_el_post_badge = nullptr; +static DuskElemHandle g_el_replace_badge = nullptr; +static DuskElemHandle g_el_argwrite_badge = nullptr; +static DuskElemHandle g_el_cancel_count = nullptr; +static DuskElemHandle g_el_post_count = nullptr; +static DuskElemHandle g_el_link_angle = nullptr; + // Pre-hook on posMove. Hold L to test argRef write and cancellation. static int32_t on_posMove_pre(void* args) { g_pre_fired = true; @@ -33,70 +40,83 @@ static int32_t on_posMove_pre(void* args) { } // Post-hook on posMove. Fires even when the pre-hook cancelled. -static void on_posMove_post(void* args) { +static void on_posMove_post(void* args, void* retval) { g_post_fired = true; ++g_post_count; (void)args; + (void)retval; } // Replace-hook on execute. Calls through to the original so gameplay is unaffected. using ExecuteEntry = dusk::HookEntry<&daAlink_c::execute>; -static void on_execute_replace(void* args) { +static void on_execute_replace(void* args, void* retval) { g_replace_fired = true; - ExecuteEntry::g_orig(dusk::arg(args, 0)); + int result = ExecuteEntry::g_orig(dusk::arg(args, 0)); + if (retval) { + *static_cast(retval) = result; + } } -static void DrawPanel(void*) { - auto status = [](const char* label, bool ok) { - ImGui::TextColored(ok ? ImVec4(0, 1, 0, 1) : ImVec4(1, 0.35f, 0.35f, 1), - ok ? "[PASS]" : "[WAIT]"); - ImGui::SameLine(); - ImGui::Text("%s", label); - }; - - ImGui::SeparatorText("Hooks"); - status("pre-hook fired (posMove)", g_pre_fired); - status("post-hook fired (posMove)", g_post_fired); - status("replace-hook fired (execute)", g_replace_fired); - status("argRef write + pre cancel (hold L)", g_arg_write_ok); - ImGui::Text(" pre cancels: %d post calls: %d", g_pre_cancel_count, g_post_count); - - ImGui::SeparatorText("Resources"); - status("load_resource (hello.txt)", g_resource_ok); - if (!g_resource_text.empty()) - ImGui::TextWrapped(" \"%s\"", g_resource_text.c_str()); - - ImGui::SeparatorText("API Fields"); - status("mod_dir non-empty", g_mod_dir_snippet[0] != '\0'); - ImGui::TextWrapped(" %s", g_mod_dir_snippet); - - ImGui::Spacing(); - ImGui::Separator(); - if (ImGui::Button("Reset results")) { - g_pre_fired = false; - g_post_fired = false; - g_replace_fired = false; - g_arg_write_ok = false; - g_pre_cancel_count = 0; - g_post_count = 0; - } - daAlink_c* link = daAlink_getAlinkActorClass(); - if (link) { - ImGui::SameLine(); - ImGui::Text("(Link y angle: %d)", (int)link->shape_angle.y); - } +static void on_reset(void*) { + g_pre_fired = false; + g_post_fired = false; + g_replace_fired = false; + g_arg_write_ok = false; + g_pre_cancel_count = 0; + g_post_count = 0; } -static void DrawMenuEntry(void*) { - if (ImGui::MenuItem("Test: log all levels")) { - dusk::g_api->log_info("log_info test"); - dusk::g_api->log_warn("log_warn test"); - dusk::g_api->log_error("log_error test"); - } - if (ImGui::MenuItem("Test: reset Link y angle")) { - daAlink_c* link = daAlink_getAlinkActorClass(); - if (link) link->shape_angle.y = 0; +static void BuildPanel(DuskPanelHandle panel, void*) { + DuskModAPI* api = dusk::g_api; + + api->panel_add_section(panel, "Hooks"); + g_el_pre_badge = api->panel_add_badge_row(panel, "pre-hook fired (posMove)", g_pre_fired); + g_el_post_badge = api->panel_add_badge_row(panel, "post-hook fired (posMove)", g_post_fired); + g_el_replace_badge = + api->panel_add_badge_row(panel, "replace-hook fired (execute)", g_replace_fired); + g_el_argwrite_badge = + api->panel_add_badge_row(panel, "argRef write + pre cancel (hold L)", g_arg_write_ok); + + char countBuf[64]; + snprintf(countBuf, sizeof(countBuf), "pre cancels: %d", g_pre_cancel_count); + g_el_cancel_count = api->panel_add_dyn_text(panel, countBuf); + snprintf(countBuf, sizeof(countBuf), "post calls: %d", g_post_count); + g_el_post_count = api->panel_add_dyn_text(panel, countBuf); + + api->panel_add_section(panel, "Resources"); + api->panel_add_badge_row(panel, "load_resource (hello.txt)", g_resource_ok); + if (g_resource_text[0] != '\0') { + api->panel_add_dyn_text(panel, g_resource_text); } + + api->panel_add_section(panel, "API Fields"); + api->panel_add_badge_row(panel, "mod_dir non-empty", g_mod_dir_snippet[0] != '\0'); + api->panel_add_dyn_text(panel, g_mod_dir_snippet); + + api->panel_add_section(panel, "Actions"); + api->panel_add_button(panel, "Reset Results", on_reset, nullptr); + + g_el_link_angle = api->panel_add_dyn_text(panel, ""); +} + +static void UpdatePanel(void*) { + DuskModAPI* api = dusk::g_api; + + api->elem_set_badge(g_el_pre_badge, g_pre_fired); + api->elem_set_badge(g_el_post_badge, g_post_fired); + api->elem_set_badge(g_el_replace_badge, g_replace_fired); + api->elem_set_badge(g_el_argwrite_badge, g_arg_write_ok); + + char buf[64]; + snprintf(buf, sizeof(buf), "pre cancels: %d", g_pre_cancel_count); + api->elem_set_text(g_el_cancel_count, buf); + + snprintf(buf, sizeof(buf), "post calls: %d", g_post_count); + api->elem_set_text(g_el_post_count, buf); + + daAlink_c* link = daAlink_getAlinkActorClass(); + snprintf(buf, sizeof(buf), "Link y angle: %d", link ? (int)link->shape_angle.y : 0); + api->elem_set_text(g_el_link_angle, buf); } extern "C" { @@ -108,36 +128,38 @@ void mod_init(DuskModAPI* api) { api->log_warn("log_warn smoke test"); api->log_error("log_error smoke test"); - std::snprintf(g_mod_dir_snippet, sizeof(g_mod_dir_snippet), "%.60s", api->mod_dir); + snprintf(g_mod_dir_snippet, sizeof(g_mod_dir_snippet), "%.60s", api->mod_dir); size_t size = 0; void* data = api->load_resource("hello.txt", &size); if (data) { - g_resource_text.assign(static_cast(data), size); - while (!g_resource_text.empty() && g_resource_text.back() == '\n') - g_resource_text.pop_back(); + size_t copy = size < sizeof(g_resource_text) - 1 ? size : sizeof(g_resource_text) - 1; + memcpy(g_resource_text, data, copy); + g_resource_text[copy] = '\0'; + while (copy > 0 && g_resource_text[copy - 1] == '\n') { + g_resource_text[--copy] = '\0'; + } api->free_resource(data); g_resource_ok = true; - api->log_info("load_resource OK: \"%s\"", g_resource_text.c_str()); + api->log_info("load_resource OK: \"%s\"", g_resource_text); } else { api->log_error("load_resource FAILED for hello.txt"); } - // Missing file should return nullptr gracefully. void* missing = api->load_resource("does_not_exist.bin", nullptr); - if (!missing) + if (!missing) { api->log_info("load_resource missing-file: correctly returned nullptr"); - else { + } else { api->log_error("load_resource missing-file: unexpectedly returned data"); api->free_resource(missing); } - dusk::hookAddPre <&daAlink_c::posMove>(on_posMove_pre); + dusk::hookAddPre<&daAlink_c::posMove>(on_posMove_pre); dusk::hookAddPost<&daAlink_c::posMove>(on_posMove_post); dusk::hookSetReplace<&daAlink_c::execute>(on_execute_replace); - api->register_tab_content(DrawPanel, nullptr); - api->register_menu_item(DrawMenuEntry, nullptr); + api->register_tab_content(BuildPanel, nullptr); + api->register_tab_update(UpdatePanel, nullptr); api->log_info("mod_test ready"); } @@ -149,6 +171,8 @@ void mod_tick(DuskModAPI* api) { void mod_cleanup(DuskModAPI* api) { api->log_info("mod_test unloaded after %d ticks", g_ticks); + g_el_pre_badge = g_el_post_badge = g_el_replace_badge = nullptr; + g_el_argwrite_badge = g_el_cancel_count = g_el_post_count = nullptr; + g_el_link_angle = nullptr; } - } From 3e84c6565761a822dbdef54cfb3a869780177332 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Thu, 14 May 2026 20:41:49 +0200 Subject: [PATCH 23/48] Fix mod SDK being broken due to rebrand --- CMakeLists.txt | 10 ++++++---- cmake/DuskModSDK.cmake | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e04853cc82..e628975342 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -356,10 +356,10 @@ set(GAME_INCLUDE_DIRS ${miniz_SOURCE_DIR}) # Interface target for mods and sub-projects to inherit game headers/defines -add_library(dusk_game_headers INTERFACE) -target_include_directories(dusk_game_headers INTERFACE ${GAME_INCLUDE_DIRS}) -target_compile_definitions(dusk_game_headers INTERFACE TARGET_PC=1) -target_link_libraries(dusk_game_headers INTERFACE TracyClient) +add_library(dusklight_game_headers INTERFACE) +target_include_directories(dusklight_game_headers INTERFACE ${GAME_INCLUDE_DIRS}) +target_compile_definitions(dusklight_game_headers INTERFACE TARGET_PC=1) +target_link_libraries(dusklight_game_headers INTERFACE TracyClient) find_package(Threads REQUIRED) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd @@ -752,3 +752,5 @@ foreach (target IN LISTS BINARY_TARGETS) endif () endforeach () endforeach () + +add_subdirectory(tools/mod_test) \ No newline at end of file diff --git a/cmake/DuskModSDK.cmake b/cmake/DuskModSDK.cmake index 286725ae01..b76e578857 100644 --- a/cmake/DuskModSDK.cmake +++ b/cmake/DuskModSDK.cmake @@ -10,14 +10,14 @@ function(add_dusk_mod target_name) add_library(${target_name} SHARED ${ARG_SOURCES}) set_target_properties(${target_name} PROPERTIES PREFIX "" WINDOWS_EXPORT_ALL_SYMBOLS ON) target_compile_features(${target_name} PRIVATE cxx_std_20) - target_link_libraries(${target_name} PRIVATE dusk_game_headers) + target_link_libraries(${target_name} PRIVATE dusklight_game_headers) if(APPLE) target_link_options(${target_name} PRIVATE -undefined dynamic_lookup) elseif(UNIX) target_link_options(${target_name} PRIVATE -Wl,--allow-shlib-undefined) elseif(WIN32) - target_link_libraries(${target_name} PRIVATE dusk_game) + target_link_libraries(${target_name} PRIVATE dusklight_game) if(MSVC) target_link_options(${target_name} PRIVATE /INCREMENTAL:NO) set_target_properties(${target_name} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") From cfc0fbc34248c6c40f4e1219c0b7c9a1cf8af5ac Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Thu, 14 May 2026 20:42:20 +0200 Subject: [PATCH 24/48] Allow mods to be loaded from extracted disk files Also just some code cleanup --- files.cmake | 4 +- include/dusk/mod_loader.hpp | 12 +- src/dusk/modding/bundle_disk.cpp | 54 +++++++ src/dusk/modding/bundle_zip.cpp | 54 +++++++ src/dusk/{ => modding}/mod_loader.cpp | 209 ++++++++++++-------------- src/dusk/modding/mod_loader.hpp | 40 +++++ tools/mod_test/CMakeLists.txt | 8 - 7 files changed, 254 insertions(+), 127 deletions(-) create mode 100644 src/dusk/modding/bundle_disk.cpp create mode 100644 src/dusk/modding/bundle_zip.cpp rename src/dusk/{ => modding}/mod_loader.cpp (79%) create mode 100644 src/dusk/modding/mod_loader.hpp diff --git a/files.cmake b/files.cmake index 17105b6f8d..d4460a5f6b 100644 --- a/files.cmake +++ b/files.cmake @@ -1523,7 +1523,9 @@ set(DUSK_FILES src/dusk/OSThread.cpp src/dusk/OSMutex.cpp src/dusk/hook_system.cpp - src/dusk/mod_loader.cpp + src/dusk/modding/mod_loader.cpp + src/dusk/modding/bundle_disk.cpp + src/dusk/modding/bundle_zip.cpp src/dusk/gx_helper.cpp src/dusk/discord.cpp src/dusk/discord.hpp diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index f9c488ce01..b04172048a 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -5,7 +5,10 @@ #include #include "dusk/mod_api.h" -#include "miniz.h" + +namespace dusk::modding { +class ModBundle; +} namespace dusk { @@ -40,10 +43,7 @@ struct LoadedMod { FnCleanup fn_cleanup = nullptr; DuskModAPI api{}; - - std::vector zip_data; - mz_zip_archive res_zip{}; - bool res_zip_open = false; + std::unique_ptr bundle; std::vector tab_content; std::vector tab_updates; @@ -65,7 +65,7 @@ class ModLoader { std::filesystem::path m_modsDir; bool m_initialized = false; - void tryLoadDusk(const std::filesystem::path& modPath); + void tryLoadDusk(const std::filesystem::path& modPath, bool fromDir); void buildAPI(LoadedMod& mod); }; diff --git a/src/dusk/modding/bundle_disk.cpp b/src/dusk/modding/bundle_disk.cpp new file mode 100644 index 0000000000..4bbba938ba --- /dev/null +++ b/src/dusk/modding/bundle_disk.cpp @@ -0,0 +1,54 @@ +#include + +#include "dusk/io.hpp" +#include "mod_loader.hpp" + +namespace fs = std::filesystem; + +namespace dusk::modding { +ModBundleDisk::ModBundleDisk(fs::path root) : root_path(std::move(root)) {} + +std::vector ModBundleDisk::readFile(const std::string& fileName) { + const fs::path filePath = reinterpret_cast(fileName.c_str()); + const auto finalPath = root_path / fileName; + + return io::FileStream::ReadAllBytes(finalPath); +} + +std::vector ModBundleDisk::getFileNames() { + std::vector files; + + std::error_code ec; + for (fs::recursive_directory_iterator it(root_path, + fs::directory_options::skip_permission_denied | + fs::directory_options::follow_directory_symlink, + ec); + it != fs::recursive_directory_iterator(); it.increment(ec)) + { + if (ec) { + break; + } + + if (!it->is_regular_file()) { + continue; + } + + const auto& path = it->path(); + const auto relPath = fs::relative(path, root_path); + auto string = io::fs_path_to_string(relPath); + if constexpr (fs::path::preferred_separator != '/') { + // Convert \ to / on Windows + for (auto& chr : string) { + if (chr == fs::path::preferred_separator) { + chr = '/'; + } + } + } + + files.emplace_back(std::move(string)); + } + + return files; +} + +} // namespace dusk::modding \ No newline at end of file diff --git a/src/dusk/modding/bundle_zip.cpp b/src/dusk/modding/bundle_zip.cpp new file mode 100644 index 0000000000..b39b7a16f2 --- /dev/null +++ b/src/dusk/modding/bundle_zip.cpp @@ -0,0 +1,54 @@ +#include "fmt/format.h" +#include "mod_loader.hpp" + +#include + +namespace dusk::modding { + +ModBundleZip::ModBundleZip(std::vector&& data) : zip_data(std::move(data)) { + if (!mz_zip_reader_init_mem(&res_zip, zip_data.data(), zip_data.size(), 0)) { + const auto error = mz_zip_get_last_error(&res_zip); + throw std::runtime_error( + fmt::format("Opening zip failed: {}", mz_zip_get_error_string(error))); + } +} + +ModBundleZip::~ModBundleZip() { + mz_zip_reader_end(&res_zip); +} + +std::vector ModBundleZip::readFile(const std::string& fileName) { + size_t size; + const auto ptr = mz_zip_reader_extract_file_to_heap(&res_zip, fileName.c_str(), &size, 0); + + if (!ptr) { + throw std::runtime_error(fmt::format("File does not exist: {}", fileName)); + } + + std::span data(static_cast(ptr), size); + std::vector vec(data.begin(), data.end()); + + mz_free(ptr); + + return vec; +} + +std::vector ModBundleZip::getFileNames() { + std::vector results; + + for (mz_uint i = 0, n = mz_zip_reader_get_num_files(&res_zip); i < n; ++i) { + mz_zip_archive_file_stat stat{}; + if (!mz_zip_reader_file_stat(&res_zip, i, &stat)) { + continue; + } + if (mz_zip_reader_is_file_a_directory(&res_zip, i)) { + continue; + } + + results.emplace_back(stat.m_filename); + } + + return results; +} + +} // namespace dusk::modding diff --git a/src/dusk/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp similarity index 79% rename from src/dusk/mod_loader.cpp rename to src/dusk/modding/mod_loader.cpp index 34384d6366..c7720fc9fc 100644 --- a/src/dusk/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -1,6 +1,7 @@ #include "dusk/mod_loader.hpp" #include "dusk/hook_system.hpp" #include "dusk/logging.h" +#include "mod_loader.hpp" #include @@ -9,9 +10,11 @@ #include #include #include +#include #include #include +#include "dusk/io.hpp" #include "miniz.h" #include "nlohmann/json.hpp" @@ -20,6 +23,8 @@ #define NOMINMAX #include +static aurora::Module Log("dusk::modLoader"); + static void* pl_dlopen(const std::filesystem::path& p) { return LoadLibraryW(p.wstring().c_str()); } @@ -67,31 +72,26 @@ static constexpr const char* k_libExt = ".so"; #endif #endif +using namespace dusk::modding; +using namespace std::string_view_literals; + #if defined(_M_ARM64) || defined(__aarch64__) -static constexpr std::string_view k_archSuffix = "_arm64"; +static constexpr std::string_view k_archSuffix = "_arm64"sv; #elif defined(_M_X64) || defined(__x86_64__) -static constexpr std::string_view k_archSuffix = "_x64"; +static constexpr std::string_view k_archSuffix = "_x64"sv; #elif defined(_M_IX86) || defined(__i386__) -static constexpr std::string_view k_archSuffix = "_x86"; +static constexpr std::string_view k_archSuffix = "_x86"sv; #else -static constexpr std::string_view k_archSuffix = ""; +static constexpr std::string_view k_archSuffix = ""sv; #endif -static FILE* fs_fopen(const std::filesystem::path& p, const char* mode) { -#if defined(_WIN32) - std::wstring wmode(mode, mode + strlen(mode)); - return _wfopen(p.wstring().c_str(), wmode.c_str()); -#else - return fopen(p.c_str(), mode); -#endif -} - static thread_local dusk::LoadedMod* g_currentMod = nullptr; static std::unordered_map g_services; namespace dusk { thread_local void* g_dusk_hook_current_mod = nullptr; -} + +} // namespace dusk struct ModGuard { explicit ModGuard(dusk::LoadedMod* m) { @@ -149,26 +149,27 @@ static void* cb_load_resource(const char* relative_path, size_t* out_size) { DuskLog.error("load_resource: called outside mod context or with null path"); return nullptr; } - if (!g_currentMod->res_zip_open) { - DuskLog.error("[{}] load_resource: zip not available", g_currentMod->name); - return nullptr; - } std::string entry = std::string("res/") + relative_path; - size_t sz = 0; - void* data = mz_zip_reader_extract_file_to_heap(&g_currentMod->res_zip, entry.c_str(), &sz, 0); - if (!data) { - DuskLog.error("[{}] load_resource: '{}' not found in zip", g_currentMod->name, entry); + std::vector data; + try { + data = g_currentMod->bundle->readFile(entry); + } catch (const std::runtime_error& e) { + DuskLog.error("[{}] load_resource: '{}' failed: {}", g_currentMod->name, entry, e.what()); return nullptr; } + + const auto retPtr = std::malloc(data.size()); + std::memcpy(retPtr, data.data(), data.size()); + if (out_size) { - *out_size = sz; + *out_size = data.size(); } - return data; + return retPtr; } static void cb_free_resource(void* data) { - mz_free(data); + std::free(data); } namespace { @@ -378,81 +379,74 @@ void ModLoader::buildAPI(LoadedMod& mod) { mod.api.service_get = cb_service_get; } -void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { - namespace fs = std::filesystem; +static std::unique_ptr loadBundle(const std::filesystem::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 zipBytes; - { - FILE* f = fs_fopen(modPath, "rb"); - if (!f) { - DuskLog.error("ModLoader: failed to open {}", modPath.filename().string()); - return; +struct DllLocateResult { + std::string primary; + std::string fallback; +}; + +static std::string_view getFileNameWithoutExtension(const std::string_view fileName) { + return fileName.substr(0, fileName.find_last_of('.')); +} + +static DllLocateResult LocateDllInBundle(ModBundle& bundle) { + std::string dllEntry, dllFallback; + for (const auto name : bundle.getFileNames()) { + if (!name.ends_with(".dll"sv)) { + continue; } - fseek(f, 0, SEEK_END); - long fsize = ftell(f); - fseek(f, 0, SEEK_SET); - zipBytes.resize(static_cast(fsize)); - fread(zipBytes.data(), 1, zipBytes.size(), f); - fclose(f); - } - std::string metaName, metaVersion, metaAuthor, metaDescription; - { - mz_zip_archive zip{}; - if (mz_zip_reader_init_mem(&zip, zipBytes.data(), zipBytes.size(), 0)) { - size_t jsonSize = 0; - void* jsonData = mz_zip_reader_extract_file_to_heap(&zip, "mod.json", &jsonSize, 0); - mz_zip_reader_end(&zip); - if (jsonData) { - try { - std::string jsonStr(static_cast(jsonData), jsonSize); - mz_free(jsonData); - jsonData = nullptr; - auto j = nlohmann::json::parse(jsonStr); - metaName = j.value("name", ""); - metaVersion = j.value("version", ""); - metaAuthor = j.value("author", ""); - metaDescription = j.value("description", ""); - } catch (const std::exception& e) { - mz_free(jsonData); - DuskLog.warn( - "ModLoader: bad mod.json in {}: {}", modPath.filename().string(), e.what()); - } - } + if (!k_archSuffix.empty() && getFileNameWithoutExtension(name).ends_with(k_archSuffix)) { + dllEntry = name; + } else if (dllFallback.empty()) { + dllFallback = name; } } - mz_zip_archive zip{}; - if (!mz_zip_reader_init_mem(&zip, zipBytes.data(), zipBytes.size(), 0)) { - DuskLog.error("ModLoader: failed to open {}", modPath.filename().string()); + return DllLocateResult{dllEntry, dllFallback}; +} + +void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) { + namespace fs = std::filesystem; + + 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()); return; } - std::string dllEntry, dllFallback; - for (mz_uint i = 0, n = mz_zip_reader_get_num_files(&zip); i < n; ++i) { - mz_zip_archive_file_stat stat{}; - if (!mz_zip_reader_file_stat(&zip, i, &stat)) { - continue; - } - if (mz_zip_reader_is_file_a_directory(&zip, i)) { - continue; - } - fs::path fname(stat.m_filename); - if (fname.extension() == k_libExt) { - if (!k_archSuffix.empty() && fname.stem().string().ends_with(k_archSuffix)) { - dllEntry = stat.m_filename; - break; - } else if (dllFallback.empty()) { - dllFallback = stat.m_filename; - } - } + std::string metaName, metaVersion, metaAuthor, metaDescription; + try + { + const auto metaJson = bundle->readFile("mod.json"); + auto j = nlohmann::json::parse(metaJson); + metaName = j.value("name", ""); + metaVersion = j.value("version", ""); + metaAuthor = j.value("author", ""); + metaDescription = j.value("description", ""); + } + catch (const std::runtime_error& e) { + Log.error( + "ModLoader: bad mod.json in {}: {}", io::fs_path_to_string(modPath.filename()), e.what()); + return; } + + auto [dllEntry, dllFallback] = LocateDllInBundle(*bundle); if (dllEntry.empty()) { dllEntry = dllFallback; } if (dllEntry.empty()) { - mz_zip_reader_end(&zip); DuskLog.warn( "ModLoader: no *{} found in {} — skipping", k_libExt, modPath.filename().string()); return; @@ -464,27 +458,26 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { const fs::path dllCachePath = cacheDir / fs::path(dllEntry).filename(); - size_t dllSize = 0; - void* dllData = mz_zip_reader_extract_file_to_heap(&zip, dllEntry.c_str(), &dllSize, 0); - mz_zip_reader_end(&zip); - - if (!dllData) { + std::vector dllData; + try { + dllData = bundle->readFile(dllEntry); + } catch (const std::runtime_error& e) { DuskLog.error( - "ModLoader: failed to extract {} from {}", dllEntry, modPath.filename().string()); + "ModLoader: failed to extract {} from {}", dllEntry, io::fs_path_to_string(modPath.filename())); return; } + { - FILE* out = fs_fopen(dllCachePath, "wb"); - if (out) { - fwrite(dllData, 1, dllSize, out); - fclose(out); - } else { - mz_free(dllData); + std::ofstream out(dllCachePath, std::ios::binary | std::ios::out); + if (!out) { DuskLog.error("ModLoader: failed to write {}", dllCachePath.string()); return; } + + out.write( + reinterpret_cast(dllData.data()), + static_cast(dllData.size())); } - mz_free(dllData); void* handle = pl_dlopen(dllCachePath); if (!handle) { @@ -520,14 +513,9 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath) { mod.author = metaAuthor.empty() ? "unknown" : metaAuthor; mod.description = metaDescription; - mod.zip_data = std::move(zipBytes); + mod.bundle = std::move(bundle); m_mods.push_back(std::move(mod)); - { - LoadedMod& stored = m_mods.back(); - if (mz_zip_reader_init_mem(&stored.res_zip, stored.zip_data.data(), stored.zip_data.size(), 0)) { - stored.res_zip_open = true; - } - } + DuskLog.info("ModLoader: found '{}' v{} by {} ({})", m_mods.back().name, m_mods.back().version, m_mods.back().author, modPath.filename().string()); } @@ -548,7 +536,9 @@ void ModLoader::init() { std::error_code ec; std::vector entries; for (auto& e : fs::directory_iterator(m_modsDir, ec)) { - if (e.is_regular_file() && e.path().extension() == ".dusk") { + if (e.is_directory() && std::filesystem::exists(e.path() / "mod.json")) { + entries.push_back(e); + } else if (e.is_regular_file() && e.path().extension() == ".dusk") { entries.push_back(e); } } @@ -559,7 +549,7 @@ void ModLoader::init() { m_mods.reserve(entries.size()); for (auto& entry : entries) { - tryLoadDusk(entry.path()); + tryLoadDusk(entry.path(), entry.is_directory()); } if (m_mods.empty()) { @@ -623,11 +613,6 @@ void ModLoader::shutdown() { } catch (...) { } } - if (mod.res_zip_open) { - mz_zip_reader_end(&mod.res_zip); - mod.res_zip_open = false; - } - mod.zip_data.clear(); if (mod.handle) { pl_dlclose(mod.handle); mod.handle = nullptr; diff --git a/src/dusk/modding/mod_loader.hpp b/src/dusk/modding/mod_loader.hpp new file mode 100644 index 0000000000..9fbc34c6a8 --- /dev/null +++ b/src/dusk/modding/mod_loader.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include "miniz.h" + +namespace dusk::modding { + +class ModBundle { +public: + virtual ~ModBundle() = default; + + virtual std::vector readFile(const std::string& fileName) = 0; + virtual std::vector getFileNames() = 0; +}; + +class ModBundleZip final : public ModBundle { +public: + explicit ModBundleZip(std::vector&& data); + ~ModBundleZip() override; + std::vector readFile(const std::string& fileName) override; + std::vector getFileNames() override; + +private: + std::vector zip_data; + mz_zip_archive res_zip{}; + bool res_zip_open = false; +}; + +class ModBundleDisk final : public ModBundle { +public: + explicit ModBundleDisk(std::filesystem::path root); + ~ModBundleDisk() override = default; + std::vector readFile(const std::string& fileName) override; + std::vector getFileNames() override; + +private: + std::filesystem::path root_path; +}; + +} // namespace dusk::modding diff --git a/tools/mod_test/CMakeLists.txt b/tools/mod_test/CMakeLists.txt index 4c1fcf6332..87ea7c60f6 100644 --- a/tools/mod_test/CMakeLists.txt +++ b/tools/mod_test/CMakeLists.txt @@ -1,11 +1,3 @@ -cmake_minimum_required(VERSION 3.25) -project(mod_test CXX) - -set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to dusk source root") -add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL) - -set(DUSK_MODS_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/mods" CACHE PATH "Directory to write .dusk packages into") - add_dusk_mod(mod_test SOURCES src/mod.cpp MOD_JSON mod.json From 37e5b7409d3868fbb82f88d01b2d44a07b4c36b0 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 15 May 2026 21:01:13 +0200 Subject: [PATCH 25/48] Move mod init earlier Probably necessary if we're gonna be replacing game files etc --- src/m_Do/m_Do_main.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index a356bbd571..0b60e65555 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -223,8 +223,6 @@ void main01(void) { OSReport("Calling cDyl_InitAsync()...\n"); cDyl_InitAsync(); - dusk::ModLoader::instance().init(); - g_mDoAud_audioHeap = JKRCreateSolidHeap(audioHeapSize, JKRGetCurrentHeap(), false); JKRHEAP_NAME(g_mDoAud_audioHeap, "g_mDoAud_audioHeap"); @@ -745,6 +743,9 @@ int game_main(int argc, char* argv[]) { dusk::ModLoader::instance().setModsDir(dusk::ConfigPath / "mods"); } + DuskLog.info("Initializing mods..."); + dusk::ModLoader::instance().init(); + OSReport("Starting main01 (Game Loop)...\n"); main01(); From 42d412a06e0e1a76f910d17e11ecea8bdebabf3d Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 15 May 2026 21:04:42 +0200 Subject: [PATCH 26/48] Mod file overlay system Mods can now replace DVD files with contents of their "overlay" folder (I'll update the docs later when I do a full pass and make non-code mods more of a first-class citizen) Fixes https://github.com/TwilitRealm/dusklight/issues/1306 --- files.cmake | 1 + include/dusk/mod_loader.hpp | 1 + src/dusk/modding/bundle_disk.cpp | 13 ++- src/dusk/modding/bundle_zip.cpp | 11 +++ src/dusk/modding/mod_loader.cpp | 3 + src/dusk/modding/mod_loader.hpp | 4 + src/dusk/modding/mod_loader_overlay.cpp | 108 ++++++++++++++++++++++++ 7 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 src/dusk/modding/mod_loader_overlay.cpp diff --git a/files.cmake b/files.cmake index d4460a5f6b..378527a6dd 100644 --- a/files.cmake +++ b/files.cmake @@ -1524,6 +1524,7 @@ set(DUSK_FILES src/dusk/OSMutex.cpp src/dusk/hook_system.cpp src/dusk/modding/mod_loader.cpp + src/dusk/modding/mod_loader_overlay.cpp src/dusk/modding/bundle_disk.cpp src/dusk/modding/bundle_zip.cpp src/dusk/gx_helper.cpp diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index b04172048a..04f896c3ab 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -67,6 +67,7 @@ class ModLoader { void tryLoadDusk(const std::filesystem::path& modPath, bool fromDir); void buildAPI(LoadedMod& mod); + void initOverlayFiles(); }; } // namespace dusk diff --git a/src/dusk/modding/bundle_disk.cpp b/src/dusk/modding/bundle_disk.cpp index 4bbba938ba..c6260a771d 100644 --- a/src/dusk/modding/bundle_disk.cpp +++ b/src/dusk/modding/bundle_disk.cpp @@ -9,10 +9,8 @@ namespace dusk::modding { ModBundleDisk::ModBundleDisk(fs::path root) : root_path(std::move(root)) {} std::vector ModBundleDisk::readFile(const std::string& fileName) { - const fs::path filePath = reinterpret_cast(fileName.c_str()); - const auto finalPath = root_path / fileName; - return io::FileStream::ReadAllBytes(finalPath); + return io::FileStream::ReadAllBytes(toRealPath(fileName)); } std::vector ModBundleDisk::getFileNames() { @@ -51,4 +49,13 @@ std::vector ModBundleDisk::getFileNames() { return files; } +size_t ModBundleDisk::getFileSize(const std::string& fileName) { + return std::filesystem::file_size(toRealPath(fileName)); +} + +std::filesystem::path ModBundleDisk::toRealPath(const std::string& fileName) const { + const fs::path filePath = reinterpret_cast(fileName.c_str()); + return root_path / fileName; +} + } // namespace dusk::modding \ No newline at end of file diff --git a/src/dusk/modding/bundle_zip.cpp b/src/dusk/modding/bundle_zip.cpp index b39b7a16f2..d85f394d32 100644 --- a/src/dusk/modding/bundle_zip.cpp +++ b/src/dusk/modding/bundle_zip.cpp @@ -51,4 +51,15 @@ std::vector ModBundleZip::getFileNames() { return results; } +size_t ModBundleZip::getFileSize(const std::string& fileName) { + const auto idx = mz_zip_reader_locate_file(&res_zip, fileName.c_str(), nullptr, 0); + if (idx < 0) { + throw std::runtime_error(fmt::format("Unable to locate file in zip: {}", fileName)); + } + + mz_zip_archive_file_stat stat{}; + mz_zip_reader_file_stat(&res_zip, idx, &stat); + return stat.m_uncomp_size; +} + } // namespace dusk::modding diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index c7720fc9fc..a726d8850b 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -14,6 +14,7 @@ #include #include +#include "aurora/dvd.h" #include "dusk/io.hpp" #include "miniz.h" #include "nlohmann/json.hpp" @@ -557,6 +558,8 @@ void ModLoader::init() { return; } + initOverlayFiles(); + DuskLog.info("ModLoader: initializing {} mod(s)...", m_mods.size()); for (auto& mod : m_mods) { buildAPI(mod); diff --git a/src/dusk/modding/mod_loader.hpp b/src/dusk/modding/mod_loader.hpp index 9fbc34c6a8..b7ca7997a6 100644 --- a/src/dusk/modding/mod_loader.hpp +++ b/src/dusk/modding/mod_loader.hpp @@ -11,6 +11,7 @@ class ModBundle { virtual std::vector readFile(const std::string& fileName) = 0; virtual std::vector getFileNames() = 0; + virtual size_t getFileSize(const std::string& fileName) = 0; }; class ModBundleZip final : public ModBundle { @@ -19,6 +20,7 @@ class ModBundleZip final : public ModBundle { ~ModBundleZip() override; std::vector readFile(const std::string& fileName) override; std::vector getFileNames() override; + size_t getFileSize(const std::string& fileName) override; private: std::vector zip_data; @@ -32,8 +34,10 @@ class ModBundleDisk final : public ModBundle { ~ModBundleDisk() override = default; std::vector readFile(const std::string& fileName) override; std::vector getFileNames() override; + size_t getFileSize(const std::string& fileName) override; private: + [[nodiscard]] std::filesystem::path toRealPath(const std::string& fileName) const; std::filesystem::path root_path; }; diff --git a/src/dusk/modding/mod_loader_overlay.cpp b/src/dusk/modding/mod_loader_overlay.cpp new file mode 100644 index 0000000000..0607473f83 --- /dev/null +++ b/src/dusk/modding/mod_loader_overlay.cpp @@ -0,0 +1,108 @@ +#include "aurora/dvd.h" +#include "aurora/lib/logging.hpp" +#include "dusk/mod_loader.hpp" +#include "mod_loader.hpp" + +#include + +using namespace std::string_literals; + +namespace { + +aurora::Module Log("dusk::modLoader::overlay"); + +struct OverlayFileData { + std::string bundlePath; + dusk::LoadedMod* mod; // TODO: is using a raw pointer a bad idea here? +}; + +std::vector s_overlayFiles; + +void findOverlayFiles(std::vector& files, dusk::LoadedMod& mod) { + for (const auto& file : mod.bundle->getFileNames()) { + if (!file.starts_with("overlay/")) { + continue; + } + + auto overlayPath = file.substr("overlay/"s.size()); + assert(!overlayPath.starts_with('/')); + overlayPath.insert(0, "/"); + + const auto size = mod.bundle->getFileSize(file); + + const auto index = s_overlayFiles.size(); + s_overlayFiles.emplace_back(file, &mod); + files.emplace_back(strdup(overlayPath.c_str()), reinterpret_cast(index), size); + } +} + +struct OpenOverlayFile { + std::vector data; + size_t pos; +}; + +void* cbOpen(void* userdata) { + const auto index = reinterpret_cast(userdata); + const auto& fileData = s_overlayFiles[index]; + auto fileContents = fileData.mod->bundle->readFile(fileData.bundlePath); + + return new OpenOverlayFile(std::move(fileContents), 0); +} + +void cbClose(void* handle) { + const auto openFile = static_cast(handle); + delete openFile; +} + +int64_t cbRead(void* handle, uint8_t *buf, const size_t len) { + auto& openFile = *static_cast(handle); + + const auto remainingSpace = openFile.data.size() - openFile.pos; + const auto toRead = std::min(remainingSpace, len); + std::memcpy(buf, openFile.data.data() + openFile.pos, toRead); + openFile.pos += toRead; + return static_cast(toRead); +} + +int64_t cbSeek(void* handle, int64_t offset, int32_t whence) { + if (whence != 0) { + Log.fatal("Invalid seek mode from aurora: {}", whence); + } + + auto& openFile = *static_cast(handle); + const auto posSigned = std::clamp(offset, static_cast(0), static_cast(openFile.data.size())); + openFile.pos = static_cast(posSigned); + return posSigned; +} + +constexpr AuroraOverlayCallbacks s_overlayCallbacks = { + .open = cbOpen, + .close = cbClose, + .read = cbRead, + .seek = cbSeek, +}; + +} + +namespace dusk { + +void ModLoader::initOverlayFiles() { + Log.debug("Initializing overlay files..."); + + aurora_dvd_overlay_callbacks(&s_overlayCallbacks); + + std::vector files; + + for (auto& mod : m_mods) { + findOverlayFiles(files, mod); + } + + Log.debug("Found {} overlay files.", files.size()); + aurora_dvd_overlay_files(files.data(), files.size()); + + for (const auto& file : files) { + std::free(const_cast(file.fileName)); + } +} + +} // namespace dusk From 012b54b325645bfc06f5140f9b4901226c16e0e2 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 15 May 2026 22:04:22 +0200 Subject: [PATCH 27/48] Use fs_path_to_string instead of .string() Unicode fixes --- src/dusk/modding/mod_loader.cpp | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index a726d8850b..18a65043c1 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -449,7 +449,7 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) if (dllEntry.empty()) { DuskLog.warn( - "ModLoader: no *{} found in {} — skipping", k_libExt, modPath.filename().string()); + "ModLoader: no *{} found in {} — skipping", k_libExt, io::fs_path_to_string(modPath.filename())); return; } @@ -471,7 +471,7 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) { std::ofstream out(dllCachePath, std::ios::binary | std::ios::out); if (!out) { - DuskLog.error("ModLoader: failed to write {}", dllCachePath.string()); + DuskLog.error("ModLoader: failed to write {}", io::fs_path_to_string(dllCachePath)); return; } @@ -482,18 +482,18 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) void* handle = pl_dlopen(dllCachePath); if (!handle) { - DuskLog.error("ModLoader: failed to open {}: {}", dllCachePath.string(), pl_dlerror()); + DuskLog.error("ModLoader: failed to open {}: {}", io::fs_path_to_string(dllCachePath), pl_dlerror()); return; } LoadedMod mod; - mod.mod_path = fs::absolute(modPath).string(); - mod.dir = fs::absolute(cacheDir).string(); + mod.mod_path = io::fs_path_to_string(fs::absolute(modPath)); + mod.dir = io::fs_path_to_string(fs::absolute(cacheDir)); mod.handle = handle; auto* mod_api_ver = reinterpret_cast(pl_dlsym(handle, "mod_api_version")); if (mod_api_ver && *mod_api_ver != DUSK_MOD_API_VERSION) { DuskLog.error("ModLoader: {} expects API v{} but engine is v{}, skipping", - fs::path(dllEntry).filename().string(), *mod_api_ver, DUSK_MOD_API_VERSION); + io::fs_path_to_string(fs::path(dllEntry).filename()), *mod_api_ver, DUSK_MOD_API_VERSION); pl_dlclose(handle); return; } @@ -504,12 +504,12 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) if (!mod.fn_init || !mod.fn_tick) { DuskLog.error("ModLoader: {} missing mod_init or mod_tick — skipping", - fs::path(dllEntry).filename().string()); + io::fs_path_to_string(fs::path(dllEntry).filename())); pl_dlclose(handle); return; } - mod.name = metaName.empty() ? modPath.stem().string() : metaName; + mod.name = metaName.empty() ? io::fs_path_to_string(modPath.stem()) : metaName; mod.version = metaVersion.empty() ? "?" : metaVersion; mod.author = metaAuthor.empty() ? "unknown" : metaAuthor; mod.description = metaDescription; @@ -518,7 +518,7 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) m_mods.push_back(std::move(mod)); DuskLog.info("ModLoader: found '{}' v{} by {} ({})", m_mods.back().name, m_mods.back().version, - m_mods.back().author, modPath.filename().string()); + m_mods.back().author, io::fs_path_to_string(modPath.filename())); } void ModLoader::init() { @@ -530,7 +530,7 @@ void ModLoader::init() { namespace fs = std::filesystem; if (!fs::is_directory(m_modsDir)) { DuskLog.info( - "ModLoader: mods directory '{}' not found — mod loading skipped", m_modsDir.string()); + "ModLoader: mods directory '{}' not found — mod loading skipped", io::fs_path_to_string(m_modsDir)); return; } From 3f018204b6a92165b03db7bfcd84d38b5927f4c2 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 15 May 2026 22:40:46 +0200 Subject: [PATCH 28/48] Add mod IDs to mod json Each mod must have a unique ID --- include/dusk/mod_loader.hpp | 7 ++- src/dusk/modding/mod_loader.cpp | 95 +++++++++++++++++++++++++-------- src/dusk/ui/mods_window.cpp | 10 ++-- tools/mod_template/mod.json | 1 + tools/mod_test/mod.json | 1 + 5 files changed, 86 insertions(+), 28 deletions(-) diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 04f896c3ab..e0ec830474 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -22,11 +22,16 @@ struct RmlTabUpdateCallback { void* userdata; }; -struct LoadedMod { +struct ModMetadata { + std::string id; std::string name; std::string version; std::string author; std::string description; +}; + +struct LoadedMod { + ModMetadata metadata; std::string mod_path; std::string dir; diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index 18a65043c1..02d6759cc9 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -74,6 +74,7 @@ static constexpr const char* k_libExt = ".so"; #endif using namespace dusk::modding; +using namespace std::string_literals; using namespace std::string_view_literals; #if defined(_M_ARM64) || defined(__aarch64__) @@ -106,7 +107,7 @@ struct ModGuard { }; static const char* modName() { - return g_currentMod ? g_currentMod->name.c_str() : "mod"; + return g_currentMod ? g_currentMod->metadata.id.c_str() : "mod"; } static void cb_log_info(const char* fmt, ...) { @@ -156,7 +157,7 @@ static void* cb_load_resource(const char* relative_path, size_t* out_size) { try { data = g_currentMod->bundle->readFile(entry); } catch (const std::runtime_error& e) { - DuskLog.error("[{}] load_resource: '{}' failed: {}", g_currentMod->name, entry, e.what()); + DuskLog.error("[{}] load_resource: '{}' failed: {}", g_currentMod->metadata.id, entry, e.what()); return nullptr; } @@ -415,6 +416,50 @@ static DllLocateResult LocateDllInBundle(ModBundle& bundle) { return DllLocateResult{dllEntry, dllFallback}; } +class InvalidModDataException : public std::runtime_error { +public: + explicit InvalidModDataException(const std::string& msg) : runtime_error(msg) {} + explicit InvalidModDataException(const char* msg) : runtime_error(msg) {} +}; + +static ModMetadata loadMetadata(const std::filesystem::path& modPath, ModBundle& bundle) { + const auto metaJson = bundle.readFile("mod.json"); + auto j = nlohmann::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", ""); + + if (metaId.empty()) { + throw InvalidModDataException("Missing ID value in mod metadata!"); + } + if (metaName.empty()) { + metaName = io::fs_path_to_string(modPath.stem()); + } + if (metaVersion.empty()) { + metaVersion = "?"s; + } + if (metaAuthor.empty()) { + metaAuthor = "unknown"s; + } + + return ModMetadata{ + std::move(metaId), + std::move(metaName), + std::move(metaVersion), + std::move(metaAuthor), + std::move(metaDescription), + }; +} + +static bool checkDuplicateMod(const ModMetadata& metadata, const std::vector& mods) { + return std::ranges::any_of(mods, [&](const LoadedMod& mod) { + return mod.metadata.id == metadata.id; + }); +} + void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) { namespace fs = std::filesystem; @@ -426,15 +471,10 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) return; } - std::string metaName, metaVersion, metaAuthor, metaDescription; + ModMetadata metadata; try { - const auto metaJson = bundle->readFile("mod.json"); - auto j = nlohmann::json::parse(metaJson); - metaName = j.value("name", ""); - metaVersion = j.value("version", ""); - metaAuthor = j.value("author", ""); - metaDescription = j.value("description", ""); + metadata = loadMetadata(modPath, *bundle); } catch (const std::runtime_error& e) { Log.error( @@ -442,6 +482,14 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) return; } + if (checkDuplicateMod(metadata, m_mods)) { + Log.error( + "ModLoader: mod with id '{}' already exists, not loading {}", + metadata.id, + io::fs_path_to_string(modPath.filename())); + return; + } + auto [dllEntry, dllFallback] = LocateDllInBundle(*bundle); if (dllEntry.empty()) { dllEntry = dllFallback; @@ -509,16 +557,19 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) return; } - mod.name = metaName.empty() ? io::fs_path_to_string(modPath.stem()) : metaName; - mod.version = metaVersion.empty() ? "?" : metaVersion; - mod.author = metaAuthor.empty() ? "unknown" : metaAuthor; - mod.description = metaDescription; + mod.metadata = std::move(metadata); mod.bundle = std::move(bundle); - m_mods.push_back(std::move(mod)); - DuskLog.info("ModLoader: found '{}' v{} by {} ({})", m_mods.back().name, m_mods.back().version, - m_mods.back().author, io::fs_path_to_string(modPath.filename())); + const auto& inserted = m_mods.emplace_back(std::move(mod)); + + DuskLog.info( + "ModLoader: found '{}' ('{}') v{} by {} ({})", + inserted.metadata.name, + inserted.metadata.id, + inserted.metadata.version, + inserted.metadata.author, + io::fs_path_to_string(modPath.filename())); } void ModLoader::init() { @@ -571,14 +622,14 @@ void ModLoader::init() { mod.fn_init(&mod.api); if (!mod.load_failed) { mod.active = true; - DuskLog.info("ModLoader: '{}' initialized", mod.name); + DuskLog.info("ModLoader: '{}' initialized", mod.metadata.id); } else { - DuskLog.error("ModLoader: '{}' failed to load due to hook conflicts", mod.name); + DuskLog.error("ModLoader: '{}' failed to load due to hook conflicts", mod.metadata.id); } } catch (const std::exception& e) { - DuskLog.error("ModLoader: exception in {}.mod_init(): {}", mod.name, e.what()); + DuskLog.error("ModLoader: exception in {}.mod_init(): {}", mod.metadata.id, e.what()); } catch (...) { - DuskLog.error("ModLoader: unknown exception in {}.mod_init()", mod.name); + DuskLog.error("ModLoader: unknown exception in {}.mod_init()", mod.metadata.id); } } @@ -597,10 +648,10 @@ void ModLoader::tick() { mod.fn_tick(&mod.api); } catch (const std::exception& e) { DuskLog.error( - "ModLoader: exception in {}.mod_tick(): {} — disabling", mod.name, e.what()); + "ModLoader: exception in {}.mod_tick(): {} — disabling", mod.metadata.id, e.what()); mod.active = false; } catch (...) { - DuskLog.error("ModLoader: unknown exception in {}.mod_tick() — disabling", mod.name); + DuskLog.error("ModLoader: unknown exception in {}.mod_tick() — disabling", mod.metadata.id); mod.active = false; } } diff --git a/src/dusk/ui/mods_window.cpp b/src/dusk/ui/mods_window.cpp index 6f4c1b70ab..6c7053584b 100644 --- a/src/dusk/ui/mods_window.cpp +++ b/src/dusk/ui/mods_window.cpp @@ -38,8 +38,8 @@ Rml::String build_mod_detail_rml(const dusk::LoadedMod& mod) { R"(Path)" R"({})" R"()", - mod.version, - mod.author, + mod.metadata.version, + mod.metadata.author, statusClass, statusText, mod.mod_path ); @@ -62,7 +62,7 @@ ModsWindow::ModsWindow() { for (size_t i = 0; i < mods.size(); ++i) { mSnapshot.push_back({mods[i].active, mods[i].load_failed}); - add_tab(mods[i].name, [this, i](Rml::Element* content) { + add_tab(mods[i].metadata.name, [this, i](Rml::Element* content) { mActiveModIndex = static_cast(i); const auto& curMods = dusk::ModLoader::instance().mods(); @@ -76,9 +76,9 @@ ModsWindow::ModsWindow() { pane.add_section("Details"); pane.add_rml(build_mod_detail_rml(mod)); - if (!mod.description.empty()) { + if (!mod.metadata.description.empty()) { pane.add_section("Description"); - pane.add_text(mod.description); + pane.add_text(mod.metadata.description); } for (const auto& cb : mod.tab_content) { diff --git a/tools/mod_template/mod.json b/tools/mod_template/mod.json index 0ebe9f5176..44364017ee 100644 --- a/tools/mod_template/mod.json +++ b/tools/mod_template/mod.json @@ -1,4 +1,5 @@ { + "id": "example.template_mod", "name": "Template Mod", "version": "1.0.0", "author": "Maddie", diff --git a/tools/mod_test/mod.json b/tools/mod_test/mod.json index 0a168a6c22..0e36ab47a2 100644 --- a/tools/mod_test/mod.json +++ b/tools/mod_test/mod.json @@ -1,4 +1,5 @@ { + "id": "dev.twilitrealm.test_mod", "name": "API Test Mod", "version": "1.0.0", "author": "dusk", From 32069d936cbedd03b562c09268fbdf7f7f3fcf7e Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 15 May 2026 23:11:29 +0200 Subject: [PATCH 29/48] Make native module handles a special type We love RAII --- files.cmake | 2 + include/dusk/mod_loader.hpp | 3 +- src/dusk/modding/mod_loader.cpp | 80 +++++----------------------- src/dusk/modding/native_module.cpp | 83 ++++++++++++++++++++++++++++++ src/dusk/modding/native_module.hpp | 35 +++++++++++++ 5 files changed, 134 insertions(+), 69 deletions(-) create mode 100644 src/dusk/modding/native_module.cpp create mode 100644 src/dusk/modding/native_module.hpp diff --git a/files.cmake b/files.cmake index 378527a6dd..a0370df464 100644 --- a/files.cmake +++ b/files.cmake @@ -1525,6 +1525,8 @@ set(DUSK_FILES src/dusk/hook_system.cpp src/dusk/modding/mod_loader.cpp src/dusk/modding/mod_loader_overlay.cpp + src/dusk/modding/native_module.cpp + src/dusk/modding/native_module.hpp src/dusk/modding/bundle_disk.cpp src/dusk/modding/bundle_zip.cpp src/dusk/gx_helper.cpp diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index e0ec830474..13624ee6eb 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -8,6 +8,7 @@ namespace dusk::modding { class ModBundle; +class NativeModule; } namespace dusk { @@ -35,7 +36,7 @@ struct LoadedMod { std::string mod_path; std::string dir; - void* handle = nullptr; + std::unique_ptr handle; bool active = false; bool load_failed = false; diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index 02d6759cc9..c222e33d68 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -8,7 +8,6 @@ #include #include -#include #include #include #include @@ -17,62 +16,11 @@ #include "aurora/dvd.h" #include "dusk/io.hpp" #include "miniz.h" +#include "native_module.hpp" #include "nlohmann/json.hpp" -#if defined(_WIN32) -#define WIN32_LEAN_AND_MEAN -#define NOMINMAX -#include - static aurora::Module Log("dusk::modLoader"); -static void* pl_dlopen(const std::filesystem::path& p) { - return LoadLibraryW(p.wstring().c_str()); -} -static void* pl_dlsym(void* h, const char* name) { - return reinterpret_cast(GetProcAddress(static_cast(h), name)); -} -static void pl_dlclose(void* h) { - FreeLibrary(static_cast(h)); -} -static std::string pl_dlerror() { - char buf[256]{}; - FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, - GetLastError(), 0, buf, sizeof(buf), nullptr); - std::string s = buf; - while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) { - s.pop_back(); - } - return s; -} -static constexpr const char* k_libExt = ".dll"; - -#else -#include -static void* pl_dlopen(const std::filesystem::path& p) { -#if defined(__linux__) - return dlopen(p.c_str(), RTLD_LAZY | RTLD_LOCAL | RTLD_DEEPBIND); -#else - return dlopen(p.c_str(), RTLD_LAZY | RTLD_LOCAL); -#endif -} -static void* pl_dlsym(void* h, const char* name) { - return dlsym(h, name); -} -static void pl_dlclose(void* h) { - dlclose(h); -} -static std::string pl_dlerror() { - const char* e = dlerror(); - return e ? e : "(unknown error)"; -} -#if defined(__APPLE__) -static constexpr const char* k_libExt = ".dylib"; -#else -static constexpr const char* k_libExt = ".so"; -#endif -#endif - using namespace dusk::modding; using namespace std::string_literals; using namespace std::string_view_literals; @@ -497,7 +445,7 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) if (dllEntry.empty()) { DuskLog.warn( - "ModLoader: no *{} found in {} — skipping", k_libExt, io::fs_path_to_string(modPath.filename())); + "ModLoader: no *{} found in {} — skipping", NativeModule::LibraryExtension, io::fs_path_to_string(modPath.filename())); return; } @@ -528,32 +476,32 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) static_cast(dllData.size())); } - void* handle = pl_dlopen(dllCachePath); - if (!handle) { - DuskLog.error("ModLoader: failed to open {}: {}", io::fs_path_to_string(dllCachePath), pl_dlerror()); + NativeModule native; + try { + native = NativeModule(dllCachePath); + } catch (const std::runtime_error& e) { + DuskLog.error("ModLoader: failed to open {}: {}", io::fs_path_to_string(dllCachePath), e.what()); return; } LoadedMod mod; mod.mod_path = io::fs_path_to_string(fs::absolute(modPath)); mod.dir = io::fs_path_to_string(fs::absolute(cacheDir)); - mod.handle = handle; - auto* mod_api_ver = reinterpret_cast(pl_dlsym(handle, "mod_api_version")); + mod.handle = std::make_unique(std::move(native)); + const auto mod_api_ver = mod.handle->LookupSymbol("mod_api_version"); if (mod_api_ver && *mod_api_ver != DUSK_MOD_API_VERSION) { DuskLog.error("ModLoader: {} expects API v{} but engine is v{}, skipping", io::fs_path_to_string(fs::path(dllEntry).filename()), *mod_api_ver, DUSK_MOD_API_VERSION); - pl_dlclose(handle); return; } - mod.fn_init = reinterpret_cast(pl_dlsym(handle, "mod_init")); - mod.fn_tick = reinterpret_cast(pl_dlsym(handle, "mod_tick")); - mod.fn_cleanup = reinterpret_cast(pl_dlsym(handle, "mod_cleanup")); + mod.fn_init = mod.handle->LookupSymbol("mod_init"); + mod.fn_tick = mod.handle->LookupSymbol("mod_tick"); + mod.fn_cleanup = mod.handle->LookupSymbol("mod_cleanup"); if (!mod.fn_init || !mod.fn_tick) { DuskLog.error("ModLoader: {} missing mod_init or mod_tick — skipping", io::fs_path_to_string(fs::path(dllEntry).filename())); - pl_dlclose(handle); return; } @@ -667,10 +615,6 @@ void ModLoader::shutdown() { } catch (...) { } } - if (mod.handle) { - pl_dlclose(mod.handle); - mod.handle = nullptr; - } } m_mods.clear(); g_services.clear(); diff --git a/src/dusk/modding/native_module.cpp b/src/dusk/modding/native_module.cpp new file mode 100644 index 0000000000..82cbe501bf --- /dev/null +++ b/src/dusk/modding/native_module.cpp @@ -0,0 +1,83 @@ +#include "native_module.hpp" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#endif + +namespace { +#if defined(_WIN32) +void* pl_dlopen(const std::filesystem::path& p) { + return LoadLibraryW(p.wstring().c_str()); +} +void* pl_dlsym(void* h, const char* name) { + return reinterpret_cast(GetProcAddress(static_cast(h), name)); +} +void pl_dlclose(void* h) { + FreeLibrary(static_cast(h)); +} +std::string pl_dlerror() { + char buf[256]{}; + FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, + GetLastError(), 0, buf, sizeof(buf), nullptr); + std::string s = buf; + while (!s.empty() && (s.back() == '\r' || s.back() == '\n')) { + s.pop_back(); + } + return s; +} +#else +#include +static void* pl_dlopen(const std::filesystem::path& p) { +#if defined(__linux__) + return dlopen(p.c_str(), RTLD_LAZY | RTLD_LOCAL | RTLD_DEEPBIND); +#else + return dlopen(p.c_str(), RTLD_LAZY | RTLD_LOCAL); +#endif +} +static void* pl_dlsym(void* h, const char* name) { + return dlsym(h, name); +} +static void pl_dlclose(void* h) { + dlclose(h); +} +static std::string pl_dlerror() { + const char* e = dlerror(); + return e ? e : "(unknown error)"; +} +#endif +} + +namespace dusk::modding { +NativeModule::NativeModule() noexcept : handle(nullptr) { +} + +NativeModule::NativeModule(NativeModule&& other) noexcept { + handle = other.handle; + other.handle = nullptr; +} + +NativeModule& NativeModule::operator=(NativeModule&& other) noexcept { + handle = other.handle; + other.handle = nullptr; + return *this; +} + +NativeModule::NativeModule(const std::filesystem::path& path) { + handle = pl_dlopen(path); + if (!handle) { + throw std::runtime_error(pl_dlerror()); + } +} + +NativeModule::~NativeModule() { + if (handle) { + pl_dlclose(handle); + } +} + +void* NativeModule::LookupSymbol(const char* name) const { + return pl_dlsym(handle, name); +} +} // namespace dusk::modding \ No newline at end of file diff --git a/src/dusk/modding/native_module.hpp b/src/dusk/modding/native_module.hpp new file mode 100644 index 0000000000..00bfb7d6dc --- /dev/null +++ b/src/dusk/modding/native_module.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace dusk::modding { +class NativeModule final { +public: + NativeModule() noexcept; + NativeModule(const NativeModule& other) = delete; + NativeModule(NativeModule&& other) noexcept; + explicit NativeModule(const std::filesystem::path& path); + ~NativeModule(); + + void* LookupSymbol(const char* name) const; + + template + T LookupSymbol(const char* name) const { + return reinterpret_cast(LookupSymbol(name)); + } + + NativeModule& operator=(NativeModule&& other) noexcept; + +#if defined(_WIN32) + static constexpr auto LibraryExtension = ".dll"; +#elif defined(__APPLE__) + static constexpr auto LibraryExtension = ".dylib"; +#else + static constexpr auto LibraryExtension = ".so"; +#endif + +private: + void* handle; +}; + +} From 9823ca7c4a802578880e8ca86c3665645a918138 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 15 May 2026 23:37:23 +0200 Subject: [PATCH 30/48] Split native mod stuff out of LoadedMod --- include/dusk/mod_loader.hpp | 21 ++-- src/dusk/modding/mod_loader.cpp | 170 +++++++++++++++++--------------- 2 files changed, 103 insertions(+), 88 deletions(-) diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 13624ee6eb..d48875b4ff 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -31,14 +31,9 @@ struct ModMetadata { std::string description; }; -struct LoadedMod { - ModMetadata metadata; - std::string mod_path; - std::string dir; - +struct NativeMod { std::unique_ptr handle; - bool active = false; - bool load_failed = false; + DuskModAPI api{}; using FnInit = void (*)(DuskModAPI*); using FnTick = void (*)(DuskModAPI*); @@ -47,8 +42,17 @@ struct LoadedMod { FnInit fn_init = nullptr; FnTick fn_tick = nullptr; FnCleanup fn_cleanup = nullptr; +}; - DuskModAPI api{}; +struct LoadedMod { + ModMetadata metadata; + std::string mod_path; + std::string dir; + + bool active = false; + bool load_failed = false; + + std::unique_ptr native; std::unique_ptr bundle; std::vector tab_content; @@ -72,6 +76,7 @@ class ModLoader { bool m_initialized = false; void tryLoadDusk(const std::filesystem::path& modPath, bool fromDir); + bool tryLoadNativeMod(LoadedMod& mod); void buildAPI(LoadedMod& mod); void initOverlayFiles(); }; diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index c222e33d68..598b44e79d 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -302,31 +302,32 @@ ModLoader& ModLoader::instance() { } void ModLoader::buildAPI(LoadedMod& mod) { - mod.api.api_version = DUSK_MOD_API_VERSION; - mod.api.mod_dir = mod.dir.c_str(); - mod.api.log_info = cb_log_info; - mod.api.log_warn = cb_log_warn; - mod.api.log_error = cb_log_error; - mod.api.load_resource = cb_load_resource; - mod.api.free_resource = cb_free_resource; - mod.api.register_tab_content = cb_register_tab_content; - mod.api.register_tab_update = cb_register_tab_update; - mod.api.panel_add_section = cb_panel_add_section; - mod.api.panel_add_button = cb_panel_add_button; - mod.api.panel_add_badge_row = cb_panel_add_badge_row; - mod.api.panel_add_dyn_text = cb_panel_add_dyn_text; - mod.api.elem_set_badge = cb_elem_set_badge; - mod.api.elem_set_text = cb_elem_set_text; - mod.api.panel_add_progress = cb_panel_add_progress; - mod.api.elem_set_progress = cb_elem_set_progress; - mod.api.hook_install = hookInstallByAddr; - mod.api.hook_pre = api_hook_pre; - mod.api.hook_post = api_hook_post; - mod.api.hook_replace = api_hook_replace; - mod.api.hook_dispatch_pre = hookDispatchPre; - mod.api.hook_dispatch_post = hookDispatchPost; - mod.api.service_publish = cb_service_publish; - mod.api.service_get = cb_service_get; + auto& native = *mod.native; + native.api.api_version = DUSK_MOD_API_VERSION; + native.api.mod_dir = mod.dir.c_str(); + native.api.log_info = cb_log_info; + native.api.log_warn = cb_log_warn; + native.api.log_error = cb_log_error; + native.api.load_resource = cb_load_resource; + native.api.free_resource = cb_free_resource; + native.api.register_tab_content = cb_register_tab_content; + native.api.register_tab_update = cb_register_tab_update; + native.api.panel_add_section = cb_panel_add_section; + native.api.panel_add_button = cb_panel_add_button; + native.api.panel_add_badge_row = cb_panel_add_badge_row; + native.api.panel_add_dyn_text = cb_panel_add_dyn_text; + native.api.elem_set_badge = cb_elem_set_badge; + native.api.elem_set_text = cb_elem_set_text; + native.api.panel_add_progress = cb_panel_add_progress; + native.api.elem_set_progress = cb_elem_set_progress; + native.api.hook_install = hookInstallByAddr; + native.api.hook_pre = api_hook_pre; + native.api.hook_post = api_hook_post; + native.api.hook_replace = api_hook_replace; + native.api.hook_dispatch_pre = hookDispatchPre; + native.api.hook_dispatch_post = hookDispatchPost; + native.api.service_publish = cb_service_publish; + native.api.service_get = cb_service_get; } static std::unique_ptr loadBundle(const std::filesystem::path& modPath, bool fromDir) { @@ -408,48 +409,21 @@ static bool checkDuplicateMod(const ModMetadata& metadata, const std::vector 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()); - return; - } - - ModMetadata metadata; - try - { - metadata = loadMetadata(modPath, *bundle); - } - catch (const std::runtime_error& e) { - Log.error( - "ModLoader: bad mod.json in {}: {}", io::fs_path_to_string(modPath.filename()), e.what()); - return; - } - - if (checkDuplicateMod(metadata, m_mods)) { - Log.error( - "ModLoader: mod with id '{}' already exists, not loading {}", - metadata.id, - io::fs_path_to_string(modPath.filename())); - return; - } - - auto [dllEntry, dllFallback] = LocateDllInBundle(*bundle); + auto [dllEntry, dllFallback] = LocateDllInBundle(*mod.bundle); if (dllEntry.empty()) { dllEntry = dllFallback; } if (dllEntry.empty()) { - DuskLog.warn( - "ModLoader: no *{} found in {} — skipping", NativeModule::LibraryExtension, io::fs_path_to_string(modPath.filename())); - return; + DuskLog.error( + "ModLoader: no *{} found in {} — skipping", NativeModule::LibraryExtension, mod.metadata.id); + return false; } - const fs::path cacheDir = m_modsDir / ".cache" / modPath.stem(); + const fs::path cacheDir = m_modsDir / ".cache" / mod.metadata.id; std::error_code ec; fs::create_directories(cacheDir, ec); @@ -457,18 +431,18 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) std::vector dllData; try { - dllData = bundle->readFile(dllEntry); + dllData = mod.bundle->readFile(dllEntry); } catch (const std::runtime_error& e) { DuskLog.error( - "ModLoader: failed to extract {} from {}", dllEntry, io::fs_path_to_string(modPath.filename())); - return; + "ModLoader: failed to extract {} from {}", dllEntry, mod.metadata.id); + return false; } { std::ofstream out(dllCachePath, std::ios::binary | std::ios::out); if (!out) { DuskLog.error("ModLoader: failed to write {}", io::fs_path_to_string(dllCachePath)); - return; + return false; } out.write( @@ -476,39 +450,75 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) static_cast(dllData.size())); } - NativeModule native; + auto nativeMod = std::make_unique(); try { - native = NativeModule(dllCachePath); + nativeMod->handle = std::make_unique(dllCachePath); } catch (const std::runtime_error& e) { DuskLog.error("ModLoader: failed to open {}: {}", io::fs_path_to_string(dllCachePath), e.what()); - return; + return false; } - LoadedMod mod; - mod.mod_path = io::fs_path_to_string(fs::absolute(modPath)); - mod.dir = io::fs_path_to_string(fs::absolute(cacheDir)); - mod.handle = std::make_unique(std::move(native)); - const auto mod_api_ver = mod.handle->LookupSymbol("mod_api_version"); + const auto mod_api_ver = nativeMod->handle->LookupSymbol("mod_api_version"); if (mod_api_ver && *mod_api_ver != DUSK_MOD_API_VERSION) { DuskLog.error("ModLoader: {} expects API v{} but engine is v{}, skipping", io::fs_path_to_string(fs::path(dllEntry).filename()), *mod_api_ver, DUSK_MOD_API_VERSION); - return; + return false; } - mod.fn_init = mod.handle->LookupSymbol("mod_init"); - mod.fn_tick = mod.handle->LookupSymbol("mod_tick"); - mod.fn_cleanup = mod.handle->LookupSymbol("mod_cleanup"); + nativeMod->fn_init = nativeMod->handle->LookupSymbol("mod_init"); + nativeMod->fn_tick = nativeMod->handle->LookupSymbol("mod_tick"); + nativeMod->fn_cleanup = nativeMod->handle->LookupSymbol("mod_cleanup"); - if (!mod.fn_init || !mod.fn_tick) { + if (!nativeMod->fn_init || !nativeMod->fn_tick) { DuskLog.error("ModLoader: {} missing mod_init or mod_tick — skipping", io::fs_path_to_string(fs::path(dllEntry).filename())); + return false; + } + + mod.dir = io::fs_path_to_string(fs::absolute(cacheDir)); + mod.native = std::move(nativeMod); + return true; +} + +void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) { + namespace fs = std::filesystem; + + 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()); return; } - mod.metadata = std::move(metadata); + ModMetadata metadata; + try + { + metadata = loadMetadata(modPath, *bundle); + } + catch (const std::runtime_error& e) { + Log.error( + "ModLoader: bad mod.json in {}: {}", io::fs_path_to_string(modPath.filename()), e.what()); + return; + } + + if (checkDuplicateMod(metadata, m_mods)) { + Log.error( + "ModLoader: mod with id '{}' already exists, not loading {}", + metadata.id, + io::fs_path_to_string(modPath.filename())); + return; + } + LoadedMod mod; + mod.mod_path = io::fs_path_to_string(fs::absolute(modPath)); + mod.metadata = std::move(metadata); mod.bundle = std::move(bundle); + if (!tryLoadNativeMod(mod)) { + return; + } + const auto& inserted = m_mods.emplace_back(std::move(mod)); DuskLog.info( @@ -567,7 +577,7 @@ void ModLoader::init() { for (auto& mod : m_mods) { ModGuard guard(&mod); try { - mod.fn_init(&mod.api); + mod.native->fn_init(&mod.native->api); if (!mod.load_failed) { mod.active = true; DuskLog.info("ModLoader: '{}' initialized", mod.metadata.id); @@ -593,7 +603,7 @@ void ModLoader::tick() { } ModGuard guard(&mod); try { - mod.fn_tick(&mod.api); + mod.native->fn_tick(&mod.native->api); } catch (const std::exception& e) { DuskLog.error( "ModLoader: exception in {}.mod_tick(): {} — disabling", mod.metadata.id, e.what()); @@ -608,10 +618,10 @@ void ModLoader::tick() { void ModLoader::shutdown() { for (auto& mod : m_mods) { hookClearMod(&mod); - if (mod.fn_cleanup) { + if (mod.native->fn_cleanup) { ModGuard guard(&mod); try { - mod.fn_cleanup(&mod.api); + mod.native->fn_cleanup(&mod.native->api); } catch (...) { } } From fb9ffb444a9d23a503afdfec797fdcbbfa39554b Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 15 May 2026 23:46:41 +0200 Subject: [PATCH 31/48] Allow non-code mods to exist --- include/dusk/mod_loader.hpp | 1 + src/dusk/modding/mod_loader.cpp | 16 ++++++++++++---- tools/mod_template/mod.json | 3 ++- tools/mod_test/mod.json | 3 ++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index d48875b4ff..3b467b6db0 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -29,6 +29,7 @@ struct ModMetadata { std::string version; std::string author; std::string description; + bool hasCode; }; struct NativeMod { diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index 598b44e79d..b5ba972eaa 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -380,6 +380,7 @@ static ModMetadata loadMetadata(const std::filesystem::path& modPath, ModBundle& std::string metaVersion = j.value("version", ""); std::string metaAuthor = j.value("author", ""); std::string metaDescription = j.value("description", ""); + const bool hasCode = j.value("has_code", false); if (metaId.empty()) { throw InvalidModDataException("Missing ID value in mod metadata!"); @@ -400,6 +401,7 @@ static ModMetadata loadMetadata(const std::filesystem::path& modPath, ModBundle& std::move(metaVersion), std::move(metaAuthor), std::move(metaDescription), + hasCode, }; } @@ -515,7 +517,7 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) mod.metadata = std::move(metadata); mod.bundle = std::move(bundle); - if (!tryLoadNativeMod(mod)) { + if (mod.metadata.hasCode && !tryLoadNativeMod(mod)) { return; } @@ -571,10 +573,16 @@ void ModLoader::init() { DuskLog.info("ModLoader: initializing {} mod(s)...", m_mods.size()); for (auto& mod : m_mods) { - buildAPI(mod); + if (mod.native) { + buildAPI(mod); + } } for (auto& mod : m_mods) { + if (!mod.native) { + continue; + } + ModGuard guard(&mod); try { mod.native->fn_init(&mod.native->api); @@ -598,7 +606,7 @@ void ModLoader::init() { void ModLoader::tick() { for (auto& mod : m_mods) { - if (!mod.active) { + if (!mod.active || !mod.native) { continue; } ModGuard guard(&mod); @@ -618,7 +626,7 @@ void ModLoader::tick() { void ModLoader::shutdown() { for (auto& mod : m_mods) { hookClearMod(&mod); - if (mod.native->fn_cleanup) { + if (mod.native && mod.native->fn_cleanup) { ModGuard guard(&mod); try { mod.native->fn_cleanup(&mod.native->api); diff --git a/tools/mod_template/mod.json b/tools/mod_template/mod.json index 44364017ee..f920df6dd2 100644 --- a/tools/mod_template/mod.json +++ b/tools/mod_template/mod.json @@ -3,5 +3,6 @@ "name": "Template Mod", "version": "1.0.0", "author": "Maddie", - "description": "An example Dusk mod" + "description": "An example Dusk mod", + "has_code": true } diff --git a/tools/mod_test/mod.json b/tools/mod_test/mod.json index 0e36ab47a2..f36d8abe2d 100644 --- a/tools/mod_test/mod.json +++ b/tools/mod_test/mod.json @@ -3,5 +3,6 @@ "name": "API Test Mod", "version": "1.0.0", "author": "dusk", - "description": "Exercises every feature of the Dusk mod API." + "description": "Exercises every feature of the Dusk mod API.", + "has_code": true } From 0692fa542360e68b457515ad6733e112402b8ade Mon Sep 17 00:00:00 2001 From: Ash Date: Sun, 24 May 2026 23:23:38 +0200 Subject: [PATCH 32/48] wip: load other shared library formats (#1790) --- src/dusk/modding/mod_loader.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index b5ba972eaa..1f81978176 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -351,7 +351,7 @@ static std::string_view getFileNameWithoutExtension(const std::string_view fileN static DllLocateResult LocateDllInBundle(ModBundle& bundle) { std::string dllEntry, dllFallback; for (const auto name : bundle.getFileNames()) { - if (!name.ends_with(".dll"sv)) { + if (!name.ends_with(".dll"sv) && !name.ends_with(".dylib"sv) && !name.ends_with(".so"sv)) { continue; } From 358d218e8f8491a3f6b3a99c97bf1a50ea562449 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Thu, 28 May 2026 00:17:25 +0200 Subject: [PATCH 33/48] Fix Windows Unicode paths in disk mods oops --- src/dusk/modding/bundle_disk.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dusk/modding/bundle_disk.cpp b/src/dusk/modding/bundle_disk.cpp index c6260a771d..60b8a19369 100644 --- a/src/dusk/modding/bundle_disk.cpp +++ b/src/dusk/modding/bundle_disk.cpp @@ -55,7 +55,7 @@ size_t ModBundleDisk::getFileSize(const std::string& fileName) { std::filesystem::path ModBundleDisk::toRealPath(const std::string& fileName) const { const fs::path filePath = reinterpret_cast(fileName.c_str()); - return root_path / fileName; + return root_path / filePath; } } // namespace dusk::modding \ No newline at end of file From 9973a2815458560521b56230a18fa5db381730e7 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Thu, 28 May 2026 00:40:05 +0200 Subject: [PATCH 34/48] EntryNum assignment for new aurora API changes --- src/dusk/modding/mod_loader_overlay.cpp | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/dusk/modding/mod_loader_overlay.cpp b/src/dusk/modding/mod_loader_overlay.cpp index 0607473f83..8f6953650e 100644 --- a/src/dusk/modding/mod_loader_overlay.cpp +++ b/src/dusk/modding/mod_loader_overlay.cpp @@ -3,6 +3,8 @@ #include "dusk/mod_loader.hpp" #include "mod_loader.hpp" +#include "absl/container/flat_hash_map.h" + #include using namespace std::string_literals; @@ -18,6 +20,19 @@ struct OverlayFileData { std::vector s_overlayFiles; +s32 s_nextEntryNum; +absl::flat_hash_map s_entryNumIndex; + +s32 assignEntryNum(std::string_view const fileName) { + if (s_entryNumIndex.contains(fileName)) { + return s_entryNumIndex[fileName]; + } + + auto const newEntryNum = s_nextEntryNum++; + s_entryNumIndex[fileName] = newEntryNum; + return newEntryNum; +} + void findOverlayFiles(std::vector& files, dusk::LoadedMod& mod) { for (const auto& file : mod.bundle->getFileNames()) { if (!file.starts_with("overlay/")) { @@ -32,7 +47,11 @@ void findOverlayFiles(std::vector& files, dusk::LoadedMod& mo const auto index = s_overlayFiles.size(); s_overlayFiles.emplace_back(file, &mod); - files.emplace_back(strdup(overlayPath.c_str()), reinterpret_cast(index), size); + files.emplace_back( + strdup(overlayPath.c_str()), + reinterpret_cast(index), + size, + assignEntryNum(overlayPath)); } } @@ -89,6 +108,8 @@ namespace dusk { void ModLoader::initOverlayFiles() { Log.debug("Initializing overlay files..."); + s_nextEntryNum = aurora_dvd_base_entry_count(); + aurora_dvd_overlay_callbacks(&s_overlayCallbacks); std::vector files; From b88a5e4ac3305ef7edacde48c106cb7fcdb48236 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 29 May 2026 00:37:13 +0200 Subject: [PATCH 35/48] Allow code mods to be disabled on build, disable them by default for now. --- CMakeLists.txt | 10 ++++++++++ src/dusk/modding/mod_loader.cpp | 5 +++++ src/dusk/modding/mod_loader.hpp | 10 ++++++++++ 3 files changed, 25 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5cb2e5a600..4c6e29be9d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -461,6 +461,16 @@ if(ANDROID) list(APPEND GAME_COMPILE_DEFS TARGET_ANDROID=1) endif () +set(DUSK_ENABLE_CODE_MODS_DEFAULT OFF) +option(DUSK_ENABLE_CODE_MODS "Enable code mods" ${DUSK_ENABLE_CODE_MODS_DEFAULT}) +if (DUSK_ENABLE_CODE_MODS) + if (NOT ANDROID AND NOT IOS AND NOT TVOS) + list(APPEND GAME_COMPILE_DEFS DUSK_CODE_MODS=1) + else () + message(FATAL_ERROR "Code mods not supported on the target platform!") + endif () +endif () + # game_debug is for game code files that we know work when compiled with DEBUG=1 # Of course, if building a release build, this distinction is irrelevant set(GAME_DEBUG_FILES diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index 1f81978176..91afbcc7f0 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -412,6 +412,11 @@ static bool checkDuplicateMod(const ModMetadata& metadata, const std::vector #include "miniz.h" +#if __APPLE__ +#include +#endif + namespace dusk::modding { +#if DUSK_CODE_MODS +constexpr bool EnableCodeMods = true; +#else +constexpr bool EnableCodeMods = false; +#endif + class ModBundle { public: virtual ~ModBundle() = default; From 5f0c44eb843320887e167c55a2d90bdb33f5cc2d Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 29 May 2026 01:25:03 +0200 Subject: [PATCH 36/48] Don't force symbol export if code mods disabled Allows us to re-enable PCH on RmlUI. We'll likely need to rethink how this works anyways IMO. --- CMakeLists.txt | 19 ++++++++++--------- src/dusk/launcher_win32.cpp | 2 +- src/dusk/main.cpp | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c6e29be9d..0a200cbfa3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -135,13 +135,6 @@ set(AURORA_ENABLE_RMLUI ON CACHE BOOL "Enable RmlUi UI support" FORCE) add_subdirectory(extern/aurora EXCLUDE_FROM_ALL) target_compile_definitions(aurora_mtx PRIVATE MTX_USE_PS=1) -# rmlui_core uses its own PCH which creates a duplicate PCH marker symbol when linked -# Disabling rmlui's PCH removes the conflicting marker and lets the link succeed -if (MSVC AND TARGET rmlui_core) - set_target_properties(rmlui_core PROPERTIES DISABLE_PRECOMPILE_HEADERS ON) -endif () - - add_subdirectory(libs/freeverb) option(DUSK_BUILD_WARNINGS "Enable compiler warnings (off by default)") @@ -528,10 +521,16 @@ if(ANDROID) elseif(WIN32) add_library(dusklight_game SHARED ${DUSK_FILES}) set_target_properties(dusklight_game PROPERTIES - WINDOWS_EXPORT_ALL_SYMBOLS ON + WINDOWS_EXPORT_ALL_SYMBOLS ${DUSK_ENABLE_CODE_MODS} OUTPUT_NAME dusklight PDB_NAME dusklight_game) + # rmlui_core uses its own PCH which creates a duplicate PCH marker symbol when linked + # Disabling rmlui's PCH removes the conflicting marker and lets the link succeed + if (MSVC AND TARGET rmlui_core AND DUSK_ENABLE_CODE_MODS) + set_target_properties(rmlui_core PROPERTIES DISABLE_PRECOMPILE_HEADERS ON) + endif () + add_executable(dusklight WIN32 src/dusk/launcher_win32.cpp) target_link_libraries(dusklight PRIVATE dusklight_game) target_include_directories(dusklight PRIVATE include) @@ -794,4 +793,6 @@ foreach (target IN LISTS BINARY_TARGETS) endforeach () endforeach () -add_subdirectory(tools/mod_test) \ No newline at end of file +if (DUSK_ENABLE_CODE_MODS) + add_subdirectory(tools/mod_test) +endif () diff --git a/src/dusk/launcher_win32.cpp b/src/dusk/launcher_win32.cpp index c434febd78..8f060634e4 100644 --- a/src/dusk/launcher_win32.cpp +++ b/src/dusk/launcher_win32.cpp @@ -8,7 +8,7 @@ #include // see src/dusk/main.cpp -extern "C" int WINAPI dusk_WinMain(HINSTANCE hInst, HINSTANCE hPrev, PWSTR cmd, int show); +int dusk_WinMain(HINSTANCE hInst, HINSTANCE hPrev, PWSTR cmd, int show); int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE hPrev, PWSTR cmd, int show) { return dusk_WinMain(hInst, hPrev, cmd, show); diff --git a/src/dusk/main.cpp b/src/dusk/main.cpp index 993196b34d..9d1bbc9319 100644 --- a/src/dusk/main.cpp +++ b/src/dusk/main.cpp @@ -225,7 +225,7 @@ int main(int argc, char* argv[]) { #if _WIN32 // Entry point called by the launcher executable. -extern "C" int WINAPI dusk_WinMain(HINSTANCE, HINSTANCE, PWSTR, int) { +int __declspec(dllexport) dusk_WinMain(HINSTANCE, HINSTANCE, PWSTR, int) { return RunWindowsGuiEntryPoint(); } #endif From be3e6b80ebd34e85186f4a9459e75cbf227e55b0 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 29 May 2026 01:55:26 +0200 Subject: [PATCH 37/48] Move code mod API to separate C++ file --- files.cmake | 1 + src/dusk/hook_system.cpp | 8 +- src/dusk/modding/mod_loader.cpp | 292 ---------------------------- src/dusk/modding/mod_loader.hpp | 23 +++ src/dusk/modding/mod_loader_api.cpp | 285 +++++++++++++++++++++++++++ 5 files changed, 314 insertions(+), 295 deletions(-) create mode 100644 src/dusk/modding/mod_loader_api.cpp diff --git a/files.cmake b/files.cmake index bdc214597b..9b51ddb1e9 100644 --- a/files.cmake +++ b/files.cmake @@ -1526,6 +1526,7 @@ set(DUSK_FILES src/dusk/OSMutex.cpp src/dusk/hook_system.cpp src/dusk/modding/mod_loader.cpp + src/dusk/modding/mod_loader_api.cpp src/dusk/modding/mod_loader_overlay.cpp src/dusk/modding/native_module.cpp src/dusk/modding/native_module.hpp diff --git a/src/dusk/hook_system.cpp b/src/dusk/hook_system.cpp index 10c2ddf17d..620505822b 100644 --- a/src/dusk/hook_system.cpp +++ b/src/dusk/hook_system.cpp @@ -10,7 +10,9 @@ namespace dusk { -extern thread_local void* g_dusk_hook_current_mod; +namespace modding { + extern thread_local void* g_dusk_hook_current_mod; +} struct PreHookFn { void* mod; @@ -55,8 +57,8 @@ static void* resolveImportThunk(void* addr) { struct ModGuard { void* prev; - explicit ModGuard(void* mod) : prev(g_dusk_hook_current_mod) { g_dusk_hook_current_mod = mod; } - ~ModGuard() { g_dusk_hook_current_mod = prev; } + explicit ModGuard(void* mod) : prev(modding::g_dusk_hook_current_mod) { modding::g_dusk_hook_current_mod = mod; } + ~ModGuard() { modding::g_dusk_hook_current_mod = prev; } }; void hookInstallByAddr(void* fn_addr, void* tramp_fn, void** orig_store) { diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index 91afbcc7f0..6a19a01a00 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -3,15 +3,10 @@ #include "dusk/logging.h" #include "mod_loader.hpp" -#include - - #include -#include #include #include #include -#include #include "aurora/dvd.h" #include "dusk/io.hpp" @@ -35,264 +30,6 @@ static constexpr std::string_view k_archSuffix = "_x86"sv; static constexpr std::string_view k_archSuffix = ""sv; #endif -static thread_local dusk::LoadedMod* g_currentMod = nullptr; -static std::unordered_map g_services; - -namespace dusk { -thread_local void* g_dusk_hook_current_mod = nullptr; - -} // namespace dusk - -struct ModGuard { - explicit ModGuard(dusk::LoadedMod* m) { - g_currentMod = m; - dusk::g_dusk_hook_current_mod = m; - } - ~ModGuard() { - g_currentMod = nullptr; - dusk::g_dusk_hook_current_mod = nullptr; - } -}; - -static const char* modName() { - return g_currentMod ? g_currentMod->metadata.id.c_str() : "mod"; -} - -static void cb_log_info(const char* fmt, ...) { - va_list ap, ap2; - va_start(ap, fmt); - va_copy(ap2, ap); - std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); - va_end(ap2); - vsnprintf(s.data(), s.size() + 1, fmt, ap); - va_end(ap); - DuskLog.info("[{}] {}", modName(), s); -} - -static void cb_log_warn(const char* fmt, ...) { - va_list ap, ap2; - va_start(ap, fmt); - va_copy(ap2, ap); - std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); - va_end(ap2); - vsnprintf(s.data(), s.size() + 1, fmt, ap); - va_end(ap); - DuskLog.warn("[{}] {}", modName(), s); -} - -static void cb_log_error(const char* fmt, ...) { - va_list ap, ap2; - va_start(ap, fmt); - va_copy(ap2, ap); - std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); - va_end(ap2); - vsnprintf(s.data(), s.size() + 1, fmt, ap); - va_end(ap); - DuskLog.error("[{}] {}", modName(), s); -} - -static void* cb_load_resource(const char* relative_path, size_t* out_size) { - if (out_size) { - *out_size = 0; - } - if (!g_currentMod || !relative_path) { - DuskLog.error("load_resource: called outside mod context or with null path"); - return nullptr; - } - - std::string entry = std::string("res/") + relative_path; - std::vector data; - try { - data = g_currentMod->bundle->readFile(entry); - } catch (const std::runtime_error& e) { - DuskLog.error("[{}] load_resource: '{}' failed: {}", g_currentMod->metadata.id, entry, e.what()); - return nullptr; - } - - const auto retPtr = std::malloc(data.size()); - std::memcpy(retPtr, data.data(), data.size()); - - if (out_size) { - *out_size = data.size(); - } - return retPtr; -} - -static void cb_free_resource(void* data) { - std::free(data); -} - -namespace { - -class ModClickListener : public Rml::EventListener { -public: - ModClickListener(void (*cb)(void*), void* ud) : m_cb(cb), m_ud(ud) {} - void ProcessEvent(Rml::Event&) override { m_cb(m_ud); } - void OnDetach(Rml::Element*) override { delete this; } -private: - void (*m_cb)(void*); - void* m_ud; -}; - -static std::string escape_rml(const char* text) { - std::string out; - for (const char* p = text; *p; ++p) { - switch (*p) { - case '&': out += "&"; break; - case '<': out += "<"; break; - case '>': out += ">"; break; - default: out += *p; break; - } - } - return out; -} - -} - -static void cb_panel_add_section(DuskPanelHandle panel, const char* text) { - auto* pane = static_cast(panel); - if (!pane || !text) { - return; - } - auto el = pane->GetOwnerDocument()->CreateElement("div"); - el->SetClass("section-heading", true); - el->SetInnerRML(escape_rml(text)); - pane->AppendChild(std::move(el)); -} - -static void cb_panel_add_button(DuskPanelHandle panel, const char* label, - void (*cb)(void*), void* userdata) { - auto* pane = static_cast(panel); - if (!pane || !label || !cb) { - return; - } - auto btn = pane->GetOwnerDocument()->CreateElement("button"); - btn->SetInnerRML(escape_rml(label)); - btn->AddEventListener(Rml::EventId::Click, new ModClickListener(cb, userdata)); - pane->AppendChild(std::move(btn)); -} - -static DuskElemHandle cb_panel_add_badge_row(DuskPanelHandle panel, const char* label, int ok) { - auto* pane = static_cast(panel); - if (!pane || !label) { - return nullptr; - } - auto* doc = pane->GetOwnerDocument(); - - auto row = doc->CreateElement("div"); - row->SetClass("mod-info-row", true); - - auto badge = doc->CreateElement("span"); - badge->SetClass("achievement-badge", true); - badge->SetClass(ok ? "unlocked" : "locked", true); - badge->SetInnerRML(ok ? "PASS" : "WAIT"); - Rml::Element* badgePtr = row->AppendChild(std::move(badge)); - - auto lbl = doc->CreateElement("span"); - lbl->SetClass("mod-info-value", true); - lbl->SetInnerRML(escape_rml(label)); - row->AppendChild(std::move(lbl)); - - pane->AppendChild(std::move(row)); - return static_cast(badgePtr); -} - -static DuskElemHandle cb_panel_add_dyn_text(DuskPanelHandle panel, const char* text) { - auto* pane = static_cast(panel); - if (!pane) { - return nullptr; - } - auto el = pane->GetOwnerDocument()->CreateElement("div"); - el->SetInnerRML(text ? escape_rml(text) : std::string{}); - Rml::Element* ptr = pane->AppendChild(std::move(el)); - return static_cast(ptr); -} - -static void cb_elem_set_badge(DuskElemHandle elem, int ok) { - auto* el = static_cast(elem); - if (!el) { - return; - } - el->SetClass("unlocked", ok != 0); - el->SetClass("locked", ok == 0); - el->SetInnerRML(ok ? "PASS" : "WAIT"); -} - -static void cb_elem_set_text(DuskElemHandle elem, const char* text) { - auto* el = static_cast(elem); - if (!el || !text) { - return; - } - el->SetInnerRML(escape_rml(text)); -} - -static DuskElemHandle cb_panel_add_progress(DuskPanelHandle panel, float value) { - auto* pane = static_cast(panel); - if (!pane) { - return nullptr; - } - auto el = pane->GetOwnerDocument()->CreateElement("progress"); - el->SetClass("progress-health", true); - el->SetAttribute("value", value); - Rml::Element* ptr = pane->AppendChild(std::move(el)); - return static_cast(ptr); -} - -static void cb_elem_set_progress(DuskElemHandle elem, float value) { - auto* el = static_cast(elem); - if (!el) { - return; - } - el->SetAttribute("value", value); -} - -static void cb_register_tab_content(void (*build_fn)(void*, void*), void* userdata) { - if (g_currentMod && build_fn) { - g_currentMod->tab_content.push_back({build_fn, userdata}); - } -} - -static void cb_register_tab_update(void (*update_fn)(void*), void* userdata) { - if (g_currentMod && update_fn) { - g_currentMod->tab_updates.push_back({update_fn, userdata}); - } -} - -static void cb_service_publish(const char* name, void* ptr) { - if (!name) { - return; - } - if (g_services.count(name)) { - DuskLog.error( - "[{}] service_publish: '{}' already published by another mod", modName(), name); - } - g_services[name] = ptr; -} - -static void* cb_service_get(const char* name) { - if (!name) { - return nullptr; - } - auto it = g_services.find(name); - return it != g_services.end() ? it->second : nullptr; -} - -static void api_hook_pre(void* addr, int32_t (*fn)(void* args)) { - dusk::hookRegisterPre(addr, g_currentMod, fn); -} - -static void api_hook_post(void* addr, void (*fn)(void* args, void* retval)) { - dusk::hookRegisterPost(addr, g_currentMod, modName(), fn); -} - -static void api_hook_replace(void* addr, void (*fn)(void* args, void* retval)) { - if (!dusk::hookSetReplace(addr, g_currentMod, modName(), fn)) { - if (g_currentMod) { - g_currentMod->load_failed = true; - } - } -} - static dusk::ModLoader g_modLoader; namespace dusk { @@ -301,35 +38,6 @@ ModLoader& ModLoader::instance() { return g_modLoader; } -void ModLoader::buildAPI(LoadedMod& mod) { - auto& native = *mod.native; - native.api.api_version = DUSK_MOD_API_VERSION; - native.api.mod_dir = mod.dir.c_str(); - native.api.log_info = cb_log_info; - native.api.log_warn = cb_log_warn; - native.api.log_error = cb_log_error; - native.api.load_resource = cb_load_resource; - native.api.free_resource = cb_free_resource; - native.api.register_tab_content = cb_register_tab_content; - native.api.register_tab_update = cb_register_tab_update; - native.api.panel_add_section = cb_panel_add_section; - native.api.panel_add_button = cb_panel_add_button; - native.api.panel_add_badge_row = cb_panel_add_badge_row; - native.api.panel_add_dyn_text = cb_panel_add_dyn_text; - native.api.elem_set_badge = cb_elem_set_badge; - native.api.elem_set_text = cb_elem_set_text; - native.api.panel_add_progress = cb_panel_add_progress; - native.api.elem_set_progress = cb_elem_set_progress; - native.api.hook_install = hookInstallByAddr; - native.api.hook_pre = api_hook_pre; - native.api.hook_post = api_hook_post; - native.api.hook_replace = api_hook_replace; - native.api.hook_dispatch_pre = hookDispatchPre; - native.api.hook_dispatch_post = hookDispatchPost; - native.api.service_publish = cb_service_publish; - native.api.service_get = cb_service_get; -} - static std::unique_ptr loadBundle(const std::filesystem::path& modPath, bool fromDir) { if (fromDir) { return std::make_unique(modPath); diff --git a/src/dusk/modding/mod_loader.hpp b/src/dusk/modding/mod_loader.hpp index 47731a6b98..dca6aac6a3 100644 --- a/src/dusk/modding/mod_loader.hpp +++ b/src/dusk/modding/mod_loader.hpp @@ -3,6 +3,8 @@ #include #include "miniz.h" +#include "dusk/mod_loader.hpp" + #if __APPLE__ #include #endif @@ -51,4 +53,25 @@ class ModBundleDisk final : public ModBundle { std::filesystem::path root_path; }; + +extern thread_local LoadedMod* g_currentMod; +extern std::unordered_map g_services; + +extern thread_local void* g_dusk_hook_current_mod; + +struct ModGuard { + explicit ModGuard(dusk::LoadedMod* m) { + g_currentMod = m; + g_dusk_hook_current_mod = m; + } + ~ModGuard() { + g_currentMod = nullptr; + g_dusk_hook_current_mod = nullptr; + } +}; + +inline const char* modName() { + return g_currentMod ? g_currentMod->metadata.id.c_str() : "mod"; +} + } // namespace dusk::modding diff --git a/src/dusk/modding/mod_loader_api.cpp b/src/dusk/modding/mod_loader_api.cpp new file mode 100644 index 0000000000..c5a2b8f496 --- /dev/null +++ b/src/dusk/modding/mod_loader_api.cpp @@ -0,0 +1,285 @@ +#include + +#include "dusk/hook_system.hpp" +#include "dusk/logging.h" +#include "dusk/mod_api.h" +#include "dusk/mod_loader.hpp" +#include "mod_loader.hpp" + +using namespace dusk::modding; + +namespace dusk::modding { + +thread_local LoadedMod* g_currentMod = nullptr; +std::unordered_map g_services; + +thread_local void* g_dusk_hook_current_mod = nullptr; + +} + +namespace { + +void cb_log_info(const char* fmt, ...) { + va_list ap, ap2; + va_start(ap, fmt); + va_copy(ap2, ap); + std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); + va_end(ap2); + vsnprintf(s.data(), s.size() + 1, fmt, ap); + va_end(ap); + DuskLog.info("[{}] {}", modName(), s); +} + +void cb_log_warn(const char* fmt, ...) { + va_list ap, ap2; + va_start(ap, fmt); + va_copy(ap2, ap); + std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); + va_end(ap2); + vsnprintf(s.data(), s.size() + 1, fmt, ap); + va_end(ap); + DuskLog.warn("[{}] {}", modName(), s); +} + +void cb_log_error(const char* fmt, ...) { + va_list ap, ap2; + va_start(ap, fmt); + va_copy(ap2, ap); + std::string s(vsnprintf(nullptr, 0, fmt, ap2), '\0'); + va_end(ap2); + vsnprintf(s.data(), s.size() + 1, fmt, ap); + va_end(ap); + DuskLog.error("[{}] {}", modName(), s); +} + +void* cb_load_resource(const char* relative_path, size_t* out_size) { + if (out_size) { + *out_size = 0; + } + if (!g_currentMod || !relative_path) { + DuskLog.error("load_resource: called outside mod context or with null path"); + return nullptr; + } + + std::string entry = std::string("res/") + relative_path; + std::vector data; + try { + data = g_currentMod->bundle->readFile(entry); + } catch (const std::runtime_error& e) { + DuskLog.error("[{}] load_resource: '{}' failed: {}", g_currentMod->metadata.id, entry, e.what()); + return nullptr; + } + + const auto retPtr = std::malloc(data.size()); + std::memcpy(retPtr, data.data(), data.size()); + + if (out_size) { + *out_size = data.size(); + } + return retPtr; +} + +void cb_free_resource(void* data) { + std::free(data); +} + +class ModClickListener : public Rml::EventListener { +public: + ModClickListener(void (*cb)(void*), void* ud) : m_cb(cb), m_ud(ud) {} + void ProcessEvent(Rml::Event&) override { m_cb(m_ud); } + void OnDetach(Rml::Element*) override { delete this; } +private: + void (*m_cb)(void*); + void* m_ud; +}; + +std::string escape_rml(const char* text) { + std::string out; + for (const char* p = text; *p; ++p) { + switch (*p) { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + default: out += *p; break; + } + } + return out; +} + +void cb_panel_add_section(DuskPanelHandle panel, const char* text) { + auto* pane = static_cast(panel); + if (!pane || !text) { + return; + } + auto el = pane->GetOwnerDocument()->CreateElement("div"); + el->SetClass("section-heading", true); + el->SetInnerRML(escape_rml(text)); + pane->AppendChild(std::move(el)); +} + +void cb_panel_add_button(DuskPanelHandle panel, const char* label, + void (*cb)(void*), void* userdata) { + auto* pane = static_cast(panel); + if (!pane || !label || !cb) { + return; + } + auto btn = pane->GetOwnerDocument()->CreateElement("button"); + btn->SetInnerRML(escape_rml(label)); + btn->AddEventListener(Rml::EventId::Click, new ModClickListener(cb, userdata)); + pane->AppendChild(std::move(btn)); +} + +DuskElemHandle cb_panel_add_badge_row(DuskPanelHandle panel, const char* label, int ok) { + auto* pane = static_cast(panel); + if (!pane || !label) { + return nullptr; + } + auto* doc = pane->GetOwnerDocument(); + + auto row = doc->CreateElement("div"); + row->SetClass("mod-info-row", true); + + auto badge = doc->CreateElement("span"); + badge->SetClass("achievement-badge", true); + badge->SetClass(ok ? "unlocked" : "locked", true); + badge->SetInnerRML(ok ? "PASS" : "WAIT"); + Rml::Element* badgePtr = row->AppendChild(std::move(badge)); + + auto lbl = doc->CreateElement("span"); + lbl->SetClass("mod-info-value", true); + lbl->SetInnerRML(escape_rml(label)); + row->AppendChild(std::move(lbl)); + + pane->AppendChild(std::move(row)); + return static_cast(badgePtr); +} + +DuskElemHandle cb_panel_add_dyn_text(DuskPanelHandle panel, const char* text) { + auto* pane = static_cast(panel); + if (!pane) { + return nullptr; + } + auto el = pane->GetOwnerDocument()->CreateElement("div"); + el->SetInnerRML(text ? escape_rml(text) : std::string{}); + Rml::Element* ptr = pane->AppendChild(std::move(el)); + return static_cast(ptr); +} + +void cb_elem_set_badge(DuskElemHandle elem, int ok) { + auto* el = static_cast(elem); + if (!el) { + return; + } + el->SetClass("unlocked", ok != 0); + el->SetClass("locked", ok == 0); + el->SetInnerRML(ok ? "PASS" : "WAIT"); +} + +void cb_elem_set_text(DuskElemHandle elem, const char* text) { + auto* el = static_cast(elem); + if (!el || !text) { + return; + } + el->SetInnerRML(escape_rml(text)); +} + +DuskElemHandle cb_panel_add_progress(DuskPanelHandle panel, float value) { + auto* pane = static_cast(panel); + if (!pane) { + return nullptr; + } + auto el = pane->GetOwnerDocument()->CreateElement("progress"); + el->SetClass("progress-health", true); + el->SetAttribute("value", value); + Rml::Element* ptr = pane->AppendChild(std::move(el)); + return static_cast(ptr); +} + +void cb_elem_set_progress(DuskElemHandle elem, float value) { + auto* el = static_cast(elem); + if (!el) { + return; + } + el->SetAttribute("value", value); +} + +void cb_register_tab_content(void (*build_fn)(void*, void*), void* userdata) { + if (g_currentMod && build_fn) { + g_currentMod->tab_content.push_back({build_fn, userdata}); + } +} + +void cb_register_tab_update(void (*update_fn)(void*), void* userdata) { + if (g_currentMod && update_fn) { + g_currentMod->tab_updates.push_back({update_fn, userdata}); + } +} + +void cb_service_publish(const char* name, void* ptr) { + if (!name) { + return; + } + if (g_services.count(name)) { + DuskLog.error( + "[{}] service_publish: '{}' already published by another mod", modName(), name); + } + g_services[name] = ptr; +} + +void* cb_service_get(const char* name) { + if (!name) { + return nullptr; + } + auto it = g_services.find(name); + return it != g_services.end() ? it->second : nullptr; +} + +void api_hook_pre(void* addr, int32_t (*fn)(void* args)) { + dusk::hookRegisterPre(addr, g_currentMod, fn); +} + +void api_hook_post(void* addr, void (*fn)(void* args, void* retval)) { + dusk::hookRegisterPost(addr, g_currentMod, modName(), fn); +} + +void api_hook_replace(void* addr, void (*fn)(void* args, void* retval)) { + if (!dusk::hookSetReplace(addr, g_currentMod, modName(), fn)) { + if (g_currentMod) { + g_currentMod->load_failed = true; + } + } +} + +} + +namespace dusk { +void ModLoader::buildAPI(LoadedMod& mod) { + auto& native = *mod.native; + native.api.api_version = DUSK_MOD_API_VERSION; + native.api.mod_dir = mod.dir.c_str(); + native.api.log_info = cb_log_info; + native.api.log_warn = cb_log_warn; + native.api.log_error = cb_log_error; + native.api.load_resource = cb_load_resource; + native.api.free_resource = cb_free_resource; + native.api.register_tab_content = cb_register_tab_content; + native.api.register_tab_update = cb_register_tab_update; + native.api.panel_add_section = cb_panel_add_section; + native.api.panel_add_button = cb_panel_add_button; + native.api.panel_add_badge_row = cb_panel_add_badge_row; + native.api.panel_add_dyn_text = cb_panel_add_dyn_text; + native.api.elem_set_badge = cb_elem_set_badge; + native.api.elem_set_text = cb_elem_set_text; + native.api.panel_add_progress = cb_panel_add_progress; + native.api.elem_set_progress = cb_elem_set_progress; + native.api.hook_install = hookInstallByAddr; + native.api.hook_pre = api_hook_pre; + native.api.hook_post = api_hook_post; + native.api.hook_replace = api_hook_replace; + native.api.hook_dispatch_pre = hookDispatchPre; + native.api.hook_dispatch_post = hookDispatchPost; + native.api.service_publish = cb_service_publish; + native.api.service_get = cb_service_get; +} + +} \ No newline at end of file From be2924b509012150b720a4d9eaa4c67f624ddd8a Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 29 May 2026 01:59:12 +0200 Subject: [PATCH 38/48] Replace DuskLog uses with local log for modloader --- src/dusk/modding/mod_loader.cpp | 52 ++++++++++++++++----------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index 6a19a01a00..25d6fe3ffc 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -121,7 +121,7 @@ static bool checkDuplicateMod(const ModMetadata& metadata, const std::vectorreadFile(dllEntry); } catch (const std::runtime_error& e) { - DuskLog.error( - "ModLoader: failed to extract {} from {}", dllEntry, mod.metadata.id); + Log.error( + "failed to extract {} from {}", dllEntry, mod.metadata.id); return false; } { std::ofstream out(dllCachePath, std::ios::binary | std::ios::out); if (!out) { - DuskLog.error("ModLoader: failed to write {}", io::fs_path_to_string(dllCachePath)); + Log.error("failed to write {}", io::fs_path_to_string(dllCachePath)); return false; } @@ -169,13 +169,13 @@ bool ModLoader::tryLoadNativeMod(LoadedMod& mod) { try { nativeMod->handle = std::make_unique(dllCachePath); } catch (const std::runtime_error& e) { - DuskLog.error("ModLoader: failed to open {}: {}", io::fs_path_to_string(dllCachePath), e.what()); + Log.error("failed to open {}: {}", io::fs_path_to_string(dllCachePath), e.what()); return false; } const auto mod_api_ver = nativeMod->handle->LookupSymbol("mod_api_version"); if (mod_api_ver && *mod_api_ver != DUSK_MOD_API_VERSION) { - DuskLog.error("ModLoader: {} expects API v{} but engine is v{}, skipping", + Log.error("{} expects API v{} but engine is v{}, skipping", io::fs_path_to_string(fs::path(dllEntry).filename()), *mod_api_ver, DUSK_MOD_API_VERSION); return false; } @@ -185,7 +185,7 @@ bool ModLoader::tryLoadNativeMod(LoadedMod& mod) { nativeMod->fn_cleanup = nativeMod->handle->LookupSymbol("mod_cleanup"); if (!nativeMod->fn_init || !nativeMod->fn_tick) { - DuskLog.error("ModLoader: {} missing mod_init or mod_tick — skipping", + Log.error("{} missing mod_init or mod_tick — skipping", io::fs_path_to_string(fs::path(dllEntry).filename())); return false; } @@ -213,13 +213,13 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) } catch (const std::runtime_error& e) { Log.error( - "ModLoader: bad mod.json in {}: {}", io::fs_path_to_string(modPath.filename()), e.what()); + "bad mod.json in {}: {}", io::fs_path_to_string(modPath.filename()), e.what()); return; } if (checkDuplicateMod(metadata, m_mods)) { Log.error( - "ModLoader: mod with id '{}' already exists, not loading {}", + "mod with id '{}' already exists, not loading {}", metadata.id, io::fs_path_to_string(modPath.filename())); return; @@ -236,8 +236,8 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) const auto& inserted = m_mods.emplace_back(std::move(mod)); - DuskLog.info( - "ModLoader: found '{}' ('{}') v{} by {} ({})", + Log.info( + "found '{}' ('{}') v{} by {} ({})", inserted.metadata.name, inserted.metadata.id, inserted.metadata.version, @@ -253,8 +253,8 @@ void ModLoader::init() { namespace fs = std::filesystem; if (!fs::is_directory(m_modsDir)) { - DuskLog.info( - "ModLoader: 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; } @@ -278,13 +278,13 @@ void ModLoader::init() { } if (m_mods.empty()) { - DuskLog.info("ModLoader: no mods found"); + Log.info("no mods found"); return; } initOverlayFiles(); - DuskLog.info("ModLoader: initializing {} mod(s)...", m_mods.size()); + Log.info("initializing {} mod(s)...", m_mods.size()); for (auto& mod : m_mods) { if (mod.native) { buildAPI(mod); @@ -301,20 +301,20 @@ void ModLoader::init() { mod.native->fn_init(&mod.native->api); if (!mod.load_failed) { mod.active = true; - DuskLog.info("ModLoader: '{}' initialized", mod.metadata.id); + Log.info("'{}' initialized", mod.metadata.id); } else { - DuskLog.error("ModLoader: '{}' failed to load due to hook conflicts", mod.metadata.id); + Log.error("'{}' failed to load due to hook conflicts", mod.metadata.id); } } catch (const std::exception& e) { - DuskLog.error("ModLoader: exception in {}.mod_init(): {}", mod.metadata.id, e.what()); + Log.error("exception in {}.mod_init(): {}", mod.metadata.id, e.what()); } catch (...) { - DuskLog.error("ModLoader: unknown exception in {}.mod_init()", mod.metadata.id); + Log.error("unknown exception in {}.mod_init()", mod.metadata.id); } } auto active = std::count_if(m_mods.begin(), m_mods.end(), [](const LoadedMod& m) { return m.active; }); - DuskLog.info("ModLoader: {}/{} mod(s) active", active, m_mods.size()); + Log.info("{}/{} mod(s) active", active, m_mods.size()); } void ModLoader::tick() { @@ -326,11 +326,11 @@ void ModLoader::tick() { try { mod.native->fn_tick(&mod.native->api); } catch (const std::exception& e) { - DuskLog.error( - "ModLoader: exception in {}.mod_tick(): {} — disabling", mod.metadata.id, e.what()); + Log.error( + "exception in {}.mod_tick(): {} — disabling", mod.metadata.id, e.what()); mod.active = false; } catch (...) { - DuskLog.error("ModLoader: unknown exception in {}.mod_tick() — disabling", mod.metadata.id); + Log.error("unknown exception in {}.mod_tick() — disabling", mod.metadata.id); mod.active = false; } } @@ -349,7 +349,7 @@ void ModLoader::shutdown() { } m_mods.clear(); g_services.clear(); - DuskLog.info("ModLoader: all mods unloaded"); + Log.info("all mods unloaded"); } } // namespace dusk From 87d56be232fe0d2f9f3f102b61df2fdacf8e3c05 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 29 May 2026 02:29:50 +0200 Subject: [PATCH 39/48] Improve native mod load failure diagnostics --- include/dusk/mod_loader.hpp | 39 +++++++++++++++++++- src/dusk/modding/mod_loader.cpp | 47 +++++++++++++++++-------- src/dusk/modding/mod_loader_overlay.cpp | 4 ++- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 3b467b6db0..04c4e4906e 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -45,6 +45,41 @@ struct NativeMod { FnCleanup fn_cleanup = nullptr; }; +enum class NativeModStatus : u8 { + /** + * Mod does not have native code included. + */ + None, + + /** + * Native code mod loaded successfully. + * + * Note that this only indicates load status of the native library. If the native lib throws in + * its init function, it will still be disabled! + */ + Loaded, + + /** + * This build was compiled without native mod support! + */ + BuildDisabled, + + /** + * Mod does not have a native library suitable for this build's platform. + */ + ModMissingPlatform, + + /** + * Mod is built for a different API version than this build of the game. + */ + ApiVersionMismatch, + + /** + * Unknown error loading the native mod. + */ + Unknown, +}; + struct LoadedMod { ModMetadata metadata; std::string mod_path; @@ -53,7 +88,9 @@ struct LoadedMod { bool active = false; bool load_failed = false; + NativeModStatus native_status = NativeModStatus::None; std::unique_ptr native; + std::unique_ptr bundle; std::vector tab_content; @@ -77,7 +114,7 @@ class ModLoader { bool m_initialized = false; void tryLoadDusk(const std::filesystem::path& modPath, bool fromDir); - bool tryLoadNativeMod(LoadedMod& mod); + void tryLoadNativeMod(LoadedMod& mod); void buildAPI(LoadedMod& mod); void initOverlayFiles(); }; diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index 25d6fe3ffc..f2499131d2 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -119,10 +119,11 @@ static bool checkDuplicateMod(const ModMetadata& metadata, const std::vectorhandle = std::make_unique(dllCachePath); } catch (const std::runtime_error& e) { Log.error("failed to open {}: {}", io::fs_path_to_string(dllCachePath), e.what()); - return false; + return; } const auto mod_api_ver = nativeMod->handle->LookupSymbol("mod_api_version"); if (mod_api_ver && *mod_api_ver != DUSK_MOD_API_VERSION) { Log.error("{} expects API v{} but engine is v{}, skipping", io::fs_path_to_string(fs::path(dllEntry).filename()), *mod_api_ver, DUSK_MOD_API_VERSION); - return false; + mod.native_status = NativeModStatus::ApiVersionMismatch; + return; } nativeMod->fn_init = nativeMod->handle->LookupSymbol("mod_init"); @@ -187,12 +190,12 @@ bool ModLoader::tryLoadNativeMod(LoadedMod& mod) { if (!nativeMod->fn_init || !nativeMod->fn_tick) { Log.error("{} missing mod_init or mod_tick — skipping", io::fs_path_to_string(fs::path(dllEntry).filename())); - return false; + return; } mod.dir = io::fs_path_to_string(fs::absolute(cacheDir)); mod.native = std::move(nativeMod); - return true; + mod.native_status = NativeModStatus::Loaded; } void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) { @@ -226,12 +229,21 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) } LoadedMod mod; + mod.active = true; mod.mod_path = io::fs_path_to_string(fs::absolute(modPath)); mod.metadata = std::move(metadata); mod.bundle = std::move(bundle); - if (mod.metadata.hasCode && !tryLoadNativeMod(mod)) { - return; + 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! + + if (mod.native_status != NativeModStatus::Loaded) { + Log.error("Native mod '{}' failed to load, disabling", metadata.id); + mod.active = false; + } } const auto& inserted = m_mods.emplace_back(std::move(mod)); @@ -282,36 +294,41 @@ void ModLoader::init() { return; } - initOverlayFiles(); Log.info("initializing {} mod(s)...", m_mods.size()); for (auto& mod : m_mods) { - if (mod.native) { + if (mod.native && mod.active) { buildAPI(mod); } } for (auto& mod : m_mods) { - if (!mod.native) { + if (!mod.native || !mod.active) { continue; } + Log.debug("Initializing '{}'", mod.metadata.id); + ModGuard guard(&mod); try { mod.native->fn_init(&mod.native->api); if (!mod.load_failed) { - mod.active = true; Log.info("'{}' initialized", mod.metadata.id); } else { + mod.active = false; Log.error("'{}' failed to load due to hook conflicts", mod.metadata.id); } } 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); } } + initOverlayFiles(); + auto active = std::count_if(m_mods.begin(), m_mods.end(), [](const LoadedMod& m) { return m.active; }); Log.info("{}/{} mod(s) active", active, m_mods.size()); diff --git a/src/dusk/modding/mod_loader_overlay.cpp b/src/dusk/modding/mod_loader_overlay.cpp index 8f6953650e..fef860c489 100644 --- a/src/dusk/modding/mod_loader_overlay.cpp +++ b/src/dusk/modding/mod_loader_overlay.cpp @@ -115,7 +115,9 @@ void ModLoader::initOverlayFiles() { std::vector files; for (auto& mod : m_mods) { - findOverlayFiles(files, mod); + if (mod.active) { + findOverlayFiles(files, mod); + } } Log.debug("Found {} overlay files.", files.size()); From d9795f4098725d9bf7f6de9f4615627ebffde1f4 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 29 May 2026 02:43:00 +0200 Subject: [PATCH 40/48] Validate that mod IDs have a restricted character set. Avoid funny business. --- src/dusk/modding/mod_loader.cpp | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index f2499131d2..e16d21f179 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -79,6 +79,28 @@ class InvalidModDataException : public std::runtime_error { explicit InvalidModDataException(const char* msg) : runtime_error(msg) {} }; +static void validateModId(std::string_view const str) { + if (str.empty()) { + throw InvalidModDataException("Missing ID value in mod metadata!"); + } + + for (auto const chr : str) { + if (chr == '.' || chr == '_') + continue; + + if (chr >= '0' && chr <= '9') + continue; + + if (chr >= 'a' && chr <= 'z') + continue; + + if (chr >= 'A' && chr <= 'Z') + continue; + + 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) { const auto metaJson = bundle.readFile("mod.json"); auto j = nlohmann::json::parse(metaJson); @@ -90,9 +112,8 @@ static ModMetadata loadMetadata(const std::filesystem::path& modPath, ModBundle& std::string metaDescription = j.value("description", ""); const bool hasCode = j.value("has_code", false); - if (metaId.empty()) { - throw InvalidModDataException("Missing ID value in mod metadata!"); - } + validateModId(metaId); + if (metaName.empty()) { metaName = io::fs_path_to_string(modPath.stem()); } From 3792912ad14f90993fb8885d8efe24dd80a4a09e Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 29 May 2026 04:38:24 +0200 Subject: [PATCH 41/48] Allow CVars to be registered late, improve usability for dynamically registered CVars. Now we store the raw JSON value in memory for unregistered CVars. Intended to be used for mod CVars, as we obviously can't statically define all of those. CVar names are now stored as an std::string, so the lifetime is easy to manage when dynamically registered. CVars cannot be moved/copied anymore. We had some code that was accidentally relying on this, and I fixed that. --- include/dusk/config.hpp | 9 ++-- include/dusk/config_var.hpp | 17 +++++-- src/dusk/config.cpp | 94 ++++++++++++++++++++++++++++++------- src/dusk/ui/settings.cpp | 10 ++-- src/m_Do/m_Do_main.cpp | 14 ++---- 5 files changed, 101 insertions(+), 43 deletions(-) diff --git a/include/dusk/config.hpp b/include/dusk/config.hpp index 382c4c24c6..5994ec6375 100644 --- a/include/dusk/config.hpp +++ b/include/dusk/config.hpp @@ -89,17 +89,16 @@ class InvalidConfigError : public std::runtime_error { */ void Register(ConfigVarBase& configVar); -/** - * \brief Indicate that all registrations have happened and everything should lock in. - */ -void FinishRegistration(); - /** * \brief Load config from the standard user preferences location. */ void LoadFromUserPreferences(); void LoadFromFileName(const char* path); +void LoadArgOverride(std::string_view name, std::string_view value); + +void Shutdown(); + /** * \brief Save the config to file. */ diff --git a/include/dusk/config_var.hpp b/include/dusk/config_var.hpp index 0bae27bfd3..86cb32a1cd 100644 --- a/include/dusk/config_var.hpp +++ b/include/dusk/config_var.hpp @@ -68,7 +68,7 @@ class ConfigVarBase { /** * The name of this CVar, used in the configuration file. */ - const char* name; + std::string name; /** * Whether this CVar has been registered with the global managing logic. @@ -86,8 +86,10 @@ class ConfigVarBase { */ const ConfigImplBase* impl; - ConfigVarBase(const char* name, const ConfigImplBase* impl); - virtual ~ConfigVarBase() = default; + // The configuration system stores a direct pointer to the ConfigVar instance. + // It is not legal to move or copy it. + ConfigVarBase(const ConfigVarBase&) = delete; + ConfigVarBase(std::string name, const ConfigImplBase* impl); /** * Check that the CVar is registered, aborting if this is not the case. @@ -98,6 +100,8 @@ class ConfigVarBase { } public: + virtual ~ConfigVarBase(); + /** * Get the name of this CVar, used in the configuration file. */ @@ -120,6 +124,7 @@ class ConfigVarBase { * This is necessary to make it legal to access. */ void markRegistered(); + void unmarkRegistered(); /** * Clear a speedrun-mode override if one is active on this CVar. @@ -185,10 +190,12 @@ class ConfigVar : public ConfigVarBase { * @param arg Arguments to forward to construct the default value. */ template - ConfigVar(const char* name, Args&&... arg) - : ConfigVarBase(name, GetConfigImpl()), defaultValue(std::forward(arg)...), + ConfigVar(std::string name, Args&&... arg) + : ConfigVarBase(std::move(name), GetConfigImpl()), defaultValue(std::forward(arg)...), value(), overrideValue() {} + ConfigVar(ConfigVar const&) = delete; + /** * \brief Get the current value of the CVar. * diff --git a/src/dusk/config.cpp b/src/dusk/config.cpp index aed43089c4..55a6527389 100644 --- a/src/dusk/config.cpp +++ b/src/dusk/config.cpp @@ -12,8 +12,9 @@ #include #include -#include "dusk/main.h" #include "dusk/action_bindings.h" +#include "dusk/logging.h" +#include "dusk/main.h" using namespace dusk::config; @@ -23,8 +24,9 @@ using json = nlohmann::json; aurora::Module DuskConfigLog("dusk::config"); -static absl::flat_hash_map RegisteredConfigVars; -static bool RegistrationDone = false; +static absl::flat_hash_map RegisteredConfigVars; +static absl::flat_hash_map UnregisteredConfigVars; +static absl::flat_hash_map UnregisteredConfigVarOverrides; static std::filesystem::path GetConfigJsonPath() { return dusk::ConfigPath / ConfigFileName; @@ -46,17 +48,23 @@ static void ReplaceFile(const std::filesystem::path& source, const std::filesyst } } -ConfigVarBase::ConfigVarBase(const char* name, const ConfigImplBase* impl) : name(name), registered(false), layer(ConfigVarLayer::Default), impl(impl) { +ConfigVarBase::ConfigVarBase(std::string name, const ConfigImplBase* impl) : name(std::move(name)), registered(false), layer(ConfigVarLayer::Default), impl(impl) { } const char* ConfigVarBase::getName() const noexcept { - return name; + return name.c_str(); } const ConfigImplBase* ConfigVarBase::getImpl() const noexcept { return impl; } +ConfigVarBase::~ConfigVarBase() { + if (registered) { + DuskLog.fatal("CVar '{}' was destroyed while still registered!", name); + } +} + template static T sanitizeEnumValue(const ConfigVar& cVar, T value) { if constexpr (std::is_enum_v) { @@ -200,17 +208,37 @@ namespace dusk::config { } void dusk::config::Register(ConfigVarBase& configVar) { - const auto& name = configVar.getName(); - if (RegistrationDone) { - DuskConfigLog.fatal("Tried to register CVar {} after registrations closed!", name); - } - + const std::string_view name = configVar.getName(); if (RegisteredConfigVars.contains(name)) { DuskConfigLog.fatal("Tried to register CVar {} twice!", name); } RegisteredConfigVars[name] = &configVar; configVar.markRegistered(); + + const auto unregPair = UnregisteredConfigVars.find(name); + if (unregPair != UnregisteredConfigVars.end()) { + const auto value = std::move(unregPair->second); + UnregisteredConfigVars.erase(name); + + try { + configVar.getImpl()->loadFromJson(configVar, value); + } catch (std::exception& e) { + DuskConfigLog.error("Failed to load key '{}' from config value: {}", name, e.what()); + } + } + + const auto overridePair = UnregisteredConfigVarOverrides.find(name); + if (overridePair != UnregisteredConfigVarOverrides.end()) { + const auto value = std::move(overridePair->second); + UnregisteredConfigVars.erase(name); + + try { + configVar.getImpl()->loadFromArg(configVar, value); + } catch (std::exception& e) { + DuskConfigLog.error("Failed to load key '{}' from override arg: {}", name, e.what()); + } + } } void ConfigVarBase::markRegistered() { @@ -220,8 +248,11 @@ void ConfigVarBase::markRegistered() { registered = true; } -void dusk::config::FinishRegistration() { - RegistrationDone = true; +void ConfigVarBase::unmarkRegistered() { + if (!registered) + abort(); + + registered = false; } void dusk::config::LoadFromUserPreferences() { @@ -242,11 +273,16 @@ static void LoadFromPath(const char* path) { return; } + UnregisteredConfigVars.clear(); + for (const auto& el : j.items()) { const auto& key = el.key(); auto configVar = RegisteredConfigVars.find(key); if (configVar == RegisteredConfigVars.end()) { - DuskConfigLog.error("Unknown key '{}' found in config!", key); + DuskConfigLog.debug( + "Unknown key '{}' found in config! If this gets registered later, that's acceptable!", + key); + UnregisteredConfigVars.emplace(key, el.value()); continue; } @@ -259,10 +295,6 @@ static void LoadFromPath(const char* path) { } void dusk::config::LoadFromFileName(const char* path) { - if (!RegistrationDone) { - DuskConfigLog.fatal("Registration not finished yet!"); - } - DuskConfigLog.info("Loading config from '{}'", path); try { @@ -280,6 +312,20 @@ void dusk::config::LoadFromFileName(const char* path) { } } +void dusk::config::LoadArgOverride(std::string_view name, std::string_view value) { + const auto cVar = GetConfigVar(name); + if (!cVar) { + UnregisteredConfigVarOverrides.emplace(name, name); + return; + } + + try { + cVar->getImpl()->loadFromArg(*cVar, value); + } catch (const std::exception& e) { + DuskLog.fatal("Unable to parse: '{}': {}", value, e.what()); + } +} + void dusk::config::Save() { const auto configJsonPath = GetConfigJsonPath(); if (configJsonPath.empty()) { @@ -300,6 +346,10 @@ void dusk::config::Save() { } } + for (const auto& pair : UnregisteredConfigVars) { + j[pair.first] = pair.second; + } + try { const auto tempConfigJsonPath = GetTempConfigJsonPath(configJsonPath); io::FileStream::WriteAllText(tempConfigJsonPath, j.dump(4)); @@ -330,3 +380,13 @@ void dusk::config::EnumerateRegistered(std::function callb callback(*pair.second); } } + +void dusk::config::Shutdown() { + for (auto& pair : RegisteredConfigVars) { + pair.second->unmarkRegistered(); + } + + RegisteredConfigVars.clear(); + UnregisteredConfigVars.clear(); + UnregisteredConfigVarOverrides.clear(); +} diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index 99dee7ace7..444b910dd5 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -429,7 +429,7 @@ void add_speedrun_disabled_option(Pane& leftPane, Pane& rightPane, ConfigVar& std::function isDisabled = {}, std::string suffix = "") { auto& button = leftPane.add_child(NumberButton::Props{ .key = std::move(key), - .getValue = [&var] { return var; }, + .getValue = [&var] { return var.getValue(); }, .setValue = [&var, min, max](int value) { var.setValue(std::clamp(value, min, max)); @@ -1047,7 +1047,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { leftPane.add_section("Tools"); addOption("Turbo Key", getSettings().game.enableTurboKeybind, "Hold Tab to increase game speed by up to 4x.", - [] { return getSettings().game.speedrunMode; }); + [] { return getSettings().game.speedrunMode.getValue(); }); addOption("Reset Key (" + Rml::String{hotkeys::DO_RESET} + ")", getSettings().game.enableResetKeybind, "Press " + Rml::String{hotkeys::DO_RESET} + " to reset the game."); @@ -1155,7 +1155,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { getSettings().game.damageMultiplier.setValue(value); config::Save(); }, - .isDisabled = [] { return getSettings().game.speedrunMode; }, + .isDisabled = [] { return getSettings().game.speedrunMode.getValue(); }, .isModified = [] { return getSettings().game.damageMultiplier.getValue() != @@ -1446,7 +1446,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { } } }, - .isDisabled = [] { return getSettings().game.speedrunMode; }, + .isDisabled = [] { return getSettings().game.speedrunMode.getValue(); }, }); config_bool_select(leftPane, rightPane, getSettings().game.showInputViewer, { diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index a813368a63..542b64584b 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -416,16 +416,7 @@ static void ApplyCVarOverrides(const cxxopts::OptionValue& option) { const auto name = std::string_view(cvarArg).substr(0, sep); const auto value = std::string_view(cvarArg).substr(sep + 1); - const auto cVar = dusk::config::GetConfigVar(name); - if (!cVar) { - DuskLog.fatal("Unknown --cvar name: '{}'", name); - } - - try { - cVar->getImpl()->loadFromArg(*cVar, value); - } catch (const std::exception& e) { - DuskLog.fatal("Unable to parse: '{}': {}", value, e.what()); - } + dusk::config::LoadArgOverride(name, value); } } @@ -504,7 +495,6 @@ int game_main(int argc, char* argv[]) { mainCalled = true; dusk::registerSettings(); - dusk::config::FinishRegistration(); cxxopts::ParseResult parsed_arg_options; @@ -796,6 +786,8 @@ int game_main(int argc, char* argv[]) { dusk::discord::shutdown(); #endif dusk::ui::shutdown(); + + dusk::config::Shutdown(); aurora_shutdown(); return 0; From f38514db799a3e925858a3a13361cc813cc33500 Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 29 May 2026 04:59:42 +0200 Subject: [PATCH 42/48] Mods can be disabled by config We now register a CVar for mod enablement for each mod. Also made LoadedMod stored in unique_ptr because we keep pointers to them already, and I'm entirely uncomfortable with keeping pointers to the vector directly. --- include/dusk/mod_loader.hpp | 14 +++- src/dusk/modding/mod_loader.cpp | 85 ++++++++++++++++++------- src/dusk/modding/mod_loader_overlay.cpp | 6 +- 3 files changed, 77 insertions(+), 28 deletions(-) diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 04c4e4906e..a4d9db13ed 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -3,8 +3,10 @@ #include #include #include +#include #include "dusk/mod_api.h" +#include "dusk/config_var.hpp" namespace dusk::modding { class ModBundle; @@ -85,6 +87,8 @@ struct LoadedMod { std::string mod_path; std::string dir; + std::unique_ptr> cvarIsEnabled; + bool active = false; bool load_failed = false; @@ -106,10 +110,16 @@ class ModLoader { void tick(); void shutdown(); - const std::vector& mods() const { return m_mods; } + [[nodiscard]] auto mods() const { + return m_mods | std::views::transform([](const auto& m) -> LoadedMod& { return *m; }); + } + + [[nodiscard]] auto active_mods() const { + return mods() | std::views::filter([](const auto& m) { return m.active; }); + } private: - std::vector m_mods; + std::vector> m_mods; std::filesystem::path m_modsDir; bool m_initialized = false; diff --git a/src/dusk/modding/mod_loader.cpp b/src/dusk/modding/mod_loader.cpp index e16d21f179..4cb57923c0 100644 --- a/src/dusk/modding/mod_loader.cpp +++ b/src/dusk/modding/mod_loader.cpp @@ -9,6 +9,7 @@ #include #include "aurora/dvd.h" +#include "dusk/config.hpp" #include "dusk/io.hpp" #include "miniz.h" #include "native_module.hpp" @@ -32,6 +33,10 @@ 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; + namespace dusk { ModLoader& ModLoader::instance() { @@ -134,10 +139,11 @@ static ModMetadata loadMetadata(const std::filesystem::path& modPath, ModBundle& }; } -static bool checkDuplicateMod(const ModMetadata& metadata, const std::vector& mods) { - return std::ranges::any_of(mods, [&](const LoadedMod& mod) { - return mod.metadata.id == metadata.id; - }); +template +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) { @@ -219,6 +225,29 @@ void ModLoader::tryLoadNativeMod(LoadedMod& mod) { 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; +} + +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; @@ -241,7 +270,7 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) return; } - if (checkDuplicateMod(metadata, m_mods)) { + if (checkDuplicateMod(metadata, mods())) { Log.error( "mod with id '{}' already exists, not loading {}", metadata.id, @@ -249,11 +278,14 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) return; } - LoadedMod mod; + const auto& inserted = m_mods.emplace_back(std::make_unique()); + + auto& mod = *inserted; mod.active = true; 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; @@ -267,14 +299,13 @@ void ModLoader::tryLoadDusk(const std::filesystem::path& modPath, bool fromDir) } } - const auto& inserted = m_mods.emplace_back(std::move(mod)); Log.info( "found '{}' ('{}') v{} by {} ({})", - inserted.metadata.name, - inserted.metadata.id, - inserted.metadata.version, - inserted.metadata.author, + mod.metadata.name, + mod.metadata.id, + mod.metadata.version, + mod.metadata.author, io::fs_path_to_string(modPath.filename())); } @@ -317,14 +348,23 @@ void ModLoader::init() { Log.info("initializing {} mod(s)...", m_mods.size()); - for (auto& mod : m_mods) { - if (mod.native && mod.active) { + for (auto& mod : mods()) { + Register(*mod.cvarIsEnabled); + + if (!mod.cvarIsEnabled->getValue()) { + 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 : m_mods) { - if (!mod.native || !mod.active) { + for (auto& mod : active_mods()) { + if (!mod.native) { continue; } @@ -350,22 +390,20 @@ void ModLoader::init() { initOverlayFiles(); - auto active = - std::count_if(m_mods.begin(), m_mods.end(), [](const LoadedMod& m) { return m.active; }); + auto active = std::ranges::count_if(mods(), [](const LoadedMod& m) { return m.active; }); Log.info("{}/{} mod(s) active", active, m_mods.size()); } void ModLoader::tick() { - for (auto& mod : m_mods) { - if (!mod.active || !mod.native) { + for (auto& mod : active_mods()) { + if (!mod.native) { continue; } ModGuard guard(&mod); try { mod.native->fn_tick(&mod.native->api); } catch (const std::exception& e) { - Log.error( - "exception in {}.mod_tick(): {} — disabling", mod.metadata.id, e.what()); + Log.error("exception in {}.mod_tick(): {} — disabling", mod.metadata.id, e.what()); mod.active = false; } catch (...) { Log.error("unknown exception in {}.mod_tick() — disabling", mod.metadata.id); @@ -375,7 +413,7 @@ void ModLoader::tick() { } void ModLoader::shutdown() { - for (auto& mod : m_mods) { + for (auto& mod : mods()) { hookClearMod(&mod); if (mod.native && mod.native->fn_cleanup) { ModGuard guard(&mod); @@ -384,7 +422,10 @@ void ModLoader::shutdown() { } catch (...) { } } + + OrphanedConfigVars.emplace_back(std::move(mod.cvarIsEnabled)); } + m_mods.clear(); g_services.clear(); Log.info("all mods unloaded"); diff --git a/src/dusk/modding/mod_loader_overlay.cpp b/src/dusk/modding/mod_loader_overlay.cpp index fef860c489..994433a543 100644 --- a/src/dusk/modding/mod_loader_overlay.cpp +++ b/src/dusk/modding/mod_loader_overlay.cpp @@ -114,10 +114,8 @@ void ModLoader::initOverlayFiles() { std::vector files; - for (auto& mod : m_mods) { - if (mod.active) { - findOverlayFiles(files, mod); - } + for (auto& mod : active_mods()) { + findOverlayFiles(files, mod); } Log.debug("Found {} overlay files.", files.size()); From 183e7669c240ab9bfa9b18d3f80772128523774f Mon Sep 17 00:00:00 2001 From: PJB3005 Date: Fri, 29 May 2026 05:09:10 +0200 Subject: [PATCH 43/48] Add mods button to prelaunch UI Fixes https://github.com/TwilitRealm/dusklight/issues/1904 --- src/dusk/ui/prelaunch.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index ef6b6e5709..f26ab48d0e 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -28,6 +28,7 @@ #include #include "m_Do/m_Do_MemCard.h" +#include "mods_window.hpp" namespace dusk::ui { namespace { @@ -728,9 +729,16 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB }); apply_intro_animation(mMenuButtons.back()->root(), "delay-2"); + mMenuButtons.push_back(std::make_unique