diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80257624c5..2f9f666700 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,6 +80,7 @@ jobs: path: | build/install/Dusklight-*.AppImage build/install/debug.tar.* + build/install/sdk/ build-apple: name: Build Apple (${{matrix.name}}) @@ -148,6 +149,7 @@ jobs: path: | build/install/Dusklight.app build/install/debug.tar.* + build/install/sdk/ build-android: name: Build Android (${{matrix.name}}) @@ -203,6 +205,9 @@ jobs: - name: Build native library run: cmake --build --preset ${{matrix.preset}} --target dusklight + - name: Build bundled mods + run: cmake --build --preset ${{matrix.preset}} --target dusklight_mods + - name: Stage stripped JNI library run: ANDROID_STAGE_ABIS="${{matrix.abi}}" platforms/android/scripts/stage-jni-libs.sh @@ -210,11 +215,17 @@ jobs: working-directory: platforms/android run: ./gradlew :app:assembleRelease --rerun-tasks + - name: Stage artifacts + run: | + mkdir -p upload/sdk + cp build/*/stub-android-*.so upload/sdk/ + cp platforms/android/app/build/outputs/apk/release/app-${{matrix.abi}}-release-unsigned.apk upload/ + - name: Upload artifacts uses: actions/upload-artifact@v7 with: name: dusklight-${{env.DUSK_VERSION}}-android-${{matrix.artifact_arch}} - path: platforms/android/app/build/outputs/apk/release/app-${{matrix.abi}}-release-unsigned.apk + path: upload/ build-windows: name: Build Windows (${{matrix.name}}) @@ -279,5 +290,8 @@ jobs: path: | build/install/*.exe build/install/*.dll + build/install/*.symdb build/install/res/ + build/install/mods/ build/install/debug.7z + build/install/sdk/ diff --git a/CMakeLists.txt b/CMakeLists.txt index f0f80651ac..bf35cb2e45 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -491,9 +491,12 @@ if (WIN32) target_link_libraries(dusklight PRIVATE Psapi) endif () if (APPLE) - # Mods resolve game symbols from the executable at dlopen time. - target_link_options(dusklight PRIVATE -Wl,-export_dynamic) -elseif (UNIX AND NOT ANDROID) + include(cmake/AppleExports.cmake) + setup_apple_exports(dusklight) +elseif (ANDROID) + include(cmake/AndroidExports.cmake) + setup_android_exports(dusklight) +elseif (UNIX) target_link_options(dusklight PRIVATE -rdynamic) endif () diff --git a/CMakePresets.json b/CMakePresets.json index 394a1ddef5..e6485ddac1 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -22,18 +22,6 @@ "CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreadedDLL" } }, - { - "name": "release", - "hidden": true, - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Release", - "CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreadedDLL", - "CMAKE_INTERPROCEDURAL_OPTIMIZATION": { - "type": "BOOL", - "value": true - } - } - }, { "name": "ci", "hidden": true, @@ -208,17 +196,13 @@ { "name": "windows-arm64-msvc", "displayName": "Windows ARM64 (MSVC)", - "generator": "Ninja", - "binaryDir": "${sourceDir}/build/${presetName}", + "inherits": [ + "windows-msvc" + ], "architecture": { "value": "arm64", "strategy": "external" }, - "cacheVariables": { - "CMAKE_C_COMPILER": "cl", - "CMAKE_CXX_COMPILER": "cl", - "CMAKE_INSTALL_PREFIX": "${sourceDir}/build/install" - }, "vendor": { "microsoft.com/VisualStudioSettings/CMake/1.0": { "hostOS": [ @@ -445,8 +429,7 @@ "hidden": true, "inherits": [ "android-base", - "ci", - "release" + "ci" ], "cacheVariables": { "DUSK_ENABLE_SENTRY_NATIVE": { diff --git a/ci/build-appimage.sh b/ci/build-appimage.sh index e12f1163b9..ec7d6b410e 100755 --- a/ci/build-appimage.sh +++ b/ci/build-appimage.sh @@ -19,6 +19,9 @@ for install_path in build/install/*; do [[ "$(basename "$install_path")" == *.* ]] && continue cp -r "$install_path" build/appdir/usr/bin done +if [[ -f build/install/dusklight.symdb ]]; then + cp build/install/dusklight.symdb build/appdir/usr/bin +fi cp -r platforms/freedesktop/{16x16,32x32,48x48,64x64,128x128,256x256,512x512,1024x1024} build/appdir/usr/share/icons/hicolor cp platforms/freedesktop/dev.twilitrealm.dusk.desktop build/appdir/usr/share/applications diff --git a/cmake/AndroidExports.cmake b/cmake/AndroidExports.cmake new file mode 100644 index 0000000000..23e14898e0 --- /dev/null +++ b/cmake/AndroidExports.cmake @@ -0,0 +1,64 @@ +include_guard(GLOBAL) + +get_filename_component(_dir "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + +# Android mod linking: symgen scans and filters the game's exports, generating a version script used for the executable +# link step, and generates a shared object stub that mods can link against without having to build the whole game. +function(setup_android_exports target) + include("${_dir}/SymbolManifest.cmake") + ensure_symgen(TRUE) + add_dependencies(${target} symgen) + + set(_rsp_lines "$") + foreach (_lib IN LISTS JSYSTEM_LIBRARIES) + list(APPEND _rsp_lines "$") + endforeach () + list(JOIN _rsp_lines "\n" _rsp_content) + set(_rsp "${CMAKE_BINARY_DIR}/dusklight_exports_input.rsp") + file(GENERATE OUTPUT "${_rsp}" CONTENT "${_rsp_content}") + + set(_sdk_args) + foreach (_lib aurora_card aurora_core aurora_dvd aurora_gd aurora_gx aurora_mtx + aurora_os aurora_pad aurora_si aurora_vi) + if (TARGET ${_lib}) + list(APPEND _sdk_args --sdk-lib "$") + endif () + endforeach () + if (TARGET dawn::webgpu_dawn) + get_target_property(_dawn_type dawn::webgpu_dawn TYPE) + if (_dawn_type STREQUAL "STATIC_LIBRARY") + list(APPEND _sdk_args --sdk-lib "$") + endif () + endif () + + set(_vscript "${CMAKE_BINARY_DIR}/dusklight_exports.ver") + add_custom_command(TARGET ${target} PRE_LINK + # TODO: src/dusk/ is NOT excluded: inline code in game headers + # currently call into it (e.g. dusk::frame_interp::lookup_replacement). + COMMAND "${SYMGEN_EXE}" exports + "@${_rsp}" + --out "${_vscript}" + --format version-script + --exclude cmake_pch + --exclude miniz + --exclude asan_options + # Resolved from the Java side; the SDL ones live in the statically-linked + # SDL archive, outside the provenance scan. + --extra-sym JNI_OnLoad + --extra-sym SDL_main + --extra-sym "Java_*" + ${_sdk_args} + COMMENT "Generating dusklight exports" + VERBATIM) + target_link_options(${target} PRIVATE "-Wl,--version-script=${_vscript}") + + string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _arch) + set(_stub "${CMAKE_BINARY_DIR}/stub-android-${_arch}.so") + add_custom_command(TARGET ${target} POST_BUILD + COMMAND "${SYMGEN_EXE}" stub -f elf "$" -o "${_stub}" + --soname "$" --arch "${_arch}" + BYPRODUCTS "${_stub}" + COMMENT "Generating dusklight link stub" + VERBATIM) + install(FILES "${_stub}" DESTINATION sdk) +endfunction() diff --git a/cmake/AppleExports.cmake b/cmake/AppleExports.cmake new file mode 100644 index 0000000000..0b43fdb6ee --- /dev/null +++ b/cmake/AppleExports.cmake @@ -0,0 +1,91 @@ +include_guard(GLOBAL) + +get_filename_component(_dir "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + +# Apple mod linking: symgen scans and filters the game's exports, generating an exports list used for the executable +# link step, and generates a MH_EXECUTE Mach-O stub that mods can link against without having to build the whole game. +function(setup_apple_exports target) + include("${_dir}/SymbolManifest.cmake") + ensure_symgen(TRUE) + set(_symgen "${SYMGEN_EXE}") + add_dependencies(${target} symgen) + + set(_config_subdir "") + if (CMAKE_CONFIGURATION_TYPES) + set(_config_subdir "$/") + endif () + + set(_rsp_lines "$") + foreach (_lib IN LISTS JSYSTEM_LIBRARIES) + list(APPEND _rsp_lines "$") + endforeach () + list(JOIN _rsp_lines "\n" _rsp_content) + set(_rsp "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_exports_input.rsp") + file(GENERATE OUTPUT "${_rsp}" CONTENT "${_rsp_content}") + + set(_sdk_args) + foreach (_lib aurora_card aurora_core aurora_dvd aurora_gd aurora_gx aurora_mtx + aurora_os aurora_pad aurora_si aurora_vi) + if (TARGET ${_lib}) + list(APPEND _sdk_args --sdk-lib "$") + endif () + endforeach () + + # Dawn is linked statically on Apple; mods reach wgpu* through the executable. + if (TARGET dawn::webgpu_dawn) + get_target_property(_dawn_type dawn::webgpu_dawn TYPE) + if (_dawn_type STREQUAL "STATIC_LIBRARY") + list(APPEND _sdk_args --sdk-lib "$") + endif () + endif () + + set(_exp "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_exports.exp") + add_custom_command(TARGET ${target} PRE_LINK + # TODO: src/dusk/ is NOT excluded: inline code in game headers + # currently call into it (e.g. dusk::frame_interp::lookup_replacement). + COMMAND "${_symgen}" exports + "@${_rsp}" + --out "${_exp}" + --exclude cmake_pch + --exclude miniz + --exclude asan_options + ${_sdk_args} + COMMENT "Generating dusklight exports" + VERBATIM) + target_link_options(${target} PRIVATE -Xlinker -exported_symbols_list -Xlinker "${_exp}") + + # Generate the stub executable mods link against via -bundle_loader. + set(_stub_args) + if (IOS) + set(_stub_platform "ios") + list(APPEND _stub_args --platform ios) + elseif (TVOS) + set(_stub_platform "tvos") + list(APPEND _stub_args --platform tvos) + else () + set(_stub_platform "macos") + endif () + if (CMAKE_OSX_DEPLOYMENT_TARGET) + list(APPEND _stub_args --min-os "${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif () + if (CMAKE_OSX_ARCHITECTURES) + set(_archs "${CMAKE_OSX_ARCHITECTURES}") + else () + set(_archs "${CMAKE_SYSTEM_PROCESSOR}") + endif () + set(_arch_names "") + foreach (_arch IN LISTS _archs) + string(TOLOWER "${_arch}" _arch) + list(APPEND _stub_args --arch "${_arch}") + list(APPEND _arch_names "${_arch}") + endforeach () + list(JOIN _arch_names "_" _arch_name) + + set(_stub "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight-stub") + add_custom_command(TARGET ${target} POST_BUILD + COMMAND "${_symgen}" stub -f macho "${_exp}" -o "${_stub}" ${_stub_args} + BYPRODUCTS "${_stub}" + COMMENT "Generating dusklight link stub" + VERBATIM) + install(FILES "${_stub}" DESTINATION sdk RENAME "stub-${_stub_platform}-${_arch_name}") +endfunction() diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index 833e433360..0e8d72a0a0 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -1,8 +1,9 @@ -# add_mod( SOURCES ... MOD_JSON [RES_DIR ] [OVERLAY_DIR ] +# add_mod( SOURCES ... MOD_JSON +# [RUNTIME_LIBRARIES ...] [RES_DIR ] [OVERLAY_DIR ] # [TEXTURES_DIR ] [OUTPUT_DIR ] [BUNDLE]) set(DUSK_MODS_OUTPUT_DIR "${CMAKE_BINARY_DIR}/mods" CACHE PATH "Directory to write mod packages into") -function(_mod_lib_name out_var) +function(_mod_lib_info out_platform_var out_name_var) set(_arch "${CMAKE_SYSTEM_PROCESSOR}") if (APPLE AND CMAKE_OSX_ARCHITECTURES) list(LENGTH CMAKE_OSX_ARCHITECTURES _count) @@ -12,18 +13,20 @@ function(_mod_lib_name out_var) set(_arch "${CMAKE_OSX_ARCHITECTURES}") endif () string(TOLOWER "${CMAKE_SYSTEM_NAME}" _platform) + if (_platform STREQUAL "darwin") + set(_platform "macos") + endif () 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) + set(${out_platform_var} "${_platform}-${_arch}" PARENT_SCOPE) + set(${out_name_var} "mod${_ext}" PARENT_SCOPE) endfunction() function(_mod_resolve_source_path out_var path) @@ -45,7 +48,8 @@ function(_mod_collect_assets out_var dir) endfunction() function(add_mod target_name) - cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OVERLAY_DIR;TEXTURES_DIR;OUTPUT_DIR" "SOURCES" ${ARGN}) + cmake_parse_arguments(ARG "BUNDLE" "MOD_JSON;RES_DIR;OVERLAY_DIR;TEXTURES_DIR;OUTPUT_DIR" + "SOURCES;RUNTIME_LIBRARIES" ${ARGN}) if (NOT ARG_MOD_JSON) message(FATAL_ERROR "add_mod: MOD_JSON is required") endif () @@ -55,11 +59,12 @@ function(add_mod target_name) endif () set(_has_lib FALSE) + set(_lib_platform "") set(_lib_name "") if (ARG_SOURCES) set(_has_lib TRUE) - add_library(${target_name} SHARED ${ARG_SOURCES}) - _mod_lib_name(_lib_name) + add_library(${target_name} MODULE ${ARG_SOURCES}) + _mod_lib_info(_lib_platform _lib_name) set_target_properties(${target_name} PROPERTIES PREFIX "" C_VISIBILITY_PRESET hidden @@ -90,50 +95,65 @@ function(add_mod target_name) endif () if (APPLE) - # Game symbols resolve against the host executable at dlopen time. - target_link_options(${target_name} PRIVATE -undefined dynamic_lookup) + if (TARGET dusklight) + set(_game_exe "$") + add_dependencies(${target_name} dusklight) + elseif (DUSK_GAME_EXE) + _mod_resolve_source_path(_game_exe "${DUSK_GAME_EXE}") + else () + message(FATAL_ERROR "add_mod: DUSK_GAME_EXE is not set (game executable)") + endif () + target_link_options(${target_name} PRIVATE + -Xlinker -bundle_loader -Xlinker "${_game_exe}") + set_property(TARGET ${target_name} APPEND PROPERTY LINK_DEPENDS "${_game_exe}") + set_target_properties(${target_name} PROPERTIES + BUILD_RPATH "@loader_path" + INSTALL_RPATH "@loader_path") elseif (ANDROID) if (TARGET dusklight) target_link_libraries(${target_name} PRIVATE dusklight) - elseif (DUSK_GAME_SOLIB) - target_link_libraries(${target_name} PRIVATE "${DUSK_GAME_SOLIB}") + elseif (DUSK_GAME_EXE) + _mod_resolve_source_path(_game_lib "${DUSK_GAME_EXE}") + target_link_libraries(${target_name} PRIVATE "${_game_lib}") else () - message(FATAL_ERROR "add_mod: DUSK_GAME_SOLIB is not set (libmain.so)") + message(FATAL_ERROR "add_mod: DUSK_GAME_EXE is not set (libmain.so or stub)") endif () + set_target_properties(${target_name} PROPERTIES + BUILD_RPATH "$ORIGIN" + INSTALL_RPATH "$ORIGIN") elseif (UNIX) target_link_options(${target_name} PRIVATE -Wl,--allow-shlib-undefined) + set_target_properties(${target_name} PROPERTIES + BUILD_RPATH "$ORIGIN" + INSTALL_RPATH "$ORIGIN") elseif (WIN32) - # Link against the generated import library (game ABI surface). Function calls - # resolve through import thunks. Data is toolchain dependent: - # - clang-cl: lld's mingw mode auto-imports data references, fixed up at load by - # the mod SDK's pseudo-relocation runtime (pseudo_reloc.cpp). - # - cl (MSVC): only DUSK_GAME_DATA-annotated data is reachable. Un-annotated - # references fail to link. - if (NOT DUSK_GAME_IMPLIB) - message(FATAL_ERROR "add_mod: DUSK_GAME_IMPLIB is not set.") + # Mods link against the game's import library (sdk/windows-.lib, generated by + # setup_windows_exports); cl and clang-cl both consume it in MSVC mode. Function + # calls resolve through import thunks; game data is reachable only through + # __declspec(dllimport), i.e. DUSK_GAME_DATA-annotated declarations. + if (TARGET dusklight) + if (NOT DUSK_GAME_IMPLIB) + message(FATAL_ERROR "add_mod: DUSK_GAME_IMPLIB is not set (see setup_windows_exports)") + endif () + set(_game_lib "${DUSK_GAME_IMPLIB}") + elseif (DUSK_GAME_EXE) + _mod_resolve_source_path(_game_lib "${DUSK_GAME_EXE}") + if (NOT _game_lib MATCHES "\\.lib$") + message(FATAL_ERROR + "add_mod: DUSK_GAME_EXE must be an import library on Windows " + "(sdk/windows-.lib)") + endif () + else () + message(FATAL_ERROR "add_mod: DUSK_GAME_EXE is not set (import library)") endif () - target_link_libraries(${target_name} PRIVATE "${DUSK_GAME_IMPLIB}") + target_link_libraries(${target_name} PRIVATE "${_game_lib}") set_target_properties(${target_name} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreadedDLL") target_compile_definitions(${target_name} PRIVATE _ITERATOR_DEBUG_LEVEL=0) - if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") - target_compile_options(${target_name} PRIVATE "$<$:/clang:-mcmodel=large>") - target_sources(${target_name} PRIVATE "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../sdk/pseudo_reloc.cpp") - # lld mingw mode rewrites /DEFAULTLIB directives to -l style and skips %LIB%, so - # the CRT libraries and search paths are spelled out explicitly. - target_link_options(${target_name} PRIVATE -lldmingw /nodefaultlib /INCREMENTAL:NO) - target_link_libraries(${target_name} PRIVATE - msvcrt.lib msvcprt.lib vcruntime.lib ucrt.lib - oldnames.lib uuid.lib kernel32.lib user32.lib) - set(_lib_dirs "$ENV{LIB}") - if ("${_lib_dirs}" STREQUAL "") - message(FATAL_ERROR "add_mod: %LIB% is empty; configure from a VS dev shell") - endif () - foreach (_libdir IN LISTS _lib_dirs) - target_link_options(${target_name} PRIVATE "/libpath:${_libdir}") - endforeach () - endif () endif () endif () + if (ARG_RUNTIME_LIBRARIES AND NOT _has_lib) + message(FATAL_ERROR "add_mod: RUNTIME_LIBRARIES requires SOURCES") + endif () set(_output_dir "${DUSK_MODS_OUTPUT_DIR}") if (ARG_OUTPUT_DIR) @@ -142,18 +162,39 @@ function(add_mod target_name) set(_stage "${CMAKE_CURRENT_BINARY_DIR}/${target_name}_stage") set(_out "${_output_dir}/${target_name}.dusk") - set(_zip_args "${_lib_name}" mod.json) + set(_zip_args mod.json) set(_package_deps "${_mod_json}") set(_package_inputs "${_mod_json}") set(_extra_cmds "") set(_lib_copy_cmd "") set(_target_depend "") if (_has_lib) - list(APPEND _zip_args "${_lib_name}") - set(_lib_copy_cmd COMMAND ${CMAKE_COMMAND} -E copy_if_different - "$" "${_stage}/${_lib_name}") + list(APPEND _zip_args lib) + set(_lib_copy_cmd + COMMAND ${CMAKE_COMMAND} -E make_directory "${_stage}/lib/${_lib_platform}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "${_stage}/lib/${_lib_platform}/${_lib_name}") set(_target_depend ${target_name}) endif () + string(TOLOWER "${_lib_name}" _lib_name_key) + set(_runtime_lib_name_keys "${_lib_name_key}") + foreach (_runtime_lib IN LISTS ARG_RUNTIME_LIBRARIES) + _mod_resolve_source_path(_runtime_lib_path "${_runtime_lib}") + if (NOT EXISTS "${_runtime_lib_path}" OR IS_DIRECTORY "${_runtime_lib_path}") + message(FATAL_ERROR "add_mod: runtime library does not exist or is not a file: ${_runtime_lib_path}") + endif () + get_filename_component(_runtime_lib_name "${_runtime_lib_path}" NAME) + string(TOLOWER "${_runtime_lib_name}" _runtime_lib_name_key) + list(FIND _runtime_lib_name_keys "${_runtime_lib_name_key}" _runtime_lib_name_index) + if (NOT _runtime_lib_name_index EQUAL -1) + message(FATAL_ERROR "add_mod: duplicate runtime library filename: ${_runtime_lib_name}") + endif () + list(APPEND _runtime_lib_name_keys "${_runtime_lib_name_key}") + list(APPEND _package_deps "${_runtime_lib_path}") + list(APPEND _package_inputs "${_runtime_lib_path}") + list(APPEND _lib_copy_cmd COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_runtime_lib_path}" "${_stage}/lib/${_lib_platform}/${_runtime_lib_name}") + endforeach () if (ARG_RES_DIR) _mod_resolve_source_path(_res_dir "${ARG_RES_DIR}") _mod_collect_assets(_res_deps "${_res_dir}") @@ -190,6 +231,7 @@ function(add_mod 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_PLATFORMS "${_lib_platform}") 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" @@ -228,9 +270,10 @@ endfunction() # 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. +# - macOS: pre-extracted stage dirs into the installed app's Contents/Resources/mods, native +# libs ad-hoc signed in place, then the whole bundle re-signed. +# - iOS/tvOS: assets into /mods/, the mod library into Frameworks/.so, +# and runtime libraries alongside it. # - 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) @@ -239,6 +282,7 @@ function(install_bundled_mods) endif () get_property(_ids GLOBAL PROPERTY DUSK_BUNDLED_MOD_IDS) get_property(_stages GLOBAL PROPERTY DUSK_BUNDLED_MOD_STAGES) + get_property(_lib_platforms GLOBAL PROPERTY DUSK_BUNDLED_MOD_LIB_PLATFORMS) get_property(_lib_names GLOBAL PROPERTY DUSK_BUNDLED_MOD_LIB_NAMES) list(LENGTH _targets _count) math(EXPR _last "${_count} - 1") @@ -254,19 +298,30 @@ function(install_bundled_mods) list(GET _targets ${_i} _target) list(GET _ids ${_i} _id) list(GET _stages ${_i} _stage) + list(GET _lib_platforms ${_i} _lib_platform) list(GET _lib_names ${_i} _lib_name) install(DIRECTORY "${_stage}/" DESTINATION "${_bundle_dir}/mods/${_id}" - PATTERN "${_lib_name}" EXCLUDE) + PATTERN "lib" EXCLUDE) install(PROGRAMS "$" - DESTINATION "${_bundle_dir}/Frameworks" RENAME "${_id}.dylib") + DESTINATION "${_bundle_dir}/Frameworks" RENAME "${_id}.so") + install(DIRECTORY "${_stage}/lib/${_lib_platform}/" + DESTINATION "${_bundle_dir}/Frameworks" + PATTERN "${_lib_name}" EXCLUDE) endforeach () else () foreach (_i RANGE ${_last}) list(GET _ids ${_i} _id) list(GET _stages ${_i} _stage) + list(GET _lib_platforms ${_i} _lib_platform) 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)") + install(CODE " + file(GLOB _mod_libs \"${_bundle_dir}/Contents/Resources/mods/${_id}/lib/${_lib_platform}/*\") + foreach (_mod_lib IN LISTS _mod_libs) + if (NOT IS_DIRECTORY \"\${_mod_lib}\") + execute_process(COMMAND /usr/bin/codesign --force --sign - \"\${_mod_lib}\" COMMAND_ERROR_IS_FATAL ANY) + endif () + endforeach ()") endforeach () if (TARGET crashpad_handler) install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - \"${_bundle_dir}/Contents/MacOS/$\" COMMAND_ERROR_IS_FATAL ANY)") diff --git a/cmake/SymbolManifest.cmake b/cmake/SymbolManifest.cmake index 927040a47f..1cb78b6bb8 100644 --- a/cmake/SymbolManifest.cmake +++ b/cmake/SymbolManifest.cmake @@ -2,7 +2,7 @@ include_guard(GLOBAL) get_filename_component(_SYMBOL_MANIFEST_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) -set(_SYMGEN_VERSION "1.1.1") +set(_SYMGEN_VERSION "1.2.0") set(_SYMGEN_RELEASE_BASE_URL "https://github.com/encounter/symgen/releases/download/v${_SYMGEN_VERSION}") set(SYMGEN_PATH "" CACHE FILEPATH "Path to a symgen executable; empty downloads the pinned release") mark_as_advanced(SYMGEN_PATH) @@ -122,4 +122,7 @@ function(setup_symbol_manifest target) COMMAND "${SYMGEN_EXE}" manifest ${_input} --out "${_out}" COMMENT "Generating symbol manifest" VERBATIM) + if (NOT APPLE) + install(FILES "${_out}" DESTINATION .) + endif () endfunction() diff --git a/cmake/WindowsExports.cmake b/cmake/WindowsExports.cmake index 3010c82f25..a82f7756af 100644 --- a/cmake/WindowsExports.cmake +++ b/cmake/WindowsExports.cmake @@ -6,9 +6,16 @@ get_filename_component(_DUSK_WINDOWS_EXPORTS_CMAKE_DIR "${CMAKE_CURRENT_LIST_FIL # import library mods link against. symgen scans the built objects, filters by source, and # writes a .def used by the main link and import library generation. function(setup_windows_exports target) - if (NOT CMAKE_SIZEOF_VOID_P EQUAL 8) - message(WARNING "dusklight: Windows code-mod exports are x64-only for now; skipping") - return() + string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" _implib_arch) + if (_implib_arch STREQUAL "arm64") + set(_dlltool_machine "arm64") + set(_lib_machine "arm64") + elseif (_implib_arch MATCHES "^(amd64|x86_64)$") + set(_dlltool_machine "i386:x86-64") + set(_lib_machine "x64") + else () + message(FATAL_ERROR + "dusklight: no Windows mod linking support for ${CMAKE_SYSTEM_PROCESSOR}") endif () include("${_DUSK_WINDOWS_EXPORTS_CMAKE_DIR}/SymbolManifest.cmake") @@ -53,7 +60,7 @@ function(setup_windows_exports target) # TODO: src/dusk/ is NOT excluded: inline code in game headers # currently call into it (e.g. dusk::frame_interp::lookup_replacement). COMMAND "${_symgen}" def - --rsp "${_rsp}" + "@${_rsp}" --out "${_def}" --exclude cmake_pch --exclude miniz @@ -70,11 +77,11 @@ function(setup_windows_exports target) get_filename_component(_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) find_program(DUSK_LLVM_DLLTOOL llvm-dlltool HINTS "${_compiler_dir}") if (DUSK_LLVM_DLLTOOL) - set(_implib_cmd "${DUSK_LLVM_DLLTOOL}" -d "${_def}" -D dusklight.exe -m i386:x86-64 - -l "${_implib}") + set(_implib_cmd "${DUSK_LLVM_DLLTOOL}" -d "${_def}" -D dusklight.exe + -m "${_dlltool_machine}" -l "${_implib}") else () - set(_implib_cmd "${CMAKE_AR}" /nologo "/def:${_def}" /machine:x64 /name:dusklight.exe - "/out:${_implib}") + set(_implib_cmd "${CMAKE_AR}" /nologo "/def:${_def}" "/machine:${_lib_machine}" + /name:dusklight.exe "/out:${_implib}") endif () add_custom_command(TARGET ${target} POST_BUILD COMMAND ${_implib_cmd} @@ -82,9 +89,6 @@ function(setup_windows_exports target) COMMENT "Generating dusklight import library" VERBATIM) set(DUSK_GAME_IMPLIB "${_implib}" CACHE INTERNAL "Import library for Windows mod linking") - set(DUSK_GAME_DEF "${_def}" CACHE INTERNAL "Curated export .def for the game executable") - # Ship the import library as sdk/dusklight.lib in the install tree: mods may use it to - # compile against Dusklight without having to build the whole game. (See DUSK_GAME_IMPLIB) - install(FILES "${_implib}" DESTINATION sdk RENAME dusklight.lib) + install(FILES "${_implib}" DESTINATION sdk RENAME "windows-${_implib_arch}.lib") endfunction() diff --git a/docs/modding.md b/docs/modding.md index 6f2d1a8580..4988f6a6e4 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -22,7 +22,7 @@ function, read and write data fields, and hook the vast majority of game functio 7. [Asset Overlays](#asset-overlays) 8. [Runtime Lifecycle](#runtime-lifecycle) 9. [Error Handling](#error-handling) -10. [Advanced: Exporting Services](#advanced-exporting-services) +10. [Advanced](#advanced) --- @@ -131,26 +131,30 @@ A service is a struct of C function pointers with a version header. You declare loader resolves it before your mod initializes: ```cpp -IMPORT_SERVICE(LogService, svc_log); // required, any minor version -IMPORT_SERVICE_VERSION(LogService, svc_log, 2); // required, minor version >= 2 +IMPORT_SERVICE(LogService, svc_log); // required, latest minor version +IMPORT_SERVICE_VERSION(LogService, svc_log, 0); // required, minimum minor version 0 (for backwards compatibility) IMPORT_OPTIONAL_SERVICE(SomeService, svc_maybe); // may be null ``` Each service is individually versioned, and there may be multiple major versions of a service provided at once, allowing backwards compatibility with older mods while still changing services fundamentally if necessary. A **major** bump is a breaking change, treated as a different service entirely. For **additive** changes, a service appends new -functions to the end of the struct without breaking existing callers and simply bumps the minor version. Mods that -want the newer functions may use `IMPORT_SERVICE_VERSION` to require that minor at **load time**, or `SERVICE_HAS` to -check at **runtime** whether a specific function is available. +functions to the end of the struct without breaking existing callers and simply bumps the minor version. + +`IMPORT_SERVICE` and `IMPORT_OPTIONAL_SERVICE` require the latest minor version compiled against, making every field in +the service safe to call. A mod can use `IMPORT_SERVICE_VERSION` (or its optional counterpart) with an older minor +version to remain compatible with older Dusklight versions, then use `SERVICE_HAS` to check at runtime for fields added +after that explicitly requested version. The contract (see `include/mods/api.h` for the full version): - **A required import is guaranteed valid.** If the service is missing or too old, the mod fails to load with a clear error. No need to null check at call sites. -- **Anything at or below the minor version you imported can be called unconditionally.** +- **Anything at or below the minor version you imported can be called unconditionally.** The default macros import + the service type's current minor version; the versioned macros explicitly override that minimum. - Optional imports may be null; check once in `mod_initialize`. -- Fields newer than your imported minor must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a null - check. +- Fields newer than your imported minor version must be gated behind `SERVICE_HAS(service, ServiceType, field)` plus a + null check. --- @@ -200,13 +204,13 @@ 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). +`get_service`/`publish_service` provide dynamic service lookup; see [Exporting Services](#exporting-services). **Lifecycle watches.** If your mod provides a service that hands out per-caller state (registrations, callbacks, handles), watch other mods' lifecycle and drop what you hold for a mod when it detaches. ```cpp -IMPORT_SERVICE_VERSION(HostService, svc_host, 1); +IMPORT_SERVICE(HostService, svc_host); void on_mod_lifecycle(ModContext* ctx, ModContext* subject, const char* subject_id, ModLifecycleEvent event, void* user_data) { @@ -469,8 +473,14 @@ Mods may hook the vast majority of game functions, including file-local static, #include "mods/svc/hook.h" IMPORT_SERVICE(HookService, svc_hook); + +DEFINE_HOOK(&daAlink_c::posMove, LinkPosMove); +DEFINE_HOOK(&daAlink_c::execute, LinkExecute); ``` +Every hook target must be **declared** at namespace scope with `DEFINE_HOOK` (a target you can name in C++) or +`DEFINE_HOOK_SYMBOL` (a symbol name). + ### Pre-hooks Run before the original. Return `HOOK_SKIP_ORIGINAL` to cancel it (post-hooks still run). @@ -484,7 +494,7 @@ HookAction on_pos_move_pre(ModContext*, void* args, void* retval, void* userdata return HOOK_CONTINUE; } -dusk::mods::hook_add_pre<&daAlink_c::posMove>(svc_hook, on_pos_move_pre); +dusk::mods::hook_add_pre(svc_hook, on_pos_move_pre); ``` ### Post-hooks @@ -495,24 +505,22 @@ if any. ```cpp void on_pos_move_post(ModContext*, void* args, void* retval, void* userdata) { ... } -dusk::mods::hook_add_post<&daAlink_c::posMove>(svc_hook, on_pos_move_post); +dusk::mods::hook_add_post(svc_hook, on_pos_move_post); ``` ### Replace-hooks -Substitute the original entirely. Call through to it via `Hook<...>::g_orig` if needed: +Substitute the original entirely. Call through to it via the declaration's `g_orig` if needed: ```cpp -using ExecuteEntry = dusk::mods::Hook<&daAlink_c::execute>; - void on_execute_replace(ModContext*, void* args, void* retval, void*) { - int result = ExecuteEntry::g_orig(dusk::mods::arg(args, 0)); + int result = LinkExecute::g_orig(dusk::mods::arg(args, 0)); if (retval != nullptr) { *static_cast(retval) = result; } } -dusk::mods::hook_replace<&daAlink_c::execute>(svc_hook, on_execute_replace); +dusk::mods::hook_replace(svc_hook, on_execute_replace); ``` By default a second replace-hook on the same function is a conflict; `HookOptions` (`replace_policy`, `priority`, @@ -525,10 +533,8 @@ Functions you can't name in C++ (file-local statics, private class members, anyt symbol name instead. You must supply the signature along with the name. ```cpp -// static callback used by Link's hookshot collider in d_a_alink_hook.inc -using HookshotHit = dusk::mods::NamedHook< - "daAlink_hookshotAtHitCallBack", - void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*)>; +DEFINE_HOOK_SYMBOL("daAlink_hookshotAtHitCallBack", + void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*), HookshotHit); dusk::mods::hook_add_pre(svc_hook, on_hookshot_hit_pre); ... @@ -537,9 +543,16 @@ HookshotHit::g_orig(link, atObjInf, target, tgObjInf); // call through to the o Class member functions must include `Class*` as the first argument. -The install fails with the resolve error when the name is missing (`MOD_UNAVAILABLE`), ambiguous (`MOD_CONFLICT`), -or the manifest is absent (`MOD_UNSUPPORTED`). Unlike `Hook<&Class::method>`, the signature is **not** -compiler-checked: a mismatched signature will corrupt the call. +Two spellings work on every platform: + +- **Display names** (`daAlink_c::posMove`, `fapGm_Before`): the qualified name with no parameter list. They carry no + signature, so overload sets (and file-local statics sharing a name) return `MOD_CONFLICT`. +- **Decorated names** (`_ZN9daAlink_c7posMoveEv` / `?posMove@daAlink_c@@...`): the platform's mangled spelling in + dlsym convention (no Mach-O leading underscore). The escape hatch for overloads. + +Installing fails with `MOD_UNAVAILABLE` when it didn't resolve (missing, ambiguous, or no symbol manifest). Unlike +`DEFINE_HOOK`, the signature is **not** compiler-checked: a mismatched signature will corrupt the +call. ### Reading and writing arguments @@ -552,6 +565,8 @@ T& ref = dusk::mods::arg_ref(args, n); // read/write reference ``` ```cpp +DEFINE_HOOK(fopAcM_createItem, CreateItem); + // fpc_ProcID fopAcM_createItem(..., int itemNo, ...): turn heart drops into green rupees HookAction on_create_item_pre(ModContext*, void* args, void*, void*) { int& itemNo = dusk::mods::arg_ref(args, 1); @@ -561,36 +576,11 @@ HookAction on_create_item_pre(ModContext*, void* args, void*, void*) { return HOOK_CONTINUE; } -dusk::mods::hook_add_pre<&fopAcM_createItem>(svc_hook, on_create_item_pre); +dusk::mods::hook_add_pre(svc_hook, on_create_item_pre); ``` For reference parameters (e.g. `const cXyz& pos`), `arg_ref` yields a direct reference. -### Resolving symbols by name - -`resolve` looks a symbol up in the **symbol manifest**: a name→address map generated alongside every game build and -keyed to that exact binary. It covers the whole image, including functions that aren't exported (file-local statics), -which makes them hookable: - -```cpp -IMPORT_SERVICE(HookService, svc_hook); - -void* addr = nullptr; -uint32_t flags = 0; -if (svc_hook->resolve(mod_ctx, "GXSetProjection", &addr, &flags) == MOD_OK) { - // addr is the function's real address in the running game; hook or call it. -} -``` - -Two spellings work on every platform: - -- **Display names** (`daAlink_c::posMove`, `fapGm_Before`): the qualified name with no parameter list. They carry no - signature, so overload sets (and file-local statics sharing a name) return `MOD_CONFLICT`. -- **Decorated names** (`_ZN9daAlink_c7posMoveEv` / `?posMove@daAlink_c@@...`): the platform's mangled spelling in - dlsym convention (no Mach-O leading underscore). The escape hatch for overloads. - -`MOD_UNSUPPORTED` means the manifest is missing or was built for a different game binary. - ### Game code ABI contract A primary consideration when letting mods link against the game is maintaining ABI stability across Dusklight @@ -680,7 +670,9 @@ explicit results. --- -## Advanced: Exporting Services +## Advanced + +### Exporting Services Mods may export services of their own, permitting framework mods and cross-mod integration. Define the interface in a header both mods share: @@ -704,6 +696,7 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = MY_MOD_SERVICE_ID; static constexpr uint16_t major_version = MY_MOD_SERVICE_MAJOR; + static constexpr uint16_t minor_version = MY_MOD_SERVICE_MINOR; }; #endif ``` @@ -732,7 +725,7 @@ 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 +#### 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 @@ -762,3 +755,31 @@ For services whose construction can't happen at static-init time, declare the ex 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. + +### Native Runtime Libraries + +`RUNTIME_LIBRARIES` passed to `add_mod` are packaged beside the mod's native module in `lib//`. Dusklight +extracts the whole directory before loading the mod, so libraries linked by the mod resolve normally. The SDK links the +mod itself with `$ORIGIN` on Linux and `@loader_path` on Apple platforms; runtime libraries with their own non-system +dependencies must also be built with origin-relative lookup paths. On Windows, Dusklight uses an isolated DLL search +rooted at this directory. + +```cmake +add_mod(my_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RUNTIME_LIBRARIES "${VENDOR_RUNTIME_LIBRARY}") +``` + +SDKs that load plugins by directory can pass them the absolute runtime path from the current HostService: + +```cpp +IMPORT_SERVICE(HostService, svc_host); + +const char* nativeDir = svc_host->native_dir(mod_ctx); // read-only +``` + +Libraries loaded explicitly by the mod remain its responsibility: stop their threads and unload them during +`mod_shutdown`. Do not write into `native_dir`; use `mod_dir` for writable state. Native library namespaces are +process-wide on some platforms, so two mods cannot safely assume that incompatible libraries with the same filename +will remain isolated. diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 9d77f6984a..74e01a0fdd 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -67,9 +67,32 @@ struct ModSearchDir { std::filesystem::path nativeLibDir; }; +struct ModMetaParsed { + uint32_t abiVersion = 0; + std::vector imports; + std::vector exports; + std::vector hookFns; + std::vector hookMems; + std::vector hookNames; +}; + +inline const char* hook_mem_vtable_symbol(const ModMetaHookMem& rec) { + return reinterpret_cast(&rec) + sizeof(ModMetaHookMem); +} + +inline const char* hook_mem_display_name(const ModMetaHookMem& rec) { + const char* vtable = hook_mem_vtable_symbol(rec); + return vtable + std::char_traits::length(vtable) + 1; +} + +inline const char* hook_name_symbol(const ModMetaHookName& rec) { + return reinterpret_cast(&rec) + sizeof(ModMetaHookName); +} + struct NativeMod { std::unique_ptr handle; - const ModManifest* manifest = nullptr; + const ModMeta* meta = nullptr; + ModMetaParsed parsed; ModContext** contextSymbol = nullptr; ModInitializeFn fn_initialize = nullptr; @@ -111,6 +134,11 @@ enum class NativeModStatus : u8 { */ MissingExport, + /** + * Mod's metadata record section is malformed. + */ + InvalidMetadata, + /** * Unknown error loading the native mod. */ @@ -126,7 +154,7 @@ struct LoadedMod { // Native lib is dlopen'd in place and stays resident for the session. Reload is unsupported. bool inPlace = false; - std::unique_ptr > cvarIsEnabled; + std::unique_ptr> cvarIsEnabled; config::Subscription enabledSubscription = 0; bool active = false; @@ -148,6 +176,8 @@ struct LoadedMod { uint32_t cacheGeneration = 0; // Currently extracted native library, empty if none. std::string nativePath; + // Read-only directory containing the current platform's main module and runtime libraries. + std::string nativeDir; NativeModStatus nativeStatus = NativeModStatus::None; std::unique_ptr native; @@ -197,10 +227,10 @@ private: // 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::string directory; }; - std::vector > m_mods; + std::vector> m_mods; std::vector m_searchDirs; std::filesystem::path m_cacheDir; std::vector m_pendingRequests; @@ -210,7 +240,8 @@ private: 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); + void load_native(LoadedMod& mod, const std::string& dllEntry, + const std::vector& runtimeEntries); // 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); diff --git a/include/mods/api.h b/include/mods/api.h index 85e2f76545..c63d08cfe2 100644 --- a/include/mods/api.h +++ b/include/mods/api.h @@ -23,7 +23,7 @@ extern "C" { #define MOD_EXTERN_C #endif -#define MOD_ABI_VERSION 5u +#define MOD_ABI_VERSION 1u #define MOD_ERROR_MESSAGE_SIZE 512u typedef struct ModContext ModContext; @@ -37,7 +37,8 @@ typedef enum ModResult { MOD_INVALID_ARGUMENT = 5, } ModResult; -static_assert(sizeof(ModResult) == 4, "mod SDK enums must be int-sized; do not build mods with -fshort-enums"); +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; @@ -108,41 +109,134 @@ typedef enum ServiceExportFlags { SERVICE_EXPORT_DEFERRED = 1u << 0u, } ServiceExportFlags; -typedef struct ServiceImport { - uint32_t struct_size; - const char* service_id; +/* + * Mod metadata records. + * + * A mod's manifest is a sequence of records in a dedicated section of the native library ("modmeta" + * on ELF, "__DATA,__modmeta" on Mach-O, "modmeta$a/$d/$z" on PE). + * + * The records are pure data. Every string is inline and NUL-terminated. Fields documented as + * runtime-only hold relocated pointers that are meaningless on disk; static parsers recover their + * targets from the file's relocation/bind entries instead. + * + * Layout rules: + * - Little-endian, 8-byte aligned; every record size is a multiple of 8. + * - Parsers must skip all-zero 8-byte units (linker padding) and records of unknown kind. + * - Exactly one MOD_META_HEADER record per library. + * + * The IMPORT_SERVICE/EXPORT_SERVICE/DEFINE_HOOK macros emit these records; mods do not construct + * them by hand. + */ + +#define MOD_META_SERVICE_ID_SIZE 64u + +/* Records are 8-byte aligned so the linker packs them without padding; most are naturally + * aligned by their pointer fields, the alignment below covers the rest. */ +#if defined(__cplusplus) +#define MOD_META_ALIGN alignas(8) +#elif defined(_MSC_VER) +#define MOD_META_ALIGN __declspec(align(8)) +#else +#define MOD_META_ALIGN __attribute__((aligned(8))) +#endif + +typedef enum ModMetaKind { + MOD_META_PAD = 0, + MOD_META_HEADER = 1, + MOD_META_IMPORT = 2, + MOD_META_EXPORT = 3, + MOD_META_HOOK_FN = 4, + MOD_META_HOOK_MEM = 5, + MOD_META_HOOK_NAME = 6, +} ModMetaKind; + +typedef struct ModMetaRecord { + uint16_t size; /* total record size in bytes, a multiple of 8 */ + uint8_t kind; /* ModMetaKind */ + uint8_t flags; /* ServiceImportFlags / ServiceExportFlags for imports/exports */ +} ModMetaRecord; + +typedef struct ModMetaServiceId { + char chars[MOD_META_SERVICE_ID_SIZE]; /* NUL-terminated */ +} ModMetaServiceId; + +typedef struct MOD_META_ALIGN ModMetaHeader { + ModMetaRecord rec; + uint32_t abi_version; +} ModMetaHeader; + +static_assert(sizeof(ModMetaHeader) == 8); + +typedef struct MOD_META_ALIGN ModMetaImport { + ModMetaRecord rec; uint16_t major_version; uint16_t min_minor_version; - uint32_t flags; - void* slot; -} ServiceImport; + void* slot; /* runtime only */ + ModMetaServiceId service_id; +} ModMetaImport; -typedef struct ServiceExport { - uint32_t struct_size; - const char* service_id; +static_assert(sizeof(ModMetaImport) == 16 + MOD_META_SERVICE_ID_SIZE); + +typedef struct MOD_META_ALIGN ModMetaExport { + ModMetaRecord rec; uint16_t major_version; uint16_t minor_version; - uint32_t flags; - const void* service; -} ServiceExport; + const void* service; /* runtime only */ + ModMetaServiceId service_id; +} ModMetaExport; -typedef struct ModManifest { +static_assert(sizeof(ModMetaExport) == 16 + MOD_META_SERVICE_ID_SIZE); + +/* Hook on a function named at link time: `target` carries the &fn relocation. */ +typedef struct MOD_META_ALIGN ModMetaHookFn { + ModMetaRecord rec; + uint32_t reserved; + void* target; /* runtime only */ + void* resolved; /* runtime only */ +} ModMetaHookFn; + +static_assert(sizeof(ModMetaHookFn) == 24); + +/* + * Hook on a member function: `pmf` holds the compiler's pointer-to-member representation + * (non-virtual: a function address relocation; virtual Itanium/AAPCS: literal slot words). Two + * NUL-terminated strings follow `resolved`: the class vtable symbol (empty if the class name is not + * representable), then the stringified target for tooling display. + */ +typedef struct MOD_META_ALIGN ModMetaHookMem { + ModMetaRecord rec; + uint32_t reserved; + unsigned char pmf[16]; + void* resolved; /* runtime only */ +} ModMetaHookMem; + +static_assert(sizeof(ModMetaHookMem) == 32); + +/* + * Hook on a function by symbol name, for targets that cannot be named in C++ (file-local statics, + * private members). One NUL-terminated string follows `resolved`; it may be either the platform + * mangled name or the demangled qualified display name. + */ +typedef struct MOD_META_ALIGN ModMetaHookName { + ModMetaRecord rec; + uint32_t reserved; + void* resolved; /* runtime only */ +} ModMetaHookName; + +static_assert(sizeof(ModMetaHookName) == 16); + +typedef struct ModMeta { uint32_t struct_size; - uint32_t abi_version; - const ServiceImport* imports; - size_t import_count; - const ServiceExport* exports; - size_t export_count; -} ModManifest; + const void* records_begin; + const void* records_end; +} ModMeta; -typedef const ModManifest* (*ModGetManifestFn)(void); +MOD_EXPORT extern const ModMeta mod_meta; 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); diff --git a/include/mods/hook.hpp b/include/mods/hook.hpp index c58bd719c9..94d5bce797 100644 --- a/include/mods/hook.hpp +++ b/include/mods/hook.hpp @@ -2,11 +2,7 @@ #include "mods/svc/hook.h" -#include -#include -#include #include -#include #include namespace dusk::mods { @@ -14,255 +10,20 @@ namespace dusk::mods { template T arg(void* argsRaw, int n) noexcept { void** args = static_cast(argsRaw); - return *static_cast > >(args[n]); + return *static_cast>>(args[n]); } template std::remove_reference_t& arg_ref(void* argsRaw, int n) noexcept { void** args = static_cast(argsRaw); - return *static_cast > >(args[n]); + return *static_cast>>(args[n]); } -template -void* mfp_addr(F fn) noexcept { - void* p = nullptr; - static_assert(sizeof(fn) >= sizeof(void*), "unexpected function pointer size"); - std::memcpy(&p, &fn, sizeof(void*)); - return p; -} - -/* A string usable as a template argument: carries the hook target's symbol name and - * makes each NamedHook instantiation's static state unique. */ -template -struct FixedString { - char chars[N]{}; - constexpr FixedString(const char (&s)[N]) noexcept { - for (size_t i = 0; i < N; ++i) { - chars[i] = s[i]; - } - } -}; - -namespace detail { - -template -constexpr std::string_view class_name() { -#if defined(__clang__) || defined(__GNUC__) - // "... class_name() [T = daAlink_c]" / "... [with T = daAlink_c; ...]" - constexpr std::string_view fn = __PRETTY_FUNCTION__; - constexpr size_t start = fn.find("T = ") + 4; - return fn.substr(start, fn.find_first_of(";]", start) - start); -#elif defined(_MSC_VER) - // "... class_name(void)" - constexpr std::string_view fn = __FUNCSIG__; - constexpr size_t start = fn.find("class_name<") + 11; - constexpr std::string_view name = fn.substr(start, fn.rfind(">(") - start); - if constexpr (name.starts_with("class ")) { - return name.substr(6); - } else if constexpr (name.starts_with("struct ")) { - return name.substr(7); - } else { - return name; - } -#else -#error "unsupported compiler" -#endif -} - -/* The manifest name of C's vtable. Only unscoped, non-template class names are - * supported (an empty result fails resolution and the install reports it). */ -template -constexpr auto vtable_symbol() { - constexpr std::string_view name = class_name(); - constexpr bool simple = name.find_first_of(":<> ") == std::string_view::npos; - // "_ZTV" + decimal length + name / "??_7" + name + "@@6B@", NUL-terminated - std::array out{}; - if constexpr (!simple) { - return out; - } - size_t n = 0; -#if defined(_WIN32) - for (char c : {'?', '?', '_', '7'}) { - out[n++] = c; - } - for (char c : name) { - out[n++] = c; - } - for (char c : {'@', '@', '6', 'B', '@'}) { - out[n++] = c; - } -#else - for (char c : {'_', 'Z', 'T', 'V'}) { - out[n++] = c; - } - size_t len = name.size(); - char digits[8]{}; - size_t d = 0; - while (len != 0) { - digits[d++] = static_cast('0' + len % 10); - len /= 10; - } - while (d != 0) { - out[n++] = digits[--d]; - } - for (char c : name) { - out[n++] = c; - } -#endif - return out; -} - -#if defined(_WIN32) -/* Follow jump stubs, then match the MSVC vcall thunk a virtual mfp points at. - * Returns the vtable slot's byte offset, or npos when fn is not a vcall thunk. */ -inline size_t vcall_slot_offset(const void*& fn) noexcept { - constexpr size_t npos = static_cast(-1); -#if defined(_M_X64) || defined(__x86_64__) - const auto* p = static_cast(fn); - for (int i = 0; i < 8 && p[0] == 0xE9; ++i) { // incremental-link stubs - int32_t rel; - std::memcpy(&rel, p + 1, 4); - p += 5 + rel; - } - fn = p; - // The vptr load. Unoptimized clang-cl thunks spill/reload rcx first - // (push rax; mov [rsp], rcx; mov rcx, [rsp]), so scan a short window. - const uint8_t* q = nullptr; - for (int i = 0; i <= 12; ++i) { - if (p[i] == 0x48 && p[i + 1] == 0x8B && p[i + 2] == 0x01) { // mov rax, [rcx] - q = p + i + 3; - break; - } - } - if (q == nullptr) { - return npos; - } - if (q[0] == 0xFF && q[1] == 0x20) { // jmp [rax] (MSVC) - return 0; - } - if (q[0] == 0xFF && q[1] == 0x60) { // jmp [rax + imm8] - return static_cast(q[2]); - } - if (q[0] == 0xFF && q[1] == 0xA0) { // jmp [rax + imm32] - int32_t off; - std::memcpy(&off, q + 2, 4); - return off; - } - // clang-cl: mov rax, [rax + off]; (pop r10;) jmp rax. Requiring the jmp rax - // distinguishes the thunk from an ordinary getter that begins the same way. - if (q[0] == 0x48 && q[1] == 0x8B && (q[2] == 0x00 || q[2] == 0x40 || q[2] == 0x80)) { - size_t off = 0; - const uint8_t* r = q + 3; - if (q[2] == 0x40) { - off = static_cast(q[3]); - r = q + 4; - } else if (q[2] == 0x80) { - int32_t off32; - std::memcpy(&off32, q + 3, 4); - off = off32; - r = q + 7; - } - for (int i = 0; i <= 8; ++i) { - if (r[i] == 0xFF && r[i + 1] == 0xE0) { // jmp rax (48 REX optional) - return off; - } - } - } - return npos; -#elif defined(_M_ARM64) || defined(__aarch64__) - const auto* p = static_cast(fn); - uint32_t insn[3]; - for (int i = 0; i < 8; ++i) { // incremental-link `b` stubs - std::memcpy(insn, p, 4); - if ((insn[0] & 0xFC000000u) != 0x14000000u) { - break; - } - const auto imm26 = static_cast(insn[0] << 6) >> 6; - p += static_cast(imm26) * 4; - } - fn = p; - std::memcpy(insn, p, 12); - // ldr Xt, [x0]; ldr Xs, [Xt, #imm12*8]; br Xs - if ((insn[0] & 0xFFFFFFE0u) != 0xF9400000u) { - return npos; - } - const uint32_t t = insn[0] & 0x1Fu; - if ((insn[1] & 0xFFC003E0u) != (0xF9400000u | (t << 5))) { - return npos; - } - const uint32_t s = insn[1] & 0x1Fu; - if (insn[2] != (0xD61F0000u | (s << 5))) { - return npos; - } - return ((insn[1] >> 10) & 0xFFFu) * 8; -#else - (void)fn; - return npos; -#endif -} -#endif - -/* Code address of the member function a mfp designates. Virtual mfps don't carry - * one; recover it from the class's vtable (resolved from the symbol manifest), so - * Hook works uniformly on virtual and non-virtual members. */ -template -ModResult member_target(const HookService* hooks, F mfp, void** out) { - *out = nullptr; - uintptr_t words[sizeof(F) > sizeof(uintptr_t) ? 2 : 1] = {}; - std::memcpy(words, &mfp, sizeof(words) < sizeof(F) ? sizeof(words) : sizeof(F)); - -#if defined(_WIN32) - const void* fn = reinterpret_cast(words[0]); - const size_t slot = vcall_slot_offset(fn); - if (slot == static_cast(-1)) { // not a vcall thunk: direct address - *out = const_cast(fn); - return MOD_OK; - } - void* vtable = nullptr; - const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol().data(), &vtable, nullptr); - if (resolved != MOD_OK) { - return resolved; - } - // ??_7 points at the first slot. - *out = *reinterpret_cast(static_cast(vtable) + slot); -#else -#if defined(__aarch64__) || defined(__arm__) - // AAPCS C++ ABI: the virtual flag is bit 0 of the adjustment word (function - // addresses can't spare their low bit), and ptr holds the slot offset directly. - const bool isVirtual = (words[1] & 1) != 0; - const uintptr_t thisAdjust = words[1] >> 1; - const uintptr_t slotOffset = words[0]; -#else - // Itanium C++ ABI: virtual mfps set bit 0 of ptr; the slot offset is ptr - 1. - const bool isVirtual = (words[0] & 1) != 0; - const uintptr_t thisAdjust = words[1]; - const uintptr_t slotOffset = words[0] - 1; -#endif - if (!isVirtual) { // non-virtual: the address itself - *out = reinterpret_cast(words[0]); - return MOD_OK; - } - if (thisAdjust != 0) { - // this-adjusting mfp (member of a secondary base): the slot offset is - // relative to a vtable we can't locate. Hook the overrider by name instead. - return MOD_UNSUPPORTED; - } - void* vtable = nullptr; - const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol().data(), &vtable, nullptr); - if (resolved != MOD_OK) { - return resolved; - } - // _ZTV points at the offset-to-top slot; the address point mfps index from is - // two pointers in (past offset-to-top and the typeinfo pointer). - *out = *reinterpret_cast(static_cast(vtable) + 2 * sizeof(void*) + slotOffset); -#endif - return *out != nullptr ? MOD_OK : MOD_UNAVAILABLE; -} - -} // namespace detail - -/* Trampoline generator + per-target state shared by Hook and NamedHook. Tag makes - * each hooked target's statics distinct; the target address is filled in at install. */ +/* + * Trampoline generator + per-target state. Tag makes each hooked target's statics distinct; the + * target address comes from the declaration's metadata record, resolved by the host at mod + * initialization. + */ template struct HookImpl { static inline R (*g_orig)(A...) = nullptr; @@ -333,60 +94,64 @@ struct NameTag {}; } // namespace detail /* - * Typed hook on a function named at compile time (&daAlink_c::execute, &free_fn). - * Member functions may be virtual: the install decodes the member function pointer and hooks the - * class's own overrider. + * Typed base for a hook on a function named at compile time (&daAlink_c::execute, &free_fn). + * Instantiate through DEFINE_HOOK, which pairs it with the metadata record the host resolves. */ template struct Hook; template -struct Hook : HookImpl, R, C*, A...> { - static ModResult resolve_target(const HookService* hooks, void** out) { - return detail::member_target(hooks, Target, out); - } -}; +struct Hook : HookImpl, R, C*, A...> {}; template -struct Hook : HookImpl, R, const C*, A...> { - static ModResult resolve_target(const HookService* hooks, void** out) { - return detail::member_target(hooks, Target, out); - } -}; +struct Hook : HookImpl, R, const C*, A...> {}; template -struct Hook : HookImpl, R, A...> { - static ModResult resolve_target(const HookService*, void** out) { - *out = mfp_addr(Target); - return MOD_OK; - } -}; +struct Hook : HookImpl, R, A...> {}; /* - * Typed hook on a function by its symbol name, for targets you can't name in C++: file-local - * statics, private members, or symbols without a header. The signature is written free-style with - * the receiver first and is *not* compiler-checked. - * - * using HookshotHit = dusk::mods::NamedHook< - * "daAlink_hookshotAtHitCallBack", - * void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*)>; - * dusk::mods::hook_add_pre(svc_hook, on_hookshot_hit); + * Typed base for a hook on a function by its symbol name, for targets you can't name in C++: + * file-local statics, private members, or symbols without a header. The signature is written + * free-style with the receiver first and is *not* compiler-checked. Instantiate through + * DEFINE_HOOK_SYMBOL. */ template struct NamedHook; template -struct NamedHook : HookImpl, R, A...> { - static ModResult resolve_target(const HookService* hooks, void** out) { - HookSymbolFlags flags{}; - const ModResult resolved = hooks->resolve(mod_ctx, Name.chars, out, &flags); - if (resolved == MOD_OK && (flags & HOOK_SYMBOL_CODE) == 0) { - *out = nullptr; - return MOD_INVALID_ARGUMENT; - } - return resolved; +struct NamedHook : HookImpl, R, A...> {}; + +/* + * Declare a hook target. The declaration emits a metadata record that the host resolves at mod + * initialization. Every hook target must be declared. + * + * DEFINE_HOOK(&daAlink_c::execute, LinkExecute); + * DEFINE_HOOK_SYMBOL("daAlink_hookshotAtHitCallBack", + * void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*), HookshotHit); + * + * dusk::mods::hook_add_pre(svc_hook, on_link_execute); + * + * DEFINE_HOOK_SYMBOL names may be the platform mangled name (dlopen convention, no Mach-O + * leading underscore) or the demangled qualified display name; overloaded display names are + * ambiguous and need the mangled form. + */ +#define DEFINE_HOOK(target, alias) \ + [[maybe_unused]] static const void* const mod_meta_hook_##alias = \ + &::dusk::mods::detail::HookRecordFor<(target), \ + ::dusk::mods::FixedString{#target}>::Holder::record; \ + struct alias : ::dusk::mods::Hook<(target)> { \ + static void* resolved_target() { \ + return ::dusk::mods::detail::HookRecordFor<(target), \ + ::dusk::mods::FixedString{#target}>::Holder::record.resolved; \ + } \ + } + +#define DEFINE_HOOK_SYMBOL(name, sig, alias) \ + MOD_META_RECORD static constinit auto mod_meta_hook_##alias = \ + ::dusk::mods::detail::make_hook_name_record<::dusk::mods::FixedString{name}>(); \ + struct alias : ::dusk::mods::NamedHook<::dusk::mods::FixedString{name}, sig> { \ + static void* resolved_target() { return mod_meta_hook_##alias.resolved; } \ } -}; template ModResult hook_install(const HookService* hooks) { @@ -396,20 +161,16 @@ ModResult hook_install(const HookService* hooks) { Entry::hooks = hooks; if (Entry::target == nullptr) { - const ModResult resolved = Entry::resolve_target(hooks, &Entry::target); - if (resolved != MOD_OK) { - return resolved; + void* resolved = Entry::resolved_target(); + if (resolved == nullptr) { + return MOD_UNAVAILABLE; } + Entry::target = resolved; } return hooks->install(mod_ctx, Entry::target, reinterpret_cast(Entry::trampoline), reinterpret_cast(&Entry::g_orig)); } -template -ModResult hook_install(const HookService* hooks) { - return hook_install >(hooks); -} - template ModResult hook_add_pre( const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) { @@ -421,12 +182,6 @@ ModResult hook_add_pre( return hooks->add_pre(mod_ctx, Entry::target, callback, options); } -template -ModResult hook_add_pre( - const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) { - return hook_add_pre >(hooks, callback, options); -} - template ModResult hook_add_post( const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) { @@ -438,12 +193,6 @@ ModResult hook_add_post( return hooks->add_post(mod_ctx, Entry::target, callback, options); } -template -ModResult hook_add_post( - const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) { - return hook_add_post >(hooks, callback, options); -} - template ModResult hook_replace( const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) { @@ -455,10 +204,4 @@ ModResult hook_replace( return hooks->replace(mod_ctx, Entry::target, callback, options); } -template -ModResult hook_replace( - const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) { - return hook_replace >(hooks, callback, options); -} - } // namespace dusk::mods diff --git a/include/mods/meta.hpp b/include/mods/meta.hpp new file mode 100644 index 0000000000..1f533feaa7 --- /dev/null +++ b/include/mods/meta.hpp @@ -0,0 +1,287 @@ +#pragma once + +#include "mods/api.h" + +#include +#include +#include +#include +#include + +/* + * modmeta records. Each IMPORT_SERVICE/EXPORT_SERVICE/DEFINE_HOOK use places one + * constant-initialized record object in the metadata section. + */ +#if defined(_WIN32) +#pragma section("modmeta$a", read, write) +#pragma section("modmeta$d", read, write) +#pragma section("modmeta$z", read, write) +#if defined(__clang__) +#define MOD_META_RECORD __declspec(allocate("modmeta$d")) __attribute__((used)) +#else +#define MOD_META_RECORD __declspec(allocate("modmeta$d")) +#endif +#elif defined(__APPLE__) +#define MOD_META_RECORD __attribute__((section("__DATA,__modmeta"), used)) +#elif defined(__has_attribute) && __has_attribute(retain) +#define MOD_META_RECORD __attribute__((section("modmeta"), used, retain)) +#else +#define MOD_META_RECORD __attribute__((section("modmeta"), used)) +#endif + +/* Section bounds for the mod_meta descriptor */ +#if defined(_WIN32) +#define MOD_META_BOUNDS_DEFN \ + extern "C" { \ + __declspec(allocate("modmeta$a")) constinit unsigned long long mod_meta_bounds_begin = 0; \ + __declspec(allocate("modmeta$z")) constinit unsigned long long mod_meta_bounds_end = 0; \ + } +#define MOD_META_BOUNDS_BEGIN (&mod_meta_bounds_begin) +#define MOD_META_BOUNDS_END (&mod_meta_bounds_end) +#elif defined(__APPLE__) +extern "C" const unsigned char mod_meta_bounds_begin[] __asm("section$start$__DATA$__modmeta"); +extern "C" const unsigned char mod_meta_bounds_end[] __asm("section$end$__DATA$__modmeta"); +#define MOD_META_BOUNDS_DEFN +#define MOD_META_BOUNDS_BEGIN (mod_meta_bounds_begin) +#define MOD_META_BOUNDS_END (mod_meta_bounds_end) +#else +extern "C" const unsigned char __start_modmeta[]; +extern "C" const unsigned char __stop_modmeta[]; +#define MOD_META_BOUNDS_DEFN +#define MOD_META_BOUNDS_BEGIN (__start_modmeta) +#define MOD_META_BOUNDS_END (__stop_modmeta) +#endif + +namespace dusk::mods { + +/* A string usable as a template argument: carries a symbol/target name into record builders + * and makes each hook declaration's static state unique. */ +template +struct FixedString { + char chars[N]{}; + constexpr FixedString(const char (&s)[N]) noexcept { + for (size_t i = 0; i < N; ++i) { + chars[i] = s[i]; + } + } +}; + +namespace detail { + +template +constexpr std::string_view class_name() { +#if defined(__clang__) || defined(__GNUC__) + // "... class_name() [T = daAlink_c]" / "... [with T = daAlink_c; ...]" + constexpr std::string_view fn = __PRETTY_FUNCTION__; + constexpr size_t start = fn.find("T = ") + 4; + return fn.substr(start, fn.find_first_of(";]", start) - start); +#elif defined(_MSC_VER) + // "... class_name(void)" + constexpr std::string_view fn = __FUNCSIG__; + constexpr size_t start = fn.find("class_name<") + 11; + constexpr std::string_view name = fn.substr(start, fn.rfind(">(") - start); + if constexpr (name.starts_with("class ")) { + return name.substr(6); + } else if constexpr (name.starts_with("struct ")) { + return name.substr(7); + } else { + return name; + } +#else +#error "unsupported compiler" +#endif +} + +/* The symbol name of C's vtable. Only unscoped, non-template class names are supported (an + * empty result makes hooks on virtual members of C fail resolution, which is reported). */ +template +constexpr auto vtable_symbol() { + constexpr std::string_view name = class_name(); + constexpr bool simple = name.find_first_of(":<> ") == std::string_view::npos; + // "_ZTV" + decimal length + name / "??_7" + name + "@@6B@", NUL-terminated + std::array out{}; + if constexpr (!simple) { + return out; + } + size_t n = 0; +#if defined(_WIN32) + for (char c : {'?', '?', '_', '7'}) { + out[n++] = c; + } + for (char c : name) { + out[n++] = c; + } + for (char c : {'@', '@', '6', 'B', '@'}) { + out[n++] = c; + } +#else + for (char c : {'_', 'Z', 'T', 'V'}) { + out[n++] = c; + } + size_t len = name.size(); + char digits[8]{}; + size_t d = 0; + while (len != 0) { + digits[d++] = static_cast('0' + len % 10); + len /= 10; + } + while (d != 0) { + out[n++] = digits[--d]; + } + for (char c : name) { + out[n++] = c; + } +#endif + return out; +} + +template +struct member_traits; +template +struct member_traits { + using Class = C; +}; +template +struct member_traits { + using Class = C; +}; + +consteval ModMetaServiceId make_service_id(const char* id) { + ModMetaServiceId out{}; + size_t n = 0; + for (; id[n] != '\0'; ++n) { + if (n + 1 >= MOD_META_SERVICE_ID_SIZE) { + throw "service id exceeds MOD_META_SERVICE_ID_SIZE"; + } + out.chars[n] = id[n]; + } + return out; +} + +consteval ModMetaHeader make_header() { + ModMetaHeader r{}; + r.rec = {sizeof(ModMetaHeader), MOD_META_HEADER, 0}; + r.abi_version = MOD_ABI_VERSION; + return r; +} + +/* + * Typed record variants: embedding the target as its native type makes the compiler emit the + * on-disk representation (relocations, PMF slot words) that static parsers read; the layouts + * match the byte-view structs in api.h. + */ + +template +struct HookFnRecord { + ModMetaRecord rec; + uint32_t reserved; + F target; + void* resolved; +}; + +template +struct HookMemRecord { + ModMetaRecord rec; + uint32_t reserved; + union { + F fn; + unsigned char raw[16]; + } pmf; + void* resolved; + char names[N]; +}; + +template +struct HookNameRecord { + ModMetaRecord rec; + uint32_t reserved; + void* resolved; + char name[N]; +}; + +template +constexpr size_t align_up(size_t n) { + return (n + (N - 1)) & ~(N - 1); +} + +template +struct HookMemNames { + char chars[N]{}; + size_t len{}; +}; + +template +consteval auto make_hook_mem_names() { + using C = member_traits::Class; + // Strip the leading '&' of the stringified target expression for display. + constexpr size_t dispFrom = Disp.chars[0] == '&' ? 1 : 0; + constexpr size_t dispLen = sizeof(Disp.chars) - 1 - dispFrom; + constexpr auto vtbl = vtable_symbol(); + constexpr size_t vtblLen = std::string_view{vtbl.data()}.size(); + HookMemNames(vtblLen + 1 + dispLen + 1)> r{}; + size_t at = 0; + for (size_t i = 0; i < vtblLen; ++i) { + r.chars[at++] = vtbl[i]; + } + r.chars[at++] = '\0'; + for (size_t i = 0; i < dispLen; ++i) { + r.chars[at++] = Disp.chars[dispFrom + i]; + } + r.len = sizeof(r.chars); + return r; +} + +/* + * MSVC constant-evaluates a pointer-to-member only when every other operand in the + * initializer is a literal: no consteval calls, constexpr-object copies, or default + * member initializers. + */ +template +struct HookMemHolder { + using F = decltype(Target); + static_assert(sizeof(F) <= 16, "unsupported pointer-to-member representation"); + MOD_META_RECORD static constinit inline HookMemRecord record = { + {sizeof(HookMemRecord), MOD_META_HOOK_MEM, 0}, 0, {Target}, nullptr, + {Cs...}}; +}; + +template +struct HookFnHolder { + using F = decltype(Target); + static_assert(std::is_pointer_v && std::is_function_v>, + "hook target must be a function or member function"); + MOD_META_RECORD static constinit inline HookFnRecord record = { + {sizeof(HookFnRecord), MOD_META_HOOK_FN, 0}, 0, Target, nullptr}; +}; + +template > +struct HookRecordFor { + using Holder = HookFnHolder; +}; + +template +struct HookRecordFor { + template + struct Bind; + template + struct Bind> { + using Type = HookMemHolder().chars[Is]...>; + }; + using Holder = + Bind().len>>::Type; +}; + +template +consteval auto make_hook_name_record() { + constexpr size_t len = sizeof(Name.chars) - 1; + HookNameRecord(len + 1)> r{}; + r.rec = {sizeof(r), MOD_META_HOOK_NAME, 0}; + for (size_t i = 0; i < len; ++i) { + r.name[i] = Name.chars[i]; + } + return r; +} + +} // namespace detail +} // namespace dusk::mods diff --git a/include/mods/service.hpp b/include/mods/service.hpp index d79b53f24d..f5ba322c45 100644 --- a/include/mods/service.hpp +++ b/include/mods/service.hpp @@ -1,60 +1,17 @@ #pragma once #include "mods/api.h" +#include "mods/meta.hpp" #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; @@ -71,33 +28,41 @@ inline ModResult set_error(ModError* outError, ModResult code, const char* messa #define DEFINE_MOD() \ extern "C" { \ MOD_EXPORT ModContext* mod_ctx = nullptr; \ - MOD_EXPORT const ModManifest* mod_get_manifest(void) { \ - return ::dusk::mods::detail::manifest(); \ } \ + MOD_META_RECORD static constinit ModMetaHeader mod_meta_header_record = \ + ::dusk::mods::detail::make_header(); \ + MOD_META_BOUNDS_DEFN \ + extern "C" { \ + MOD_EXPORT constinit const ModMeta mod_meta = { \ + sizeof(ModMeta), \ + MOD_META_BOUNDS_BEGIN, \ + MOD_META_BOUNDS_END, \ + }; \ } // 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. +// must be checked against nullptr before use. The unversioned macros use the latest minor version; +// set an explicit version to target an older minor version for backwards compatibility. #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), \ - }) + MOD_META_RECORD static constinit ModMetaImport mod_meta_import_##variable = { \ + {sizeof(ModMetaImport), MOD_META_IMPORT, static_cast(flags_value)}, \ + static_cast(major_value), \ + static_cast(min_minor_value), \ + &(variable), \ + ::dusk::mods::detail::make_service_id(service_id_value), \ + } #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_SERVICE(service_type, variable) \ + IMPORT_SERVICE_VERSION( \ + service_type, variable, ::dusk::mods::ServiceTraits::minor_version) #define IMPORT_OPTIONAL_SERVICE_VERSION(service_type, variable, min_minor_value) \ IMPORT_SERVICE_EX(service_type, variable, ::dusk::mods::ServiceTraits::id, \ @@ -105,34 +70,27 @@ inline ModResult set_error(ModError* outError, ModResult code, const char* messa SERVICE_IMPORT_OPTIONAL) #define IMPORT_OPTIONAL_SERVICE(service_type, variable) \ - IMPORT_OPTIONAL_SERVICE_VERSION(service_type, variable, 0) + IMPORT_OPTIONAL_SERVICE_VERSION( \ + service_type, variable, ::dusk::mods::ServiceTraits::minor_version) #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), \ - }); \ + MOD_META_RECORD static constinit ModMetaExport mod_meta_export_##instance = { \ + {sizeof(ModMetaExport), MOD_META_EXPORT, SERVICE_EXPORT_STATIC}, \ + (instance).header.major_version, \ + (instance).header.minor_version, \ + &(instance), \ + ::dusk::mods::detail::make_service_id(service_id_value), \ } #define EXPORT_SERVICE(instance) \ EXPORT_SERVICE_AS( \ - instance, ::dusk::mods::ServiceTraits >::id) + 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, \ - }); \ + MOD_META_RECORD static constinit ModMetaExport mod_meta_export_##token = { \ + {sizeof(ModMetaExport), MOD_META_EXPORT, SERVICE_EXPORT_DEFERRED}, \ + static_cast(major_value), \ + static_cast(minor_value), \ + nullptr, \ + ::dusk::mods::detail::make_service_id(service_id_value), \ } diff --git a/include/mods/svc/camera.h b/include/mods/svc/camera.h index f974cec152..884aadef1e 100644 --- a/include/mods/svc/camera.h +++ b/include/mods/svc/camera.h @@ -60,5 +60,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = CAMERA_SERVICE_ID; static constexpr uint16_t major_version = CAMERA_SERVICE_MAJOR; + static constexpr uint16_t minor_version = CAMERA_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/config.h b/include/mods/svc/config.h index 2a26210295..4275312201 100644 --- a/include/mods/svc/config.h +++ b/include/mods/svc/config.h @@ -103,5 +103,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = CONFIG_SERVICE_ID; static constexpr uint16_t major_version = CONFIG_SERVICE_MAJOR; + static constexpr uint16_t minor_version = CONFIG_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/game.h b/include/mods/svc/game.h index fbd914d4bc..116d6fc316 100644 --- a/include/mods/svc/game.h +++ b/include/mods/svc/game.h @@ -26,5 +26,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = GAME_SERVICE_ID; static constexpr uint16_t major_version = GAME_SERVICE_MAJOR; + static constexpr uint16_t minor_version = GAME_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/gfx.h b/include/mods/svc/gfx.h index a9812fd044..d87255e978 100644 --- a/include/mods/svc/gfx.h +++ b/include/mods/svc/gfx.h @@ -205,5 +205,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = GFX_SERVICE_ID; static constexpr uint16_t major_version = GFX_SERVICE_MAJOR; + static constexpr uint16_t minor_version = GFX_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/hook.h b/include/mods/svc/hook.h index d1a75fa6cc..00760d2945 100644 --- a/include/mods/svc/hook.h +++ b/include/mods/svc/hook.h @@ -121,5 +121,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = HOOK_SERVICE_ID; static constexpr uint16_t major_version = HOOK_SERVICE_MAJOR; + static constexpr uint16_t minor_version = HOOK_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/host.h b/include/mods/svc/host.h index 43c19cadfb..b0b278612c 100644 --- a/include/mods/svc/host.h +++ b/include/mods/svc/host.h @@ -9,7 +9,7 @@ #define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" #define HOST_SERVICE_MAJOR 2u -#define HOST_SERVICE_MINOR 0u +#define HOST_SERVICE_MINOR 1u /* * Ignore unknown values: later service minors may add events. @@ -91,6 +91,16 @@ typedef struct HostService { ModResult (*watch_mod_lifecycle)( ModContext* ctx, ModLifecycleFn fn, void* user_data, uint64_t* out_handle); ModResult (*unwatch_mod_lifecycle)(ModContext* ctx, uint64_t handle); + + /* + * Read-only directory containing this platform's packaged native runtime: the mod module + * and any RUNTIME_LIBRARIES. The path is absolute and remains valid until mod_shutdown + * returns. Libraries loaded dynamically from here are owned by the mod and must be unloaded + * during mod_shutdown. + * + * Added in minor version 1. + */ + const char* (*native_dir)(ModContext* ctx); } HostService; #ifdef __cplusplus @@ -100,5 +110,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = HOST_SERVICE_ID; static constexpr uint16_t major_version = HOST_SERVICE_MAJOR; + static constexpr uint16_t minor_version = HOST_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/log.h b/include/mods/svc/log.h index bad94f9503..b25780ed4a 100644 --- a/include/mods/svc/log.h +++ b/include/mods/svc/log.h @@ -43,5 +43,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = LOG_SERVICE_ID; static constexpr uint16_t major_version = LOG_SERVICE_MAJOR; + static constexpr uint16_t minor_version = LOG_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/overlay.h b/include/mods/svc/overlay.h index 9fc86fc850..bf6fd7b975 100644 --- a/include/mods/svc/overlay.h +++ b/include/mods/svc/overlay.h @@ -53,5 +53,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = OVERLAY_SERVICE_ID; static constexpr uint16_t major_version = OVERLAY_SERVICE_MAJOR; + static constexpr uint16_t minor_version = OVERLAY_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/resource.h b/include/mods/svc/resource.h index 1173b2ab1d..602ab1a25f 100644 --- a/include/mods/svc/resource.h +++ b/include/mods/svc/resource.h @@ -48,5 +48,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = RESOURCE_SERVICE_ID; static constexpr uint16_t major_version = RESOURCE_SERVICE_MAJOR; + static constexpr uint16_t minor_version = RESOURCE_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/texture.h b/include/mods/svc/texture.h index d664c5f99d..1c7a944cba 100644 --- a/include/mods/svc/texture.h +++ b/include/mods/svc/texture.h @@ -84,5 +84,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = TEXTURE_SERVICE_ID; static constexpr uint16_t major_version = TEXTURE_SERVICE_MAJOR; + static constexpr uint16_t minor_version = TEXTURE_SERVICE_MINOR; }; #endif diff --git a/include/mods/svc/ui.h b/include/mods/svc/ui.h index d9f2ac669e..676706a739 100644 --- a/include/mods/svc/ui.h +++ b/include/mods/svc/ui.h @@ -280,5 +280,6 @@ template <> struct dusk::mods::ServiceTraits { static constexpr const char* id = UI_SERVICE_ID; static constexpr uint16_t major_version = UI_SERVICE_MAJOR; + static constexpr uint16_t minor_version = UI_SERVICE_MINOR; }; #endif diff --git a/mods/shadow_mod/src/mod.cpp b/mods/shadow_mod/src/mod.cpp index 0eec4fecef..4e3470c9be 100644 --- a/mods/shadow_mod/src/mod.cpp +++ b/mods/shadow_mod/src/mod.cpp @@ -93,10 +93,15 @@ constexpr float kMaxLightLookahead = 10000.0f; constexpr float kSunMoonDistance = 80000.0f; constexpr float kSunMoonZDistance = -48000.0f; -using ClipperSphereClip = int (J3DUClipper::*)(f32 const (*)[4], Vec, f32) const; -using ClipperBoxClip = int (J3DUClipper::*)(f32 const (*)[4], Vec*, Vec*) const; -constexpr ClipperSphereClip kClipperSphereClip = static_cast(&J3DUClipper::clip); -constexpr ClipperBoxClip kClipperBoxClip = static_cast(&J3DUClipper::clip); +DEFINE_HOOK(&dDlst_shadowControl_c::imageDraw, GameShadowImageDraw); +DEFINE_HOOK(&dDlst_shadowControl_c::draw, GameShadowDraw); +DEFINE_HOOK(&drawCloudShadow, CloudShadowDraw); +DEFINE_HOOK(static_cast(&J3DUClipper::clip), + ClipperSphereClip); +DEFINE_HOOK( + static_cast(&J3DUClipper::clip), + ClipperBoxClip); +DEFINE_HOOK(GXCopyTex, CopyTex); // Mirror of the WGSL Uniforms struct (keep in sync with res/shadow.wgsl). struct ShadowUniforms { @@ -1048,20 +1053,18 @@ MOD_EXPORT ModResult mod_initialize(ModError* error) { // Skip the game's own shadow rendering while the dynamic pass is active: the // shadowControl pair covers the actor real/blob shadows, drawCloudShadow the weather // cloud shadows. - if (dusk::mods::hook_add_pre<&dDlst_shadowControl_c::imageDraw>(svc_hook, on_game_shadow_pre) != - MOD_OK || - dusk::mods::hook_add_pre<&dDlst_shadowControl_c::draw>(svc_hook, on_game_shadow_pre) != - MOD_OK || - dusk::mods::hook_add_pre<&drawCloudShadow>(svc_hook, on_game_shadow_pre) != MOD_OK) + if (dusk::mods::hook_add_pre(svc_hook, on_game_shadow_pre) != MOD_OK || + dusk::mods::hook_add_pre(svc_hook, on_game_shadow_pre) != MOD_OK || + dusk::mods::hook_add_pre(svc_hook, on_game_shadow_pre) != MOD_OK) { return dusk::mods::set_error(error, MOD_ERROR, "failed to hook game shadow rendering"); } - if (dusk::mods::hook_add_pre(svc_hook, on_frustum_clip_pre) != MOD_OK || - dusk::mods::hook_add_pre(svc_hook, on_frustum_clip_pre) != MOD_OK) + if (dusk::mods::hook_add_pre(svc_hook, on_frustum_clip_pre) != MOD_OK || + dusk::mods::hook_add_pre(svc_hook, on_frustum_clip_pre) != MOD_OK) { return dusk::mods::set_error(error, MOD_ERROR, "failed to hook frustum clipping"); } - if (dusk::mods::hook_add_pre(svc_hook, on_copy_tex_pre) != MOD_OK) { + if (dusk::mods::hook_add_pre(svc_hook, on_copy_tex_pre) != MOD_OK) { return dusk::mods::set_error(error, MOD_ERROR, "failed to hook GXCopyTex"); } UiModsPanelDesc panelDesc = UI_MODS_PANEL_DESC_INIT; diff --git a/platforms/android/.gitignore b/platforms/android/.gitignore index 7775e3c7f4..002aaa86aa 100644 --- a/platforms/android/.gitignore +++ b/platforms/android/.gitignore @@ -3,3 +3,4 @@ build/ app/build/ local.properties app/src/main/jniLibs/*/*.so +app/src/main/bundled_mods/ diff --git a/platforms/android/app/build.gradle b/platforms/android/app/build.gradle index 8527cd1f4d..c7a25e732b 100644 --- a/platforms/android/app/build.gradle +++ b/platforms/android/app/build.gradle @@ -12,6 +12,11 @@ def syncDuskAssets = tasks.register('syncDuskAssets', Sync) { into 'res' exclude '**/.DS_Store' } + // Staged by platforms/android/scripts/stage-jni-libs.sh + from(new File(projectDir, 'src/main/bundled_mods')) { + into 'mods' + include '*.dusk' + } into duskGeneratedAssetsDir } diff --git a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java index 96fc302d9e..2fa6cbacb3 100644 --- a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java +++ b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java @@ -27,6 +27,10 @@ import org.libsdl.app.SDLActivity; import org.libsdl.app.SDLSurface; import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.util.ArrayList; import java.util.List; @@ -90,10 +94,55 @@ public class DuskActivity extends SDLActivity { @Override protected void onCreate(Bundle savedInstanceState) { + extractBundledMods(); super.onCreate(savedInstanceState); hideSystemBars(); } + // Bundled mod packages ship as APK assets, which the native loader cannot read directly; + // mirror them into internal storage (the loader's CachePath/bundled_mods search dir) + // before SDL_main starts. + private void extractBundledMods() { + File outDir = new File(getFilesDir(), "bundled_mods"); + try { + deleteRecursively(outDir); // drop packages removed by an app update + String[] names = getAssets().list("mods"); + if (names == null || names.length == 0) { + return; + } + if (!outDir.mkdirs()) { + Log.w(TAG, "Unable to create " + outDir); + return; + } + byte[] buffer = new byte[65536]; + for (String name : names) { + if (!name.endsWith(".dusk")) { + continue; + } + try (InputStream in = getAssets().open("mods/" + name); + OutputStream out = new FileOutputStream(new File(outDir, name))) + { + int count; + while ((count = in.read(buffer)) > 0) { + out.write(buffer, 0, count); + } + } + } + } catch (IOException e) { + Log.w(TAG, "Failed to extract bundled mods", e); + } + } + + private static void deleteRecursively(File file) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + file.delete(); + } + @Override protected SDLSurface createSDLSurface(Context context) { return new DuskSurface(context); diff --git a/platforms/android/scripts/stage-jni-libs.sh b/platforms/android/scripts/stage-jni-libs.sh index 42b08c13e6..a30d961156 100755 --- a/platforms/android/scripts/stage-jni-libs.sh +++ b/platforms/android/scripts/stage-jni-libs.sh @@ -70,3 +70,27 @@ for abi in $ANDROID_STAGE_ABIS; do esac copy_lib "$abi" "$src" done + +# Stage bundled mod packages into the app's assets source dir. +MODS_STAGING_DIR="$ROOT_DIR/platforms/android/app/src/main/bundled_mods" +rm -rf "$MODS_STAGING_DIR" +mkdir -p "$MODS_STAGING_DIR" +for abi in $ANDROID_STAGE_ABIS; do + case "$abi" in + arm64-v8a) build_dir="$ROOT_DIR/build/android-arm64" ;; + x86_64) build_dir="$ROOT_DIR/build/android-x86_64" ;; + esac + [[ -d "$build_dir/bundled_mods" ]] || continue + for pkg in "$build_dir/bundled_mods"/*.dusk; do + [[ -f "$pkg" ]] || continue + name="$(basename "$pkg")" + if [[ ! -f "$MODS_STAGING_DIR/$name" ]]; then + cp -f "$pkg" "$MODS_STAGING_DIR/$name" + echo "Staged bundled mod $pkg" + else + stage_dir="$build_dir/mods/${name%.dusk}/${name%.dusk}_stage" + (cd "$stage_dir" && zip -q -r "$MODS_STAGING_DIR/$name" lib) + echo "Appended $abi libraries to bundled mod $name" + fi + done +done diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index 8670603866..7d1d17a8c2 100644 --- a/sdk/CMakeLists.txt +++ b/sdk/CMakeLists.txt @@ -7,8 +7,7 @@ # add_subdirectory(/sdk dusk-sdk EXCLUDE_FROM_ALL) # add_mod(my_mod SOURCES ... MOD_JSON mod.json) # -# On Windows, pass -DDUSK_GAME_IMPLIB= from the -# matching game release. TODO: auto-download from tag +# TODO: auto-download link targets from tag cmake_minimum_required(VERSION 3.25) @@ -38,10 +37,10 @@ include("${CMAKE_CURRENT_SOURCE_DIR}/../extern/aurora/cmake/AuroraDawnProvider.c # Game ABI headers & compile definitions include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/GameABIConfig.cmake") -if (WIN32) - set(DUSK_GAME_IMPLIB "" CACHE FILEPATH "Path to the dusklight import library (sdk/dusklight.lib)") - if (DUSK_GAME_IMPLIB AND NOT EXISTS "${DUSK_GAME_IMPLIB}") - message(FATAL_ERROR "Mod SDK: DUSK_GAME_IMPLIB does not exist: ${DUSK_GAME_IMPLIB}") +if (WIN32 OR APPLE OR ANDROID) + set(DUSK_GAME_EXE "" CACHE FILEPATH "Game binary or link stub mods link against") + if (DUSK_GAME_EXE AND NOT EXISTS "${DUSK_GAME_EXE}") + message(FATAL_ERROR "Mod SDK: DUSK_GAME_EXE does not exist: ${DUSK_GAME_EXE}") endif () endif () diff --git a/sdk/pseudo_reloc.cpp b/sdk/pseudo_reloc.cpp deleted file mode 100644 index f86d783f2d..0000000000 --- a/sdk/pseudo_reloc.cpp +++ /dev/null @@ -1,139 +0,0 @@ -// Mod SDK runtime (Windows): applies lld's MinGW-style runtime pseudo-relocations on -// the plain MSVC CRT. This is what lets mods reference game data (`extern` globals) with no -// __declspec(dllimport) annotations: `lld-link -lldmingw` auto-imports the data through IAT -// slots and records fixups, and this module applies them at load time. -// -// The fixup pass runs as an early CRT initializer, and the IAT slots it reads were already -// bound by the OS loader, so even mod static initializers observe patched references. - -#include - -#include -#include -#include - -extern "C" char __RUNTIME_PSEUDO_RELOC_LIST__; -extern "C" char __RUNTIME_PSEUDO_RELOC_LIST_END__; -extern "C" IMAGE_DOS_HEADER __ImageBase; - -namespace { - -struct HdrV2 { - uint32_t magic1; - uint32_t magic2; - uint32_t version; -}; -struct ItemV2 { - uint32_t sym; // RVA of the __imp_ slot (OS-bound IAT entry) - uint32_t target; // RVA of the reference to patch - uint32_t flags; // low 8 bits: bit width of the reference -}; - -bool g_relocsFailed = false; - -void report(const char* fmt, ...) { - char buf[512]; - va_list args; - va_start(args, fmt); - vsnprintf(buf, sizeof(buf), fmt, args); - va_end(args); - fprintf(stderr, "%s\n", buf); - OutputDebugStringA(buf); -} - -bool compute_fixup(const ItemV2& item, char* base, intptr_t* out) { - char* impSlot = base + item.sym; - const intptr_t real = *reinterpret_cast(impSlot); - char* target = base + item.target; - const int bits = static_cast(item.flags & 0xff); - - intptr_t reldata; - switch (bits) { - case 8: - reldata = *reinterpret_cast(target); - break; - case 16: - reldata = *reinterpret_cast(target); - break; - case 32: - reldata = *reinterpret_cast(target); - break; - case 64: - reldata = *reinterpret_cast(target); - break; - default: - report("unsupported %d-bit pseudo-relocation at RVA 0x%x", bits, item.target); - return false; - } - reldata -= reinterpret_cast(impSlot); - reldata += real; - - if (bits < 64) { - const intptr_t maxUnsigned = (intptr_t{1} << bits) - 1; - const intptr_t minSigned = -(intptr_t{1} << (bits - 1)); - if (reldata > maxUnsigned || reldata < minSigned) { - report("%d-bit data fixup at RVA 0x%x is out of range (delta %+lld)", bits, item.target, - static_cast(reldata)); - return false; - } - } - *out = reldata; - return true; -} - -} // namespace - -// lld refuses to emit runtime pseudo-relocs unless a function with exactly this (mingw CRT) -// name exists in the image. It is also our actual entry point. -extern "C" void _pei386_runtime_relocator() { - char* base = reinterpret_cast(&__ImageBase); - char* start = &__RUNTIME_PSEUDO_RELOC_LIST__; - char* end = &__RUNTIME_PSEUDO_RELOC_LIST_END__; - if (end - start < static_cast(sizeof(HdrV2))) { - return; // no data auto-imports in this mod - } - const HdrV2* hdr = reinterpret_cast(start); - if (hdr->magic1 != 0 || hdr->magic2 != 0 || hdr->version != 1) { - report("unexpected pseudo-relocation list format"); - g_relocsFailed = true; - return; - } - const ItemV2* items = reinterpret_cast(hdr + 1); - const ItemV2* itemsEnd = reinterpret_cast(end); - - // Validate everything before writing anything, so a bad fixup can't leave the image - // half-patched. - intptr_t scratch; - for (const ItemV2* it = items; it < itemsEnd; ++it) { - if (!compute_fixup(*it, base, &scratch)) { - g_relocsFailed = true; - return; - } - } - for (const ItemV2* it = items; it < itemsEnd; ++it) { - intptr_t reldata; - compute_fixup(*it, base, &reldata); - char* target = base + it->target; - const size_t len = static_cast(it->flags & 0xff) / 8; - DWORD old = 0; - if (!VirtualProtect(target, len, PAGE_EXECUTE_READWRITE, &old)) { - report("VirtualProtect failed at RVA 0x%x", it->target); - g_relocsFailed = true; - return; - } - std::memcpy(target, &reldata, len); - VirtualProtect(target, len, old, &old); - } -} - -using PVFV = void (*)(); -#pragma section(".CRT$XCB", read) -extern "C" __declspec(allocate(".CRT$XCB")) PVFV dusk_mod_pseudo_reloc_init = - _pei386_runtime_relocator; - -BOOL WINAPI DllMain(HINSTANCE, DWORD reason, LPVOID) { - if (reason == DLL_PROCESS_ATTACH && g_relocsFailed) { - return FALSE; - } - return TRUE; -} diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index e1bb2797c8..adf465e367 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -16,6 +17,7 @@ #include "dusk/io.hpp" #include "dusk/mods/log_buffer.hpp" #include "dusk/mods/svc/config.hpp" +#include "dusk/mods/svc/hook.hpp" #include "dusk/mods/svc/registry.hpp" #include "dusk/ui/mods_window.hpp" #include "dusk/ui/ui.hpp" @@ -28,46 +30,51 @@ using namespace std::string_view_literals; #if defined(_WIN32) #if defined(_M_ARM64) -static constexpr std::string_view k_nativeLibName = "windows-arm64.dll"sv; +static constexpr std::string_view k_nativePlatform = "windows-arm64"sv; #elif defined(_M_X64) -static constexpr std::string_view k_nativeLibName = "windows-amd64.dll"sv; +static constexpr std::string_view k_nativePlatform = "windows-amd64"sv; #elif defined(_M_IX86) -static constexpr std::string_view k_nativeLibName = "windows-x86.dll"sv; +static constexpr std::string_view k_nativePlatform = "windows-x86"sv; #else -static constexpr std::string_view k_nativeLibName = ""sv; +static constexpr std::string_view k_nativePlatform = ""sv; #endif +static constexpr std::string_view k_nativeLibName = "mod.dll"sv; #elif defined(__ANDROID__) #if defined(__aarch64__) -static constexpr std::string_view k_nativeLibName = "android-aarch64.so"sv; +static constexpr std::string_view k_nativePlatform = "android-aarch64"sv; #elif defined(__x86_64__) -static constexpr std::string_view k_nativeLibName = "android-x86_64.so"sv; +static constexpr std::string_view k_nativePlatform = "android-x86_64"sv; #else -static constexpr std::string_view k_nativeLibName = ""sv; +static constexpr std::string_view k_nativePlatform = ""sv; #endif +static constexpr std::string_view k_nativeLibName = "mod.so"sv; #elif defined(__APPLE__) #include #if TARGET_OS_IOS -static constexpr std::string_view k_nativeLibName = "ios-arm64.dylib"sv; +static constexpr std::string_view k_nativePlatform = "ios-arm64"sv; #elif TARGET_OS_TV -static constexpr std::string_view k_nativeLibName = "tvos-arm64.dylib"sv; +static constexpr std::string_view k_nativePlatform = "tvos-arm64"sv; #elif defined(__aarch64__) -static constexpr std::string_view k_nativeLibName = "darwin-arm64.dylib"sv; +static constexpr std::string_view k_nativePlatform = "macos-arm64"sv; #elif defined(__x86_64__) -static constexpr std::string_view k_nativeLibName = "darwin-x86_64.dylib"sv; +static constexpr std::string_view k_nativePlatform = "macos-x86_64"sv; #else -static constexpr std::string_view k_nativeLibName = ""sv; +static constexpr std::string_view k_nativePlatform = ""sv; #endif +static constexpr std::string_view k_nativeLibName = "mod.so"sv; #elif defined(__linux__) #if defined(__aarch64__) -static constexpr std::string_view k_nativeLibName = "linux-aarch64.so"sv; +static constexpr std::string_view k_nativePlatform = "linux-aarch64"sv; #elif defined(__x86_64__) -static constexpr std::string_view k_nativeLibName = "linux-x86_64.so"sv; +static constexpr std::string_view k_nativePlatform = "linux-x86_64"sv; #elif defined(__i386__) -static constexpr std::string_view k_nativeLibName = "linux-x86.so"sv; +static constexpr std::string_view k_nativePlatform = "linux-x86"sv; #else -static constexpr std::string_view k_nativeLibName = ""sv; +static constexpr std::string_view k_nativePlatform = ""sv; #endif +static constexpr std::string_view k_nativeLibName = "mod.so"sv; #else +static constexpr std::string_view k_nativePlatform = ""sv; static constexpr std::string_view k_nativeLibName = ""sv; #endif @@ -75,6 +82,23 @@ namespace dusk::mods { namespace { aurora::Module Log{"dusk::mods::loader"}; ModLoader g_modLoader; +constexpr std::string_view k_nativeLibDir = "lib/"sv; + +class DirectoryRollback { +public: + ~DirectoryRollback() { + if (!mPath.empty()) { + std::error_code ec; + std::filesystem::remove_all(mPath, ec); + } + } + + void set_path(std::filesystem::path path) { mPath = std::move(path); } + void release() { mPath.clear(); } + +private: + std::filesystem::path mPath; +}; std::unique_ptr load_bundle(const std::filesystem::path& modPath, bool fromDir) { if (fromDir) { @@ -85,24 +109,49 @@ std::unique_ptr load_bundle(const std::filesystem::path& modPath, boo } } -struct DllLocateResult { +struct NativeLocateResult { std::string entry; + std::vector runtimeEntries; bool anyLibs = false; }; -DllLocateResult locate_dll_in_bundle(ModBundle& bundle) { - DllLocateResult result; +NativeLocateResult locate_native_runtime(ModBundle& bundle) { + NativeLocateResult result; + const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); + const std::string nativeEntry = platformPrefix + std::string{k_nativeLibName}; 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))) - { + if (!name.starts_with(k_nativeLibDir)) { continue; } - result.anyLibs = true; - if (name == k_nativeLibName) { + + const std::string_view libPath{ + name.data() + k_nativeLibDir.size(), name.size() - k_nativeLibDir.size()}; + const auto platformEnd = libPath.find('/'); + if (platformEnd != std::string_view::npos) { + const auto entryName = libPath.substr(platformEnd + 1); + if (entryName.find('/') == std::string_view::npos && + (entryName == "mod.dll"sv || entryName == "mod.so"sv)) + { + result.anyLibs = true; + } + } + + if (!k_nativePlatform.empty() && name.starts_with(platformPrefix)) { + const std::string_view relativeName{ + name.data() + platformPrefix.size(), name.size() - platformPrefix.size()}; + if (!is_safe_resource_path(relativeName)) { + continue; + } + result.runtimeEntries.push_back(name); + } + if (name == nativeEntry) { result.entry = name; } } + std::ranges::sort(result.runtimeEntries); + result.runtimeEntries.erase( + std::unique(result.runtimeEntries.begin(), result.runtimeEntries.end()), + result.runtimeEntries.end()); return result; } } // namespace @@ -220,31 +269,149 @@ static ModMetadata load_metadata(const std::filesystem::path& modPath, ModBundle }; } -static bool validate_manifest(const ModManifest* manifest, LoadedMod& mod) { - if (manifest == nullptr) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "returned a null mod manifest"); - mod.nativeStatus = NativeModStatus::MissingExport; +// True if the first `capacity` bytes of `str` contain a NUL. +static bool terminated_within(const char* str, size_t capacity) { + return std::memchr(str, '\0', capacity) != nullptr; +} + +static bool parse_meta(NativeMod& native, LoadedMod& mod) { + const ModMeta* meta = native.meta; + if (meta->struct_size < sizeof(ModMeta)) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_meta descriptor has invalid size {}", + meta->struct_size); + mod.nativeStatus = NativeModStatus::InvalidMetadata; return false; } - if (manifest->struct_size != sizeof(ModManifest)) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "manifest has invalid size {} (expected {})", - manifest->struct_size, sizeof(ModManifest)); - mod.nativeStatus = NativeModStatus::ApiVersionMismatch; - return false; - } - if (manifest->abi_version != MOD_ABI_VERSION) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expects ABI v{} but engine is v{}, skipping", - manifest->abi_version, MOD_ABI_VERSION); - mod.nativeStatus = NativeModStatus::ApiVersionMismatch; - return false; - } - if ((manifest->import_count > 0 && manifest->imports == nullptr) || - (manifest->export_count > 0 && manifest->exports == nullptr)) + const auto* cursor = static_cast(meta->records_begin); + const auto* end = static_cast(meta->records_end); + if (cursor == nullptr || end == nullptr || cursor > end || + (reinterpret_cast(cursor) & 7) != 0) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "manifest has invalid import/export arrays"); - mod.nativeStatus = NativeModStatus::MissingExport; + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "mod_meta section bounds are invalid"); + mod.nativeStatus = NativeModStatus::InvalidMetadata; return false; } + + ModMetaParsed parsed; + size_t headerCount = 0; + const auto invalid = [&](std::string_view why) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "invalid metadata record at offset {}: {}", + cursor - static_cast(meta->records_begin), why); + mod.nativeStatus = NativeModStatus::InvalidMetadata; + return false; + }; + + while (cursor < end) { + if (end - cursor < 8) { + return invalid("trailing bytes"); + } + uint64_t first = 0; + std::memcpy(&first, cursor, sizeof(first)); + if (first == 0) { // linker padding / bounds sentinel + cursor += 8; + continue; + } + + const auto* rec = reinterpret_cast(cursor); + const size_t size = rec->size; + if (size < 8 || size % 8 != 0 || size > static_cast(end - cursor)) { + return invalid("bad record size"); + } + + switch (rec->kind) { + case MOD_META_PAD: + break; + case MOD_META_HEADER: { + if (size < sizeof(ModMetaHeader)) { + return invalid("truncated header record"); + } + const auto* header = reinterpret_cast(rec); + ++headerCount; + parsed.abiVersion = header->abi_version; + break; + } + case MOD_META_IMPORT: { + if (size < sizeof(ModMetaImport)) { + return invalid("truncated import record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + if (!terminated_within(record->service_id.chars, sizeof(record->service_id.chars))) { + return invalid("unterminated import service id"); + } + parsed.imports.push_back(record); + break; + } + case MOD_META_EXPORT: { + if (size < sizeof(ModMetaExport)) { + return invalid("truncated export record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + if (!terminated_within(record->service_id.chars, sizeof(record->service_id.chars))) { + return invalid("unterminated export service id"); + } + parsed.exports.push_back(record); + break; + } + case MOD_META_HOOK_FN: { + if (size < sizeof(ModMetaHookFn)) { + return invalid("truncated hook record"); + } + parsed.hookFns.push_back( + reinterpret_cast(const_cast(cursor))); + break; + } + case MOD_META_HOOK_MEM: { + if (size <= sizeof(ModMetaHookMem)) { + return invalid("truncated hook record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + const char* strings = reinterpret_cast(cursor) + sizeof(ModMetaHookMem); + const size_t capacity = size - sizeof(ModMetaHookMem); + if (!terminated_within(strings, capacity)) { + return invalid("unterminated hook vtable symbol"); + } + const size_t vtableLen = std::char_traits::length(strings); + if (!terminated_within(strings + vtableLen + 1, capacity - vtableLen - 1)) { + return invalid("unterminated hook display name"); + } + parsed.hookMems.push_back(record); + break; + } + case MOD_META_HOOK_NAME: { + if (size <= sizeof(ModMetaHookName)) { + return invalid("truncated hook record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + const char* name = reinterpret_cast(cursor) + sizeof(ModMetaHookName); + if (!terminated_within(name, size - sizeof(ModMetaHookName))) { + return invalid("unterminated hook symbol name"); + } + parsed.hookNames.push_back(record); + break; + } + default: + // Additive record kinds may appear within a format version; skip them. + log::write(mod.metadata.id, LOG_LEVEL_DEBUG, "skipping unknown metadata record kind {}", + rec->kind); + break; + } + cursor += size; + } + + if (headerCount != 1) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expected 1 metadata header record, found {}", + headerCount); + mod.nativeStatus = NativeModStatus::InvalidMetadata; + return false; + } + if (parsed.abiVersion != MOD_ABI_VERSION) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "expects ABI v{} but engine is v{}, skipping", + parsed.abiVersion, MOD_ABI_VERSION); + mod.nativeStatus = NativeModStatus::ApiVersionMismatch; + return false; + } + + native.parsed = std::move(parsed); return true; } @@ -270,12 +437,14 @@ static std::string native_status_message(const NativeModStatus 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); + return fmt::format("Mod not supported on this platform ({})", k_nativePlatform); 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::InvalidMetadata: + return "Invalid mod metadata records"; case NativeModStatus::Unknown: return "Unknown mod load failure"; case NativeModStatus::None: @@ -303,7 +472,8 @@ std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) return path; } -void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { +void ModLoader::load_native( + LoadedMod& mod, const std::string& dllEntry, const std::vector& runtimeEntries) { if (!EnableCodeMods) { log::write(mod.metadata.id, LOG_LEVEL_ERROR, "Code mods are not available in this build"); mod.nativeStatus = NativeModStatus::BuildDisabled; @@ -313,10 +483,19 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { namespace fs = std::filesystem; const fs::path cacheDir = m_cacheDir / mod.metadata.id; + const fs::path scratchDir = cacheDir / "data"; std::error_code ec; - fs::create_directories(cacheDir, ec); + fs::create_directories(scratchDir, ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to create mod directory {}: {}", + io::fs_path_to_string(scratchDir), ec.message()); + return; + } + mod.dir = io::fs_path_to_string(fs::absolute(scratchDir)); fs::path libPath; + fs::path runtimeDir; + DirectoryRollback runtimeDirRollback; if (mod.inPlace) { if (!dllEntry.empty()) { libPath = fs::path(mod.modPath) / dllEntry; @@ -328,6 +507,7 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { mod.nativeStatus = NativeModStatus::ModMissingPlatform; return; } + runtimeDir = libPath.parent_path(); } else { if (dllEntry.empty()) { log::write(mod.metadata.id, LOG_LEVEL_ERROR, @@ -336,34 +516,63 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { 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::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}", dllEntry); + // Every generation gets a new directory. The main module and all of its runtime + // libraries therefore have fresh paths and can coexist with a previous generation + // that is still unwinding after a reload. + runtimeDir = cacheDir / fmt::format("g{}", ++mod.cacheGeneration); + runtimeDirRollback.set_path(runtimeDir); + fs::create_directories(runtimeDir, ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "failed to create native runtime directory {}: {}", + io::fs_path_to_string(runtimeDir), ec.message()); return; } - { - std::ofstream out(dllCachePath, std::ios::binary | std::ios::out); - if (!out) { - log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", - io::fs_path_to_string(dllCachePath)); + const std::string platformPrefix = fmt::format("{}{}/", k_nativeLibDir, k_nativePlatform); + for (const auto& entry : runtimeEntries) { + if (!entry.starts_with(platformPrefix)) { + continue; + } + const std::string_view relativeName{ + entry.data() + platformPrefix.size(), entry.size() - platformPrefix.size()}; + if (!is_safe_resource_path(relativeName)) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "unsafe native runtime path '{}'; skipping", entry); return; } - out.write(reinterpret_cast(dllData.data()), - static_cast(dllData.size())); + const fs::path outputPath = runtimeDir / fs::path{relativeName}; + fs::create_directories(outputPath.parent_path(), ec); + if (ec) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, + "failed to create directory for {}: {}", entry, ec.message()); + return; + } + + std::vector data; + try { + data = mod.bundle->readFile(entry); + } catch (const std::exception& e) { + log::write( + mod.metadata.id, LOG_LEVEL_ERROR, "failed to extract {}: {}", entry, e.what()); + return; + } + + std::ofstream out(outputPath, std::ios::binary | std::ios::out); + if (!out) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); + return; + } + out.write(reinterpret_cast(data.data()), + static_cast(data.size())); + if (!out) { + log::write(mod.metadata.id, LOG_LEVEL_ERROR, "failed to write {}", entry); + return; + } } - libPath = dllCachePath; + libPath = runtimeDir / fs::path{dllEntry}.filename(); } auto nativeMod = std::make_unique(); @@ -375,13 +584,13 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { return; } - const auto getManifest = nativeMod->handle->LookupSymbol("mod_get_manifest"); + nativeMod->meta = nativeMod->handle->LookupSymbol("mod_meta"); 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 || + if (!nativeMod->meta || !nativeMod->contextSymbol || !nativeMod->fn_initialize || !nativeMod->fn_update || !nativeMod->fn_shutdown) { log::write(mod.metadata.id, LOG_LEVEL_ERROR, @@ -391,8 +600,7 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { return; } - nativeMod->manifest = getManifest(); - if (!validate_manifest(nativeMod->manifest, mod)) { + if (!parse_meta(*nativeMod, mod)) { return; } @@ -401,10 +609,11 @@ void ModLoader::load_native(LoadedMod& mod, const std::string& dllEntry) { } *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.nativeDir = io::fs_path_to_string(fs::absolute(runtimeDir)); mod.native = std::move(nativeMod); mod.nativeStatus = NativeModStatus::Loaded; + runtimeDirRollback.release(); } void ModLoader::unload_native(LoadedMod& mod) { @@ -412,43 +621,38 @@ void ModLoader::unload_native(LoadedMod& mod) { 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)}); + m_retiredNatives.push_back({std::move(mod.native), std::move(mod.nativeDir)}); mod.nativePath.clear(); + mod.nativeDir.clear(); } void ModLoader::drain_retired_natives() { for (auto& retired : m_retiredNatives) { retired.native.reset(); - if (!retired.path.empty()) { + if (!retired.directory.empty()) { std::error_code ec; - std::filesystem::remove(retired.path, ec); + std::filesystem::remove_all(retired.directory, ec); } } m_retiredNatives.clear(); } -static ModManifestInfo build_manifest_info(const ModManifest& manifest) { +static ModManifestInfo build_manifest_info(const ModMetaParsed& parsed) { 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)) - { + info.imports.reserve(parsed.imports.size()); + for (const auto* record : parsed.imports) { + if (!svc::valid_service_id(record->service_id.chars)) { continue; } - info.imports.push_back({serviceImport.service_id, serviceImport.major_version, - (serviceImport.flags & SERVICE_IMPORT_OPTIONAL) == 0}); + info.imports.push_back({record->service_id.chars, record->major_version, + (record->rec.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)) - { + info.exports.reserve(parsed.exports.size()); + for (const auto* record : parsed.exports) { + if (!svc::valid_service_id(record->service_id.chars)) { continue; } - info.exports.push_back({serviceExport.service_id, serviceExport.major_version}); + info.exports.push_back({record->service_id.chars, record->major_version}); } return info; } @@ -484,24 +688,20 @@ static bool required_deps_active(const LoadedMod& mod) { // 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) { + if (!mod.active || !mod.native) { 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) - { + for (const auto* serviceExport : mod.native->parsed.exports) { + if ((serviceExport->rec.flags & SERVICE_EXPORT_DEFERRED) == 0) { continue; } const auto* record = - svc::find_service_record(serviceExport.service_id, serviceExport.major_version); + svc::find_service_record(serviceExport->service_id.chars, serviceExport->major_version); if (record != nullptr && record->service == nullptr) { log::write(mod.metadata.id, LOG_LEVEL_WARN, "declared deferred service '{}@{}' but never published it during initialization", - serviceExport.service_id, serviceExport.major_version); + serviceExport->service_id.chars, serviceExport->major_version); } } } @@ -552,15 +752,15 @@ void ModLoader::try_load_mod( mod.cvarIsEnabled = std::make_unique>(mod_enabled_cvar_name(mod.metadata.id), true); - const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); + const auto [dllEntry, runtimeEntries, anyLibs] = locate_native_runtime(*mod.bundle); if (anyLibs || (mod.inPlace && !external_native_lib_path(mod).empty())) { mod.nativeStatus = NativeModStatus::Unknown; - load_native(mod, dllEntry); + load_native(mod, dllEntry, runtimeEntries); if (mod.nativeStatus != NativeModStatus::Loaded) { Log.error("Native mod '{}' failed to load, disabling", mod.metadata.id); fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); } else { - mod.manifestInfo = build_manifest_info(*mod.native->manifest); + mod.manifestInfo = build_manifest_info(mod.native->parsed); } } @@ -593,6 +793,8 @@ bool ModLoader::activate_mod(LoadedMod& mod) { return false; } + svc::hook_resolve_mod_records(mod); + *mod.native->contextSymbol = mod.context.get(); log::write(mod.metadata.id, LOG_LEVEL_TRACE, "calling mod_initialize"); @@ -879,14 +1081,14 @@ bool ModLoader::ensure_native_loaded(LoadedMod& mod) { return true; } - const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); + const auto [dllEntry, runtimeEntries, anyLibs] = locate_native_runtime(*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); + load_native(mod, dllEntry, runtimeEntries); if (mod.nativeStatus != NativeModStatus::Loaded) { fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); return false; @@ -922,14 +1124,16 @@ bool ModLoader::reload_bundle(LoadedMod& mod) { mod.failureReason.clear(); ModManifestInfo newInfo; - if (const auto [dllEntry, anyLibs] = locate_dll_in_bundle(*mod.bundle); anyLibs) { + if (const auto [dllEntry, runtimeEntries, anyLibs] = locate_native_runtime(*mod.bundle); + anyLibs) + { mod.nativeStatus = NativeModStatus::Unknown; - load_native(mod, dllEntry); + load_native(mod, dllEntry, runtimeEntries); if (mod.nativeStatus != NativeModStatus::Loaded) { fail_mod(mod, MOD_ERROR, native_status_message(mod.nativeStatus)); return false; } - newInfo = build_manifest_info(*mod.native->manifest); + newInfo = build_manifest_info(mod.native->parsed); } else { mod.nativeStatus = NativeModStatus::None; ++mod.cacheGeneration; @@ -998,8 +1202,7 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { if (register_static_service_exports(*mod)) { mod->servicesRegistered = true; } else { - log::write( - mod->metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); + log::write(mod->metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); deactivate_mod(*mod); } } diff --git a/src/dusk/mods/loader/native_module.cpp b/src/dusk/mods/loader/native_module.cpp index edd3addb4f..f6adf20c60 100644 --- a/src/dusk/mods/loader/native_module.cpp +++ b/src/dusk/mods/loader/native_module.cpp @@ -17,7 +17,8 @@ namespace { #if defined(_WIN32) void* pl_dlopen(const std::filesystem::path& p) { - return LoadLibraryW(p.wstring().c_str()); + return LoadLibraryExW(p.wstring().c_str(), nullptr, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); } void* pl_dlsym(void* h, const char* name) { return reinterpret_cast(GetProcAddress(static_cast(h), name)); @@ -55,11 +56,10 @@ std::string pl_dlerror() { return e ? e : "(unknown error)"; } #endif -} +} // namespace namespace dusk::mods::loader { -NativeModule::NativeModule() noexcept : handle(nullptr) { -} +NativeModule::NativeModule() noexcept : handle(nullptr) {} NativeModule::NativeModule(NativeModule&& other) noexcept { handle = other.handle; diff --git a/src/dusk/mods/loader/native_module.hpp b/src/dusk/mods/loader/native_module.hpp index ff2acf9632..2632496f96 100644 --- a/src/dusk/mods/loader/native_module.hpp +++ b/src/dusk/mods/loader/native_module.hpp @@ -22,8 +22,6 @@ public: #if defined(_WIN32) static constexpr auto LibraryExtension = ".dll"; -#elif defined(__APPLE__) - static constexpr auto LibraryExtension = ".dylib"; #else static constexpr auto LibraryExtension = ".so"; #endif diff --git a/src/dusk/mods/svc/hook.cpp b/src/dusk/mods/svc/hook.cpp index 41399bf97d..b5eb60c216 100644 --- a/src/dusk/mods/svc/hook.cpp +++ b/src/dusk/mods/svc/hook.cpp @@ -6,6 +6,7 @@ #if DUSK_CODE_MODS #include "dusk/logging.h" +#include "dusk/mods/log_buffer.hpp" #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include #include #endif @@ -64,8 +66,29 @@ struct InstalledHook { std::unordered_map s_registry; std::unordered_map s_installed; +std::unordered_map> s_declaredTargets; uint64_t s_nextOrder = 0; +bool declared_target(const ModContext* context, void* fnAddr) { + if (context == nullptr) { + return false; + } + const auto it = s_declaredTargets.find(context); + if (it == s_declaredTargets.end()) { + return false; + } + const auto addr = reinterpret_cast(fnAddr); + return std::ranges::find(it->second, addr) != it->second.end(); +} + +ModResult reject_undeclared(ModContext* context, void* fnAddr) { + log::write(mod_id_from_context(context), LOG_LEVEL_ERROR, + "tried to hook undeclared target {:p}; hook targets must be declared with " + "DEFINE_HOOK/DEFINE_HOOK_SYMBOL", + fnAddr); + return MOD_INVALID_ARGUMENT; +} + HookOptions normalize_options(const HookOptions* options) { if (options == nullptr || options->struct_size < sizeof(HookOptions)) { return HOOK_OPTIONS_INIT; @@ -158,6 +181,18 @@ void* resolve_import_thunk(void* addr) { return addr; } +// Resolve thunks recursively (max of 8 steps) until we find our target. +void* resolve_target(void* addr) { + for (int i = 0; i < 8; ++i) { + void* next = resolve_import_thunk(addr); + if (next == addr) { + break; + } + addr = next; + } + return addr; +} + funchook_t* install_trampoline(void* fnAddr, void* trampoline, void** outOriginal) { funchook_t* fh = funchook_create(); if (fh == nullptr) { @@ -199,7 +234,10 @@ ModResult hook_install(ModContext* context, void* fnAddr, void* trampolineFn, vo return MOD_INVALID_ARGUMENT; } - fnAddr = resolve_import_thunk(fnAddr); + fnAddr = resolve_target(fnAddr); + if (!declared_target(context, fnAddr)) { + return reject_undeclared(context, fnAddr); + } const auto key = reinterpret_cast(fnAddr); if (const auto it = s_installed.find(key); it != s_installed.end()) { auto& entry = it->second; @@ -242,7 +280,10 @@ ModResult hook_add_pre( if (fnAddr == nullptr || context == nullptr || callback == nullptr) { return MOD_INVALID_ARGUMENT; } - fnAddr = resolve_import_thunk(fnAddr); + fnAddr = resolve_target(fnAddr); + if (!declared_target(context, fnAddr)) { + return reject_undeclared(context, fnAddr); + } auto& hooks = s_registry[reinterpret_cast(fnAddr)].pre; hooks.push_back({context, callback, normalize_options(options), s_nextOrder++}); sort_hooks(hooks); @@ -254,7 +295,10 @@ ModResult hook_add_post( if (fnAddr == nullptr || context == nullptr || callback == nullptr) { return MOD_INVALID_ARGUMENT; } - fnAddr = resolve_import_thunk(fnAddr); + fnAddr = resolve_target(fnAddr); + if (!declared_target(context, fnAddr)) { + return reject_undeclared(context, fnAddr); + } auto& hooks = s_registry[reinterpret_cast(fnAddr)].post; hooks.push_back({context, nullptr, callback, normalize_options(options), s_nextOrder++}); sort_hooks(hooks); @@ -268,7 +312,10 @@ ModResult hook_replace( } const HookOptions normalized = normalize_options(options); - fnAddr = resolve_import_thunk(fnAddr); + fnAddr = resolve_target(fnAddr); + if (!declared_target(context, fnAddr)) { + return reject_undeclared(context, fnAddr); + } auto& slot = s_registry[reinterpret_cast(fnAddr)]; if (slot.replace.replaceCallback == nullptr) { slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; @@ -302,7 +349,7 @@ ModResult hook_dispatch_pre( return MOD_INVALID_ARGUMENT; } - fnAddr = resolve_import_thunk(fnAddr); + fnAddr = resolve_target(fnAddr); const auto it = s_registry.find(reinterpret_cast(fnAddr)); if (it == s_registry.end()) { return MOD_OK; @@ -352,7 +399,7 @@ ModResult hook_dispatch_post(ModContext*, void* fnAddr, void* args, void* retval return MOD_INVALID_ARGUMENT; } - fnAddr = resolve_import_thunk(fnAddr); + fnAddr = resolve_target(fnAddr); const auto it = s_registry.find(reinterpret_cast(fnAddr)); if (it == s_registry.end()) { return MOD_OK; @@ -371,8 +418,187 @@ ModResult hook_dispatch_post(ModContext*, void* fnAddr, void* args, void* retval return MOD_OK; } +#if defined(_WIN32) +/* Follow jump stubs, then match the MSVC vcall thunk a virtual mfp points at. + * Returns the vtable slot's byte offset, or npos when fn is not a vcall thunk. */ +size_t vcall_slot_offset(const void*& fn) noexcept { + constexpr size_t npos = static_cast(-1); +#if defined(_M_X64) || defined(__x86_64__) + const auto* p = static_cast(fn); + for (int i = 0; i < 8 && p[0] == 0xE9; ++i) { // incremental-link stubs + int32_t rel; + std::memcpy(&rel, p + 1, 4); + p += 5 + rel; + } + fn = p; + // The vptr load. Unoptimized clang-cl thunks spill/reload rcx first + // (push rax; mov [rsp], rcx; mov rcx, [rsp]), so scan a short window. + const uint8_t* q = nullptr; + for (int i = 0; i <= 12; ++i) { + if (p[i] == 0x48 && p[i + 1] == 0x8B && p[i + 2] == 0x01) { // mov rax, [rcx] + q = p + i + 3; + break; + } + } + if (q == nullptr) { + return npos; + } + if (q[0] == 0xFF && q[1] == 0x20) { // jmp [rax] (MSVC) + return 0; + } + if (q[0] == 0xFF && q[1] == 0x60) { // jmp [rax + imm8] + return static_cast(q[2]); + } + if (q[0] == 0xFF && q[1] == 0xA0) { // jmp [rax + imm32] + int32_t off; + std::memcpy(&off, q + 2, 4); + return off; + } + // clang-cl: mov rax, [rax + off]; (pop r10;) jmp rax. Requiring the jmp rax + // distinguishes the thunk from an ordinary getter that begins the same way. + if (q[0] == 0x48 && q[1] == 0x8B && (q[2] == 0x00 || q[2] == 0x40 || q[2] == 0x80)) { + size_t off = 0; + const uint8_t* r = q + 3; + if (q[2] == 0x40) { + off = static_cast(q[3]); + r = q + 4; + } else if (q[2] == 0x80) { + int32_t off32; + std::memcpy(&off32, q + 3, 4); + off = off32; + r = q + 7; + } + for (int i = 0; i <= 8; ++i) { + if (r[i] == 0xFF && r[i + 1] == 0xE0) { // jmp rax (48 REX optional) + return off; + } + } + } + return npos; +#elif defined(_M_ARM64) || defined(__aarch64__) + const auto* p = static_cast(fn); + uint32_t insn[3]; + for (int i = 0; i < 8; ++i) { // incremental-link `b` stubs + std::memcpy(insn, p, 4); + if ((insn[0] & 0xFC000000u) != 0x14000000u) { + break; + } + const auto imm26 = static_cast(insn[0] << 6) >> 6; + p += static_cast(imm26) * 4; + } + fn = p; + std::memcpy(insn, p, 12); + // ldr Xt, [x0]; ldr Xs, [Xt, #imm12*8]; br Xs + if ((insn[0] & 0xFFFFFFE0u) != 0xF9400000u) { + return npos; + } + const uint32_t t = insn[0] & 0x1Fu; + if ((insn[1] & 0xFFC003E0u) != (0xF9400000u | (t << 5))) { + return npos; + } + const uint32_t s = insn[1] & 0x1Fu; + if (insn[2] != (0xD61F0000u | (s << 5))) { + return npos; + } + return ((insn[1] >> 10) & 0xFFFu) * 8; +#else + (void)fn; + return npos; +#endif +} +#endif // _WIN32 + +bool resolve_symbol_checked(const char* symbol, bool requireCode, void** out, std::string& why) { + HookSymbolFlags flags{}; + switch (manifest::resolve(symbol, out, &flags)) { + case manifest::ResolveStatus::Ok: + if (requireCode && (flags & HOOK_SYMBOL_CODE) == 0) { + why = fmt::format("'{}' is not a code symbol", symbol); + return false; + } + return true; + case manifest::ResolveStatus::Unavailable: + why = "no symbol manifest for this build"; + return false; + case manifest::ResolveStatus::NotFound: + why = fmt::format("symbol '{}' not found", symbol); + return false; + case manifest::ResolveStatus::Ambiguous: + why = fmt::format("'{}' maps to more than one address; use the mangled name", symbol); + return false; + } + why = "unexpected resolve failure"; + return false; +} + +/* Decode a HOOK_MEM record's pointer-to-member representation into the target code address, + * mirroring what calling through the mfp would invoke. Virtual members hook the class's own + * overrider, read from its vtable (resolved from the symbol manifest). */ +void* resolve_member_record( + const ModMetaHookMem& record, const char* vtableSymbol, std::string& why) { + uintptr_t words[2]; + std::memcpy(words, record.pmf, sizeof(words)); + +#if defined(_WIN32) + const void* fn = reinterpret_cast(words[0]); + const size_t slot = vcall_slot_offset(fn); + if (slot == static_cast(-1)) { // not a vcall thunk: direct address + return const_cast(fn); + } + if (vtableSymbol[0] == '\0') { + why = "class name is not representable as a vtable symbol"; + return nullptr; + } + void* vtable = nullptr; + if (!resolve_symbol_checked(vtableSymbol, false, &vtable, why)) { + return nullptr; + } + // ??_7 points at the first slot. + return *reinterpret_cast(static_cast(vtable) + slot); +#else +#if defined(__aarch64__) || defined(__arm__) + // AAPCS C++ ABI: the virtual flag is bit 0 of the adjustment word (function + // addresses can't spare their low bit), and ptr holds the slot offset directly. + const bool isVirtual = (words[1] & 1) != 0; + const uintptr_t thisAdjust = words[1] >> 1; + const uintptr_t slotOffset = words[0]; +#else + // Itanium C++ ABI: virtual mfps set bit 0 of ptr; the slot offset is ptr - 1. + const bool isVirtual = (words[0] & 1) != 0; + const uintptr_t thisAdjust = words[1]; + const uintptr_t slotOffset = words[0] - 1; +#endif + if (!isVirtual) { // non-virtual: the address itself + return reinterpret_cast(words[0]); + } + if (thisAdjust != 0) { + // this-adjusting mfp (member of a secondary base): the slot offset is relative to a + // vtable we can't locate. Hook the overrider by name instead. + why = "virtual member of a secondary base; hook the overrider by name"; + return nullptr; + } + if (vtableSymbol[0] == '\0') { + why = "class name is not representable as a vtable symbol"; + return nullptr; + } + void* vtable = nullptr; + if (!resolve_symbol_checked(vtableSymbol, false, &vtable, why)) { + return nullptr; + } + // _ZTV points at the offset-to-top slot; the address point mfps index from is + // two pointers in (past offset-to-top and the typeinfo pointer). + void* target = + *reinterpret_cast(static_cast(vtable) + 2 * sizeof(void*) + slotOffset); + if (target == nullptr) { + why = "vtable slot is empty"; + } + return target; +#endif +} + void hook_remove_mod(LoadedMod& mod) { ModContext* context = mod.context.get(); + s_declaredTargets.erase(context); for (auto it = s_registry.begin(); it != s_registry.end();) { auto& slot = it->second; @@ -508,6 +734,56 @@ constexpr HookService s_hookService{ } // namespace +#if DUSK_CODE_MODS +void hook_resolve_mod_records(LoadedMod& mod) { + auto& declared = s_declaredTargets[mod.context.get()]; + declared.clear(); + if (!mod.native) { + return; + } + + const auto resolved = [&](void* target, void** slot) { + target = resolve_target(target); + *slot = target; + declared.push_back(reinterpret_cast(target)); + }; + const auto unresolved = [&](const char* what, std::string_view why, void** slot) { + *slot = nullptr; + log::write(mod.metadata.id, LOG_LEVEL_WARN, + "hook target '{}' did not resolve ({}); installing this hook will fail", what, why); + }; + + for (auto* record : mod.native->parsed.hookFns) { + if (record->target != nullptr) { + resolved(record->target, &record->resolved); + } else { + unresolved("", "null link-time target", &record->resolved); + } + } + for (auto* record : mod.native->parsed.hookMems) { + std::string why; + void* target = resolve_member_record(*record, hook_mem_vtable_symbol(*record), why); + if (target != nullptr) { + resolved(target, &record->resolved); + } else { + unresolved(hook_mem_display_name(*record), why, &record->resolved); + } + } + for (auto* record : mod.native->parsed.hookNames) { + const char* name = hook_name_symbol(*record); + std::string why; + void* target = nullptr; + if (resolve_symbol_checked(name, true, &target, why)) { + resolved(target, &record->resolved); + } else { + unresolved(name, why, &record->resolved); + } + } +} +#else +void hook_resolve_mod_records(LoadedMod&) {} +#endif // DUSK_CODE_MODS + constinit const ServiceModule g_hookModule{ .id = HOOK_SERVICE_ID, .majorVersion = HOOK_SERVICE_MAJOR, diff --git a/src/dusk/mods/svc/hook.hpp b/src/dusk/mods/svc/hook.hpp new file mode 100644 index 0000000000..d712a8054a --- /dev/null +++ b/src/dusk/mods/svc/hook.hpp @@ -0,0 +1,11 @@ +#pragma once + +namespace dusk::mods { +struct LoadedMod; +} + +namespace dusk::mods::svc { + +void hook_resolve_mod_records(LoadedMod& mod); + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index 0199a7865a..f5d409f797 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -63,6 +63,11 @@ const char* host_mod_dir(ModContext* context) { return mod != nullptr ? mod->dir.c_str() : ""; } +const char* host_native_dir(ModContext* context) { + const auto* mod = mod_from_context(context); + return mod != nullptr ? mod->nativeDir.c_str() : ""; +} + struct LifecycleWatcher { ModLifecycleFn fn = nullptr; void* userData = nullptr; @@ -142,6 +147,7 @@ constinit HostService s_hostService{ .mod_dir = host_mod_dir, .watch_mod_lifecycle = host_watch_mod_lifecycle, .unwatch_mod_lifecycle = host_unwatch_mod_lifecycle, + .native_dir = host_native_dir, }; } // namespace diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 338625230a..2033d47475 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -217,29 +217,25 @@ void ModLoader::init_services() { } bool ModLoader::register_static_service_exports(LoadedMod& mod) { - if (!mod.native || mod.native->manifest == nullptr) { + if (!mod.native) { 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)) - { + for (const auto* serviceExport : mod.native->parsed.exports) { + if (!svc::valid_service_id(serviceExport->service_id.chars)) { 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) { + const bool deferred = (serviceExport->rec.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); + svc::register_service(serviceExport->service_id.chars, serviceExport->major_version, + serviceExport->minor_version, serviceExport->service, &mod, deferred); if (result != MOD_OK) { fail_mod(mod, result, "Service export registration failed"); return false; @@ -262,18 +258,13 @@ std::string ModLoader::describe_missing_import( // 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) - { + if ((other.active && !other.loadFailed) || !other.native) { 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) + for (const auto* serviceExport : other.native->parsed.exports) { + if (svc::valid_service_id(serviceExport->service_id.chars) && + std::string_view{serviceExport->service_id.chars} == serviceId && + serviceExport->major_version == majorVersion) { return fmt::format("Required service {}@{} unavailable: provider '{}' {}", serviceId, majorVersion, other.metadata.id, @@ -286,35 +277,33 @@ std::string ModLoader::describe_missing_import( } bool ModLoader::resolve_service_imports(LoadedMod& mod) { - if (!mod.native || mod.native->manifest == nullptr) { + if (!mod.native) { 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) + for (const auto* serviceImport : mod.native->parsed.imports) { + if (!svc::valid_service_id(serviceImport->service_id.chars) || + 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); + const auto* service = svc::find_service(serviceImport->service_id.chars, + serviceImport->major_version, serviceImport->min_minor_version); if (service == nullptr) { - *static_cast(serviceImport.slot) = nullptr; - if ((serviceImport.flags & SERVICE_IMPORT_OPTIONAL) != 0) { + *static_cast(serviceImport->slot) = nullptr; + if ((serviceImport->rec.flags & SERVICE_IMPORT_OPTIONAL) != 0) { continue; } fail_mod(mod, MOD_UNAVAILABLE, - describe_missing_import(serviceImport.service_id, serviceImport.major_version, - serviceImport.min_minor_version)); + describe_missing_import(serviceImport->service_id.chars, + serviceImport->major_version, serviceImport->min_minor_version)); return false; } - *static_cast(serviceImport.slot) = service->service; + *static_cast(serviceImport->slot) = service->service; } return true;