From 4339e60ba65dc16702b019d614bfaf7dc724d5b9 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 15:14:58 -0600 Subject: [PATCH] Hook service, symgen manifest & data linking --- CMakeLists.txt | 85 +++++- cmake/ModSDK.cmake | 45 +++ cmake/PatchCapstone.cmake | 13 + cmake/PatchFunchook.cmake | 60 ++++ cmake/SymbolManifest.cmake | 125 ++++++++ cmake/WindowsExports.cmake | 76 +++++ docs/modding.md | 172 ++++++++++- files.cmake | 4 + include/d/d_com_inf_game.h | 2 +- include/dusk/gx_helper.h | 3 +- include/dusk/profiling.hpp | 12 + include/global.h | 13 + include/mods/hook.hpp | 464 +++++++++++++++++++++++++++++ include/mods/svc/game.h | 30 ++ include/mods/svc/hook.h | 131 ++++++++ include/mods/svc/host.h | 12 +- sdk/CMakeLists.txt | 10 + sdk/pseudo_reloc.cpp | 139 +++++++++ src/dusk/mods/loader/loader.cpp | 5 +- src/dusk/mods/loader/manifest.cpp | 386 ++++++++++++++++++++++++ src/dusk/mods/loader/manifest.hpp | 50 ++++ src/dusk/mods/svc/game.cpp | 21 ++ src/dusk/mods/svc/hook.cpp | 480 ++++++++++++++++++++++++++++++ src/dusk/mods/svc/host.cpp | 13 +- src/dusk/mods/svc/registry.cpp | 2 + src/dusk/mods/svc/registry.hpp | 2 + 26 files changed, 2341 insertions(+), 14 deletions(-) create mode 100644 cmake/PatchCapstone.cmake create mode 100644 cmake/PatchFunchook.cmake create mode 100644 cmake/SymbolManifest.cmake create mode 100644 cmake/WindowsExports.cmake create mode 100644 include/dusk/profiling.hpp create mode 100644 include/mods/hook.hpp create mode 100644 include/mods/svc/game.h create mode 100644 include/mods/svc/hook.h create mode 100644 sdk/pseudo_reloc.cpp create mode 100644 src/dusk/mods/loader/manifest.cpp create mode 100644 src/dusk/mods/loader/manifest.hpp create mode 100644 src/dusk/mods/svc/game.cpp create mode 100644 src/dusk/mods/svc/hook.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d7093b097c..e62704fe1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -217,7 +217,41 @@ FetchContent_Declare(miniz DOWNLOAD_EXTRACT_TIMESTAMP TRUE EXCLUDE_FROM_ALL ) -FetchContent_MakeAvailable(cxxopts json miniz) + +set(_fetch_content_deps cxxopts json miniz) +if (DUSK_ENABLE_CODE_MODS) + message(STATUS "dusklight: Fetching funchook") + # cmake/PatchFunchook.cmake patches funchook's cmake/capstone.cmake.in to inject a + # PATCH_COMMAND into capstone's inner ExternalProject. That PATCH_COMMAND runs + # cmake/PatchCapstone.cmake after capstone is cloned, which removes the + # cmake_policy(SET CMP0048 OLD) line that CMake >= 3.27 rejects. + # This is incredibly scuffed and we should probably think of a better way to do this + set(CAPSTONE_FIX_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/cmake/PatchCapstone.cmake") + FetchContent_Declare(funchook + GIT_REPOSITORY https://github.com/kubo/funchook.git + GIT_TAG v1.1.3 + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE + PATCH_COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR= -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/PatchFunchook.cmake + EXCLUDE_FROM_ALL + ) + set(FUNCHOOK_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(FUNCHOOK_BUILD_SHARED OFF CACHE BOOL "" FORCE) + set(FUNCHOOK_INSTALL OFF CACHE BOOL "" FORCE) + if (APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _osx_arch_count) + if (_osx_arch_count EQUAL 1) + list(GET CMAKE_OSX_ARCHITECTURES 0 _osx_arch) + if (_osx_arch MATCHES "^(arm64|aarch64|ARM64)$") + set(FUNCHOOK_CPU arm64 CACHE STRING "" FORCE) + elseif (_osx_arch MATCHES "^(x86_64|AMD64|amd64|i[3-6]86|x86)$") + set(FUNCHOOK_CPU x86 CACHE STRING "" FORCE) + endif () + endif () + endif () + list(APPEND _fetch_content_deps funchook) +endif () +FetchContent_MakeAvailable(${_fetch_content_deps}) if (DUSK_ENABLE_SENTRY_NATIVE) message(STATUS "dusklight: Fetching sentry-native") @@ -273,6 +307,9 @@ find_package(Threads REQUIRED) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt Threads::Threads zstd::libzstd dusklight_game_headers) +if (DUSK_ENABLE_CODE_MODS) + list(APPEND GAME_LIBS funchook-static) +endif () if (DUSK_ENABLE_SENTRY_NATIVE) list(APPEND GAME_LIBS sentry) @@ -413,6 +450,52 @@ target_compile_definitions(dusklight PRIVATE ${GAME_COMPILE_DEFS}) target_include_directories(dusklight PRIVATE ${miniz_SOURCE_DIR}) target_link_libraries(dusklight PRIVATE aurora::main ${GAME_LIBS} ${JSYSTEM_LINK_LIBRARIES}) target_precompile_headers(dusklight PRIVATE "$<$:${CMAKE_SOURCE_DIR}/include/dusk_pch.hpp>") + +if (DUSK_ENABLE_CODE_MODS) + include(cmake/SymbolManifest.cmake) + if (WIN32) + # Game ABI exports & import library for mod linking + include(cmake/WindowsExports.cmake) + setup_windows_exports(dusklight) + endif () + # Post-link symbol manifest: hookable-surface name->address map keyed to the build. + setup_symbol_manifest(dusklight) +endif () + +# Hook reliability: guaranteed patchable entries on the game ABI surface, and no identical-code folding. +if (MSVC) + if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:/hotpatch>) + endif () + if (CMAKE_SYSTEM_PROCESSOR STREQUAL "ARM64") + target_link_options(dusklight PRIVATE /FUNCTIONPADMIN:16 /OPT:NOICF) + else () + target_link_options(dusklight PRIVATE /FUNCTIONPADMIN /OPT:NOICF) + endif () +elseif (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + if (CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64" OR CMAKE_OSX_ARCHITECTURES MATCHES "arm64") + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:-fpatchable-function-entry=2,1>) + else () + set(DUSK_PATCHABLE_ENTRY_FLAG $<$:-fpatchable-function-entry=10,5>) + endif () +endif () +if (DEFINED DUSK_PATCHABLE_ENTRY_FLAG) + target_compile_options(dusklight PRIVATE ${DUSK_PATCHABLE_ENTRY_FLAG}) + foreach(jsystem_lib IN LISTS JSYSTEM_LIBRARIES) + target_compile_options(${jsystem_lib} PRIVATE ${DUSK_PATCHABLE_ENTRY_FLAG}) + endforeach() +endif () + +if (WIN32) + target_link_libraries(dusklight PRIVATE Psapi) +endif () +if (APPLE) + # Mods resolve game symbols from the executable at dlopen time. + target_link_options(dusklight PRIVATE -Wl,-export_dynamic) +elseif (UNIX AND NOT ANDROID) + target_link_options(dusklight PRIVATE -rdynamic) +endif () + if (TARGET crashpad_handler) add_dependencies(dusklight crashpad_handler) add_custom_command(TARGET dusklight POST_BUILD diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index 909e8abcba..c320dcb5f4 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -85,6 +85,51 @@ function(add_mod target_name) endif () endif () + if (APPLE) + # Game symbols resolve against the host executable at dlopen time. + target_link_options(${target_name} PRIVATE -undefined dynamic_lookup) + elseif (ANDROID) + if (TARGET dusklight) + target_link_libraries(${target_name} PRIVATE dusklight) + elseif (DUSK_GAME_SOLIB) + target_link_libraries(${target_name} PRIVATE "${DUSK_GAME_SOLIB}") + else () + message(FATAL_ERROR "add_mod: DUSK_GAME_SOLIB is not set (libmain.so)") + endif () + elseif (UNIX) + target_link_options(${target_name} PRIVATE -Wl,--allow-shlib-undefined) + elseif (WIN32) + # Link against the generated import library (game ABI surface). Function calls + # resolve through import thunks. Data is toolchain dependent: + # - clang-cl: lld's mingw mode auto-imports data references, fixed up at load by + # the mod SDK's pseudo-relocation runtime (pseudo_reloc.cpp). + # - cl (MSVC): only DUSK_GAME_DATA-annotated data is reachable. Un-annotated + # references fail to link. + if (NOT DUSK_GAME_IMPLIB) + message(FATAL_ERROR "add_mod: DUSK_GAME_IMPLIB is not set.") + endif () + target_link_libraries(${target_name} PRIVATE "${DUSK_GAME_IMPLIB}") + set_target_properties(${target_name} PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreadedDLL") + target_compile_definitions(${target_name} PRIVATE _ITERATOR_DEBUG_LEVEL=0) + if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + target_compile_options(${target_name} PRIVATE "$<$:/clang:-mcmodel=large>") + target_sources(${target_name} PRIVATE "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../sdk/pseudo_reloc.cpp") + # lld mingw mode rewrites /DEFAULTLIB directives to -l style and skips %LIB%, so + # the CRT libraries and search paths are spelled out explicitly. + target_link_options(${target_name} PRIVATE -lldmingw /nodefaultlib /INCREMENTAL:NO) + target_link_libraries(${target_name} PRIVATE + msvcrt.lib msvcprt.lib vcruntime.lib ucrt.lib + oldnames.lib uuid.lib kernel32.lib user32.lib) + set(_lib_dirs "$ENV{LIB}") + if ("${_lib_dirs}" STREQUAL "") + message(FATAL_ERROR "add_mod: %LIB% is empty; configure from a VS dev shell") + endif () + foreach (_libdir IN LISTS _lib_dirs) + target_link_options(${target_name} PRIVATE "/libpath:${_libdir}") + endforeach () + endif () + endif () + set(_output_dir "${DUSK_MODS_OUTPUT_DIR}") if (ARG_OUTPUT_DIR) set(_output_dir "${ARG_OUTPUT_DIR}") diff --git a/cmake/PatchCapstone.cmake b/cmake/PatchCapstone.cmake new file mode 100644 index 0000000000..fa8a1dd160 --- /dev/null +++ b/cmake/PatchCapstone.cmake @@ -0,0 +1,13 @@ +# Patches capstone's CMakeLists.txt for compatibility with CMake >= 4.0: +# - Bumps cmake_minimum_required to 3.10 (CMake >= 4.0 dropped < 3.5 support; < 3.10 warns) +# - Removes cmake_policy(SET CMP0048 OLD) (rejected by CMake >= 3.27) +file(READ "${DIR}/CMakeLists.txt" _content) +string(REGEX REPLACE + "cmake_minimum_required[ \t]*\\([ \t]*VERSION[ \t]+[0-9]+\\.[0-9]+(\\.[0-9]+)?[ \t]*\\)" + "cmake_minimum_required(VERSION 3.10)" + _content "${_content}") +string(REGEX REPLACE + "cmake_policy[ \t]*\\([ \t]*SET[ \t]+CMP0048[ \t]+OLD[ \t]*\\)" + "# cmake_policy(SET CMP0048 OLD)" + _content "${_content}") +file(WRITE "${DIR}/CMakeLists.txt" "${_content}") diff --git a/cmake/PatchFunchook.cmake b/cmake/PatchFunchook.cmake new file mode 100644 index 0000000000..6d5a6e36b4 --- /dev/null +++ b/cmake/PatchFunchook.cmake @@ -0,0 +1,60 @@ +file(READ "${SOURCE_DIR}/cmake/capstone.cmake.in" _content) + +# Insert PATCH_COMMAND before CONFIGURE_COMMAND in the ExternalProject_Add. +# Bracket args prevent cmake from substituting ${...} while writing this file. +string(REPLACE + " CONFIGURE_COMMAND \"\"" + [=[ PATCH_COMMAND "${CMAKE_COMMAND}" -DDIR=${CMAKE_CURRENT_BINARY_DIR}/capstone-src -P "${CAPSTONE_FIX_SCRIPT}" + CONFIGURE_COMMAND ""]=] + _content "${_content}") + +file(WRITE "${SOURCE_DIR}/cmake/capstone.cmake.in" "${_content}") + +file(READ "${SOURCE_DIR}/src/funchook_unix.c" _unix_content) + +# macOS rejects the POSIX mprotect RWX/RW transition for executable image pages on arm64. +# Use Mach VM_PROT_COPY for the short patch window, then restore RX permissions. +if (NOT _unix_content MATCHES "VM_PROT_READ \\| VM_PROT_WRITE \\| VM_PROT_COPY") + string(REPLACE + [=[ rv = mprotect(mstate->addr, mstate->size, prot);]=] + [=[#ifdef __APPLE__ + kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, + (vm_size_t)mstate->size, FALSE, + VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY); + if (kr == KERN_SUCCESS) { + funchook_log(funchook, " unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR")\n", + mstate->addr, mstate->size, start, len); + return 0; + } + funchook_set_error_message(funchook, "Failed to unprotect memory %p (size=%"PRIuPTR", prot=read,write,copy) <- %p (size=%"PRIuPTR", error=%s)", + mstate->addr, mstate->size, start, len, + mach_error_string(kr)); + return FUNCHOOK_ERROR_MEMORY_FUNCTION; +#endif + rv = mprotect(mstate->addr, mstate->size, prot);]=] + _unix_content "${_unix_content}") + + string(REPLACE + [=[ char errbuf[128]; + int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] + [=[ char errbuf[128]; +#ifdef __APPLE__ + kern_return_t kr = vm_protect(mach_task_self(), (vm_address_t)mstate->addr, + (vm_size_t)mstate->size, FALSE, + VM_PROT_READ | VM_PROT_EXECUTE); + + if (kr == KERN_SUCCESS) { + funchook_log(funchook, " protect memory %p (size=%"PRIuPTR", prot=read,exec)\n", + mstate->addr, mstate->size); + return 0; + } + funchook_set_error_message(funchook, "Failed to protect memory %p (size=%"PRIuPTR", prot=read,exec, error=%s)", + mstate->addr, mstate->size, + mach_error_string(kr)); + return FUNCHOOK_ERROR_MEMORY_FUNCTION; +#endif + int rv = mprotect(mstate->addr, mstate->size, PROT_READ | PROT_EXEC);]=] + _unix_content "${_unix_content}") +endif () + +file(WRITE "${SOURCE_DIR}/src/funchook_unix.c" "${_unix_content}") diff --git a/cmake/SymbolManifest.cmake b/cmake/SymbolManifest.cmake new file mode 100644 index 0000000000..ffaae9177e --- /dev/null +++ b/cmake/SymbolManifest.cmake @@ -0,0 +1,125 @@ +include_guard(GLOBAL) + +get_filename_component(_SYMBOL_MANIFEST_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + +set(_SYMGEN_VERSION "1.1.0") +set(_SYMGEN_RELEASE_BASE_URL "https://github.com/encounter/symgen/releases/download/v${_SYMGEN_VERSION}") +set(SYMGEN_PATH "" CACHE FILEPATH "Path to a symgen executable; empty downloads the pinned release") +mark_as_advanced(SYMGEN_PATH) + +function(symgen_host_asset out_name) + string(TOLOWER "${CMAKE_HOST_SYSTEM_PROCESSOR}" _host_processor) + set(_asset "") + + if (CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + if (_host_processor MATCHES "^(arm64|aarch64)$") + set(_asset "symgen-macos-arm64") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-macos-x86_64") + endif () + elseif (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + if (_host_processor MATCHES "^(aarch64|arm64)$") + set(_asset "symgen-linux-aarch64") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-linux-x86_64") + elseif (_host_processor MATCHES "^(i[3-6]86|x86)$") + set(_asset "symgen-linux-i686") + endif () + elseif (CMAKE_HOST_WIN32) + if (_host_processor MATCHES "^(arm64|aarch64)$") + set(_asset "symgen-windows-arm64.exe") + elseif (_host_processor MATCHES "^(x86_64|amd64)$") + set(_asset "symgen-windows-x86_64.exe") + elseif (_host_processor MATCHES "^(i[3-6]86|x86)$") + set(_asset "symgen-windows-x86.exe") + endif () + endif () + + set(${out_name} "${_asset}" PARENT_SCOPE) +endfunction() + +function(ensure_symgen required) + if (TARGET symgen) + return() + endif () + + if (SYMGEN_PATH) + get_filename_component(_symgen "${SYMGEN_PATH}" ABSOLUTE) + if (NOT EXISTS "${_symgen}") + if (required) + message(FATAL_ERROR "symgen: SYMGEN_PATH does not exist: ${_symgen}") + endif () + message(STATUS "symgen: SYMGEN_PATH does not exist, symbol manifest generation " + "skipped (by-name hook resolution will be unavailable)") + return() + endif () + else () + symgen_host_asset(_asset) + if (_asset STREQUAL "") + if (required) + message(FATAL_ERROR "symgen: no prebuilt binary for host " + "${CMAKE_HOST_SYSTEM_NAME}/${CMAKE_HOST_SYSTEM_PROCESSOR} " + "(configure with -DDUSK_ENABLE_CODE_MODS=OFF)") + endif () + message(STATUS "symgen: no prebuilt binary for host " + "${CMAKE_HOST_SYSTEM_NAME}/${CMAKE_HOST_SYSTEM_PROCESSOR}; " + "symbol manifest generation skipped (by-name hook resolution will be unavailable)") + return() + endif () + + set(_symgen_dir "${CMAKE_BINARY_DIR}/_deps/symgen") + set(_symgen "${_symgen_dir}/${_asset}") + set(_url "${_SYMGEN_RELEASE_BASE_URL}/${_asset}") + message(STATUS "dusklight: Fetching symgen ${_SYMGEN_VERSION} (${_asset})") + file(MAKE_DIRECTORY "${_symgen_dir}") + file(DOWNLOAD "${_url}" "${_symgen}" + TLS_VERIFY ON + STATUS _download_status + SHOW_PROGRESS) + list(GET _download_status 0 _download_code) + if (NOT _download_code EQUAL 0) + list(GET _download_status 1 _download_message) + file(REMOVE "${_symgen}") + if (required) + message(FATAL_ERROR "symgen: failed to download ${_url}: ${_download_message}") + endif () + message(STATUS "symgen: failed to download ${_url}: ${_download_message}; " + "symbol manifest generation skipped (by-name hook resolution will be unavailable)") + return() + endif () + if (NOT CMAKE_HOST_WIN32) + file(CHMOD "${_symgen}" PERMISSIONS + OWNER_READ OWNER_WRITE OWNER_EXECUTE + GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE) + endif () + endif () + + add_custom_target(symgen DEPENDS "${_symgen}") + set(SYMGEN_EXE "${_symgen}" CACHE INTERNAL "symgen executable" FORCE) +endfunction() + +function(setup_symbol_manifest target) + ensure_symgen(TRUE) + if (NOT TARGET symgen) + return() + endif () + add_dependencies(${target} symgen) + + if (WIN32) + set(_input --pdb "$") + set(_out "$/dusklight.symdb") + else () + set(_input --binary "$") + if (APPLE) + set(_out "$/Resources/dusklight.symdb") + else () + set(_out "$/dusklight.symdb") + endif () + endif () + + add_custom_command(TARGET ${target} POST_BUILD + COMMAND "${SYMGEN_EXE}" manifest ${_input} --out "${_out}" + COMMENT "Generating symbol manifest" + VERBATIM) +endfunction() diff --git a/cmake/WindowsExports.cmake b/cmake/WindowsExports.cmake new file mode 100644 index 0000000000..f380094b78 --- /dev/null +++ b/cmake/WindowsExports.cmake @@ -0,0 +1,76 @@ +include_guard(GLOBAL) + +get_filename_component(_DUSK_WINDOWS_EXPORTS_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + +# Windows mod linking: generate the curated export surface for the game executable and the +# import library mods link against. symgen scans the built objects, filters by source, and +# writes a .def used by the main link and import library generation. +function(setup_windows_exports target) + if (NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(WARNING "dusklight: Windows code-mod exports are x64-only for now; skipping") + return() + endif () + + include("${_DUSK_WINDOWS_EXPORTS_CMAKE_DIR}/SymbolManifest.cmake") + ensure_symgen(TRUE) + set(_symgen "${SYMGEN_EXE}") + add_dependencies(${target} symgen) + + set(_rsp_lines "$") + foreach (_lib IN LISTS JSYSTEM_LIBRARIES) + list(APPEND _rsp_lines "$") + endforeach () + list(JOIN _rsp_lines "\n" _rsp_content) + set(_rsp "${CMAKE_BINARY_DIR}/dusklight_exports_input.rsp") + file(GENERATE OUTPUT "${_rsp}" CONTENT "${_rsp_content}") + + set(_sdk_args) + foreach (_lib aurora_card aurora_core aurora_dvd aurora_gd aurora_gx aurora_mtx + aurora_os aurora_pad aurora_si aurora_vi) + if (TARGET ${_lib}) + list(APPEND _sdk_args --sdk-lib "$") + endif () + endforeach () + + # Generate curated exports list from the main binary + set(_def "${CMAKE_BINARY_DIR}/dusklight_exports.def") + add_custom_command(TARGET ${target} PRE_LINK + # TODO: src/dusk/ is NOT excluded: inline code in game headers + # currently call into it (e.g. dusk::frame_interp::lookup_replacement). + COMMAND "${_symgen}" def + --rsp "${_rsp}" + --out "${_def}" + --exclude cmake_pch + --exclude miniz + --exclude asan_options + --max-exports 58000 + ${_sdk_args} + COMMENT "Generating dusklight exports" + VERBATIM) + target_link_options(${target} PRIVATE "/DEF:${_def}") + + # Generate import library for mods to link against. + set(_implib "${CMAKE_BINARY_DIR}/dusklight_imports.lib") + get_filename_component(_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) + find_program(DUSK_LLVM_DLLTOOL llvm-dlltool HINTS "${_compiler_dir}") + if (DUSK_LLVM_DLLTOOL) + set(_implib_cmd "${DUSK_LLVM_DLLTOOL}" -d "${_def}" -D dusklight.exe -m i386:x86-64 + -l "${_implib}") + else () + set(_implib_cmd "${CMAKE_AR}" /nologo "/def:${_def}" /machine:x64 /name:dusklight.exe + "/out:${_implib}") + endif () + add_custom_command(TARGET ${target} POST_BUILD + COMMAND ${_implib_cmd} + BYPRODUCTS "${_implib}" + COMMENT "Generating dusklight import library" + VERBATIM) + if ("$CACHE{DUSK_GAME_IMPLIB}" STREQUAL "") + set(DUSK_GAME_IMPLIB "${_implib}" CACHE INTERNAL "Import library for Windows mod linking") + endif () + set(DUSK_GAME_DEF "${_def}" CACHE INTERNAL "Curated export .def for the game executable") + + # Ship the import library as sdk/dusklight.lib in the install tree: mods may use it to + # compile against Dusklight without having to build the whole game. (See DUSK_GAME_IMPLIB) + install(FILES "${_implib}" DESTINATION sdk RENAME dusklight.lib) +endfunction() diff --git a/docs/modding.md b/docs/modding.md index 77becddf0b..2330181179 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -3,8 +3,9 @@ Mods are distributed as `.dusk` files: zip archives containing a `mod.json` manifest and, optionally, compiled code libraries and resources. -Everything a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and mods -can define their own to talk to each other. +Most things a mod does goes through **services**: small, versioned C APIs. Dusklight provides built-in services, and +mods can define their own to talk to each other. Mods also link against the game itself: include game headers and call +game functions directly. ## Table of Contents @@ -13,10 +14,11 @@ can define their own to talk to each other. 3. [Anatomy of a Code Mod](#anatomy-of-a-code-mod) 4. [Services](#services) 5. [Built-in Services](#built-in-services) -6. [Asset Overlays](#asset-overlays) -7. [Runtime Lifecycle](#runtime-lifecycle) -8. [Error Handling](#error-handling) -9. [Advanced: Exporting Services](#advanced-exporting-services) +6. [Hooking Game Functions](#hooking-game-functions) +7. [Asset Overlays](#asset-overlays) +8. [Runtime Lifecycle](#runtime-lifecycle) +9. [Error Handling](#error-handling) +10. [Advanced: Exporting Services](#advanced-exporting-services) --- @@ -211,6 +213,11 @@ svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch); `MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead. +### HookService (`mods/svc/hook.h`) + +Install hooks on game functions. You'll rarely call it directly; use the typed helpers in `mods/hook.hpp` described +below. + ### OverlayService (`mods/svc/overlay.h`) Registers DVD file overlays at runtime. The dynamic counterpart to the static `overlay/` directory ( @@ -302,6 +309,153 @@ Writes that store the same value are silent. Values applied from `config.json` o --- +## Hooking Game Functions + +`mods/hook.hpp` provides typed helpers over the hook service: + +```cpp +#include "mods/hook.hpp" +#include "mods/svc/hook.h" + +IMPORT_SERVICE(HookService, svc_hook); +``` + +### Pre-hooks + +Run before the original. Return `HOOK_SKIP_ORIGINAL` to cancel it (post-hooks still run). + +```cpp +HookAction on_pos_move_pre(ModContext*, void* args, void* retval, void* userdata) { + daAlink_c* link = dusk::mods::arg(args, 0); // arg 0 is `this` + if (link->shape_angle.y > 10000) { + return HOOK_SKIP_ORIGINAL; + } + return HOOK_CONTINUE; +} + +dusk::mods::hook_add_pre<&daAlink_c::posMove>(svc_hook, on_pos_move_pre); +``` + +### Post-hooks + +Run after the original (or after a replace-hook, or after a cancelled original). `retval` points to the return value, if +any. + +```cpp +void on_pos_move_post(ModContext*, void* args, void* retval, void* userdata) { ... } + +dusk::mods::hook_add_post<&daAlink_c::posMove>(svc_hook, on_pos_move_post); +``` + +### Replace-hooks + +Substitute the original entirely. Call through to it via `Hook<...>::g_orig` if needed: + +```cpp +using ExecuteEntry = dusk::mods::Hook<&daAlink_c::execute>; + +void on_execute_replace(ModContext*, void* args, void* retval, void*) { + int result = ExecuteEntry::g_orig(dusk::mods::arg(args, 0)); + if (retval != nullptr) { + *static_cast(retval) = result; + } +} + +dusk::mods::hook_replace<&daAlink_c::execute>(svc_hook, on_execute_replace); +``` + +By default a second replace-hook on the same function is a conflict; `HookOptions` (`replace_policy`, `priority`, +`userdata`) controls this and callback ordering. Multiple mods can attach pre/post hooks to the same function +independently. + +### Hooking by name + +Functions you can't name in C++ (file-local statics, private class members, anything not in a header) can be hooked by +symbol name instead. You must supply the signature along with the name. + +```cpp +// static callback used by Link's hookshot collider in d_a_alink_hook.inc +using HookshotHit = dusk::mods::NamedHook< + "daAlink_hookshotAtHitCallBack", + void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*)>; + +dusk::mods::hook_add_pre(svc_hook, on_hookshot_hit_pre); +... +HookshotHit::g_orig(link, atObjInf, target, tgObjInf); // call through to the original +``` + +Class member functions must include `Class*` as the first argument. + +The install fails with the resolve error when the name is missing (`MOD_UNAVAILABLE`), ambiguous (`MOD_CONFLICT`), +or the manifest is absent (`MOD_UNSUPPORTED`). Unlike `Hook<&Class::method>`, the signature is **not** +compiler-checked: a mismatched signature will corrupt the call. + +### Reading and writing arguments + +`args` is an array of pointers to the arguments. For member functions, index 0 is `this`; parameters follow in +declaration order. + +```cpp +T value = dusk::mods::arg(args, n); // copy +T& ref = dusk::mods::arg_ref(args, n); // read/write reference +``` + +```cpp +// fpc_ProcID fopAcM_createItem(..., int itemNo, ...): turn heart drops into green rupees +HookAction on_create_item_pre(ModContext*, void* args, void*, void*) { + int& itemNo = dusk::mods::arg_ref(args, 1); + if (itemNo == dItemNo_HEART_e) { + itemNo = dItemNo_GREEN_RUPEE_e; + } + return HOOK_CONTINUE; +} + +dusk::mods::hook_add_pre<&fopAcM_createItem>(svc_hook, on_create_item_pre); +``` + +For reference parameters (e.g. `const cXyz& pos`), `arg_ref` yields a direct reference. + +### Resolving symbols by name + +`resolve` looks a symbol up in the **symbol manifest**: a name→address map generated alongside every game build and +keyed to that exact binary. It covers the whole image, including functions that aren't exported (file-local statics), +which makes them hookable: + +```cpp +IMPORT_SERVICE(HookService, svc_hook); + +void* addr = nullptr; +uint32_t flags = 0; +if (svc_hook->resolve(mod_ctx, "GXSetProjection", &addr, &flags) == MOD_OK) { + // addr is the function's real address in the running game; hook or call it. +} +``` + +Two spellings work on every platform: + +- **Display names** (`daAlink_c::posMove`, `fapGm_Before`): the qualified name with no parameter list. They carry no + signature, so overload sets (and file-local statics sharing a name) return `MOD_CONFLICT`. +- **Decorated names** (`_ZN9daAlink_c7posMoveEv` / `?posMove@daAlink_c@@...`): the platform's mangled spelling in + dlsym convention (no Mach-O leading underscore). The escape hatch for overloads. + +`MOD_UNSUPPORTED` means the manifest is missing or was built for a different game binary. + +### Game code ABI contract + +If your mod calls or hooks game code directly (anything beyond the service APIs), import `GameService` ( +`mods/svc/game.h`): + +```cpp +IMPORT_SERVICE(GameService, svc_game); +``` + +Its major version is the game code ABI epoch: it's bumped when game struct or vtable layouts change incompatibly, and +the ordinary service version check then rejects your mod with a clear error instead of letting it corrupt memory in a +version it wasn't built for. Service-only and asset-only mods should *not* import it; they stay compatible across game +ABI changes. + +--- + ## Asset Overlays Files placed under `overlay/` in the `.dusk` archive override game files at the corresponding path. For example, @@ -331,7 +485,7 @@ and [TextureService](#textureservice-modssvctextureh). Mods can be disabled, re-enabled, and reloaded at runtime without restarting the game (the enabled state persists as the `mod..enabled` config var). Write your mod assuming this happens: -- **Disable** calls `mod_shutdown`, removes your services, overlays, and texture replacements (both static and +- **Disable** calls `mod_shutdown`, removes your hooks, services, overlays, and texture replacements (both static and runtime-registered), and unloads your library. - **Enable** and **Reload** load a *fresh copy* of your library, imports are re-resolved, and `mod_initialize` runs again. You never see a second `mod_initialize` on the same image, so just make `mod_shutdown` release anything the @@ -344,6 +498,10 @@ first (in reverse dependency order) and brings them back afterward. A mod whose suspended and resumes automatically when the provider returns. Mods with an *optional* import of a disabled provider restart with that import null. +**One caution for hooks:** lifecycle changes are applied between frames, which is safe for hooks on functions +that return every frame (effectively everything you'd normally hook). Avoid hooking a function that stays on +the stack for the whole session (e.g. the outermost main loop); a mod that does cannot be safely unloaded. + --- ## Error Handling diff --git a/files.cmake b/files.cmake index 03d7a3af66..524c53943f 100644 --- a/files.cmake +++ b/files.cmake @@ -1550,8 +1550,12 @@ set(DUSK_FILES src/dusk/mods/loader/loader.hpp src/dusk/mods/loader/native_module.cpp src/dusk/mods/loader/native_module.hpp + src/dusk/mods/loader/manifest.cpp + src/dusk/mods/loader/manifest.hpp src/dusk/mods/svc/config.cpp src/dusk/mods/svc/config.hpp + src/dusk/mods/svc/game.cpp + src/dusk/mods/svc/hook.cpp src/dusk/mods/svc/host.cpp src/dusk/mods/svc/log.cpp src/dusk/mods/svc/overlay.cpp diff --git a/include/d/d_com_inf_game.h b/include/d/d_com_inf_game.h index d240507f97..b66c1ee8e8 100644 --- a/include/d/d_com_inf_game.h +++ b/include/d/d_com_inf_game.h @@ -17,7 +17,7 @@ #include "m_Do/m_Do_graphic.h" #include -#include "tracy/Tracy.hpp" +#include "dusk/profiling.hpp" enum dComIfG_ButtonStatus { /* 0x00 */ BUTTON_STATUS_NONE, diff --git a/include/dusk/gx_helper.h b/include/dusk/gx_helper.h index bf5f3c4d3a..02a36fc6db 100644 --- a/include/dusk/gx_helper.h +++ b/include/dusk/gx_helper.h @@ -5,7 +5,8 @@ #include #include -#include "tracy/Tracy.hpp" + +#include "profiling.hpp" #if DUSK_GFX_DEBUG_GROUPS #define GX_DEBUG_GROUP(name, ...) \ diff --git a/include/dusk/profiling.hpp b/include/dusk/profiling.hpp new file mode 100644 index 0000000000..82dab45216 --- /dev/null +++ b/include/dusk/profiling.hpp @@ -0,0 +1,12 @@ +#pragma once + +#if defined(__has_include) +#if __has_include() +#include +#endif +#endif + +#ifndef ZoneScoped +#define ZoneScoped +#define ZoneScopedN(name) +#endif diff --git a/include/global.h b/include/global.h index 2a8e182bfa..5add912748 100644 --- a/include/global.h +++ b/include/global.h @@ -114,6 +114,19 @@ inline int __builtin_clz(unsigned int v) { #endif +// Data symbols exported from the main exe need dllimport on the mod side. +// DUSK_BUILDING_GAME is defined for the game build so the same headers work in both. +#if defined(TARGET_PC) && defined(_WIN32) && !defined(DUSK_BUILDING_GAME) +#define DUSK_GAME_EXTERN extern __declspec(dllimport) +#define DUSK_GAME_DATA __declspec(dllimport) +#elif defined(TARGET_PC) && defined(_WIN32) && defined(DUSK_BUILDING_GAME) +#define DUSK_GAME_EXTERN extern __declspec(dllexport) +#define DUSK_GAME_DATA __declspec(dllexport) +#else +#define DUSK_GAME_EXTERN extern +#define DUSK_GAME_DATA +#endif + #define FAST_DIV(x, n) (x >> (n / 2)) #define SQUARE(x) ((x) * (x)) diff --git a/include/mods/hook.hpp b/include/mods/hook.hpp new file mode 100644 index 0000000000..c58bd719c9 --- /dev/null +++ b/include/mods/hook.hpp @@ -0,0 +1,464 @@ +#pragma once + +#include "mods/svc/hook.h" + +#include +#include +#include +#include +#include +#include + +namespace dusk::mods { + +template +T arg(void* argsRaw, int n) noexcept { + void** args = static_cast(argsRaw); + return *static_cast > >(args[n]); +} + +template +std::remove_reference_t& arg_ref(void* argsRaw, int n) noexcept { + void** args = static_cast(argsRaw); + return *static_cast > >(args[n]); +} + +template +void* mfp_addr(F fn) noexcept { + void* p = nullptr; + static_assert(sizeof(fn) >= sizeof(void*), "unexpected function pointer size"); + std::memcpy(&p, &fn, sizeof(void*)); + return p; +} + +/* A string usable as a template argument: carries the hook target's symbol name and + * makes each NamedHook instantiation's static state unique. */ +template +struct FixedString { + char chars[N]{}; + constexpr FixedString(const char (&s)[N]) noexcept { + for (size_t i = 0; i < N; ++i) { + chars[i] = s[i]; + } + } +}; + +namespace detail { + +template +constexpr std::string_view class_name() { +#if defined(__clang__) || defined(__GNUC__) + // "... class_name() [T = daAlink_c]" / "... [with T = daAlink_c; ...]" + constexpr std::string_view fn = __PRETTY_FUNCTION__; + constexpr size_t start = fn.find("T = ") + 4; + return fn.substr(start, fn.find_first_of(";]", start) - start); +#elif defined(_MSC_VER) + // "... class_name(void)" + constexpr std::string_view fn = __FUNCSIG__; + constexpr size_t start = fn.find("class_name<") + 11; + constexpr std::string_view name = fn.substr(start, fn.rfind(">(") - start); + if constexpr (name.starts_with("class ")) { + return name.substr(6); + } else if constexpr (name.starts_with("struct ")) { + return name.substr(7); + } else { + return name; + } +#else +#error "unsupported compiler" +#endif +} + +/* The manifest name of C's vtable. Only unscoped, non-template class names are + * supported (an empty result fails resolution and the install reports it). */ +template +constexpr auto vtable_symbol() { + constexpr std::string_view name = class_name(); + constexpr bool simple = name.find_first_of(":<> ") == std::string_view::npos; + // "_ZTV" + decimal length + name / "??_7" + name + "@@6B@", NUL-terminated + std::array out{}; + if constexpr (!simple) { + return out; + } + size_t n = 0; +#if defined(_WIN32) + for (char c : {'?', '?', '_', '7'}) { + out[n++] = c; + } + for (char c : name) { + out[n++] = c; + } + for (char c : {'@', '@', '6', 'B', '@'}) { + out[n++] = c; + } +#else + for (char c : {'_', 'Z', 'T', 'V'}) { + out[n++] = c; + } + size_t len = name.size(); + char digits[8]{}; + size_t d = 0; + while (len != 0) { + digits[d++] = static_cast('0' + len % 10); + len /= 10; + } + while (d != 0) { + out[n++] = digits[--d]; + } + for (char c : name) { + out[n++] = c; + } +#endif + return out; +} + +#if defined(_WIN32) +/* Follow jump stubs, then match the MSVC vcall thunk a virtual mfp points at. + * Returns the vtable slot's byte offset, or npos when fn is not a vcall thunk. */ +inline size_t vcall_slot_offset(const void*& fn) noexcept { + constexpr size_t npos = static_cast(-1); +#if defined(_M_X64) || defined(__x86_64__) + const auto* p = static_cast(fn); + for (int i = 0; i < 8 && p[0] == 0xE9; ++i) { // incremental-link stubs + int32_t rel; + std::memcpy(&rel, p + 1, 4); + p += 5 + rel; + } + fn = p; + // The vptr load. Unoptimized clang-cl thunks spill/reload rcx first + // (push rax; mov [rsp], rcx; mov rcx, [rsp]), so scan a short window. + const uint8_t* q = nullptr; + for (int i = 0; i <= 12; ++i) { + if (p[i] == 0x48 && p[i + 1] == 0x8B && p[i + 2] == 0x01) { // mov rax, [rcx] + q = p + i + 3; + break; + } + } + if (q == nullptr) { + return npos; + } + if (q[0] == 0xFF && q[1] == 0x20) { // jmp [rax] (MSVC) + return 0; + } + if (q[0] == 0xFF && q[1] == 0x60) { // jmp [rax + imm8] + return static_cast(q[2]); + } + if (q[0] == 0xFF && q[1] == 0xA0) { // jmp [rax + imm32] + int32_t off; + std::memcpy(&off, q + 2, 4); + return off; + } + // clang-cl: mov rax, [rax + off]; (pop r10;) jmp rax. Requiring the jmp rax + // distinguishes the thunk from an ordinary getter that begins the same way. + if (q[0] == 0x48 && q[1] == 0x8B && (q[2] == 0x00 || q[2] == 0x40 || q[2] == 0x80)) { + size_t off = 0; + const uint8_t* r = q + 3; + if (q[2] == 0x40) { + off = static_cast(q[3]); + r = q + 4; + } else if (q[2] == 0x80) { + int32_t off32; + std::memcpy(&off32, q + 3, 4); + off = off32; + r = q + 7; + } + for (int i = 0; i <= 8; ++i) { + if (r[i] == 0xFF && r[i + 1] == 0xE0) { // jmp rax (48 REX optional) + return off; + } + } + } + return npos; +#elif defined(_M_ARM64) || defined(__aarch64__) + const auto* p = static_cast(fn); + uint32_t insn[3]; + for (int i = 0; i < 8; ++i) { // incremental-link `b` stubs + std::memcpy(insn, p, 4); + if ((insn[0] & 0xFC000000u) != 0x14000000u) { + break; + } + const auto imm26 = static_cast(insn[0] << 6) >> 6; + p += static_cast(imm26) * 4; + } + fn = p; + std::memcpy(insn, p, 12); + // ldr Xt, [x0]; ldr Xs, [Xt, #imm12*8]; br Xs + if ((insn[0] & 0xFFFFFFE0u) != 0xF9400000u) { + return npos; + } + const uint32_t t = insn[0] & 0x1Fu; + if ((insn[1] & 0xFFC003E0u) != (0xF9400000u | (t << 5))) { + return npos; + } + const uint32_t s = insn[1] & 0x1Fu; + if (insn[2] != (0xD61F0000u | (s << 5))) { + return npos; + } + return ((insn[1] >> 10) & 0xFFFu) * 8; +#else + (void)fn; + return npos; +#endif +} +#endif + +/* Code address of the member function a mfp designates. Virtual mfps don't carry + * one; recover it from the class's vtable (resolved from the symbol manifest), so + * Hook works uniformly on virtual and non-virtual members. */ +template +ModResult member_target(const HookService* hooks, F mfp, void** out) { + *out = nullptr; + uintptr_t words[sizeof(F) > sizeof(uintptr_t) ? 2 : 1] = {}; + std::memcpy(words, &mfp, sizeof(words) < sizeof(F) ? sizeof(words) : sizeof(F)); + +#if defined(_WIN32) + const void* fn = reinterpret_cast(words[0]); + const size_t slot = vcall_slot_offset(fn); + if (slot == static_cast(-1)) { // not a vcall thunk: direct address + *out = const_cast(fn); + return MOD_OK; + } + void* vtable = nullptr; + const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol().data(), &vtable, nullptr); + if (resolved != MOD_OK) { + return resolved; + } + // ??_7 points at the first slot. + *out = *reinterpret_cast(static_cast(vtable) + slot); +#else +#if defined(__aarch64__) || defined(__arm__) + // AAPCS C++ ABI: the virtual flag is bit 0 of the adjustment word (function + // addresses can't spare their low bit), and ptr holds the slot offset directly. + const bool isVirtual = (words[1] & 1) != 0; + const uintptr_t thisAdjust = words[1] >> 1; + const uintptr_t slotOffset = words[0]; +#else + // Itanium C++ ABI: virtual mfps set bit 0 of ptr; the slot offset is ptr - 1. + const bool isVirtual = (words[0] & 1) != 0; + const uintptr_t thisAdjust = words[1]; + const uintptr_t slotOffset = words[0] - 1; +#endif + if (!isVirtual) { // non-virtual: the address itself + *out = reinterpret_cast(words[0]); + return MOD_OK; + } + if (thisAdjust != 0) { + // this-adjusting mfp (member of a secondary base): the slot offset is + // relative to a vtable we can't locate. Hook the overrider by name instead. + return MOD_UNSUPPORTED; + } + void* vtable = nullptr; + const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol().data(), &vtable, nullptr); + if (resolved != MOD_OK) { + return resolved; + } + // _ZTV points at the offset-to-top slot; the address point mfps index from is + // two pointers in (past offset-to-top and the typeinfo pointer). + *out = *reinterpret_cast(static_cast(vtable) + 2 * sizeof(void*) + slotOffset); +#endif + return *out != nullptr ? MOD_OK : MOD_UNAVAILABLE; +} + +} // namespace detail + +/* Trampoline generator + per-target state shared by Hook and NamedHook. Tag makes + * each hooked target's statics distinct; the target address is filled in at install. */ +template +struct HookImpl { + static inline R (*g_orig)(A...) = nullptr; + static inline const HookService* hooks = nullptr; + static inline void* target = nullptr; + + static bool dispatch_pre(void* args, void* retval) { + if (hooks == nullptr) { + return false; + } + + int skipOriginal = 0; + const ModResult result = hooks->dispatch_pre(mod_ctx, target, args, retval, &skipOriginal); + return result == MOD_OK && skipOriginal != 0; + } + + static void dispatch_post(void* args, void* retval) { + if (hooks != nullptr) { + hooks->dispatch_post(mod_ctx, target, args, retval); + } + } + + static R trampoline(A... args) { + if constexpr (sizeof...(A) == 0) { + if constexpr (std::is_void_v) { + const bool skipOriginal = dispatch_pre(nullptr, nullptr); + if (!skipOriginal) { + g_orig(args...); + } + dispatch_post(nullptr, nullptr); + } else { + R result{}; + const bool skipOriginal = + dispatch_pre(nullptr, static_cast(std::addressof(result))); + if (!skipOriginal) { + result = g_orig(args...); + } + dispatch_post(nullptr, static_cast(std::addressof(result))); + return result; + } + } else { + void* ptrs[] = {static_cast(std::addressof(args))...}; + if constexpr (std::is_void_v) { + const bool skipOriginal = dispatch_pre(static_cast(ptrs), nullptr); + if (!skipOriginal) { + g_orig(args...); + } + dispatch_post(static_cast(ptrs), nullptr); + } else { + R result{}; + const bool skipOriginal = dispatch_pre( + static_cast(ptrs), static_cast(std::addressof(result))); + if (!skipOriginal) { + result = g_orig(args...); + } + dispatch_post(static_cast(ptrs), static_cast(std::addressof(result))); + return result; + } + } + } +}; + +namespace detail { +template +using TargetTag = std::integral_constant; +template +struct NameTag {}; +} // namespace detail + +/* + * Typed hook on a function named at compile time (&daAlink_c::execute, &free_fn). + * Member functions may be virtual: the install decodes the member function pointer and hooks the + * class's own overrider. + */ +template +struct Hook; + +template +struct Hook : HookImpl, R, C*, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + return detail::member_target(hooks, Target, out); + } +}; + +template +struct Hook : HookImpl, R, const C*, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + return detail::member_target(hooks, Target, out); + } +}; + +template +struct Hook : HookImpl, R, A...> { + static ModResult resolve_target(const HookService*, void** out) { + *out = mfp_addr(Target); + return MOD_OK; + } +}; + +/* + * Typed hook on a function by its symbol name, for targets you can't name in C++: file-local + * statics, private members, or symbols without a header. The signature is written free-style with + * the receiver first and is *not* compiler-checked. + * + * using HookshotHit = dusk::mods::NamedHook< + * "daAlink_hookshotAtHitCallBack", + * void(fopAc_ac_c*, dCcD_GObjInf*, fopAc_ac_c*, dCcD_GObjInf*)>; + * dusk::mods::hook_add_pre(svc_hook, on_hookshot_hit); + */ +template +struct NamedHook; + +template +struct NamedHook : HookImpl, R, A...> { + static ModResult resolve_target(const HookService* hooks, void** out) { + HookSymbolFlags flags{}; + const ModResult resolved = hooks->resolve(mod_ctx, Name.chars, out, &flags); + if (resolved == MOD_OK && (flags & HOOK_SYMBOL_CODE) == 0) { + *out = nullptr; + return MOD_INVALID_ARGUMENT; + } + return resolved; + } +}; + +template +ModResult hook_install(const HookService* hooks) { + if (hooks == nullptr) { + return MOD_UNAVAILABLE; + } + + Entry::hooks = hooks; + if (Entry::target == nullptr) { + const ModResult resolved = Entry::resolve_target(hooks, &Entry::target); + if (resolved != MOD_OK) { + return resolved; + } + } + return hooks->install(mod_ctx, Entry::target, reinterpret_cast(Entry::trampoline), + reinterpret_cast(&Entry::g_orig)); +} + +template +ModResult hook_install(const HookService* hooks) { + return hook_install >(hooks); +} + +template +ModResult hook_add_pre( + const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) { + const ModResult installed = hook_install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->add_pre(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_add_pre( + const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) { + return hook_add_pre >(hooks, callback, options); +} + +template +ModResult hook_add_post( + const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) { + const ModResult installed = hook_install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->add_post(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_add_post( + const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) { + return hook_add_post >(hooks, callback, options); +} + +template +ModResult hook_replace( + const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) { + const ModResult installed = hook_install(hooks); + if (installed != MOD_OK) { + return installed; + } + + return hooks->replace(mod_ctx, Entry::target, callback, options); +} + +template +ModResult hook_replace( + const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) { + return hook_replace >(hooks, callback, options); +} + +} // namespace dusk::mods diff --git a/include/mods/svc/game.h b/include/mods/svc/game.h new file mode 100644 index 0000000000..fbd914d4bc --- /dev/null +++ b/include/mods/svc/game.h @@ -0,0 +1,30 @@ +#pragma once + +#include "mods/api.h" + +/* + * Mods that link or hook game code directly must import this service; service-only and asset-only + * mods must not. + * + * Major version is the game-code ABI epoch: it is bumped when game-visible struct or vtable layouts + * change incompatibly (e.g. a TARGET_PC field added to an existing game struct). The loader's + * ordinary version check then fails mods built against the old epoch with a clear message instead + * of letting them corrupt memory. + */ +#define GAME_SERVICE_ID "dev.twilitrealm.dusklight.game" +#define GAME_SERVICE_MAJOR 1u +#define GAME_SERVICE_MINOR 0u + +typedef struct GameService { + ServiceHeader header; +} GameService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = GAME_SERVICE_ID; + static constexpr uint16_t major_version = GAME_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/hook.h b/include/mods/svc/hook.h new file mode 100644 index 0000000000..5eb710d89d --- /dev/null +++ b/include/mods/svc/hook.h @@ -0,0 +1,131 @@ +#pragma once + +#include "mods/api.h" + +/* + * Intercept game functions by address. Prefer the typed helpers in mods/hook.hpp + * (hook_add_pre/hook_add_post/hook_replace over a &Class::method): they generate the + * trampoline and hide install/dispatch, which are the low-level primitives those helpers + * build. resolve() maps a symbol name to an address for targets you can't name at compile time + * (file-local statics included). + * + * Every call is game-thread-only. Install and removal must run with no hooked function on the + * stack; the loader guarantees this by applying mod lifecycle changes between frames, which is + * why hooking a function that never returns (the outermost loop) makes a mod un-unloadable. + */ + +#define HOOK_SERVICE_ID "dev.twilitrealm.dusklight.hook" +#define HOOK_SERVICE_MAJOR 1u +#define HOOK_SERVICE_MINOR 0u + +/* Symbol flags reported by resolve() */ +typedef enum HookSymbolFlags { + HOOK_SYMBOL_CODE = 1u << 0u, + HOOK_SYMBOL_DATA = 1u << 1u, + /* Not exported/dynamically visible: hookable, but never linkable. */ + HOOK_SYMBOL_LOCAL = 1u << 2u, + /* Other names share this address (ICF fold/alias): a hook intercepts them all. */ + HOOK_SYMBOL_MULTI_NAME = 1u << 3u, + /* Resolved through a demangled display-name alias rather than the real symbol. */ + HOOK_SYMBOL_DISPLAY = 1u << 6u, +} HookSymbolFlags; + +/* A pre-hook's return value: whether to run the original function. */ +typedef enum HookAction { + HOOK_CONTINUE = 0, /* run the original (and any lower-priority pre-hooks) */ + HOOK_SKIP_ORIGINAL = 1, /* cancel the original and remaining pre-hooks; post-hooks still run */ +} HookAction; + +/* How replace resolves a second replace-hook on a target that already has one. */ +typedef enum HookReplacePolicy { + HOOK_REPLACE_CONFLICT = 0, /* refuse with MOD_CONFLICT (the default) */ + HOOK_REPLACE_PRIORITY = 1, /* take over only if this options.priority is strictly higher */ + HOOK_REPLACE_OVERRIDE = 2, /* take over unconditionally */ +} HookReplacePolicy; + +typedef enum HookFlags { + HOOK_FLAG_NONE = 0u, + HOOK_FLAG_FAIL_ON_CONFLICT = 1u << 0u, +} HookFlags; + +/* + * Hook callbacks. `args` is an array of pointers to the call's arguments (index 0 is `this` + * for member functions); `retval` points at the return slot (NULL for void). Read and write + * them through dusk::mods::arg / arg_ref from mods/hook.hpp. `userdata` is the pointer + * from HookOptions. All run on the game thread, in the hooked call's own stack frame. + */ +typedef HookAction (*HookPreFn)(ModContext* ctx, void* args, void* retval, void* userdata); +typedef void (*HookPostFn)(ModContext* ctx, void* args, void* retval, void* userdata); +typedef void (*HookReplaceFn)(ModContext* ctx, void* args, void* retval, void* userdata); + +typedef struct HookOptions { + uint32_t struct_size; + /* Higher runs first; ties break by registration order. Applies to pre/post ordering and, + * with HOOK_REPLACE_PRIORITY, to replace-hook takeover. */ + int32_t priority; + uint32_t flags; /* HookFlags */ + HookReplacePolicy replace_policy; + void* userdata; /* passed back to the callback */ +} HookOptions; + +#define HOOK_OPTIONS_INIT {sizeof(HookOptions), 0, HOOK_FLAG_NONE, HOOK_REPLACE_CONFLICT, NULL} + +typedef struct HookService { + ServiceHeader header; + + /* + * Install a trampoline detour on fn_addr and return the address to call the original through in + * *out_original_fn. The typed helpers generate the trampoline and call this; mods normally + * don't. The first mod to install a given target owns the live detour; later mods register as + * candidates so a hook survives the owner unloading (the detour is handed off and every + * original pointer is rewritten). Idempotent per (mod, out slot). + */ + ModResult (*install)( + ModContext* ctx, void* fn_addr, void* trampoline_fn, void** out_original_fn); + + /* + * Register a callback on an already-installed target. Pre runs before the original (and can + * cancel it), post runs after (even if cancelled). Any number of mods may add pre/post to the + * same target; they run in priority then registration order. replace installs a single + * substitute for the original, managed by options.replace_policy, MOD_CONFLICT if refused. + */ + ModResult (*add_pre)( + ModContext* ctx, void* fn_addr, HookPreFn callback, const HookOptions* options); + ModResult (*add_post)( + ModContext* ctx, void* fn_addr, HookPostFn callback, const HookOptions* options); + ModResult (*replace)( + ModContext* ctx, void* fn_addr, HookReplaceFn callback, const HookOptions* options); + + /* + * Run the registered callbacks for a target. The generated trampoline calls these; they + * are not a mod-facing entry point. dispatch_pre reports through *out_skip_original + * whether the original should be skipped (a pre-hook returned HOOK_SKIP_ORIGINAL, or a + * replace-hook ran). + */ + ModResult (*dispatch_pre)( + ModContext* ctx, void* fn_addr, void* args, void* retval, int* out_skip_original); + ModResult (*dispatch_post)(ModContext* ctx, void* fn_addr, void* args, void* retval); + + /* + * Resolve a game symbol by name from the symbol manifest, including non-exported (static) + * functions. Names can be either the platform's mangled name (i.e. the name passed to dlopen; + * no Mach-O leading underscore) or the qualified function name without parameters (e.g. + * "daAlink_c::execute"). out_flags (optional) receives HookSymbolFlags. + * + * Results: MOD_OK; MOD_UNSUPPORTED (no manifest for this build, missing or stale); + * MOD_UNAVAILABLE (symbol not found); MOD_CONFLICT (name maps to more than one address: C++ + * overloads or per-TU statics; use the mangled name). + */ + ModResult (*resolve)( + ModContext* ctx, const char* symbol, void** out_addr, HookSymbolFlags* out_flags); +} HookService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = HOOK_SERVICE_ID; + static constexpr uint16_t major_version = HOOK_SERVICE_MAJOR; +}; +#endif diff --git a/include/mods/svc/host.h b/include/mods/svc/host.h index 61d0831dae..43c19cadfb 100644 --- a/include/mods/svc/host.h +++ b/include/mods/svc/host.h @@ -8,8 +8,8 @@ */ #define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" -#define HOST_SERVICE_MAJOR 1u -#define HOST_SERVICE_MINOR 1u +#define HOST_SERVICE_MAJOR 2u +#define HOST_SERVICE_MINOR 0u /* * Ignore unknown values: later service minors may add events. @@ -35,6 +35,14 @@ typedef void (*ModLifecycleFn)(ModContext* ctx, ModContext* subject, const char* typedef struct HostService { ServiceHeader header; + /* Version string of the current Dusklight build. (e.g. "1.4.2") */ + const char* version; + + /* Build id of the running game binary: PDB GUID+age on Windows, LC_UUID on macOS, GNU build-id + * on Linux. May be empty (len 0) if the identity could not be determined. */ + const uint8_t* build_id; + uint32_t build_id_len; + /* * Look up a service by id at call time. Unlike a manifest import, this sees whatever is * currently published and carries no initialization-order guarantee (see mods/api.h). diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index 63953e78bf..533bcc22c1 100644 --- a/sdk/CMakeLists.txt +++ b/sdk/CMakeLists.txt @@ -6,6 +6,9 @@ # Usage (from a mod project): # add_subdirectory(/sdk dusk-sdk EXCLUDE_FROM_ALL) # add_mod(my_mod SOURCES ... MOD_JSON mod.json) +# +# On Windows, pass -DDUSK_GAME_IMPLIB= from the +# matching game release. TODO: auto-download from tag cmake_minimum_required(VERSION 3.25) @@ -30,4 +33,11 @@ configure_version_header() # Game ABI headers & compile definitions include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/GameABIConfig.cmake") +if (WIN32) + set(DUSK_GAME_IMPLIB "" CACHE FILEPATH "Path to the dusklight import library (sdk/dusklight.lib)") + if (DUSK_GAME_IMPLIB AND NOT EXISTS "${DUSK_GAME_IMPLIB}") + message(FATAL_ERROR "Mod SDK: DUSK_GAME_IMPLIB does not exist: ${DUSK_GAME_IMPLIB}") + endif () +endif () + include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/ModSDK.cmake") diff --git a/sdk/pseudo_reloc.cpp b/sdk/pseudo_reloc.cpp new file mode 100644 index 0000000000..f86d783f2d --- /dev/null +++ b/sdk/pseudo_reloc.cpp @@ -0,0 +1,139 @@ +// Mod SDK runtime (Windows): applies lld's MinGW-style runtime pseudo-relocations on +// the plain MSVC CRT. This is what lets mods reference game data (`extern` globals) with no +// __declspec(dllimport) annotations: `lld-link -lldmingw` auto-imports the data through IAT +// slots and records fixups, and this module applies them at load time. +// +// The fixup pass runs as an early CRT initializer, and the IAT slots it reads were already +// bound by the OS loader, so even mod static initializers observe patched references. + +#include + +#include +#include +#include + +extern "C" char __RUNTIME_PSEUDO_RELOC_LIST__; +extern "C" char __RUNTIME_PSEUDO_RELOC_LIST_END__; +extern "C" IMAGE_DOS_HEADER __ImageBase; + +namespace { + +struct HdrV2 { + uint32_t magic1; + uint32_t magic2; + uint32_t version; +}; +struct ItemV2 { + uint32_t sym; // RVA of the __imp_ slot (OS-bound IAT entry) + uint32_t target; // RVA of the reference to patch + uint32_t flags; // low 8 bits: bit width of the reference +}; + +bool g_relocsFailed = false; + +void report(const char* fmt, ...) { + char buf[512]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + fprintf(stderr, "%s\n", buf); + OutputDebugStringA(buf); +} + +bool compute_fixup(const ItemV2& item, char* base, intptr_t* out) { + char* impSlot = base + item.sym; + const intptr_t real = *reinterpret_cast(impSlot); + char* target = base + item.target; + const int bits = static_cast(item.flags & 0xff); + + intptr_t reldata; + switch (bits) { + case 8: + reldata = *reinterpret_cast(target); + break; + case 16: + reldata = *reinterpret_cast(target); + break; + case 32: + reldata = *reinterpret_cast(target); + break; + case 64: + reldata = *reinterpret_cast(target); + break; + default: + report("unsupported %d-bit pseudo-relocation at RVA 0x%x", bits, item.target); + return false; + } + reldata -= reinterpret_cast(impSlot); + reldata += real; + + if (bits < 64) { + const intptr_t maxUnsigned = (intptr_t{1} << bits) - 1; + const intptr_t minSigned = -(intptr_t{1} << (bits - 1)); + if (reldata > maxUnsigned || reldata < minSigned) { + report("%d-bit data fixup at RVA 0x%x is out of range (delta %+lld)", bits, item.target, + static_cast(reldata)); + return false; + } + } + *out = reldata; + return true; +} + +} // namespace + +// lld refuses to emit runtime pseudo-relocs unless a function with exactly this (mingw CRT) +// name exists in the image. It is also our actual entry point. +extern "C" void _pei386_runtime_relocator() { + char* base = reinterpret_cast(&__ImageBase); + char* start = &__RUNTIME_PSEUDO_RELOC_LIST__; + char* end = &__RUNTIME_PSEUDO_RELOC_LIST_END__; + if (end - start < static_cast(sizeof(HdrV2))) { + return; // no data auto-imports in this mod + } + const HdrV2* hdr = reinterpret_cast(start); + if (hdr->magic1 != 0 || hdr->magic2 != 0 || hdr->version != 1) { + report("unexpected pseudo-relocation list format"); + g_relocsFailed = true; + return; + } + const ItemV2* items = reinterpret_cast(hdr + 1); + const ItemV2* itemsEnd = reinterpret_cast(end); + + // Validate everything before writing anything, so a bad fixup can't leave the image + // half-patched. + intptr_t scratch; + for (const ItemV2* it = items; it < itemsEnd; ++it) { + if (!compute_fixup(*it, base, &scratch)) { + g_relocsFailed = true; + return; + } + } + for (const ItemV2* it = items; it < itemsEnd; ++it) { + intptr_t reldata; + compute_fixup(*it, base, &reldata); + char* target = base + it->target; + const size_t len = static_cast(it->flags & 0xff) / 8; + DWORD old = 0; + if (!VirtualProtect(target, len, PAGE_EXECUTE_READWRITE, &old)) { + report("VirtualProtect failed at RVA 0x%x", it->target); + g_relocsFailed = true; + return; + } + std::memcpy(target, &reldata, len); + VirtualProtect(target, len, old, &old); + } +} + +using PVFV = void (*)(); +#pragma section(".CRT$XCB", read) +extern "C" __declspec(allocate(".CRT$XCB")) PVFV dusk_mod_pseudo_reloc_init = + _pei386_runtime_relocator; + +BOOL WINAPI DllMain(HINSTANCE, DWORD reason, LPVOID) { + if (reason == DLL_PROCESS_ATTACH && g_relocsFailed) { + return FALSE; + } + return TRUE; +} diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 00c2cc69e8..fb293a55b4 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -11,9 +11,10 @@ #include "depgraph.hpp" #include "dusk/config.hpp" -#include "dusk/mods/svc/config.hpp" #include "dusk/io.hpp" +#include "dusk/mods/svc/config.hpp" #include "dusk/mods/svc/registry.hpp" +#include "manifest.hpp" #include "miniz.h" #include "native_module.hpp" #include "nlohmann/json.hpp" @@ -639,6 +640,8 @@ void ModLoader::init() { } m_initialized = true; + manifest::initialize(); + if (m_searchDirs.empty()) { Log.warn("no mod search directories configured; mod loading skipped"); return; diff --git a/src/dusk/mods/loader/manifest.cpp b/src/dusk/mods/loader/manifest.cpp new file mode 100644 index 0000000000..7e83dfb8f6 --- /dev/null +++ b/src/dusk/mods/loader/manifest.cpp @@ -0,0 +1,386 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include "manifest.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "aurora/lib/logging.hpp" + +#include "dusk/io.hpp" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#elif defined(__APPLE__) +#include +#include +#elif defined(__linux__) +#include +#include +#endif + +namespace dusk::mods::manifest { +namespace { + +aurora::Module Log("dusk::mods::manifest"); + +constexpr char kMagic[8] = {'S', 'Y', 'M', 'G', 'E', 'N', '\0', '\0'}; +constexpr uint32_t kVersion = 2; + +enum class Compression : uint32_t { + None = 0, + Zstd = 1, +}; + +// Mirrors the symgen manifest writer. +struct Header { + char magic[8]; + uint32_t version; + uint32_t compression; + uint64_t uncompressedLen; + uint64_t compressedLen; + uint32_t buildIdLen; + uint8_t buildId[32]; + uint32_t entryCount; +}; +static_assert(sizeof(Header) == 72); + +struct Entry { + uint64_t hash; + uint64_t rva; + uint32_t nameOff; + HookSymbolFlags flags; +}; +static_assert(sizeof(Entry) == 24); + +struct State { + std::vector data; + const Entry* entries = nullptr; + uint32_t entryCount = 0; + const char* strings = nullptr; + uint64_t stringsLen = 0; + uintptr_t imageBase = 0; + // (rva, nameOff) of entries flagged kFlagInlineSites, sorted by rva + std::vector > inlineSites; + bool loaded = false; + bool initialized = false; +}; +State s_state; + +uint64_t fnv1a64(const char* str) { + uint64_t hash = 0xcbf29ce484222325ull; + for (const char* p = str; *p != '\0'; ++p) { + hash ^= static_cast(*p); + hash *= 0x100000001b3ull; + } + return hash; +} + +// Build id of the running executable image, matching what symgen recorded: +// PDB GUID (RFC 4122 byte order) + age on Windows, LC_UUID on Mach-O, GNU +// build-id on ELF. Also reports the address RVAs are relative to. +bool running_image_identity(std::vector& outId, uintptr_t& outBase) { +#if defined(_WIN32) + auto* base = reinterpret_cast(GetModuleHandleW(nullptr)); + outBase = reinterpret_cast(base); + const auto* dos = reinterpret_cast(base); + const auto* nt = reinterpret_cast(base + dos->e_lfanew); + const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG]; + if (dir.VirtualAddress == 0) { + return false; + } + const auto* entries = reinterpret_cast(base + dir.VirtualAddress); + for (size_t i = 0; i < dir.Size / sizeof(IMAGE_DEBUG_DIRECTORY); ++i) { + if (entries[i].Type != IMAGE_DEBUG_TYPE_CODEVIEW) { + continue; + } + struct CvInfo { + uint32_t signature; // 'RSDS' + uint8_t guid[16]; + uint32_t age; + }; + if (entries[i].SizeOfData < sizeof(CvInfo)) { + continue; + } + const auto* cv = reinterpret_cast(base + entries[i].AddressOfRawData); + if (cv->signature != 0x53445352) { // "RSDS" + continue; + } + // The GUID struct stores Data1..Data3 little-endian in memory; the manifest + // stores RFC 4122 (big-endian) order, so swap them here. + outId.assign(cv->guid, cv->guid + 16); + std::swap(outId[0], outId[3]); + std::swap(outId[1], outId[2]); + std::swap(outId[4], outId[5]); + std::swap(outId[6], outId[7]); + for (int b = 0; b < 4; ++b) { + outId.push_back(static_cast(cv->age >> (8 * b))); + } + return true; + } + return false; +#elif defined(__APPLE__) + // Image 0 is the main executable. The manifest stores link-time vmaddrs + // (nm convention, __TEXT vmaddr included). + const auto* header = _dyld_get_image_header(0); + outBase = static_cast(_dyld_get_image_vmaddr_slide(0)); + const auto* header64 = reinterpret_cast(header); + const auto* cmd = reinterpret_cast(header64 + 1); + for (uint32_t i = 0; i < header64->ncmds; ++i) { + if (cmd->cmd == LC_UUID) { + const auto* uuidCmd = reinterpret_cast(cmd); + outId.assign(uuidCmd->uuid, uuidCmd->uuid + 16); + return true; + } + cmd = reinterpret_cast( + reinterpret_cast(cmd) + cmd->cmdsize); + } + return false; +#elif defined(__linux__) + struct Ctx { + std::vector* id; + uintptr_t base = 0; + bool found = false; + } ctx{&outId}; + dl_iterate_phdr( + [](dl_phdr_info* info, size_t, void* data) -> int { + auto* ctx = static_cast(data); + // The first callback is the main executable. + ctx->base = info->dlpi_addr; + for (int i = 0; i < info->dlpi_phnum; ++i) { + const auto& phdr = info->dlpi_phdr[i]; + if (phdr.p_type != PT_NOTE) { + continue; + } + const auto* p = reinterpret_cast(info->dlpi_addr + phdr.p_vaddr); + const auto* end = p + phdr.p_memsz; + while (p + sizeof(ElfW(Nhdr)) <= end) { + const auto* note = reinterpret_cast(p); + const auto* name = p + sizeof(ElfW(Nhdr)); + const auto* desc = name + ((note->n_namesz + 3) & ~3u); + if (note->n_type == NT_GNU_BUILD_ID && note->n_namesz == 4 && + std::memcmp(name, "GNU", 4) == 0) + { + ctx->id->assign(desc, desc + note->n_descsz); + ctx->found = true; + return 1; + } + p = desc + ((note->n_descsz + 3) & ~3u); + } + } + return 1; // only inspect the main executable + }, + &ctx); + outBase = ctx.base; + return ctx.found; +#else + (void)outId; + (void)outBase; + return false; +#endif +} + +std::filesystem::path manifest_path() { + const char* basePath = SDL_GetBasePath(); + std::filesystem::path dir = + basePath != nullptr ? std::filesystem::path{basePath} : std::filesystem::current_path(); + return dir / "dusklight.symdb"; +} + +std::string hex_string(const uint8_t* data, size_t len) { + std::string out; + out.reserve(len * 2); + for (size_t i = 0; i < len; ++i) { + constexpr char kHex[] = "0123456789abcdef"; + out.push_back(kHex[data[i] >> 4]); + out.push_back(kHex[data[i] & 0xF]); + } + return out; +} + +} // namespace + +void initialize() { + if (s_state.initialized) { + return; + } + s_state.initialized = true; + + const auto path = manifest_path(); + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + Log.info("no symbol manifest at {}; by-name resolution unavailable", + io::fs_path_to_string(path)); + return; + } + std::vector data; + try { + data = io::FileStream::ReadAllBytes(path); + } catch (const std::exception& e) { + Log.error("failed to read symbol manifest {}: {}", io::fs_path_to_string(path), e.what()); + return; + } + if (data.size() < sizeof(Header)) { + Log.error( + "symbol manifest {} is truncated ({} bytes)", io::fs_path_to_string(path), data.size()); + return; + } + + Header header{}; + std::memcpy(&header, data.data(), sizeof(header)); + if (std::memcmp(header.magic, kMagic, sizeof(kMagic)) != 0 || header.version != kVersion) { + Log.error("symbol manifest {} has wrong magic/version", io::fs_path_to_string(path)); + return; + } + const auto compression = static_cast(header.compression); + if ((compression != Compression::None && compression != Compression::Zstd) || + header.buildIdLen > sizeof(header.buildId) || + header.compressedLen > data.size() - sizeof(Header) || + header.uncompressedLen > std::numeric_limits::max() || + (compression == Compression::None && header.compressedLen != header.uncompressedLen)) + { + Log.error("symbol manifest {} is malformed", io::fs_path_to_string(path)); + return; + } + + std::vector imageId; + uintptr_t imageBase = 0; + if (!running_image_identity(imageId, imageBase)) { + Log.error("cannot determine the running image's build id; ignoring symbol manifest"); + return; + } + if (imageId.size() != header.buildIdLen || + std::memcmp(imageId.data(), header.buildId, imageId.size()) != 0) + { + Log.error("symbol manifest {} is stale: built for {}, running image is {}", + io::fs_path_to_string(path), hex_string(header.buildId, header.buildIdLen), + hex_string(imageId.data(), imageId.size())); + return; + } + + const auto compressedLen = static_cast(header.compressedLen); + const auto uncompressedLen = static_cast(header.uncompressedLen); + std::vector payload; + const auto* storedPayload = data.data() + sizeof(Header); + if (compression == Compression::None) { + payload.assign(storedPayload, storedPayload + compressedLen); + } else { + payload.resize(uncompressedLen); + const size_t decompressedLen = + ZSTD_decompress(payload.data(), payload.size(), storedPayload, compressedLen); + if (ZSTD_isError(decompressedLen)) { + Log.error("failed to decompress symbol manifest {}: {}", io::fs_path_to_string(path), + ZSTD_getErrorName(decompressedLen)); + return; + } + if (decompressedLen != payload.size()) { + Log.error("symbol manifest {} decompressed to {} bytes, expected {}", + io::fs_path_to_string(path), decompressedLen, payload.size()); + return; + } + } + data = std::move(payload); + + const uint64_t entriesEnd = uint64_t{header.entryCount} * sizeof(Entry); + if (entriesEnd > data.size()) { + Log.error("decompressed symbol manifest {} is malformed", io::fs_path_to_string(path)); + return; + } + + s_state.data = std::move(data); + s_state.entries = reinterpret_cast(s_state.data.data()); + s_state.entryCount = header.entryCount; + s_state.strings = reinterpret_cast(s_state.data.data() + entriesEnd); + s_state.stringsLen = s_state.data.size() - entriesEnd; + s_state.imageBase = imageBase; + for (uint32_t i = 0; i < s_state.entryCount; ++i) { + const Entry& entry = s_state.entries[i]; + if ((entry.flags & kFlagInlineSites) != 0 && entry.nameOff < s_state.stringsLen) { + s_state.inlineSites.emplace_back(entry.rva, entry.nameOff); + } + } + std::sort(s_state.inlineSites.begin(), s_state.inlineSites.end()); + s_state.inlineSites.erase(std::unique(s_state.inlineSites.begin(), s_state.inlineSites.end(), + [](const auto& a, const auto& b) { return a.first == b.first; }), + s_state.inlineSites.end()); + s_state.loaded = true; + Log.info("symbol manifest loaded: {} symbols, build id {}", s_state.entryCount, + hex_string(header.buildId, header.buildIdLen)); +} + +bool available() { + return s_state.loaded; +} + +const std::vector& image_build_id() { + static const std::vector s_id = [] { + std::vector id; + uintptr_t base = 0; + running_image_identity(id, base); + return id; + }(); + return s_id; +} + +ResolveStatus resolve(const char* name, void** outAddr, HookSymbolFlags* outFlags) { + if (!s_state.loaded) { + return ResolveStatus::Unavailable; + } + const uint64_t hash = fnv1a64(name); + const Entry* begin = s_state.entries; + const Entry* end = begin + s_state.entryCount; + size_t lo = 0; + size_t hi = s_state.entryCount; + while (lo < hi) { + const size_t mid = lo + (hi - lo) / 2; + if (begin[mid].hash < hash) { + lo = mid + 1; + } else { + hi = mid; + } + } + for (const Entry* entry = begin + lo; entry != end && entry->hash == hash; ++entry) { + if (entry->nameOff >= s_state.stringsLen || + std::strcmp(s_state.strings + entry->nameOff, name) != 0) + { + continue; + } + if ((entry->flags & kFlagDupName) != 0) { + return ResolveStatus::Ambiguous; + } + *outAddr = reinterpret_cast(s_state.imageBase + entry->rva); + if (outFlags != nullptr) { + *outFlags = entry->flags; + } + return ResolveStatus::Ok; + } + return ResolveStatus::NotFound; +} + +bool has_inline_sites(const void* addr, const char** outName) { + if (!s_state.loaded || s_state.inlineSites.empty()) { + return false; + } + const auto rva = static_cast(reinterpret_cast(addr) - s_state.imageBase); + const auto it = std::lower_bound(s_state.inlineSites.begin(), s_state.inlineSites.end(), + std::pair{rva, 0}); + if (it == s_state.inlineSites.end() || it->first != rva) { + return false; + } + if (outName != nullptr) { + *outName = s_state.strings + it->second; + } + return true; +} + +} // namespace dusk::mods::manifest diff --git a/src/dusk/mods/loader/manifest.hpp b/src/dusk/mods/loader/manifest.hpp new file mode 100644 index 0000000000..c6bc03f038 --- /dev/null +++ b/src/dusk/mods/loader/manifest.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +#include "mods/svc/hook.h" + +namespace dusk::mods::manifest { + +// Symbol flags mirrored from symgen. +constexpr uint32_t kFlagCode = 1u << 0; +constexpr uint32_t kFlagData = 1u << 1; +constexpr uint32_t kFlagLocal = 1u << 2; +constexpr uint32_t kFlagMultiName = 1u << 3; +constexpr uint32_t kFlagDupName = 1u << 4; +constexpr uint32_t kFlagInlineSites = 1u << 5; +constexpr uint32_t kFlagDisplay = 1u << 6; + +enum class ResolveStatus { + Ok, + Unavailable, // no manifest loaded (missing, stale, or malformed) + NotFound, + Ambiguous, // name maps to multiple addresses (overloads / per-TU statics) +}; + +// Maps the symbol manifest next to the game binary and validates it against the +// running image's build id (PDB GUID+age / Mach-O UUID / GNU build-id). A missing or +// stale manifest logs and leaves by-name resolution unavailable; hooks by address are +// unaffected. Safe to call more than once. +void initialize(); + +bool available(); + +// Build id of the running executable image (PDB GUID+age / Mach-O UUID / GNU +// build-id), computed once on first use; empty if it couldn't be determined. +// Independent of whether a manifest file was loaded. +const std::vector& image_build_id(); + +// Resolve a symbol name to its address in the running image. Names can be either the platform's +// mangled name (i.e. the name passed to dlopen; no Mach-O leading underscore) or the function name +// without parameters (e.g. "daAlink_c::execute"). +ResolveStatus resolve(const char* name, void** outAddr, HookSymbolFlags* outFlags = nullptr); + +// True if the manifest records that the function at this code address was inlined into +// at least one caller in this build. An entry hook on it only intercepts the calls +// that were not inlined. outName receives the symbol name (valid for the process lifetime) +// when known. False when no manifest is loaded. +bool has_inline_sites(const void* addr, const char** outName = nullptr); + +} // namespace dusk::mods::manifest diff --git a/src/dusk/mods/svc/game.cpp b/src/dusk/mods/svc/game.cpp new file mode 100644 index 0000000000..f0bb8639a3 --- /dev/null +++ b/src/dusk/mods/svc/game.cpp @@ -0,0 +1,21 @@ +#include "registry.hpp" + +#include "mods/svc/game.h" + +namespace dusk::mods::svc { +namespace { + +constexpr GameService s_gameService{ + .header = SERVICE_HEADER(GameService, GAME_SERVICE_MAJOR, GAME_SERVICE_MINOR), +}; + +} // namespace + +constinit const ServiceModule g_gameModule{ + .id = GAME_SERVICE_ID, + .majorVersion = GAME_SERVICE_MAJOR, + .minorVersion = GAME_SERVICE_MINOR, + .service = &s_gameService, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/hook.cpp b/src/dusk/mods/svc/hook.cpp new file mode 100644 index 0000000000..f565776183 --- /dev/null +++ b/src/dusk/mods/svc/hook.cpp @@ -0,0 +1,480 @@ +#include "registry.hpp" + +#include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/loader/manifest.hpp" +#include "mods/svc/hook.h" + +#if DUSK_CODE_MODS +#include "dusk/logging.h" + +#include +#include +#include +#include +#include +#include +#endif + +namespace dusk::mods::svc { +namespace { + +#if DUSK_CODE_MODS + +struct PreHookFn { + ModContext* context = nullptr; + HookPreFn callback = nullptr; + HookOptions options = HOOK_OPTIONS_INIT; + uint64_t order = 0; +}; + +struct VoidHookFn { + ModContext* context = nullptr; + HookReplaceFn replaceCallback = nullptr; + HookPostFn postCallback = nullptr; + HookOptions options = HOOK_OPTIONS_INIT; + uint64_t order = 0; +}; + +struct HookSlot { + std::vector pre; + VoidHookFn replace{}; + std::vector post; +}; + +// One per mod that requested a hook on a target: its template-generated trampoline and the +// address of its Hook::g_orig, both living in the mod's dylib. Any candidate's trampoline +// is interchangeable (dispatch walks the shared HookSlot), so when the active installer's mod +// unloads, the funchook detour is handed off to a surviving candidate and every candidate's +// *orig_store is rewritten to the new original pointer. +struct HookCandidate { + ModContext* context = nullptr; + void* trampoline = nullptr; + void** origStore = nullptr; + uint64_t order = 0; +}; + +struct InstalledHook { + funchook_t* handle = nullptr; + void* original = nullptr; + ModContext* active = nullptr; + std::vector candidates; +}; + +std::unordered_map s_registry; +std::unordered_map s_installed; +uint64_t s_nextOrder = 0; + +HookOptions normalize_options(const HookOptions* options) { + if (options == nullptr || options->struct_size < sizeof(HookOptions)) { + return HOOK_OPTIONS_INIT; + } + return *options; +} + +template +void sort_hooks(std::vector& hooks) { + std::ranges::stable_sort(hooks, [](const T& a, const T& b) { + if (a.options.priority != b.options.priority) { + return a.options.priority > b.options.priority; + } + return a.order < b.order; + }); +} + +// Follow E9/FF25 chains to skip MSVC incremental-link and import stubs. +void* resolve_import_thunk(void* addr) { +#if defined(_WIN32) && (defined(_M_X64) || defined(__x86_64__)) + for (int i = 0; i < 8; ++i) { + const auto* p = static_cast(addr); + if (p[0] == 0x48 && p[1] == 0xFF && p[2] == 0x25) { // lld emits a REX.W prefix + ++p; + } + if (p[0] == 0xFF && p[1] == 0x25) { + int32_t offset; + std::memcpy(&offset, p + 2, 4); + addr = const_cast(*reinterpret_cast(p + 6 + offset)); + break; + } + if (p[0] == 0xE9) { + int32_t offset; + std::memcpy(&offset, p + 1, 4); + addr = const_cast(p) + 5 + offset; + } else { + break; + } + } +#elif defined(_WIN32) && (defined(_M_ARM64) || defined(__aarch64__)) + // Import thunks are `adrp x16; ldr x16, [x16, #off]; br x16` (deref the IAT slot); + // incremental-link stubs are a plain `b`, or `adrp x16; add x16, x16, #off; br x16` + // range-extension thunks when the target is out of B range. + for (int i = 0; i < 8; ++i) { + const auto* p = static_cast(addr); + uint32_t insn0, insn1, insn2; + std::memcpy(&insn0, p, 4); + if ((insn0 & 0xFC000000u) == 0x14000000u) { // b imm26 + auto imm26 = static_cast(insn0 << 6) >> 6; + addr = const_cast(p) + static_cast(imm26) * 4; + continue; + } + if ((insn0 & 0x9F00001Fu) != 0x90000010u) { // adrp x16, page + break; + } + std::memcpy(&insn1, p + 4, 4); + std::memcpy(&insn2, p + 8, 4); + if (insn2 != 0xD61F0200u) { // br x16 + break; + } + auto immhi = static_cast(static_cast(insn0 << 8) >> 13); // bits 23:5 + auto immlo = static_cast((insn0 >> 29) & 3); + auto page = (reinterpret_cast(p) & ~uintptr_t{0xFFF}) + + (static_cast((immhi << 2) | immlo) << 12); + if ((insn1 & 0xFFC003FFu) == 0xF9400210u) { // ldr x16, [x16, #imm12*8] + auto slot = page + ((insn1 >> 10) & 0xFFF) * 8; + addr = *reinterpret_cast(slot); + break; + } + if ((insn1 & 0xFF8003FFu) == 0x91000210u) { // add x16, x16, #imm12{, lsl #12} + auto imm = static_cast((insn1 >> 10) & 0xFFF); + addr = reinterpret_cast(page + (((insn1 >> 22) & 1) != 0 ? imm << 12 : imm)); + continue; + } + break; + } +#endif + return addr; +} + +funchook_t* install_trampoline(void* fnAddr, void* trampoline, void** outOriginal) { + funchook_t* fh = funchook_create(); + if (fh == nullptr) { + DuskLog.warn("HookSystem: funchook_create failed for {:p}", fnAddr); + return nullptr; + } + + void* fn = fnAddr; + const int prep = funchook_prepare(fh, &fn, trampoline); + const int inst = prep == 0 ? funchook_install(fh, 0) : -1; + if (prep != 0 || inst != 0) { + const char* message = funchook_error_message(fh); + DuskLog.warn("HookSystem: funchook failed for {:p} (prepare={} install={}): {}", fnAddr, + prep, inst, message != nullptr && message[0] != '\0' ? message : "no details"); + funchook_destroy(fh); + return nullptr; + } + + *outOriginal = fn; + return fh; +} + +ModResult hook_install(ModContext* context, void* fnAddr, void* trampolineFn, void** outOriginal) { + if (fnAddr == nullptr || trampolineFn == nullptr || outOriginal == nullptr) { + return MOD_INVALID_ARGUMENT; + } + // Try to detect an invalid function pointer (possibly a vtable slot offset or Itanium mfp + // value) and provide a helpful warning instead of faulting + const auto raw = reinterpret_cast(fnAddr); + if (raw < 0x10000 +#if defined(__aarch64__) || defined(_M_ARM64) + || (raw & 3) != 0 // code is 4-aligned +#endif + ) + { + DuskLog.warn("HookSystem: {:p} from {} is not a code address (virtual member function " + "pointer? hook via dusk::mods::Hook or resolve())", + fnAddr, mod_id_from_context(context)); + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto key = reinterpret_cast(fnAddr); + if (const auto it = s_installed.find(key); it != s_installed.end()) { + auto& entry = it->second; + // hook_add_pre + hook_add_post on the same target share one g_orig per mod. + const bool known = std::ranges::any_of(entry.candidates, [&](const HookCandidate& cand) { + return cand.context == context && cand.origStore == outOriginal; + }); + if (!known) { + entry.candidates.push_back({context, trampolineFn, outOriginal, s_nextOrder++}); + } + *outOriginal = entry.original; + return MOD_OK; + } + + // Inlining can't be intercepted by an entry patch: warn once per target when this + // build inlined the function into callers. + if (const char* name = nullptr; manifest::has_inline_sites(fnAddr, &name)) { + DuskLog.warn("HookSystem: '{}' ({:p}) for {} was inlined into callers in this build; " + "the hook only covers the calls that were not inlined", + name != nullptr ? name : "?", fnAddr, mod_id_from_context(context)); + } + + void* original = nullptr; + funchook_t* fh = install_trampoline(fnAddr, trampolineFn, &original); + if (fh == nullptr) { + return MOD_ERROR; + } + + auto& entry = s_installed[key]; + entry.handle = fh; + entry.original = original; + entry.active = context; + entry.candidates.push_back({context, trampolineFn, outOriginal, s_nextOrder++}); + *outOriginal = original; + return MOD_OK; +} + +ModResult hook_add_pre( + ModContext* context, void* fnAddr, HookPreFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + fnAddr = resolve_import_thunk(fnAddr); + auto& hooks = s_registry[reinterpret_cast(fnAddr)].pre; + hooks.push_back({context, callback, normalize_options(options), s_nextOrder++}); + sort_hooks(hooks); + return MOD_OK; +} + +ModResult hook_add_post( + ModContext* context, void* fnAddr, HookPostFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + fnAddr = resolve_import_thunk(fnAddr); + auto& hooks = s_registry[reinterpret_cast(fnAddr)].post; + hooks.push_back({context, nullptr, callback, normalize_options(options), s_nextOrder++}); + sort_hooks(hooks); + return MOD_OK; +} + +ModResult hook_replace( + ModContext* context, void* fnAddr, HookReplaceFn callback, const HookOptions* options) { + if (fnAddr == nullptr || context == nullptr || callback == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + const HookOptions normalized = normalize_options(options); + fnAddr = resolve_import_thunk(fnAddr); + auto& slot = s_registry[reinterpret_cast(fnAddr)]; + if (slot.replace.replaceCallback == nullptr) { + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + } + + switch (normalized.replace_policy) { + case HOOK_REPLACE_CONFLICT: + DuskLog.error("HookSystem: '{}' conflicts with '{}', both replace the same function", + mod_id_from_context(context), mod_id_from_context(slot.replace.context)); + return MOD_CONFLICT; + case HOOK_REPLACE_PRIORITY: + if (normalized.priority <= slot.replace.options.priority) { + return MOD_CONFLICT; + } + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + case HOOK_REPLACE_OVERRIDE: + slot.replace = {context, callback, nullptr, normalized, s_nextOrder++}; + return MOD_OK; + } + return MOD_INVALID_ARGUMENT; +} + +ModResult hook_dispatch_pre( + ModContext*, void* fnAddr, void* args, void* retval, int* outSkipOriginal) { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 0; + } + if (fnAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto it = s_registry.find(reinterpret_cast(fnAddr)); + if (it == s_registry.end()) { + return MOD_OK; + } + auto& slot = it->second; + for (auto& hook : slot.pre) { + if (hook.callback != nullptr && + hook.callback(hook.context, args, retval, hook.options.userdata) == HOOK_SKIP_ORIGINAL) + { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 1; + } + return MOD_OK; + } + } + if (slot.replace.replaceCallback != nullptr) { + slot.replace.replaceCallback( + slot.replace.context, args, retval, slot.replace.options.userdata); + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 1; + } + } + return MOD_OK; +} + +ModResult hook_dispatch_post(ModContext*, void* fnAddr, void* args, void* retval) { + if (fnAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + fnAddr = resolve_import_thunk(fnAddr); + const auto it = s_registry.find(reinterpret_cast(fnAddr)); + if (it == s_registry.end()) { + return MOD_OK; + } + for (auto& hook : it->second.post) { + if (hook.postCallback != nullptr) { + hook.postCallback(hook.context, args, retval, hook.options.userdata); + } + } + return MOD_OK; +} + +void hook_remove_mod(LoadedMod& mod) { + ModContext* context = mod.context.get(); + + for (auto it = s_registry.begin(); it != s_registry.end();) { + auto& slot = it->second; + std::erase_if(slot.pre, [&](const PreHookFn& hook) { return hook.context == context; }); + std::erase_if(slot.post, [&](const VoidHookFn& hook) { return hook.context == context; }); + if (slot.replace.context == context) { + slot.replace = {}; + } + if (slot.pre.empty() && slot.post.empty() && slot.replace.replaceCallback == nullptr) { + it = s_registry.erase(it); + } else { + ++it; + } + } + + for (auto it = s_installed.begin(); it != s_installed.end();) { + auto& entry = it->second; + // The departing mod's g_orig slots are about to be unmapped; drop its candidates before + // any orig_store rewrites below. + std::erase_if(entry.candidates, + [context](const HookCandidate& cand) { return cand.context == context; }); + if (entry.active != context) { + ++it; + continue; + } + + auto* target = reinterpret_cast(it->first); + const int uninst = funchook_uninstall(entry.handle, 0); + const int destr = funchook_destroy(entry.handle); + if (uninst != 0 || destr != 0) { + DuskLog.warn("HookSystem: funchook uninstall/destroy for {:p} returned {}/{}", target, + uninst, destr); + } + entry.handle = nullptr; + entry.active = nullptr; + + if (entry.candidates.empty()) { + it = s_installed.erase(it); + continue; + } + + // Hand the detour off to a surviving candidate (lowest registration order first; the + // vector is append-ordered). A candidate whose install fails stays in the list: its + // g_orig must still track the current original pointer. + for (auto& cand : entry.candidates) { + void* original = nullptr; + funchook_t* fh = install_trampoline(target, cand.trampoline, &original); + if (fh == nullptr) { + continue; + } + entry.handle = fh; + entry.original = original; + entry.active = cand.context; + DuskLog.info("HookSystem: reinstalled trampoline for {:p}: {} -> {} (tramp={:p})", + target, mod_id_from_context(context), mod_id_from_context(cand.context), + cand.trampoline); + break; + } + + if (entry.active == nullptr) { + DuskLog.warn("HookSystem: no reinstallable trampoline for {:p}; hooks there are " + "disabled until a mod reinstalls one", + target); + for (auto& cand : entry.candidates) { + *cand.origStore = target; + } + it = s_installed.erase(it); + continue; + } + + for (auto& cand : entry.candidates) { + *cand.origStore = entry.original; + } + ++it; + } +} + +#else // DUSK_CODE_MODS + +ModResult hook_install(ModContext*, void*, void*, void**) { + return MOD_UNSUPPORTED; +} +ModResult hook_add_pre(ModContext*, void*, HookPreFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_add_post(ModContext*, void*, HookPostFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_replace(ModContext*, void*, HookReplaceFn, const HookOptions*) { + return MOD_UNSUPPORTED; +} +ModResult hook_dispatch_pre(ModContext*, void*, void*, void*, int* outSkipOriginal) { + if (outSkipOriginal != nullptr) { + *outSkipOriginal = 0; + } + return MOD_UNSUPPORTED; +} +ModResult hook_dispatch_post(ModContext*, void*, void*, void*) { + return MOD_UNSUPPORTED; +} +void hook_remove_mod(LoadedMod&) {} + +#endif // DUSK_CODE_MODS + +// By-name resolution reads the symbol manifest, which is independent of the hook engine. +ModResult hook_resolve(ModContext*, const char* symbol, void** outAddr, HookSymbolFlags* outFlags) { + if (symbol == nullptr || outAddr == nullptr) { + return MOD_INVALID_ARGUMENT; + } + switch (manifest::resolve(symbol, outAddr, outFlags)) { + case manifest::ResolveStatus::Ok: + return MOD_OK; + case manifest::ResolveStatus::Unavailable: + return MOD_UNSUPPORTED; + case manifest::ResolveStatus::NotFound: + return MOD_UNAVAILABLE; + case manifest::ResolveStatus::Ambiguous: + return MOD_CONFLICT; + } + return MOD_ERROR; +} + +constexpr HookService s_hookService{ + .header = SERVICE_HEADER(HookService, HOOK_SERVICE_MAJOR, HOOK_SERVICE_MINOR), + .install = hook_install, + .add_pre = hook_add_pre, + .add_post = hook_add_post, + .replace = hook_replace, + .dispatch_pre = hook_dispatch_pre, + .dispatch_post = hook_dispatch_post, + .resolve = hook_resolve, +}; + +} // namespace + +constinit const ServiceModule g_hookModule{ + .id = HOOK_SERVICE_ID, + .majorVersion = HOOK_SERVICE_MAJOR, + .minorVersion = HOOK_SERVICE_MINOR, + .service = &s_hookService, + .modDetached = hook_remove_mod, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index 6255b13c42..05971d5be1 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -1,10 +1,12 @@ #include "registry.hpp" #include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/loader/manifest.hpp" #include "fmt/format.h" #include #include +#include namespace dusk::mods::svc { namespace { @@ -117,8 +119,11 @@ void host_mod_detached(LoadedMod& mod) { } } -constexpr HostService s_hostService{ +constinit HostService s_hostService{ .header = SERVICE_HEADER(HostService, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR), + .version = DUSK_VERSION_STRING, + .build_id = nullptr, + .build_id_len = 0, .get_service = host_get_service, .publish_service = host_publish_service, .fail = host_fail, @@ -137,6 +142,12 @@ constinit const ServiceModule g_hostModule{ .majorVersion = HOST_SERVICE_MAJOR, .minorVersion = HOST_SERVICE_MINOR, .service = &s_hostService, + .initialize = + [] { + const auto& buildId = manifest::image_build_id(); + s_hostService.build_id = buildId.empty() ? nullptr : buildId.data(); + s_hostService.build_id_len = static_cast(buildId.size()); + }, .modDetached = host_mod_detached, }; diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 31f67bc7ab..0a2da3add3 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -200,9 +200,11 @@ void ModLoader::init_services() { &svc::g_hostModule, &svc::g_logModule, &svc::g_resourceModule, + &svc::g_hookModule, &svc::g_overlayModule, &svc::g_textureModule, &svc::g_configModule, + &svc::g_gameModule, }) { svc::register_module(*module); diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 6ffa1a7582..bdbe2c2e72 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -65,8 +65,10 @@ void modules_shutdown(); extern const ServiceModule g_hostModule; extern const ServiceModule g_logModule; extern const ServiceModule g_resourceModule; +extern const ServiceModule g_hookModule; extern const ServiceModule g_overlayModule; extern const ServiceModule g_textureModule; extern const ServiceModule g_configModule; +extern const ServiceModule g_gameModule; } // namespace dusk::mods::svc