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 () - add_executable(dusk src/dusk/main.cpp) -endif () + 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) -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) + # 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) + set(DUSK_MAIN_TARGET dusk) +endif() + +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 @@ public: 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 @@ public: 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 @@ public: static int m_height; static f32 m_heightF; static f32 m_widthF; + #endif #if TARGET_PC static f32 m_safeMinXF; @@ -380,7 +381,6 @@ public: 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 @@ private: 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); +} + +}