From 5c9c76cc0b1444cb9f0f70bf962e0a63429abd23 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 01:21:01 -0600 Subject: [PATCH 01/14] Dusklight mod API core Co-authored-by: qwertyquerty Co-authored-by: PJB3005 --- CMakeLists.txt | 195 ++--- CMakePresets.json | 20 +- cmake/DetectVersion.cmake | 121 +++ cmake/GameABIConfig.cmake | 27 + cmake/ModSDK.cmake | 215 +++++ docs/modding.md | 259 ++++++ files.cmake | 13 + include/dusk/mod_loader.hpp | 245 ++++++ include/mods/api.h | 152 ++++ include/mods/service.hpp | 138 +++ include/mods/svc/host.h | 63 ++ include/mods/svc/log.h | 47 + platforms/macos/Dusklight.entitlements | 8 + sdk/CMakeLists.txt | 33 + src/dusk/data.cpp | 16 +- src/dusk/data.hpp | 1 + src/dusk/mods/loader/bundle_disk.cpp | 60 ++ src/dusk/mods/loader/bundle_zip.cpp | 68 ++ src/dusk/mods/loader/context.cpp | 70 ++ src/dusk/mods/loader/depgraph.cpp | 207 +++++ src/dusk/mods/loader/depgraph.hpp | 17 + src/dusk/mods/loader/loader.cpp | 1088 ++++++++++++++++++++++++ src/dusk/mods/loader/loader.hpp | 67 ++ src/dusk/mods/loader/native_module.cpp | 83 ++ src/dusk/mods/loader/native_module.hpp | 35 + src/dusk/mods/svc/host.cpp | 76 ++ src/dusk/mods/svc/log.cpp | 64 ++ src/dusk/mods/svc/registry.cpp | 259 ++++++ src/dusk/mods/svc/registry.hpp | 36 + src/f_ap/f_ap_game.cpp | 3 + src/m_Do/m_Do_main.cpp | 58 +- tools/mod_template/CMakeLists.txt | 19 + tools/mod_template/mod.json | 7 + tools/mod_template/res/.gitkeep | 0 tools/mod_template/src/mod.cpp | 22 + 35 files changed, 3645 insertions(+), 147 deletions(-) create mode 100644 cmake/DetectVersion.cmake create mode 100644 cmake/GameABIConfig.cmake create mode 100644 cmake/ModSDK.cmake create mode 100644 docs/modding.md create mode 100644 include/dusk/mod_loader.hpp create mode 100644 include/mods/api.h create mode 100644 include/mods/service.hpp create mode 100644 include/mods/svc/host.h create mode 100644 include/mods/svc/log.h create mode 100644 platforms/macos/Dusklight.entitlements create mode 100644 sdk/CMakeLists.txt create mode 100644 src/dusk/mods/loader/bundle_disk.cpp create mode 100644 src/dusk/mods/loader/bundle_zip.cpp create mode 100644 src/dusk/mods/loader/context.cpp create mode 100644 src/dusk/mods/loader/depgraph.cpp create mode 100644 src/dusk/mods/loader/depgraph.hpp create mode 100644 src/dusk/mods/loader/loader.cpp create mode 100644 src/dusk/mods/loader/loader.hpp create mode 100644 src/dusk/mods/loader/native_module.cpp create mode 100644 src/dusk/mods/loader/native_module.hpp create mode 100644 src/dusk/mods/svc/host.cpp create mode 100644 src/dusk/mods/svc/log.cpp create mode 100644 src/dusk/mods/svc/registry.cpp create mode 100644 src/dusk/mods/svc/registry.hpp create mode 100644 tools/mod_template/CMakeLists.txt create mode 100644 tools/mod_template/mod.json create mode 100644 tools/mod_template/res/.gitkeep create mode 100644 tools/mod_template/src/mod.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c485a0c6a..d7093b097c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,96 +5,8 @@ if (NOT CMAKE_BUILD_TYPE) "Build type options: Debug Release RelWithDebInfo MinSizeRel" FORCE) endif () -set(DUSK_VERSION_OVERRIDE "" CACHE STRING "Override version string (skips git detection and format validation)") - -if (DUSK_VERSION_OVERRIDE) - set(DUSK_WC_DESCRIBE "${DUSK_VERSION_OVERRIDE}") - set(DUSK_VERSION_STRING "0.0.0.0") - set(DUSK_SHORT_VERSION_STRING "0.0.0") - set(DUSK_VERSION_CODE "1") - set(DUSK_WC_REVISION "") - set(DUSK_WC_BRANCH "") - set(DUSK_WC_DATE "") - message(STATUS "Dusklight version overridden to ${DUSK_WC_DESCRIBE}") -else () - # obtain revision info from git - 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 - 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 - OUTPUT_VARIABLE dusk_git_head_symbolic - OUTPUT_STRIP_TRAILING_WHITESPACE) - execute_process(WORKING_DIRECTORY ${CMAKE_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 - 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*" - OUTPUT_VARIABLE DUSK_WC_DESCRIBE - OUTPUT_STRIP_TRAILING_WHITESPACE) - - # remove the git hash, then collapse a clean "-0" suffix only - string(REGEX REPLACE "-[^-]+(-dirty|)$" "\\1" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") - 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 - 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 - OUTPUT_VARIABLE DUSK_WC_DATE - OUTPUT_STRIP_TRAILING_WHITESPACE) -else () - message(STATUS "Unable to find git, commit information will not be available") -endif () - -if (DUSK_WC_DESCRIBE MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)([-+].*)?$") - set(DUSK_SHORT_VERSION_STRING "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}.${CMAKE_MATCH_3}") - set(_ver_major ${CMAKE_MATCH_1}) - set(_ver_minor ${CMAKE_MATCH_2}) - set(_ver_patch ${CMAKE_MATCH_3}) - set(DUSK_VERSION_TWEAK "0") - if (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]+)(-dirty)?$") - set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") - elseif (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-[0-9A-Za-z.-]+-([0-9]+)(-dirty)?$") - set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") - endif () - set(DUSK_VERSION_STRING "${DUSK_SHORT_VERSION_STRING}.${DUSK_VERSION_TWEAK}") - if(DUSK_VERSION_TWEAK GREATER 999) - set(_tweak 999) - else() - set(_tweak ${DUSK_VERSION_TWEAK}) - endif() - # encoding: major*1e7 + minor*1e5 + patch*1e3 + tweak; collision-free for major<210, minor<100, patch<100, tweak<=999 - math(EXPR DUSK_VERSION_CODE - "${_ver_major} * 10000000 + ${_ver_minor} * 100000 + ${_ver_patch} * 1000 + ${_tweak}") -else () - set(DUSK_WC_DESCRIBE "UNKNOWN-VERSION") - set(DUSK_VERSION_STRING "0.0.0.0") - set(DUSK_SHORT_VERSION_STRING "0.0.0") - set(DUSK_VERSION_CODE "1") -endif () - -endif () - -# Add version information to CI environment variables -if(DEFINED ENV{GITHUB_ENV}) - file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION=${DUSK_WC_DESCRIBE}\n") - file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION_CODE=${DUSK_VERSION_CODE}\n") -endif() -message(STATUS "Dusklight version set to ${DUSK_WC_DESCRIBE}") +include(cmake/DetectVersion.cmake) +detect_version() message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") project(dusklight LANGUAGES C CXX VERSION ${DUSK_VERSION_STRING}) if (APPLE) @@ -177,12 +89,12 @@ option(DUSK_ENABLE_UPDATE_CHECKER "Enable update checking support" ON) option(DUSK_ENABLE_SENTRY_NATIVE "Enable sentry-native crash reporting support" OFF) option(DUSK_PACKAGE_INSTALL "Install Dusklight with a Linux-native file structure" OFF) option(DUSK_GFX_DEBUG_GROUPS "Report debug groups to the native graphics API" ${DUSK_GFX_DEBUG_GROUPS_DEFAULT}) -set(DUSK_SENTRY_DSN "" CACHE STRING "Sentry DSN") -set(DUSK_SENTRY_ENVIRONMENT "development" CACHE STRING "Sentry environment") +option(DUSK_ENABLE_CODE_MODS "Enable code mods" OFF) # Edit & Continue if (MSVC) - if ("${CMAKE_MSVC_DEBUG_INFORMATION_FORMAT}" STREQUAL "" AND CMAKE_BUILD_TYPE STREQUAL "Debug") + if ("${CMAKE_MSVC_DEBUG_INFORMATION_FORMAT}" STREQUAL "" AND CMAKE_BUILD_TYPE STREQUAL "Debug" + AND CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "EditAndContinue") endif () if (CMAKE_MSVC_DEBUG_INFORMATION_FORMAT STREQUAL "EditAndContinue") @@ -299,7 +211,13 @@ FetchContent_Declare(json URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa DOWNLOAD_EXTRACT_TIMESTAMP FALSE ) -FetchContent_MakeAvailable(cxxopts json) +message(STATUS "dusklight: 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 +) +FetchContent_MakeAvailable(cxxopts json miniz) if (DUSK_ENABLE_SENTRY_NATIVE) message(STATUS "dusklight: Fetching sentry-native") @@ -333,21 +251,7 @@ if(_arch MATCHES "^(arm|aarch64)" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQU add_compile_options(-fsigned-char) endif() -if (CMAKE_SYSTEM_NAME STREQUAL Windows) - set(PLATFORM_NAME win32) -elseif (CMAKE_SYSTEM_NAME STREQUAL Darwin) - if (IOS) - set(PLATFORM_NAME ios) - elseif (TVOS) - set(PLATFORM_NAME tvos) - else () - set(PLATFORM_NAME macos) - endif () -else () - string(TOLOWER CMAKE_SYSTEM_NAME PLATFORM_NAME) -endif () - -configure_file(${CMAKE_SOURCE_DIR}/version.h.in ${CMAKE_BINARY_DIR}/version.h) +configure_version_header() include(files.cmake) @@ -363,23 +267,12 @@ set(DUSK_COPYRIGHT "Copyright (C) Twilit Realm contributors") source_group("dolzel" FILES ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${REL_FILES}) source_group("dusklight" FILES ${DUSK_FILES} ${DUSK_HTTP_BACKEND_FILES}) -# PARTIAL_DEBUG makes debug and release share one struct/vtable ABI so a mod binary loads into either -set(GAME_COMPILE_DEFS TARGET_PC WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1 PARTIAL_DEBUG=1) - -set(GAME_INCLUDE_DIRS - include - src - assets/GZ2E01 # TODO: make this dynamic if needed? - libs/JSystem/include - libs - extern/aurora/include/dolphin - extern - ${CMAKE_BINARY_DIR}) +include(cmake/GameABIConfig.cmake) 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 - Threads::Threads zstd::libzstd) + Threads::Threads zstd::libzstd dusklight_game_headers) if (DUSK_ENABLE_SENTRY_NATIVE) list(APPEND GAME_LIBS sentry) @@ -447,8 +340,8 @@ if (DUSK_ENABLE_DISCORD AND NOT ANDROID AND NOT IOS AND NOT TVOS) list(APPEND GAME_COMPILE_DEFS DUSK_DISCORD=1) endif () -if(ANDROID) - list(APPEND GAME_COMPILE_DEFS TARGET_ANDROID=1) +if (DUSK_ENABLE_CODE_MODS) + list(APPEND GAME_COMPILE_DEFS DUSK_CODE_MODS=1) endif () if (DUSK_PACKAGE_INSTALL) @@ -475,7 +368,7 @@ set(GAME_DEBUG_FILES set_source_files_properties( ${GAME_DEBUG_FILES} PROPERTIES - COMPILE_DEFINITIONS "$<$:DEBUG=1>;PARTIAL_DEBUG=1" + COMPILE_DEFINITIONS "$<$:DEBUG=1>" ) # game_base is for all other game code files @@ -489,16 +382,11 @@ set(GAME_BASE_FILES set_source_files_properties( ${GAME_BASE_FILES} PROPERTIES - COMPILE_DEFINITIONS "NDEBUG=1;NDEBUG_DEFINED=1;DEBUG_DEFINED=0;PARTIAL_DEBUG=1" + COMPILE_DEFINITIONS "NDEBUG=1;NDEBUG_DEFINED=1;DEBUG_DEFINED=0" ) foreach(jsystem_lib IN LISTS JSYSTEM_LIBRARIES) - target_compile_definitions(${jsystem_lib} PRIVATE - ${GAME_COMPILE_DEFS} - $<$:DEBUG=1> - PARTIAL_DEBUG=1 - ) - target_include_directories(${jsystem_lib} PRIVATE ${GAME_INCLUDE_DIRS}) + target_compile_definitions(${jsystem_lib} PRIVATE ${GAME_COMPILE_DEFS} $<$:DEBUG=1>) target_link_libraries(${jsystem_lib} PRIVATE ${GAME_LIBS}) set_target_properties(${jsystem_lib} PROPERTIES FOLDER "JSystem") endforeach() @@ -510,7 +398,7 @@ 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) @@ -522,7 +410,7 @@ if (ENABLE_ASAN) endif () target_compile_definitions(dusklight PRIVATE ${GAME_COMPILE_DEFS}) -target_include_directories(dusklight PRIVATE ${GAME_INCLUDE_DIRS}) +target_include_directories(dusklight PRIVATE ${miniz_SOURCE_DIR}) 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 (TARGET crashpad_handler) @@ -543,6 +431,7 @@ endif () if (CMAKE_SYSTEM_NAME STREQUAL Linux) target_link_options(dusklight PRIVATE "-Wl,--build-id=sha1") + target_link_libraries(dusklight PRIVATE dl) endif () if (NOT APPLE) @@ -583,6 +472,13 @@ if (WIN32) endif () endif () +include(cmake/ModSDK.cmake) + +if (DUSK_ENABLE_CODE_MODS AND CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + add_custom_target(dusklight_mods) # Aggregate target for all in-tree mods + add_subdirectory(tools/mod_template) +endif () + if (APPLE) if (IOS) set(DUSK_RESOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios) @@ -590,6 +486,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}/Dusklight.entitlements) endif () set(DUSK_INFO_PLIST ${DUSK_RESOURCE_DIR}/Info.plist.in) file(GLOB_RECURSE DUSK_RESOURCE_FILES @@ -609,8 +506,7 @@ if (APPLE) get_filename_component(NEW_FILE_PATH ${NEW_FILE} DIRECTORY) set_property(SOURCE ${FILE} PROPERTY MACOSX_PACKAGE_LOCATION "Resources/${NEW_FILE_PATH}") endforeach () - set_target_properties( - dusklight PROPERTIES + set(_apple_bundle_properties MACOSX_BUNDLE TRUE MACOSX_BUNDLE_BUNDLE_NAME ${DUSK_BUNDLE_NAME} MACOSX_BUNDLE_GUI_IDENTIFIER ${DUSK_BUNDLE_IDENTIFIER} @@ -621,6 +517,31 @@ if (APPLE) XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES" XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "YES" ) + if (DUSK_ENTITLEMENTS) + list(APPEND _apple_bundle_properties + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${DUSK_ENTITLEMENTS}) + endif () + if (NOT IOS AND NOT TVOS) + list(APPEND _apple_bundle_properties + XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME "YES") + endif () + set_target_properties(dusklight PROPERTIES ${_apple_bundle_properties}) + + if (NOT IOS AND NOT TVOS AND NOT "${CMAKE_GENERATOR}" STREQUAL "Xcode") + set(_sign_nested_commands) + if (TARGET crashpad_handler) + list(APPEND _sign_nested_commands + COMMAND /usr/bin/codesign --force --sign - + "$/crashpad_handler") + endif () + add_custom_command(TARGET dusklight POST_BUILD + ${_sign_nested_commands} + COMMAND /usr/bin/codesign --force --sign - --entitlements + "${DUSK_ENTITLEMENTS}" "$" + COMMENT "Signing Dusklight.app with entitlements" + VERBATIM + ) + endif () endif () if (APPLE AND NOT IOS AND NOT TVOS) @@ -744,3 +665,5 @@ foreach (target IN LISTS BINARY_TARGETS) endif () endforeach () endforeach () + +install_bundled_mods() diff --git a/CMakePresets.json b/CMakePresets.json index b7cb18c2d1..394a1ddef5 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -72,7 +72,11 @@ "type": "BOOL", "value": false }, - "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install" + "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install", + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + } }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { @@ -159,6 +163,10 @@ "CMAKE_C_COMPILER": "cl", "CMAKE_CXX_COMPILER": "cl", "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install", + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + }, "CMAKE_DISABLE_FIND_PACKAGE_PkgConfig": { "type": "BOOL", "value": true @@ -262,7 +270,11 @@ "type": "BOOL", "value": false }, - "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install" + "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install", + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + } }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { @@ -394,6 +406,10 @@ "type": "BOOL", "value": false }, + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + }, "CMAKE_DISABLE_FIND_PACKAGE_PkgConfig": { "type": "BOOL", "value": true diff --git a/cmake/DetectVersion.cmake b/cmake/DetectVersion.cmake new file mode 100644 index 0000000000..ec72a97a2e --- /dev/null +++ b/cmake/DetectVersion.cmake @@ -0,0 +1,121 @@ +# Version detection shared by the main build and the mod SDK (sdk/CMakeLists.txt) +include_guard(GLOBAL) + +get_filename_component(_DUSK_VERSION_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) + +set(DUSK_SENTRY_DSN "" CACHE STRING "Sentry DSN") +set(DUSK_SENTRY_ENVIRONMENT "development" CACHE STRING "Sentry environment") + +set(DUSK_VERSION_OVERRIDE "" CACHE STRING "Override version string (skips git detection and format validation)") + +macro(detect_version) + if (DUSK_VERSION_OVERRIDE) + set(DUSK_WC_DESCRIBE "${DUSK_VERSION_OVERRIDE}") + set(DUSK_VERSION_STRING "0.0.0.0") + set(DUSK_SHORT_VERSION_STRING "0.0.0") + set(DUSK_VERSION_CODE "1") + set(DUSK_WC_REVISION "") + set(DUSK_WC_BRANCH "") + set(DUSK_WC_DATE "") + message(STATUS "Dusklight version overridden to ${DUSK_WC_DESCRIBE}") + else () + # obtain revision info from git + find_package(Git) + if (GIT_FOUND) + # make sure version information gets re-run when the current Git HEAD changes + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse --git-path HEAD + OUTPUT_VARIABLE dusk_git_head_filename + OUTPUT_STRIP_TRAILING_WHITESPACE) + get_filename_component(dusk_git_head_filename "${dusk_git_head_filename}" ABSOLUTE BASE_DIR "${_DUSK_VERSION_ROOT}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_filename}") + + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse --symbolic-full-name HEAD + OUTPUT_VARIABLE dusk_git_head_symbolic + OUTPUT_STRIP_TRAILING_WHITESPACE) + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} + COMMAND ${GIT_EXECUTABLE} rev-parse --git-path ${dusk_git_head_symbolic} + OUTPUT_VARIABLE dusk_git_head_symbolic_filename + OUTPUT_STRIP_TRAILING_WHITESPACE) + get_filename_component(dusk_git_head_symbolic_filename "${dusk_git_head_symbolic_filename}" ABSOLUTE BASE_DIR "${_DUSK_VERSION_ROOT}") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${dusk_git_head_symbolic_filename}") + + # defines DUSK_WC_REVISION + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} rev-parse HEAD + OUTPUT_VARIABLE DUSK_WC_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE) + # defines DUSK_WC_DESCRIBE + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} describe --tags --long --dirty --match "v*" + OUTPUT_VARIABLE DUSK_WC_DESCRIBE + OUTPUT_STRIP_TRAILING_WHITESPACE) + + # remove the git hash, then collapse a clean "-0" suffix only + string(REGEX REPLACE "-[^-]+(-dirty|)$" "\\1" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") + string(REGEX REPLACE "-0$" "" DUSK_WC_DESCRIBE "${DUSK_WC_DESCRIBE}") + + # defines DUSK_WC_BRANCH + execute_process(WORKING_DIRECTORY ${_DUSK_VERSION_ROOT} 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 ${_DUSK_VERSION_ROOT} COMMAND ${GIT_EXECUTABLE} log -1 --format=%ad + OUTPUT_VARIABLE DUSK_WC_DATE + OUTPUT_STRIP_TRAILING_WHITESPACE) + else () + message(STATUS "Unable to find git, commit information will not be available") + endif () + + if (DUSK_WC_DESCRIBE MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)([-+].*)?$") + set(DUSK_SHORT_VERSION_STRING "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}.${CMAKE_MATCH_3}") + set(_ver_major ${CMAKE_MATCH_1}) + set(_ver_minor ${CMAKE_MATCH_2}) + set(_ver_patch ${CMAKE_MATCH_3}) + set(DUSK_VERSION_TWEAK "0") + if (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]+)(-dirty)?$") + set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") + elseif (DUSK_WC_DESCRIBE MATCHES "^v[0-9]+\\.[0-9]+\\.[0-9]+-[0-9A-Za-z.-]+-([0-9]+)(-dirty)?$") + set(DUSK_VERSION_TWEAK "${CMAKE_MATCH_1}") + endif () + set(DUSK_VERSION_STRING "${DUSK_SHORT_VERSION_STRING}.${DUSK_VERSION_TWEAK}") + if (DUSK_VERSION_TWEAK GREATER 999) + set(_tweak 999) + else () + set(_tweak ${DUSK_VERSION_TWEAK}) + endif () + # encoding: major*1e7 + minor*1e5 + patch*1e3 + tweak; collision-free for major<210, minor<100, patch<100, tweak<=999 + math(EXPR DUSK_VERSION_CODE + "${_ver_major} * 10000000 + ${_ver_minor} * 100000 + ${_ver_patch} * 1000 + ${_tweak}") + else () + set(DUSK_WC_DESCRIBE "UNKNOWN-VERSION") + set(DUSK_VERSION_STRING "0.0.0.0") + set(DUSK_SHORT_VERSION_STRING "0.0.0") + set(DUSK_VERSION_CODE "1") + endif () + + endif () + + # Add version information to CI environment variables + if (DEFINED ENV{GITHUB_ENV}) + file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION=${DUSK_WC_DESCRIBE}\n") + file(APPEND "$ENV{GITHUB_ENV}" "DUSK_VERSION_CODE=${DUSK_VERSION_CODE}\n") + endif () + message(STATUS "Dusklight version set to ${DUSK_WC_DESCRIBE}") +endmacro() + +# Sets PLATFORM_NAME and configures version.h into the caller's binary dir. +macro(configure_version_header) + if (CMAKE_SYSTEM_NAME STREQUAL Windows) + set(PLATFORM_NAME win32) + elseif (CMAKE_SYSTEM_NAME STREQUAL Darwin) + if (IOS) + set(PLATFORM_NAME ios) + elseif (TVOS) + set(PLATFORM_NAME tvos) + else () + set(PLATFORM_NAME macos) + endif () + else () + string(TOLOWER CMAKE_SYSTEM_NAME PLATFORM_NAME) + endif () + + configure_file(${_DUSK_VERSION_ROOT}/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/version.h) +endmacro() diff --git a/cmake/GameABIConfig.cmake b/cmake/GameABIConfig.cmake new file mode 100644 index 0000000000..030f1181a0 --- /dev/null +++ b/cmake/GameABIConfig.cmake @@ -0,0 +1,27 @@ +# The game ABI surface shared by the main build and the mod SDK (sdk/CMakeLists.txt) +include_guard(GLOBAL) + +get_filename_component(_game_root "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) + +# PARTIAL_DEBUG makes debug and release share one struct/vtable ABI so a mod binary loads into either +set(_game_compile_defs TARGET_PC=1 WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1 PARTIAL_DEBUG=1) +if (ANDROID) + list(APPEND _game_compile_defs TARGET_ANDROID=1) +endif () + +set(_game_include_dirs + ${_game_root}/include + ${_game_root}/src + ${_game_root}/assets/GZ2E01 # TODO: make this dynamic if needed? + ${_game_root}/libs/JSystem/include + ${_game_root}/libs + ${_game_root}/extern/aurora/include/dolphin + ${_game_root}/extern/aurora/include + ${_game_root}/extern + ${CMAKE_CURRENT_BINARY_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 ${_game_compile_defs}) diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake new file mode 100644 index 0000000000..e15e892a58 --- /dev/null +++ b/cmake/ModSDK.cmake @@ -0,0 +1,215 @@ +# add_mod( SOURCES ... MOD_JSON [RES_DIR ] [OUTPUT_DIR ] [BUNDLE]) +set(DUSK_MODS_OUTPUT_DIR "${CMAKE_BINARY_DIR}/mods" CACHE PATH "Directory to write mod packages into") + +function(_mod_lib_name out_var) + set(_arch "${CMAKE_SYSTEM_PROCESSOR}") + if (APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _count) + if (_count GREATER 1) + message(FATAL_ERROR "add_mod: universal binaries are not supported") + endif () + set(_arch "${CMAKE_OSX_ARCHITECTURES}") + endif () + string(TOLOWER "${CMAKE_SYSTEM_NAME}" _platform) + string(TOLOWER "${_arch}" _arch) + if (_arch MATCHES "^(i[3-6]86|x86)$") + set(_arch "x86") + endif () + if (WIN32) + set(_ext ".dll") + elseif (APPLE) + set(_ext ".dylib") + else () + set(_ext ".so") + endif () + set(${out_var} "${_platform}-${_arch}${_ext}" PARENT_SCOPE) +endfunction() + +function(_mod_resolve_source_path out_var path) + if (IS_ABSOLUTE "${path}") + set(_path "${path}") + else () + set(_path "${CMAKE_CURRENT_SOURCE_DIR}/${path}") + endif () + set(${out_var} "${_path}" PARENT_SCOPE) +endfunction() + +function(_mod_collect_assets out_var dir) + if (NOT IS_DIRECTORY "${dir}") + message(FATAL_ERROR "add_mod: asset directory does not exist: ${dir}") + endif () + + file(GLOB_RECURSE _files CONFIGURE_DEPENDS LIST_DIRECTORIES false "${dir}/*") + set(${out_var} ${_files} PARENT_SCOPE) +endfunction() + +function(add_mod target_name) + cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OUTPUT_DIR" "SOURCES" ${ARGN}) + if (NOT ARG_MOD_JSON) + message(FATAL_ERROR "add_mod: MOD_JSON is required") + endif () + _mod_resolve_source_path(_mod_json "${ARG_MOD_JSON}") + if (NOT EXISTS "${_mod_json}") + message(FATAL_ERROR "add_mod: MOD_JSON does not exist: ${_mod_json}") + endif () + + add_library(${target_name} SHARED ${ARG_SOURCES}) + _mod_lib_name(_lib_name) + set_target_properties(${target_name} PROPERTIES + PREFIX "" + C_VISIBILITY_PRESET hidden + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON + WINDOWS_EXPORT_ALL_SYMBOLS OFF) + target_compile_features(${target_name} PRIVATE cxx_std_20) + target_link_libraries(${target_name} PRIVATE dusklight_game_headers) + + if (NOT TARGET dusklight) + # Apply global compile options for out-of-tree mod builds + if (CMAKE_SYSTEM_NAME STREQUAL Linux) + target_compile_options(${target_name} PRIVATE + -Wno-multichar -Wno-trigraphs -Wno-deprecated-declarations) + elseif (APPLE) + target_compile_options(${target_name} PRIVATE + -Wno-declaration-after-statement -Wno-non-pod-varargs) + elseif (MSVC) + target_compile_options(${target_name} PRIVATE + "$<$:/bigobj>" + "$<$:/utf-8>") + endif () + # Use signed char on ARM to match the original game (and x86) + string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _mod_arch) + if (_mod_arch MATCHES "^(arm|aarch64)" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "GNU") + target_compile_options(${target_name} PRIVATE -fsigned-char) + endif () + endif () + + set(_output_dir "${DUSK_MODS_OUTPUT_DIR}") + if (ARG_OUTPUT_DIR) + set(_output_dir "${ARG_OUTPUT_DIR}") + endif () + set(_stage "${CMAKE_CURRENT_BINARY_DIR}/${target_name}_stage") + set(_out "${_output_dir}/${target_name}.dusk") + + set(_zip_args "${_lib_name}" mod.json) + set(_package_deps "${_mod_json}") + set(_package_inputs "${_mod_json}") + set(_extra_cmds "") + if (ARG_RES_DIR) + _mod_resolve_source_path(_res_dir "${ARG_RES_DIR}") + _mod_collect_assets(_res_deps "${_res_dir}") + list(APPEND _package_deps ${_res_deps}) + list(APPEND _package_inputs "${_res_dir}" ${_res_deps}) + list(APPEND _zip_args res) + list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_res_dir}" "${_stage}/res") + endif () + + set(_bundle_cmds "") + if (ARG_BUNDLE AND TARGET dusklight) + file(READ "${_mod_json}" _mod_json_text) + string(JSON _mod_id GET "${_mod_json_text}" id) + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_TARGETS "${target_name}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_IDS "${_mod_id}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_STAGES "${_stage}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_PACKAGES "${_out}") + set_property(GLOBAL APPEND PROPERTY DUSK_BUNDLED_MOD_LIB_NAMES "${_lib_name}") + set(_bundle_cmds + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/bundled_mods" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_out}" "${CMAKE_BINARY_DIR}/bundled_mods/${target_name}.dusk") + endif () + + set(_package_target "${target_name}_package") + set(_package_inputs_file "${CMAKE_CURRENT_BINARY_DIR}/${target_name}_package_inputs.txt") + list(SORT _package_inputs) + set(_package_inputs_text "") + foreach (_package_input IN LISTS _package_inputs) + string(APPEND _package_inputs_text "${_package_input}\n") + endforeach () + file(GENERATE OUTPUT "${_package_inputs_file}" CONTENT "${_package_inputs_text}") + add_custom_command(OUTPUT "${_out}" + COMMAND ${CMAKE_COMMAND} -E rm -rf "${_stage}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${_stage}" "${_output_dir}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" "${_stage}/${_lib_name}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_mod_json}" "${_stage}/mod.json" + ${_extra_cmds} + COMMAND ${CMAKE_COMMAND} -E chdir "${_stage}" ${CMAKE_COMMAND} -E tar cvf "${_out}" --format=zip ${_zip_args} + ${_bundle_cmds} + DEPENDS ${target_name} ${_package_deps} "${_package_inputs_file}" + COMMENT "Packaging ${target_name} -> ${_out}" + COMMAND_EXPAND_LISTS + VERBATIM + ) + add_custom_target(${_package_target} ALL DEPENDS "${_out}") + if (TARGET dusklight_mods) + add_dependencies(dusklight_mods ${_package_target}) + endif () +endfunction() + +# Install rules for BUNDLE mods. +# - Windows: the .dusk archives into /mods (the loader extracts native libs to the +# user cache). +# - Linux: pre-extracted stage dirs into /mods so native libs dlopen in place from +# read-only installs. +# - macOS: pre-extracted stage dirs into the installed app's Contents/Resources/mods, dylibs +# ad-hoc signed in place, then the whole bundle re-signed. +# - iOS/tvOS: assets into /mods/ and the dylib into Frameworks/.dylib. +# - Android: nothing here; gradle packs ${CMAKE_BINARY_DIR}/bundled_mods into APK assets. +function(install_bundled_mods) + get_property(_targets GLOBAL PROPERTY DUSK_BUNDLED_MOD_TARGETS) + if (NOT _targets OR ANDROID) + return () + endif () + get_property(_ids GLOBAL PROPERTY DUSK_BUNDLED_MOD_IDS) + get_property(_stages GLOBAL PROPERTY DUSK_BUNDLED_MOD_STAGES) + get_property(_lib_names GLOBAL PROPERTY DUSK_BUNDLED_MOD_LIB_NAMES) + list(LENGTH _targets _count) + math(EXPR _last "${_count} - 1") + + if (APPLE) + get_target_property(_app_name dusklight OUTPUT_NAME) + if (NOT _app_name) + set(_app_name dusklight) + endif () + set(_bundle_dir "${CMAKE_INSTALL_PREFIX}/${_app_name}.app") + if (IOS OR TVOS) + foreach (_i RANGE ${_last}) + list(GET _targets ${_i} _target) + list(GET _ids ${_i} _id) + list(GET _stages ${_i} _stage) + list(GET _lib_names ${_i} _lib_name) + install(DIRECTORY "${_stage}/" DESTINATION "${_bundle_dir}/mods/${_id}" + PATTERN "${_lib_name}" EXCLUDE) + install(PROGRAMS "$" + DESTINATION "${_bundle_dir}/Frameworks" RENAME "${_id}.dylib") + endforeach () + else () + foreach (_i RANGE ${_last}) + list(GET _ids ${_i} _id) + list(GET _stages ${_i} _stage) + list(GET _lib_names ${_i} _lib_name) + install(DIRECTORY "${_stage}/" DESTINATION "${_bundle_dir}/Contents/Resources/mods/${_id}") + install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - \"${_bundle_dir}/Contents/Resources/mods/${_id}/${_lib_name}\" COMMAND_ERROR_IS_FATAL ANY)") + endforeach () + install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - --entitlements \"${DUSK_ENTITLEMENTS}\" \"${_bundle_dir}\" COMMAND_ERROR_IS_FATAL ANY)") + endif () + return () + endif () + + if (DUSK_PACKAGE_INSTALL) + set(_mods_dest "${CMAKE_INSTALL_DATAROOTDIR}/dusklight/mods") + else () + set(_mods_dest "${CMAKE_INSTALL_PREFIX}/mods") + endif () + if (WIN32) + foreach (_target IN LISTS _targets) + install(FILES "${CMAKE_BINARY_DIR}/bundled_mods/${_target}.dusk" DESTINATION "${_mods_dest}") + endforeach () + else () + foreach (_i RANGE ${_last}) + list(GET _ids ${_i} _id) + list(GET _stages ${_i} _stage) + install(DIRECTORY "${_stage}/" DESTINATION "${_mods_dest}/${_id}") + endforeach () + endif () +endfunction() diff --git a/docs/modding.md b/docs/modding.md new file mode 100644 index 0000000000..12c7881ade --- /dev/null +++ b/docs/modding.md @@ -0,0 +1,259 @@ +# Dusklight Mod API + +Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code libraries and resources. + +Everything a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and mods can define their own to talk to each other. + +## Table of Contents + +1. [Getting Started](#getting-started) +2. [mod.json](#modjson) +3. [Anatomy of a Code Mod](#anatomy-of-a-code-mod) +4. [Services](#services) +5. [Built-in Services](#built-in-services) +6. [Runtime Lifecycle](#runtime-lifecycle) +7. [Error Handling](#error-handling) +8. [Advanced: Exporting Services](#advanced-exporting-services) + +--- + +## Getting Started + +Fork the [mod template](../tools/mod_template/), a self-contained CMake project that uses the Dusklight mod SDK: + +``` +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(DUSKLIGHT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dusklight" CACHE PATH "Path to dusklight source root") +add_subdirectory("${DUSKLIGHT_DIR}/sdk" dusklight-sdk EXCLUDE_FROM_ALL) + +add_mod(my_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res # optional +) +``` + +Building produces `my_mod.dusk` in `build//mods/` (configurable via the `DUSK_MODS_OUTPUT_DIR` cache variable). Copy it into the game's mods folder and launch: + +- Windows: `%APPDATA%\TwilitRealm\Dusklight\mods` +- Linux: `~/.local/share/TwilitRealm/Dusklight/mods` +- macOS: `~/Library/Application Support/TwilitRealm/Dusklight/mods` + +You can also pass `--mods ` on the command line, which is handy during development. Mods will load from there instead of the user directory above. + +--- + +## mod.json + +```json +{ + "id": "com.example.my_mod", + "name": "My Mod", + "version": "1.0.0", + "author": "Your Name", + "description": "A short description shown in the mod manager.", + "icon": "res/my_icon.png", + "banner": "res/my_banner.png" +} +``` + +`id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and alphanumerics). Everything else is optional but recommended. + +`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. 512x512), the banner (roughly 3.5:1). +Both keys are optional; if omitted, `res/icon.png` and `res/banner.png` are used automatically when present. + +--- + +## Anatomy of a Code Mod + +```cpp +#include "mods/service.hpp" +#include "mods/svc/log.h" + +DEFINE_MOD(); // once, in exactly one translation unit +IMPORT_SERVICE(LogService, svc_log); // resolved by the loader before mod_initialize + +extern "C" { + +MOD_EXPORT ModResult mod_initialize(ModError* error) { + svc_log->info(mod_ctx, "hello from my_mod"); + return MOD_OK; +} + +MOD_EXPORT ModResult mod_update(ModError* error) { // called every frame + return MOD_OK; +} + +MOD_EXPORT ModResult mod_shutdown(ModError* error) { + return MOD_OK; +} +} +``` + +All three lifecycle exports are required. `mod_ctx` is your mod's identity token, set by the loader before `mod_initialize` runs. Pass it as the first argument to every service call. + +--- + +## Services + +A service is a struct of C function pointers with a version header. You declare what you use at file scope, and the loader resolves it before your mod initializes: + +```cpp +IMPORT_SERVICE(LogService, svc_log); // required, any minor version +IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor version >= 2 +IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null +``` + +The rules (see `include/mods/api.h` for the full contract): + +- **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear error. No need to null check at call sites. +- **Anything at or below the minor version you imported can be called unconditionally.** +- Optional imports may be null; check once in `mod_initialize`. +- Fields newer than your imported minor must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a null check. + +Service versions follow one rule: a **major** bump is a breaking change (treated as a different service entirely), a **minor** bump only appends functions. + +--- + +## Built-in Services + +### LogService (`mods/svc/log.h`) + +```cpp +IMPORT_SERVICE(LogService, svc_log); + +svc_log->info(mod_ctx, "spawned the thing"); +svc_log->warn(mod_ctx, "that looks wrong"); +svc_log->error(mod_ctx, "very bad"); +svc_log->write(mod_ctx, LOG_LEVEL_DEBUG, "verbose details"); +``` + +Messages appear in the console prefixed with your mod ID. Messages are plain strings: use `snprintf` or `fmt::format` for formatting. + +### HostService (`mods/svc/host.h`) + +Mod metadata and runtime interaction with the loader: + +```cpp +IMPORT_SERVICE(HostService, svc_host); + +const char* id = svc_host->mod_id(mod_ctx); +const char* dir = svc_host->mod_dir(mod_ctx); // writable per-mod directory +svc_host->fail(mod_ctx, MOD_ERROR, "something unrecoverable happened"); // disables the mod +``` + +`get_service`/`publish_service` provide dynamic service lookup; see [Advanced](#advanced-exporting-services). + +--- + +## Runtime Lifecycle + +Mods can be disabled, re-enabled, and reloaded at runtime without restarting the game (the enabled state persists as the `mod..enabled` config var). Write your mod assuming this happens: + +- **Disable** calls `mod_shutdown`, removes your services, and unloads your library. +- **Enable** and **Reload** load a *fresh copy* of your library, imports are re-resolved, and `mod_initialize` runs again. You never see a second `mod_initialize` on the same image, so just make `mod_shutdown` release anything the loader doesn't manage for you (threads, files, game-side state you mutated). +- **Reload** additionally re-reads the `.dusk` from disk, picking up a rebuilt library and changed assets. This is the fast iteration loop during development: rebuild, click Reload. + +**Dependents restart too.** Disabling or reloading a mod that exports services shuts down the mods importing them first (in reverse dependency order) and brings them back afterward. A mod whose *required* provider is disabled stays suspended and resumes automatically when the provider returns. Mods with an *optional* import of a disabled provider restart with that import null. + +--- + +## Error Handling + +Service calls report failure through `ModResult` return values (`MOD_OK`, `MOD_UNAVAILABLE`, `MOD_INVALID_ARGUMENT`, ...). Lifecycle exports additionally receive a `ModError*`: fill it (e.g. with `dusk::mods::set_error(error, code, "message")`) and return the code, and the loader disables the mod and shows the message to the user. + +```cpp +MOD_EXPORT ModResult mod_initialize(ModError* error) { + if (!load_my_data()) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to load data"); + } + return MOD_OK; +} +``` + +Throwing exceptions out of lifecycle functions also disables the mod (they are caught by the loader), but prefer explicit results. + +--- + +## Advanced: Exporting Services + +Mods can provide services to other mods. Define the interface in a header both mods share: + +```cpp +// my_mod_api.h +#include "mods/api.h" + +#define MY_MOD_SERVICE_ID "com.example.my_mod.api" +#define MY_MOD_SERVICE_MAJOR 1u +#define MY_MOD_SERVICE_MINOR 0u + +typedef struct MyModService { + ServiceHeader header; + ModResult (*do_thing)(ModContext* ctx, int value); +} MyModService; + +#ifdef __cplusplus +#include "mods/service.hpp" +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = MY_MOD_SERVICE_ID; + static constexpr uint16_t major_version = MY_MOD_SERVICE_MAJOR; +}; +#endif +``` + +**Provider:** + +```cpp +ModResult do_thing(ModContext* ctx, int value) { ... } + +constexpr MyModService g_service{ + .header = SERVICE_HEADER(MyModService, MY_MOD_SERVICE_MAJOR, MY_MOD_SERVICE_MINOR), + .do_thing = do_thing, +}; +EXPORT_SERVICE(g_service); +``` + +**Consumer:** + +```cpp +IMPORT_SERVICE(MyModService, svc_my_mod); +// or IMPORT_OPTIONAL_SERVICE if the dependency is optional + +svc_my_mod->do_thing(mod_ctx, 42); +``` + +The loader registers all exports before resolving any imports, so declaration order between mods doesn't matter. Note that the `ctx` a provider receives identifies the *calling* mod. + +### Dependencies between mods + +Service imports are also dependency declarations: the loader initializes mods in dependency order, so by the time your `mod_initialize` runs, every mod you import services from (required *or* optional) has already finished its own `mod_initialize`. This includes deferred services: a service the provider publishes during its initialization resolves into your import slot just like a static export. + +Consequences of that contract: + +- If a provider fails to load, every mod that *requires* one of its services is disabled too, with an error naming the provider. Optional imports of a failed provider simply resolve to `NULL`. +- Mods whose **required** imports form a cycle all fail to load. If the cycle runs through an **optional** import, the loader breaks it there: the optional import still resolves, but its provider may not be initialized yet when you run. +- `svc_host->get_service(...)` is outside this system. It sees whatever is published at call time and gives no initialization-order guarantee, which also makes it the escape hatch for intentionally cyclic designs. + +Mods shut down in reverse initialization order, so services you import remain safe to call from `mod_shutdown`. + +Rules for providers: + +- Service IDs are global and use reverse-DNS names (e.g. `com.mydomain.mod.service`) +- Every function pointer covered by your declared minor version must be populated. +- Within a major version, only append fields; never reorder, remove, or repurpose them. Breaking changes require a major bump (which is, in effect, a new service). +- Only one provider per `(id, major)` pair may be registered; duplicates are load errors. + +For services whose construction can't happen at static-init time, declare the export with `EXPORT_DEFERRED_SERVICE(...)` and publish the pointer later via `svc_host->publish_service(...)`. Consumers can fetch services dynamically with `svc_host->get_service(...)`; prefer manifest imports whenever possible, since they give the loader dependency information and fail fast with good errors. diff --git a/files.cmake b/files.cmake index f891024e8a..fcf603d360 100644 --- a/files.cmake +++ b/files.cmake @@ -1541,6 +1541,19 @@ set(DUSK_FILES src/dusk/OSReport.cpp src/dusk/OSThread.cpp src/dusk/OSMutex.cpp + src/dusk/mods/loader/bundle_disk.cpp + src/dusk/mods/loader/bundle_zip.cpp + src/dusk/mods/loader/context.cpp + src/dusk/mods/loader/depgraph.cpp + src/dusk/mods/loader/depgraph.hpp + src/dusk/mods/loader/loader.cpp + src/dusk/mods/loader/loader.hpp + src/dusk/mods/loader/native_module.cpp + src/dusk/mods/loader/native_module.hpp + src/dusk/mods/svc/host.cpp + src/dusk/mods/svc/log.cpp + src/dusk/mods/svc/registry.cpp + src/dusk/mods/svc/registry.hpp src/dusk/discord.cpp src/dusk/discord.hpp src/dusk/discord_presence.cpp diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp new file mode 100644 index 0000000000..2a67e209f7 --- /dev/null +++ b/include/dusk/mod_loader.hpp @@ -0,0 +1,245 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "dusk/config.hpp" +#include "dusk/config_var.hpp" +#include "mods/api.h" + +namespace dusk::mods { +struct LoadedMod; +class ModBundle; +} // namespace dusk::mods + +struct ModContext { + dusk::mods::LoadedMod* mod = nullptr; +}; + +namespace dusk::mods::loader { +class NativeModule; +} // namespace dusk::mods::loader + +namespace dusk::mods { + +struct ModDependencyEdge { + LoadedMod* mod = nullptr; + bool required = false; +}; + +struct ModManifestInfo { + struct Import { + std::string id; + uint16_t major = 0; + bool required = false; + bool operator==(const Import&) const = default; + }; + struct Export { + std::string id; + uint16_t major = 0; + bool operator==(const Export&) const = default; + }; + std::vector imports; + std::vector exports; + bool operator==(const ModManifestInfo&) const = default; +}; + +struct ModMetadata { + std::string id; + std::string name; + std::string version; + std::string author; + std::string description; + std::string iconPath; + std::string bannerPath; +}; + +struct ModSearchDir { + std::filesystem::path path; + // Directory bundles dlopen their native lib in place instead of extracting it to the cache. + // Required where extracted code cannot run (iOS), desirable for signed/read-only installs. + bool inPlaceNative = false; + // Native library location for platforms that restrict placement (e.g. iOS/tvOS Frameworks/) + std::filesystem::path nativeLibDir; +}; + +struct NativeMod { + std::unique_ptr handle; + const ModManifest* manifest = nullptr; + ModContext** contextSymbol = nullptr; + + ModInitializeFn fn_initialize = nullptr; + ModUpdateFn fn_update = nullptr; + ModShutdownFn fn_shutdown = 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 ships native libraries, but none matches this build's platform and architecture. + */ + ModMissingPlatform, + + /** + * Mod is built for a different ABI version than this build of the game. + */ + ApiVersionMismatch, + + /** + * Mod is missing a required native API export. + */ + MissingExport, + + /** + * Unknown error loading the native mod. + */ + Unknown, +}; + +struct LoadedMod { + ModMetadata metadata; + std::string modPath; + std::string dir; + + uint32_t searchDirIndex = 0; + // Native lib is dlopen'd in place and stays resident for the session. Reload is unsupported. + bool inPlace = false; + + std::unique_ptr > cvarIsEnabled; + config::Subscription enabledSubscription = 0; + + bool active = false; + bool loadFailed = false; + std::string failureReason; + + // mod_initialize succeeded; a mod_shutdown is owed on deactivation. + bool initialized = false; + // Static service exports are currently present in the registry. + bool servicesRegistered = false; + // Lifecycle state last applied by the loader; diffed against cvarIsEnabled to pick up + // runtime enable/disable requests. + bool enabledApplied = false; + // Deactivated because a provider it imports from was disabled, not by its own cvar. + bool suspendedByProvider = false; + // Bumped per native lib extraction so every dlopen sees a fresh path (and thus a fresh + // image with fresh statics; a previous dlclose may not fully unmap). Also bumped by + // asset-only reloads, so it doubles as a generation for anything caching per-mod content. + uint32_t cacheGeneration = 0; + // Currently extracted native library, empty if none. + std::string nativePath; + + NativeModStatus nativeStatus = NativeModStatus::None; + std::unique_ptr native; + std::unique_ptr context; + + // Shared so in-flight readers keep their bundle alive across disable/reload. + std::shared_ptr bundle; + + ModManifestInfo manifestInfo; + + // Mods this mod imports services from, and mods importing services from this mod. + std::vector dependencies; + std::vector dependents; +}; + +class ModLoader { +public: + static ModLoader& instance(); + + void set_search_dirs(std::vector dirs) { m_searchDirs = std::move(dirs); } + void set_cache_dir(std::filesystem::path dir) { m_cacheDir = std::move(dir); } + void init(); + void tick(); + void shutdown(); + + void request_enable(std::string_view id); + void request_disable(std::string_view id); + void request_reload(std::string_view id); + void notify_mod_failure(LoadedMod& mod); + + [[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: + enum class RequestKind : u8 { Enable, Disable, Reload }; + struct Request { + std::string modId; + RequestKind kind; + }; + // ModLoader::tick runs inside fapGm_Execute, so code from an unloading mod can still be + // live on the stack (its frame unwinds after the tick). dlclose is therefore deferred to + // the next tick, by which point every per-frame entry into the mod should have returned. + struct RetiredNative { + std::unique_ptr native; + std::string path; + }; + + std::vector > m_mods; + std::vector m_searchDirs; + std::filesystem::path m_cacheDir; + std::vector m_pendingRequests; + std::vector m_retiredNatives; + bool m_initialized = false; + bool m_startupComplete = false; + + void try_load_mod(const std::filesystem::path& modPath, bool fromDir, uint32_t searchDirIndex); + void load_native(LoadedMod& mod, const std::string& dllEntry); + // Resolved / if it exists on disk, empty otherwise. + [[nodiscard]] std::filesystem::path external_native_lib_path(const LoadedMod& mod) const; + void unload_native(LoadedMod& mod); + // Registers exports (if needed), resolves imports and runs mod_initialize. + // Returns whether the mod ended up active; failures go through fail_mod. + bool activate_mod(LoadedMod& mod); + // Runs mod_shutdown (if needed), removes the mod's services, and unloads the native lib. + // Must only run with no mod code on the stack (startup, shutdown, or top of tick). + void deactivate_mod(LoadedMod& mod); + void init_services(); + bool register_static_service_exports(LoadedMod& mod); + bool resolve_service_imports(LoadedMod& mod); + [[nodiscard]] std::string describe_missing_import( + const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion) const; + void clear_services(); + void fail_mod(LoadedMod& mod, ModResult code, std::string_view message); + + LoadedMod* find_mod(std::string_view id); + void drain_retired_natives(); + void apply_pending_requests(); + void on_enabled_changed(LoadedMod& mod); + // Deactivates `target` (if needed) and its transitive dependents, optionally re-reads the + // bundle from disk, then reactivates whatever the current cvar/provider state allows. + void apply_lifecycle_change(LoadedMod& target, bool reload); + // `target` plus transitive active/suspended dependents, in m_mods (init) order. + std::vector collect_lifecycle_set(LoadedMod& target); + bool reload_bundle(LoadedMod& mod); + bool ensure_native_loaded(LoadedMod& mod); +}; + +using ModIndex = std::ranges::range_difference_t().mods())>; + +} // namespace dusk::mods diff --git a/include/mods/api.h b/include/mods/api.h new file mode 100644 index 0000000000..85e2f76545 --- /dev/null +++ b/include/mods/api.h @@ -0,0 +1,152 @@ +#pragma once + +#ifdef __cplusplus +#include +#include +extern "C" { +#else +#include +#include +#include +#include +#endif + +#if defined(_WIN32) +#define MOD_EXPORT __declspec(dllexport) +#else +#define MOD_EXPORT __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +#define MOD_EXTERN_C extern "C" +#else +#define MOD_EXTERN_C +#endif + +#define MOD_ABI_VERSION 5u +#define MOD_ERROR_MESSAGE_SIZE 512u + +typedef struct ModContext ModContext; + +typedef enum ModResult { + MOD_OK = 0, + MOD_ERROR = 1, + MOD_UNAVAILABLE = 2, + MOD_UNSUPPORTED = 3, + MOD_CONFLICT = 4, + MOD_INVALID_ARGUMENT = 5, +} ModResult; + +static_assert(sizeof(ModResult) == 4, "mod SDK enums must be int-sized; do not build mods with -fshort-enums"); + +typedef struct ModError { + uint32_t struct_size; + ModResult code; + char message[MOD_ERROR_MESSAGE_SIZE]; +} ModError; + +#define MOD_ERROR_INIT {sizeof(ModError), MOD_OK, {0}} + +/* + * Opaque per-mod context, populated by the host before mod_initialize is called. + * Pass it as the first argument to every service call; it identifies the calling + * mod for attribution (logging, resource lookup, hook ownership, etc.). + */ +MOD_EXPORT extern ModContext* mod_ctx; + +/* + * Service versioning contract: + * + * A service is a struct of function pointers, beginning with a ServiceHeader. + * Compatibility is tracked with a major/minor version pair: + * + * - A major version bump is a breaking change. Different majors are distinct + * services; the registry never matches an import against a different major. + * - A minor version bump may only append fields to the end of the struct. + * Existing fields must keep their offsets and semantics. + * + * Providers: exporting minor N means every function pointer introduced at or + * below N is populated (non-NULL). struct_size reflects the compiled struct. + * + * Importers: importing with min_minor_version N guarantees (enforced at load + * time) that the resolved service is at least minor N, so any field introduced + * at or below N may be used unconditionally, with no availability checks. + * Fields newer than the declared min_minor_version must be gated behind + * SERVICE_HAS plus a NULL check on the pointer itself. + * + * Load ordering: a manifest import of another mod's service (required or + * optional) guarantees that the provider's mod_initialize completed before the + * importer's runs, and deferred services published during the provider's + * initialization resolve into import slots just like static exports. If a + * provider fails to load, mods that required its services fail in turn. Mods + * whose required imports form a cycle all fail to load; a cycle involving an + * optional import is broken by dropping the ordering guarantee (not the + * resolution) of that optional import. Dynamic lookups via + * HostService::get_service carry no ordering guarantee: they see whatever has + * been published at call time. + */ +typedef struct ServiceHeader { + uint32_t struct_size; + uint16_t major_version; + uint16_t minor_version; +} ServiceHeader; + +#define SERVICE_HEADER(service_type, major, minor) {sizeof(service_type), (major), (minor)} + +#define SERVICE_HAS(service, service_type, field) \ + ((service) != NULL && \ + (service)->header.struct_size >= \ + (uint32_t)(offsetof(service_type, field) + sizeof(((service_type*)0)->field))) + +typedef enum ServiceImportFlags { + SERVICE_IMPORT_REQUIRED = 0u, + SERVICE_IMPORT_OPTIONAL = 1u << 0u, +} ServiceImportFlags; + +typedef enum ServiceExportFlags { + SERVICE_EXPORT_STATIC = 0u, + SERVICE_EXPORT_DEFERRED = 1u << 0u, +} ServiceExportFlags; + +typedef struct ServiceImport { + uint32_t struct_size; + const char* service_id; + uint16_t major_version; + uint16_t min_minor_version; + uint32_t flags; + void* slot; +} ServiceImport; + +typedef struct ServiceExport { + uint32_t struct_size; + const char* service_id; + uint16_t major_version; + uint16_t minor_version; + uint32_t flags; + const void* service; +} ServiceExport; + +typedef struct ModManifest { + uint32_t struct_size; + uint32_t abi_version; + const ServiceImport* imports; + size_t import_count; + const ServiceExport* exports; + size_t export_count; +} ModManifest; + +typedef const ModManifest* (*ModGetManifestFn)(void); + +typedef ModResult (*ModInitializeFn)(ModError* out_error); +typedef ModResult (*ModUpdateFn)(ModError* out_error); +typedef ModResult (*ModShutdownFn)(ModError* out_error); + +MOD_EXPORT const ModManifest* mod_get_manifest(void); + +MOD_EXPORT ModResult mod_initialize(ModError* out_error); +MOD_EXPORT ModResult mod_update(ModError* out_error); +MOD_EXPORT ModResult mod_shutdown(ModError* out_error); + +#ifdef __cplusplus +} +#endif diff --git a/include/mods/service.hpp b/include/mods/service.hpp new file mode 100644 index 0000000000..d79b53f24d --- /dev/null +++ b/include/mods/service.hpp @@ -0,0 +1,138 @@ +#pragma once + +#include "mods/api.h" + +#include +#include +#include +#include + +namespace dusk::mods { + +template +struct ServiceTraits; + +namespace detail { + +inline std::vector& imports() { + static std::vector entries; + return entries; +} + +inline std::vector& exports() { + static std::vector entries; + return entries; +} + +inline int register_import(ServiceImport entry) { + imports().push_back(entry); + return 0; +} + +inline int register_export(ServiceExport entry) { + exports().push_back(entry); + return 0; +} + +inline const ModManifest* manifest() { + static ModManifest manifest{ + sizeof(ModManifest), + MOD_ABI_VERSION, + nullptr, + 0, + nullptr, + 0, + }; + + auto& importEntries = imports(); + auto& exportEntries = exports(); + manifest.imports = importEntries.data(); + manifest.import_count = importEntries.size(); + manifest.exports = exportEntries.data(); + manifest.export_count = exportEntries.size(); + return &manifest; +} + +} // namespace detail + +inline ModResult set_error(ModError* outError, ModResult code, const char* message) { + if (outError != nullptr && outError->struct_size >= sizeof(ModError)) { + outError->code = code; + outError->message[0] = '\0'; + if (message != nullptr) { + std::snprintf(outError->message, sizeof(outError->message), "%s", message); + } + } + return code; +} + +} // namespace dusk::mods + +#define DEFINE_MOD() \ + extern "C" { \ + MOD_EXPORT ModContext* mod_ctx = nullptr; \ + MOD_EXPORT const ModManifest* mod_get_manifest(void) { \ + return ::dusk::mods::detail::manifest(); \ + } \ + } + +// Declares `static const service_type* variable`, filled in by the host before mod_initialize. +// Required imports are guaranteed non-null (the mod fails to load otherwise); optional imports +// must be checked against nullptr before use. +#define IMPORT_SERVICE_EX( \ + service_type, variable, service_id_value, major_value, min_minor_value, flags_value) \ + static const service_type* variable = nullptr; \ + [[maybe_unused]] static const int mod_import_registration_##variable = \ + ::dusk::mods::detail::register_import(ServiceImport{ \ + sizeof(ServiceImport), \ + (service_id_value), \ + static_cast(major_value), \ + static_cast(min_minor_value), \ + static_cast(flags_value), \ + &(variable), \ + }) + +#define IMPORT_SERVICE_VERSION(service_type, variable, min_minor_value) \ + IMPORT_SERVICE_EX(service_type, variable, ::dusk::mods::ServiceTraits::id, \ + ::dusk::mods::ServiceTraits::major_version, min_minor_value, \ + SERVICE_IMPORT_REQUIRED) + +#define IMPORT_SERVICE(service_type, variable) IMPORT_SERVICE_VERSION(service_type, variable, 0) + +#define IMPORT_OPTIONAL_SERVICE_VERSION(service_type, variable, min_minor_value) \ + IMPORT_SERVICE_EX(service_type, variable, ::dusk::mods::ServiceTraits::id, \ + ::dusk::mods::ServiceTraits::major_version, min_minor_value, \ + SERVICE_IMPORT_OPTIONAL) + +#define IMPORT_OPTIONAL_SERVICE(service_type, variable) \ + IMPORT_OPTIONAL_SERVICE_VERSION(service_type, variable, 0) + +#define EXPORT_SERVICE_AS(instance, service_id_value) \ + namespace { \ + const int mod_export_registration_##instance = \ + ::dusk::mods::detail::register_export(ServiceExport{ \ + sizeof(ServiceExport), \ + (service_id_value), \ + (instance).header.major_version, \ + (instance).header.minor_version, \ + SERVICE_EXPORT_STATIC, \ + &(instance), \ + }); \ + } + +#define EXPORT_SERVICE(instance) \ + EXPORT_SERVICE_AS( \ + instance, ::dusk::mods::ServiceTraits >::id) + +#define EXPORT_DEFERRED_SERVICE(token, service_id_value, major_value, minor_value) \ + namespace { \ + const int mod_deferred_export_registration_##token = \ + ::dusk::mods::detail::register_export(ServiceExport{ \ + sizeof(ServiceExport), \ + (service_id_value), \ + static_cast(major_value), \ + static_cast(minor_value), \ + SERVICE_EXPORT_DEFERRED, \ + nullptr, \ + }); \ + } diff --git a/include/mods/svc/host.h b/include/mods/svc/host.h new file mode 100644 index 0000000000..6f5844a88f --- /dev/null +++ b/include/mods/svc/host.h @@ -0,0 +1,63 @@ +#pragma once + +#include "mods/api.h" + +/* + * The host service: the calling mod's identity and its runtime interface to the loader. + * Always available; every other service can be reached from it. + */ + +#define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" +#define HOST_SERVICE_MAJOR 1u +#define HOST_SERVICE_MINOR 0u + +typedef struct HostService { + ServiceHeader header; + + /* + * Look up a service by id at call time. Unlike a manifest import, this sees whatever is + * currently published and carries no initialization-order guarantee (see mods/api.h). + * MOD_UNAVAILABLE if no matching service is published; *out_service is null on failure. + */ + ModResult (*get_service)(ModContext* ctx, const char* service_id, uint16_t major_version, + uint16_t min_minor_version, const void** out_service); + + /* + * Publish a service the calling mod declared as a deferred export in its manifest. + * Must happen during mod_initialize so importers can resolve it; `service` must stay + * valid until the mod shuts down. + */ + ModResult (*publish_service)( + ModContext* ctx, const char* service_id, uint16_t major_version, const void* service); + + /* + * Report an unrecoverable failure. The calling mod's services stop resolving immediately + * and the loader fully disables it at the next safe point; `message` is shown to the user. + * Safe to call from any mod callback. + */ + void (*fail)(ModContext* ctx, ModResult code, const char* message); + + /* + * The calling mod's manifest metadata. Returned strings remain valid while the mod is + * loaded. + */ + const char* (*mod_id)(ModContext* ctx); + const char* (*mod_name)(ModContext* ctx); + const char* (*mod_version)(ModContext* ctx); + + /* + * A writable scratch directory reserved for the calling mod. Contents survive disable + * and reload within a session, but the directory is wiped at game startup. + */ + const char* (*mod_dir)(ModContext* ctx); +} HostService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = HOST_SERVICE_ID; + static constexpr uint16_t major_version = HOST_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/log.h b/include/mods/svc/log.h new file mode 100644 index 0000000000..bad94f9503 --- /dev/null +++ b/include/mods/svc/log.h @@ -0,0 +1,47 @@ +#pragma once + +#include "mods/api.h" + +/* + * Logging into the game's console and log files. Messages are attributed to the calling mod + * (prefixed with its ID). + */ + +#define LOG_SERVICE_ID "dev.twilitrealm.dusklight.log" +#define LOG_SERVICE_MAJOR 1u +#define LOG_SERVICE_MINOR 0u + +typedef enum LogLevel { + LOG_LEVEL_TRACE = 0, + LOG_LEVEL_DEBUG = 1, + LOG_LEVEL_INFO = 2, + LOG_LEVEL_WARN = 3, + LOG_LEVEL_ERROR = 4, +} LogLevel; + +typedef struct LogService { + ServiceHeader header; + + /* + * Write a log message at the given level. + * `message` is a plain UTF-8 string and is copied before returning. + */ + void (*write)(ModContext* ctx, LogLevel level, const char* message); + + /* Per-level shorthands for write. */ + void (*trace)(ModContext* ctx, const char* message); + void (*debug)(ModContext* ctx, const char* message); + void (*info)(ModContext* ctx, const char* message); + void (*warn)(ModContext* ctx, const char* message); + void (*error)(ModContext* ctx, const char* message); +} LogService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = LOG_SERVICE_ID; + static constexpr uint16_t major_version = LOG_SERVICE_MAJOR; +}; +#endif diff --git a/platforms/macos/Dusklight.entitlements b/platforms/macos/Dusklight.entitlements new file mode 100644 index 0000000000..123d12a53e --- /dev/null +++ b/platforms/macos/Dusklight.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt new file mode 100644 index 0000000000..63953e78bf --- /dev/null +++ b/sdk/CMakeLists.txt @@ -0,0 +1,33 @@ +# Dusklight Mod SDK entry point +# +# Provides game/service headers, compile definitions and version.h without +# configuring the full game tree. +# +# Usage (from a mod project): +# add_subdirectory(/sdk dusk-sdk EXCLUDE_FROM_ALL) +# add_mod(my_mod SOURCES ... MOD_JSON mod.json) + +cmake_minimum_required(VERSION 3.25) + +if (TARGET dusklight_game_headers) + message(FATAL_ERROR "Mod SDK already configured") +endif () + +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/WindowsTargetProcessor.cmake") + +if (NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE "RelWithDebInfo" CACHE STRING + "Build type options: Debug Release RelWithDebInfo MinSizeRel" FORCE) +endif () + +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# Version detection & version.h +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/DetectVersion.cmake") +detect_version() +configure_version_header() + +# Game ABI headers & compile definitions +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/GameABIConfig.cmake") + +include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/ModSDK.cmake") diff --git a/src/dusk/data.cpp b/src/dusk/data.cpp index 40e7c30a21..69ee5bebc8 100644 --- a/src/dusk/data.cpp +++ b/src/dusk/data.cpp @@ -121,14 +121,6 @@ std::filesystem::path active_pref_path() { return get_pref_path(); } -std::filesystem::path base_path_relative(const std::filesystem::path& path) { - const auto* basePath = SDL_GetBasePath(); - if (!basePath) { - return path; - } - return path_from_utf8(basePath) / path; -} - std::filesystem::path default_data_path(const std::filesystem::path& prefPath) { #ifdef __APPLE__ #if TARGET_OS_IOS && !TARGET_OS_TV @@ -888,6 +880,14 @@ void ensure_data_directory(const std::filesystem::path& dataPath) { } // namespace +std::filesystem::path base_path_relative(const std::filesystem::path& path) { + const auto* basePath = SDL_GetBasePath(); + if (!basePath) { + return path; + } + return path_from_utf8(basePath) / path; +} + bool open_data_path() { #if DUSK_CAN_OPEN_DATA_FOLDER std::error_code ec; diff --git a/src/dusk/data.hpp b/src/dusk/data.hpp index bf20035a2d..7e7861dbf0 100644 --- a/src/dusk/data.hpp +++ b/src/dusk/data.hpp @@ -29,6 +29,7 @@ struct Paths { }; Paths initialize_data(); +std::filesystem::path base_path_relative(const std::filesystem::path& path); std::filesystem::path configured_data_path(); std::filesystem::path cache_path(); bool open_data_path(); diff --git a/src/dusk/mods/loader/bundle_disk.cpp b/src/dusk/mods/loader/bundle_disk.cpp new file mode 100644 index 0000000000..26e1bbd722 --- /dev/null +++ b/src/dusk/mods/loader/bundle_disk.cpp @@ -0,0 +1,60 @@ +#include + +#include "dusk/io.hpp" +#include "loader.hpp" + +namespace fs = std::filesystem; + +namespace dusk::mods { +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::mods diff --git a/src/dusk/mods/loader/bundle_zip.cpp b/src/dusk/mods/loader/bundle_zip.cpp new file mode 100644 index 0000000000..517c5943c2 --- /dev/null +++ b/src/dusk/mods/loader/bundle_zip.cpp @@ -0,0 +1,68 @@ +#include "fmt/format.h" +#include "loader.hpp" + +#include + +namespace dusk::mods { + +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) { + std::lock_guard lock{m_mutex}; + 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::lock_guard lock{m_mutex}; + 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) { + std::lock_guard lock{m_mutex}; + 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::mods diff --git a/src/dusk/mods/loader/context.cpp b/src/dusk/mods/loader/context.cpp new file mode 100644 index 0000000000..99417117d6 --- /dev/null +++ b/src/dusk/mods/loader/context.cpp @@ -0,0 +1,70 @@ +#include "loader.hpp" + +#include "dusk/logging.h" +#include "dusk/mods/svc/registry.hpp" +#include "dusk/ui/ui.hpp" +#include "fmt/format.h" + +#include + +namespace dusk::mods { + +LoadedMod* mod_from_context(ModContext* context) { + return context != nullptr ? context->mod : nullptr; +} + +const LoadedMod* mod_from_context(const ModContext* context) { + return context != nullptr ? context->mod : nullptr; +} + +const char* mod_id_from_context(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.id.c_str() : "mod"; +} + +bool is_safe_resource_path(std::string_view path) { + if (path.empty() || path.starts_with('/') || path.starts_with('\\') || + path.find(':') != std::string_view::npos) + { + return false; + } + + while (!path.empty()) { + const auto slash = path.find_first_of("/\\"); + const auto segment = path.substr(0, slash); + if (segment.empty() || segment == "." || segment == "..") { + return false; + } + if (slash == std::string_view::npos) { + break; + } + path.remove_prefix(slash + 1); + } + + return true; +} + +void fail_mod(LoadedMod& mod, ModResult code, std::string_view message) { + const bool firstFailure = !mod.loadFailed; + mod.active = false; + mod.loadFailed = true; + mod.failureReason = message; + // Stop the failed mod's services from resolving; mods that required them fail in turn. + // Pointers already handed to other mods stay callable since the library remains loaded. + // Nothing else is torn down here: fail_mod can run mid-frame (e.g. from a failing mod + // callback), and full teardown happens via deactivate_mod at a safe point. + svc::remove_services_for_provider(mod); + mod.servicesRegistered = false; + ModLoader::instance().notify_mod_failure(mod); + DuskLog.error("[{}] disabled: {} ({})", mod.metadata.id, message, static_cast(code)); + if (firstFailure) { + ui::push_toast({ + .type = "warning", + .title = "Mod Disabled", + .content = ui::escape(fmt::format("{}: {}", mod.metadata.name, message)), + .duration = std::chrono::seconds(5), + }); + } +} + +} // namespace dusk::mods::loader diff --git a/src/dusk/mods/loader/depgraph.cpp b/src/dusk/mods/loader/depgraph.cpp new file mode 100644 index 0000000000..20c831075a --- /dev/null +++ b/src/dusk/mods/loader/depgraph.cpp @@ -0,0 +1,207 @@ +#include "depgraph.hpp" + +#include +#include + +#include "dusk/logging.h" +#include "loader.hpp" +#include "native_module.hpp" + +static aurora::Module Log("dusk::modLoader"); + +namespace dusk::mods::loader { + +namespace { + +struct Edge { + size_t provider; + size_t importer; + bool required; + bool alive = true; +}; + +std::vector collect_edges(const std::vector>& mods) { + for (auto& mod : mods) { + mod->dependencies.clear(); + mod->dependents.clear(); + } + + // Mirrors the registry's first-registration-wins rule for duplicate exports. + const auto findProvider = [&](const ModManifestInfo::Import& serviceImport) -> size_t { + for (size_t i = 0; i < mods.size(); ++i) { + const auto matches = [&](const ModManifestInfo::Export& serviceExport) { + return serviceExport.major == serviceImport.major && + serviceExport.id == serviceImport.id; + }; + if (std::ranges::any_of(mods[i]->manifestInfo.exports, matches)) { + return i; + } + } + return mods.size(); // Host-provided or unavailable: no ordering constraint. + }; + + std::vector edges; + for (size_t importer = 0; importer < mods.size(); ++importer) { + auto& mod = *mods[importer]; + for (const auto& serviceImport : mod.manifestInfo.imports) { + const size_t provider = findProvider(serviceImport); + if (provider >= mods.size() || provider == importer) { + continue; + } + auto& providerMod = *mods[provider]; + const bool required = serviceImport.required; + + const auto existing = std::ranges::find_if(edges, [&](const Edge& edge) { + return edge.provider == provider && edge.importer == importer; + }); + if (existing != edges.end()) { + if (required && !existing->required) { + existing->required = true; + for (auto& dep : mod.dependencies) { + if (dep.mod == &providerMod) { + dep.required = true; + } + } + for (auto& dep : providerMod.dependents) { + if (dep.mod == &mod) { + dep.required = true; + } + } + } + } else { + edges.push_back({provider, importer, required}); + mod.dependencies.push_back({&providerMod, required}); + providerMod.dependents.push_back({&mod, required}); + } + } + } + return edges; +} + +// True if `start` can reach itself following live required edges through unplaced mods. +bool in_required_cycle( + const size_t start, const std::vector& edges, const std::vector& placed) { + std::vector pending{start}; + std::vector visited(placed.size(), false); + while (!pending.empty()) { + const size_t current = pending.back(); + pending.pop_back(); + for (const auto& edge : edges) { + if (!edge.alive || !edge.required || edge.provider != current || placed[edge.importer]) + { + continue; + } + if (edge.importer == start) { + return true; + } + if (!visited[edge.importer]) { + visited[edge.importer] = true; + pending.push_back(edge.importer); + } + } + } + return false; +} + +} // namespace + +void sort_mods(std::vector>& mods) { + const size_t count = mods.size(); + auto edges = collect_edges(mods); + if (edges.empty()) { + return; + } + + std::vector indegree(count, 0); + for (const auto& edge : edges) { + ++indegree[edge.importer]; + } + + std::vector order; + order.reserve(count); + std::vector placed(count, false); + + const auto place = [&](const size_t index) { + placed[index] = true; + order.push_back(index); + for (auto& edge : edges) { + if (edge.alive && edge.provider == index) { + edge.alive = false; + --indegree[edge.importer]; + } + } + }; + + while (order.size() < count) { + // Always take the lowest unplaced scan index that is ready, keeping the + // final order as close to scan (filename) order as the graph allows. + const auto ready = [&]() -> size_t { + for (size_t i = 0; i < count; ++i) { + if (!placed[i] && indegree[i] == 0) { + return i; + } + } + return count; + }(); + if (ready < count) { + place(ready); + continue; + } + + // Stalled: every unplaced mod is on or downstream of a cycle. + std::vector cycleMods; + for (size_t i = 0; i < count; ++i) { + if (!placed[i] && in_required_cycle(i, edges, placed)) { + cycleMods.push_back(i); + } + } + if (!cycleMods.empty()) { + std::string names; + for (const size_t index : cycleMods) { + if (!names.empty()) { + names += ", "; + } + names += mods[index]->metadata.id; + } + for (const size_t index : cycleMods) { + fail_mod(*mods[index], MOD_CONFLICT, + "required service import cycle between mods: " + names); + place(index); + } + continue; + } + + // Only optional imports left in the cycle: drop one edge and retry. The + // import still resolves, but without any initialization-order guarantee. + const auto optionalEdge = std::ranges::find_if(edges, [&](const Edge& edge) { + return edge.alive && !edge.required && !placed[edge.provider] && + !placed[edge.importer]; + }); + if (optionalEdge == edges.end()) { + // Unreachable: a stall with no required cycle implies an optional edge. + Log.error("mod dependency sort stalled unexpectedly"); + break; + } + Log.warn("optional service import cycle: '{}' will initialize before its optional " + "provider '{}'", + mods[optionalEdge->importer]->metadata.id, mods[optionalEdge->provider]->metadata.id); + optionalEdge->alive = false; + --indegree[optionalEdge->importer]; + } + + // Defensive: append anything a stall break left behind, in scan order. + for (size_t i = 0; i < count; ++i) { + if (!placed[i]) { + place(i); + } + } + + std::vector> sorted; + sorted.reserve(count); + for (const size_t index : order) { + sorted.push_back(std::move(mods[index])); + } + mods = std::move(sorted); +} + +} // namespace dusk::mods::loader diff --git a/src/dusk/mods/loader/depgraph.hpp b/src/dusk/mods/loader/depgraph.hpp new file mode 100644 index 0000000000..a6f5b30d50 --- /dev/null +++ b/src/dusk/mods/loader/depgraph.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +#include "dusk/mod_loader.hpp" + +namespace dusk::mods::loader { + +// Reorders mods so service providers precede their importers, populating +// LoadedMod::dependencies/dependents from manifest imports along the way. +// Mods whose required imports form a cycle are failed; a cycle that can be +// broken by dropping an optional import is broken with a warning. Scan order +// is preserved between mods with no dependency relationship. +void sort_mods(std::vector>& mods); + +} // namespace dusk::mods::loader diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp new file mode 100644 index 0000000000..769ec28e87 --- /dev/null +++ b/src/dusk/mods/loader/loader.cpp @@ -0,0 +1,1088 @@ +#include "loader.hpp" +#include "dusk/logging.h" +#include "dusk/mod_loader.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "depgraph.hpp" +#include "dusk/config.hpp" +#include "dusk/io.hpp" +#include "dusk/mods/svc/registry.hpp" +#include "miniz.h" +#include "native_module.hpp" +#include "nlohmann/json.hpp" + +static aurora::Module Log("dusk::modLoader"); + +using namespace std::string_literals; +using namespace std::string_view_literals; + +#if defined(_WIN32) +#if defined(_M_ARM64) +static constexpr std::string_view k_nativeLibName = "windows-arm64.dll"sv; +#elif defined(_M_X64) +static constexpr std::string_view k_nativeLibName = "windows-amd64.dll"sv; +#elif defined(_M_IX86) +static constexpr std::string_view k_nativeLibName = "windows-x86.dll"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#elif defined(__ANDROID__) +#if defined(__aarch64__) +static constexpr std::string_view k_nativeLibName = "android-aarch64.so"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativeLibName = "android-x86_64.so"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#elif defined(__APPLE__) +#include +#if TARGET_OS_IOS +static constexpr std::string_view k_nativeLibName = "ios-arm64.dylib"sv; +#elif TARGET_OS_TV +static constexpr std::string_view k_nativeLibName = "tvos-arm64.dylib"sv; +#elif defined(__aarch64__) +static constexpr std::string_view k_nativeLibName = "darwin-arm64.dylib"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativeLibName = "darwin-x86_64.dylib"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#elif defined(__linux__) +#if defined(__aarch64__) +static constexpr std::string_view k_nativeLibName = "linux-aarch64.so"sv; +#elif defined(__x86_64__) +static constexpr std::string_view k_nativeLibName = "linux-x86_64.so"sv; +#elif defined(__i386__) +static constexpr std::string_view k_nativeLibName = "linux-x86.so"sv; +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif +#else +static constexpr std::string_view k_nativeLibName = ""sv; +#endif + +namespace dusk::mods { +namespace { +ModLoader g_modLoader; + +std::unique_ptr load_bundle(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 entry; + bool anyLibs = false; +}; + +DllLocateResult locate_dll_in_bundle(ModBundle& bundle) { + DllLocateResult result; + for (const auto& name : bundle.getFileNames()) { + if (name.find('/') != std::string::npos || + (!name.ends_with(".dll"sv) && !name.ends_with(".dylib"sv) && !name.ends_with(".so"sv))) + { + continue; + } + result.anyLibs = true; + if (name == k_nativeLibName) { + result.entry = name; + } + } + return result; +} +} // namespace + +ModLoader& ModLoader::instance() { + return g_modLoader; +} + +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 validate_mod_id(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 bool bundle_has_file(ModBundle& bundle, const std::string& path) { + try { + bundle.getFileSize(path); + return true; + } catch (const std::runtime_error&) { + return false; + } +} + +static std::string resolve_image_path(ModBundle& bundle, const std::string& modId, + std::string_view key, const std::string& manifestPath, const std::string& defaultPath) { + if (!manifestPath.empty()) { + if (!is_safe_resource_path(manifestPath)) { + Log.warn("{}: invalid {} path '{}' in mod.json", modId, key, manifestPath); + } else if (!bundle_has_file(bundle, manifestPath)) { + Log.warn("{}: {} path '{}' not found in bundle", modId, key, manifestPath); + } else { + return manifestPath; + } + } + if (bundle_has_file(bundle, defaultPath)) { + return defaultPath; + } + return {}; +} + +static ModMetadata load_metadata(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", ""); + std::string metaIcon = j.value("icon", ""); + std::string metaBanner = j.value("banner", ""); + + validate_mod_id(metaId); + + if (metaName.empty()) { + metaName = io::fs_path_to_string(modPath.stem()); + } + if (metaVersion.empty()) { + metaVersion = "?"s; + } + if (metaAuthor.empty()) { + metaAuthor = "unknown"s; + } + + std::string iconPath = resolve_image_path(bundle, metaId, "icon", metaIcon, "res/icon.png"s); + std::string bannerPath = + resolve_image_path(bundle, metaId, "banner", metaBanner, "res/banner.png"s); + + return ModMetadata{ + std::move(metaId), + std::move(metaName), + std::move(metaVersion), + std::move(metaAuthor), + std::move(metaDescription), + std::move(iconPath), + std::move(bannerPath), + }; +} + +static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) { + if (manifest == nullptr) { + Log.error("{} returned a null mod manifest", mod.metadata.id); + mod.nativeStatus = NativeModStatus::MissingExport; + return false; + } + if (manifest->struct_size != sizeof(ModManifest)) { + Log.error("{} manifest has invalid size {} (expected {})", mod.metadata.id, + manifest->struct_size, sizeof(ModManifest)); + mod.nativeStatus = NativeModStatus::ApiVersionMismatch; + return false; + } + if (manifest->abi_version != MOD_ABI_VERSION) { + Log.error("{} expects ABI v{} but engine is v{}, skipping", mod.metadata.id, + manifest->abi_version, MOD_ABI_VERSION); + mod.nativeStatus = NativeModStatus::ApiVersionMismatch; + return false; + } + if ((manifest->import_count > 0 && manifest->imports == nullptr) || + (manifest->export_count > 0 && manifest->exports == nullptr)) + { + Log.error("{} manifest has invalid import/export arrays", mod.metadata.id); + mod.nativeStatus = NativeModStatus::MissingExport; + return false; + } + return true; +} + +static bool validate_context_symbol(ModContext** contextSymbol, LoadedMod& mod) { + if (contextSymbol == nullptr) { + Log.error("{} missing required mod_ctx export", mod.metadata.id); + mod.nativeStatus = NativeModStatus::MissingExport; + return false; + } + return true; +} + +static std::string lifecycle_error_message( + const char* fnName, const ModResult result, const ModError& error) { + if (error.message[0] != '\0') { + return error.message; + } + return fmt::format("{} failed with result {}", fnName, static_cast(result)); +} + +static std::string native_status_message(const NativeModStatus status) { + switch (status) { + case NativeModStatus::BuildDisabled: + return "Code mods are disabled on this Dusklight build"; + case NativeModStatus::ModMissingPlatform: + return fmt::format("Mod not supported on this platform ({})", k_nativeLibName); + case NativeModStatus::ApiVersionMismatch: + // TODO: differentiate whether mod or Dusklight is out of date + return "Mod ABI version mismatch"; + case NativeModStatus::MissingExport: + return "Missing required mod API exports"; + case NativeModStatus::Unknown: + return "Unknown mod load failure"; + case NativeModStatus::None: + case NativeModStatus::Loaded: + break; + } + return "native mod failed to load"; +} + +std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) const { + namespace fs = std::filesystem; + if (k_nativeLibName.empty()) { + return {}; + } + const auto& libDir = m_searchDirs[mod.searchDirIndex].nativeLibDir; + if (libDir.empty()) { + return {}; + } + fs::path path = libDir / fs::path(mod.metadata.id + io::fs_path_to_string( + fs::path(k_nativeLibName).extension())); + std::error_code ec; + if (!fs::is_regular_file(path, ec)) { + return {}; + } + return path; +} + +void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { + if (!EnableCodeMods) { + Log.error("Code mods are not available in this build"); + mod.nativeStatus = NativeModStatus::BuildDisabled; + return; + } + + namespace fs = std::filesystem; + + const fs::path cacheDir = m_cacheDir / mod.metadata.id; + std::error_code ec; + fs::create_directories(cacheDir, ec); + + fs::path libPath; + if (mod.inPlace) { + if (!dllEntry.empty()) { + libPath = fs::path(mod.modPath) / dllEntry; + } else if (auto external = external_native_lib_path(mod); !external.empty()) { + libPath = std::move(external); + } else { + Log.error("no native library named {} found in {}; skipping", k_nativeLibName, + mod.metadata.id); + mod.nativeStatus = NativeModStatus::ModMissingPlatform; + return; + } + } else { + if (dllEntry.empty()) { + Log.error("no native library named {} found in {}; skipping", k_nativeLibName, + mod.metadata.id); + mod.nativeStatus = NativeModStatus::ModMissingPlatform; + return; + } + + // Generation-versioned filename: every dlopen gets a path it has never seen, so a reload + // always yields a fresh image with fresh statics even if the previous dlclose did not + // fully unmap the old one (TLS/ObjC pinning). The .cache dir is wiped on startup. + const fs::path dllCachePath = + cacheDir / fmt::format("{}.g{}{}", mod.metadata.id, ++mod.cacheGeneration, + io::fs_path_to_string(fs::path(dllEntry).extension())); + + std::vector dllData; + try { + dllData = mod.bundle->readFile(dllEntry); + } catch (const std::exception& 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())); + } + + libPath = dllCachePath; + } + + auto nativeMod = std::make_unique(); + try { + nativeMod->handle = std::make_unique(libPath); + } catch (const std::runtime_error& e) { + Log.error("failed to open {}: {}", io::fs_path_to_string(libPath), e.what()); + return; + } + + const auto getManifest = nativeMod->handle->LookupSymbol("mod_get_manifest"); + nativeMod->contextSymbol = nativeMod->handle->LookupSymbol("mod_ctx"); + nativeMod->fn_initialize = nativeMod->handle->LookupSymbol("mod_initialize"); + nativeMod->fn_update = nativeMod->handle->LookupSymbol("mod_update"); + nativeMod->fn_shutdown = nativeMod->handle->LookupSymbol("mod_shutdown"); + + if (!getManifest || !nativeMod->contextSymbol || !nativeMod->fn_initialize || + !nativeMod->fn_update || !nativeMod->fn_shutdown) + { + Log.error("{} missing required mod API exports; skipping", + io::fs_path_to_string(libPath.filename())); + mod.nativeStatus = NativeModStatus::MissingExport; + return; + } + + nativeMod->manifest = getManifest(); + if (!validate_manifest(nativeMod->manifest, mod)) { + return; + } + + if (!validate_context_symbol(nativeMod->contextSymbol, mod)) { + return; + } + *nativeMod->contextSymbol = mod.context.get(); + + mod.dir = io::fs_path_to_string(fs::absolute(cacheDir)); + mod.nativePath = io::fs_path_to_string(fs::absolute(libPath)); + mod.native = std::move(nativeMod); + mod.nativeStatus = NativeModStatus::Loaded; +} + +void ModLoader::unload_native(LoadedMod& mod) { + if (!mod.native || mod.inPlace) { + return; + } + // Deferred dlclose: this mod's code may still be on the stack below the current tick + m_retiredNatives.push_back({std::move(mod.native), std::move(mod.nativePath)}); + mod.nativePath.clear(); +} + +void ModLoader::drain_retired_natives() { + for (auto& retired : m_retiredNatives) { + retired.native.reset(); + if (!retired.path.empty()) { + std::error_code ec; + std::filesystem::remove(retired.path, ec); + } + } + m_retiredNatives.clear(); +} + +static ModManifestInfo build_manifest_info(const ModManifest& manifest) { + ModManifestInfo info; + info.imports.reserve(manifest.import_count); + for (size_t i = 0; i < manifest.import_count; ++i) { + const auto& serviceImport = manifest.imports[i]; + if (serviceImport.struct_size != sizeof(ServiceImport) || + !svc::valid_service_id(serviceImport.service_id)) + { + continue; + } + info.imports.push_back({serviceImport.service_id, serviceImport.major_version, + (serviceImport.flags & SERVICE_IMPORT_OPTIONAL) == 0}); + } + info.exports.reserve(manifest.export_count); + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size != sizeof(ServiceExport) || + !svc::valid_service_id(serviceExport.service_id)) + { + continue; + } + info.exports.push_back({serviceExport.service_id, serviceExport.major_version}); + } + return info; +} + +std::string escape_mod_id_for_config(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 mod_enabled_cvar_name(std::string_view const id) { + return fmt::format("mod.{}.enabled", escape_mod_id_for_config(id)); +} + +// A deferred export that was not published by the end of the provider's initialization can +// never resolve, which is almost certainly a bug in the provider. +static void warn_unpublished_deferred_exports(const LoadedMod& mod) { + if (!mod.active || !mod.native || mod.native->manifest == nullptr) { + return; + } + + const auto& manifest = *mod.native->manifest; + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size != sizeof(ServiceExport) || + (serviceExport.flags & SERVICE_EXPORT_DEFERRED) == 0) + { + continue; + } + const auto* record = + svc::find_service_record(serviceExport.service_id, serviceExport.major_version); + if (record != nullptr && record->service == nullptr) { + Log.warn("'{}' declared deferred service '{}@{}' but never published it during " + "initialization", + mod.metadata.id, serviceExport.service_id, serviceExport.major_version); + } + } +} + +void ModLoader::try_load_mod( + const std::filesystem::path& modPath, bool fromDir, uint32_t searchDirIndex) { + namespace fs = std::filesystem; + + std::unique_ptr bundle; + try { + bundle = load_bundle(modPath, fromDir); + } catch (const std::exception& e) { + Log.error( + "Failed to open {} bundle: {}", io::fs_path_to_string(modPath.filename()), e.what()); + return; + } + + ModMetadata metadata; + try { + metadata = load_metadata(modPath, *bundle); + } catch (const std::exception& e) { + Log.error("bad mod.json in {}: {}", io::fs_path_to_string(modPath.filename()), e.what()); + return; + } + + if (const auto* existing = find_mod(metadata.id)) { + if (existing->searchDirIndex < searchDirIndex) { + Log.info("'{}' ({}) shadowed by higher-priority copy {}", metadata.id, + io::fs_path_to_string(modPath.filename()), existing->modPath); + } else { + 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.modPath = io::fs_path_to_string(fs::absolute(modPath)); + mod.searchDirIndex = searchDirIndex; + mod.inPlace = m_searchDirs[searchDirIndex].inPlaceNative && fromDir; + mod.metadata = std::move(metadata); + mod.bundle = std::move(bundle); + mod.context = std::make_unique(); + mod.context->mod = &mod; + mod.cvarIsEnabled = + std::make_unique >(mod_enabled_cvar_name(mod.metadata.id), true); + + const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); + if (anyLibs || (mod.inPlace && !external_native_lib_path(mod).empty())) { + mod.nativeStatus = NativeModStatus::Unknown; + load_native(mod, dllEntry); + if (mod.nativeStatus != NativeModStatus::Loaded) { + Log.error("Native mod '{}' failed to load, disabling", mod.metadata.id); + mod.active = false; + mod.loadFailed = true; + mod.failureReason = native_status_message(mod.nativeStatus); + } else { + mod.manifestInfo = build_manifest_info(*mod.native->manifest); + } + } + + Log.info("found '{}' ('{}') v{} by {} ({})", mod.metadata.name, mod.metadata.id, + mod.metadata.version, mod.metadata.author, io::fs_path_to_string(modPath.filename())); +} + +bool ModLoader::activate_mod(LoadedMod& mod) { + mod.active = true; + + // Asset-only mods have no lifecycle beyond their overlay files. + if (!mod.native) { + mod.enabledApplied = true; + return true; + } + + if (!mod.servicesRegistered) { + if (!register_static_service_exports(mod)) { + Log.error("'{}' failed to register service exports", mod.metadata.id); + return false; + } + mod.servicesRegistered = true; + } + + if (!resolve_service_imports(mod)) { + Log.error("'{}' failed to resolve service imports", mod.metadata.id); + return false; + } + + *mod.native->contextSymbol = mod.context.get(); + + Log.debug("Initializing '{}'", mod.metadata.id); + try { + ModError error = MOD_ERROR_INIT; + const auto result = mod.native->fn_initialize(&error); + if (result == MOD_OK && !mod.loadFailed) { + mod.initialized = true; + Log.info("'{}' initialized", mod.metadata.id); + } else { + fail_mod(mod, result, lifecycle_error_message("mod_initialize", result, error)); + } + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("exception in mod_initialize: {}", e.what())); + } catch (...) { + fail_mod(mod, MOD_ERROR, "unknown exception in mod_initialize"); + } + + warn_unpublished_deferred_exports(mod); + + if (!mod.active) { + return false; + } + + mod.enabledApplied = true; + return true; +} + +void ModLoader::deactivate_mod(LoadedMod& mod) { + if (mod.initialized && mod.native && mod.native->fn_shutdown) { + try { + ModError error = MOD_ERROR_INIT; + const auto result = mod.native->fn_shutdown(&error); + if (result != MOD_OK) { + Log.error("mod_shutdown failed for '{}': {}", mod.metadata.id, + lifecycle_error_message("mod_shutdown", result, error)); + } + } catch (...) { + } + } + mod.initialized = false; + + if (mod.servicesRegistered) { + svc::remove_services_for_provider(mod); + mod.servicesRegistered = false; + } + unload_native(mod); + + mod.active = false; + mod.enabledApplied = false; +} + +void ModLoader::init() { + if (m_initialized) { + return; + } + m_initialized = true; + + if (m_searchDirs.empty()) { + Log.warn("no mod search directories configured; mod loading skipped"); + return; + } + + if (m_cacheDir.empty()) { + m_cacheDir = m_searchDirs.front().path / ".cache"; + } + + namespace fs = std::filesystem; + std::error_code ec; + + // Stale libs from previous sessions (see load_native). + fs::remove_all(m_cacheDir, ec); + + for (size_t dirIndex = 0; dirIndex < m_searchDirs.size(); ++dirIndex) { + const auto& searchDir = m_searchDirs[dirIndex]; + + // --mods can point the user dir at the bundled dir; don't scan the same dir twice. + bool alreadyScanned = false; + for (size_t earlier = 0; earlier < dirIndex && !alreadyScanned; ++earlier) { + alreadyScanned = fs::equivalent(m_searchDirs[earlier].path, searchDir.path, ec); + } + if (alreadyScanned) { + continue; + } + + if (!fs::is_directory(searchDir.path)) { + if (dirIndex == 0) { + Log.info("mods directory '{}' not found", io::fs_path_to_string(searchDir.path)); + } else { + Log.debug("mods directory '{}' not found", io::fs_path_to_string(searchDir.path)); + } + continue; + } + + std::vector entries; + for (auto& e : fs::directory_iterator(searchDir.path, 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(); + }); + + for (auto& entry : entries) { + try_load_mod(entry.path(), entry.is_directory(), static_cast(dirIndex)); + } + } + + if (m_mods.empty()) { + Log.info("no mods found"); + return; + } + + std::stable_sort(m_mods.begin(), m_mods.end(), + [](const auto& a, const auto& b) { return a->searchDirIndex > b->searchDirIndex; }); + + Log.info("initializing {} mod(s)...", m_mods.size()); + for (auto& mod : mods()) { + mod.enabledSubscription = Register(*mod.cvarIsEnabled, + [this, &mod](const bool&, const bool&) { on_enabled_changed(mod); }); + } + + init_services(); + + for (auto& mod : mods()) { + if (!mod.native) { + continue; + } + if (register_static_service_exports(mod)) { + mod.servicesRegistered = true; + } else { + Log.error("'{}' failed to register service exports", mod.metadata.id); + } + } + + // Providers must initialize (and publish deferred services) before their importers, so + // imports are resolved per mod, interleaved with initialization, in dependency order. + loader::sort_mods(m_mods); + + // Config-disabled mods keep their dependency edges but must not resolve until enabled. + for (auto& mod : mods()) { + if (!mod.cvarIsEnabled->getValue()) { + Log.info("Mod '{}' is disabled by config", mod.metadata.id); + mod.active = false; + if (mod.servicesRegistered) { + svc::remove_services_for_provider(mod); + mod.servicesRegistered = false; + } + } + } + + for (auto& mod : mods()) { + if (mod.active) { + activate_mod(mod); + } + } + + auto active = std::ranges::count_if(mods(), [](const LoadedMod& m) { return m.active; }); + Log.info("{}/{} mod(s) active", active, m_mods.size()); + + m_startupComplete = true; +} + +LoadedMod* ModLoader::find_mod(std::string_view id) { + for (auto& mod : mods()) { + if (mod.metadata.id == id) { + return &mod; + } + } + return nullptr; +} + +void ModLoader::request_enable(std::string_view id) { + if (auto* mod = find_mod(id)) { + mod->cvarIsEnabled->setValue(true); + } +} + +void ModLoader::request_disable(std::string_view id) { + if (auto* mod = find_mod(id)) { + mod->cvarIsEnabled->setValue(false); + } +} + +void ModLoader::request_reload(std::string_view id) { + m_pendingRequests.push_back({std::string{id}, RequestKind::Reload}); +} + +void ModLoader::notify_mod_failure(LoadedMod& mod) { + // Startup failures are handled inline by activate_mod; the queue only exists for failures + // raised while mod code may be on the stack (mod_update, hook callbacks, UI callbacks). + if (!m_startupComplete) { + return; + } + m_pendingRequests.push_back({mod.metadata.id, RequestKind::Disable}); +} + +static bool requiredDepsActive(const LoadedMod& mod) { + return std::ranges::all_of(mod.dependencies, + [](const ModDependencyEdge& edge) { return !edge.required || edge.mod->active; }); +} + +std::vector ModLoader::collect_lifecycle_set(LoadedMod& target) { + std::vector included{&target}; + std::vector pending{&target}; + while (!pending.empty()) { + auto* current = pending.back(); + pending.pop_back(); + for (const auto& edge : current->dependents) { + auto* dependent = edge.mod; + if (!dependent->active && !dependent->suspendedByProvider) { + continue; + } + if (std::ranges::find(included, dependent) != included.end()) { + continue; + } + included.push_back(dependent); + pending.push_back(dependent); + } + } + + std::vector ordered; + ordered.reserve(included.size()); + for (auto& mod : mods()) { + if (std::ranges::find(included, &mod) != included.end()) { + ordered.push_back(&mod); + } + } + return ordered; +} + +bool ModLoader::ensure_native_loaded(LoadedMod& mod) { + if (mod.native || mod.nativeStatus == NativeModStatus::None) { + return true; + } + + const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); + if (!anyLibs && !(mod.inPlace && !external_native_lib_path(mod).empty())) { + mod.nativeStatus = NativeModStatus::None; + return true; + } + + mod.nativeStatus = NativeModStatus::Unknown; + load_native(mod, dllEntry); + if (mod.nativeStatus != NativeModStatus::Loaded) { + fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); + return false; + } + return true; +} + +bool ModLoader::reload_bundle(LoadedMod& mod) { + namespace fs = std::filesystem; + Log.info("reloading '{}' from {}", mod.metadata.id, mod.modPath); + + std::shared_ptr newBundle; + ModMetadata newMetadata; + try { + std::error_code ec; + newBundle = load_bundle(mod.modPath, fs::is_directory(mod.modPath, ec)); + newMetadata = load_metadata(mod.modPath, *newBundle); + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("reload failed: {}", e.what())); + return false; + } + + if (newMetadata.id != mod.metadata.id) { + fail_mod(mod, MOD_CONFLICT, + fmt::format("mod id changed on reload ('{}'); restart the game", newMetadata.id)); + return false; + } + + mod.metadata = std::move(newMetadata); + // In-flight readers of the old bundle keep it alive through their shared_ptr. + mod.bundle = std::move(newBundle); + mod.loadFailed = false; + mod.failureReason.clear(); + + ModManifestInfo newInfo; + if (const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); anyLibs) { + mod.nativeStatus = NativeModStatus::Unknown; + load_native(mod, dllEntry); + if (mod.nativeStatus != NativeModStatus::Loaded) { + fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); + return false; + } + newInfo = build_manifest_info(*mod.native->manifest); + } else { + mod.nativeStatus = NativeModStatus::None; + ++mod.cacheGeneration; + } + + if (newInfo != mod.manifestInfo) { + // The reload changes the mod's imports/exports; rebuild the dependency graph so edges, + // init/tick/shutdown order and cascade sets reflect the new manifest. + Log.info("'{}' changed its service imports/exports; rebuilding mod dependency graph", + mod.metadata.id); + mod.manifestInfo = std::move(newInfo); + loader::sort_mods(m_mods); + } + + return true; +} + +void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { + auto affected = collect_lifecycle_set(target); + + // Dependents first (reverse init order), like shutdown. + for (auto* mod : affected | std::views::reverse) { + const bool needsTeardown = + mod->active || + (mod == &target && (mod->initialized || (reload && mod->native != nullptr))); + if (!needsTeardown) { + continue; + } + const bool wasActive = mod->active; + Log.info("deactivating '{}'", mod->metadata.id); + deactivate_mod(*mod); + if (mod != &target && wasActive) { + // Provisional; cleared below if the mod comes straight back up. + mod->suspendedByProvider = true; + } + } + + if (reload) { + // On failure the target is failed and stays down; dependents get resume attempts below + // and suspend against the failed provider where required. + reload_bundle(target); + + // The reload may have rebuilt the dependency graph and reordered m_mods; refresh the + // set's iteration order so reactivation still runs providers first. + std::vector reordered; + reordered.reserve(affected.size()); + for (auto& mod : mods()) { + if (std::ranges::find(affected, &mod) != affected.end()) { + reordered.push_back(&mod); + } + } + affected = std::move(reordered); + } + + // Mirror startup: publish every candidate's static exports before any of them initialize, + // so importers within the set resolve providers regardless of initialization order + // (optional cycles rely on this). + for (auto* mod : affected) { + if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { + continue; + } + if (!ensure_native_loaded(*mod)) { + continue; + } + if (mod->native && !mod->servicesRegistered && register_static_service_exports(*mod)) { + mod->servicesRegistered = true; + } + } + + // Providers first (init order). The target is naturally first among the affected mods. + for (auto* mod : affected) { + if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { + continue; + } + if (!requiredDepsActive(*mod)) { + mod->suspendedByProvider = true; + Log.info("'{}' suspended: a required provider is disabled", mod->metadata.id); + continue; + } + mod->suspendedByProvider = false; + activate_mod(*mod); + } + + // Mods that stayed down must not leave their exports resolvable. + for (auto* mod : affected) { + if (!mod->active && mod->servicesRegistered) { + svc::remove_services_for_provider(*mod); + mod->servicesRegistered = false; + } + } +} + +namespace { +bool s_configDirty = false; +std::chrono::steady_clock::time_point s_lastConfigSave{}; +constexpr std::chrono::seconds kConfigSaveDebounce{2}; +} // namespace + +void config_mark_dirty() { + s_configDirty = true; +} + +void config_flush_if_dirty(const bool force) { + if (!s_configDirty) { + return; + } + const auto now = std::chrono::steady_clock::now(); + if (!force && now - s_lastConfigSave < kConfigSaveDebounce) { + return; + } + s_configDirty = false; + s_lastConfigSave = now; + config::save(); +} + +void ModLoader::on_enabled_changed(LoadedMod& mod) { + config_mark_dirty(); + if (mod.loadFailed) { + return; + } + if (mod.suspendedByProvider) { + if (!mod.cvarIsEnabled->getValue()) { + // The user disabled a suspended mod; stop waiting for its providers. + mod.suspendedByProvider = false; + } + return; + } + m_pendingRequests.push_back({mod.metadata.id, + mod.cvarIsEnabled->getValue() ? RequestKind::Enable : RequestKind::Disable}); +} + +void ModLoader::apply_pending_requests() { + // Images retired by the previous tick have had a full frame to unwind off the stack. + drain_retired_natives(); + + if (m_pendingRequests.empty()) { + return; + } + + // Coalesce per mod, last request wins. Failures during apply re-enqueue for next tick. + const auto requests = std::exchange(m_pendingRequests, {}); + std::vector coalesced; + for (const auto& request : requests) { + const auto existing = std::ranges::find_if( + coalesced, [&](const Request& r) { return r.modId == request.modId; }); + if (existing != coalesced.end()) { + existing->kind = request.kind; + } else { + coalesced.push_back(request); + } + } + + for (const auto& request : coalesced) { + auto* mod = find_mod(request.modId); + if (mod == nullptr) { + Log.warn("lifecycle request for unknown mod '{}'", request.modId); + continue; + } + if (request.kind == RequestKind::Reload && mod->inPlace) { + Log.warn("'{}' is a built-in mod and can't be reloaded", mod->metadata.id); + continue; + } + if (request.kind == RequestKind::Enable && mod->enabledApplied) { + continue; + } + if (request.kind == RequestKind::Disable && !mod->enabledApplied && !mod->active) { + continue; + } + apply_lifecycle_change(*mod, request.kind == RequestKind::Reload); + } + + 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() { + apply_pending_requests(); + + for (auto& mod : mods()) { + if (!mod.active || !mod.native) { + continue; + } + try { + ModError error = MOD_ERROR_INIT; + const auto result = mod.native->fn_update(&error); + if (result != MOD_OK) { + fail_mod(mod, result, lifecycle_error_message("mod_update", result, error)); + } + } catch (const std::exception& e) { + fail_mod(mod, MOD_ERROR, fmt::format("exception in mod_update: {}", e.what())); + } catch (...) { + fail_mod(mod, MOD_ERROR, "unknown exception in mod_update"); + } + } + + config_flush_if_dirty(false); +} + +void ModLoader::shutdown() { + // Reverse initialization order, so importers shut down before their service providers. + for (auto& mod : mods() | std::views::reverse) { + deactivate_mod(mod); + if (mod.enabledSubscription != 0) { + config::unsubscribe(mod.enabledSubscription); + mod.enabledSubscription = 0; + } + unregister(*mod.cvarIsEnabled); + } + + m_mods.clear(); + drain_retired_natives(); + clear_services(); + config_flush_if_dirty(true); + Log.info("all mods unloaded"); +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/loader.hpp b/src/dusk/mods/loader/loader.hpp new file mode 100644 index 0000000000..cb81f1059b --- /dev/null +++ b/src/dusk/mods/loader/loader.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include "miniz.h" + +#include "dusk/mod_loader.hpp" + +namespace dusk::mods { + +#if DUSK_CODE_MODS +constexpr bool EnableCodeMods = true; +#else +constexpr bool EnableCodeMods = false; +#endif + +// Implementations must be safe for concurrent calls; bundle reads are not limited to the +// game thread. +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; + std::mutex m_mutex; +}; + +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; +}; + +LoadedMod* mod_from_context(ModContext* context); +const LoadedMod* mod_from_context(const ModContext* context); +const char* mod_id_from_context(ModContext* context); +void fail_mod(LoadedMod& mod, ModResult code, std::string_view message); +bool is_safe_resource_path(std::string_view path); +std::string escape_mod_id_for_config(std::string_view id); + +void config_mark_dirty(); +void config_flush_if_dirty(bool force); + +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/native_module.cpp b/src/dusk/mods/loader/native_module.cpp new file mode 100644 index 0000000000..aa4a369fb0 --- /dev/null +++ b/src/dusk/mods/loader/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 +void* pl_dlopen(const std::filesystem::path& p) { + int flags = RTLD_LAZY | RTLD_LOCAL; +#if defined(RTLD_DEEPBIND) + flags |= RTLD_DEEPBIND; +#endif + return dlopen(p.c_str(), flags); +} +void* pl_dlsym(void* h, const char* name) { + return dlsym(h, name); +} +void pl_dlclose(void* h) { + dlclose(h); +} +std::string pl_dlerror() { + const char* e = dlerror(); + return e ? e : "(unknown error)"; +} +#endif +} + +namespace dusk::mods::loader { +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::mods::loader diff --git a/src/dusk/mods/loader/native_module.hpp b/src/dusk/mods/loader/native_module.hpp new file mode 100644 index 0000000000..ff2acf9632 --- /dev/null +++ b/src/dusk/mods/loader/native_module.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace dusk::mods::loader { +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/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp new file mode 100644 index 0000000000..f107aa0b84 --- /dev/null +++ b/src/dusk/mods/svc/host.cpp @@ -0,0 +1,76 @@ +#include "registry.hpp" + +#include "dusk/mods/loader/loader.hpp" + +namespace dusk::mods::svc { +namespace { + +ModResult host_get_service(ModContext*, const char* serviceId, const uint16_t majorVersion, + const uint16_t minMinorVersion, const void** outService) { + if (outService == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outService = nullptr; + const auto* service = find_service(serviceId, majorVersion, minMinorVersion); + if (service == nullptr) { + return MOD_UNAVAILABLE; + } + *outService = service->service; + return MOD_OK; +} + +ModResult host_publish_service( + ModContext* context, const char* serviceId, const uint16_t majorVersion, const void* service) { + auto* mod = mod_from_context(context); + if (mod == nullptr || !valid_service_id(serviceId) || service == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + return publish_deferred_service(*mod, serviceId, majorVersion, service); +} + +void host_fail(ModContext* context, const ModResult code, const char* message) { + auto* mod = mod_from_context(context); + if (mod != nullptr) { + fail_mod(*mod, code, message != nullptr ? message : "mod reported failure"); + } +} + +const char* host_mod_id(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.id.c_str() : ""; +} + +const char* host_mod_name(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.name.c_str() : ""; +} + +const char* host_mod_version(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->metadata.version.c_str() : ""; +} + +const char* host_mod_dir(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->dir.c_str() : ""; +} + +constexpr HostService s_hostService{ + .header = SERVICE_HEADER(HostService, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR), + .get_service = host_get_service, + .publish_service = host_publish_service, + .fail = host_fail, + .mod_id = host_mod_id, + .mod_name = host_mod_name, + .mod_version = host_mod_version, + .mod_dir = host_mod_dir, +}; + +} // namespace + +const HostService& host_service() { + return s_hostService; +} + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/log.cpp b/src/dusk/mods/svc/log.cpp new file mode 100644 index 0000000000..5d10d8cac2 --- /dev/null +++ b/src/dusk/mods/svc/log.cpp @@ -0,0 +1,64 @@ +#include "registry.hpp" + +#include "dusk/logging.h" +#include "dusk/mods/loader/loader.hpp" + +namespace dusk::mods::svc { +namespace { + +void log_write(ModContext* context, const LogLevel level, const char* message) { + const char* text = message != nullptr ? message : ""; + switch (level) { + case LOG_LEVEL_TRACE: + case LOG_LEVEL_DEBUG: + DuskLog.debug("[{}] {}", mod_id_from_context(context), text); + break; + case LOG_LEVEL_INFO: + DuskLog.info("[{}] {}", mod_id_from_context(context), text); + break; + case LOG_LEVEL_WARN: + DuskLog.warn("[{}] {}", mod_id_from_context(context), text); + break; + case LOG_LEVEL_ERROR: + DuskLog.error("[{}] {}", mod_id_from_context(context), text); + break; + } +} + +void log_trace(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_TRACE, message); +} + +void log_debug(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_DEBUG, message); +} + +void log_info(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_INFO, message); +} + +void log_warn(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_WARN, message); +} + +void log_error(ModContext* context, const char* message) { + log_write(context, LOG_LEVEL_ERROR, message); +} + +constexpr LogService s_logService{ + .header = SERVICE_HEADER(LogService, LOG_SERVICE_MAJOR, LOG_SERVICE_MINOR), + .write = log_write, + .trace = log_trace, + .debug = log_debug, + .info = log_info, + .warn = log_warn, + .error = log_error, +}; + +} // namespace + +const LogService& log_service() { + return s_logService; +} + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp new file mode 100644 index 0000000000..9e61d0791f --- /dev/null +++ b/src/dusk/mods/svc/registry.cpp @@ -0,0 +1,259 @@ +#include "registry.hpp" + +#include "dusk/logging.h" +#include "dusk/mods/loader/loader.hpp" + +#include +#include + +namespace dusk::mods::svc { +namespace { + +std::unordered_map s_services; + +std::string service_key(std::string_view id, const uint16_t majorVersion) { + std::string key{id}; + key.push_back('\x1f'); + key += std::to_string(majorVersion); + return key; +} + +const char* mod_id(const LoadedMod* mod) { + return mod != nullptr ? mod->metadata.id.c_str() : "dusklight"; +} + +bool validate_service_header(const ServiceHeader* header, const char* serviceId, + const uint16_t majorVersion, const uint16_t minorVersion, LoadedMod* provider) { + if (header == nullptr) { + DuskLog.error("[{}] service '{}' has null header", mod_id(provider), serviceId); + return false; + } + if (header->struct_size < sizeof(ServiceHeader)) { + DuskLog.error("[{}] service '{}' has invalid header size {}", mod_id(provider), serviceId, + header->struct_size); + return false; + } + if (header->major_version != majorVersion || header->minor_version != minorVersion) { + DuskLog.error("[{}] service '{}' header version {}.{} does not match export {}.{}", + mod_id(provider), serviceId, header->major_version, header->minor_version, majorVersion, + minorVersion); + return false; + } + return true; +} + +} // namespace + +bool valid_service_id(const char* serviceId) { + return serviceId != nullptr && serviceId[0] != '\0'; +} + +ModResult register_service(const char* serviceId, const uint16_t majorVersion, + const uint16_t minorVersion, const void* service, LoadedMod* provider, const bool deferred) { + if (!valid_service_id(serviceId)) { + DuskLog.error("[{}] attempted to register a service with no id", mod_id(provider)); + return MOD_INVALID_ARGUMENT; + } + + if (!deferred && !validate_service_header(static_cast(service), serviceId, + majorVersion, minorVersion, provider)) + { + return MOD_INVALID_ARGUMENT; + } + + const auto key = service_key(serviceId, majorVersion); + if (s_services.contains(key)) { + DuskLog.error("[{}] duplicate service '{}@{}'", mod_id(provider), serviceId, majorVersion); + return MOD_CONFLICT; + } + + s_services.emplace(key, ServiceRecord{ + serviceId, + majorVersion, + minorVersion, + service, + provider, + deferred, + }); + return MOD_OK; +} + +ModResult publish_deferred_service( + LoadedMod& provider, const char* serviceId, const uint16_t majorVersion, const void* service) { + if (!valid_service_id(serviceId) || service == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + const auto it = s_services.find(service_key(serviceId, majorVersion)); + if (it == s_services.end() || !it->second.deferred || it->second.provider != &provider) { + DuskLog.error("[{}] tried to publish undeclared service '{}@{}'", provider.metadata.id, + serviceId, majorVersion); + return MOD_UNSUPPORTED; + } + auto& record = it->second; + if (record.service != nullptr) { + return MOD_CONFLICT; + } + + const auto* header = static_cast(service); + if (!validate_service_header(header, serviceId, majorVersion, record.minorVersion, &provider)) { + return MOD_INVALID_ARGUMENT; + } + + record.service = service; + record.minorVersion = header->minor_version; + return MOD_OK; +} + +void remove_services_for_provider(const LoadedMod& provider) { + std::erase_if( + s_services, [&](const auto& entry) { return entry.second.provider == &provider; }); +} + +const ServiceRecord* find_service( + const char* serviceId, const uint16_t majorVersion, const uint16_t minMinorVersion) { + const auto* record = find_service_record(serviceId, majorVersion); + if (record == nullptr || record->service == nullptr || record->minorVersion < minMinorVersion) { + return nullptr; + } + return record; +} + +const ServiceRecord* find_service_record(const char* serviceId, const uint16_t majorVersion) { + if (!valid_service_id(serviceId)) { + return nullptr; + } + + const auto it = s_services.find(service_key(serviceId, majorVersion)); + return it != s_services.end() ? &it->second : nullptr; +} + +void clear_services() { + s_services.clear(); +} + +} // namespace dusk::mods::svc + +namespace dusk::mods { + +void ModLoader::init_services() { + svc::clear_services(); + svc::register_service(HOST_SERVICE_ID, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR, + &svc::host_service(), nullptr, false); + svc::register_service( + LOG_SERVICE_ID, LOG_SERVICE_MAJOR, LOG_SERVICE_MINOR, &svc::log_service(), nullptr, false); +} + +bool ModLoader::register_static_service_exports(LoadedMod& mod) { + if (!mod.native || mod.native->manifest == nullptr) { + return true; + } + + const auto& manifest = *mod.native->manifest; + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size != sizeof(ServiceExport) || + !svc::valid_service_id(serviceExport.service_id)) + { + fail_mod(mod, MOD_INVALID_ARGUMENT, "invalid service export descriptor"); + return false; + } + + const bool deferred = (serviceExport.flags & SERVICE_EXPORT_DEFERRED) != 0; + if (!deferred && serviceExport.service == nullptr) { + fail_mod(mod, MOD_INVALID_ARGUMENT, "static service export has null service pointer"); + return false; + } + + const auto result = + svc::register_service(serviceExport.service_id, serviceExport.major_version, + serviceExport.minor_version, serviceExport.service, &mod, deferred); + if (result != MOD_OK) { + fail_mod(mod, result, "service export registration failed"); + return false; + } + } + + return true; +} + +std::string ModLoader::describe_missing_import( + const char* serviceId, const uint16_t majorVersion, const uint16_t minMinorVersion) const { + if (const auto* record = svc::find_service_record(serviceId, majorVersion)) { + if (record->service == nullptr) { + return fmt::format("required service {}@{} was never published by provider '{}'", + serviceId, majorVersion, svc::mod_id(record->provider)); + } + return fmt::format("required service {}@{} only provides minor version {} (need >= {})", + serviceId, majorVersion, record->minorVersion, minMinorVersion); + } + + // No record can also mean the provider failed or is disabled and its services were removed. + for (const auto& other : mods()) { + if ((other.active && !other.loadFailed) || !other.native || + other.native->manifest == nullptr) + { + continue; + } + const auto& manifest = *other.native->manifest; + for (size_t i = 0; i < manifest.export_count; ++i) { + const auto& serviceExport = manifest.exports[i]; + if (serviceExport.struct_size == sizeof(ServiceExport) && + svc::valid_service_id(serviceExport.service_id) && + std::string_view{serviceExport.service_id} == serviceId && + serviceExport.major_version == majorVersion) + { + return fmt::format("required service {}@{} unavailable: provider '{}' {}", + serviceId, majorVersion, other.metadata.id, + other.loadFailed ? "failed to load" : "is disabled"); + } + } + } + + return fmt::format("required service unavailable: {}@{}", serviceId, majorVersion); +} + +bool ModLoader::resolve_service_imports(LoadedMod& mod) { + if (!mod.native || mod.native->manifest == nullptr) { + return true; + } + + const auto& manifest = *mod.native->manifest; + for (size_t i = 0; i < manifest.import_count; ++i) { + const auto& serviceImport = manifest.imports[i]; + if (serviceImport.struct_size != sizeof(ServiceImport) || + !svc::valid_service_id(serviceImport.service_id) || serviceImport.slot == nullptr) + { + fail_mod(mod, MOD_INVALID_ARGUMENT, "invalid service import descriptor"); + return false; + } + + const auto* service = svc::find_service( + serviceImport.service_id, serviceImport.major_version, serviceImport.min_minor_version); + if (service == nullptr) { + *static_cast(serviceImport.slot) = nullptr; + if ((serviceImport.flags & SERVICE_IMPORT_OPTIONAL) != 0) { + continue; + } + + fail_mod(mod, MOD_UNAVAILABLE, + describe_missing_import(serviceImport.service_id, serviceImport.major_version, + serviceImport.min_minor_version)); + return false; + } + + *static_cast(serviceImport.slot) = service->service; + } + + return true; +} + +void ModLoader::clear_services() { + svc::clear_services(); +} + +void ModLoader::fail_mod(LoadedMod& mod, const ModResult code, std::string_view message) { + mods::fail_mod(mod, code, message); +} + +} // namespace dusk::mods diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp new file mode 100644 index 0000000000..0b850840d9 --- /dev/null +++ b/src/dusk/mods/svc/registry.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "dusk/mod_loader.hpp" +#include "mods/svc/host.h" +#include "mods/svc/log.h" + +#include +#include + +namespace dusk::mods::svc { + +struct ServiceRecord { + std::string id; + uint16_t majorVersion = 0; + uint16_t minorVersion = 0; + const void* service = nullptr; + LoadedMod* provider = nullptr; + bool deferred = false; +}; + +bool valid_service_id(const char* serviceId); +ModResult register_service(const char* serviceId, uint16_t majorVersion, uint16_t minorVersion, + const void* service, LoadedMod* provider, bool deferred); +ModResult publish_deferred_service( + LoadedMod& provider, const char* serviceId, uint16_t majorVersion, const void* service); +void remove_services_for_provider(const LoadedMod& provider); +const ServiceRecord* find_service( + const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion); +// Unlike find_service, also returns deferred records that have not been published yet. +const ServiceRecord* find_service_record(const char* serviceId, uint16_t majorVersion); +void clear_services(); + +const HostService& host_service(); +const LogService& log_service(); + +} // namespace dusk::mods::svc diff --git a/src/f_ap/f_ap_game.cpp b/src/f_ap/f_ap_game.cpp index ca11ccbe64..f4ccc32da0 100644 --- a/src/f_ap/f_ap_game.cpp +++ b/src/f_ap/f_ap_game.cpp @@ -18,6 +18,7 @@ #include "dusk/frame_interpolation.h" #include "dusk/livesplit.h" #include "dusk/logging.h" +#include "dusk/mod_loader.hpp" #include "f_op/f_op_camera_mng.h" #include "f_op/f_op_draw_tag.h" #include "f_op/f_op_overlap_mng.h" @@ -815,6 +816,8 @@ static void duskExecute() { if (dusk::getSettings().game.infiniteOxygen) { dComIfGp_setOxygen(dComIfGp_getMaxOxygen()); } + + dusk::mods::ModLoader::instance().tick(); } #endif diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index 8ed11c54c4..ce029035b1 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -60,6 +60,7 @@ #include "dusk/imgui/ImGuiConsole.hpp" #include "dusk/imgui/ImGuiEngine.hpp" #include "dusk/iso_validate.hpp" +#include "dusk/mod_loader.hpp" #include "dusk/logging.h" #include "dusk/main.h" #include "dusk/ui/menu_bar.hpp" @@ -363,6 +364,7 @@ void main01(void) { } while (dusk::IsRunning); exit:; + dusk::mods::ModLoader::instance().shutdown(); dusk::ui::shutdown(); } @@ -512,6 +514,7 @@ int game_main(int argc, char* argv[]) { ("h,help", "Print usage") ("console", "Show the Windows console window for logs", cxxopts::value()->default_value("false")->implicit_value("true")) ("dvd", "Path to DVD image file", cxxopts::value()) + ("mods", "Path to mods directory", cxxopts::value()) ("backend", "Graphics API backend to use (auto, d3d12, d3d11, metal, vulkan, null)", cxxopts::value()) ("cvar", "Override configuration variables without modifying config", cxxopts::value>()); @@ -785,8 +788,61 @@ int game_main(int argc, char* argv[]) { // mDoMain::developmentMode = 1; // Force Dev Mode for Debugging mDoDvdThd::SyncWidthSound = false; - OSReport("Starting main01 (Game Loop)...\n"); + // Mod search directories, highest priority first: user dir (--mods replaces it), then + // mods/ next to the app, then install-bundled mods inside the app bundle. + { + std::vector modDirs; + if (parsed_arg_options.contains("mods") && + !parsed_arg_options["mods"].as().empty()) + { + modDirs.push_back({.path = parsed_arg_options["mods"].as()}); + } else { + modDirs.push_back({.path = dusk::ConfigPath / "mods"}); + } +#if TARGET_ANDROID + // APK-bundled mods are extracted to internal storage + // by DuskActivity before SDL_main runs. + modDirs.push_back({ + .path = dusk::CachePath / "bundled_mods", + }); +#elif defined(__APPLE__) && (TARGET_OS_IOS || TARGET_OS_TV) + modDirs.push_back({ + .path = dusk::data::base_path_relative("mods"), + .inPlaceNative = true, + .nativeLibDir = dusk::data::base_path_relative("Frameworks"), + }); +#else +#if defined(__APPLE__) + // Base path is Contents/Resources; search up for dev mods + // TODO: scope to non-CI builds + modDirs.push_back({ + .path = dusk::data::base_path_relative("../../../mods").lexically_normal(), + .inPlaceNative = true, + }); + // Contents/Resources/mods + modDirs.push_back({ + .path = dusk::data::base_path_relative("mods"), + .inPlaceNative = true, + }); +#else + modDirs.push_back({ + .path = dusk::data::base_path_relative("mods"), + .inPlaceNative = true, + }); +#endif +#endif + dusk::mods::ModLoader::instance().set_search_dirs(std::move(modDirs)); + } +#if TARGET_ANDROID + // A user-relocated data dir can live on external storage, which is mounted noexec. + // Native mod libraries must be extracted to internal storage. + dusk::mods::ModLoader::instance().set_cache_dir(dusk::CachePath / "mod_cache"); +#endif + DuskLog.info("Initializing mods..."); + dusk::mods::ModLoader::instance().init(); + + OSReport("Starting main01 (Game Loop)...\n"); main01(); diff --git a/tools/mod_template/CMakeLists.txt b/tools/mod_template/CMakeLists.txt new file mode 100644 index 0000000000..3d40c3c5c4 --- /dev/null +++ b/tools/mod_template/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.25) +project(template_mod CXX) + +if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to dusk source root") + option(DUSK_MOD_USE_FULL_TREE "Use full build instead of the minimal mod SDK" OFF) + set(CMAKE_POSITION_INDEPENDENT_CODE ON) + if (DUSK_MOD_USE_FULL_TREE) + add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL) + else () + add_subdirectory("${DUSK_DIR}/sdk" dusk-sdk EXCLUDE_FROM_ALL) + endif () +endif () + +add_mod(template_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res +) diff --git a/tools/mod_template/mod.json b/tools/mod_template/mod.json new file mode 100644 index 0000000000..46507a87ee --- /dev/null +++ b/tools/mod_template/mod.json @@ -0,0 +1,7 @@ +{ + "id": "com.example.mod", + "name": "Template Mod", + "version": "1.0.0", + "author": "You", + "description": "An example Dusklight mod" +} diff --git a/tools/mod_template/res/.gitkeep b/tools/mod_template/res/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/mod_template/src/mod.cpp b/tools/mod_template/src/mod.cpp new file mode 100644 index 0000000000..35d19331fd --- /dev/null +++ b/tools/mod_template/src/mod.cpp @@ -0,0 +1,22 @@ +#include "mods/service.hpp" +#include "mods/svc/log.h" + +DEFINE_MOD(); +IMPORT_SERVICE(LogService, svc_log); + +extern "C" { + +MOD_EXPORT ModResult mod_initialize(ModError*) { + svc_log->info(mod_ctx, "template_mod initialized"); + return MOD_OK; +} + +MOD_EXPORT ModResult mod_update(ModError*) { + return MOD_OK; +} + +MOD_EXPORT ModResult mod_shutdown(ModError*) { + svc_log->info(mod_ctx, "template_mod unloaded"); + return MOD_OK; +} +} From 9b75dc5fd2fb4eb702e1a8c5f3e9bcb593edf7a6 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 11:09:44 -0600 Subject: [PATCH 02/14] Service lifecycle events --- docs/modding.md | 20 +++++++++ include/dusk/mod_loader.hpp | 4 +- include/mods/svc/host.h | 35 +++++++++++++++- src/dusk/mods/loader/loader.cpp | 10 +++++ src/dusk/mods/svc/host.cpp | 73 +++++++++++++++++++++++++++++++-- src/dusk/mods/svc/log.cpp | 9 ++-- src/dusk/mods/svc/registry.cpp | 69 +++++++++++++++++++++++++++++-- src/dusk/mods/svc/registry.hpp | 36 +++++++++++++++- 8 files changed, 241 insertions(+), 15 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 12c7881ade..f3332da0e9 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -156,6 +156,26 @@ svc_host->fail(mod_ctx, MOD_ERROR, "something unrecoverable happened"); // disa `get_service`/`publish_service` provide dynamic service lookup; see [Advanced](#advanced-exporting-services). +**Lifecycle watches.** If your mod provides a service that hands out per-caller state (registrations, callbacks, handles), +watch other mods' lifecycle and drop what you hold for a mod when it detaches. + +```cpp +IMPORT_SERVICE_VERSION(HostService, svc_host, 1); + +void on_mod_lifecycle(ModContext* ctx, ModContext* subject, const char* subject_id, + ModLifecycleEvent event, void* user_data) { + if (event == MOD_LIFECYCLE_DETACHED) { + drop_state_for(subject); // same ModContext* the subject passed into your service + } +} + +uint64_t watch = 0; +svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch); +``` + +`MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and +every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead. + --- ## Runtime Lifecycle diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 2a67e209f7..20b4de8e70 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -216,8 +216,8 @@ private: // Registers exports (if needed), resolves imports and runs mod_initialize. // Returns whether the mod ended up active; failures go through fail_mod. bool activate_mod(LoadedMod& mod); - // Runs mod_shutdown (if needed), removes the mod's services, and unloads the native lib. - // Must only run with no mod code on the stack (startup, shutdown, or top of tick). + // Runs mod_shutdown (if needed), detaches the mod from every service, and unloads the + // native lib. Must only run with no mod code on the stack (startup, shutdown, or top of tick). void deactivate_mod(LoadedMod& mod); void init_services(); bool register_static_service_exports(LoadedMod& mod); diff --git a/include/mods/svc/host.h b/include/mods/svc/host.h index 6f5844a88f..61d0831dae 100644 --- a/include/mods/svc/host.h +++ b/include/mods/svc/host.h @@ -9,7 +9,28 @@ #define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" #define HOST_SERVICE_MAJOR 1u -#define HOST_SERVICE_MINOR 0u +#define HOST_SERVICE_MINOR 1u + +/* + * Ignore unknown values: later service minors may add events. + */ +typedef enum ModLifecycleEvent { + /* + * The subject mod is gone: its mod_shutdown has run (when it initialized at all) and + * every service has already dropped the state it held for it. The subject's library is + * still mapped, so pointers into it are valid to compare against, but they must not be + * called or dereferenced after the callback returns. Drop everything keyed to the + * mod: callbacks it registered, its ModContext*, state indexed by it. + */ + MOD_LIFECYCLE_DETACHED = 0, +} ModLifecycleEvent; + +/* + * ctx is the watching mod's own context; subject identifies the mod the event is about. + * subject_id is valid only for the duration of the call. + */ +typedef void (*ModLifecycleFn)(ModContext* ctx, ModContext* subject, const char* subject_id, + ModLifecycleEvent event, void* user_data); typedef struct HostService { ServiceHeader header; @@ -50,6 +71,18 @@ typedef struct HostService { * and reload within a session, but the directory is wiped at game startup. */ const char* (*mod_dir)(ModContext* ctx); + + /* + * Observe other mods' lifecycle events. Any mod whose service hands out per-caller state + * (registrations, callbacks, handles) should watch for MOD_LIFECYCLE_DETACHED and drop what it + * holds for the subject. + * + * Callbacks fire on the game thread at a lifecycle safe point (never mid-frame), for + * every mod but the watcher itself (use mod_shutdown for self-cleanup). + */ + ModResult (*watch_mod_lifecycle)( + ModContext* ctx, ModLifecycleFn fn, void* user_data, uint64_t* out_handle); + ModResult (*unwatch_mod_lifecycle)(ModContext* ctx, uint64_t handle); } HostService; #ifdef __cplusplus diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 769ec28e87..a8459717e6 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -599,6 +599,8 @@ bool ModLoader::activate_mod(LoadedMod& mod) { warn_unpublished_deferred_exports(mod); if (!mod.active) { + // Failed initialization may have left hooks or other service state behind + svc::modules_mod_detached(mod); return false; } @@ -624,6 +626,7 @@ void ModLoader::deactivate_mod(LoadedMod& mod) { svc::remove_services_for_provider(mod); mod.servicesRegistered = false; } + svc::modules_mod_detached(mod); unload_native(mod); mod.active = false; @@ -739,6 +742,8 @@ void ModLoader::init() { } } + svc::modules_lifecycle_applied(); + auto active = std::ranges::count_if(mods(), [](const LoadedMod& m) { return m.active; }); Log.info("{}/{} mod(s) active", active, m_mods.size()); @@ -1040,11 +1045,14 @@ void ModLoader::apply_pending_requests() { apply_lifecycle_change(*mod, request.kind == RequestKind::Reload); } + svc::modules_lifecycle_applied(); + 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() { + svc::modules_frame_begin(); apply_pending_requests(); for (auto& mod : mods()) { @@ -1064,6 +1072,7 @@ void ModLoader::tick() { } } + svc::modules_frame_end(); config_flush_if_dirty(false); } @@ -1080,6 +1089,7 @@ void ModLoader::shutdown() { m_mods.clear(); drain_retired_natives(); + svc::modules_shutdown(); clear_services(); config_flush_if_dirty(true); Log.info("all mods unloaded"); diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index f107aa0b84..6255b13c42 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -1,6 +1,10 @@ #include "registry.hpp" #include "dusk/mods/loader/loader.hpp" +#include "fmt/format.h" + +#include +#include namespace dusk::mods::svc { namespace { @@ -56,6 +60,63 @@ const char* host_mod_dir(ModContext* context) { return mod != nullptr ? mod->dir.c_str() : ""; } +struct LifecycleWatcher { + uint64_t handle = 0; + LoadedMod* owner = nullptr; + ModLifecycleFn fn = nullptr; + void* userData = nullptr; +}; + +std::vector s_watchers; +uint64_t s_nextWatchHandle = 1; + +ModResult host_watch_mod_lifecycle( + ModContext* context, ModLifecycleFn fn, void* userData, uint64_t* outHandle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || fn == nullptr || outHandle == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto handle = s_nextWatchHandle++; + s_watchers.push_back({handle, mod, fn, userData}); + *outHandle = handle; + return MOD_OK; +} + +ModResult host_unwatch_mod_lifecycle(ModContext* context, const uint64_t handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto erased = std::erase_if(s_watchers, + [&](const LifecycleWatcher& w) { return w.handle == handle && w.owner == mod; }); + return erased != 0 ? MOD_OK : MOD_INVALID_ARGUMENT; +} + +void host_mod_detached(LoadedMod& mod) { + // The subject's own watches go first: a mod is never notified about its own teardown. + std::erase_if(s_watchers, [&](const LifecycleWatcher& w) { return w.owner == &mod; }); + + // Iterate a snapshot: callbacks may watch/unwatch, and a failing callback erases the + // failing mod's services. + const auto snapshot = s_watchers; + for (const auto& watcher : snapshot) { + const bool alive = std::ranges::any_of( + s_watchers, [&](const LifecycleWatcher& w) { return w.handle == watcher.handle; }); + if (!alive) { + continue; + } + try { + watcher.fn(watcher.owner->context.get(), mod.context.get(), mod.metadata.id.c_str(), + MOD_LIFECYCLE_DETACHED, watcher.userData); + } catch (const std::exception& e) { + fail_mod(*watcher.owner, MOD_ERROR, + fmt::format("exception in mod lifecycle callback: {}", e.what())); + } catch (...) { + fail_mod(*watcher.owner, MOD_ERROR, "unknown exception in mod lifecycle callback"); + } + } +} + constexpr HostService s_hostService{ .header = SERVICE_HEADER(HostService, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR), .get_service = host_get_service, @@ -65,12 +126,18 @@ constexpr HostService s_hostService{ .mod_name = host_mod_name, .mod_version = host_mod_version, .mod_dir = host_mod_dir, + .watch_mod_lifecycle = host_watch_mod_lifecycle, + .unwatch_mod_lifecycle = host_unwatch_mod_lifecycle, }; } // namespace -const HostService& host_service() { - return s_hostService; -} +constinit const ServiceModule g_hostModule{ + .id = HOST_SERVICE_ID, + .majorVersion = HOST_SERVICE_MAJOR, + .minorVersion = HOST_SERVICE_MINOR, + .service = &s_hostService, + .modDetached = host_mod_detached, +}; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/log.cpp b/src/dusk/mods/svc/log.cpp index 5d10d8cac2..d9fc88eae7 100644 --- a/src/dusk/mods/svc/log.cpp +++ b/src/dusk/mods/svc/log.cpp @@ -57,8 +57,11 @@ constexpr LogService s_logService{ } // namespace -const LogService& log_service() { - return s_logService; -} +constinit const ServiceModule g_logModule{ + .id = LOG_SERVICE_ID, + .majorVersion = LOG_SERVICE_MAJOR, + .minorVersion = LOG_SERVICE_MINOR, + .service = &s_logService, +}; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 9e61d0791f..3951822bad 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -3,13 +3,16 @@ #include "dusk/logging.h" #include "dusk/mods/loader/loader.hpp" +#include #include #include +#include namespace dusk::mods::svc { namespace { std::unordered_map s_services; +std::vector s_modules; std::string service_key(std::string_view id, const uint16_t majorVersion) { std::string key{id}; @@ -130,6 +133,60 @@ const ServiceRecord* find_service_record(const char* serviceId, const uint16_t m void clear_services() { s_services.clear(); + s_modules.clear(); +} + +ModResult register_module(const ServiceModule& module) { + const auto result = register_service( + module.id, module.majorVersion, module.minorVersion, module.service, nullptr, false); + if (result != MOD_OK) { + return result; + } + s_modules.push_back(&module); + if (module.initialize != nullptr) { + module.initialize(); + } + return MOD_OK; +} + +void modules_mod_detached(LoadedMod& mod) { + for (const auto* module : s_modules | std::views::reverse) { + if (module->modDetached != nullptr) { + module->modDetached(mod); + } + } +} + +void modules_lifecycle_applied() { + for (const auto* module : s_modules) { + if (module->lifecycleApplied != nullptr) { + module->lifecycleApplied(); + } + } +} + +void modules_frame_begin() { + for (const auto* module : s_modules) { + if (module->frameBegin != nullptr) { + module->frameBegin(); + } + } +} + +void modules_frame_end() { + for (const auto* module : s_modules) { + if (module->frameEnd != nullptr) { + module->frameEnd(); + } + } +} + +void modules_shutdown() { + for (const auto* module : s_modules | std::views::reverse) { + if (module->shutdown != nullptr) { + module->shutdown(); + } + } } } // namespace dusk::mods::svc @@ -138,10 +195,14 @@ namespace dusk::mods { void ModLoader::init_services() { svc::clear_services(); - svc::register_service(HOST_SERVICE_ID, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR, - &svc::host_service(), nullptr, false); - svc::register_service( - LOG_SERVICE_ID, LOG_SERVICE_MAJOR, LOG_SERVICE_MINOR, &svc::log_service(), nullptr, false); + for (const auto* module : + { + &svc::g_hostModule, + &svc::g_logModule, + }) + { + svc::register_module(*module); + } } bool ModLoader::register_static_service_exports(LoadedMod& mod) { diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 0b850840d9..4f59aabff1 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -18,6 +18,31 @@ struct ServiceRecord { bool deferred = false; }; +// A host service and its lifecycle hooks. Every hook is optional. Frame and lifecycle hooks run in +// registration order, modDetached in reverse registration order. +struct ServiceModule { + const char* id = nullptr; + uint16_t majorVersion = 0; + uint16_t minorVersion = 0; + const void* service = nullptr; + + // One-time setup, at registration (ModLoader::init_services). + void (*initialize)() = nullptr; + // A mod is going away (deactivation or failed activation): drop all state held for it. + // Runs after the mod's mod_shutdown and before its library unloads, so pointers into + // the mod are still valid but must not be called. + void (*modDetached)(LoadedMod& mod) = nullptr; + // A batch of (de)activations finished applying: startup, and runtime enable/disable/ + // reload requests. The set of active mods is stable when this runs. + void (*lifecycleApplied)() = nullptr; + // Top of ModLoader::tick, before pending lifecycle requests apply. + void (*frameBegin)() = nullptr; + // End of ModLoader::tick, after every mod_update. + void (*frameEnd)() = nullptr; + // ModLoader::shutdown, after every mod has deactivated. + void (*shutdown)() = nullptr; +}; + bool valid_service_id(const char* serviceId); ModResult register_service(const char* serviceId, uint16_t majorVersion, uint16_t minorVersion, const void* service, LoadedMod* provider, bool deferred); @@ -30,7 +55,14 @@ const ServiceRecord* find_service( const ServiceRecord* find_service_record(const char* serviceId, uint16_t majorVersion); void clear_services(); -const HostService& host_service(); -const LogService& log_service(); +ModResult register_module(const ServiceModule& module); +void modules_mod_detached(LoadedMod& mod); +void modules_lifecycle_applied(); +void modules_frame_begin(); +void modules_frame_end(); +void modules_shutdown(); + +extern const ServiceModule g_hostModule; +extern const ServiceModule g_logModule; } // namespace dusk::mods::svc From c876ea558f2f0e28e5a6045418facbbf0249c8f6 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 11:32:13 -0600 Subject: [PATCH 03/14] Config service --- docs/modding.md | 36 +++ files.cmake | 2 + include/mods/svc/config.h | 107 ++++++++ src/dusk/mods/loader/loader.cpp | 29 +- src/dusk/mods/loader/loader.hpp | 3 - src/dusk/mods/svc/config.cpp | 451 ++++++++++++++++++++++++++++++++ src/dusk/mods/svc/config.hpp | 9 + src/dusk/mods/svc/registry.cpp | 1 + src/dusk/mods/svc/registry.hpp | 1 + 9 files changed, 609 insertions(+), 30 deletions(-) create mode 100644 include/mods/svc/config.h create mode 100644 src/dusk/mods/svc/config.cpp create mode 100644 src/dusk/mods/svc/config.hpp diff --git a/docs/modding.md b/docs/modding.md index f3332da0e9..28fc19af5f 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -176,6 +176,42 @@ svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch); `MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead. +### ConfigService (`mods/svc/config.h`) + +Persistent, mod-scoped configuration variables. Each var is stored in the user's `config.json` under +`mod..` (escaping: `.` → `_`, `_` → `__`, so `com.example.my_mod` becomes `com_example_my__mod`), +next to the host's own settings: + +```cpp +IMPORT_SERVICE(ConfigService, svc_config); + +ConfigVarDesc desc = CONFIG_VAR_DESC_INIT; +desc.name = "speedMultiplier"; // 1-64 chars from [A-Za-z0-9_-]; "enabled" is reserved +desc.type = CONFIG_VAR_FLOAT; +desc.default_float = 1.0; +ConfigVarHandle var = 0; +svc_config->register_var(mod_ctx, &desc, &var); + +double speed = 1.0; +svc_config->get_float(mod_ctx, var, &speed); +svc_config->set_float(mod_ctx, var, 2.0); + +// Optional: get notified when the value changes. +void on_speed_changed(ModContext* ctx, ConfigVarHandle var, const ConfigVarValue* value, + const ConfigVarValue* previous, void* user_data) { + /* value->float_value is the new value, previous->float_value the old one */ +} +svc_config->subscribe(mod_ctx, var, on_speed_changed, nullptr, nullptr); +``` + +Types: `CONFIG_VAR_BOOL` (`bool`), `CONFIG_VAR_INT` (`int64_t`), `CONFIG_VAR_FLOAT` (`double`), `CONFIG_VAR_STRING` +(UTF-8; `get_string` copies into a caller buffer, pass a `NULL` buffer with size 0 to query the length). Accessors are +typed and must match the registration. + +Change callbacks fire on the game thread whenever the value changes at runtime (your own `set_*` calls included). +Writes that store the same value are silent. Values applied from `config.json` or `--cvar` at registration do +**not** fire callbacks; read the value after `register_var` for the starting state. + --- ## Runtime Lifecycle diff --git a/files.cmake b/files.cmake index fcf603d360..fb4b8dc25c 100644 --- a/files.cmake +++ b/files.cmake @@ -1550,6 +1550,8 @@ set(DUSK_FILES src/dusk/mods/loader/loader.hpp src/dusk/mods/loader/native_module.cpp src/dusk/mods/loader/native_module.hpp + src/dusk/mods/svc/config.cpp + src/dusk/mods/svc/config.hpp src/dusk/mods/svc/host.cpp src/dusk/mods/svc/log.cpp src/dusk/mods/svc/registry.cpp diff --git a/include/mods/svc/config.h b/include/mods/svc/config.h new file mode 100644 index 0000000000..2a26210295 --- /dev/null +++ b/include/mods/svc/config.h @@ -0,0 +1,107 @@ +#pragma once + +#include "mods/api.h" + +#define CONFIG_SERVICE_ID "dev.twilitrealm.dusklight.config" +#define CONFIG_SERVICE_MAJOR 1u +#define CONFIG_SERVICE_MINOR 0u + +/* Handle for a config var registered by the calling mod. 0 is never a valid handle. */ +typedef uint64_t ConfigVarHandle; +/* Handle for a change subscription. 0 is never a valid handle. */ +typedef uint64_t ConfigSubscriptionHandle; + +typedef enum ConfigVarType { + CONFIG_VAR_BOOL = 0, /* bool */ + CONFIG_VAR_INT = 1, /* int64_t */ + CONFIG_VAR_FLOAT = 2, /* double */ + CONFIG_VAR_STRING = 3, /* UTF-8 */ +} ConfigVarType; + +typedef struct ConfigVarDesc { + uint32_t struct_size; + /* Name fragment: 1-64 characters from [A-Za-z0-9_-]. The full config key is + * "mod..", persisted in config.json alongside host settings. + * "enabled" is reserved by the loader. */ + const char* name; + ConfigVarType type; + /* Default value; only the field matching `type` is read. */ + bool default_bool; + int64_t default_int; + double default_float; + const char* default_string; /* NULL means "" */ +} ConfigVarDesc; + +#define CONFIG_VAR_DESC_INIT {sizeof(ConfigVarDesc), NULL, CONFIG_VAR_BOOL, false, 0, 0.0, NULL} + +/* Snapshot of a var's value; only the field matching `type` is meaningful. */ +typedef struct ConfigVarValue { + uint32_t struct_size; + ConfigVarType type; + bool bool_value; + int64_t int_value; + double float_value; + const char* string_value; /* NUL-terminated; NULL for non-string vars */ + size_t string_length; /* excludes the NUL */ +} ConfigVarValue; + +/* + * Fired on the game thread whenever the var's effective value changes at runtime: the calling mod's + * own set_* calls and any other runtime writer. Writes that leave the value unchanged do not fire, + * and neither do values applied from config.json or --cvar during registration. `value` holds the + * new (current) value and `previous` the one it replaced; both snapshots are valid only for the + * duration of the call (copy string_value if you need to keep it). Setting the same var from inside + * its own callback applies the write but is not re-notified. + */ +typedef void (*ConfigChangedFn)(ModContext* ctx, ConfigVarHandle var, const ConfigVarValue* value, + const ConfigVarValue* previous, void* user_data); + +/* + * Scoped configuration variables. + * + * Registrations are owned by the calling mod and removed automatically (subscriptions included) + * when it is disabled, reloaded, or fails. Values are saved to config.json. Writes are debounced, + * not flushed per set. + */ +typedef struct ConfigService { + ServiceHeader header; + + /* Register a config var. If a value for the full key was saved earlier (or set via --cvar), + * it takes effect immediately; otherwise the var starts at the default. Registering a name + * that is already live is MOD_CONFLICT. */ + ModResult (*register_var)( + ModContext* ctx, const ConfigVarDesc* desc, ConfigVarHandle* out_handle); + /* Unregister a var previously registered by the calling mod. Its persisted value is kept. */ + ModResult (*unregister_var)(ModContext* ctx, ConfigVarHandle var); + + /* Typed accessors; the type must match the registration (MOD_INVALID_ARGUMENT otherwise). */ + ModResult (*get_bool)(ModContext* ctx, ConfigVarHandle var, bool* out_value); + ModResult (*set_bool)(ModContext* ctx, ConfigVarHandle var, bool value); + ModResult (*get_int)(ModContext* ctx, ConfigVarHandle var, int64_t* out_value); + ModResult (*set_int)(ModContext* ctx, ConfigVarHandle var, int64_t value); + ModResult (*get_float)(ModContext* ctx, ConfigVarHandle var, double* out_value); + ModResult (*set_float)(ModContext* ctx, ConfigVarHandle var, double value); + /* Copies the NUL-terminated value into buffer. out_length (optional) receives the full + * length excluding the NUL regardless of buffer size; call with buffer == NULL and + * buffer_size == 0 to query the length. A non-NULL buffer that is too small fails with + * MOD_INVALID_ARGUMENT and writes nothing. */ + ModResult (*get_string)( + ModContext* ctx, ConfigVarHandle var, char* buffer, size_t buffer_size, size_t* out_length); + ModResult (*set_string)(ModContext* ctx, ConfigVarHandle var, const char* value); + + /* Subscribe to changes of a var registered by the calling mod. out_handle may be NULL if + * the subscription is never removed manually (cleanup on mod teardown is automatic). */ + ModResult (*subscribe)(ModContext* ctx, ConfigVarHandle var, ConfigChangedFn callback, + void* user_data, ConfigSubscriptionHandle* out_handle); + ModResult (*unsubscribe)(ModContext* ctx, ConfigSubscriptionHandle handle); +} ConfigService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = CONFIG_SERVICE_ID; + static constexpr uint16_t major_version = CONFIG_SERVICE_MAJOR; +}; +#endif diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index a8459717e6..00c2cc69e8 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -3,7 +3,6 @@ #include "dusk/mod_loader.hpp" #include -#include #include #include #include @@ -12,6 +11,7 @@ #include "depgraph.hpp" #include "dusk/config.hpp" +#include "dusk/mods/svc/config.hpp" #include "dusk/io.hpp" #include "dusk/mods/svc/registry.hpp" #include "miniz.h" @@ -966,31 +966,8 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { } } -namespace { -bool s_configDirty = false; -std::chrono::steady_clock::time_point s_lastConfigSave{}; -constexpr std::chrono::seconds kConfigSaveDebounce{2}; -} // namespace - -void config_mark_dirty() { - s_configDirty = true; -} - -void config_flush_if_dirty(const bool force) { - if (!s_configDirty) { - return; - } - const auto now = std::chrono::steady_clock::now(); - if (!force && now - s_lastConfigSave < kConfigSaveDebounce) { - return; - } - s_configDirty = false; - s_lastConfigSave = now; - config::save(); -} - void ModLoader::on_enabled_changed(LoadedMod& mod) { - config_mark_dirty(); + svc::config_mark_dirty(); if (mod.loadFailed) { return; } @@ -1073,7 +1050,6 @@ void ModLoader::tick() { } svc::modules_frame_end(); - config_flush_if_dirty(false); } void ModLoader::shutdown() { @@ -1091,7 +1067,6 @@ void ModLoader::shutdown() { drain_retired_natives(); svc::modules_shutdown(); clear_services(); - config_flush_if_dirty(true); Log.info("all mods unloaded"); } diff --git a/src/dusk/mods/loader/loader.hpp b/src/dusk/mods/loader/loader.hpp index cb81f1059b..6b9fd39232 100644 --- a/src/dusk/mods/loader/loader.hpp +++ b/src/dusk/mods/loader/loader.hpp @@ -61,7 +61,4 @@ void fail_mod(LoadedMod& mod, ModResult code, std::string_view message); bool is_safe_resource_path(std::string_view path); std::string escape_mod_id_for_config(std::string_view id); -void config_mark_dirty(); -void config_flush_if_dirty(bool force); - } // namespace dusk::mods diff --git a/src/dusk/mods/svc/config.cpp b/src/dusk/mods/svc/config.cpp new file mode 100644 index 0000000000..b7ea9c9e9d --- /dev/null +++ b/src/dusk/mods/svc/config.cpp @@ -0,0 +1,451 @@ +#include "config.hpp" + +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/config.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::config"); + +struct ModConfigVarEntry { + uint64_t handle = 0; + ConfigVarType type = CONFIG_VAR_BOOL; + std::unique_ptr var; +}; + +struct ModConfigSubscription { + uint64_t handle = 0; + uint64_t varHandle = 0; + config::Subscription coreSubscription = 0; +}; + +struct ModConfigRecord { + std::vector vars; + std::vector subscriptions; +}; + +std::unordered_map s_modConfig; +uint64_t s_nextHandle = 1; +bool s_dirty = false; +std::chrono::steady_clock::time_point s_lastSave{}; +constexpr std::chrono::seconds kSaveDebounce{2}; + +void config_flush_if_dirty(const bool force) { + if (!s_dirty) { + return; + } + const auto now = std::chrono::steady_clock::now(); + if (!force && now - s_lastSave < kSaveDebounce) { + return; + } + s_dirty = false; + s_lastSave = now; + config::save(); +} + +// Translate the type-erased previous-value pointer into the C ABI snapshot struct. The string +// pointer aliases the previous std::string, which outlives the notification. +ConfigVarValue translate_previous(const uint32_t type, const void* previous) { + ConfigVarValue value{}; + value.struct_size = sizeof(ConfigVarValue); + value.type = static_cast(type); + switch (type) { + case CONFIG_VAR_BOOL: + value.bool_value = *static_cast(previous); + break; + case CONFIG_VAR_INT: + value.int_value = *static_cast(previous); + break; + case CONFIG_VAR_FLOAT: + value.float_value = *static_cast(previous); + break; + case CONFIG_VAR_STRING: { + const auto* str = static_cast(previous); + value.string_value = str->c_str(); + value.string_length = str->size(); + break; + } + default: + break; + } + return value; +} + +// Snapshot the var's current (new) value for the notification. Strings are copied into +// stringStorage so the snapshot stays valid even if the callback writes the var again. +ConfigVarValue translate_current( + const uint32_t type, config::ConfigVarBase& varBase, std::string& stringStorage) { + ConfigVarValue value{}; + value.struct_size = sizeof(ConfigVarValue); + value.type = static_cast(type); + switch (type) { + case CONFIG_VAR_BOOL: + value.bool_value = static_cast&>(varBase).getValue(); + break; + case CONFIG_VAR_INT: + value.int_value = static_cast&>(varBase).getValue(); + break; + case CONFIG_VAR_FLOAT: + value.float_value = static_cast&>(varBase).getValue(); + break; + case CONFIG_VAR_STRING: + stringStorage = static_cast&>(varBase).getValue(); + value.string_value = stringStorage.c_str(); + value.string_length = stringStorage.size(); + break; + default: + break; + } + return value; +} + +bool valid_var_fragment(const char* name) { + if (name == nullptr) { + return false; + } + const std::string_view fragment{name}; + if (fragment.empty() || fragment.size() > 64) { + return false; + } + return std::ranges::all_of(fragment, [](char ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || + ch == '_' || ch == '-'; + }); +} + +config::ConfigVarBase* find_var(LoadedMod& mod, const uint64_t handle, uint32_t expectedType) { + const auto recordIt = s_modConfig.find(&mod); + if (recordIt == s_modConfig.end()) { + return nullptr; + } + const auto& vars = recordIt->second.vars; + const auto entry = + std::ranges::find_if(vars, [&](const auto& e) { return e.handle == handle; }); + if (entry == vars.end() || entry->type != expectedType) { + return nullptr; + } + return entry->var.get(); +} + +template +ConfigVar* find_typed_var(ModContext* context, ConfigVarHandle handle, uint32_t type) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return nullptr; + } + // The type tag was checked, so the downcast is safe. + return static_cast*>(find_var(*mod, handle, type)); +} + +void config_remove_mod(LoadedMod& mod) { + const auto it = s_modConfig.find(&mod); + if (it == s_modConfig.end()) { + return; + } + for (const auto& sub : it->second.subscriptions) { + config::unsubscribe(sub.coreSubscription); + } + for (const auto& entry : it->second.vars) { + config::unregister(*entry.var); + } + s_modConfig.erase(it); +} + +ModResult config_register_var( + ModContext* context, const ConfigVarDesc* desc, ConfigVarHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(ConfigVarDesc) || + !valid_var_fragment(desc->name)) + { + return MOD_INVALID_ARGUMENT; + } + + const auto fullName = + fmt::format("mod.{}.{}", escape_mod_id_for_config(mod->metadata.id), desc->name); + if (config::GetConfigVar(fullName) != nullptr) { + Log.error("[{}] config var '{}' conflicts with an existing config key", mod->metadata.id, + fullName); + return MOD_CONFLICT; + } + + std::unique_ptr var; + switch (desc->type) { + case CONFIG_VAR_BOOL: + var = std::make_unique >(fullName, desc->default_bool); + break; + case CONFIG_VAR_INT: + var = std::make_unique >(fullName, desc->default_int); + break; + case CONFIG_VAR_FLOAT: + var = std::make_unique >(fullName, desc->default_float); + break; + case CONFIG_VAR_STRING: + var = std::make_unique >( + fullName, desc->default_string != nullptr ? desc->default_string : ""); + break; + default: + return MOD_INVALID_ARGUMENT; + } + + // Back-fills a stashed/saved value (or a --cvar override) if one exists for this key. + // Loads apply silently: the registering mod cannot have subscribed to this var yet, and it + // reads the value right after registration anyway. + config::Register(*var); + + auto& record = s_modConfig[mod]; + auto& entry = record.vars.emplace_back(); + entry.handle = s_nextHandle++; + entry.type = desc->type; + entry.var = std::move(var); + if (outHandle != nullptr) { + *outHandle = entry.handle; + } + return MOD_OK; +} + +ModResult config_unregister_var(ModContext* context, ConfigVarHandle var) { + auto* mod = mod_from_context(context); + if (mod == nullptr || var == 0) { + return MOD_INVALID_ARGUMENT; + } + const auto recordIt = s_modConfig.find(mod); + if (recordIt != s_modConfig.end()) { + auto& record = recordIt->second; + const auto entry = + std::ranges::find_if(record.vars, [&](const auto& e) { return e.handle == var; }); + if (entry != record.vars.end()) { + std::erase_if(record.subscriptions, [&](const ModConfigSubscription& sub) { + if (sub.varHandle != var) { + return false; + } + config::unsubscribe(sub.coreSubscription); + return true; + }); + + // The persisted value is stashed and restored by a future registration of the + // same name. + config::unregister(*entry->var); + record.vars.erase(entry); + return MOD_OK; + } + } + Log.error("[{}] config unregister failed: unknown handle {}", mod->metadata.id, var); + return MOD_INVALID_ARGUMENT; +} + +ModResult config_get_bool(ModContext* context, ConfigVarHandle var, bool* outValue) { + if (outValue != nullptr) { + *outValue = false; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_BOOL); + if (cvar == nullptr || outValue == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outValue = cvar->getValue(); + return MOD_OK; +} + +ModResult config_set_bool(ModContext* context, ConfigVarHandle var, bool value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_BOOL); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(value); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_get_int(ModContext* context, ConfigVarHandle var, int64_t* outValue) { + if (outValue != nullptr) { + *outValue = 0; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_INT); + if (cvar == nullptr || outValue == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outValue = cvar->getValue(); + return MOD_OK; +} + +ModResult config_set_int(ModContext* context, ConfigVarHandle var, int64_t value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_INT); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(value); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_get_float(ModContext* context, ConfigVarHandle var, double* outValue) { + if (outValue != nullptr) { + *outValue = 0.0; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_FLOAT); + if (cvar == nullptr || outValue == nullptr) { + return MOD_INVALID_ARGUMENT; + } + *outValue = cvar->getValue(); + return MOD_OK; +} + +ModResult config_set_float(ModContext* context, ConfigVarHandle var, double value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_FLOAT); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(value); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_get_string( + ModContext* context, ConfigVarHandle var, char* buffer, size_t bufferSize, size_t* outLength) { + if (outLength != nullptr) { + *outLength = 0; + } + auto* cvar = find_typed_var(context, var, CONFIG_VAR_STRING); + if (cvar == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto& value = cvar->getValue(); + if (outLength != nullptr) { + *outLength = value.size(); + } + if (buffer == nullptr) { + // Length query; any other use of a null buffer is a caller bug. + return bufferSize == 0 ? MOD_OK : MOD_INVALID_ARGUMENT; + } + if (bufferSize < value.size() + 1) { + return MOD_INVALID_ARGUMENT; + } + std::memcpy(buffer, value.c_str(), value.size() + 1); + return MOD_OK; +} + +ModResult config_set_string(ModContext* context, ConfigVarHandle var, const char* value) { + auto* cvar = find_typed_var(context, var, CONFIG_VAR_STRING); + if (cvar == nullptr || value == nullptr) { + return MOD_INVALID_ARGUMENT; + } + cvar->setValue(std::string{value}); + config_mark_dirty(); + return MOD_OK; +} + +ModResult config_subscribe(ModContext* context, ConfigVarHandle var, ConfigChangedFn callback, + void* userData, ConfigSubscriptionHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || var == 0 || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto recordIt = s_modConfig.find(mod); + if (recordIt == s_modConfig.end()) { + return MOD_INVALID_ARGUMENT; + } + auto& record = recordIt->second; + const auto entry = + std::ranges::find_if(record.vars, [&](const auto& e) { return e.handle == var; }); + if (entry == record.vars.end()) { + return MOD_INVALID_ARGUMENT; + } + + auto& sub = record.subscriptions.emplace_back(); + sub.handle = s_nextHandle++; + sub.varHandle = var; + sub.coreSubscription = config::subscribe(entry->var->getName(), + [modPtr = mod, callback, userData, varHandle = var, type = entry->type]( + config::ConfigVarBase& varBase, const void* previous) { + const ConfigVarValue previousValue = translate_previous(type, previous); + std::string stringStorage; + const ConfigVarValue currentValue = translate_current(type, varBase, stringStorage); + try { + callback(modPtr->context.get(), varHandle, ¤tValue, &previousValue, userData); + } catch (const std::exception& e) { + fail_mod(*modPtr, MOD_ERROR, + fmt::format("exception in config change callback: {}", e.what())); + } catch (...) { + fail_mod(*modPtr, MOD_ERROR, "unknown exception in config change callback"); + } + }); + if (outHandle != nullptr) { + *outHandle = sub.handle; + } + return MOD_OK; +} + +ModResult config_unsubscribe(ModContext* context, ConfigSubscriptionHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + const auto recordIt = s_modConfig.find(mod); + if (recordIt != s_modConfig.end()) { + auto& subscriptions = recordIt->second.subscriptions; + const auto sub = + std::ranges::find_if(subscriptions, [&](const auto& s) { return s.handle == handle; }); + if (sub != subscriptions.end()) { + config::unsubscribe(sub->coreSubscription); + subscriptions.erase(sub); + return MOD_OK; + } + } + Log.error("[{}] config unsubscribe failed: unknown handle {}", mod->metadata.id, handle); + return MOD_INVALID_ARGUMENT; +} + +constexpr ConfigService s_configService{ + .header = SERVICE_HEADER(ConfigService, CONFIG_SERVICE_MAJOR, CONFIG_SERVICE_MINOR), + .register_var = config_register_var, + .unregister_var = config_unregister_var, + .get_bool = config_get_bool, + .set_bool = config_set_bool, + .get_int = config_get_int, + .set_int = config_set_int, + .get_float = config_get_float, + .set_float = config_set_float, + .get_string = config_get_string, + .set_string = config_set_string, + .subscribe = config_subscribe, + .unsubscribe = config_unsubscribe, +}; + +} // namespace + +void config_mark_dirty() { + s_dirty = true; +} + +constinit const ServiceModule g_configModule{ + .id = CONFIG_SERVICE_ID, + .majorVersion = CONFIG_SERVICE_MAJOR, + .minorVersion = CONFIG_SERVICE_MINOR, + .service = &s_configService, + .modDetached = config_remove_mod, + .frameEnd = [] { config_flush_if_dirty(false); }, + .shutdown = [] { config_flush_if_dirty(true); }, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/config.hpp b/src/dusk/mods/svc/config.hpp new file mode 100644 index 0000000000..657c1cbf14 --- /dev/null +++ b/src/dusk/mods/svc/config.hpp @@ -0,0 +1,9 @@ +#pragma once + +namespace dusk::mods::svc { + +// Marks the mods' config keys dirty for the config service's debounced save. The loader +// calls this for the enabled cvars it owns. +void config_mark_dirty(); + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 3951822bad..fd5909d996 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -199,6 +199,7 @@ void ModLoader::init_services() { { &svc::g_hostModule, &svc::g_logModule, + &svc::g_configModule, }) { svc::register_module(*module); diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 4f59aabff1..4954c97077 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -64,5 +64,6 @@ void modules_shutdown(); extern const ServiceModule g_hostModule; extern const ServiceModule g_logModule; +extern const ServiceModule g_configModule; } // namespace dusk::mods::svc From af5635dd42c16cc14626564e5eb7f95f26cd83ec Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 13:55:55 -0600 Subject: [PATCH 04/14] Resource, texture and overlay services --- cmake/ModSDK.cmake | 23 +- docs/modding.md | 221 ++++++++++--- extern/aurora | 2 +- files.cmake | 3 + include/dusk/mod_loader.hpp | 2 +- include/dusk/texture_replacements.hpp | 5 + include/mods/svc/overlay.h | 57 ++++ include/mods/svc/resource.h | 52 +++ include/mods/svc/texture.h | 88 +++++ src/dusk/mods/svc/overlay.cpp | 351 ++++++++++++++++++++ src/dusk/mods/svc/registry.cpp | 3 + src/dusk/mods/svc/registry.hpp | 3 + src/dusk/mods/svc/resource.cpp | 102 ++++++ src/dusk/mods/svc/texture.cpp | 455 ++++++++++++++++++++++++++ src/dusk/texture_replacements.cpp | 3 +- 15 files changed, 1322 insertions(+), 48 deletions(-) create mode 100644 include/mods/svc/overlay.h create mode 100644 include/mods/svc/resource.h create mode 100644 include/mods/svc/texture.h create mode 100644 src/dusk/mods/svc/overlay.cpp create mode 100644 src/dusk/mods/svc/resource.cpp create mode 100644 src/dusk/mods/svc/texture.cpp diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index e15e892a58..909e8abcba 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -1,4 +1,5 @@ -# add_mod( SOURCES ... MOD_JSON [RES_DIR ] [OUTPUT_DIR ] [BUNDLE]) +# add_mod( SOURCES ... MOD_JSON [RES_DIR ] [OVERLAY_DIR ] +# [TEXTURES_DIR ] [OUTPUT_DIR ] [BUNDLE]) set(DUSK_MODS_OUTPUT_DIR "${CMAKE_BINARY_DIR}/mods" CACHE PATH "Directory to write mod packages into") function(_mod_lib_name out_var) @@ -44,7 +45,7 @@ function(_mod_collect_assets out_var dir) endfunction() function(add_mod target_name) - cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OUTPUT_DIR" "SOURCES" ${ARGN}) + cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OVERLAY_DIR;TEXTURES_DIR;OUTPUT_DIR" "SOURCES" ${ARGN}) if (NOT ARG_MOD_JSON) message(FATAL_ERROR "add_mod: MOD_JSON is required") endif () @@ -104,6 +105,24 @@ function(add_mod target_name) list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory "${_res_dir}" "${_stage}/res") endif () + if (ARG_OVERLAY_DIR) + _mod_resolve_source_path(_overlay_dir "${ARG_OVERLAY_DIR}") + _mod_collect_assets(_overlay_deps "${_overlay_dir}") + list(APPEND _package_deps ${_overlay_deps}) + list(APPEND _package_inputs "${_overlay_dir}" ${_overlay_deps}) + list(APPEND _zip_args overlay) + list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_overlay_dir}" "${_stage}/overlay") + endif () + if (ARG_TEXTURES_DIR) + _mod_resolve_source_path(_textures_dir "${ARG_TEXTURES_DIR}") + _mod_collect_assets(_textures_deps "${_textures_dir}") + list(APPEND _package_deps ${_textures_deps}) + list(APPEND _package_inputs "${_textures_dir}" ${_textures_deps}) + list(APPEND _zip_args textures) + list(APPEND _extra_cmds COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_textures_dir}" "${_stage}/textures") + endif () set(_bundle_cmds "") if (ARG_BUNDLE AND TARGET dusklight) diff --git a/docs/modding.md b/docs/modding.md index 28fc19af5f..77becddf0b 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1,8 +1,10 @@ # Dusklight Mod API -Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code libraries and resources. +Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code +libraries and resources. -Everything a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and mods can define their own to talk to each other. +Everything a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and mods +can define their own to talk to each other. ## Table of Contents @@ -11,22 +13,25 @@ Everything a mod does goes through **services**: small, versioned C APIs. Duskli 3. [Anatomy of a Code Mod](#anatomy-of-a-code-mod) 4. [Services](#services) 5. [Built-in Services](#built-in-services) -6. [Runtime Lifecycle](#runtime-lifecycle) -7. [Error Handling](#error-handling) -8. [Advanced: Exporting Services](#advanced-exporting-services) +6. [Asset Overlays](#asset-overlays) +7. [Runtime Lifecycle](#runtime-lifecycle) +8. [Error Handling](#error-handling) +9. [Advanced: Exporting Services](#advanced-exporting-services) --- ## Getting Started -Fork the [mod template](../tools/mod_template/), a self-contained CMake project that uses the Dusklight mod SDK: +Fork the [mod template](../tools/mod_template/), a self-contained CMake project that uses the Dusklight mod SDK. ``` my_mod/ ├── CMakeLists.txt ├── mod.json ├── src/mod.cpp -└── res/ (optional bundled resources) +├── res/ (optional bundled resources) +├── overlay/ (optional game file overrides) +└── textures/ (optional texture replacements) ``` **CMakeLists.txt:** @@ -39,19 +44,23 @@ set(DUSKLIGHT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dusklight" CACHE PATH "Path to du add_subdirectory("${DUSKLIGHT_DIR}/sdk" dusklight-sdk EXCLUDE_FROM_ALL) add_mod(my_mod - SOURCES src/mod.cpp - MOD_JSON mod.json - RES_DIR res # optional + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res # optional + OVERLAY_DIR overlay # optional + TEXTURES_DIR textures # optional ) ``` -Building produces `my_mod.dusk` in `build//mods/` (configurable via the `DUSK_MODS_OUTPUT_DIR` cache variable). Copy it into the game's mods folder and launch: +Building produces `my_mod.dusk` in `build//mods/` (configurable via the `DUSK_MODS_OUTPUT_DIR` cache variable). +Copy it into the game's mods folder and launch: - Windows: `%APPDATA%\TwilitRealm\Dusklight\mods` - Linux: `~/.local/share/TwilitRealm/Dusklight/mods` - macOS: `~/Library/Application Support/TwilitRealm/Dusklight/mods` -You can also pass `--mods ` on the command line, which is handy during development. Mods will load from there instead of the user directory above. +You can also pass `--mods ` on the command line, which is handy during development. Mods will load from there +instead of the user directory above. --- @@ -59,19 +68,21 @@ You can also pass `--mods ` on the command line, which is handy during deve ```json { - "id": "com.example.my_mod", - "name": "My Mod", - "version": "1.0.0", - "author": "Your Name", - "description": "A short description shown in the mod manager.", - "icon": "res/my_icon.png", - "banner": "res/my_banner.png" + "id": "com.example.my_mod", + "name": "My Mod", + "version": "1.0.0", + "author": "Your Name", + "description": "A short description shown in the mod manager.", + "icon": "res/my_icon.png", + "banner": "res/my_banner.png" } ``` -`id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and alphanumerics). Everything else is optional but recommended. +`id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and alphanumerics). Everything +else is optional but recommended. -`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. 512x512), the banner (roughly 3.5:1). +`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. 512x512), +the banner (roughly 3.5:1). Both keys are optional; if omitted, `res/icon.png` and `res/banner.png` are used automatically when present. --- @@ -102,13 +113,15 @@ MOD_EXPORT ModResult mod_shutdown(ModError* error) { } ``` -All three lifecycle exports are required. `mod_ctx` is your mod's identity token, set by the loader before `mod_initialize` runs. Pass it as the first argument to every service call. +All three lifecycle exports are required. `mod_ctx` is your mod's identity token, set by the loader before +`mod_initialize` runs. Pass it as the first argument to every service call. --- ## Services -A service is a struct of C function pointers with a version header. You declare what you use at file scope, and the loader resolves it before your mod initializes: +A service is a struct of C function pointers with a version header. You declare what you use at file scope, and the +loader resolves it before your mod initializes: ```cpp IMPORT_SERVICE(LogService, svc_log); // required, any minor version @@ -118,12 +131,15 @@ IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null The rules (see `include/mods/api.h` for the full contract): -- **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear error. No need to null check at call sites. +- **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear + error. No need to null check at call sites. - **Anything at or below the minor version you imported can be called unconditionally.** - Optional imports may be null; check once in `mod_initialize`. -- Fields newer than your imported minor must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a null check. +- Fields newer than your imported minor must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a null + check. -Service versions follow one rule: a **major** bump is a breaking change (treated as a different service entirely), a **minor** bump only appends functions. +Service versions follow one rule: a **major** bump is a breaking change (treated as a different service entirely), a * +*minor** bump only appends functions. --- @@ -140,7 +156,26 @@ svc_log->error(mod_ctx, "very bad"); svc_log->write(mod_ctx, LOG_LEVEL_DEBUG, "verbose details"); ``` -Messages appear in the console prefixed with your mod ID. Messages are plain strings: use `snprintf` or `fmt::format` for formatting. +Messages appear in the console prefixed with your mod ID. Messages are plain strings: use `snprintf` or `fmt::format` +for formatting. + +### ResourceService (`mods/svc/resource.h`) + +Loads files from the `res/` tree of your `.dusk` archive. Paths are relative to `res/` (pass `"config.txt"`, not +`"res/config.txt"`); absolute paths and `..` are rejected. + +```cpp +IMPORT_SERVICE(ResourceService, svc_resource); + +ResourceBuffer buf = RESOURCE_BUFFER_INIT; +if (svc_resource->load(mod_ctx, "config.txt", &buf) == MOD_OK) { + // buf.data / buf.size + svc_resource->free(mod_ctx, &buf); +} +``` + +Missing files return `MOD_UNAVAILABLE`. Always `free` what you `load`. For writable storage, use the directory from +`svc_host->mod_dir(mod_ctx)`. ### HostService (`mods/svc/host.h`) @@ -156,8 +191,8 @@ svc_host->fail(mod_ctx, MOD_ERROR, "something unrecoverable happened"); // disa `get_service`/`publish_service` provide dynamic service lookup; see [Advanced](#advanced-exporting-services). -**Lifecycle watches.** If your mod provides a service that hands out per-caller state (registrations, callbacks, handles), -watch other mods' lifecycle and drop what you hold for a mod when it detaches. +**Lifecycle watches.** If your mod provides a service that hands out per-caller state (registrations, callbacks, +handles), watch other mods' lifecycle and drop what you hold for a mod when it detaches. ```cpp IMPORT_SERVICE_VERSION(HostService, svc_host, 1); @@ -176,6 +211,59 @@ svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch); `MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead. +### OverlayService (`mods/svc/overlay.h`) + +Registers DVD file overlays at runtime. The dynamic counterpart to the static `overlay/` directory ( +see [Asset Overlays](#asset-overlays)). Overlay a disc path with a file from your bundle, or with a caller-owned +buffer (copied on registration): + +```cpp +IMPORT_SERVICE(OverlayService, svc_overlay); + +OverlayHandle handle = 0; +svc_overlay->add_file(mod_ctx, "/res/Msgus.arc", "res/replacement.arc", &handle); +svc_overlay->add_buffer(mod_ctx, "/generated.txt", data, size, nullptr); +svc_overlay->remove(mod_ctx, handle); +``` + +`disc_path` must be absolute (leading `/`) and is matched against the disc case-insensitively. Paths that don't exist on +the disc are added as new files. Changes are applied at the next frame boundary, and data the game already read stays in +memory until the file is re-read (sometimes a scene reload, sometimes never; a restart may be required). + +See [Asset Overlays](#asset-overlays) for priority and conflict handling. + +### TextureService (`mods/svc/texture.h`) + +Registers texture replacements at runtime. The dynamic counterpart to the static `textures/` directory ( +see [Asset Overlays](#asset-overlays)). Two forms: raw texel data with an explicit key, or an encoded `.dds`/`.png` from +your bundle whose filename encodes the key: + +```cpp +IMPORT_SERVICE(TextureService, svc_texture); + +// Encoded file; filename follows the replacement naming convention. +TextureReplacementHandle handle = 0; +svc_texture->register_file(mod_ctx, "res/tex1_32x32_$_6.png", &handle); + +// Raw data: match by texel-data pointer or by content hash (TEXTURE_KEY_SOURCE). +TextureKey key = TEXTURE_KEY_INIT; +key.kind = TEXTURE_KEY_POINTER; +key.pointer = someTexObj.data; +TextureData data = TEXTURE_DATA_INIT; +data.data = pixels; data.size = pixelsSize; +data.width = 32; data.height = 32; data.gx_format = GX_TF_RGBA8_PC; +svc_texture->register_data(mod_ctx, &key, &data, nullptr); + +svc_texture->unregister(mod_ctx, handle); +``` + +Filenames use the same convention as the user's `texture_replacements` directory: +`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, where hashes may be `$` (wildcard). `_mipN` sidecar files next to +a registered file are picked up automatically. Files are decoded lazily on first use by the renderer; raw data is copied +at registration. Registrations follow your mod's lifecycle. + +See [Asset Overlays](#asset-overlays) for priority and conflict handling. + ### ConfigService (`mods/svc/config.h`) Persistent, mod-scoped configuration variables. Each var is stored in the user's `config.json` under @@ -214,21 +302,56 @@ Writes that store the same value are silent. Values applied from `config.json` o --- +## Asset Overlays + +Files placed under `overlay/` in the `.dusk` archive override game files at the corresponding path. For example, +`overlay/res/Stage/...` shadows that file on the game disc image. This requires no code: an archive with just +`mod.json` and `overlay/` is a complete mod. + +Files placed under `textures/` register as texture replacements the same way. Filenames follow the replacement naming +convention (`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, `$` as a hash wildcard). Subdirectories are scanned +recursively; only the filename needs to match. + +Both follow the mod's lifecycle: disabling the mod removes its overrides (files revert to the disc contents on their +next open; added files stop existing), and reloading serves the new bundle's content. Game data the engine already read +stays as-is until it is loaded again, which may require a scene reload or a full restart. Texture replacements usually +take effect immediately. + +If multiple sources replace the same file or texture, the last one wins: runtime registrations override static +`textures/` or `overlay/` files, and later-loaded mods override earlier ones. Cross-mod conflicts log warnings. +**All** mod-provided texture replacements override the user's `texture_replacements/`. + +To configure overlays and texture replacements at runtime instead, see [OverlayService](#overlayservice-modssvcoverlayh) +and [TextureService](#textureservice-modssvctextureh). + +--- + ## Runtime Lifecycle -Mods can be disabled, re-enabled, and reloaded at runtime without restarting the game (the enabled state persists as the `mod..enabled` config var). Write your mod assuming this happens: +Mods can be disabled, re-enabled, and reloaded at runtime without restarting the game (the enabled state persists as the +`mod..enabled` config var). Write your mod assuming this happens: -- **Disable** calls `mod_shutdown`, removes your services, and unloads your library. -- **Enable** and **Reload** load a *fresh copy* of your library, imports are re-resolved, and `mod_initialize` runs again. You never see a second `mod_initialize` on the same image, so just make `mod_shutdown` release anything the loader doesn't manage for you (threads, files, game-side state you mutated). -- **Reload** additionally re-reads the `.dusk` from disk, picking up a rebuilt library and changed assets. This is the fast iteration loop during development: rebuild, click Reload. +- **Disable** calls `mod_shutdown`, removes your services, overlays, and texture replacements (both static and + runtime-registered), and unloads your library. +- **Enable** and **Reload** load a *fresh copy* of your library, imports are re-resolved, and `mod_initialize` runs + again. You never see a second `mod_initialize` on the same image, so just make `mod_shutdown` release anything the + loader doesn't manage for you (threads, files, game-side state you mutated). +- **Reload** additionally re-reads the `.dusk` from disk, picking up a rebuilt library and changed assets. This is the + fast iteration loop during development: rebuild, click Reload. -**Dependents restart too.** Disabling or reloading a mod that exports services shuts down the mods importing them first (in reverse dependency order) and brings them back afterward. A mod whose *required* provider is disabled stays suspended and resumes automatically when the provider returns. Mods with an *optional* import of a disabled provider restart with that import null. +**Dependents restart too.** Disabling or reloading a mod that exports services shuts down the mods importing them +first (in reverse dependency order) and brings them back afterward. A mod whose *required* provider is disabled stays +suspended and resumes automatically when the provider returns. Mods with an *optional* import of a disabled provider +restart with that import null. --- ## Error Handling -Service calls report failure through `ModResult` return values (`MOD_OK`, `MOD_UNAVAILABLE`, `MOD_INVALID_ARGUMENT`, ...). Lifecycle exports additionally receive a `ModError*`: fill it (e.g. with `dusk::mods::set_error(error, code, "message")`) and return the code, and the loader disables the mod and shows the message to the user. +Service calls report failure through `ModResult` return values (`MOD_OK`, `MOD_UNAVAILABLE`, +`MOD_INVALID_ARGUMENT`, ...). Lifecycle exports additionally receive a `ModError*`: fill it (e.g. with +`dusk::mods::set_error(error, code, "message")`) and return the code, and the loader disables the mod and shows the +message to the user. ```cpp MOD_EXPORT ModResult mod_initialize(ModError* error) { @@ -239,7 +362,8 @@ MOD_EXPORT ModResult mod_initialize(ModError* error) { } ``` -Throwing exceptions out of lifecycle functions also disables the mod (they are caught by the loader), but prefer explicit results. +Throwing exceptions out of lifecycle functions also disables the mod (they are caught by the loader), but prefer +explicit results. --- @@ -291,17 +415,24 @@ IMPORT_SERVICE(MyModService, svc_my_mod); svc_my_mod->do_thing(mod_ctx, 42); ``` -The loader registers all exports before resolving any imports, so declaration order between mods doesn't matter. Note that the `ctx` a provider receives identifies the *calling* mod. +The loader registers all exports before resolving any imports, so declaration order between mods doesn't matter. Note +that the `ctx` a provider receives identifies the *calling* mod. ### Dependencies between mods -Service imports are also dependency declarations: the loader initializes mods in dependency order, so by the time your `mod_initialize` runs, every mod you import services from (required *or* optional) has already finished its own `mod_initialize`. This includes deferred services: a service the provider publishes during its initialization resolves into your import slot just like a static export. +Service imports are also dependency declarations: the loader initializes mods in dependency order, so by the time your +`mod_initialize` runs, every mod you import services from (required *or* optional) has already finished its own +`mod_initialize`. This includes deferred services: a service the provider publishes during its initialization resolves +into your import slot just like a static export. Consequences of that contract: -- If a provider fails to load, every mod that *requires* one of its services is disabled too, with an error naming the provider. Optional imports of a failed provider simply resolve to `NULL`. -- Mods whose **required** imports form a cycle all fail to load. If the cycle runs through an **optional** import, the loader breaks it there: the optional import still resolves, but its provider may not be initialized yet when you run. -- `svc_host->get_service(...)` is outside this system. It sees whatever is published at call time and gives no initialization-order guarantee, which also makes it the escape hatch for intentionally cyclic designs. +- If a provider fails to load, every mod that *requires* one of its services is disabled too, with an error naming the + provider. Optional imports of a failed provider simply resolve to `NULL`. +- Mods whose **required** imports form a cycle all fail to load. If the cycle runs through an **optional** import, the + loader breaks it there: the optional import still resolves, but its provider may not be initialized yet when you run. +- `svc_host->get_service(...)` is outside this system. It sees whatever is published at call time and gives no + initialization-order guarantee, which also makes it the escape hatch for intentionally cyclic designs. Mods shut down in reverse initialization order, so services you import remain safe to call from `mod_shutdown`. @@ -309,7 +440,11 @@ Rules for providers: - Service IDs are global and use reverse-DNS names (e.g. `com.mydomain.mod.service`) - Every function pointer covered by your declared minor version must be populated. -- Within a major version, only append fields; never reorder, remove, or repurpose them. Breaking changes require a major bump (which is, in effect, a new service). +- Within a major version, only append fields; never reorder, remove, or repurpose them. Breaking changes require a major + bump (which is, in effect, a new service). - Only one provider per `(id, major)` pair may be registered; duplicates are load errors. -For services whose construction can't happen at static-init time, declare the export with `EXPORT_DEFERRED_SERVICE(...)` and publish the pointer later via `svc_host->publish_service(...)`. Consumers can fetch services dynamically with `svc_host->get_service(...)`; prefer manifest imports whenever possible, since they give the loader dependency information and fail fast with good errors. +For services whose construction can't happen at static-init time, declare the export with `EXPORT_DEFERRED_SERVICE(...)` +and publish the pointer later via `svc_host->publish_service(...)`. Consumers can fetch services dynamically with +`svc_host->get_service(...)`; prefer manifest imports whenever possible, since they give the loader dependency +information and fail fast with good errors. diff --git a/extern/aurora b/extern/aurora index 9087a409da..0a5a5d90ef 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 9087a409da35b17446af12d7456ec6563cf2dd43 +Subproject commit 0a5a5d90efd2dbc7b402ab61f1021922dbf7496f diff --git a/files.cmake b/files.cmake index fb4b8dc25c..03d7a3af66 100644 --- a/files.cmake +++ b/files.cmake @@ -1554,6 +1554,9 @@ set(DUSK_FILES src/dusk/mods/svc/config.hpp src/dusk/mods/svc/host.cpp src/dusk/mods/svc/log.cpp + src/dusk/mods/svc/overlay.cpp + src/dusk/mods/svc/resource.cpp + src/dusk/mods/svc/texture.cpp src/dusk/mods/svc/registry.cpp src/dusk/mods/svc/registry.hpp src/dusk/discord.cpp diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 20b4de8e70..7f9d1ad340 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -153,7 +153,7 @@ struct LoadedMod { std::unique_ptr native; std::unique_ptr context; - // Shared so in-flight readers keep their bundle alive across disable/reload. + // Shared with overlay file registrations so in-flight DVD reads survive disable/reload. std::shared_ptr bundle; ModManifestInfo manifestInfo; diff --git a/include/dusk/texture_replacements.hpp b/include/dusk/texture_replacements.hpp index ffb3fe8d62..85cb59b001 100644 --- a/include/dusk/texture_replacements.hpp +++ b/include/dusk/texture_replacements.hpp @@ -1,8 +1,13 @@ #ifndef DUSK_TEXTURE_REPLACEMENTS_HPP #define DUSK_TEXTURE_REPLACEMENTS_HPP +#include + namespace dusk::texture_replacements { +// Mod replacements are prioritized *over* user replacements (/texture_replacements/) +inline constexpr int32_t kUserTextureReplacementPriority = -1'000'000; + void reload(); void set_enabled(bool enabled); void shutdown(); diff --git a/include/mods/svc/overlay.h b/include/mods/svc/overlay.h new file mode 100644 index 0000000000..9fc86fc850 --- /dev/null +++ b/include/mods/svc/overlay.h @@ -0,0 +1,57 @@ +#pragma once + +#include "mods/api.h" + +#define OVERLAY_SERVICE_ID "dev.twilitrealm.dusklight.overlay" +#define OVERLAY_SERVICE_MAJOR 1u +#define OVERLAY_SERVICE_MINOR 0u + +/* Handle for a runtime overlay registration. 0 is never a valid handle. */ +typedef uint64_t OverlayHandle; + +/* + * Runtime DVD file overlays. + * + * Registrations are owned by the calling mod and removed automatically when it is disabled, + * reloaded, or fails. Changes are applied at the next frame boundary; data the game has already + * read stays in memory until it re-reads the file (sometimes on scene reload, sometimes on + * restart). + * + * disc_path names the file to overlay: absolute with a leading '/', matched against the disc + * case-insensitively (e.g. "/res/Stage/R04_00.arc"). Paths that do not exist on the disc are added + * as new files. + * + * If multiple sources overlay the same path, the last one wins: a mod's runtime registrations beat + * its static overlay/ files, and later-loaded mods beat earlier ones. + */ +typedef struct OverlayService { + ServiceHeader header; + + /* + * Overlay disc_path with a file from the calling mod's bundle (bundle-relative path, e.g. + * "res/replacement.arc"). The file's contents are read lazily on each open, so the bundle + * file must not change size while registered. + */ + ModResult (*add_file)( + ModContext* ctx, const char* disc_path, const char* bundle_path, OverlayHandle* out_handle); + + /* + * Overlay disc_path with a caller-owned buffer. The data is copied; the caller may free it + * as soon as this returns. + */ + ModResult (*add_buffer)(ModContext* ctx, const char* disc_path, const void* data, size_t size, + OverlayHandle* out_handle); + + /* Remove a runtime overlay previously added by the calling mod. */ + ModResult (*remove)(ModContext* ctx, OverlayHandle handle); +} OverlayService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = OVERLAY_SERVICE_ID; + static constexpr uint16_t major_version = OVERLAY_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/resource.h b/include/mods/svc/resource.h new file mode 100644 index 0000000000..1173b2ab1d --- /dev/null +++ b/include/mods/svc/resource.h @@ -0,0 +1,52 @@ +#pragma once + +#include "mods/api.h" + +/* + * Read-only access to the res/ tree of the calling mod's own bundle. Reload serves the new + * bundle's contents. For writable storage, use HostService::mod_dir. + */ + +#define RESOURCE_SERVICE_ID "dev.twilitrealm.dusklight.resource" +#define RESOURCE_SERVICE_MAJOR 1u +#define RESOURCE_SERVICE_MINOR 0u + +/* + * A loaded resource, allocated by the service. Return every successful load with free; + * buffers still live when the mod is disabled or reloaded are reclaimed with a warning. + */ +typedef struct ResourceBuffer { + uint32_t struct_size; + void* data; + size_t size; +} ResourceBuffer; + +#define RESOURCE_BUFFER_INIT {sizeof(ResourceBuffer), NULL, 0u} + +typedef struct ResourceService { + ServiceHeader header; + + /* + * Load a file into a fresh allocation. `relative_path` is resolved against the bundle's + * res/ directory. Absolute paths and ".." are rejected. MOD_UNAVAILABLE if the file does not + * exist. An empty file loads as data == NULL with size 0. Previous contents of `out_buffer` are + * overwritten, not freed. + */ + ModResult (*load)(ModContext* ctx, const char* relative_path, ResourceBuffer* out_buffer); + + /* + * Release a loaded buffer and reset it to the empty state. Safe to call on an empty or + * already-freed buffer. + */ + void (*free)(ModContext* ctx, ResourceBuffer* buffer); +} ResourceService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = RESOURCE_SERVICE_ID; + static constexpr uint16_t major_version = RESOURCE_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/texture.h b/include/mods/svc/texture.h new file mode 100644 index 0000000000..d664c5f99d --- /dev/null +++ b/include/mods/svc/texture.h @@ -0,0 +1,88 @@ +#pragma once + +#include "mods/api.h" + +#define TEXTURE_SERVICE_ID "dev.twilitrealm.dusklight.texture" +#define TEXTURE_SERVICE_MAJOR 1u +#define TEXTURE_SERVICE_MINOR 0u + +/* Handle for a runtime texture replacement registration. 0 is never a valid handle. */ +typedef uint64_t TextureReplacementHandle; + +typedef enum TextureKeyKind { + /* Match a texture by the address of its in-memory GX texel data. */ + TEXTURE_KEY_POINTER = 0, + /* Match by content: XXH64 of the base mip level (and of the referenced TLUT range for + * palette formats), as encoded in replacement filenames / texture dumps. */ + TEXTURE_KEY_SOURCE = 1, +} TextureKeyKind; + +/* Wildcard values for TEXTURE_KEY_SOURCE hashes ("$" in the filename convention). */ +#define TEXTURE_HASH_WILDCARD UINT64_C(0xFFFFFFFFFFFFFFFF) +#define TEXTURE_TLUT_WILDCARD UINT64_C(0xFFFFFFFFFFFFFFFE) + +typedef struct TextureKey { + uint32_t struct_size; + TextureKeyKind kind; + const void* pointer; /* TEXTURE_KEY_POINTER only */ + uint64_t texture_hash; /* TEXTURE_KEY_SOURCE */ + uint64_t tlut_hash; /* TEXTURE_KEY_SOURCE, palette formats only */ + uint32_t width; + uint32_t height; + uint32_t gx_format; + bool has_tlut; +} TextureKey; + +#define TEXTURE_KEY_INIT {sizeof(TextureKey), TEXTURE_KEY_POINTER, NULL, 0u, 0u, 0u, 0u, 0u, false} + +typedef struct TextureData { + uint32_t struct_size; + const void* data; /* texel data laid out in gx_format; copied by the service */ + size_t size; + uint32_t width; + uint32_t height; + uint32_t mip_count; + uint32_t gx_format; /* any GX texture format supported by Aurora's converter */ +} TextureData; + +#define TEXTURE_DATA_INIT {sizeof(TextureData), NULL, 0u, 0u, 0u, 1u, 0u} + +/* + * Runtime texture replacements. + * + * Registrations are owned by the calling mod and removed automatically when it is disabled, + * reloaded, or fails. When multiple sources replace the same texture, the highest priority wins: + * later-loaded mods beat earlier ones, and any mod beats the user's texture_replacements config + * directory. Files shipped in a mod's textures/ directory register automatically with the same + * ownership and priority; this service is for replacements decided at runtime. + */ +typedef struct TextureService { + ServiceHeader header; + + /* Register a replacement from raw texel data. The data is copied; the caller may free it as + * soon as this returns. */ + ModResult (*register_data)(ModContext* ctx, const TextureKey* key, const TextureData* data, + TextureReplacementHandle* out_handle); + + /* + * Register a replacement from an encoded .dds/.png inside the calling mod's bundle. The + * filename encodes the key (same convention as the texture_replacements directory, e.g. + * "tex1_{w}x{h}_{hash}_{fmt}.dds"); "_mipN" sidecars next to it are picked up automatically. + * The file is decoded lazily on first use by the renderer. + */ + ModResult (*register_file)(ModContext* ctx, const char* bundle_path, + TextureReplacementHandle* out_handle); + + /* Remove a replacement previously registered by the calling mod. */ + ModResult (*unregister)(ModContext* ctx, TextureReplacementHandle handle); +} TextureService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = TEXTURE_SERVICE_ID; + static constexpr uint16_t major_version = TEXTURE_SERVICE_MAJOR; +}; +#endif diff --git a/src/dusk/mods/svc/overlay.cpp b/src/dusk/mods/svc/overlay.cpp new file mode 100644 index 0000000000..13cab1ac0f --- /dev/null +++ b/src/dusk/mods/svc/overlay.cpp @@ -0,0 +1,351 @@ +#include "registry.hpp" + +#include "aurora/dvd.h" +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/overlay.h" + +#include +#include +#include +#include +#include +#include + +using namespace std::string_literals; + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::overlay"); + +struct OverlayFileData { + std::string bundlePath; + std::shared_ptr bundle; + std::shared_ptr > buffer; +}; + +// Keyed by the id passed to Aurora as per-file userdata. Guarded by s_overlayMutex: Aurora may +// call cbOpen from a DVD thread while the game thread replaces the set in overlay_sync_files. +// The shared bundle/buffer pointer keeps a disabled/reloaded mod's data readable until the last +// open completes. +std::unordered_map s_overlayFiles; +uintptr_t s_nextOverlayId = 1; +std::mutex s_overlayMutex; + +struct RuntimeOverlayEntry { + uint64_t handle = 0; + std::string discPath; + std::string bundlePath; // bundle-backed if non-empty + std::shared_ptr> buffer; // buffer-backed otherwise + size_t size = 0; +}; +std::unordered_map> s_runtimeOverlays; +uint64_t s_nextRuntimeHandle = 1; +bool s_overlaysDirty = false; + +// Aurora matches overlay paths against the disc case-insensitively and later entries win, so +// claims are tracked by lowercased path and re-claims by a different mod warn. +void claim_overlay_path(std::unordered_map& claims, + const std::string& discPath, const LoadedMod& mod) { + std::string key = discPath; + for (auto& ch : key) { + if (ch >= 'A' && ch <= 'Z') { + ch += 'a' - 'A'; + } + } + const auto [it, inserted] = claims.try_emplace(std::move(key), &mod); + if (!inserted && it->second != &mod) { + Log.warn("Overlay conflict: '{}' is provided by both '{}' and '{}'; '{}' wins.", discPath, + it->second->metadata.id, mod.metadata.id, mod.metadata.id); + it->second = &mod; + } +} + +void find_overlay_files(std::vector& files, LoadedMod& mod, + std::unordered_map& claims) { + 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 id = s_nextOverlayId++; + s_overlayFiles.emplace(id, OverlayFileData{file, mod.bundle, nullptr}); + claim_overlay_path(claims, overlayPath, mod); + files.emplace_back(strdup(overlayPath.c_str()), reinterpret_cast(id), size); + } +} + +void append_runtime_overlays(std::vector& files, LoadedMod& mod, + std::unordered_map& claims) { + const auto it = s_runtimeOverlays.find(&mod); + if (it == s_runtimeOverlays.end()) { + return; + } + + for (const auto& entry : it->second) { + const auto id = s_nextOverlayId++; + if (entry.buffer != nullptr) { + s_overlayFiles.emplace(id, OverlayFileData{{}, nullptr, entry.buffer}); + } else { + s_overlayFiles.emplace(id, OverlayFileData{entry.bundlePath, mod.bundle, nullptr}); + } + claim_overlay_path(claims, entry.discPath, mod); + files.emplace_back(strdup(entry.discPath.c_str()), reinterpret_cast(id), entry.size); + } +} + +struct OpenOverlayFile { + std::vector ownedData; + std::shared_ptr > shared; + size_t pos = 0; + + [[nodiscard]] const std::vector& data() const { + return shared != nullptr ? *shared : ownedData; + } +}; + +void* cbOpen(void* userdata) { + const auto id = reinterpret_cast(userdata); + OverlayFileData fileData; + { + std::lock_guard lock{s_overlayMutex}; + const auto it = s_overlayFiles.find(id); + if (it == s_overlayFiles.end()) { + // The overlay set was re-pushed between the FST lookup and this call. + return nullptr; + } + fileData = it->second; + } + + if (fileData.buffer != nullptr) { + return new OpenOverlayFile{.shared = std::move(fileData.buffer)}; + } + + try { + auto fileContents = fileData.bundle->readFile(fileData.bundlePath); + return new OpenOverlayFile{.ownedData = std::move(fileContents)}; + } catch (const std::runtime_error& e) { + Log.error("Failed to read overlay file {}: {}", fileData.bundlePath, e.what()); + return nullptr; + } +} + +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, +}; + +void overlay_sync_files() { + static bool callbacksRegistered = false; + if (!callbacksRegistered) { + aurora_dvd_overlay_callbacks(&s_overlayCallbacks); + callbacksRegistered = true; + } + + s_overlaysDirty = false; + + std::vector files; + std::unordered_map claims; + { + std::lock_guard lock{s_overlayMutex}; + s_overlayFiles.clear(); + for (auto& mod : ModLoader::instance().active_mods()) { + find_overlay_files(files, mod, claims); + append_runtime_overlays(files, mod, claims); + } + } + + Log.debug("Registering {} overlay file(s).", files.size()); + aurora_dvd_overlay_files(files.data(), files.size(), nullptr); + + for (const auto& file : files) { + std::free(const_cast(file.fileName)); + } +} + +uint64_t overlay_add_file( + LoadedMod& mod, std::string discPath, std::string bundlePath, size_t size) { + const auto handle = s_nextRuntimeHandle++; + s_runtimeOverlays[&mod].push_back({ + .handle = handle, + .discPath = std::move(discPath), + .bundlePath = std::move(bundlePath), + .size = size, + }); + s_overlaysDirty = true; + return handle; +} + +uint64_t overlay_add_buffer(LoadedMod& mod, std::string discPath, std::vector data) { + const auto handle = s_nextRuntimeHandle++; + const auto size = data.size(); + s_runtimeOverlays[&mod].push_back({ + .handle = handle, + .discPath = std::move(discPath), + .buffer = std::make_shared>(std::move(data)), + .size = size, + }); + s_overlaysDirty = true; + return handle; +} + +bool overlay_remove(LoadedMod& mod, uint64_t handle) { + const auto it = s_runtimeOverlays.find(&mod); + if (it == s_runtimeOverlays.end()) { + return false; + } + if (std::erase_if(it->second, [&](const auto& entry) { return entry.handle == handle; }) == 0) { + return false; + } + if (it->second.empty()) { + s_runtimeOverlays.erase(it); + } + s_overlaysDirty = true; + return true; +} + +void overlay_remove_mod(LoadedMod& mod) { + if (s_runtimeOverlays.erase(&mod) != 0) { + s_overlaysDirty = true; + } +} + +bool consume_overlays_dirty() { + return std::exchange(s_overlaysDirty, false); +} + +constexpr size_t kMaxOverlayFileSize = UINT32_MAX; + +bool is_valid_disc_path(const char* discPath) { + if (discPath == nullptr) { + return false; + } + const std::string_view path{discPath}; + return path.starts_with('/') && is_safe_resource_path(path.substr(1)); +} + +ModResult overlay_add_file( + ModContext* context, const char* discPath, const char* bundlePath, OverlayHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_disc_path(discPath) || bundlePath == nullptr || + !is_safe_resource_path(bundlePath)) + { + return MOD_INVALID_ARGUMENT; + } + + size_t size = 0; + try { + size = mod->bundle->getFileSize(bundlePath); + } catch (const std::exception& e) { + Log.error( + "[{}] overlay add_file '{}' failed: {}", mod->metadata.id, bundlePath, e.what()); + return MOD_UNAVAILABLE; + } + if (size > kMaxOverlayFileSize) { + Log.error("[{}] overlay add_file '{}' failed: file too large ({} bytes)", + mod->metadata.id, bundlePath, size); + return MOD_INVALID_ARGUMENT; + } + + const auto handle = overlay_add_file(*mod, discPath, bundlePath, size); + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult overlay_add_buffer(ModContext* context, const char* discPath, const void* data, + size_t size, OverlayHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || !is_valid_disc_path(discPath) || (data == nullptr && size != 0) || + size > kMaxOverlayFileSize) + { + return MOD_INVALID_ARGUMENT; + } + + const auto* bytes = static_cast(data); + const auto handle = overlay_add_buffer(*mod, discPath, std::vector{bytes, bytes + size}); + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult overlay_remove(ModContext* context, OverlayHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + if (!overlay_remove(*mod, handle)) { + Log.error("[{}] overlay remove failed: unknown handle {}", mod->metadata.id, handle); + return MOD_INVALID_ARGUMENT; + } + return MOD_OK; +} + +constexpr OverlayService s_overlayService{ + .header = SERVICE_HEADER(OverlayService, OVERLAY_SERVICE_MAJOR, OVERLAY_SERVICE_MINOR), + .add_file = overlay_add_file, + .add_buffer = overlay_add_buffer, + .remove = overlay_remove, +}; + +} // namespace + +constinit const ServiceModule g_overlayModule{ + .id = OVERLAY_SERVICE_ID, + .majorVersion = OVERLAY_SERVICE_MAJOR, + .minorVersion = OVERLAY_SERVICE_MINOR, + .service = &s_overlayService, + .modDetached = overlay_remove_mod, + .lifecycleApplied = overlay_sync_files, + .frameEnd = + [] { + if (consume_overlays_dirty()) { + overlay_sync_files(); + } + }, +}; +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index fd5909d996..31f67bc7ab 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -199,6 +199,9 @@ void ModLoader::init_services() { { &svc::g_hostModule, &svc::g_logModule, + &svc::g_resourceModule, + &svc::g_overlayModule, + &svc::g_textureModule, &svc::g_configModule, }) { diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 4954c97077..6ffa1a7582 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -64,6 +64,9 @@ void modules_shutdown(); extern const ServiceModule g_hostModule; extern const ServiceModule g_logModule; +extern const ServiceModule g_resourceModule; +extern const ServiceModule g_overlayModule; +extern const ServiceModule g_textureModule; extern const ServiceModule g_configModule; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/resource.cpp b/src/dusk/mods/svc/resource.cpp new file mode 100644 index 0000000000..d96973f785 --- /dev/null +++ b/src/dusk/mods/svc/resource.cpp @@ -0,0 +1,102 @@ +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/resource.h" + +#include + +#include +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +aurora::Module Log("dusk::mods::resource"); + +// Allocations by owning mod, so buffers still live when a mod detaches can be freed. +std::unordered_map s_buffers; + +void resource_remove_mod(LoadedMod& mod) { + size_t reclaimed = 0; + std::erase_if(s_buffers, [&](const auto& entry) { + if (entry.second != &mod) { + return false; + } + std::free(entry.first); + ++reclaimed; + return true; + }); + if (reclaimed != 0) { + Log.warn("[{}] reclaimed {} resource buffer(s) that were never freed", mod.metadata.id, + reclaimed); + } +} + +ModResult resource_load(ModContext* context, const char* relativePath, ResourceBuffer* outBuffer) { + if (outBuffer == nullptr || outBuffer->struct_size < sizeof(ResourceBuffer)) { + return MOD_INVALID_ARGUMENT; + } + outBuffer->data = nullptr; + outBuffer->size = 0; + auto* mod = mod_from_context(context); + if (mod == nullptr || relativePath == nullptr || !is_safe_resource_path(relativePath)) { + return MOD_INVALID_ARGUMENT; + } + + const auto entry = fmt::format("res/{}", relativePath); + std::vector data; + try { + data = mod->bundle->readFile(entry); + } catch (const std::runtime_error& e) { + Log.error("[{}] resource load '{}' failed: {}", mod->metadata.id, entry, e.what()); + return MOD_UNAVAILABLE; + } + + if (!data.empty()) { + void* copy = std::malloc(data.size()); + if (copy == nullptr) { + return MOD_ERROR; + } + std::memcpy(copy, data.data(), data.size()); + s_buffers.emplace(copy, mod); + outBuffer->data = copy; + outBuffer->size = data.size(); + } + return MOD_OK; +} + +void resource_free(ModContext* context, ResourceBuffer* buffer) { + if (buffer == nullptr || buffer->struct_size < sizeof(ResourceBuffer) || + buffer->data == nullptr) + { + return; + } + if (s_buffers.erase(buffer->data) == 0) { + Log.error("[{}] resource free: not a live loaded buffer", mod_id_from_context(context)); + return; + } + std::free(buffer->data); + buffer->data = nullptr; + buffer->size = 0; +} + +constexpr ResourceService s_resourceService{ + .header = SERVICE_HEADER(ResourceService, RESOURCE_SERVICE_MAJOR, RESOURCE_SERVICE_MINOR), + .load = resource_load, + .free = resource_free, +}; + +} // namespace + +constinit const ServiceModule g_resourceModule{ + .id = RESOURCE_SERVICE_ID, + .majorVersion = RESOURCE_SERVICE_MAJOR, + .minorVersion = RESOURCE_SERVICE_MINOR, + .service = &s_resourceService, + .modDetached = resource_remove_mod, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/texture.cpp b/src/dusk/mods/svc/texture.cpp new file mode 100644 index 0000000000..7eada55739 --- /dev/null +++ b/src/dusk/mods/svc/texture.cpp @@ -0,0 +1,455 @@ +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/texture.h" + +#include +#include + +#include +#include +#include +#include +#include + +using namespace std::string_literals; + +static_assert(TEXTURE_HASH_WILDCARD == aurora::texture::kWildcardTextureHash); +static_assert(TEXTURE_TLUT_WILDCARD == aurora::texture::kWildcardTlutHash); + +namespace dusk::mods::svc { +namespace { + +struct TextureRawData { + std::vector data; + uint32_t width = 0; + uint32_t height = 0; + uint32_t mipCount = 1; + uint32_t gxFormat = 0; +}; + +aurora::Module Log("dusk::mods::textures"); + +// Referenced by Aurora's lazy virtual-file reads (from arbitrary threads, under Aurora's registry +// lock) and by raw-entry spans. Immutable after construction; freed only after the corresponding +// unregister_replacement returns, at which point Aurora guarantees no further reads. +struct TextureKeepalive { + std::shared_ptr bundle; + std::string bundlePath; + std::vector ownedData; +}; + +// Called with Aurora's registry lock held: must not take any Dusk lock or re-enter +// aurora::texture. ModBundle reads are documented thread-safe. +bool texture_read_cb(void* userData, const char* path, std::vector& outBytes) { + auto* keepalive = static_cast(userData); + try { + outBytes = keepalive->bundle->readFile(path); + return true; + } catch (...) { + return false; + } +} + +struct RuntimeTextureEntry { + uint64_t handle = 0; + aurora::texture::ReplacementRegistration registration; + std::shared_ptr keepalive; + // Original inputs, kept for re-registration when the mod's priority changes. + bool isVirtual = false; + aurora::texture::ReplacementKey key; // raw entries only + uint32_t width = 0; + uint32_t height = 0; + uint32_t mipCount = 1; + uint32_t gxFormat = 0; + std::string label; +}; + +struct ModTextureRecord { + int32_t appliedPriority = 0; + bool staticRegistered = false; + aurora::texture::ReplacementGroup staticGroup; + std::vector> staticKeepalives; + std::vector runtime; +}; + +// Game thread only: all mutations happen in service calls made from mod code (init/update/hooks +// run inside ModLoader::tick), in the loader's sync/deactivate paths, or at shutdown. +std::unordered_map s_modTextures; +uint64_t s_nextTextureHandle = 1; + +// Position in m_mods (dependency-sorted load order) + 1; later-loaded mods win. The user +// texture_replacements directory uses kUserTextureReplacementPriority, below any mod. +int32_t compute_mod_priority(const LoadedMod& mod) { + int32_t index = 0; + for (const auto& other : ModLoader::instance().mods()) { + ++index; + if (&other == &mod) { + return index; + } + } + return index + 1; +} + +bool is_sidecar_mip(std::string_view stem) { + constexpr std::string_view tag = "_mip"; + size_t i = stem.size(); + while (i > 0 && stem[i - 1] >= '0' && stem[i - 1] <= '9') { + --i; + } + if (i == stem.size() || i < tag.size()) { + return false; + } + return stem.substr(i - tag.size(), tag.size()) == tag; +} + +bool has_replacement_extension(std::string_view filename) { + const auto dot = filename.rfind('.'); + if (dot == std::string_view::npos) { + return false; + } + std::string ext{filename.substr(dot)}; + std::ranges::transform(ext, ext.begin(), + [](char ch) { return ch >= 'A' && ch <= 'Z' ? static_cast(ch + 'a' - 'A') : ch; }); + return ext == ".dds" || ext == ".png"; +} + +std::string_view final_path_component(std::string_view path) { + const auto slash = path.rfind('/'); + return slash == std::string_view::npos ? path : path.substr(slash + 1); +} + +const LoadedMod* find_static_conflict( + const aurora::texture::ReplacementKey& key, const LoadedMod* exclude) { + for (const auto& [mod, record] : s_modTextures) { + if (mod == exclude) { + continue; + } + for (const auto& registration : record.staticGroup.registrations) { + if (registration.key == key) { + return mod; + } + } + } + return nullptr; +} + +void register_static_textures(LoadedMod& mod, ModTextureRecord& record) { + std::vector candidates; + for (const auto& file : mod.bundle->getFileNames()) { + if (!file.starts_with("textures/") || !has_replacement_extension(file)) { + continue; + } + auto filename = final_path_component(file); + if (is_sidecar_mip(filename.substr(0, filename.rfind('.')))) { + continue; + } + candidates.push_back(file); + } + // Deterministic order; with the first parse of a key winning, this mirrors Aurora's + // load_replacement_directory dedupe semantics. + std::ranges::sort(candidates); + + std::vector seenKeys; + for (const auto& path : candidates) { + const auto parsed = aurora::texture::parse_replacement_filename(final_path_component(path)); + if (!parsed.has_value()) { + Log.warn( + "[{}] '{}' does not follow the texture replacement naming convention; skipped.", + mod.metadata.id, path); + continue; + } + const aurora::texture::ReplacementKey key{*parsed}; + if (std::ranges::find(seenKeys, key) != seenKeys.end()) { + continue; + } + seenKeys.push_back(key); + + if (const auto* other = find_static_conflict(key, &mod); other != nullptr) { + const auto& winner = + s_modTextures.find(other)->second.appliedPriority > record.appliedPriority ? + *other : + mod; + Log.warn( + "Texture replacement conflict: '{}' is replaced by both '{}' and '{}'; '{}' wins.", + path, other->metadata.id, mod.metadata.id, winner.metadata.id); + } + + auto keepalive = std::make_shared(mod.bundle, path); + const auto registration = aurora::texture::register_virtual_replacement(path, + {.read = texture_read_cb, .userData = keepalive.get()}, + {.priority = record.appliedPriority}); + if (registration.id == 0) { + continue; + } + record.staticGroup.registrations.push_back(registration); + record.staticKeepalives.push_back(std::move(keepalive)); + } + + record.staticRegistered = true; + if (!record.staticGroup.registrations.empty()) { + Log.info("[{}] registered {} texture replacement(s).", mod.metadata.id, + record.staticGroup.registrations.size()); + } +} + +void register_runtime_entry(RuntimeTextureEntry& entry, int32_t priority) { + if (entry.isVirtual) { + entry.registration = aurora::texture::register_virtual_replacement( + entry.keepalive->bundlePath, + {.read = texture_read_cb, .userData = entry.keepalive.get()}, {.priority = priority}); + } else { + entry.registration = aurora::texture::register_replacement(entry.key, + { + .bytes = {entry.keepalive->ownedData.data(), entry.keepalive->ownedData.size()}, + .width = entry.width, + .height = entry.height, + .mipCount = entry.mipCount, + .gxFormat = entry.gxFormat, + .label = entry.label, + }, + {.priority = priority}); + } +} + +void unregister_record(ModTextureRecord& record) { + aurora::texture::unregister_replacements(record.staticGroup); + record.staticGroup.registrations.clear(); + record.staticKeepalives.clear(); + record.staticRegistered = false; + for (auto& entry : record.runtime) { + aurora::texture::unregister_replacement(entry.registration); + entry.registration = {}; + } +} + +void textures_sync_replacements() { + // Module detach removes records eagerly, but a record whose mod is no + // longer active must not linger with stale priority. + std::erase_if(s_modTextures, [&](auto& item) { + if (item.first->active) { + return false; + } + unregister_record(item.second); + return true; + }); + + for (auto& mod : ModLoader::instance().active_mods()) { + const auto priority = compute_mod_priority(mod); + auto& record = s_modTextures[&mod]; + + if (record.staticRegistered && record.appliedPriority == priority) { + continue; + } + + if (record.staticRegistered) { + // A reload re-sorted m_mods and changed this mod's priority: re-register everything + // at the new priority. Cheap, since file-backed entries decode lazily. + aurora::texture::unregister_replacements(record.staticGroup); + record.staticGroup.registrations.clear(); + record.staticKeepalives.clear(); + record.appliedPriority = priority; + register_static_textures(mod, record); + for (auto& entry : record.runtime) { + aurora::texture::unregister_replacement(entry.registration); + register_runtime_entry(entry, priority); + } + } else { + record.appliedPriority = priority; + register_static_textures(mod, record); + } + } +} + +uint64_t texture_register_raw( + LoadedMod& mod, const aurora::texture::ReplacementKey& key, TextureRawData data) { + auto& record = s_modTextures[&mod]; + if (record.appliedPriority == 0) { + record.appliedPriority = compute_mod_priority(mod); + } + + auto& entry = record.runtime.emplace_back(); + entry.handle = s_nextTextureHandle++; + entry.keepalive = std::make_shared(); + entry.keepalive->ownedData = std::move(data.data); + entry.isVirtual = false; + entry.key = key; + entry.width = data.width; + entry.height = data.height; + entry.mipCount = data.mipCount; + entry.gxFormat = data.gxFormat; + entry.label = fmt::format("mod {} texture {}", mod.metadata.id, entry.handle); + register_runtime_entry(entry, record.appliedPriority); + return entry.handle; +} + +uint64_t texture_register_file(LoadedMod& mod, std::string bundlePath) { + auto& record = s_modTextures[&mod]; + if (record.appliedPriority == 0) { + record.appliedPriority = compute_mod_priority(mod); + } + + auto& entry = record.runtime.emplace_back(); + entry.handle = s_nextTextureHandle++; + entry.keepalive = std::make_shared(mod.bundle, std::move(bundlePath)); + entry.isVirtual = true; + register_runtime_entry(entry, record.appliedPriority); + if (entry.registration.id == 0) { + record.runtime.pop_back(); + return 0; + } + return entry.handle; +} + +bool texture_unregister(LoadedMod& mod, uint64_t handle) { + const auto it = s_modTextures.find(&mod); + if (it == s_modTextures.end()) { + return false; + } + auto& runtime = it->second.runtime; + const auto entry = + std::ranges::find_if(runtime, [&](const auto& e) { return e.handle == handle; }); + if (entry == runtime.end()) { + return false; + } + aurora::texture::unregister_replacement(entry->registration); + runtime.erase(entry); + return true; +} + +void textures_remove_mod(LoadedMod& mod) { + const auto it = s_modTextures.find(&mod); + if (it == s_modTextures.end()) { + return; + } + unregister_record(it->second); + s_modTextures.erase(it); +} + +std::optional translate_key(const TextureKey* key) { + if (key == nullptr || key->struct_size < sizeof(TextureKey)) { + return std::nullopt; + } + switch (key->kind) { + case TEXTURE_KEY_POINTER: + if (key->pointer == nullptr) { + return std::nullopt; + } + return aurora::texture::ReplacementKey{aurora::texture::TexturePointerKey{key->pointer}}; + case TEXTURE_KEY_SOURCE: + if (key->width == 0 || key->height == 0) { + return std::nullopt; + } + return aurora::texture::ReplacementKey{aurora::texture::TextureSourceKey{ + .textureHash = key->texture_hash, + .tlutHash = key->tlut_hash, + .width = key->width, + .height = key->height, + .format = key->gx_format, + .hasTlut = key->has_tlut, + }}; + default: + return std::nullopt; + } +} + +ModResult texture_register_data(ModContext* context, const TextureKey* key, const TextureData* data, + TextureReplacementHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + const auto translatedKey = translate_key(key); + if (mod == nullptr || !translatedKey.has_value() || data == nullptr || + data->struct_size < sizeof(TextureData) || data->data == nullptr || data->size == 0 || + data->width == 0 || data->height == 0 || data->mip_count == 0) + { + return MOD_INVALID_ARGUMENT; + } + + const auto* bytes = static_cast(data->data); + const auto handle = texture_register_raw(*mod, *translatedKey, + { + .data = std::vector{bytes, bytes + data->size}, + .width = data->width, + .height = data->height, + .mipCount = data->mip_count, + .gxFormat = data->gx_format, + }); + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult texture_register_file( + ModContext* context, const char* bundlePath, TextureReplacementHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || bundlePath == nullptr || !is_safe_resource_path(bundlePath)) { + return MOD_INVALID_ARGUMENT; + } + + const std::string_view path{bundlePath}; + const auto slash = path.rfind('/'); + const auto filename = slash == std::string_view::npos ? path : path.substr(slash + 1); + if (!aurora::texture::parse_replacement_filename(filename).has_value()) { + Log.error("[{}] texture register_file '{}' failed: " + "filename does not follow the replacement naming convention", + mod->metadata.id, bundlePath); + return MOD_INVALID_ARGUMENT; + } + + try { + mod->bundle->getFileSize(bundlePath); + } catch (const std::exception& e) { + Log.error( + "[{}] texture register_file '{}' failed: {}", mod->metadata.id, bundlePath, e.what()); + return MOD_UNAVAILABLE; + } + + const auto handle = texture_register_file(*mod, bundlePath); + if (handle == 0) { + return MOD_INVALID_ARGUMENT; + } + if (outHandle != nullptr) { + *outHandle = handle; + } + return MOD_OK; +} + +ModResult texture_unregister(ModContext* context, TextureReplacementHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + if (!texture_unregister(*mod, handle)) { + Log.error( + "[{}] texture unregister failed: unknown handle {}", mod->metadata.id, handle); + return MOD_INVALID_ARGUMENT; + } + return MOD_OK; +} + +constexpr TextureService s_textureService{ + .header = SERVICE_HEADER(TextureService, TEXTURE_SERVICE_MAJOR, TEXTURE_SERVICE_MINOR), + .register_data = texture_register_data, + .register_file = texture_register_file, + .unregister = texture_unregister, +}; + +} // namespace + +constinit const ServiceModule g_textureModule{ + .id = TEXTURE_SERVICE_ID, + .majorVersion = TEXTURE_SERVICE_MAJOR, + .minorVersion = TEXTURE_SERVICE_MINOR, + .service = &s_textureService, + .modDetached = textures_remove_mod, + .lifecycleApplied = textures_sync_replacements, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/texture_replacements.cpp b/src/dusk/texture_replacements.cpp index 5477a38dd9..2866af855f 100644 --- a/src/dusk/texture_replacements.cpp +++ b/src/dusk/texture_replacements.cpp @@ -20,7 +20,8 @@ void reload() { } const auto root = ConfigPath / "texture_replacements"; - s_directoryGroup = aurora::texture::load_replacement_directory(root); + s_directoryGroup = aurora::texture::load_replacement_directory( + root, {.priority = kUserTextureReplacementPriority}); DuskLog.info("Texture replacement directory loaded: {} registration(s)", s_directoryGroup.registrations.size()); } From 4339e60ba65dc16702b019d614bfaf7dc724d5b9 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 15:14:58 -0600 Subject: [PATCH 05/14] Hook service, symgen manifest & data linking --- CMakeLists.txt | 85 +++++- cmake/ModSDK.cmake | 45 +++ cmake/PatchCapstone.cmake | 13 + cmake/PatchFunchook.cmake | 60 ++++ cmake/SymbolManifest.cmake | 125 ++++++++ cmake/WindowsExports.cmake | 76 +++++ docs/modding.md | 172 ++++++++++- files.cmake | 4 + include/d/d_com_inf_game.h | 2 +- include/dusk/gx_helper.h | 3 +- include/dusk/profiling.hpp | 12 + include/global.h | 13 + include/mods/hook.hpp | 464 +++++++++++++++++++++++++++++ include/mods/svc/game.h | 30 ++ include/mods/svc/hook.h | 131 ++++++++ include/mods/svc/host.h | 12 +- sdk/CMakeLists.txt | 10 + sdk/pseudo_reloc.cpp | 139 +++++++++ src/dusk/mods/loader/loader.cpp | 5 +- src/dusk/mods/loader/manifest.cpp | 386 ++++++++++++++++++++++++ src/dusk/mods/loader/manifest.hpp | 50 ++++ src/dusk/mods/svc/game.cpp | 21 ++ src/dusk/mods/svc/hook.cpp | 480 ++++++++++++++++++++++++++++++ src/dusk/mods/svc/host.cpp | 13 +- src/dusk/mods/svc/registry.cpp | 2 + src/dusk/mods/svc/registry.hpp | 2 + 26 files changed, 2341 insertions(+), 14 deletions(-) create mode 100644 cmake/PatchCapstone.cmake create mode 100644 cmake/PatchFunchook.cmake create mode 100644 cmake/SymbolManifest.cmake create mode 100644 cmake/WindowsExports.cmake create mode 100644 include/dusk/profiling.hpp create mode 100644 include/mods/hook.hpp create mode 100644 include/mods/svc/game.h create mode 100644 include/mods/svc/hook.h create mode 100644 sdk/pseudo_reloc.cpp create mode 100644 src/dusk/mods/loader/manifest.cpp create mode 100644 src/dusk/mods/loader/manifest.hpp create mode 100644 src/dusk/mods/svc/game.cpp create mode 100644 src/dusk/mods/svc/hook.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d7093b097c..e62704fe1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -217,7 +217,41 @@ FetchContent_Declare(miniz DOWNLOAD_EXTRACT_TIMESTAMP TRUE EXCLUDE_FROM_ALL ) -FetchContent_MakeAvailable(cxxopts json miniz) + +set(_fetch_content_deps cxxopts json miniz) +if (DUSK_ENABLE_CODE_MODS) + message(STATUS "dusklight: Fetching funchook") + # cmake/PatchFunchook.cmake patches funchook's cmake/capstone.cmake.in to inject a + # PATCH_COMMAND into capstone's inner ExternalProject. That PATCH_COMMAND runs + # cmake/PatchCapstone.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/PatchCapstone.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/PatchFunchook.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) + if (APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _osx_arch_count) + if (_osx_arch_count EQUAL 1) + list(GET CMAKE_OSX_ARCHITECTURES 0 _osx_arch) + if (_osx_arch MATCHES "^(arm64|aarch64|ARM64)$") + set(FUNCHOOK_CPU arm64 CACHE STRING "" FORCE) + elseif (_osx_arch MATCHES "^(x86_64|AMD64|amd64|i[3-6]86|x86)$") + set(FUNCHOOK_CPU x86 CACHE STRING "" FORCE) + endif () + endif () + endif () + list(APPEND _fetch_content_deps funchook) +endif () +FetchContent_MakeAvailable(${_fetch_content_deps}) if (DUSK_ENABLE_SENTRY_NATIVE) message(STATUS "dusklight: Fetching sentry-native") @@ -273,6 +307,9 @@ 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 Threads::Threads zstd::libzstd dusklight_game_headers) +if (DUSK_ENABLE_CODE_MODS) + list(APPEND GAME_LIBS funchook-static) +endif () if (DUSK_ENABLE_SENTRY_NATIVE) list(APPEND GAME_LIBS sentry) @@ -413,6 +450,52 @@ target_compile_definitions(dusklight PRIVATE ${GAME_COMPILE_DEFS}) target_include_directories(dusklight PRIVATE ${miniz_SOURCE_DIR}) 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 (DUSK_ENABLE_CODE_MODS) + include(cmake/SymbolManifest.cmake) + if (WIN32) + # Game ABI exports & import library for mod linking + include(cmake/WindowsExports.cmake) + setup_windows_exports(dusklight) + endif () + # Post-link symbol manifest: hookable-surface name->address map keyed to the build. + setup_symbol_manifest(dusklight) +endif () + +# Hook reliability: guaranteed patchable entries on the game ABI surface, and no identical-code folding. +if (MSVC) + if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:/hotpatch>) + endif () + if (CMAKE_SYSTEM_PROCESSOR STREQUAL "ARM64") + target_link_options(dusklight PRIVATE /FUNCTIONPADMIN:16 /OPT:NOICF) + else () + target_link_options(dusklight PRIVATE /FUNCTIONPADMIN /OPT:NOICF) + endif () +elseif (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + if (CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64" OR CMAKE_OSX_ARCHITECTURES MATCHES "arm64") + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:-fpatchable-function-entry=2,1>) + else () + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:-fpatchable-function-entry=10,5>) + endif () +endif () +if (DEFINED DUSK_PATCHABLE_ENTRY_FLAG) + target_compile_options(dusklight PRIVATE ${DUSK_PATCHABLE_ENTRY_FLAG}) + foreach(jsystem_lib IN LISTS JSYSTEM_LIBRARIES) + target_compile_options(${jsystem_lib} PRIVATE ${DUSK_PATCHABLE_ENTRY_FLAG}) + endforeach() +endif () + +if (WIN32) + target_link_libraries(dusklight PRIVATE Psapi) +endif () +if (APPLE) + # Mods resolve game symbols from the executable at dlopen time. + target_link_options(dusklight PRIVATE -Wl,-export_dynamic) +elseif (UNIX AND NOT ANDROID) + target_link_options(dusklight PRIVATE -rdynamic) +endif () + if (TARGET crashpad_handler) add_dependencies(dusklight crashpad_handler) add_custom_command(TARGET dusklight POST_BUILD diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index 909e8abcba..c320dcb5f4 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -85,6 +85,51 @@ function(add_mod target_name) endif () endif () + if (APPLE) + # Game symbols resolve against the host executable at dlopen time. + target_link_options(${target_name} PRIVATE -undefined dynamic_lookup) + elseif (ANDROID) + if (TARGET dusklight) + target_link_libraries(${target_name} PRIVATE dusklight) + elseif (DUSK_GAME_SOLIB) + target_link_libraries(${target_name} PRIVATE "${DUSK_GAME_SOLIB}") + else () + message(FATAL_ERROR "add_mod: DUSK_GAME_SOLIB is not set (libmain.so)") + endif () + elseif (UNIX) + target_link_options(${target_name} PRIVATE -Wl,--allow-shlib-undefined) + elseif (WIN32) + # Link against the generated import library (game ABI surface). Function calls + # resolve through import thunks. Data is toolchain dependent: + # - clang-cl: lld's mingw mode auto-imports data references, fixed up at load by + # the mod SDK's pseudo-relocation runtime (pseudo_reloc.cpp). + # - cl (MSVC): only DUSK_GAME_DATA-annotated data is reachable. Un-annotated + # references fail to link. + if (NOT DUSK_GAME_IMPLIB) + message(FATAL_ERROR "add_mod: DUSK_GAME_IMPLIB is not set.") + endif () + target_link_libraries(${target_name} PRIVATE "${DUSK_GAME_IMPLIB}") + set_target_properties(${target_name} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreadedDLL") + target_compile_definitions(${target_name} PRIVATE _ITERATOR_DEBUG_LEVEL=0) + if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + target_compile_options(${target_name} PRIVATE "$<$:/clang:-mcmodel=large>") + target_sources(${target_name} PRIVATE "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../sdk/pseudo_reloc.cpp") + # lld mingw mode rewrites /DEFAULTLIB directives to -l style and skips %LIB%, so + # the CRT libraries and search paths are spelled out explicitly. + target_link_options(${target_name} PRIVATE -lldmingw /nodefaultlib /INCREMENTAL:NO) + target_link_libraries(${target_name} PRIVATE + msvcrt.lib msvcprt.lib vcruntime.lib ucrt.lib + oldnames.lib uuid.lib kernel32.lib user32.lib) + set(_lib_dirs "$ENV{LIB}") + if ("${_lib_dirs}" STREQUAL "") + message(FATAL_ERROR "add_mod: %LIB% is empty; configure from a VS dev shell") + endif () + foreach (_libdir IN LISTS _lib_dirs) + target_link_options(${target_name} PRIVATE "/libpath:${_libdir}") + endforeach () + endif () + endif () + set(_output_dir "${DUSK_MODS_OUTPUT_DIR}") if (ARG_OUTPUT_DIR) set(_output_dir "${ARG_OUTPUT_DIR}") diff --git a/cmake/PatchCapstone.cmake b/cmake/PatchCapstone.cmake new file mode 100644 index 0000000000..fa8a1dd160 --- /dev/null +++ b/cmake/PatchCapstone.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/PatchFunchook.cmake b/cmake/PatchFunchook.cmake new file mode 100644 index 0000000000..6d5a6e36b4 --- /dev/null +++ b/cmake/PatchFunchook.cmake @@ -0,0 +1,60 @@ +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}") + +file(READ "${SOURCE_DIR}/src/funchook_unix.c" _unix_content) + +# macOS rejects the POSIX mprotect RWX/RW transition for executable image pages on arm64. +# Use Mach VM_PROT_COPY for the short patch window, then restore RX permissions. +if (NOT _unix_content MATCHES "VM_PROT_READ \\| VM_PROT_WRITE \\| VM_PROT_COPY") + string(REPLACE + [=[ rv = mprotect(mstate->addr, mstate->size, prot);]=] + [=[#ifdef __APPLE__ + kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, + (vm_size_t)mstate->size, FALSE, + VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY); + if (kr == KERN_SUCCESS) { + funchook_log(funchook, " unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR")\n", + mstate->addr, mstate->size, start, len); + return 0; + } + funchook_set_error_message(funchook, "Failed to unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR", error=%s)", + mstate->addr, mstate->size, start, len, + mach_error_string(kr)); + return FUNCHOOK_ERROR_MEMORY_FUNCTION; +#endif + rv = mprotect(mstate->addr, mstate->size, prot);]=] + _unix_content "${_unix_content}") + + string(REPLACE + [=[ char errbuf[128]; + int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] + [=[ char errbuf[128]; +#ifdef __APPLE__ + kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, + (vm_size_t)mstate->size, FALSE, + VM_PROT_READ | VM_PROT_EXECUTE); + + if (kr == KERN_SUCCESS) { + funchook_log(funchook, " protect memory %p (size=%"PRIuPTR", prot=read,exec)\n", + mstate->addr, mstate->size); + return 0; + } + funchook_set_error_message(funchook, "Failed to protect memory %p (size=%"PRIuPTR", prot=read,exec, error=%s)", + mstate->addr, mstate->size, + mach_error_string(kr)); + return FUNCHOOK_ERROR_MEMORY_FUNCTION; +#endif + int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] + _unix_content "${_unix_content}") +endif () + +file(WRITE "${SOURCE_DIR}/src/funchook_unix.c" "${_unix_content}") diff --git a/cmake/SymbolManifest.cmake b/cmake/SymbolManifest.cmake new file mode 100644 index 0000000000..ffaae9177e --- /dev/null +++ b/cmake/SymbolManifest.cmake @@ -0,0 +1,125 @@ +include_guard(GLOBAL) + +get_filename_component(_SYMBOL_MANIFEST_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + +set(_SYMGEN_VERSION "1.1.0") +set(_SYMGEN_RELEASE_BASE_URL "https://github.com/encounter/symgen/releases/download/v${_SYMGEN_VERSION}") +set(SYMGEN_PATH "" CACHE FILEPATH "Path to a symgen executable; empty downloads the pinned release") +mark_as_advanced(SYMGEN_PATH) + +function(symgen_host_asset out_name) + string(TOLOWER "${CMAKE_HOST_SYSTEM_PROCESSOR}" _host_processor) + set(_asset "") + + if (CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + if (_host_processor MATCHES "^(arm64|aarch64)$") + set(_asset "symgen-macos-arm64") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-macos-x86_64") + endif () + elseif (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + if (_host_processor MATCHES "^(aarch64|arm64)$") + set(_asset "symgen-linux-aarch64") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-linux-x86_64") + elseif (_host_processor MATCHES "^(i[3-6]86|x86)$") + set(_asset "symgen-linux-i686") + endif () + elseif (CMAKE_HOST_WIN32) + if (_host_processor MATCHES "^(arm64|aarch64)$") + set(_asset "symgen-windows-arm64.exe") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-windows-x86_64.exe") + elseif (_host_processor MATCHES "^(i[3-6]86|x86)$") + set(_asset "symgen-windows-x86.exe") + endif () + endif () + + set(${out_name} "${_asset}" PARENT_SCOPE) +endfunction() + +function(ensure_symgen required) + if (TARGET symgen) + return() + endif () + + if (SYMGEN_PATH) + get_filename_component(_symgen "${SYMGEN_PATH}" ABSOLUTE) + if (NOT EXISTS "${_symgen}") + if (required) + message(FATAL_ERROR "symgen: SYMGEN_PATH does not exist: ${_symgen}") + endif () + message(STATUS "symgen: SYMGEN_PATH does not exist, symbol manifest generation " + "skipped (by-name hook resolution will be unavailable)") + return() + endif () + else () + symgen_host_asset(_asset) + if (_asset STREQUAL "") + if (required) + message(FATAL_ERROR "symgen: no prebuilt binary for host " + "${CMAKE_HOST_SYSTEM_NAME}/${CMAKE_HOST_SYSTEM_PROCESSOR} " + "(configure with -DDUSK_ENABLE_CODE_MODS=OFF)") + endif () + message(STATUS "symgen: no prebuilt binary for host " + "${CMAKE_HOST_SYSTEM_NAME}/${CMAKE_HOST_SYSTEM_PROCESSOR}; " + "symbol manifest generation skipped (by-name hook resolution will be unavailable)") + return() + endif () + + set(_symgen_dir "${CMAKE_BINARY_DIR}/_deps/symgen") + set(_symgen "${_symgen_dir}/${_asset}") + set(_url "${_SYMGEN_RELEASE_BASE_URL}/${_asset}") + message(STATUS "dusklight: Fetching symgen ${_SYMGEN_VERSION} (${_asset})") + file(MAKE_DIRECTORY "${_symgen_dir}") + file(DOWNLOAD "${_url}" "${_symgen}" + TLS_VERIFY ON + STATUS _download_status + SHOW_PROGRESS) + list(GET _download_status 0 _download_code) + if (NOT _download_code EQUAL 0) + list(GET _download_status 1 _download_message) + file(REMOVE "${_symgen}") + if (required) + message(FATAL_ERROR "symgen: failed to download ${_url}: ${_download_message}") + endif () + message(STATUS "symgen: failed to download ${_url}: ${_download_message}; " + "symbol manifest generation skipped (by-name hook resolution will be unavailable)") + return() + endif () + if (NOT CMAKE_HOST_WIN32) + file(CHMOD "${_symgen}" PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE) + endif () + endif () + + add_custom_target(symgen DEPENDS "${_symgen}") + set(SYMGEN_EXE "${_symgen}" CACHE INTERNAL "symgen executable" FORCE) +endfunction() + +function(setup_symbol_manifest target) + ensure_symgen(TRUE) + if (NOT TARGET symgen) + return() + endif () + add_dependencies(${target} symgen) + + if (WIN32) + set(_input --pdb "$") + set(_out "$/dusklight.symdb") + else () + set(_input --binary "$") + if (APPLE) + set(_out "$/Resources/dusklight.symdb") + else () + set(_out "$/dusklight.symdb") + endif () + endif () + + add_custom_command(TARGET ${target} POST_BUILD + COMMAND "${SYMGEN_EXE}" manifest ${_input} --out "${_out}" + COMMENT "Generating symbol manifest" + VERBATIM) +endfunction() diff --git a/cmake/WindowsExports.cmake b/cmake/WindowsExports.cmake new file mode 100644 index 0000000000..f380094b78 --- /dev/null +++ b/cmake/WindowsExports.cmake @@ -0,0 +1,76 @@ +include_guard(GLOBAL) + +get_filename_component(_DUSK_WINDOWS_EXPORTS_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + +# Windows mod linking: generate the curated export surface for the game executable and the +# import library mods link against. symgen scans the built objects, filters by source, and +# writes a .def used by the main link and import library generation. +function(setup_windows_exports target) + if (NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(WARNING "dusklight: Windows code-mod exports are x64-only for now; skipping") + return() + endif () + + include("${_DUSK_WINDOWS_EXPORTS_CMAKE_DIR}/SymbolManifest.cmake") + ensure_symgen(TRUE) + set(_symgen "${SYMGEN_EXE}") + add_dependencies(${target} symgen) + + set(_rsp_lines "$") + foreach (_lib IN LISTS JSYSTEM_LIBRARIES) + list(APPEND _rsp_lines "$") + endforeach () + list(JOIN _rsp_lines "\n" _rsp_content) + set(_rsp "${CMAKE_BINARY_DIR}/dusklight_exports_input.rsp") + file(GENERATE OUTPUT "${_rsp}" CONTENT "${_rsp_content}") + + set(_sdk_args) + foreach (_lib aurora_card aurora_core aurora_dvd aurora_gd aurora_gx aurora_mtx + aurora_os aurora_pad aurora_si aurora_vi) + if (TARGET ${_lib}) + list(APPEND _sdk_args --sdk-lib "$") + endif () + endforeach () + + # Generate curated exports list from the main binary + set(_def "${CMAKE_BINARY_DIR}/dusklight_exports.def") + add_custom_command(TARGET ${target} PRE_LINK + # TODO: src/dusk/ is NOT excluded: inline code in game headers + # currently call into it (e.g. dusk::frame_interp::lookup_replacement). + COMMAND "${_symgen}" def + --rsp "${_rsp}" + --out "${_def}" + --exclude cmake_pch + --exclude miniz + --exclude asan_options + --max-exports 58000 + ${_sdk_args} + COMMENT "Generating dusklight exports" + VERBATIM) + target_link_options(${target} PRIVATE "/DEF:${_def}") + + # Generate import library for mods to link against. + set(_implib "${CMAKE_BINARY_DIR}/dusklight_imports.lib") + get_filename_component(_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) + find_program(DUSK_LLVM_DLLTOOL llvm-dlltool HINTS "${_compiler_dir}") + if (DUSK_LLVM_DLLTOOL) + set(_implib_cmd "${DUSK_LLVM_DLLTOOL}" -d "${_def}" -D dusklight.exe -m i386:x86-64 + -l "${_implib}") + else () + set(_implib_cmd "${CMAKE_AR}" /nologo "/def:${_def}" /machine:x64 /name:dusklight.exe + "/out:${_implib}") + endif () + add_custom_command(TARGET ${target} POST_BUILD + COMMAND ${_implib_cmd} + BYPRODUCTS "${_implib}" + COMMENT "Generating dusklight import library" + VERBATIM) + if ("$CACHE{DUSK_GAME_IMPLIB}" STREQUAL "") + set(DUSK_GAME_IMPLIB "${_implib}" CACHE INTERNAL "Import library for Windows mod linking") + endif () + set(DUSK_GAME_DEF "${_def}" CACHE INTERNAL "Curated export .def for the game executable") + + # Ship the import library as sdk/dusklight.lib in the install tree: mods may use it to + # compile against Dusklight without having to build the whole game. (See DUSK_GAME_IMPLIB) + install(FILES "${_implib}" DESTINATION sdk RENAME dusklight.lib) +endfunction() diff --git a/docs/modding.md b/docs/modding.md index 77becddf0b..2330181179 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -3,8 +3,9 @@ Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code libraries and resources. -Everything a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and mods -can define their own to talk to each other. +Most things a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and +mods can define their own to talk to each other. Mods also link against the game itself: include game headers and call +game functions directly. ## Table of Contents @@ -13,10 +14,11 @@ can define their own to talk to each other. 3. [Anatomy of a Code Mod](#anatomy-of-a-code-mod) 4. [Services](#services) 5. [Built-in Services](#built-in-services) -6. [Asset Overlays](#asset-overlays) -7. [Runtime Lifecycle](#runtime-lifecycle) -8. [Error Handling](#error-handling) -9. [Advanced: Exporting Services](#advanced-exporting-services) +6. [Hooking Game Functions](#hooking-game-functions) +7. [Asset Overlays](#asset-overlays) +8. [Runtime Lifecycle](#runtime-lifecycle) +9. [Error Handling](#error-handling) +10. [Advanced: Exporting Services](#advanced-exporting-services) --- @@ -211,6 +213,11 @@ svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch); `MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead. +### HookService (`mods/svc/hook.h`) + +Install hooks on game functions. You'll rarely call it directly; use the typed helpers in `mods/hook.hpp` described +below. + ### OverlayService (`mods/svc/overlay.h`) Registers DVD file overlays at runtime. The dynamic counterpart to the static `overlay/` directory ( @@ -302,6 +309,153 @@ Writes that store the same value are silent. Values applied from `config.json` o --- +## Hooking Game Functions + +`mods/hook.hpp` provides typed helpers over the hook service: + +```cpp +#include "mods/hook.hpp" +#include "mods/svc/hook.h" + +IMPORT_SERVICE(HookService, svc_hook); +``` + +### Pre-hooks + +Run before the original. Return `HOOK_SKIP_ORIGINAL` to cancel it (post-hooks still run). + +```cpp +HookAction on_pos_move_pre(ModContext*, void* args, void* retval, void* userdata) { + daAlink_c* link = dusk::mods::arg(args, 0); // arg 0 is `this` + if (link->shape_angle.y > 10000) { + return HOOK_SKIP_ORIGINAL; + } + return HOOK_CONTINUE; +} + +dusk::mods::hook_add_pre<&daAlink_c::posMove>(svc_hook, on_pos_move_pre); +``` + +### Post-hooks + +Run after the original (or after a replace-hook, or after a cancelled original). `retval` points to the return value, if +any. + +```cpp +void on_pos_move_post(ModContext*, void* args, void* retval, void* userdata) { ... } + +dusk::mods::hook_add_post<&daAlink_c::posMove>(svc_hook, on_pos_move_post); +``` + +### Replace-hooks + +Substitute the original entirely. Call through to it via `Hook<...>::g_orig` if needed: + +```cpp +using ExecuteEntry = dusk::mods::Hook<&daAlink_c::execute>; + +void on_execute_replace(ModContext*, void* args, void* retval, void*) { + int result = ExecuteEntry::g_orig(dusk::mods::arg(args, 0)); + if (retval != nullptr) { + *static_cast(retval) = result; + } +} + +dusk::mods::hook_replace<&daAlink_c::execute>(svc_hook, on_execute_replace); +``` + +By default a second replace-hook on the same function is a conflict; `HookOptions` (`replace_policy`, `priority`, +`userdata`) controls this and callback ordering. Multiple mods can attach pre/post hooks to the same function +independently. + +### Hooking by name + +Functions you can't name in C++ (file-local statics, private class members, anything not in a header) can be hooked by +symbol name instead. You must supply the signature along with the name. + +```cpp +// static callback used by Link's hookshot collider in d_a_alink_hook.inc +using HookshotHit = dusk::mods::NamedHook< + "daAlink_hookshotAtHitCallBack", + void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*)>; + +dusk::mods::hook_add_pre(svc_hook, on_hookshot_hit_pre); +... +HookshotHit::g_orig(link, atObjInf, target, tgObjInf); // call through to the original +``` + +Class member functions must include `Class*` as the first argument. + +The install fails with the resolve error when the name is missing (`MOD_UNAVAILABLE`), ambiguous (`MOD_CONFLICT`), +or the manifest is absent (`MOD_UNSUPPORTED`). Unlike `Hook<&Class::method>`, the signature is **not** +compiler-checked: a mismatched signature will corrupt the call. + +### Reading and writing arguments + +`args` is an array of pointers to the arguments. For member functions, index 0 is `this`; parameters follow in +declaration order. + +```cpp +T value = dusk::mods::arg(args, n); // copy +T& ref = dusk::mods::arg_ref(args, n); // read/write reference +``` + +```cpp +// fpc_ProcID fopAcM_createItem(..., int itemNo, ...): turn heart drops into green rupees +HookAction on_create_item_pre(ModContext*, void* args, void*, void*) { + int& itemNo = dusk::mods::arg_ref(args, 1); + if (itemNo == dItemNo_HEART_e) { + itemNo = dItemNo_GREEN_RUPEE_e; + } + return HOOK_CONTINUE; +} + +dusk::mods::hook_add_pre<&fopAcM_createItem>(svc_hook, on_create_item_pre); +``` + +For reference parameters (e.g. `const cXyz& pos`), `arg_ref` yields a direct reference. + +### Resolving symbols by name + +`resolve` looks a symbol up in the **symbol manifest**: a name→address map generated alongside every game build and +keyed to that exact binary. It covers the whole image, including functions that aren't exported (file-local statics), +which makes them hookable: + +```cpp +IMPORT_SERVICE(HookService, svc_hook); + +void* addr = nullptr; +uint32_t flags = 0; +if (svc_hook->resolve(mod_ctx, "GXSetProjection", &addr, &flags) == MOD_OK) { + // addr is the function's real address in the running game; hook or call it. +} +``` + +Two spellings work on every platform: + +- **Display names** (`daAlink_c::posMove`, `fapGm_Before`): the qualified name with no parameter list. They carry no + signature, so overload sets (and file-local statics sharing a name) return `MOD_CONFLICT`. +- **Decorated names** (`_ZN9daAlink_c7posMoveEv` / `?posMove@daAlink_c@@...`): the platform's mangled spelling in + dlsym convention (no Mach-O leading underscore). The escape hatch for overloads. + +`MOD_UNSUPPORTED` means the manifest is missing or was built for a different game binary. + +### Game code ABI contract + +If your mod calls or hooks game code directly (anything beyond the service APIs), import `GameService` ( +`mods/svc/game.h`): + +```cpp +IMPORT_SERVICE(GameService, svc_game); +``` + +Its major version is the game code ABI epoch: it's bumped when game struct or vtable layouts change incompatibly, and +the ordinary service version check then rejects your mod with a clear error instead of letting it corrupt memory in a +version it wasn't built for. Service-only and asset-only mods should *not* import it; they stay compatible across game +ABI changes. + +--- + ## Asset Overlays Files placed under `overlay/` in the `.dusk` archive override game files at the corresponding path. For example, @@ -331,7 +485,7 @@ and [TextureService](#textureservice-modssvctextureh). Mods can be disabled, re-enabled, and reloaded at runtime without restarting the game (the enabled state persists as the `mod..enabled` config var). Write your mod assuming this happens: -- **Disable** calls `mod_shutdown`, removes your services, overlays, and texture replacements (both static and +- **Disable** calls `mod_shutdown`, removes your hooks, services, overlays, and texture replacements (both static and runtime-registered), and unloads your library. - **Enable** and **Reload** load a *fresh copy* of your library, imports are re-resolved, and `mod_initialize` runs again. You never see a second `mod_initialize` on the same image, so just make `mod_shutdown` release anything the @@ -344,6 +498,10 @@ first (in reverse dependency order) and brings them back afterward. A mod whose suspended and resumes automatically when the provider returns. Mods with an *optional* import of a disabled provider restart with that import null. +**One caution for hooks:** lifecycle changes are applied between frames, which is safe for hooks on functions +that return every frame (effectively everything you'd normally hook). Avoid hooking a function that stays on +the stack for the whole session (e.g. the outermost main loop); a mod that does cannot be safely unloaded. + --- ## Error Handling diff --git a/files.cmake b/files.cmake index 03d7a3af66..524c53943f 100644 --- a/files.cmake +++ b/files.cmake @@ -1550,8 +1550,12 @@ set(DUSK_FILES src/dusk/mods/loader/loader.hpp src/dusk/mods/loader/native_module.cpp src/dusk/mods/loader/native_module.hpp + src/dusk/mods/loader/manifest.cpp + src/dusk/mods/loader/manifest.hpp src/dusk/mods/svc/config.cpp src/dusk/mods/svc/config.hpp + src/dusk/mods/svc/game.cpp + src/dusk/mods/svc/hook.cpp src/dusk/mods/svc/host.cpp src/dusk/mods/svc/log.cpp src/dusk/mods/svc/overlay.cpp diff --git a/include/d/d_com_inf_game.h b/include/d/d_com_inf_game.h index d240507f97..b66c1ee8e8 100644 --- a/include/d/d_com_inf_game.h +++ b/include/d/d_com_inf_game.h @@ -17,7 +17,7 @@ #include "m_Do/m_Do_graphic.h" #include -#include "tracy/Tracy.hpp" +#include "dusk/profiling.hpp" enum dComIfG_ButtonStatus { /* 0x00 */ BUTTON_STATUS_NONE, diff --git a/include/dusk/gx_helper.h b/include/dusk/gx_helper.h index bf5f3c4d3a..02a36fc6db 100644 --- a/include/dusk/gx_helper.h +++ b/include/dusk/gx_helper.h @@ -5,7 +5,8 @@ #include #include -#include "tracy/Tracy.hpp" + +#include "profiling.hpp" #if DUSK_GFX_DEBUG_GROUPS #define GX_DEBUG_GROUP(name, ...) \ diff --git a/include/dusk/profiling.hpp b/include/dusk/profiling.hpp new file mode 100644 index 0000000000..82dab45216 --- /dev/null +++ b/include/dusk/profiling.hpp @@ -0,0 +1,12 @@ +#pragma once + +#if defined(__has_include) +#if __has_include() +#include +#endif +#endif + +#ifndef ZoneScoped +#define ZoneScoped +#define ZoneScopedN(name) +#endif diff --git a/include/global.h b/include/global.h index 2a8e182bfa..5add912748 100644 --- a/include/global.h +++ b/include/global.h @@ -114,6 +114,19 @@ inline int __builtin_clz(unsigned int v) { #endif +// Data symbols exported from the main exe 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/mods/hook.hpp b/include/mods/hook.hpp new file mode 100644 index 0000000000..c58bd719c9 --- /dev/null +++ b/include/mods/hook.hpp @@ -0,0 +1,464 @@ +#pragma once + +#include "mods/svc/hook.h" + +#include +#include +#include +#include +#include +#include + +namespace dusk::mods { + +template +T arg(void* argsRaw, int n) noexcept { + void** args = static_cast(argsRaw); + return *static_cast > >(args[n]); +} + +template +std::remove_reference_t& arg_ref(void* argsRaw, int n) noexcept { + void** args = static_cast(argsRaw); + return *static_cast > >(args[n]); +} + +template +void* mfp_addr(F fn) noexcept { + void* p = nullptr; + static_assert(sizeof(fn) >= sizeof(void*), "unexpected function pointer size"); + std::memcpy(&p, &fn, sizeof(void*)); + return p; +} + +/* A string usable as a template argument: carries the hook target's symbol name and + * makes each NamedHook instantiation's static state unique. */ +template +struct FixedString { + char chars[N]{}; + constexpr FixedString(const char (&s)[N]) noexcept { + for (size_t i = 0; i < N; ++i) { + chars[i] = s[i]; + } + } +}; + +namespace detail { + +template +constexpr std::string_view class_name() { +#if defined(__clang__) || defined(__GNUC__) + // "... class_name() [T = daAlink_c]" / "... [with T = daAlink_c; ...]" + constexpr std::string_view fn = __PRETTY_FUNCTION__; + constexpr size_t start = fn.find("T = ") + 4; + return fn.substr(start, fn.find_first_of(";]", start) - start); +#elif defined(_MSC_VER) + // "... class_name(void)" + constexpr std::string_view fn = __FUNCSIG__; + constexpr size_t start = fn.find("class_name<") + 11; + constexpr std::string_view name = fn.substr(start, fn.rfind(">(") - start); + if constexpr (name.starts_with("class ")) { + return name.substr(6); + } else if constexpr (name.starts_with("struct ")) { + return name.substr(7); + } else { + return name; + } +#else +#error "unsupported compiler" +#endif +} + +/* The manifest name of C's vtable. Only unscoped, non-template class names are + * supported (an empty result fails resolution and the install reports it). */ +template +constexpr auto vtable_symbol() { + constexpr std::string_view name = class_name(); + constexpr bool simple = name.find_first_of(":<> ") == std::string_view::npos; + // "_ZTV" + decimal length + name / "??_7" + name + "@@6B@", NUL-terminated + std::array out{}; + if constexpr (!simple) { + return out; + } + size_t n = 0; +#if defined(_WIN32) + for (char c : {'?', '?', '_', '7'}) { + out[n++] = c; + } + for (char c : name) { + out[n++] = c; + } + for (char c : {'@', '@', '6', 'B', '@'}) { + out[n++] = c; + } +#else + for (char c : {'_', 'Z', 'T', 'V'}) { + out[n++] = c; + } + size_t len = name.size(); + char digits[8]{}; + size_t d = 0; + while (len != 0) { + digits[d++] = static_cast('0' + len % 10); + len /= 10; + } + while (d != 0) { + out[n++] = digits[--d]; + } + for (char c : name) { + out[n++] = c; + } +#endif + return out; +} + +#if defined(_WIN32) +/* Follow jump stubs, then match the MSVC vcall thunk a virtual mfp points at. + * Returns the vtable slot's byte offset, or npos when fn is not a vcall thunk. */ +inline size_t vcall_slot_offset(const void*& fn) noexcept { + constexpr size_t npos = static_cast(-1); +#if defined(_M_X64) || defined(__x86_64__) + const auto* p = static_cast(fn); + for (int i = 0; i < 8 && p[0] == 0xE9; ++i) { // incremental-link stubs + int32_t rel; + std::memcpy(&rel, p + 1, 4); + p += 5 + rel; + } + fn = p; + // The vptr load. Unoptimized clang-cl thunks spill/reload rcx first + // (push rax; mov [rsp], rcx; mov rcx, [rsp]), so scan a short window. + const uint8_t* q = nullptr; + for (int i = 0; i <= 12; ++i) { + if (p[i] == 0x48 && p[i + 1] == 0x8B && p[i + 2] == 0x01) { // mov rax, [rcx] + q = p + i + 3; + break; + } + } + if (q == nullptr) { + return npos; + } + if (q[0] == 0xFF && q[1] == 0x20) { // jmp [rax] (MSVC) + return 0; + } + if (q[0] == 0xFF && q[1] == 0x60) { // jmp [rax + imm8] + return static_cast(q[2]); + } + if (q[0] == 0xFF && q[1] == 0xA0) { // jmp [rax + imm32] + int32_t off; + std::memcpy(&off, q + 2, 4); + return off; + } + // clang-cl: mov rax, [rax + off]; (pop r10;) jmp rax. Requiring the jmp rax + // distinguishes the thunk from an ordinary getter that begins the same way. + if (q[0] == 0x48 && q[1] == 0x8B && (q[2] == 0x00 || q[2] == 0x40 || q[2] == 0x80)) { + size_t off = 0; + const uint8_t* r = q + 3; + if (q[2] == 0x40) { + off = static_cast(q[3]); + r = q + 4; + } else if (q[2] == 0x80) { + int32_t off32; + std::memcpy(&off32, q + 3, 4); + off = off32; + r = q + 7; + } + for (int i = 0; i <= 8; ++i) { + if (r[i] == 0xFF && r[i + 1] == 0xE0) { // jmp rax (48 REX optional) + return off; + } + } + } + return npos; +#elif defined(_M_ARM64) || defined(__aarch64__) + const auto* p = static_cast(fn); + uint32_t insn[3]; + for (int i = 0; i < 8; ++i) { // incremental-link `b` stubs + std::memcpy(insn, p, 4); + if ((insn[0] & 0xFC000000u) != 0x14000000u) { + break; + } + const auto imm26 = static_cast(insn[0] << 6) >> 6; + p += static_cast(imm26) * 4; + } + fn = p; + std::memcpy(insn, p, 12); + // ldr Xt, [x0]; ldr Xs, [Xt, #imm12*8]; br Xs + if ((insn[0] & 0xFFFFFFE0u) != 0xF9400000u) { + return npos; + } + const uint32_t t = insn[0] & 0x1Fu; + if ((insn[1] & 0xFFC003E0u) != (0xF9400000u | (t << 5))) { + return npos; + } + const uint32_t s = insn[1] & 0x1Fu; + if (insn[2] != (0xD61F0000u | (s << 5))) { + return npos; + } + return ((insn[1] >> 10) & 0xFFFu) * 8; +#else + (void)fn; + return npos; +#endif +} +#endif + +/* Code address of the member function a mfp designates. Virtual mfps don't carry + * one; recover it from the class's vtable (resolved from the symbol manifest), so + * Hook works uniformly on virtual and non-virtual members. */ +template +ModResult member_target(const HookService* hooks, F mfp, void** out) { + *out = nullptr; + uintptr_t words[sizeof(F) > sizeof(uintptr_t) ? 2 : 1] = {}; + std::memcpy(words, &mfp, sizeof(words) < sizeof(F) ? sizeof(words) : sizeof(F)); + +#if defined(_WIN32) + const void* fn = reinterpret_cast(words[0]); + const size_t slot = vcall_slot_offset(fn); + if (slot == static_cast(-1)) { // not a vcall thunk: direct address + *out = const_cast(fn); + return MOD_OK; + } + void* vtable = nullptr; + const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol().data(), &vtable, nullptr); + if (resolved != MOD_OK) { + return resolved; + } + // ??_7 points at the first slot. + *out = *reinterpret_cast(static_cast(vtable) + slot); +#else +#if defined(__aarch64__) || defined(__arm__) + // AAPCS C++ ABI: the virtual flag is bit 0 of the adjustment word (function + // addresses can't spare their low bit), and ptr holds the slot offset directly. + const bool isVirtual = (words[1] & 1) != 0; + const uintptr_t thisAdjust = words[1] >> 1; + const uintptr_t slotOffset = words[0]; +#else + // Itanium C++ ABI: virtual mfps set bit 0 of ptr; the slot offset is ptr - 1. + const bool isVirtual = (words[0] & 1) != 0; + const uintptr_t thisAdjust = words[1]; + const uintptr_t slotOffset = words[0] - 1; +#endif + if (!isVirtual) { // non-virtual: the address itself + *out = reinterpret_cast(words[0]); + return MOD_OK; + } + if (thisAdjust != 0) { + // this-adjusting mfp (member of a secondary base): the slot offset is + // relative to a vtable we can't locate. Hook the overrider by name instead. + return MOD_UNSUPPORTED; + } + void* vtable = nullptr; + const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol().data(), &vtable, nullptr); + if (resolved != MOD_OK) { + return resolved; + } + // _ZTV points at the offset-to-top slot; the address point mfps index from is + // two pointers in (past offset-to-top and the typeinfo pointer). + *out = *reinterpret_cast(static_cast(vtable) + 2 * sizeof(void*) + slotOffset); +#endif + return *out != nullptr ? MOD_OK : MOD_UNAVAILABLE; +} + +} // namespace detail + +/* Trampoline generator + per-target state shared by Hook and NamedHook. Tag makes + * each hooked target's statics distinct; the target address is filled in at install. */ +template +struct HookImpl { + static inline R (*g_orig)(A...) = nullptr; + static inline const HookService* hooks = nullptr; + static inline void* target = nullptr; + + static bool dispatch_pre(void* args, void* retval) { + if (hooks == nullptr) { + return false; + } + + int skipOriginal = 0; + const ModResult result = hooks->dispatch_pre(mod_ctx, target, args, retval, &skipOriginal); + return result == MOD_OK && skipOriginal != 0; + } + + static void dispatch_post(void* args, void* retval) { + if (hooks != nullptr) { + hooks->dispatch_post(mod_ctx, target, args, retval); + } + } + + static R trampoline(A... args) { + if constexpr (sizeof...(A) == 0) { + if constexpr (std::is_void_v) { + const bool skipOriginal = dispatch_pre(nullptr, nullptr); + if (!skipOriginal) { + g_orig(args...); + } + dispatch_post(nullptr, nullptr); + } else { + R result{}; + const bool skipOriginal = + dispatch_pre(nullptr, static_cast(std::addressof(result))); + if (!skipOriginal) { + result = g_orig(args...); + } + dispatch_post(nullptr, static_cast(std::addressof(result))); + return result; + } + } else { + void* ptrs[] = {static_cast(std::addressof(args))...}; + if constexpr (std::is_void_v) { + const bool skipOriginal = dispatch_pre(static_cast(ptrs), nullptr); + if (!skipOriginal) { + g_orig(args...); + } + dispatch_post(static_cast(ptrs), nullptr); + } else { + R result{}; + const bool skipOriginal = dispatch_pre( + static_cast(ptrs), static_cast(std::addressof(result))); + if (!skipOriginal) { + result = g_orig(args...); + } + dispatch_post(static_cast(ptrs), static_cast(std::addressof(result))); + return result; + } + } + } +}; + +namespace detail { +template +using TargetTag = std::integral_constant; +template +struct NameTag {}; +} // namespace detail + +/* + * Typed hook on a function named at compile time (&daAlink_c::execute, &free_fn). + * Member functions may be virtual: the install decodes the member function pointer and hooks the + * class's own overrider. + */ +template +struct Hook; + +template +struct Hook : HookImpl, R, C*, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + return detail::member_target(hooks, Target, out); + } +}; + +template +struct Hook : HookImpl, R, const C*, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + return detail::member_target(hooks, Target, out); + } +}; + +template +struct Hook : HookImpl, R, A...> { + static ModResult resolve_target(const HookService*, void** out) { + *out = mfp_addr(Target); + return MOD_OK; + } +}; + +/* + * Typed hook on a function by its symbol name, for targets you can't name in C++: file-local + * statics, private members, or symbols without a header. The signature is written free-style with + * the receiver first and is *not* compiler-checked. + * + * using HookshotHit = dusk::mods::NamedHook< + * "daAlink_hookshotAtHitCallBack", + * void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*)>; + * dusk::mods::hook_add_pre(svc_hook, on_hookshot_hit); + */ +template +struct NamedHook; + +template +struct NamedHook : HookImpl, R, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + HookSymbolFlags flags{}; + const ModResult resolved = hooks->resolve(mod_ctx, Name.chars, out, &flags); + if (resolved == MOD_OK && (flags & HOOK_SYMBOL_CODE) == 0) { + *out = nullptr; + return MOD_INVALID_ARGUMENT; + } + return resolved; + } +}; + +template +ModResult hook_install(const HookService* hooks) { + if (hooks == nullptr) { + return MOD_UNAVAILABLE; + } + + Entry::hooks = hooks; + if (Entry::target == nullptr) { + const ModResult resolved = Entry::resolve_target(hooks, &Entry::target); + if (resolved != MOD_OK) { + return resolved; + } + } + return hooks->install(mod_ctx, Entry::target, reinterpret_cast(Entry::trampoline), + reinterpret_cast(&Entry::g_orig)); +} + +template +ModResult hook_install(const HookService* hooks) { + return hook_install >(hooks); +} + +template +ModResult hook_add_pre( + const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) { + const ModResult installed = hook_install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->add_pre(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_add_pre( + const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) { + return hook_add_pre >(hooks, callback, options); +} + +template +ModResult hook_add_post( + const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) { + const ModResult installed = hook_install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->add_post(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_add_post( + const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) { + return hook_add_post >(hooks, callback, options); +} + +template +ModResult hook_replace( + const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) { + const ModResult installed = hook_install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->replace(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_replace( + const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) { + return hook_replace >(hooks, callback, options); +} + +} // namespace dusk::mods diff --git a/include/mods/svc/game.h b/include/mods/svc/game.h new file mode 100644 index 0000000000..fbd914d4bc --- /dev/null +++ b/include/mods/svc/game.h @@ -0,0 +1,30 @@ +#pragma once + +#include "mods/api.h" + +/* + * Mods that link or hook game code directly must import this service; service-only and asset-only + * mods must not. + * + * Major version is the game-code ABI epoch: it is bumped when game-visible struct or vtable layouts + * change incompatibly (e.g. a TARGET_PC field added to an existing game struct). The loader's + * ordinary version check then fails mods built against the old epoch with a clear message instead + * of letting them corrupt memory. + */ +#define GAME_SERVICE_ID "dev.twilitrealm.dusklight.game" +#define GAME_SERVICE_MAJOR 1u +#define GAME_SERVICE_MINOR 0u + +typedef struct GameService { + ServiceHeader header; +} GameService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = GAME_SERVICE_ID; + static constexpr uint16_t major_version = GAME_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/hook.h b/include/mods/svc/hook.h new file mode 100644 index 0000000000..5eb710d89d --- /dev/null +++ b/include/mods/svc/hook.h @@ -0,0 +1,131 @@ +#pragma once + +#include "mods/api.h" + +/* + * Intercept game functions by address. Prefer the typed helpers in mods/hook.hpp + * (hook_add_pre/hook_add_post/hook_replace over a &Class::method): they generate the + * trampoline and hide install/dispatch, which are the low-level primitives those helpers + * build. resolve() maps a symbol name to an address for targets you can't name at compile time + * (file-local statics included). + * + * Every call is game-thread-only. Install and removal must run with no hooked function on the + * stack; the loader guarantees this by applying mod lifecycle changes between frames, which is + * why hooking a function that never returns (the outermost loop) makes a mod un-unloadable. + */ + +#define HOOK_SERVICE_ID "dev.twilitrealm.dusklight.hook" +#define HOOK_SERVICE_MAJOR 1u +#define HOOK_SERVICE_MINOR 0u + +/* Symbol flags reported by resolve() */ +typedef enum HookSymbolFlags { + HOOK_SYMBOL_CODE = 1u << 0u, + HOOK_SYMBOL_DATA = 1u << 1u, + /* Not exported/dynamically visible: hookable, but never linkable. */ + HOOK_SYMBOL_LOCAL = 1u << 2u, + /* Other names share this address (ICF fold/alias): a hook intercepts them all. */ + HOOK_SYMBOL_MULTI_NAME = 1u << 3u, + /* Resolved through a demangled display-name alias rather than the real symbol. */ + HOOK_SYMBOL_DISPLAY = 1u << 6u, +} HookSymbolFlags; + +/* A pre-hook's return value: whether to run the original function. */ +typedef enum HookAction { + HOOK_CONTINUE = 0, /* run the original (and any lower-priority pre-hooks) */ + HOOK_SKIP_ORIGINAL = 1, /* cancel the original and remaining pre-hooks; post-hooks still run */ +} HookAction; + +/* How replace resolves a second replace-hook on a target that already has one. */ +typedef enum HookReplacePolicy { + HOOK_REPLACE_CONFLICT = 0, /* refuse with MOD_CONFLICT (the default) */ + HOOK_REPLACE_PRIORITY = 1, /* take over only if this options.priority is strictly higher */ + HOOK_REPLACE_OVERRIDE = 2, /* take over unconditionally */ +} HookReplacePolicy; + +typedef enum HookFlags { + HOOK_FLAG_NONE = 0u, + HOOK_FLAG_FAIL_ON_CONFLICT = 1u << 0u, +} HookFlags; + +/* + * Hook callbacks. `args` is an array of pointers to the call's arguments (index 0 is `this` + * for member functions); `retval` points at the return slot (NULL for void). Read and write + * them through dusk::mods::arg / arg_ref from mods/hook.hpp. `userdata` is the pointer + * from HookOptions. All run on the game thread, in the hooked call's own stack frame. + */ +typedef HookAction (*HookPreFn)(ModContext* ctx, void* args, void* retval, void* userdata); +typedef void (*HookPostFn)(ModContext* ctx, void* args, void* retval, void* userdata); +typedef void (*HookReplaceFn)(ModContext* ctx, void* args, void* retval, void* userdata); + +typedef struct HookOptions { + uint32_t struct_size; + /* Higher runs first; ties break by registration order. Applies to pre/post ordering and, + * with HOOK_REPLACE_PRIORITY, to replace-hook takeover. */ + int32_t priority; + uint32_t flags; /* HookFlags */ + HookReplacePolicy replace_policy; + void* userdata; /* passed back to the callback */ +} HookOptions; + +#define HOOK_OPTIONS_INIT {sizeof(HookOptions), 0, HOOK_FLAG_NONE, HOOK_REPLACE_CONFLICT, NULL} + +typedef struct HookService { + ServiceHeader header; + + /* + * Install a trampoline detour on fn_addr and return the address to call the original through in + * *out_original_fn. The typed helpers generate the trampoline and call this; mods normally + * don't. The first mod to install a given target owns the live detour; later mods register as + * candidates so a hook survives the owner unloading (the detour is handed off and every + * original pointer is rewritten). Idempotent per (mod, out slot). + */ + ModResult (*install)( + ModContext* ctx, void* fn_addr, void* trampoline_fn, void** out_original_fn); + + /* + * Register a callback on an already-installed target. Pre runs before the original (and can + * cancel it), post runs after (even if cancelled). Any number of mods may add pre/post to the + * same target; they run in priority then registration order. replace installs a single + * substitute for the original, managed by options.replace_policy, MOD_CONFLICT if refused. + */ + ModResult (*add_pre)( + ModContext* ctx, void* fn_addr, HookPreFn callback, const HookOptions* options); + ModResult (*add_post)( + ModContext* ctx, void* fn_addr, HookPostFn callback, const HookOptions* options); + ModResult (*replace)( + ModContext* ctx, void* fn_addr, HookReplaceFn callback, const HookOptions* options); + + /* + * Run the registered callbacks for a target. The generated trampoline calls these; they + * are not a mod-facing entry point. dispatch_pre reports through *out_skip_original + * whether the original should be skipped (a pre-hook returned HOOK_SKIP_ORIGINAL, or a + * replace-hook ran). + */ + ModResult (*dispatch_pre)( + ModContext* ctx, void* fn_addr, void* args, void* retval, int* out_skip_original); + ModResult (*dispatch_post)(ModContext* ctx, void* fn_addr, void* args, void* retval); + + /* + * Resolve a game symbol by name from the symbol manifest, including non-exported (static) + * functions. Names can be either the platform's mangled name (i.e. the name passed to dlopen; + * no Mach-O leading underscore) or the qualified function name without parameters (e.g. + * "daAlink_c::execute"). out_flags (optional) receives HookSymbolFlags. + * + * Results: MOD_OK; MOD_UNSUPPORTED (no manifest for this build, missing or stale); + * MOD_UNAVAILABLE (symbol not found); MOD_CONFLICT (name maps to more than one address: C++ + * overloads or per-TU statics; use the mangled name). + */ + ModResult (*resolve)( + ModContext* ctx, const char* symbol, void** out_addr, HookSymbolFlags* out_flags); +} HookService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = HOOK_SERVICE_ID; + static constexpr uint16_t major_version = HOOK_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/host.h b/include/mods/svc/host.h index 61d0831dae..43c19cadfb 100644 --- a/include/mods/svc/host.h +++ b/include/mods/svc/host.h @@ -8,8 +8,8 @@ */ #define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" -#define HOST_SERVICE_MAJOR 1u -#define HOST_SERVICE_MINOR 1u +#define HOST_SERVICE_MAJOR 2u +#define HOST_SERVICE_MINOR 0u /* * Ignore unknown values: later service minors may add events. @@ -35,6 +35,14 @@ typedef void (*ModLifecycleFn)(ModContext* ctx, ModContext* subject, const char* typedef struct HostService { ServiceHeader header; + /* Version string of the current Dusklight build. (e.g. "1.4.2") */ + const char* version; + + /* Build id of the running game binary: PDB GUID+age on Windows, LC_UUID on macOS, GNU build-id + * on Linux. May be empty (len 0) if the identity could not be determined. */ + const uint8_t* build_id; + uint32_t build_id_len; + /* * Look up a service by id at call time. Unlike a manifest import, this sees whatever is * currently published and carries no initialization-order guarantee (see mods/api.h). diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index 63953e78bf..533bcc22c1 100644 --- a/sdk/CMakeLists.txt +++ b/sdk/CMakeLists.txt @@ -6,6 +6,9 @@ # Usage (from a mod project): # add_subdirectory(/sdk dusk-sdk EXCLUDE_FROM_ALL) # add_mod(my_mod SOURCES ... MOD_JSON mod.json) +# +# On Windows, pass -DDUSK_GAME_IMPLIB= from the +# matching game release. TODO: auto-download from tag cmake_minimum_required(VERSION 3.25) @@ -30,4 +33,11 @@ configure_version_header() # Game ABI headers & compile definitions include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/GameABIConfig.cmake") +if (WIN32) + set(DUSK_GAME_IMPLIB "" CACHE FILEPATH "Path to the dusklight import library (sdk/dusklight.lib)") + if (DUSK_GAME_IMPLIB AND NOT EXISTS "${DUSK_GAME_IMPLIB}") + message(FATAL_ERROR "Mod SDK: DUSK_GAME_IMPLIB does not exist: ${DUSK_GAME_IMPLIB}") + endif () +endif () + include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/ModSDK.cmake") diff --git a/sdk/pseudo_reloc.cpp b/sdk/pseudo_reloc.cpp new file mode 100644 index 0000000000..f86d783f2d --- /dev/null +++ b/sdk/pseudo_reloc.cpp @@ -0,0 +1,139 @@ +// Mod SDK runtime (Windows): applies lld's MinGW-style runtime pseudo-relocations on +// the plain MSVC CRT. This is what lets mods reference game data (`extern` globals) with no +// __declspec(dllimport) annotations: `lld-link -lldmingw` auto-imports the data through IAT +// slots and records fixups, and this module applies them at load time. +// +// The fixup pass runs as an early CRT initializer, and the IAT slots it reads were already +// bound by the OS loader, so even mod static initializers observe patched references. + +#include + +#include +#include +#include + +extern "C" char __RUNTIME_PSEUDO_RELOC_LIST__; +extern "C" char __RUNTIME_PSEUDO_RELOC_LIST_END__; +extern "C" IMAGE_DOS_HEADER __ImageBase; + +namespace { + +struct HdrV2 { + uint32_t magic1; + uint32_t magic2; + uint32_t version; +}; +struct ItemV2 { + uint32_t sym; // RVA of the __imp_ slot (OS-bound IAT entry) + uint32_t target; // RVA of the reference to patch + uint32_t flags; // low 8 bits: bit width of the reference +}; + +bool g_relocsFailed = false; + +void report(const char* fmt, ...) { + char buf[512]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + fprintf(stderr, "%s\n", buf); + OutputDebugStringA(buf); +} + +bool compute_fixup(const ItemV2& item, char* base, intptr_t* out) { + char* impSlot = base + item.sym; + const intptr_t real = *reinterpret_cast(impSlot); + char* target = base + item.target; + const int bits = static_cast(item.flags & 0xff); + + intptr_t reldata; + switch (bits) { + case 8: + reldata = *reinterpret_cast(target); + break; + case 16: + reldata = *reinterpret_cast(target); + break; + case 32: + reldata = *reinterpret_cast(target); + break; + case 64: + reldata = *reinterpret_cast(target); + break; + default: + report("unsupported %d-bit pseudo-relocation at RVA 0x%x", bits, item.target); + return false; + } + reldata -= reinterpret_cast(impSlot); + reldata += real; + + if (bits < 64) { + const intptr_t maxUnsigned = (intptr_t{1} << bits) - 1; + const intptr_t minSigned = -(intptr_t{1} << (bits - 1)); + if (reldata > maxUnsigned || reldata < minSigned) { + report("%d-bit data fixup at RVA 0x%x is out of range (delta %+lld)", bits, item.target, + static_cast(reldata)); + return false; + } + } + *out = reldata; + return true; +} + +} // namespace + +// lld refuses to emit runtime pseudo-relocs unless a function with exactly this (mingw CRT) +// name exists in the image. It is also our actual entry point. +extern "C" void _pei386_runtime_relocator() { + char* base = reinterpret_cast(&__ImageBase); + char* start = &__RUNTIME_PSEUDO_RELOC_LIST__; + char* end = &__RUNTIME_PSEUDO_RELOC_LIST_END__; + if (end - start < static_cast(sizeof(HdrV2))) { + return; // no data auto-imports in this mod + } + const HdrV2* hdr = reinterpret_cast(start); + if (hdr->magic1 != 0 || hdr->magic2 != 0 || hdr->version != 1) { + report("unexpected pseudo-relocation list format"); + g_relocsFailed = true; + return; + } + const ItemV2* items = reinterpret_cast(hdr + 1); + const ItemV2* itemsEnd = reinterpret_cast(end); + + // Validate everything before writing anything, so a bad fixup can't leave the image + // half-patched. + intptr_t scratch; + for (const ItemV2* it = items; it < itemsEnd; ++it) { + if (!compute_fixup(*it, base, &scratch)) { + g_relocsFailed = true; + return; + } + } + for (const ItemV2* it = items; it < itemsEnd; ++it) { + intptr_t reldata; + compute_fixup(*it, base, &reldata); + char* target = base + it->target; + const size_t len = static_cast(it->flags & 0xff) / 8; + DWORD old = 0; + if (!VirtualProtect(target, len, PAGE_EXECUTE_READWRITE, &old)) { + report("VirtualProtect failed at RVA 0x%x", it->target); + g_relocsFailed = true; + return; + } + std::memcpy(target, &reldata, len); + VirtualProtect(target, len, old, &old); + } +} + +using PVFV = void (*)(); +#pragma section(".CRT$XCB", read) +extern "C" __declspec(allocate(".CRT$XCB")) PVFV dusk_mod_pseudo_reloc_init = + _pei386_runtime_relocator; + +BOOL WINAPI DllMain(HINSTANCE, DWORD reason, LPVOID) { + if (reason == DLL_PROCESS_ATTACH && g_relocsFailed) { + return FALSE; + } + return TRUE; +} diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 00c2cc69e8..fb293a55b4 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -11,9 +11,10 @@ #include "depgraph.hpp" #include "dusk/config.hpp" -#include "dusk/mods/svc/config.hpp" #include "dusk/io.hpp" +#include "dusk/mods/svc/config.hpp" #include "dusk/mods/svc/registry.hpp" +#include "manifest.hpp" #include "miniz.h" #include "native_module.hpp" #include "nlohmann/json.hpp" @@ -639,6 +640,8 @@ void ModLoader::init() { } m_initialized = true; + manifest::initialize(); + if (m_searchDirs.empty()) { Log.warn("no mod search directories configured; mod loading skipped"); return; diff --git a/src/dusk/mods/loader/manifest.cpp b/src/dusk/mods/loader/manifest.cpp new file mode 100644 index 0000000000..7e83dfb8f6 --- /dev/null +++ b/src/dusk/mods/loader/manifest.cpp @@ -0,0 +1,386 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include "manifest.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "aurora/lib/logging.hpp" + +#include "dusk/io.hpp" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#elif defined(__APPLE__) +#include +#include +#elif defined(__linux__) +#include +#include +#endif + +namespace dusk::mods::manifest { +namespace { + +aurora::Module Log("dusk::mods::manifest"); + +constexpr char kMagic[8] = {'S', 'Y', 'M', 'G', 'E', 'N', '\0', '\0'}; +constexpr uint32_t kVersion = 2; + +enum class Compression : uint32_t { + None = 0, + Zstd = 1, +}; + +// Mirrors the symgen manifest writer. +struct Header { + char magic[8]; + uint32_t version; + uint32_t compression; + uint64_t uncompressedLen; + uint64_t compressedLen; + uint32_t buildIdLen; + uint8_t buildId[32]; + uint32_t entryCount; +}; +static_assert(sizeof(Header) == 72); + +struct Entry { + uint64_t hash; + uint64_t rva; + uint32_t nameOff; + HookSymbolFlags flags; +}; +static_assert(sizeof(Entry) == 24); + +struct State { + std::vector data; + const Entry* entries = nullptr; + uint32_t entryCount = 0; + const char* strings = nullptr; + uint64_t stringsLen = 0; + uintptr_t imageBase = 0; + // (rva, nameOff) of entries flagged kFlagInlineSites, sorted by rva + std::vector > inlineSites; + bool loaded = false; + bool initialized = false; +}; +State s_state; + +uint64_t fnv1a64(const char* str) { + uint64_t hash = 0xcbf29ce484222325ull; + for (const char* p = str; *p != '\0'; ++p) { + hash ^= static_cast(*p); + hash *= 0x100000001b3ull; + } + return hash; +} + +// Build id of the running executable image, matching what symgen recorded: +// PDB GUID (RFC 4122 byte order) + age on Windows, LC_UUID on Mach-O, GNU +// build-id on ELF. Also reports the address RVAs are relative to. +bool running_image_identity(std::vector& outId, uintptr_t& outBase) { +#if defined(_WIN32) + auto* base = reinterpret_cast(GetModuleHandleW(nullptr)); + outBase = reinterpret_cast(base); + const auto* dos = reinterpret_cast(base); + const auto* nt = reinterpret_cast(base + dos->e_lfanew); + const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG]; + if (dir.VirtualAddress == 0) { + return false; + } + const auto* entries = reinterpret_cast(base + dir.VirtualAddress); + for (size_t i = 0; i < dir.Size / sizeof(IMAGE_DEBUG_DIRECTORY); ++i) { + if (entries[i].Type != IMAGE_DEBUG_TYPE_CODEVIEW) { + continue; + } + struct CvInfo { + uint32_t signature; // 'RSDS' + uint8_t guid[16]; + uint32_t age; + }; + if (entries[i].SizeOfData < sizeof(CvInfo)) { + continue; + } + const auto* cv = reinterpret_cast(base + entries[i].AddressOfRawData); + if (cv->signature != 0x53445352) { // "RSDS" + continue; + } + // The GUID struct stores Data1..Data3 little-endian in memory; the manifest + // stores RFC 4122 (big-endian) order, so swap them here. + outId.assign(cv->guid, cv->guid + 16); + std::swap(outId[0], outId[3]); + std::swap(outId[1], outId[2]); + std::swap(outId[4], outId[5]); + std::swap(outId[6], outId[7]); + for (int b = 0; b < 4; ++b) { + outId.push_back(static_cast(cv->age >> (8 * b))); + } + return true; + } + return false; +#elif defined(__APPLE__) + // Image 0 is the main executable. The manifest stores link-time vmaddrs + // (nm convention, __TEXT vmaddr included). + const auto* header = _dyld_get_image_header(0); + outBase = static_cast(_dyld_get_image_vmaddr_slide(0)); + const auto* header64 = reinterpret_cast(header); + const auto* cmd = reinterpret_cast(header64 + 1); + for (uint32_t i = 0; i < header64->ncmds; ++i) { + if (cmd->cmd == LC_UUID) { + const auto* uuidCmd = reinterpret_cast(cmd); + outId.assign(uuidCmd->uuid, uuidCmd->uuid + 16); + return true; + } + cmd = reinterpret_cast( + reinterpret_cast(cmd) + cmd->cmdsize); + } + return false; +#elif defined(__linux__) + struct Ctx { + std::vector* id; + uintptr_t base = 0; + bool found = false; + } ctx{&outId}; + dl_iterate_phdr( + [](dl_phdr_info* info, size_t, void* data) -> int { + auto* ctx = static_cast(data); + // The first callback is the main executable. + ctx->base = info->dlpi_addr; + for (int i = 0; i < info->dlpi_phnum; ++i) { + const auto& phdr = info->dlpi_phdr[i]; + if (phdr.p_type != PT_NOTE) { + continue; + } + const auto* p = reinterpret_cast(info->dlpi_addr + phdr.p_vaddr); + const auto* end = p + phdr.p_memsz; + while (p + sizeof(ElfW(Nhdr)) <= end) { + const auto* note = reinterpret_cast(p); + const auto* name = p + sizeof(ElfW(Nhdr)); + const auto* desc = name + ((note->n_namesz + 3) & ~3u); + if (note->n_type == NT_GNU_BUILD_ID && note->n_namesz == 4 && + std::memcmp(name, "GNU", 4) == 0) + { + ctx->id->assign(desc, desc + note->n_descsz); + ctx->found = true; + return 1; + } + p = desc + ((note->n_descsz + 3) & ~3u); + } + } + return 1; // only inspect the main executable + }, + &ctx); + outBase = ctx.base; + return ctx.found; +#else + (void)outId; + (void)outBase; + return false; +#endif +} + +std::filesystem::path manifest_path() { + const char* basePath = SDL_GetBasePath(); + std::filesystem::path dir = + basePath != nullptr ? std::filesystem::path{basePath} : std::filesystem::current_path(); + return dir / "dusklight.symdb"; +} + +std::string hex_string(const uint8_t* data, size_t len) { + std::string out; + out.reserve(len * 2); + for (size_t i = 0; i < len; ++i) { + constexpr char kHex[] = "0123456789abcdef"; + out.push_back(kHex[data[i] >> 4]); + out.push_back(kHex[data[i] & 0xF]); + } + return out; +} + +} // namespace + +void initialize() { + if (s_state.initialized) { + return; + } + s_state.initialized = true; + + const auto path = manifest_path(); + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + Log.info("no symbol manifest at {}; by-name resolution unavailable", + io::fs_path_to_string(path)); + return; + } + std::vector data; + try { + data = io::FileStream::ReadAllBytes(path); + } catch (const std::exception& e) { + Log.error("failed to read symbol manifest {}: {}", io::fs_path_to_string(path), e.what()); + return; + } + if (data.size() < sizeof(Header)) { + Log.error( + "symbol manifest {} is truncated ({} bytes)", io::fs_path_to_string(path), data.size()); + return; + } + + Header header{}; + std::memcpy(&header, data.data(), sizeof(header)); + if (std::memcmp(header.magic, kMagic, sizeof(kMagic)) != 0 || header.version != kVersion) { + Log.error("symbol manifest {} has wrong magic/version", io::fs_path_to_string(path)); + return; + } + const auto compression = static_cast(header.compression); + if ((compression != Compression::None && compression != Compression::Zstd) || + header.buildIdLen > sizeof(header.buildId) || + header.compressedLen > data.size() - sizeof(Header) || + header.uncompressedLen > std::numeric_limits::max() || + (compression == Compression::None && header.compressedLen != header.uncompressedLen)) + { + Log.error("symbol manifest {} is malformed", io::fs_path_to_string(path)); + return; + } + + std::vector imageId; + uintptr_t imageBase = 0; + if (!running_image_identity(imageId, imageBase)) { + Log.error("cannot determine the running image's build id; ignoring symbol manifest"); + return; + } + if (imageId.size() != header.buildIdLen || + std::memcmp(imageId.data(), header.buildId, imageId.size()) != 0) + { + Log.error("symbol manifest {} is stale: built for {}, running image is {}", + io::fs_path_to_string(path), hex_string(header.buildId, header.buildIdLen), + hex_string(imageId.data(), imageId.size())); + return; + } + + const auto compressedLen = static_cast(header.compressedLen); + const auto uncompressedLen = static_cast(header.uncompressedLen); + std::vector payload; + const auto* storedPayload = data.data() + sizeof(Header); + if (compression == Compression::None) { + payload.assign(storedPayload, storedPayload + compressedLen); + } else { + payload.resize(uncompressedLen); + const size_t decompressedLen = + ZSTD_decompress(payload.data(), payload.size(), storedPayload, compressedLen); + if (ZSTD_isError(decompressedLen)) { + Log.error("failed to decompress symbol manifest {}: {}", io::fs_path_to_string(path), + ZSTD_getErrorName(decompressedLen)); + return; + } + if (decompressedLen != payload.size()) { + Log.error("symbol manifest {} decompressed to {} bytes, expected {}", + io::fs_path_to_string(path), decompressedLen, payload.size()); + return; + } + } + data = std::move(payload); + + const uint64_t entriesEnd = uint64_t{header.entryCount} * sizeof(Entry); + if (entriesEnd > data.size()) { + Log.error("decompressed symbol manifest {} is malformed", io::fs_path_to_string(path)); + return; + } + + s_state.data = std::move(data); + s_state.entries = reinterpret_cast(s_state.data.data()); + s_state.entryCount = header.entryCount; + s_state.strings = reinterpret_cast(s_state.data.data() + entriesEnd); + s_state.stringsLen = s_state.data.size() - entriesEnd; + s_state.imageBase = imageBase; + for (uint32_t i = 0; i < s_state.entryCount; ++i) { + const Entry& entry = s_state.entries[i]; + if ((entry.flags & kFlagInlineSites) != 0 && entry.nameOff < s_state.stringsLen) { + s_state.inlineSites.emplace_back(entry.rva, entry.nameOff); + } + } + std::sort(s_state.inlineSites.begin(), s_state.inlineSites.end()); + s_state.inlineSites.erase(std::unique(s_state.inlineSites.begin(), s_state.inlineSites.end(), + [](const auto& a, const auto& b) { return a.first == b.first; }), + s_state.inlineSites.end()); + s_state.loaded = true; + Log.info("symbol manifest loaded: {} symbols, build id {}", s_state.entryCount, + hex_string(header.buildId, header.buildIdLen)); +} + +bool available() { + return s_state.loaded; +} + +const std::vector& image_build_id() { + static const std::vector s_id = [] { + std::vector id; + uintptr_t base = 0; + running_image_identity(id, base); + return id; + }(); + return s_id; +} + +ResolveStatus resolve(const char* name, void** outAddr, HookSymbolFlags* outFlags) { + if (!s_state.loaded) { + return ResolveStatus::Unavailable; + } + const uint64_t hash = fnv1a64(name); + const Entry* begin = s_state.entries; + const Entry* end = begin + s_state.entryCount; + size_t lo = 0; + size_t hi = s_state.entryCount; + while (lo < hi) { + const size_t mid = lo + (hi - lo) / 2; + if (begin[mid].hash < hash) { + lo = mid + 1; + } else { + hi = mid; + } + } + for (const Entry* entry = begin + lo; entry != end && entry->hash == hash; ++entry) { + if (entry->nameOff >= s_state.stringsLen || + std::strcmp(s_state.strings + entry->nameOff, name) != 0) + { + continue; + } + if ((entry->flags & kFlagDupName) != 0) { + return ResolveStatus::Ambiguous; + } + *outAddr = reinterpret_cast(s_state.imageBase + entry->rva); + if (outFlags != nullptr) { + *outFlags = entry->flags; + } + return ResolveStatus::Ok; + } + return ResolveStatus::NotFound; +} + +bool has_inline_sites(const void* addr, const char** outName) { + if (!s_state.loaded || s_state.inlineSites.empty()) { + return false; + } + const auto rva = static_cast(reinterpret_cast(addr) - s_state.imageBase); + const auto it = std::lower_bound(s_state.inlineSites.begin(), s_state.inlineSites.end(), + std::pair{rva, 0}); + if (it == s_state.inlineSites.end() || it->first != rva) { + return false; + } + if (outName != nullptr) { + *outName = s_state.strings + it->second; + } + return true; +} + +} // namespace dusk::mods::manifest diff --git a/src/dusk/mods/loader/manifest.hpp b/src/dusk/mods/loader/manifest.hpp new file mode 100644 index 0000000000..c6bc03f038 --- /dev/null +++ b/src/dusk/mods/loader/manifest.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +#include "mods/svc/hook.h" + +namespace dusk::mods::manifest { + +// Symbol flags mirrored from symgen. +constexpr uint32_t kFlagCode = 1u << 0; +constexpr uint32_t kFlagData = 1u << 1; +constexpr uint32_t kFlagLocal = 1u << 2; +constexpr uint32_t kFlagMultiName = 1u << 3; +constexpr uint32_t kFlagDupName = 1u << 4; +constexpr uint32_t kFlagInlineSites = 1u << 5; +constexpr uint32_t kFlagDisplay = 1u << 6; + +enum class ResolveStatus { + Ok, + Unavailable, // no manifest loaded (missing, stale, or malformed) + NotFound, + Ambiguous, // name maps to multiple addresses (overloads / per-TU statics) +}; + +// Maps the symbol manifest next to the game binary and validates it against the +// running image's build id (PDB GUID+age / Mach-O UUID / GNU build-id). A missing or +// stale manifest logs and leaves by-name resolution unavailable; hooks by address are +// unaffected. Safe to call more than once. +void initialize(); + +bool available(); + +// Build id of the running executable image (PDB GUID+age / Mach-O UUID / GNU +// build-id), computed once on first use; empty if it couldn't be determined. +// Independent of whether a manifest file was loaded. +const std::vector& image_build_id(); + +// Resolve a symbol name to its address in the running image. Names can be either the platform's +// mangled name (i.e. the name passed to dlopen; no Mach-O leading underscore) or the function name +// without parameters (e.g. "daAlink_c::execute"). +ResolveStatus resolve(const char* name, void** outAddr, HookSymbolFlags* outFlags = nullptr); + +// True if the manifest records that the function at this code address was inlined into +// at least one caller in this build. An entry hook on it only intercepts the calls +// that were not inlined. outName receives the symbol name (valid for the process lifetime) +// when known. False when no manifest is loaded. +bool has_inline_sites(const void* addr, const char** outName = nullptr); + +} // namespace dusk::mods::manifest diff --git a/src/dusk/mods/svc/game.cpp b/src/dusk/mods/svc/game.cpp new file mode 100644 index 0000000000..f0bb8639a3 --- /dev/null +++ b/src/dusk/mods/svc/game.cpp @@ -0,0 +1,21 @@ +#include "registry.hpp" + +#include "mods/svc/game.h" + +namespace dusk::mods::svc { +namespace { + +constexpr GameService s_gameService{ + .header = SERVICE_HEADER(GameService, GAME_SERVICE_MAJOR, GAME_SERVICE_MINOR), +}; + +} // namespace + +constinit const ServiceModule g_gameModule{ + .id = GAME_SERVICE_ID, + .majorVersion = GAME_SERVICE_MAJOR, + .minorVersion = GAME_SERVICE_MINOR, + .service = &s_gameService, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/hook.cpp b/src/dusk/mods/svc/hook.cpp new file mode 100644 index 0000000000..f565776183 --- /dev/null +++ b/src/dusk/mods/svc/hook.cpp @@ -0,0 +1,480 @@ +#include "registry.hpp" + +#include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/loader/manifest.hpp" +#include "mods/svc/hook.h" + +#if DUSK_CODE_MODS +#include "dusk/logging.h" + +#include +#include +#include +#include +#include +#include +#endif + +namespace dusk::mods::svc { +namespace { + +#if DUSK_CODE_MODS + +struct PreHookFn { + ModContext* context = nullptr; + HookPreFn callback = nullptr; + HookOptions options = HOOK_OPTIONS_INIT; + uint64_t order = 0; +}; + +struct VoidHookFn { + ModContext* context = nullptr; + HookReplaceFn replaceCallback = nullptr; + HookPostFn postCallback = nullptr; + HookOptions options = HOOK_OPTIONS_INIT; + uint64_t order = 0; +}; + +struct HookSlot { + std::vector pre; + VoidHookFn replace{}; + std::vector post; +}; + +// One per mod that requested a hook on a target: its template-generated trampoline and the +// address of its Hook::g_orig, both living in the mod's dylib. Any candidate's trampoline +// is interchangeable (dispatch walks the shared HookSlot), so when the active installer's mod +// unloads, the funchook detour is handed off to a surviving candidate and every candidate's +// *orig_store is rewritten to the new original pointer. +struct HookCandidate { + ModContext* context = nullptr; + void* trampoline = nullptr; + void** origStore = nullptr; + uint64_t order = 0; +}; + +struct InstalledHook { + funchook_t* handle = nullptr; + void* original = nullptr; + ModContext* active = nullptr; + std::vector candidates; +}; + +std::unordered_map s_registry; +std::unordered_map s_installed; +uint64_t s_nextOrder = 0; + +HookOptions normalize_options(const HookOptions* options) { + if (options == nullptr || options->struct_size < sizeof(HookOptions)) { + return HOOK_OPTIONS_INIT; + } + return *options; +} + +template +void sort_hooks(std::vector& hooks) { + std::ranges::stable_sort(hooks, [](const T& a, const T& b) { + if (a.options.priority != b.options.priority) { + return a.options.priority > b.options.priority; + } + return a.order < b.order; + }); +} + +// Follow E9/FF25 chains to skip MSVC incremental-link and import stubs. +void* resolve_import_thunk(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] == 0x48 && p[1] == 0xFF && p[2] == 0x25) { // lld emits a REX.W prefix + ++p; + } + 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; + } + if (p[0] == 0xE9) { + int32_t offset; + std::memcpy(&offset, p + 1, 4); + addr = const_cast(p) + 5 + offset; + } else { + break; + } + } +#elif defined(_WIN32) && (defined(_M_ARM64) || defined(__aarch64__)) + // Import thunks are `adrp x16; ldr x16, [x16, #off]; br x16` (deref the IAT slot); + // incremental-link stubs are a plain `b`, or `adrp x16; add x16, x16, #off; br x16` + // range-extension thunks when the target is out of B range. + for (int i = 0; i < 8; ++i) { + const auto* p = static_cast(addr); + uint32_t insn0, insn1, insn2; + std::memcpy(&insn0, p, 4); + if ((insn0 & 0xFC000000u) == 0x14000000u) { // b imm26 + auto imm26 = static_cast(insn0 << 6) >> 6; + addr = const_cast(p) + static_cast(imm26) * 4; + continue; + } + if ((insn0 & 0x9F00001Fu) != 0x90000010u) { // adrp x16, page + break; + } + std::memcpy(&insn1, p + 4, 4); + std::memcpy(&insn2, p + 8, 4); + if (insn2 != 0xD61F0200u) { // br x16 + break; + } + auto immhi = static_cast(static_cast(insn0 << 8) >> 13); // bits 23:5 + auto immlo = static_cast((insn0 >> 29) & 3); + auto page = (reinterpret_cast(p) & ~uintptr_t{0xFFF}) + + (static_cast((immhi << 2) | immlo) << 12); + if ((insn1 & 0xFFC003FFu) == 0xF9400210u) { // ldr x16, [x16, #imm12*8] + auto slot = page + ((insn1 >> 10) & 0xFFF) * 8; + addr = *reinterpret_cast(slot); + break; + } + if ((insn1 & 0xFF8003FFu) == 0x91000210u) { // add x16, x16, #imm12{, lsl #12} + auto imm = static_cast((insn1 >> 10) & 0xFFF); + addr = reinterpret_cast(page + (((insn1 >> 22) & 1) != 0 ? imm << 12 : imm)); + continue; + } + break; + } +#endif + return addr; +} + +funchook_t* install_trampoline(void* fnAddr, void* trampoline, void** outOriginal) { + funchook_t* fh = funchook_create(); + if (fh == nullptr) { + DuskLog.warn("HookSystem: funchook_create failed for {:p}", fnAddr); + return nullptr; + } + + void* fn = fnAddr; + const int prep = funchook_prepare(fh, &fn, trampoline); + const int inst = prep == 0 ? funchook_install(fh, 0) : -1; + if (prep != 0 || inst != 0) { + const char* message = funchook_error_message(fh); + DuskLog.warn("HookSystem: funchook failed for {:p} (prepare={} install={}): {}", fnAddr, + prep, inst, message != nullptr && message[0] != '\0' ? message : "no details"); + funchook_destroy(fh); + return nullptr; + } + + *outOriginal = fn; + return fh; +} + +ModResult hook_install(ModContext* context, void* fnAddr, void* trampolineFn, void** outOriginal) { + if (fnAddr == nullptr || trampolineFn == nullptr || outOriginal == nullptr) { + return MOD_INVALID_ARGUMENT; + } + // Try to detect an invalid function pointer (possibly a vtable slot offset or Itanium mfp + // value) and provide a helpful warning instead of faulting + const auto raw = reinterpret_cast(fnAddr); + if (raw < 0x10000 +#if defined(__aarch64__) || defined(_M_ARM64) + || (raw & 3) != 0 // code is 4-aligned +#endif + ) + { + DuskLog.warn("HookSystem: {:p} from {} is not a code address (virtual member function " + "pointer? hook via dusk::mods::Hook or resolve())", + fnAddr, mod_id_from_context(context)); + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto key = reinterpret_cast(fnAddr); + if (const auto it = s_installed.find(key); it != s_installed.end()) { + auto& entry = it->second; + // hook_add_pre + hook_add_post on the same target share one g_orig per mod. + const bool known = std::ranges::any_of(entry.candidates, [&](const HookCandidate& cand) { + return cand.context == context && cand.origStore == outOriginal; + }); + if (!known) { + entry.candidates.push_back({context, trampolineFn, outOriginal, s_nextOrder++}); + } + *outOriginal = entry.original; + return MOD_OK; + } + + // Inlining can't be intercepted by an entry patch: warn once per target when this + // build inlined the function into callers. + if (const char* name = nullptr; manifest::has_inline_sites(fnAddr, &name)) { + DuskLog.warn("HookSystem: '{}' ({:p}) for {} was inlined into callers in this build; " + "the hook only covers the calls that were not inlined", + name != nullptr ? name : "?", fnAddr, mod_id_from_context(context)); + } + + void* original = nullptr; + funchook_t* fh = install_trampoline(fnAddr, trampolineFn, &original); + if (fh == nullptr) { + return MOD_ERROR; + } + + auto& entry = s_installed[key]; + entry.handle = fh; + entry.original = original; + entry.active = context; + entry.candidates.push_back({context, trampolineFn, outOriginal, s_nextOrder++}); + *outOriginal = original; + return MOD_OK; +} + +ModResult hook_add_pre( + ModContext* context, void* fnAddr, HookPreFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + fnAddr = resolve_import_thunk(fnAddr); + auto& hooks = s_registry[reinterpret_cast(fnAddr)].pre; + hooks.push_back({context, callback, normalize_options(options), s_nextOrder++}); + sort_hooks(hooks); + return MOD_OK; +} + +ModResult hook_add_post( + ModContext* context, void* fnAddr, HookPostFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + fnAddr = resolve_import_thunk(fnAddr); + auto& hooks = s_registry[reinterpret_cast(fnAddr)].post; + hooks.push_back({context, nullptr, callback, normalize_options(options), s_nextOrder++}); + sort_hooks(hooks); + return MOD_OK; +} + +ModResult hook_replace( + ModContext* context, void* fnAddr, HookReplaceFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + const HookOptions normalized = normalize_options(options); + fnAddr = resolve_import_thunk(fnAddr); + auto& slot = s_registry[reinterpret_cast(fnAddr)]; + if (slot.replace.replaceCallback == nullptr) { + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + } + + switch (normalized.replace_policy) { + case HOOK_REPLACE_CONFLICT: + DuskLog.error("HookSystem: '{}' conflicts with '{}', both replace the same function", + mod_id_from_context(context), mod_id_from_context(slot.replace.context)); + return MOD_CONFLICT; + case HOOK_REPLACE_PRIORITY: + if (normalized.priority <= slot.replace.options.priority) { + return MOD_CONFLICT; + } + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + case HOOK_REPLACE_OVERRIDE: + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + } + return MOD_INVALID_ARGUMENT; +} + +ModResult hook_dispatch_pre( + ModContext*, void* fnAddr, void* args, void* retval, int* outSkipOriginal) { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 0; + } + if (fnAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto it = s_registry.find(reinterpret_cast(fnAddr)); + if (it == s_registry.end()) { + return MOD_OK; + } + auto& slot = it->second; + for (auto& hook : slot.pre) { + if (hook.callback != nullptr && + hook.callback(hook.context, args, retval, hook.options.userdata) == HOOK_SKIP_ORIGINAL) + { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 1; + } + return MOD_OK; + } + } + if (slot.replace.replaceCallback != nullptr) { + slot.replace.replaceCallback( + slot.replace.context, args, retval, slot.replace.options.userdata); + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 1; + } + } + return MOD_OK; +} + +ModResult hook_dispatch_post(ModContext*, void* fnAddr, void* args, void* retval) { + if (fnAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto it = s_registry.find(reinterpret_cast(fnAddr)); + if (it == s_registry.end()) { + return MOD_OK; + } + for (auto& hook : it->second.post) { + if (hook.postCallback != nullptr) { + hook.postCallback(hook.context, args, retval, hook.options.userdata); + } + } + return MOD_OK; +} + +void hook_remove_mod(LoadedMod& mod) { + ModContext* context = mod.context.get(); + + for (auto it = s_registry.begin(); it != s_registry.end();) { + auto& slot = it->second; + std::erase_if(slot.pre, [&](const PreHookFn& hook) { return hook.context == context; }); + std::erase_if(slot.post, [&](const VoidHookFn& hook) { return hook.context == context; }); + if (slot.replace.context == context) { + slot.replace = {}; + } + if (slot.pre.empty() && slot.post.empty() && slot.replace.replaceCallback == nullptr) { + it = s_registry.erase(it); + } else { + ++it; + } + } + + for (auto it = s_installed.begin(); it != s_installed.end();) { + auto& entry = it->second; + // The departing mod's g_orig slots are about to be unmapped; drop its candidates before + // any orig_store rewrites below. + std::erase_if(entry.candidates, + [context](const HookCandidate& cand) { return cand.context == context; }); + if (entry.active != context) { + ++it; + continue; + } + + auto* target = reinterpret_cast(it->first); + const int uninst = funchook_uninstall(entry.handle, 0); + const int destr = funchook_destroy(entry.handle); + if (uninst != 0 || destr != 0) { + DuskLog.warn("HookSystem: funchook uninstall/destroy for {:p} returned {}/{}", target, + uninst, destr); + } + entry.handle = nullptr; + entry.active = nullptr; + + if (entry.candidates.empty()) { + it = s_installed.erase(it); + continue; + } + + // Hand the detour off to a surviving candidate (lowest registration order first; the + // vector is append-ordered). A candidate whose install fails stays in the list: its + // g_orig must still track the current original pointer. + for (auto& cand : entry.candidates) { + void* original = nullptr; + funchook_t* fh = install_trampoline(target, cand.trampoline, &original); + if (fh == nullptr) { + continue; + } + entry.handle = fh; + entry.original = original; + entry.active = cand.context; + DuskLog.info("HookSystem: reinstalled trampoline for {:p}: {} -> {} (tramp={:p})", + target, mod_id_from_context(context), mod_id_from_context(cand.context), + cand.trampoline); + break; + } + + if (entry.active == nullptr) { + DuskLog.warn("HookSystem: no reinstallable trampoline for {:p}; hooks there are " + "disabled until a mod reinstalls one", + target); + for (auto& cand : entry.candidates) { + *cand.origStore = target; + } + it = s_installed.erase(it); + continue; + } + + for (auto& cand : entry.candidates) { + *cand.origStore = entry.original; + } + ++it; + } +} + +#else // DUSK_CODE_MODS + +ModResult hook_install(ModContext*, void*, void*, void**) { + return MOD_UNSUPPORTED; +} +ModResult hook_add_pre(ModContext*, void*, HookPreFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_add_post(ModContext*, void*, HookPostFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_replace(ModContext*, void*, HookReplaceFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_dispatch_pre(ModContext*, void*, void*, void*, int* outSkipOriginal) { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 0; + } + return MOD_UNSUPPORTED; +} +ModResult hook_dispatch_post(ModContext*, void*, void*, void*) { + return MOD_UNSUPPORTED; +} +void hook_remove_mod(LoadedMod&) {} + +#endif // DUSK_CODE_MODS + +// By-name resolution reads the symbol manifest, which is independent of the hook engine. +ModResult hook_resolve(ModContext*, const char* symbol, void** outAddr, HookSymbolFlags* outFlags) { + if (symbol == nullptr || outAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + switch (manifest::resolve(symbol, outAddr, outFlags)) { + case manifest::ResolveStatus::Ok: + return MOD_OK; + case manifest::ResolveStatus::Unavailable: + return MOD_UNSUPPORTED; + case manifest::ResolveStatus::NotFound: + return MOD_UNAVAILABLE; + case manifest::ResolveStatus::Ambiguous: + return MOD_CONFLICT; + } + return MOD_ERROR; +} + +constexpr HookService s_hookService{ + .header = SERVICE_HEADER(HookService, HOOK_SERVICE_MAJOR, HOOK_SERVICE_MINOR), + .install = hook_install, + .add_pre = hook_add_pre, + .add_post = hook_add_post, + .replace = hook_replace, + .dispatch_pre = hook_dispatch_pre, + .dispatch_post = hook_dispatch_post, + .resolve = hook_resolve, +}; + +} // namespace + +constinit const ServiceModule g_hookModule{ + .id = HOOK_SERVICE_ID, + .majorVersion = HOOK_SERVICE_MAJOR, + .minorVersion = HOOK_SERVICE_MINOR, + .service = &s_hookService, + .modDetached = hook_remove_mod, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index 6255b13c42..05971d5be1 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -1,10 +1,12 @@ #include "registry.hpp" #include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/loader/manifest.hpp" #include "fmt/format.h" #include #include +#include namespace dusk::mods::svc { namespace { @@ -117,8 +119,11 @@ void host_mod_detached(LoadedMod& mod) { } } -constexpr HostService s_hostService{ +constinit HostService s_hostService{ .header = SERVICE_HEADER(HostService, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR), + .version = DUSK_VERSION_STRING, + .build_id = nullptr, + .build_id_len = 0, .get_service = host_get_service, .publish_service = host_publish_service, .fail = host_fail, @@ -137,6 +142,12 @@ constinit const ServiceModule g_hostModule{ .majorVersion = HOST_SERVICE_MAJOR, .minorVersion = HOST_SERVICE_MINOR, .service = &s_hostService, + .initialize = + [] { + const auto& buildId = manifest::image_build_id(); + s_hostService.build_id = buildId.empty() ? nullptr : buildId.data(); + s_hostService.build_id_len = static_cast(buildId.size()); + }, .modDetached = host_mod_detached, }; diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 31f67bc7ab..0a2da3add3 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -200,9 +200,11 @@ void ModLoader::init_services() { &svc::g_hostModule, &svc::g_logModule, &svc::g_resourceModule, + &svc::g_hookModule, &svc::g_overlayModule, &svc::g_textureModule, &svc::g_configModule, + &svc::g_gameModule, }) { svc::register_module(*module); diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 6ffa1a7582..bdbe2c2e72 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -65,8 +65,10 @@ void modules_shutdown(); extern const ServiceModule g_hostModule; extern const ServiceModule g_logModule; extern const ServiceModule g_resourceModule; +extern const ServiceModule g_hookModule; extern const ServiceModule g_overlayModule; extern const ServiceModule g_textureModule; extern const ServiceModule g_configModule; +extern const ServiceModule g_gameModule; } // namespace dusk::mods::svc From e9e16a8ac1e72394933efda7d4cd073def42e304 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 8 Jul 2026 00:30:16 -0600 Subject: [PATCH 06/14] Remove redundant OSInitAlloc --- src/dusk/stubs.cpp | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/dusk/stubs.cpp b/src/dusk/stubs.cpp index c6832d2cac..b283eb43bf 100644 --- a/src/dusk/stubs.cpp +++ b/src/dusk/stubs.cpp @@ -179,14 +179,6 @@ int OSJamMessage(OSMessageQueue* mq, void* msg, s32 flags) { return 1; } -// ========================================================================== -// Arena Functions -// ========================================================================== - -void* OSInitAlloc(void* arenaStart, void* arenaEnd, int maxHeaps) { - return arenaStart; -} - // ========================================================================== // Remaining OS Stubs // ========================================================================== From 16c573a1705d0ebee5229094549f018be7427880 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 8 Jul 2026 00:44:17 -0600 Subject: [PATCH 07/14] Doc updates --- docs/modding.md | 109 +++++++++++++++++++++++++++--------------------- 1 file changed, 62 insertions(+), 47 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 2330181179..0df1fe6dfc 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1,11 +1,15 @@ # Dusklight Mod API -Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code -libraries and resources. +Mods are `.dusk` bundles: zip archives that can contain code (in the form of native libraries), resources, DVD overlay +files, and texture replacements. Mods may be enabled, disabled and reloaded at runtime. -Most things a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and -mods can define their own to talk to each other. Mods also link against the game itself: include game headers and call -game functions directly. +When code mods are loaded, they get dynamically linked by the operating system to the running game process. The mod +exports lifecycle functions that Dusklight calls into (`mod_initialize`, `mod_update`, `mod_shutdown`), and the mod +communicates with the host via **services**: plain C APIs, individually versioned. Dusklight exports several built-in +services, and mods may export services of their own, permitting framework mods and cross-mod integration. + +Beyond services, mods have full access to the original game's code: include game headers, call directly into any public +function, read and write data fields, and hook the vast majority of game functions. ## Table of Contents @@ -55,14 +59,16 @@ add_mod(my_mod ``` Building produces `my_mod.dusk` in `build//mods/` (configurable via the `DUSK_MODS_OUTPUT_DIR` cache variable). -Copy it into the game's mods folder and launch: +Dusklight searches a `mods/` directory next to the app in addition to the user directory, so a dev build launched from +`build//` picks these up automatically: rebuild, relaunch (or click Reload), done. + +For a regular game install, copy the `.dusk` into the user mods folder: - Windows: `%APPDATA%\TwilitRealm\Dusklight\mods` - Linux: `~/.local/share/TwilitRealm/Dusklight/mods` - macOS: `~/Library/Application Support/TwilitRealm/Dusklight/mods` -You can also pass `--mods ` on the command line, which is handy during development. Mods will load from there -instead of the user directory above. +Passing `--mods ` on the command line replaces the user directory with one of your choosing. --- @@ -83,9 +89,8 @@ instead of the user directory above. `id` is required: a unique, stable identifier (reverse-DNS style; periods, underscores, and alphanumerics). Everything else is optional but recommended. -`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. 512x512), -the banner (roughly 3.5:1). -Both keys are optional; if omitted, `res/icon.png` and `res/banner.png` are used automatically when present. +`icon` and `banner` are bundle-relative paths to PNG images for the in-game mod manager: the square icon (e.g. +512x512), the banner (~3.5:1). If omitted, `res/icon.png` and `res/banner.png` are used automatically when present. --- @@ -131,7 +136,14 @@ IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor versi IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null ``` -The rules (see `include/mods/api.h` for the full contract): +Each service is individually versioned, and there may be multiple major versions of a service provided at once, +allowing backwards compatibility with older mods while still changing services fundamentally if necessary. A **major** +bump is a breaking change, treated as a different service entirely. For **additive** changes, a service appends new +functions to the end of the struct without breaking existing callers and simply bumps the minor version. Mods that +want the newer functions may use `IMPORT_SERVICE_VERSION` to require that minor at **load time**, or `SERVICE_HAS` to +check at **runtime** whether a specific function is available. + +The contract (see `include/mods/api.h` for the full version): - **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear error. No need to null check at call sites. @@ -140,9 +152,6 @@ The rules (see `include/mods/api.h` for the full contract): - Fields newer than your imported minor must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a null check. -Service versions follow one rule: a **major** bump is a breaking change (treated as a different service entirely), a * -*minor** bump only appends functions. - --- ## Built-in Services @@ -158,8 +167,8 @@ svc_log->error(mod_ctx, "very bad"); svc_log->write(mod_ctx, LOG_LEVEL_DEBUG, "verbose details"); ``` -Messages appear in the console prefixed with your mod ID. Messages are plain strings: use `snprintf` or `fmt::format` -for formatting. +Messages appear in the console prefixed with your mod ID. Messages are plain UTF-8 strings and are copied before the +call returns; use `snprintf` or `fmt::format` for formatting. ### ResourceService (`mods/svc/resource.h`) @@ -176,8 +185,8 @@ if (svc_resource->load(mod_ctx, "config.txt", &buf) == MOD_OK) { } ``` -Missing files return `MOD_UNAVAILABLE`. Always `free` what you `load`. For writable storage, use the directory from -`svc_host->mod_dir(mod_ctx)`. +Missing files return `MOD_UNAVAILABLE`. Always `free` what you `load`. Note that the bundle is read-only; for writable +storage, use the directory from `svc_host->mod_dir(mod_ctx)`. ### HostService (`mods/svc/host.h`) @@ -215,14 +224,14 @@ every service dropped its state. For your own mod's teardown, use `mod_shutdown` ### HookService (`mods/svc/hook.h`) -Install hooks on game functions. You'll rarely call it directly; use the typed helpers in `mods/hook.hpp` described -below. +Installs hooks on game functions and resolves symbols by name. You'll rarely call it directly; use the typed helpers in +`mods/hook.hpp` described in [Hooking Game Functions](#hooking-game-functions). ### OverlayService (`mods/svc/overlay.h`) -Registers DVD file overlays at runtime. The dynamic counterpart to the static `overlay/` directory ( -see [Asset Overlays](#asset-overlays)). Overlay a disc path with a file from your bundle, or with a caller-owned -buffer (copied on registration): +Registers DVD file overlays at runtime: the dynamic counterpart to the static `overlay/` directory (see +[Asset Overlays](#asset-overlays)). Overlay a disc path with a file from your bundle, or with a caller-owned buffer +(copied on registration): ```cpp IMPORT_SERVICE(OverlayService, svc_overlay); @@ -233,16 +242,16 @@ svc_overlay->add_buffer(mod_ctx, "/generated.txt", data, size, nullptr); svc_overlay->remove(mod_ctx, handle); ``` -`disc_path` must be absolute (leading `/`) and is matched against the disc case-insensitively. Paths that don't exist on -the disc are added as new files. Changes are applied at the next frame boundary, and data the game already read stays in -memory until the file is re-read (sometimes a scene reload, sometimes never; a restart may be required). +`disc_path` must be absolute (leading `/`) and is matched against the disc case-insensitively. Paths that don't exist +on the disc are added as new files. Changes are applied at the next frame boundary, and data the game already read +stays in memory until the file is re-read: sometimes a scene reload, and in the worst case, a full restart. See [Asset Overlays](#asset-overlays) for priority and conflict handling. ### TextureService (`mods/svc/texture.h`) -Registers texture replacements at runtime. The dynamic counterpart to the static `textures/` directory ( -see [Asset Overlays](#asset-overlays)). Two forms: raw texel data with an explicit key, or an encoded `.dds`/`.png` from +Registers texture replacements at runtime: the dynamic counterpart to the static `textures/` directory (see +[Asset Overlays](#asset-overlays)). Two forms: raw texel data with an explicit key, or an encoded `.dds`/`.png` from your bundle whose filename encodes the key: ```cpp @@ -264,7 +273,7 @@ svc_texture->register_data(mod_ctx, &key, &data, nullptr); svc_texture->unregister(mod_ctx, handle); ``` -Filenames use the same convention as the user's `texture_replacements` directory: +Filenames use the same Dolphin-style convention as the user's `texture_replacements` directory: `tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, where hashes may be `$` (wildcard). `_mipN` sidecar files next to a registered file are picked up automatically. Files are decoded lazily on first use by the renderer; raw data is copied at registration. Registrations follow your mod's lifecycle. @@ -311,6 +320,7 @@ Writes that store the same value are silent. Values applied from `config.json` o ## Hooking Game Functions +Mods may hook the vast majority of game functions, including file-local static, private and virtual functions. `mods/hook.hpp` provides typed helpers over the hook service: ```cpp @@ -338,8 +348,8 @@ dusk::mods::hook_add_pre<&daAlink_c::posMove>(svc_hook, on_pos_move_pre); ### Post-hooks -Run after the original (or after a replace-hook, or after a cancelled original). `retval` points to the return value, if -any. +Run after the original (or after a replace-hook, or after a cancelled original). `retval` points to the return value, +if any. ```cpp void on_pos_move_post(ModContext*, void* args, void* retval, void* userdata) { ... } @@ -442,8 +452,9 @@ Two spellings work on every platform: ### Game code ABI contract -If your mod calls or hooks game code directly (anything beyond the service APIs), import `GameService` ( -`mods/svc/game.h`): +A primary consideration when letting mods link against the game is maintaining ABI stability across Dusklight +versions. If your mod calls or hooks game code directly (anything beyond the service APIs), import `GameService` +(`mods/svc/game.h`): ```cpp IMPORT_SERVICE(GameService, svc_game); @@ -451,25 +462,28 @@ IMPORT_SERVICE(GameService, svc_game); Its major version is the game code ABI epoch: it's bumped when game struct or vtable layouts change incompatibly, and the ordinary service version check then rejects your mod with a clear error instead of letting it corrupt memory in a -version it wasn't built for. Service-only and asset-only mods should *not* import it; they stay compatible across game -ABI changes. +version it wasn't built for. Service-only and asset-only mods should *not* import it and will continue to work across +game ABI changes. + +The more you can do through services, the better: a mod that avoids touching game code directly sidesteps future ABI +breaks entirely and plays nicer with other enabled mods. --- ## Asset Overlays -Files placed under `overlay/` in the `.dusk` archive override game files at the corresponding path. For example, -`overlay/res/Stage/...` shadows that file on the game disc image. This requires no code: an archive with just -`mod.json` and `overlay/` is a complete mod. +Files placed under `overlay/` in the `.dusk` archive override game files at the corresponding path, equivalent to +replacing files in the .iso. This requires no code: an archive with just `mod.json` and `overlay/` is a complete mod. -Files placed under `textures/` register as texture replacements the same way. Filenames follow the replacement naming -convention (`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, `$` as a hash wildcard). Subdirectories are scanned -recursively; only the filename needs to match. +Files placed under `textures/` register as texture replacements, and act just like the user's general +`texture_replacements/` directory: Dolphin-style naming, matched by texture hash +(`tex1_{w}x{h}_{texhash}[_{tluthash}]_{fmt}.dds|.png`, `$` as a hash wildcard). Subdirectories are scanned recursively; +only the filename needs to match. -Both follow the mod's lifecycle: disabling the mod removes its overrides (files revert to the disc contents on their -next open; added files stop existing), and reloading serves the new bundle's content. Game data the engine already read -stays as-is until it is loaded again, which may require a scene reload or a full restart. Texture replacements usually -take effect immediately. +Both mechanisms are tied to the mod's lifecycle: disabling the mod removes its overrides (files revert to the disc +contents on their next open; added files stop existing), and reloading serves the new bundle's content. However, game +data the engine already read stays as-is until it is loaded again, which may require a scene change or, in the worst +case, a full restart. Texture replacements usually take effect immediately. If multiple sources replace the same file or texture, the last one wins: runtime registrations override static `textures/` or `overlay/` files, and later-loaded mods override earlier ones. Cross-mod conflicts log warnings. @@ -527,7 +541,8 @@ explicit results. ## Advanced: Exporting Services -Mods can provide services to other mods. Define the interface in a header both mods share: +Mods may export services of their own, permitting framework mods and cross-mod integration. Define the interface in a +header both mods share: ```cpp // my_mod_api.h From 6136d9bbf7dd18edee78ecfb9251ef0cc9d7615a Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 8 Jul 2026 17:49:48 -0600 Subject: [PATCH 08/14] Set .clang-format to C++20 --- .clang-format | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.clang-format b/.clang-format index 8ffd4ebe96..1cf581604c 100644 --- a/.clang-format +++ b/.clang-format @@ -1,6 +1,6 @@ --- Language: Cpp -Standard: C++03 +Standard: c++20 AccessModifierOffset: -4 AlignAfterOpenBracket: DontAlign AlignConsecutiveAssignments: false From 36635f8c6286190807469e2be7b87b53f0635c85 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 8 Jul 2026 17:50:03 -0600 Subject: [PATCH 09/14] Fix multi-configuration builds with CMake --- cmake/WindowsExports.cmake | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/cmake/WindowsExports.cmake b/cmake/WindowsExports.cmake index f380094b78..86dc9414f2 100644 --- a/cmake/WindowsExports.cmake +++ b/cmake/WindowsExports.cmake @@ -16,12 +16,17 @@ function(setup_windows_exports target) set(_symgen "${SYMGEN_EXE}") add_dependencies(${target} symgen) + set(_config_subdir "") + if (CMAKE_CONFIGURATION_TYPES) + set(_config_subdir "$/") + endif () + set(_rsp_lines "$") foreach (_lib IN LISTS JSYSTEM_LIBRARIES) list(APPEND _rsp_lines "$") endforeach () list(JOIN _rsp_lines "\n" _rsp_content) - set(_rsp "${CMAKE_BINARY_DIR}/dusklight_exports_input.rsp") + set(_rsp "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_exports_input.rsp") file(GENERATE OUTPUT "${_rsp}" CONTENT "${_rsp_content}") set(_sdk_args) @@ -33,7 +38,7 @@ function(setup_windows_exports target) endforeach () # Generate curated exports list from the main binary - set(_def "${CMAKE_BINARY_DIR}/dusklight_exports.def") + set(_def "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_exports.def") add_custom_command(TARGET ${target} PRE_LINK # TODO: src/dusk/ is NOT excluded: inline code in game headers # currently call into it (e.g. dusk::frame_interp::lookup_replacement). @@ -50,7 +55,7 @@ function(setup_windows_exports target) target_link_options(${target} PRIVATE "/DEF:${_def}") # Generate import library for mods to link against. - set(_implib "${CMAKE_BINARY_DIR}/dusklight_imports.lib") + set(_implib "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_imports.lib") get_filename_component(_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) find_program(DUSK_LLVM_DLLTOOL llvm-dlltool HINTS "${_compiler_dir}") if (DUSK_LLVM_DLLTOOL) @@ -65,9 +70,7 @@ function(setup_windows_exports target) BYPRODUCTS "${_implib}" COMMENT "Generating dusklight import library" VERBATIM) - if ("$CACHE{DUSK_GAME_IMPLIB}" STREQUAL "") - set(DUSK_GAME_IMPLIB "${_implib}" CACHE INTERNAL "Import library for Windows mod linking") - endif () + set(DUSK_GAME_IMPLIB "${_implib}" CACHE INTERNAL "Import library for Windows mod linking") set(DUSK_GAME_DEF "${_def}" CACHE INTERNAL "Curated export .def for the game executable") # Ship the import library as sdk/dusklight.lib in the install tree: mods may use it to From bc11bf356359fa5883ecd5ed84a98fe76fcecb63 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 8 Jul 2026 17:51:13 -0600 Subject: [PATCH 10/14] Mods manager UI & logs viewer --- docs/modding.md | 6 +- files.cmake | 12 +- include/dusk/mod_loader.hpp | 8 +- res/rml/logs.rcss | 97 +++++++ res/rml/mods.rcss | 218 ++++++++++++++++ res/rml/overlay.rcss | 8 + res/rml/prelaunch.rcss | 4 + res/rml/tabbing.rcss | 15 +- src/dusk/mods/loader/context.cpp | 21 +- src/dusk/mods/loader/depgraph.cpp | 13 +- src/dusk/mods/loader/loader.cpp | 163 +++++++----- src/dusk/mods/log_buffer.cpp | 103 ++++++++ src/dusk/mods/log_buffer.hpp | 55 ++++ src/dusk/mods/{loader => }/manifest.cpp | 0 src/dusk/mods/{loader => }/manifest.hpp | 0 src/dusk/mods/svc/config.cpp | 4 +- src/dusk/mods/svc/hook.cpp | 2 +- src/dusk/mods/svc/host.cpp | 8 +- src/dusk/mods/svc/log.cpp | 18 +- src/dusk/mods/svc/registry.cpp | 38 ++- src/dusk/mods/svc/registry.hpp | 1 - src/dusk/ui/logs_window.cpp | 301 ++++++++++++++++++++++ src/dusk/ui/logs_window.hpp | 44 ++++ src/dusk/ui/menu_bar.cpp | 18 +- src/dusk/ui/menu_bar.hpp | 2 + src/dusk/ui/mod_texture_provider.cpp | 210 +++++++++++++++ src/dusk/ui/mod_texture_provider.hpp | 19 ++ src/dusk/ui/mods_window.cpp | 325 ++++++++++++++++++++++++ src/dusk/ui/mods_window.hpp | 38 +++ src/dusk/ui/overlay.cpp | 3 + src/dusk/ui/prelaunch.cpp | 14 +- src/dusk/ui/settings.cpp | 17 +- src/dusk/ui/ui.cpp | 5 +- src/dusk/ui/window.cpp | 82 +++++- src/dusk/ui/window.hpp | 13 +- 35 files changed, 1709 insertions(+), 176 deletions(-) create mode 100644 res/rml/logs.rcss create mode 100644 res/rml/mods.rcss create mode 100644 src/dusk/mods/log_buffer.cpp create mode 100644 src/dusk/mods/log_buffer.hpp rename src/dusk/mods/{loader => }/manifest.cpp (100%) rename src/dusk/mods/{loader => }/manifest.hpp (100%) create mode 100644 src/dusk/ui/logs_window.cpp create mode 100644 src/dusk/ui/logs_window.hpp create mode 100644 src/dusk/ui/mod_texture_provider.cpp create mode 100644 src/dusk/ui/mod_texture_provider.hpp create mode 100644 src/dusk/ui/mods_window.cpp create mode 100644 src/dusk/ui/mods_window.hpp diff --git a/docs/modding.md b/docs/modding.md index 0df1fe6dfc..9208f550e6 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -131,9 +131,9 @@ A service is a struct of C function pointers with a version header. You declare loader resolves it before your mod initializes: ```cpp -IMPORT_SERVICE(LogService, svc_log); // required, any minor version -IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor version >= 2 -IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null +IMPORT_SERVICE(LogService, svc_log); // required, any minor version +IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor version >= 2 +IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null ``` Each service is individually versioned, and there may be multiple major versions of a service provided at once, diff --git a/files.cmake b/files.cmake index 524c53943f..1cbef1694b 100644 --- a/files.cmake +++ b/files.cmake @@ -1496,6 +1496,10 @@ set(DUSK_FILES src/dusk/ui/input.hpp src/dusk/ui/icon_provider.cpp src/dusk/ui/icon_provider.hpp + src/dusk/ui/logs_window.cpp + src/dusk/ui/logs_window.hpp + src/dusk/ui/mod_texture_provider.cpp + src/dusk/ui/mod_texture_provider.hpp src/dusk/ui/modal.cpp src/dusk/ui/modal.hpp src/dusk/ui/nav_types.hpp @@ -1507,6 +1511,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 @@ -1541,6 +1547,10 @@ set(DUSK_FILES src/dusk/OSReport.cpp src/dusk/OSThread.cpp src/dusk/OSMutex.cpp + src/dusk/mods/log_buffer.cpp + src/dusk/mods/log_buffer.hpp + src/dusk/mods/manifest.cpp + src/dusk/mods/manifest.hpp src/dusk/mods/loader/bundle_disk.cpp src/dusk/mods/loader/bundle_zip.cpp src/dusk/mods/loader/context.cpp @@ -1550,8 +1560,6 @@ set(DUSK_FILES src/dusk/mods/loader/loader.hpp src/dusk/mods/loader/native_module.cpp src/dusk/mods/loader/native_module.hpp - src/dusk/mods/loader/manifest.cpp - src/dusk/mods/loader/manifest.hpp src/dusk/mods/svc/config.cpp src/dusk/mods/svc/config.hpp src/dusk/mods/svc/game.cpp diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 7f9d1ad340..9d77f6984a 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -176,7 +176,7 @@ public: void request_enable(std::string_view id); void request_disable(std::string_view id); void request_reload(std::string_view id); - void notify_mod_failure(LoadedMod& mod); + void notify_mod_failure(LoadedMod& mod, bool firstFailure); [[nodiscard]] auto mods() const { return m_mods | std::views::transform([](const auto& m) -> LoadedMod& { return *m; }); @@ -204,6 +204,7 @@ private: std::vector m_searchDirs; std::filesystem::path m_cacheDir; std::vector m_pendingRequests; + std::vector m_pendingFailures; std::vector m_retiredNatives; bool m_initialized = false; bool m_startupComplete = false; @@ -224,12 +225,11 @@ private: bool resolve_service_imports(LoadedMod& mod); [[nodiscard]] std::string describe_missing_import( const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion) const; - void clear_services(); - void fail_mod(LoadedMod& mod, ModResult code, std::string_view message); - LoadedMod* find_mod(std::string_view id); + LoadedMod* find_mod(std::string_view id) const; void drain_retired_natives(); void apply_pending_requests(); + void flush_toasts(); void on_enabled_changed(LoadedMod& mod); // Deactivates `target` (if needed) and its transitive dependents, optionally re-reads the // bundle from disk, then reactivates whatever the current cvar/provider state allows. diff --git a/res/rml/logs.rcss b/res/rml/logs.rcss new file mode 100644 index 0000000000..126684f9d2 --- /dev/null +++ b/res/rml/logs.rcss @@ -0,0 +1,97 @@ +window.logs content { + flex-flow: column; +} + +window.logs .log-toolbar { + display: flex; + flex-flow: row; + flex: 0 0 64dp; + height: 64dp; + align-items: center; + gap: 8dp; + padding-right: 72dp; + background-color: rgba(217, 217, 217, 10%); + border-bottom: 2dp #92875B; + font-family: "Fira Sans Condensed"; + font-weight: bold; + font-size: 18dp; +} + +window.logs > close { + top: 8dp; + right: 8dp; +} + +window.logs .log-title { + align-self: stretch; + flex: 0 0 auto; + padding: 0 24dp; + line-height: 64dp; + text-transform: uppercase; + border-bottom: 4dp #C2A42D; + font-effect: glow(0dp 4dp 0dp 4dp black); +} + +window.logs .log-title-mod { + flex: 0 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + font-family: "Fira Sans"; + font-weight: normal; + font-size: 15dp; + color: rgba(224, 219, 200, 55%); +} + +.log-toolbar-spacer { + flex: 1 1 0; +} + +.log-toolbar button { + flex: 0 0 auto; + font-family: "Fira Sans"; + font-weight: normal; + font-size: 15dp; + padding: 5dp 12dp; +} + +window.logs content pane.log-view { + flex: 1 1 0; + padding: 12dp 16dp; + padding-bottom: 0dp; + gap: 0dp; +} + +.log-lines { + display: block; +} + +.log-line { + display: block; + font-family: "Noto Mono"; + font-size: 13dp; + line-height: 1.5; + word-break: break-word; + white-space: pre-wrap; +} + +.log-line .log-time { + color: rgba(224, 219, 200, 45%); +} + +.log-line .log-mod { + color: rgba(194, 164, 45, 80%); +} + +.log-line.lvl-trace, +.log-line.lvl-debug { + opacity: 0.55; +} + +.log-line.lvl-warn .log-msg { + color: #ffa826; +} + +.log-line.lvl-error .log-msg { + color: #cc4444; +} diff --git a/res/rml/mods.rcss b/res/rml/mods.rcss new file mode 100644 index 0000000000..99aa33d236 --- /dev/null +++ b/res/rml/mods.rcss @@ -0,0 +1,218 @@ +window.mods content pane.mod-list { + flex: 0 0 360dp; + padding: 16dp; + padding-bottom: 0dp; + gap: 4dp; +} + +@media (max-height: 640dp) { + window.mods content pane.mod-list { + flex: 1 1 0; + } +} + +window.mods content pane.mod-detail { + gap: 12dp; +} + +.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 auto; +} + +.mod-info-value { + flex: 1 1 0; +} + +.mod-path { + font-size: 14dp; + word-break: break-all; + opacity: 0.7; +} + +mod-entry { + display: flex; + flex-flow: row; + gap: 12dp; + padding: 10dp; + border-radius: 10dp; + decorator: vertical-gradient(#c2a42d00 #c2a42d00); + transition: decorator 0.1s linear-in-out; + cursor: pointer; + focus: auto; +} + +mod-entry.current { + box-shadow: rgba(146, 135, 91, 40%) 0 0 0 1dp; +} + +mod-entry:hover, +mod-entry:focus-visible { + decorator: vertical-gradient(#c2a42d00 #c2a42d26); +} + +mod-entry:selected { + decorator: vertical-gradient(#c2a42d10 #c2a42d40); +} + +mod-entry .mod-icon { + flex: 0 0 auto; + width: 56dp; + height: 56dp; + border-radius: 8dp; +} + +mod-entry icon.mod-icon { + font-size: 36dp; + background-color: rgba(17, 16, 10, 20%); + color: rgba(224, 219, 200, 45%); + decorator: text("" center center); +} + +mod-entry .mod-entry-info { + display: flex; + flex-flow: column; + flex: 1 1 0; + min-width: 0; + gap: 2dp; +} + +mod-entry .mod-entry-name { + display: flex; + flex-flow: row; + align-items: baseline; + gap: 6dp; +} + +mod-entry .mod-entry-name-text { + flex: 0 1 auto; + min-width: 0; + font-weight: bold; + white-space: nowrap; + overflow: hidden; +} + +mod-entry .mod-entry-version { + flex: 0 0 auto; + font-size: 13dp; + color: rgba(224, 219, 200, 50%); +} + +mod-entry .mod-entry-status.active { + color: #44cc55; +} + +mod-entry .mod-entry-status.failed { + color: #cc4444; +} + +mod-entry .mod-entry-desc { + font-size: 14dp; + line-height: 1.3; + color: rgba(224, 219, 200, 65%); + max-height: 2.6em; + overflow: hidden; +} + +mod-entry .mod-entry-sub { + font-size: 13dp; + color: rgba(224, 219, 200, 50%); +} + +mod-entry.inactive .mod-icon { + filter: grayscale(1); +} + +mod-entry.inactive .mod-entry-info { + opacity: 0.5; +} + +mod-header { + position: relative; + flex: 0 0 auto; +} + +mod-header.has-banner { + height: 180dp; + margin: -24dp -24dp 0dp -24dp; +} + +mod-header .mod-actions { + position: absolute; + top: 24dp; + left: 24dp; + display: flex; + flex-flow: row; + gap: 8dp; +} + +mod-header .mod-actions button { + font-size: 16dp; + padding: 6dp 14dp; + background-color: rgba(21, 22, 16, 80%); + box-shadow: rgba(146, 135, 91, 60%) 0 0 0 1dp; +} + +mod-header.no-banner { + display: flex; + flex-flow: row; + align-items: center; +} + +mod-header.no-banner .mod-actions { + position: static; +} + +window.mods .mod-title { + display: block; + font-size: 28dp; + font-weight: bold; +} + +window.mods .mod-title .mod-title-version { + font-weight: normal; + font-size: 16dp; + color: rgba(224, 219, 200, 55%); +} + +window.mods .mod-author { + display: block; + font-size: 15dp; + color: rgba(224, 219, 200, 55%); +} + +window.mods .mod-restart-note { + font-size: 15dp; + color: #ffa826; + opacity: 0.85; +} + +window.mods .mod-description { + line-height: 1.5; +} + +.status-badge { + font-size: 14dp; + opacity: 0.7; +} + +.status-badge.active, +.mod-info-label.active { + color: #44cc55; + opacity: 1; +} + +.status-badge.failed, +.mod-info-label.failed { + color: #cc4444; + opacity: 1; +} \ No newline at end of file diff --git a/res/rml/overlay.rcss b/res/rml/overlay.rcss index 384c7a2a9b..98f8bfa27a 100644 --- a/res/rml/overlay.rcss +++ b/res/rml/overlay.rcss @@ -159,6 +159,14 @@ toast.achievement heading { color: #C2A42D; } +toast.warning { + border: 1dp #C2A42D; +} + +toast.warning heading { + color: #C2A42D; +} + toast.controller-warning { top: auto; right: auto; 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/tabbing.rcss b/res/rml/tabbing.rcss index 194ca89f05..8f42dd84d5 100644 --- a/res/rml/tabbing.rcss +++ b/res/rml/tabbing.rcss @@ -50,7 +50,8 @@ tab-bar[closable] tab-end-spacer { pointer-events: none; } -tab-bar[closable] close { +tab-bar[closable] close, +window > close { display: block; position: fixed; top: 8dp; @@ -70,12 +71,20 @@ tab-bar[closable] close { } tab-bar[closable] close:hover, -tab-bar[closable] close:focus-visible { +tab-bar[closable] close:focus-visible, +window > close:hover, +window > close:focus-visible { color: #fff; background-color: rgba(194, 164, 45, 24%); } -tab-bar[closable] close:active { +window > close { + top: 16dp; + right: 16dp; +} + +tab-bar[closable] close:active, +window > close:active { color: #fff; background-color: rgba(194, 164, 45, 40%); } diff --git a/src/dusk/mods/loader/context.cpp b/src/dusk/mods/loader/context.cpp index 99417117d6..9c8fe71db4 100644 --- a/src/dusk/mods/loader/context.cpp +++ b/src/dusk/mods/loader/context.cpp @@ -1,11 +1,7 @@ #include "loader.hpp" -#include "dusk/logging.h" +#include "dusk/mods/log_buffer.hpp" #include "dusk/mods/svc/registry.hpp" -#include "dusk/ui/ui.hpp" -#include "fmt/format.h" - -#include namespace dusk::mods { @@ -55,16 +51,9 @@ void fail_mod(LoadedMod& mod, ModResult code, std::string_view message) { // callback), and full teardown happens via deactivate_mod at a safe point. svc::remove_services_for_provider(mod); mod.servicesRegistered = false; - ModLoader::instance().notify_mod_failure(mod); - DuskLog.error("[{}] disabled: {} ({})", mod.metadata.id, message, static_cast(code)); - if (firstFailure) { - ui::push_toast({ - .type = "warning", - .title = "Mod Disabled", - .content = ui::escape(fmt::format("{}: {}", mod.metadata.name, message)), - .duration = std::chrono::seconds(5), - }); - } + ModLoader::instance().notify_mod_failure(mod, firstFailure); + log::write( + mod.metadata.id, LOG_LEVEL_ERROR, "failed: {} ({})", message, static_cast(code)); } -} // namespace dusk::mods::loader +} // namespace dusk::mods diff --git a/src/dusk/mods/loader/depgraph.cpp b/src/dusk/mods/loader/depgraph.cpp index 20c831075a..d1748b56c4 100644 --- a/src/dusk/mods/loader/depgraph.cpp +++ b/src/dusk/mods/loader/depgraph.cpp @@ -5,13 +5,11 @@ #include "dusk/logging.h" #include "loader.hpp" -#include "native_module.hpp" - -static aurora::Module Log("dusk::modLoader"); +#include "native_module.hpp" // IWYU pragma: keep namespace dusk::mods::loader { - namespace { +aurora::Module Log{"dusk::mods::loader"}; struct Edge { size_t provider; @@ -31,7 +29,7 @@ std::vector collect_edges(const std::vector>& m for (size_t i = 0; i < mods.size(); ++i) { const auto matches = [&](const ModManifestInfo::Export& serviceExport) { return serviceExport.major == serviceImport.major && - serviceExport.id == serviceImport.id; + serviceExport.id == serviceImport.id; }; if (std::ranges::any_of(mods[i]->manifestInfo.exports, matches)) { return i; @@ -165,7 +163,7 @@ void sort_mods(std::vector>& mods) { } for (const size_t index : cycleMods) { fail_mod(*mods[index], MOD_CONFLICT, - "required service import cycle between mods: " + names); + "Required service import cycle between mods: " + names); place(index); } continue; @@ -174,8 +172,7 @@ void sort_mods(std::vector>& mods) { // Only optional imports left in the cycle: drop one edge and retry. The // import still resolves, but without any initialization-order guarantee. const auto optionalEdge = std::ranges::find_if(edges, [&](const Edge& edge) { - return edge.alive && !edge.required && !placed[edge.provider] && - !placed[edge.importer]; + return edge.alive && !edge.required && !placed[edge.provider] && !placed[edge.importer]; }); if (optionalEdge == edges.end()) { // Unreachable: a stall with no required cycle implies an optional edge. diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index fb293a55b4..77b6e7f1a9 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -3,24 +3,26 @@ #include "dusk/mod_loader.hpp" #include +#include #include #include #include #include #include +#include "../manifest.hpp" #include "depgraph.hpp" #include "dusk/config.hpp" #include "dusk/io.hpp" +#include "dusk/mods/log_buffer.hpp" #include "dusk/mods/svc/config.hpp" #include "dusk/mods/svc/registry.hpp" -#include "manifest.hpp" +#include "dusk/ui/mods_window.hpp" +#include "dusk/ui/ui.hpp" #include "miniz.h" #include "native_module.hpp" #include "nlohmann/json.hpp" -static aurora::Module Log("dusk::modLoader"); - using namespace std::string_literals; using namespace std::string_view_literals; @@ -71,6 +73,7 @@ static constexpr std::string_view k_nativeLibName = ""sv; namespace dusk::mods { namespace { +aurora::Module Log{"dusk::mods::loader"}; ModLoader g_modLoader; std::unique_ptr load_bundle(const std::filesystem::path& modPath, bool fromDir) { @@ -163,9 +166,11 @@ static std::string resolve_image_path(ModBundle& bundle, const std::string& modI std::string_view key, const std::string& manifestPath, const std::string& defaultPath) { if (!manifestPath.empty()) { if (!is_safe_resource_path(manifestPath)) { - Log.warn("{}: invalid {} path '{}' in mod.json", modId, key, manifestPath); + log::write( + modId, LOG_LEVEL_WARN, "invalid {} path '{}' in mod.json", key, manifestPath); } else if (!bundle_has_file(bundle, manifestPath)) { - Log.warn("{}: {} path '{}' not found in bundle", modId, key, manifestPath); + log::write( + modId, LOG_LEVEL_WARN, "{} path '{}' not found in bundle", key, manifestPath); } else { return manifestPath; } @@ -217,18 +222,18 @@ static ModMetadata load_metadata(const std::filesystem::path& modPath, ModBundle static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) { if (manifest == nullptr) { - Log.error("{} returned a null mod manifest", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "returned a null mod manifest"); mod.nativeStatus = NativeModStatus::MissingExport; return false; } if (manifest->struct_size != sizeof(ModManifest)) { - Log.error("{} manifest has invalid size {} (expected {})", mod.metadata.id, + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "manifest has invalid size {} (expected {})", manifest->struct_size, sizeof(ModManifest)); mod.nativeStatus = NativeModStatus::ApiVersionMismatch; return false; } if (manifest->abi_version != MOD_ABI_VERSION) { - Log.error("{} expects ABI v{} but engine is v{}, skipping", mod.metadata.id, + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expects ABI v{} but engine is v{}, skipping", manifest->abi_version, MOD_ABI_VERSION); mod.nativeStatus = NativeModStatus::ApiVersionMismatch; return false; @@ -236,7 +241,7 @@ static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) { if ((manifest->import_count > 0 && manifest->imports == nullptr) || (manifest->export_count > 0 && manifest->exports == nullptr)) { - Log.error("{} manifest has invalid import/export arrays", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "manifest has invalid import/export arrays"); mod.nativeStatus = NativeModStatus::MissingExport; return false; } @@ -245,7 +250,7 @@ static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) { static bool validate_context_symbol(ModContext** contextSymbol, LoadedMod& mod) { if (contextSymbol == nullptr) { - Log.error("{} missing required mod_ctx export", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "missing required mod_ctx export"); mod.nativeStatus = NativeModStatus::MissingExport; return false; } @@ -289,8 +294,8 @@ std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) if (libDir.empty()) { return {}; } - fs::path path = libDir / fs::path(mod.metadata.id + io::fs_path_to_string( - fs::path(k_nativeLibName).extension())); + fs::path path = libDir / fs::path(mod.metadata.id + + io::fs_path_to_string(fs::path(k_nativeLibName).extension())); std::error_code ec; if (!fs::is_regular_file(path, ec)) { return {}; @@ -300,7 +305,7 @@ std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { if (!EnableCodeMods) { - Log.error("Code mods are not available in this build"); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "Code mods are not available in this build"); mod.nativeStatus = NativeModStatus::BuildDisabled; return; } @@ -318,15 +323,15 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { } else if (auto external = external_native_lib_path(mod); !external.empty()) { libPath = std::move(external); } else { - Log.error("no native library named {} found in {}; skipping", k_nativeLibName, - mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "no native library named {} found; skipping", k_nativeLibName); mod.nativeStatus = NativeModStatus::ModMissingPlatform; return; } } else { if (dllEntry.empty()) { - Log.error("no native library named {} found in {}; skipping", k_nativeLibName, - mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "no native library named {} found; skipping", k_nativeLibName); mod.nativeStatus = NativeModStatus::ModMissingPlatform; return; } @@ -342,14 +347,15 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { try { dllData = mod.bundle->readFile(dllEntry); } catch (const std::exception& e) { - Log.error("failed to extract {} from {}", dllEntry, mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}", dllEntry); return; } { std::ofstream out(dllCachePath, std::ios::binary | std::ios::out); if (!out) { - Log.error("failed to write {}", io::fs_path_to_string(dllCachePath)); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", + io::fs_path_to_string(dllCachePath)); return; } @@ -364,7 +370,8 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { try { nativeMod->handle = std::make_unique(libPath); } catch (const std::runtime_error& e) { - Log.error("failed to open {}: {}", io::fs_path_to_string(libPath), e.what()); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to open {}: {}", + io::fs_path_to_string(libPath), e.what()); return; } @@ -377,7 +384,8 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { if (!getManifest || !nativeMod->contextSymbol || !nativeMod->fn_initialize || !nativeMod->fn_update || !nativeMod->fn_shutdown) { - Log.error("{} missing required mod API exports; skipping", + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "{} missing required mod API exports; skipping", io::fs_path_to_string(libPath.filename())); mod.nativeStatus = NativeModStatus::MissingExport; return; @@ -486,9 +494,9 @@ static void warn_unpublished_deferred_exports(const LoadedMod& mod) { const auto* record = svc::find_service_record(serviceExport.service_id, serviceExport.major_version); if (record != nullptr && record->service == nullptr) { - Log.warn("'{}' declared deferred service '{}@{}' but never published it during " - "initialization", - mod.metadata.id, serviceExport.service_id, serviceExport.major_version); + log::write(mod.metadata.id, LOG_LEVEL_WARN, + "declared deferred service '{}@{}' but never published it during initialization", + serviceExport.service_id, serviceExport.major_version); } } } @@ -516,10 +524,10 @@ void ModLoader::try_load_mod( if (const auto* existing = find_mod(metadata.id)) { if (existing->searchDirIndex < searchDirIndex) { - Log.info("'{}' ({}) shadowed by higher-priority copy {}", metadata.id, + log::write(metadata.id, LOG_LEVEL_INFO, "{} shadowed by higher-priority copy {}", io::fs_path_to_string(modPath.filename()), existing->modPath); } else { - Log.error("mod with id '{}' already exists, not loading {}", metadata.id, + log::write(metadata.id, LOG_LEVEL_ERROR, "duplicate mod id, not loading {}", io::fs_path_to_string(modPath.filename())); } return; @@ -537,7 +545,7 @@ void ModLoader::try_load_mod( mod.context = std::make_unique(); mod.context->mod = &mod; mod.cvarIsEnabled = - std::make_unique >(mod_enabled_cvar_name(mod.metadata.id), true); + std::make_unique>(mod_enabled_cvar_name(mod.metadata.id), true); const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); if (anyLibs || (mod.inPlace && !external_native_lib_path(mod).empty())) { @@ -545,19 +553,18 @@ void ModLoader::try_load_mod( load_native(mod, dllEntry); if (mod.nativeStatus != NativeModStatus::Loaded) { Log.error("Native mod '{}' failed to load, disabling", mod.metadata.id); - mod.active = false; - mod.loadFailed = true; - mod.failureReason = native_status_message(mod.nativeStatus); + fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); } else { mod.manifestInfo = build_manifest_info(*mod.native->manifest); } } - Log.info("found '{}' ('{}') v{} by {} ({})", mod.metadata.name, mod.metadata.id, + log::write(mod.metadata.id, LOG_LEVEL_INFO, "found '{}' v{} by {} ({})", mod.metadata.name, mod.metadata.version, mod.metadata.author, io::fs_path_to_string(modPath.filename())); } bool ModLoader::activate_mod(LoadedMod& mod) { + log::write(mod.metadata.id, LOG_LEVEL_INFO, "activating mod"); mod.active = true; // Asset-only mods have no lifecycle beyond their overlay files. @@ -568,33 +575,33 @@ bool ModLoader::activate_mod(LoadedMod& mod) { if (!mod.servicesRegistered) { if (!register_static_service_exports(mod)) { - Log.error("'{}' failed to register service exports", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); return false; } mod.servicesRegistered = true; } if (!resolve_service_imports(mod)) { - Log.error("'{}' failed to resolve service imports", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to resolve service imports"); return false; } *mod.native->contextSymbol = mod.context.get(); - Log.debug("Initializing '{}'", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling mod_initialize"); try { ModError error = MOD_ERROR_INIT; const auto result = mod.native->fn_initialize(&error); if (result == MOD_OK && !mod.loadFailed) { mod.initialized = true; - Log.info("'{}' initialized", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "mod_initialize succeeded"); } else { fail_mod(mod, result, lifecycle_error_message("mod_initialize", result, error)); } } catch (const std::exception& e) { - fail_mod(mod, MOD_ERROR, fmt::format("exception in mod_initialize: {}", e.what())); + fail_mod(mod, MOD_ERROR, fmt::format("Exception in mod_initialize: {}", e.what())); } catch (...) { - fail_mod(mod, MOD_ERROR, "unknown exception in mod_initialize"); + fail_mod(mod, MOD_ERROR, "Unknown exception in mod_initialize"); } warn_unpublished_deferred_exports(mod); @@ -611,11 +618,14 @@ bool ModLoader::activate_mod(LoadedMod& mod) { void ModLoader::deactivate_mod(LoadedMod& mod) { if (mod.initialized && mod.native && mod.native->fn_shutdown) { + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling mod_shutdown"); try { ModError error = MOD_ERROR_INIT; const auto result = mod.native->fn_shutdown(&error); - if (result != MOD_OK) { - Log.error("mod_shutdown failed for '{}': {}", mod.metadata.id, + if (result == MOD_OK) { + log::write(mod.metadata.id, LOG_LEVEL_TRACE, "mod_shutdown succeeded"); + } else { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_shutdown failed: {}", lifecycle_error_message("mod_shutdown", result, error)); } } catch (...) { @@ -719,7 +729,7 @@ void ModLoader::init() { if (register_static_service_exports(mod)) { mod.servicesRegistered = true; } else { - Log.error("'{}' failed to register service exports", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); } } @@ -730,7 +740,7 @@ void ModLoader::init() { // Config-disabled mods keep their dependency edges but must not resolve until enabled. for (auto& mod : mods()) { if (!mod.cvarIsEnabled->getValue()) { - Log.info("Mod '{}' is disabled by config", mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_INFO, "disabled by config"); mod.active = false; if (mod.servicesRegistered) { svc::remove_services_for_provider(mod); @@ -753,7 +763,7 @@ void ModLoader::init() { m_startupComplete = true; } -LoadedMod* ModLoader::find_mod(std::string_view id) { +LoadedMod* ModLoader::find_mod(std::string_view id) const { for (auto& mod : mods()) { if (mod.metadata.id == id) { return &mod; @@ -778,23 +788,55 @@ void ModLoader::request_reload(std::string_view id) { m_pendingRequests.push_back({std::string{id}, RequestKind::Reload}); } -void ModLoader::notify_mod_failure(LoadedMod& mod) { - // Startup failures are handled inline by activate_mod; the queue only exists for failures - // raised while mod code may be on the stack (mod_update, hook callbacks, UI callbacks). +void ModLoader::notify_mod_failure(LoadedMod& mod, bool firstFailure) { + if (firstFailure) { + m_pendingFailures.push_back(mod.metadata.name); + } + // Startup failures are handled inline by activate_mod if (!m_startupComplete) { return; } m_pendingRequests.push_back({mod.metadata.id, RequestKind::Disable}); } +void ModLoader::flush_toasts() { + if (m_pendingFailures.empty()) { + return; + } + + const auto names = std::exchange(m_pendingFailures, {}); + + // Skip displaying toasts if the mods window is currently open + if (const auto* window = dynamic_cast(ui::top_document())) { + if (window->visible()) { + return; + } + } + + ui::Toast toast{.type = "warning", .duration = std::chrono::seconds{5}}; + if (names.size() == 1) { + toast.title = "Mod failed"; + toast.content = + fmt::format("
{} failed and was disabled.
Check Mods for " + "more information.
", + ui::escape(names.front())); + } else { + toast.title = "Mods failed"; + toast.content = fmt::format("
{} mods failed and were disabled.
Check " + "Mods for more information.
", + names.size()); + } + ui::push_toast(std::move(toast)); +} + static bool requiredDepsActive(const LoadedMod& mod) { return std::ranges::all_of(mod.dependencies, [](const ModDependencyEdge& edge) { return !edge.required || edge.mod->active; }); } std::vector ModLoader::collect_lifecycle_set(LoadedMod& target) { - std::vector included{&target}; - std::vector pending{&target}; + std::vector included{&target}; + std::vector pending{&target}; while (!pending.empty()) { auto* current = pending.back(); pending.pop_back(); @@ -843,7 +885,7 @@ bool ModLoader::ensure_native_loaded(LoadedMod& mod) { bool ModLoader::reload_bundle(LoadedMod& mod) { namespace fs = std::filesystem; - Log.info("reloading '{}' from {}", mod.metadata.id, mod.modPath); + log::write(mod.metadata.id, LOG_LEVEL_INFO, "reloading from {}", mod.modPath); std::shared_ptr newBundle; ModMetadata newMetadata; @@ -852,13 +894,13 @@ bool ModLoader::reload_bundle(LoadedMod& mod) { newBundle = load_bundle(mod.modPath, fs::is_directory(mod.modPath, ec)); newMetadata = load_metadata(mod.modPath, *newBundle); } catch (const std::exception& e) { - fail_mod(mod, MOD_ERROR, fmt::format("reload failed: {}", e.what())); + fail_mod(mod, MOD_ERROR, fmt::format("Reload failed: {}", e.what())); return false; } if (newMetadata.id != mod.metadata.id) { fail_mod(mod, MOD_CONFLICT, - fmt::format("mod id changed on reload ('{}'); restart the game", newMetadata.id)); + fmt::format("Mod ID changed on reload ('{}'); restart required", newMetadata.id)); return false; } @@ -885,8 +927,8 @@ bool ModLoader::reload_bundle(LoadedMod& mod) { if (newInfo != mod.manifestInfo) { // The reload changes the mod's imports/exports; rebuild the dependency graph so edges, // init/tick/shutdown order and cascade sets reflect the new manifest. - Log.info("'{}' changed its service imports/exports; rebuilding mod dependency graph", - mod.metadata.id); + log::write(mod.metadata.id, LOG_LEVEL_INFO, + "changed its service imports/exports; rebuilding mod dependency graph"); mod.manifestInfo = std::move(newInfo); loader::sort_mods(m_mods); } @@ -906,7 +948,7 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { continue; } const bool wasActive = mod->active; - Log.info("deactivating '{}'", mod->metadata.id); + log::write(mod->metadata.id, LOG_LEVEL_INFO, "deactivating mod"); deactivate_mod(*mod); if (mod != &target && wasActive) { // Provisional; cleared below if the mod comes straight back up. @@ -953,7 +995,8 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { } if (!requiredDepsActive(*mod)) { mod->suspendedByProvider = true; - Log.info("'{}' suspended: a required provider is disabled", mod->metadata.id); + log::write( + mod->metadata.id, LOG_LEVEL_INFO, "suspended: a required provider is disabled"); continue; } mod->suspendedByProvider = false; @@ -972,6 +1015,10 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { void ModLoader::on_enabled_changed(LoadedMod& mod) { svc::config_mark_dirty(); if (mod.loadFailed) { + if (!mod.cvarIsEnabled->getValue()) { + mod.loadFailed = false; + mod.failureReason.clear(); + } return; } if (mod.suspendedByProvider) { @@ -1013,7 +1060,7 @@ void ModLoader::apply_pending_requests() { continue; } if (request.kind == RequestKind::Reload && mod->inPlace) { - Log.warn("'{}' is a built-in mod and can't be reloaded", mod->metadata.id); + log::write(mod->metadata.id, LOG_LEVEL_WARN, "is a built-in mod and can't be reloaded"); continue; } if (request.kind == RequestKind::Enable && mod->enabledApplied) { @@ -1046,13 +1093,14 @@ void ModLoader::tick() { fail_mod(mod, result, lifecycle_error_message("mod_update", result, error)); } } catch (const std::exception& e) { - fail_mod(mod, MOD_ERROR, fmt::format("exception in mod_update: {}", e.what())); + fail_mod(mod, MOD_ERROR, fmt::format("Exception in mod_update: {}", e.what())); } catch (...) { - fail_mod(mod, MOD_ERROR, "unknown exception in mod_update"); + fail_mod(mod, MOD_ERROR, "Unknown exception in mod_update"); } } svc::modules_frame_end(); + flush_toasts(); } void ModLoader::shutdown() { @@ -1069,7 +1117,6 @@ void ModLoader::shutdown() { m_mods.clear(); drain_retired_natives(); svc::modules_shutdown(); - clear_services(); Log.info("all mods unloaded"); } diff --git a/src/dusk/mods/log_buffer.cpp b/src/dusk/mods/log_buffer.cpp new file mode 100644 index 0000000000..bfee810767 --- /dev/null +++ b/src/dusk/mods/log_buffer.cpp @@ -0,0 +1,103 @@ +#include "log_buffer.hpp" + +#include +#include + +#include "dusk/logging.h" + +namespace dusk::mods::log { +namespace { + +struct BufferState { + std::mutex mutex; + std::vector ring; + size_t head = 0; + size_t count = 0; + uint64_t nextSeq = 0; + std::vector modIds; + + uint16_t intern(std::string_view modId) { + for (size_t i = 0; i < modIds.size(); ++i) { + if (modIds[i] == modId) { + return static_cast(i); + } + } + modIds.emplace_back(modId); + return static_cast(modIds.size() - 1); + } +}; +BufferState g_buffer; + +} // namespace + +void emit(Source source, const std::string& modId, LogLevel level, const std::string& message) { + const auto timeMs = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + + { + std::lock_guard lock{g_buffer.mutex}; + if (g_buffer.ring.empty()) { + g_buffer.ring.resize(k_capacity); + } + auto& slot = g_buffer.ring[(g_buffer.head + g_buffer.count) % k_capacity]; + if (g_buffer.count == k_capacity) { + g_buffer.head = (g_buffer.head + 1) % k_capacity; + } else { + ++g_buffer.count; + } + slot.seq = g_buffer.nextSeq++; + slot.timeMs = timeMs; + slot.level = level; + slot.modIndex = g_buffer.intern(modId); + slot.source = source; + // assign() reuses the slot's capacity once the ring has wrapped + slot.message.assign(message); + } + + AuroraLogLevel auroraLevel = LOG_INFO; + switch (level) { + case LOG_LEVEL_TRACE: + case LOG_LEVEL_DEBUG: + auroraLevel = LOG_DEBUG; + break; + case LOG_LEVEL_INFO: + auroraLevel = LOG_INFO; + break; + case LOG_LEVEL_WARN: + auroraLevel = LOG_WARNING; + break; + case LOG_LEVEL_ERROR: + auroraLevel = LOG_ERROR; + break; + } + if (aurora::g_config.logLevel <= auroraLevel) { + aurora::log_internal(auroraLevel, modId.c_str(), message.c_str(), + static_cast(message.length())); + } +} + +Range copy_since(uint64_t sinceSeq, std::vector& out) { + std::lock_guard lock{g_buffer.mutex}; + const Range range{ + .firstSeq = g_buffer.nextSeq - g_buffer.count, + .nextSeq = g_buffer.nextSeq, + }; + for (uint64_t seq = std::max(sinceSeq, range.firstSeq); seq < range.nextSeq; ++seq) { + out.push_back(g_buffer.ring[(g_buffer.head + (seq - range.firstSeq)) % k_capacity]); + } + return range; +} + +std::vector ids() { + std::lock_guard lock{g_buffer.mutex}; + return g_buffer.modIds; +} + +void clear() { + std::lock_guard lock{g_buffer.mutex}; + g_buffer.head = 0; + g_buffer.count = 0; +} + +} // namespace dusk::mods::log diff --git a/src/dusk/mods/log_buffer.hpp b/src/dusk/mods/log_buffer.hpp new file mode 100644 index 0000000000..33a143705d --- /dev/null +++ b/src/dusk/mods/log_buffer.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "mods/svc/log.h" + +namespace dusk::mods::log { + +constexpr size_t k_capacity = 2048; + +enum class Source : uint8_t { + Loader, + Mod, +}; + +struct Line { + uint64_t seq = 0; // monotonic, never reset (survives clear) + int64_t timeMs = 0; + LogLevel level = LOG_LEVEL_INFO; + uint16_t modIndex = 0; // index into ids() + Source source = Source::Loader; + std::string message; +}; + +struct Range { + uint64_t firstSeq = 0; // oldest retained entry + uint64_t nextSeq = 0; // one past the newest entry +}; + +// Appends to the mod log buffer and emits through aurora logging with the mod ID as the +// module tag. Console/file output honors the configured log level; the buffer captures +// every level. Thread-safe; mods may log from worker threads. +void emit(Source source, const std::string& modId, LogLevel level, const std::string& message); + +// Appends entries with seq >= sinceSeq to `out` and returns the retained range, +// so callers diffing by seq can detect both new entries and truncation. +Range copy_since(uint64_t sinceSeq, std::vector& out); + +// Append-only intern table of mod ids; indices are stable for the session. +std::vector ids(); + +void clear(); + +// Formatting helper for loader messages. +template +void write(const std::string& modId, LogLevel level, fmt::format_string format, T&&... args) { + emit(Source::Loader, modId, level, fmt::format(format, std::forward(args)...)); +} + +} // namespace dusk::mods::log diff --git a/src/dusk/mods/loader/manifest.cpp b/src/dusk/mods/manifest.cpp similarity index 100% rename from src/dusk/mods/loader/manifest.cpp rename to src/dusk/mods/manifest.cpp diff --git a/src/dusk/mods/loader/manifest.hpp b/src/dusk/mods/manifest.hpp similarity index 100% rename from src/dusk/mods/loader/manifest.hpp rename to src/dusk/mods/manifest.hpp diff --git a/src/dusk/mods/svc/config.cpp b/src/dusk/mods/svc/config.cpp index b7ea9c9e9d..cae6bf840e 100644 --- a/src/dusk/mods/svc/config.cpp +++ b/src/dusk/mods/svc/config.cpp @@ -385,9 +385,9 @@ ModResult config_subscribe(ModContext* context, ConfigVarHandle var, ConfigChang callback(modPtr->context.get(), varHandle, ¤tValue, &previousValue, userData); } catch (const std::exception& e) { fail_mod(*modPtr, MOD_ERROR, - fmt::format("exception in config change callback: {}", e.what())); + fmt::format("Exception in config change callback: {}", e.what())); } catch (...) { - fail_mod(*modPtr, MOD_ERROR, "unknown exception in config change callback"); + fail_mod(*modPtr, MOD_ERROR, "Unknown exception in config change callback"); } }); if (outHandle != nullptr) { diff --git a/src/dusk/mods/svc/hook.cpp b/src/dusk/mods/svc/hook.cpp index f565776183..1439a80619 100644 --- a/src/dusk/mods/svc/hook.cpp +++ b/src/dusk/mods/svc/hook.cpp @@ -1,7 +1,7 @@ #include "registry.hpp" #include "dusk/mods/loader/loader.hpp" -#include "dusk/mods/loader/manifest.hpp" +#include "dusk/mods/manifest.hpp" #include "mods/svc/hook.h" #if DUSK_CODE_MODS diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index 05971d5be1..5904903ee7 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -1,7 +1,7 @@ #include "registry.hpp" #include "dusk/mods/loader/loader.hpp" -#include "dusk/mods/loader/manifest.hpp" +#include "dusk/mods/manifest.hpp" #include "fmt/format.h" #include @@ -38,7 +38,7 @@ ModResult host_publish_service( void host_fail(ModContext* context, const ModResult code, const char* message) { auto* mod = mod_from_context(context); if (mod != nullptr) { - fail_mod(*mod, code, message != nullptr ? message : "mod reported failure"); + fail_mod(*mod, code, message != nullptr ? message : "Mod reported an unknown failure"); } } @@ -112,9 +112,9 @@ void host_mod_detached(LoadedMod& mod) { MOD_LIFECYCLE_DETACHED, watcher.userData); } catch (const std::exception& e) { fail_mod(*watcher.owner, MOD_ERROR, - fmt::format("exception in mod lifecycle callback: {}", e.what())); + fmt::format("Exception in mod lifecycle callback: {}", e.what())); } catch (...) { - fail_mod(*watcher.owner, MOD_ERROR, "unknown exception in mod lifecycle callback"); + fail_mod(*watcher.owner, MOD_ERROR, "Unknown exception in mod lifecycle callback"); } } } diff --git a/src/dusk/mods/svc/log.cpp b/src/dusk/mods/svc/log.cpp index d9fc88eae7..498a20aeaf 100644 --- a/src/dusk/mods/svc/log.cpp +++ b/src/dusk/mods/svc/log.cpp @@ -1,28 +1,14 @@ #include "registry.hpp" -#include "dusk/logging.h" #include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/log_buffer.hpp" namespace dusk::mods::svc { namespace { void log_write(ModContext* context, const LogLevel level, const char* message) { const char* text = message != nullptr ? message : ""; - switch (level) { - case LOG_LEVEL_TRACE: - case LOG_LEVEL_DEBUG: - DuskLog.debug("[{}] {}", mod_id_from_context(context), text); - break; - case LOG_LEVEL_INFO: - DuskLog.info("[{}] {}", mod_id_from_context(context), text); - break; - case LOG_LEVEL_WARN: - DuskLog.warn("[{}] {}", mod_id_from_context(context), text); - break; - case LOG_LEVEL_ERROR: - DuskLog.error("[{}] {}", mod_id_from_context(context), text); - break; - } + log::emit(log::Source::Mod, mod_id_from_context(context), level, text); } void log_trace(ModContext* context, const char* message) { diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 0a2da3add3..0c7c61276a 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -1,5 +1,6 @@ #include "registry.hpp" +#include "dusk/app_info.hpp" #include "dusk/logging.h" #include "dusk/mods/loader/loader.hpp" @@ -22,7 +23,7 @@ std::string service_key(std::string_view id, const uint16_t majorVersion) { } const char* mod_id(const LoadedMod* mod) { - return mod != nullptr ? mod->metadata.id.c_str() : "dusklight"; + return mod != nullptr ? mod->metadata.id.c_str() : AppName; } bool validate_service_header(const ServiceHeader* header, const char* serviceId, @@ -45,6 +46,11 @@ bool validate_service_header(const ServiceHeader* header, const char* serviceId, return true; } +void clear_services() { + s_services.clear(); + s_modules.clear(); +} + } // namespace bool valid_service_id(const char* serviceId) { @@ -131,11 +137,6 @@ const ServiceRecord* find_service_record(const char* serviceId, const uint16_t m return it != s_services.end() ? &it->second : nullptr; } -void clear_services() { - s_services.clear(); - s_modules.clear(); -} - ModResult register_module(const ServiceModule& module) { const auto result = register_service( module.id, module.majorVersion, module.minorVersion, module.service, nullptr, false); @@ -187,6 +188,7 @@ void modules_shutdown() { module->shutdown(); } } + clear_services(); } } // namespace dusk::mods::svc @@ -222,13 +224,13 @@ bool ModLoader::register_static_service_exports(LoadedMod& mod) { if (serviceExport.struct_size != sizeof(ServiceExport) || !svc::valid_service_id(serviceExport.service_id)) { - fail_mod(mod, MOD_INVALID_ARGUMENT, "invalid service export descriptor"); + fail_mod(mod, MOD_INVALID_ARGUMENT, "Invalid service export descriptor"); return false; } const bool deferred = (serviceExport.flags & SERVICE_EXPORT_DEFERRED) != 0; if (!deferred && serviceExport.service == nullptr) { - fail_mod(mod, MOD_INVALID_ARGUMENT, "static service export has null service pointer"); + fail_mod(mod, MOD_INVALID_ARGUMENT, "Static service export has null service pointer"); return false; } @@ -236,7 +238,7 @@ bool ModLoader::register_static_service_exports(LoadedMod& mod) { svc::register_service(serviceExport.service_id, serviceExport.major_version, serviceExport.minor_version, serviceExport.service, &mod, deferred); if (result != MOD_OK) { - fail_mod(mod, result, "service export registration failed"); + fail_mod(mod, result, "Service export registration failed"); return false; } } @@ -248,10 +250,10 @@ std::string ModLoader::describe_missing_import( const char* serviceId, const uint16_t majorVersion, const uint16_t minMinorVersion) const { if (const auto* record = svc::find_service_record(serviceId, majorVersion)) { if (record->service == nullptr) { - return fmt::format("required service {}@{} was never published by provider '{}'", + return fmt::format("Required service {}@{} was never published by provider '{}'", serviceId, majorVersion, svc::mod_id(record->provider)); } - return fmt::format("required service {}@{} only provides minor version {} (need >= {})", + return fmt::format("Required service {}@{} only provides minor version {} (need >= {})", serviceId, majorVersion, record->minorVersion, minMinorVersion); } @@ -270,14 +272,14 @@ std::string ModLoader::describe_missing_import( std::string_view{serviceExport.service_id} == serviceId && serviceExport.major_version == majorVersion) { - return fmt::format("required service {}@{} unavailable: provider '{}' {}", + return fmt::format("Required service {}@{} unavailable: provider '{}' {}", serviceId, majorVersion, other.metadata.id, other.loadFailed ? "failed to load" : "is disabled"); } } } - return fmt::format("required service unavailable: {}@{}", serviceId, majorVersion); + return fmt::format("Required service unavailable: {}@{}", serviceId, majorVersion); } bool ModLoader::resolve_service_imports(LoadedMod& mod) { @@ -291,7 +293,7 @@ bool ModLoader::resolve_service_imports(LoadedMod& mod) { if (serviceImport.struct_size != sizeof(ServiceImport) || !svc::valid_service_id(serviceImport.service_id) || serviceImport.slot == nullptr) { - fail_mod(mod, MOD_INVALID_ARGUMENT, "invalid service import descriptor"); + fail_mod(mod, MOD_INVALID_ARGUMENT, "Invalid service import descriptor"); return false; } @@ -315,12 +317,4 @@ bool ModLoader::resolve_service_imports(LoadedMod& mod) { return true; } -void ModLoader::clear_services() { - svc::clear_services(); -} - -void ModLoader::fail_mod(LoadedMod& mod, const ModResult code, std::string_view message) { - mods::fail_mod(mod, code, message); -} - } // namespace dusk::mods diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index bdbe2c2e72..e8d613057e 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -53,7 +53,6 @@ const ServiceRecord* find_service( const char* serviceId, uint16_t majorVersion, uint16_t minMinorVersion); // Unlike find_service, also returns deferred records that have not been published yet. const ServiceRecord* find_service_record(const char* serviceId, uint16_t majorVersion); -void clear_services(); ModResult register_module(const ServiceModule& module); void modules_mod_detached(LoadedMod& mod); diff --git a/src/dusk/ui/logs_window.cpp b/src/dusk/ui/logs_window.cpp new file mode 100644 index 0000000000..9fdd89e50d --- /dev/null +++ b/src/dusk/ui/logs_window.cpp @@ -0,0 +1,301 @@ +#include "logs_window.hpp" + +#include +#include + +#include +#include + +#include "pane.hpp" + +namespace dusk::ui { +namespace { + +const char* level_name(LogLevel level) { + switch (level) { + case LOG_LEVEL_TRACE: + return "Trace"; + case LOG_LEVEL_DEBUG: + return "Debug"; + case LOG_LEVEL_INFO: + return "Info"; + case LOG_LEVEL_WARN: + return "Warn"; + case LOG_LEVEL_ERROR: + return "Error"; + } + return "?"; +} + +const char* level_logger_name(LogLevel level) { + switch (level) { + case LOG_LEVEL_TRACE: + return "TRACE"; + case LOG_LEVEL_DEBUG: + return "DEBUG"; + case LOG_LEVEL_INFO: + return "INFO"; + case LOG_LEVEL_WARN: + return "WARNING"; + case LOG_LEVEL_ERROR: + return "ERROR"; + } + return "?"; +} + +const char* level_class(LogLevel level) { + switch (level) { + case LOG_LEVEL_TRACE: + return "lvl-trace"; + case LOG_LEVEL_DEBUG: + return "lvl-debug"; + case LOG_LEVEL_INFO: + return "lvl-info"; + case LOG_LEVEL_WARN: + return "lvl-warn"; + case LOG_LEVEL_ERROR: + return "lvl-error"; + } + return "lvl-info"; +} + +std::string format_time(int64_t timeMs) { + const auto seconds = static_cast(timeMs / 1000); + std::tm localTime{}; +#if _WIN32 + localtime_s(&localTime, &seconds); +#else + localtime_r(&seconds, &localTime); +#endif + std::array buffer{}; + std::strftime(buffer.data(), buffer.size(), "%H:%M:%S", &localTime); + return fmt::format("{}.{:03}", buffer.data(), timeMs % 1000); +} + +void append_text(Rml::ElementDocument* doc, Rml::Element* parent, const Rml::String& text) { + parent->AppendChild(doc->CreateTextNode(text)); +} + +Rml::Element* append_span(Rml::ElementDocument* doc, Rml::Element* parent, const char* className, + const Rml::String& text) { + auto span = doc->CreateElement("span"); + span->SetClass(className, true); + append_text(doc, span.get(), text); + return parent->AppendChild(std::move(span)); +} + +} // namespace + +LogsWindow::LogsWindow(std::string modFilter) + : Window{Props{.tabBar = false, .styleSheets = {"res/rml/logs.rcss"}}}, + mModFilter{std::move(modFilter)} { + mRoot->SetClass("logs", true); + set_content([this](Rml::Element* content) { build_content(content); }); +} + +void LogsWindow::build_content(Rml::Element* content) { + auto* toolbar = append(content, "div"); + toolbar->SetClass("log-toolbar", true); + + auto* title = append(toolbar, "div"); + title->SetClass("log-title", true); + title->SetInnerRML("Logs"); + + auto* modLabel = append(toolbar, "div"); + modLabel->SetClass("log-title-mod", true); + modLabel->SetInnerRML(mModFilter.empty() ? "All mods" : fmt::format("{}", escape(mModFilter))); + + append(toolbar, "div")->SetClass("log-toolbar-spacer", true); + + for (const LogLevel level : + {LOG_LEVEL_TRACE, LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_WARN, LOG_LEVEL_ERROR}) + { + add_child(toolbar, + ControlledButton::Props{ + .text = level_name(level), + .isSelected = [this, level] { return mMinLevel <= level; }, + }) + .on_pressed([this, level] { + mMinLevel = level; + rebuild_lines(); + }); + } + + append(toolbar, "div")->SetClass("log-toolbar-spacer", true); + + add_child