Hook service, symgen manifest & data linking

This commit is contained in:
Luke Street
2026-07-07 15:14:58 -06:00
parent af5635dd42
commit 4339e60ba6
26 changed files with 2341 additions and 14 deletions
+84 -1
View File
@@ -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=<SOURCE_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 "$<$<COMPILE_LANGUAGE:CXX>:${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 $<$<COMPILE_LANGUAGE:C,CXX>:/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 $<$<COMPILE_LANGUAGE:C,CXX>:-fpatchable-function-entry=2,1>)
else ()
set(DUSK_PATCHABLE_ENTRY_FLAG $<$<COMPILE_LANGUAGE:C,CXX>:-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
+45
View File
@@ -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 "$<$<COMPILE_LANGUAGE:C,CXX>:/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}")
+13
View File
@@ -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}")
+60
View File
@@ -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}")
+125
View File
@@ -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 "$<TARGET_PDB_FILE:${target}>")
set(_out "$<TARGET_FILE_DIR:${target}>/dusklight.symdb")
else ()
set(_input --binary "$<TARGET_FILE:${target}>")
if (APPLE)
set(_out "$<TARGET_BUNDLE_CONTENT_DIR:${target}>/Resources/dusklight.symdb")
else ()
set(_out "$<TARGET_FILE_DIR:${target}>/dusklight.symdb")
endif ()
endif ()
add_custom_command(TARGET ${target} POST_BUILD
COMMAND "${SYMGEN_EXE}" manifest ${_input} --out "${_out}"
COMMENT "Generating symbol manifest"
VERBATIM)
endfunction()
+76
View File
@@ -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 "$<TARGET_OBJECTS:${target}>")
foreach (_lib IN LISTS JSYSTEM_LIBRARIES)
list(APPEND _rsp_lines "$<TARGET_FILE:${_lib}>")
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 "$<TARGET_FILE:${_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()
+165 -7
View File
@@ -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<daAlink_c*>(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<daAlink_c*>(args, 0));
if (retval != nullptr) {
*static_cast<int*>(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<HookshotHit>(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<T>(args, n); // copy
T& ref = dusk::mods::arg_ref<T>(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<int>(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<cXyz>` 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.<escaped id>.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
+4
View File
@@ -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
+1 -1
View File
@@ -17,7 +17,7 @@
#include "m_Do/m_Do_graphic.h"
#include <cstring>
#include "tracy/Tracy.hpp"
#include "dusk/profiling.hpp"
enum dComIfG_ButtonStatus {
/* 0x00 */ BUTTON_STATUS_NONE,
+2 -1
View File
@@ -5,7 +5,8 @@
#include <dolphin/gx/GXAurora.h>
#include <dolphin/gx/GXExtra.h>
#include "tracy/Tracy.hpp"
#include "profiling.hpp"
#if DUSK_GFX_DEBUG_GROUPS
#define GX_DEBUG_GROUP(name, ...) \
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#if defined(__has_include)
#if __has_include(<tracy/Tracy.hpp>)
#include <tracy/Tracy.hpp>
#endif
#endif
#ifndef ZoneScoped
#define ZoneScoped
#define ZoneScopedN(name)
#endif
+13
View File
@@ -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))
+464
View File
@@ -0,0 +1,464 @@
#pragma once
#include "mods/svc/hook.h"
#include <array>
#include <cstddef>
#include <cstring>
#include <memory>
#include <string_view>
#include <type_traits>
namespace dusk::mods {
template <class T>
T arg(void* argsRaw, int n) noexcept {
void** args = static_cast<void**>(argsRaw);
return *static_cast<std::add_pointer_t<std::remove_reference_t<T> > >(args[n]);
}
template <class T>
std::remove_reference_t<T>& arg_ref(void* argsRaw, int n) noexcept {
void** args = static_cast<void**>(argsRaw);
return *static_cast<std::add_pointer_t<std::remove_reference_t<T> > >(args[n]);
}
template <class F>
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 <size_t N>
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 <class T>
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<class daAlink_c>(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 <class C>
constexpr auto vtable_symbol() {
constexpr std::string_view name = class_name<C>();
constexpr bool simple = name.find_first_of(":<> ") == std::string_view::npos;
// "_ZTV" + decimal length + name / "??_7" + name + "@@6B@", NUL-terminated
std::array<char, name.size() + 12> 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<char>('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<size_t>(-1);
#if defined(_M_X64) || defined(__x86_64__)
const auto* p = static_cast<const uint8_t*>(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<int8_t>(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<int8_t>(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<const uint8_t*>(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<int32_t>(insn[0] << 6) >> 6;
p += static_cast<intptr_t>(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 <class C, class F>
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<const void*>(words[0]);
const size_t slot = vcall_slot_offset(fn);
if (slot == static_cast<size_t>(-1)) { // not a vcall thunk: direct address
*out = const_cast<void*>(fn);
return MOD_OK;
}
void* vtable = nullptr;
const ModResult resolved = hooks->resolve(mod_ctx, vtable_symbol<C>().data(), &vtable, nullptr);
if (resolved != MOD_OK) {
return resolved;
}
// ??_7 points at the first slot.
*out = *reinterpret_cast<void**>(static_cast<char*>(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<void*>(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<C>().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<void**>(static_cast<char*>(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 <class Tag, class R, class... A>
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<R>) {
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<void*>(std::addressof(result)));
if (!skipOriginal) {
result = g_orig(args...);
}
dispatch_post(nullptr, static_cast<void*>(std::addressof(result)));
return result;
}
} else {
void* ptrs[] = {static_cast<void*>(std::addressof(args))...};
if constexpr (std::is_void_v<R>) {
const bool skipOriginal = dispatch_pre(static_cast<void*>(ptrs), nullptr);
if (!skipOriginal) {
g_orig(args...);
}
dispatch_post(static_cast<void*>(ptrs), nullptr);
} else {
R result{};
const bool skipOriginal = dispatch_pre(
static_cast<void*>(ptrs), static_cast<void*>(std::addressof(result)));
if (!skipOriginal) {
result = g_orig(args...);
}
dispatch_post(static_cast<void*>(ptrs), static_cast<void*>(std::addressof(result)));
return result;
}
}
}
};
namespace detail {
template <auto Target>
using TargetTag = std::integral_constant<decltype(Target), Target>;
template <FixedString Name>
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 <auto Target>
struct Hook;
template <class C, class R, class... A, R (C::*Target)(A...)>
struct Hook<Target> : HookImpl<detail::TargetTag<Target>, R, C*, A...> {
static ModResult resolve_target(const HookService* hooks, void** out) {
return detail::member_target<C>(hooks, Target, out);
}
};
template <class C, class R, class... A, R (C::*Target)(A...) const>
struct Hook<Target> : HookImpl<detail::TargetTag<Target>, R, const C*, A...> {
static ModResult resolve_target(const HookService* hooks, void** out) {
return detail::member_target<C>(hooks, Target, out);
}
};
template <class R, class... A, R (*Target)(A...)>
struct Hook<Target> : HookImpl<detail::TargetTag<Target>, 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<HookshotHit>(svc_hook, on_hookshot_hit);
*/
template <FixedString Name, class Sig>
struct NamedHook;
template <FixedString Name, class R, class... A>
struct NamedHook<Name, R(A...)> : HookImpl<detail::NameTag<Name>, 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 <class Entry>
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<void*>(Entry::trampoline),
reinterpret_cast<void**>(&Entry::g_orig));
}
template <auto Target>
ModResult hook_install(const HookService* hooks) {
return hook_install<Hook<Target> >(hooks);
}
template <class Entry>
ModResult hook_add_pre(
const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) {
const ModResult installed = hook_install<Entry>(hooks);
if (installed != MOD_OK) {
return installed;
}
return hooks->add_pre(mod_ctx, Entry::target, callback, options);
}
template <auto Target>
ModResult hook_add_pre(
const HookService* hooks, HookPreFn callback, const HookOptions* options = nullptr) {
return hook_add_pre<Hook<Target> >(hooks, callback, options);
}
template <class Entry>
ModResult hook_add_post(
const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) {
const ModResult installed = hook_install<Entry>(hooks);
if (installed != MOD_OK) {
return installed;
}
return hooks->add_post(mod_ctx, Entry::target, callback, options);
}
template <auto Target>
ModResult hook_add_post(
const HookService* hooks, HookPostFn callback, const HookOptions* options = nullptr) {
return hook_add_post<Hook<Target> >(hooks, callback, options);
}
template <class Entry>
ModResult hook_replace(
const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) {
const ModResult installed = hook_install<Entry>(hooks);
if (installed != MOD_OK) {
return installed;
}
return hooks->replace(mod_ctx, Entry::target, callback, options);
}
template <auto Target>
ModResult hook_replace(
const HookService* hooks, HookReplaceFn callback, const HookOptions* options = nullptr) {
return hook_replace<Hook<Target> >(hooks, callback, options);
}
} // namespace dusk::mods
+30
View File
@@ -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<GameService> {
static constexpr const char* id = GAME_SERVICE_ID;
static constexpr uint16_t major_version = GAME_SERVICE_MAJOR;
};
#endif
+131
View File
@@ -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<T> / arg_ref<T> 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<HookService> {
static constexpr const char* id = HOOK_SERVICE_ID;
static constexpr uint16_t major_version = HOOK_SERVICE_MAJOR;
};
#endif
+10 -2
View File
@@ -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).
+10
View File
@@ -6,6 +6,9 @@
# Usage (from a mod project):
# add_subdirectory(<dusk>/sdk dusk-sdk EXCLUDE_FROM_ALL)
# add_mod(my_mod SOURCES ... MOD_JSON mod.json)
#
# On Windows, pass -DDUSK_GAME_IMPLIB=<path to sdk/dusklight.lib> 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")
+139
View File
@@ -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 <windows.h>
#include <cstdint>
#include <cstdio>
#include <cstring>
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<intptr_t*>(impSlot);
char* target = base + item.target;
const int bits = static_cast<int>(item.flags & 0xff);
intptr_t reldata;
switch (bits) {
case 8:
reldata = *reinterpret_cast<int8_t*>(target);
break;
case 16:
reldata = *reinterpret_cast<int16_t*>(target);
break;
case 32:
reldata = *reinterpret_cast<int32_t*>(target);
break;
case 64:
reldata = *reinterpret_cast<int64_t*>(target);
break;
default:
report("unsupported %d-bit pseudo-relocation at RVA 0x%x", bits, item.target);
return false;
}
reldata -= reinterpret_cast<intptr_t>(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<long long>(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<char*>(&__ImageBase);
char* start = &__RUNTIME_PSEUDO_RELOC_LIST__;
char* end = &__RUNTIME_PSEUDO_RELOC_LIST_END__;
if (end - start < static_cast<ptrdiff_t>(sizeof(HdrV2))) {
return; // no data auto-imports in this mod
}
const HdrV2* hdr = reinterpret_cast<const HdrV2*>(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<const ItemV2*>(hdr + 1);
const ItemV2* itemsEnd = reinterpret_cast<const ItemV2*>(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<size_t>(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;
}
+4 -1
View File
@@ -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;
+386
View File
@@ -0,0 +1,386 @@
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include "manifest.hpp"
#include <algorithm>
#include <cstring>
#include <filesystem>
#include <limits>
#include <utility>
#include <vector>
#include <SDL3/SDL_filesystem.h>
#include <zstd.h>
#include "aurora/lib/logging.hpp"
#include "dusk/io.hpp"
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#elif defined(__APPLE__)
#include <mach-o/dyld.h>
#include <mach-o/loader.h>
#elif defined(__linux__)
#include <elf.h>
#include <link.h>
#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<uint8_t> 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<std::pair<uint64_t, uint32_t> > 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<uint8_t>(*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<uint8_t>& outId, uintptr_t& outBase) {
#if defined(_WIN32)
auto* base = reinterpret_cast<uint8_t*>(GetModuleHandleW(nullptr));
outBase = reinterpret_cast<uintptr_t>(base);
const auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(base);
const auto* nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(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<const IMAGE_DEBUG_DIRECTORY*>(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<const CvInfo*>(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<uint8_t>(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<uintptr_t>(_dyld_get_image_vmaddr_slide(0));
const auto* header64 = reinterpret_cast<const mach_header_64*>(header);
const auto* cmd = reinterpret_cast<const load_command*>(header64 + 1);
for (uint32_t i = 0; i < header64->ncmds; ++i) {
if (cmd->cmd == LC_UUID) {
const auto* uuidCmd = reinterpret_cast<const uuid_command*>(cmd);
outId.assign(uuidCmd->uuid, uuidCmd->uuid + 16);
return true;
}
cmd = reinterpret_cast<const load_command*>(
reinterpret_cast<const uint8_t*>(cmd) + cmd->cmdsize);
}
return false;
#elif defined(__linux__)
struct Ctx {
std::vector<uint8_t>* 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<Ctx*>(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<const uint8_t*>(info->dlpi_addr + phdr.p_vaddr);
const auto* end = p + phdr.p_memsz;
while (p + sizeof(ElfW(Nhdr)) <= end) {
const auto* note = reinterpret_cast<const ElfW(Nhdr)*>(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<uint8_t> 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<Compression>(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<size_t>::max() ||
(compression == Compression::None && header.compressedLen != header.uncompressedLen))
{
Log.error("symbol manifest {} is malformed", io::fs_path_to_string(path));
return;
}
std::vector<uint8_t> 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<size_t>(header.compressedLen);
const auto uncompressedLen = static_cast<size_t>(header.uncompressedLen);
std::vector<uint8_t> 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<const Entry*>(s_state.data.data());
s_state.entryCount = header.entryCount;
s_state.strings = reinterpret_cast<const char*>(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<uint8_t>& image_build_id() {
static const std::vector<uint8_t> s_id = [] {
std::vector<uint8_t> 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<void*>(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<uint64_t>(reinterpret_cast<uintptr_t>(addr) - s_state.imageBase);
const auto it = std::lower_bound(s_state.inlineSites.begin(), s_state.inlineSites.end(),
std::pair<uint64_t, uint32_t>{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
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <cstdint>
#include <vector>
#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<uint8_t>& 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
+21
View File
@@ -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
+480
View File
@@ -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 <algorithm>
#include <cstdint>
#include <cstring>
#include <funchook.h>
#include <unordered_map>
#include <vector>
#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<PreHookFn> pre;
VoidHookFn replace{};
std::vector<VoidHookFn> 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<HookCandidate> candidates;
};
std::unordered_map<uintptr_t, HookSlot> s_registry;
std::unordered_map<uintptr_t, InstalledHook> 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 <class T>
void sort_hooks(std::vector<T>& 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<const uint8_t*>(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<void*>(*reinterpret_cast<const void* const*>(p + 6 + offset));
break;
}
if (p[0] == 0xE9) {
int32_t offset;
std::memcpy(&offset, p + 1, 4);
addr = const_cast<uint8_t*>(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<const uint8_t*>(addr);
uint32_t insn0, insn1, insn2;
std::memcpy(&insn0, p, 4);
if ((insn0 & 0xFC000000u) == 0x14000000u) { // b imm26
auto imm26 = static_cast<int32_t>(insn0 << 6) >> 6;
addr = const_cast<uint8_t*>(p) + static_cast<intptr_t>(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<int64_t>(static_cast<int32_t>(insn0 << 8) >> 13); // bits 23:5
auto immlo = static_cast<int64_t>((insn0 >> 29) & 3);
auto page = (reinterpret_cast<uintptr_t>(p) & ~uintptr_t{0xFFF}) +
(static_cast<intptr_t>((immhi << 2) | immlo) << 12);
if ((insn1 & 0xFFC003FFu) == 0xF9400210u) { // ldr x16, [x16, #imm12*8]
auto slot = page + ((insn1 >> 10) & 0xFFF) * 8;
addr = *reinterpret_cast<void**>(slot);
break;
}
if ((insn1 & 0xFF8003FFu) == 0x91000210u) { // add x16, x16, #imm12{, lsl #12}
auto imm = static_cast<uintptr_t>((insn1 >> 10) & 0xFFF);
addr = reinterpret_cast<void*>(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<uintptr_t>(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<uintptr_t>(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<uintptr_t>(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<uintptr_t>(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<uintptr_t>(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<uintptr_t>(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<uintptr_t>(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<void*>(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
+12 -1
View File
@@ -1,10 +1,12 @@
#include "registry.hpp"
#include "dusk/mods/loader/loader.hpp"
#include "dusk/mods/loader/manifest.hpp"
#include "fmt/format.h"
#include <algorithm>
#include <vector>
#include <version.h>
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<uint32_t>(buildId.size());
},
.modDetached = host_mod_detached,
};
+2
View File
@@ -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);
+2
View File
@@ -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