diff --git a/.gitignore b/.gitignore index 43b2e4a1c3..2d1ff6d3a9 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ compile_commands.json pipeline_cache.bin extract + +*.dusk diff --git a/.vscode/launch.json b/.vscode/launch.json index 515473f4d0..64f4accbd9 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}/mods"], "MIMode": "gdb", "miDebuggerPath": "gdb", "symbolSearchPath": "${command:cmake.launchTargetPath}", "console": "integratedTerminal", - "cwd":"${workspaceRoot}" + "cwd":"${workspaceRoot}", } ] } diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d118d37d5..b6fe8962c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,26 +21,26 @@ else () 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) @@ -49,11 +49,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 () @@ -176,7 +176,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 @@ -256,14 +256,42 @@ 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 "dusklight: 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 + EXCLUDE_FROM_ALL ) -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 + EXCLUDE_FROM_ALL +) + +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 + 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) message(STATUS "dusklight: Fetching sentry-native") @@ -311,7 +339,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) @@ -336,12 +364,20 @@ 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(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 - 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 Threads::Threads) list(APPEND GAME_LIBS libzstd_static) @@ -416,6 +452,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 @@ -433,7 +479,6 @@ set_source_files_properties( COMPILE_DEFINITIONS "$<$:DEBUG=1>;$<$:PARTIAL_DEBUG=1>" ) -# game_base is for all other game code files set(GAME_BASE_FILES ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} @@ -450,6 +495,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> ) @@ -465,21 +511,58 @@ if (CMAKE_CXX_LINK_GROUP_USING_RESCAN_SUPPORTED OR CMAKE_LINK_GROUP_USING_RESCAN set(JSYSTEM_LINK_LIBRARIES "$") endif () -set(DUSK_FILES src/dusk/main.cpp ${GAME_BASE_FILES} ${GAME_DEBUG_FILES}) +set(DUSK_FILES src/dusk/main.cpp ${GAME_BASE_FILES} ${GAME_DEBUG_FILES} ${miniz_SOURCE_DIR}/miniz.c) if(ANDROID) add_library(dusklight SHARED ${DUSK_FILES}) set_target_properties(dusklight PROPERTIES OUTPUT_NAME main) + set(DUSK_MAIN_TARGET dusklight) +elseif(WIN32) + add_library(dusklight_game SHARED ${DUSK_FILES}) + set_target_properties(dusklight_game PROPERTIES + 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) + set(DUSK_MAIN_TARGET dusklight_game) else () add_executable(dusklight ${DUSK_FILES}) + set(DUSK_MAIN_TARGET dusklight) endif () -target_compile_definitions(dusklight PRIVATE ${GAME_COMPILE_DEFS}) -target_include_directories(dusklight PRIVATE ${GAME_INCLUDE_DIRS}) -target_link_libraries(dusklight PRIVATE aurora::main ${GAME_LIBS} ${JSYSTEM_LINK_LIBRARIES}) -target_precompile_headers(dusklight PRIVATE "$<$:${CMAKE_SOURCE_DIR}/include/dusk_pch.hpp>") +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_CURRENT_LIST_DIR}/include/dusk_pch.hpp>") + +if(WIN32) + target_link_libraries(${DUSK_MAIN_TARGET} PRIVATE Psapi) +endif() +if(CMAKE_SYSTEM_NAME STREQUAL Linux) + target_link_libraries(${DUSK_MAIN_TARGET} PRIVATE dl) +endif() +if(APPLE) + target_link_options(${DUSK_MAIN_TARGET} PRIVATE -Wl,-export_dynamic) +elseif(UNIX AND NOT ANDROID) + target_link_options(${DUSK_MAIN_TARGET} PRIVATE -rdynamic) +endif() + if (TARGET crashpad_handler) - add_dependencies(dusklight crashpad_handler) - add_custom_command(TARGET dusklight POST_BUILD + add_dependencies(${DUSK_MAIN_TARGET} crashpad_handler) + add_custom_command(TARGET ${DUSK_MAIN_TARGET} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" "$" @@ -490,7 +573,7 @@ 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(dusklight PRIVATE "-Wl,-u,SDL_main") + target_link_options(${DUSK_MAIN_TARGET} PRIVATE "-Wl,-u,SDL_main") endif () if (CMAKE_SYSTEM_NAME STREQUAL Linux) @@ -500,7 +583,7 @@ endif () if (NOT APPLE) add_custom_command(TARGET dusklight POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory - "${CMAKE_SOURCE_DIR}/res" + "${CMAKE_CURRENT_SOURCE_DIR}/res" "$/res" COMMENT "Copying resources" ) @@ -528,13 +611,13 @@ if (WIN32) configure_file(${DUSK_WINDOWS_RESOURCE_DIR}/dusklight.rc.in ${DUSK_WINDOWS_RC} @ONLY) target_sources(dusklight PRIVATE ${DUSK_WINDOWS_ICON_ICO} ${DUSK_WINDOWS_RC}) - set_target_properties(dusklight PROPERTIES WIN32_EXECUTABLE TRUE) - if (MSVC) target_link_options(dusklight PRIVATE /MANIFEST:NO) endif () endif () +include(cmake/DuskModSDK.cmake) + if (APPLE) if (IOS) set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios) @@ -542,6 +625,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 @@ -572,6 +656,8 @@ if (APPLE) OUTPUT_NAME Dusklight 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 () @@ -591,7 +677,11 @@ if (IOS) endif () include(extern/aurora/cmake/AuroraCopyRuntimeDLLs.cmake) -aurora_copy_runtime_dlls(dusklight) +if(WIN32) + aurora_copy_runtime_dlls(dusklight dusklight_game) +else() + aurora_copy_runtime_dlls(dusklight) +endif() if (DUSK_SELECTED_OPT) if (CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") @@ -630,14 +720,26 @@ function(get_target_prefix target result_var) endif () endfunction() list(APPEND BINARY_TARGETS dusklight) +if(WIN32) + list(APPEND BINARY_TARGETS dusklight_game) +endif() 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(dusklight ${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 "") @@ -688,3 +790,7 @@ foreach (target IN LISTS BINARY_TARGETS) endif () endforeach () endforeach () + +if (DUSK_ENABLE_CODE_MODS) + add_subdirectory(tools/mod_test) +endif () diff --git a/cmake/DuskModSDK.cmake b/cmake/DuskModSDK.cmake new file mode 100644 index 0000000000..b76e578857 --- /dev/null +++ b/cmake/DuskModSDK.cmake @@ -0,0 +1,49 @@ +# 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 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 dusklight_game) + if(MSVC) + target_link_options(${target_name} PRIVATE /INCREMENTAL:NO) + set_target_properties(${target_name} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") + 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/cmake/fix_capstone_policy.cmake b/cmake/fix_capstone_policy.cmake new file mode 100644 index 0000000000..fa8a1dd160 --- /dev/null +++ b/cmake/fix_capstone_policy.cmake @@ -0,0 +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) +file(READ "${DIR}/CMakeLists.txt" _content) +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_policy[ \t]*\\([ \t]*SET[ \t]+CMP0048[ \t]+OLD[ \t]*\\)" + "# 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..c0de59160e --- /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}") diff --git a/docs/modding.md b/docs/modding.md new file mode 100644 index 0000000000..d053f66ded --- /dev/null +++ b/docs/modding.md @@ -0,0 +1,351 @@ +# 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) +9. [Inter-Mod Communication](#inter-mod-communication) +10. [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. + +- 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. + +--- + +## 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" + +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 +void mod_tick (DuskModAPI* api); // required, called every frame +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 + +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_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 | + +--- + +## 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. + + +--- + +## 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 + +```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/files.cmake b/files.cmake index fe558ba41f..7e72920a49 100644 --- a/files.cmake +++ b/files.cmake @@ -1496,6 +1496,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 @@ -1524,6 +1526,15 @@ set(DUSK_FILES src/dusk/OSReport.cpp src/dusk/OSThread.cpp 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 + 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 src/dusk/discord_presence.cpp 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_com_inf_game.h b/include/d/d_com_inf_game.h index fbc9964ca2..82e7a0fe34 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/d/d_meter2_info.h b/include/d/d_meter2_info.h index 683e555386..875bb04710 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/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/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..5725e07e29 --- /dev/null +++ b/include/dusk/hook.hpp @@ -0,0 +1,122 @@ +#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))...}; + 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), 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), 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; + +template +struct HookEntry : HookEntryBase {}; + +template +struct HookEntry : HookEntryBase {}; + +template +struct HookEntry : HookEntryFreeBase {}; + +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, void* retval)) { + 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, void* retval)) { + 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..ffd32c318e --- /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, 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* retval); +void hookDispatchPost(void* fn_addr, void* args, void* retval); + +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..af3b9571c9 --- /dev/null +++ b/include/dusk/mod_api.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +#if defined(_WIN32) +#define DUSK_MOD_EXPORT __declspec(dllexport) +#else +#define DUSK_MOD_EXPORT __attribute__((visibility("default"))) +#endif + +#define DUSK_MOD_API_VERSION 1 + +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 \ + extern "C" DUSK_MOD_EXPORT uint32_t mod_api_version = DUSK_MOD_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_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 (*build_fn)(DuskPanelHandle panel, void* userdata), void* userdata); + void (*register_tab_update)(void (*update_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* 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); + + void (*service_publish)(const char* name, void* ptr); + void* (*service_get)(const char* name); +}; + +using DuskModAPI = DuskModAPIv1; + +extern "C" { +void mod_init(DuskModAPI* api); +void mod_tick(DuskModAPI* api); +} diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp new file mode 100644 index 0000000000..e475ab129e --- /dev/null +++ b/include/dusk/mod_loader.hpp @@ -0,0 +1,134 @@ +#pragma once + +#include +#include +#include +#include + +#include "dusk/mod_api.h" +#include "dusk/config_var.hpp" + +namespace dusk::modding { +class ModBundle; +class NativeModule; +} + +namespace dusk { + +struct RmlTabContentCallback { + void (*build_fn)(void* panel, void* userdata); + void* userdata; +}; + +struct RmlTabUpdateCallback { + void (*update_fn)(void* userdata); + void* userdata; +}; + +struct ModMetadata { + std::string id; + std::string name; + std::string version; + std::string author; + std::string description; + bool hasCode; +}; + +struct NativeMod { + std::unique_ptr handle; + DuskModAPI api{}; + + using FnInit = void (*)(DuskModAPI*); + using FnTick = void (*)(DuskModAPI*); + using FnCleanup = void (*)(DuskModAPI*); + + FnInit fn_init = nullptr; + FnTick fn_tick = nullptr; + 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; + std::string dir; + + std::unique_ptr> cvarIsEnabled; + + bool active = false; + bool load_failed = false; + + NativeModStatus native_status = NativeModStatus::None; + std::unique_ptr native; + + std::unique_ptr bundle; + + std::vector tab_content; + std::vector tab_updates; +}; + +class ModLoader { +public: + static ModLoader& instance(); + + void setModsDir(std::filesystem::path dir) { m_modsDir = std::move(dir); } + void init(); + void tick(); + void shutdown(); + + [[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::filesystem::path m_modsDir; + bool m_initialized = false; + + void tryLoadDusk(const std::filesystem::path& modPath, bool fromDir); + void tryLoadNativeMod(LoadedMod& mod); + void buildAPI(LoadedMod& mod); + void initOverlayFiles(); +}; + +using ModIndex = std::ranges::range_difference_t().mods())>; + +} // 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 2a8e182bfa..c647bc4c3b 100644 --- a/include/global.h +++ b/include/global.h @@ -114,6 +114,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 __declspec(dllexport) +# 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_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/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/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/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/res/rml/prelaunch.rcss b/res/rml/prelaunch.rcss index 73efc18086..054b6e0183 100644 --- a/res/rml/prelaunch.rcss +++ b/res/rml/prelaunch.rcss @@ -362,6 +362,10 @@ body.animate-in .intro-item { transition: opacity transform 0.3s 0.6s cubic-in-out; } +.delay-6 { + transition: opacity transform 0.3s 0.7s cubic-in-out; +} + /* Mobile layout */ @media (max-height: 640dp) { .gradient { diff --git a/res/rml/window.rcss b/res/rml/window.rcss index ab338e97c7..dda1a47a9d 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -395,6 +395,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; @@ -509,6 +514,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/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/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 63e22056f5..31d381a154 100644 --- a/src/d/d_meter2_info.cpp +++ b/src/d/d_meter2_info.cpp @@ -594,7 +594,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/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/gx_helper.cpp b/src/dusk/gx_helper.cpp new file mode 100644 index 0000000000..9c7c91b69a --- /dev/null +++ b/src/dusk/gx_helper.cpp @@ -0,0 +1,11 @@ +#include "dusk/gx_helper.h" + +GXTexObjRAII::~GXTexObjRAII() { GXDestroyTexObj(this); } +void GXTexObjRAII::reset() { GXDestroyTexObj(this); } + +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..620505822b --- /dev/null +++ b/src/dusk/hook_system.cpp @@ -0,0 +1,156 @@ +#include "dusk/hook_system.hpp" +#include "dusk/logging.h" + +#include +#include +#include +#include +#include +#include + +namespace dusk { + +namespace modding { + extern thread_local void* g_dusk_hook_current_mod; +} + +struct PreHookFn { + void* mod; + int32_t (*fn)(void* args); +}; +struct VoidHookFn { + void* mod; + const char* mod_name; + void (*fn)(void* args, void* retval); +}; + +struct HookSlot { + std::vector pre; + VoidHookFn replace = {}; + std::vector post; +}; + +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 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) { + 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(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) { + fn_addr = resolveImportThunk(fn_addr); + auto key = reinterpret_cast(fn_addr); + auto it = s_installed.find(key); + if (it != s_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); + s_installed[key] = fn; + *orig_store = fn; +} + +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) { + return true; + } + } + if (slot.replace.fn) { + ModGuard g(slot.replace.mod); + slot.replace.fn(args, retval); + return true; + } + return false; +} + +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, retval); + } + } +} + +void hookRegisterPre(void* fn_addr, void* mod, int32_t (*fn)(void* args)) { + 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, 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, 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); + return false; + } + slot.replace = {mod, mod_name, fn}; + return true; +} + +void hookClearMod(void* mod) { + 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; }), + v.end()); + }; + erase(slot.pre); + erase(slot.post); + if (slot.replace.mod == mod) { + slot.replace = {}; + } + } +} + +} // namespace dusk diff --git a/src/dusk/launcher_win32.cpp b/src/dusk/launcher_win32.cpp new file mode 100644 index 0000000000..8f060634e4 --- /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 +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 a35f695b50..9d1bbc9319 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 @@ -224,7 +224,8 @@ int main(int argc, char* argv[]) { } #if _WIN32 -int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) { +// Entry point called by the launcher executable. +int __declspec(dllexport) dusk_WinMain(HINSTANCE, HINSTANCE, PWSTR, int) { return RunWindowsGuiEntryPoint(); } #endif diff --git a/src/dusk/modding/bundle_disk.cpp b/src/dusk/modding/bundle_disk.cpp new file mode 100644 index 0000000000..60b8a19369 --- /dev/null +++ b/src/dusk/modding/bundle_disk.cpp @@ -0,0 +1,61 @@ +#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) { + + return io::FileStream::ReadAllBytes(toRealPath(fileName)); +} + +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; +} + +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 / filePath; +} + +} // 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..d85f394d32 --- /dev/null +++ b/src/dusk/modding/bundle_zip.cpp @@ -0,0 +1,65 @@ +#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; +} + +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 new file mode 100644 index 0000000000..c6bdc0f51d --- /dev/null +++ b/src/dusk/modding/mod_loader.cpp @@ -0,0 +1,445 @@ +#include "dusk/mod_loader.hpp" +#include "dusk/hook_system.hpp" +#include "dusk/logging.h" +#include "mod_loader.hpp" + +#include +#include +#include +#include + +#include "aurora/dvd.h" +#include "dusk/config.hpp" +#include "dusk/io.hpp" +#include "miniz.h" +#include "native_module.hpp" +#include "nlohmann/json.hpp" + +static aurora::Module Log("dusk::modLoader"); + +using namespace dusk::modding; +using namespace std::string_literals; +using namespace std::string_view_literals; + +#if defined(_M_ARM64) || defined(__aarch64__) +static constexpr std::string_view k_archSuffix = "_arm64"sv; +#elif defined(_M_X64) || defined(__x86_64__) +static constexpr std::string_view k_archSuffix = "_x64"sv; +#elif defined(_M_IX86) || defined(__i386__) +static constexpr std::string_view k_archSuffix = "_x86"sv; +#else +static constexpr std::string_view k_archSuffix = ""sv; +#endif + +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() { + return g_modLoader; +} + +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)); + } +} + +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) && !name.ends_with(".dylib"sv) && !name.ends_with(".so"sv)) { + continue; + } + + if (!k_archSuffix.empty() && getFileNameWithoutExtension(name).ends_with(k_archSuffix)) { + dllEntry = name; + } else if (dllFallback.empty()) { + dllFallback = name; + } + } + + 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 void validateModId(std::string_view const str) { + if (str.empty()) { + throw InvalidModDataException("Missing ID value in mod metadata!"); + } + + bool lastWasPeriod = false; + for (auto const chr : str) { + if (chr == '.') { + if (lastWasPeriod) { + throw InvalidModDataException("Cannot have two consecutive periods in mod ID!"); + } + lastWasPeriod = true; + continue; + } + + lastWasPeriod = false; + + if (chr == '_') + continue; + + if (chr >= '0' && chr <= '9') + continue; + + if (chr >= 'a' && chr <= 'z') + continue; + + if (chr >= 'A' && chr <= 'Z') + 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); + + 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", ""); + const bool hasCode = j.value("has_code", false); + + validateModId(metaId); + + 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), + hasCode, + }; +} + +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) { + if (!EnableCodeMods) { + Log.error("Code mods are not available in this build"); + mod.native_status = NativeModStatus::BuildDisabled; + return; + } + + namespace fs = std::filesystem; + + auto [dllEntry, dllFallback] = LocateDllInBundle(*mod.bundle); + if (dllEntry.empty()) { + dllEntry = dllFallback; + } + + if (dllEntry.empty()) { + Log.error( + "no *{} found in {} — skipping", NativeModule::LibraryExtension, mod.metadata.id); + mod.native_status = NativeModStatus::ModMissingPlatform; + return; + } + + const fs::path cacheDir = m_modsDir / ".cache" / mod.metadata.id; + std::error_code ec; + fs::create_directories(cacheDir, ec); + + const fs::path dllCachePath = cacheDir / fs::path(dllEntry).filename(); + + std::vector dllData; + try { + dllData = mod.bundle->readFile(dllEntry); + } catch (const std::runtime_error& e) { + Log.error( + "failed to extract {} from {}", dllEntry, mod.metadata.id); + return; + } + + { + std::ofstream out(dllCachePath, std::ios::binary | std::ios::out); + if (!out) { + Log.error("failed to write {}", io::fs_path_to_string(dllCachePath)); + return; + } + + out.write( + reinterpret_cast(dllData.data()), + static_cast(dllData.size())); + } + + auto nativeMod = std::make_unique(); + try { + nativeMod->handle = std::make_unique(dllCachePath); + } catch (const std::runtime_error& e) { + Log.error("failed to open {}: {}", io::fs_path_to_string(dllCachePath), e.what()); + 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); + mod.native_status = NativeModStatus::ApiVersionMismatch; + return; + } + + 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 (!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; + } + + mod.dir = io::fs_path_to_string(fs::absolute(cacheDir)); + mod.native = std::move(nativeMod); + mod.native_status = NativeModStatus::Loaded; +} + +static std::string escapeModIdForConfig(std::string_view const id) { + std::string buf; + + // Simple escaping. All characters in mod IDs literal, except for '.' and '_'. + // '.' -> '_', '_' -> '__' + for (char const chr : id) { + if (chr == '.') { + buf.push_back('_'); + } else if (chr == '_') { + buf.push_back('_'); + buf.push_back('_'); + } else { + buf.push_back(chr); + } + } + + return buf; +} + +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; + + 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; + } + + ModMetadata metadata; + try + { + metadata = loadMetadata(modPath, *bundle); + } + catch (const std::runtime_error& e) { + Log.error( + "bad mod.json in {}: {}", io::fs_path_to_string(modPath.filename()), e.what()); + return; + } + + if (checkDuplicateMod(metadata, mods())) { + Log.error( + "mod with id '{}' already exists, not loading {}", + metadata.id, + io::fs_path_to_string(modPath.filename())); + return; + } + + const auto& inserted = m_mods.emplace_back(std::make_unique()); + + auto& mod = *inserted; + mod.active = true; + mod.mod_path = io::fs_path_to_string(fs::absolute(modPath)); + mod.metadata = std::move(metadata); + mod.bundle = std::move(bundle); + mod.cvarIsEnabled = std::make_unique>(modEnabledCVarName(mod.metadata.id), true); + + if (mod.metadata.hasCode) { + mod.native_status = NativeModStatus::Unknown; + tryLoadNativeMod(mod); + // Native mod lod failure DOES NOT block insertion into m_mods. + // We still want to be able to present the failed load in the UI! + + if (mod.native_status != NativeModStatus::Loaded) { + Log.error("Native mod '{}' failed to load, disabling", metadata.id); + mod.active = false; + } + } + + + Log.info( + "found '{}' ('{}') v{} by {} ({})", + mod.metadata.name, + mod.metadata.id, + mod.metadata.version, + mod.metadata.author, + io::fs_path_to_string(modPath.filename())); +} + +void ModLoader::init() { + if (m_initialized) { + return; + } + m_initialized = true; + + namespace fs = std::filesystem; + if (!fs::is_directory(m_modsDir)) { + Log.info( + "mods directory '{}' not found — mod loading skipped", io::fs_path_to_string(m_modsDir)); + return; + } + + std::error_code ec; + std::vector entries; + for (auto& e : fs::directory_iterator(m_modsDir, ec)) { + if (e.is_directory() && std::filesystem::exists(e.path() / "mod.json")) { + entries.push_back(e); + } else if (e.is_regular_file() && e.path().extension() == ".dusk") { + entries.push_back(e); + } + } + std::sort(entries.begin(), entries.end(), + [](const fs::directory_entry& a, const fs::directory_entry& b) { + return a.path().filename() < b.path().filename(); + }); + + m_mods.reserve(entries.size()); + for (auto& entry : entries) { + tryLoadDusk(entry.path(), entry.is_directory()); + } + + if (m_mods.empty()) { + Log.info("no mods found"); + return; + } + + + Log.info("initializing {} mod(s)...", m_mods.size()); + 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 : active_mods()) { + if (!mod.native) { + continue; + } + + Log.debug("Initializing '{}'", mod.metadata.id); + + ModGuard guard(&mod); + try { + mod.native->fn_init(&mod.native->api); + if (!mod.load_failed) { + Log.info("'{}' initialized", mod.metadata.id); + } else { + mod.active = false; + Log.error("'{}' failed to load due to hook conflicts", mod.metadata.id); + } + } 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::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 : 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()); + mod.active = false; + } catch (...) { + Log.error("unknown exception in {}.mod_tick() — disabling", mod.metadata.id); + mod.active = false; + } + } +} + +void ModLoader::shutdown() { + for (auto& mod : mods()) { + hookClearMod(&mod); + if (mod.native && mod.native->fn_cleanup) { + ModGuard guard(&mod); + try { + mod.native->fn_cleanup(&mod.native->api); + } catch (...) { + } + } + + OrphanedConfigVars.emplace_back(std::move(mod.cvarIsEnabled)); + } + + m_mods.clear(); + g_services.clear(); + Log.info("all mods unloaded"); +} + +} // namespace dusk diff --git a/src/dusk/modding/mod_loader.hpp b/src/dusk/modding/mod_loader.hpp new file mode 100644 index 0000000000..f44560ed39 --- /dev/null +++ b/src/dusk/modding/mod_loader.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include +#include "miniz.h" + +#include "dusk/mod_loader.hpp" + +namespace dusk::modding { + +#if DUSK_CODE_MODS +constexpr bool EnableCodeMods = true; +#else +constexpr bool EnableCodeMods = false; +#endif + +class ModBundle { +public: + virtual ~ModBundle() = default; + + 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 { +public: + explicit ModBundleZip(std::vector&& data); + ~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; + 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; + size_t getFileSize(const std::string& fileName) override; + +private: + [[nodiscard]] std::filesystem::path toRealPath(const std::string& fileName) const; + 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 diff --git a/src/dusk/modding/mod_loader_overlay.cpp b/src/dusk/modding/mod_loader_overlay.cpp new file mode 100644 index 0000000000..b1b1866748 --- /dev/null +++ b/src/dusk/modding/mod_loader_overlay.cpp @@ -0,0 +1,111 @@ +#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 : active_mods()) { + findOverlayFiles(files, mod); + } + + Log.debug("Found {} overlay files.", files.size()); + aurora_dvd_overlay_files(files.data(), files.size(), nullptr); + + for (const auto& file : files) { + std::free(const_cast(file.fileName)); + } +} + +} // namespace dusk 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; +}; + +} diff --git a/src/dusk/ui/menu_bar.cpp b/src/dusk/ui/menu_bar.cpp index 1be5c7abb0..36dc981fa3 100644 --- a/src/dusk/ui/menu_bar.cpp +++ b/src/dusk/ui/menu_bar.cpp @@ -16,6 +16,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 "warp.hpp" @@ -59,8 +60,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..d7bd7c1880 --- /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.metadata.version, + mod.metadata.author, + statusClass, statusText, + mod.mod_path + ); +} + +} // namespace + +ModsWindow::ModsWindow() { + const auto& mods = dusk::ModLoader::instance().mods(); + + if (mods.empty()) { + add_tab("Mods", [this](Rml::Element* content) { + auto& pane = add_child(content, Pane::Type::Uncontrolled); + pane.add_text("No mods installed."); + pane.finalize(); + }); + return; + } + + for (ModIndex i = 0; i < mods.size(); ++i) { + mSnapshot.push_back({mods[i].active, mods[i].load_failed}); + + add_tab(mods[i].metadata.name, [this, i](Rml::Element* content) { + mActiveModIndex = static_cast(i); + + const auto& curMods = dusk::ModLoader::instance().mods(); + if (i >= curMods.size()) { + return; + } + const auto& mod = curMods[i]; + + auto& pane = add_child(content, Pane::Type::Uncontrolled); + + pane.add_section("Details"); + pane.add_rml(build_mod_detail_rml(mod)); + + if (!mod.metadata.description.empty()) { + pane.add_section("Description"); + pane.add_text(mod.metadata.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 (ModIndex i = 0; i < mods.size(); ++i) { + if (mods[i].active != mSnapshot[i].active || + mods[i].load_failed != mSnapshot[i].load_failed) + { + dirty = true; + break; + } + } + } + + 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..ba550cbf25 --- /dev/null +++ b/src/dusk/ui/mods_window.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "window.hpp" + +#include + +#include "dusk/mod_loader.hpp" + +namespace dusk::ui { + +class ModsWindow : public Window { +public: + ModsWindow(); + void update() override; + +private: + struct ModSnapshot { + bool active; + bool load_failed; + }; + std::vector mSnapshot; + ModIndex mActiveModIndex = 0; +}; + +} // namespace dusk::ui diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index ef6b6e5709..c6ecc5dac1 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 { @@ -49,14 +50,14 @@ const Rml::String kDocumentSource = R"RML(