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; +} +}