diff --git a/CMakeLists.txt b/CMakeLists.txt index 75a8d01151..b1c8986597 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,6 +91,35 @@ option(DUSK_PACKAGE_INSTALL "Install Dusklight with a Linux-native file structur option(DUSK_GFX_DEBUG_GROUPS "Report debug groups to the native graphics API" ${DUSK_GFX_DEBUG_GROUPS_DEFAULT}) option(DUSK_ENABLE_CODE_MODS "Enable code mods" OFF) +set(DUSK_HAS_FUNCHOOK OFF) +if (DUSK_ENABLE_CODE_MODS AND (NOT APPLE OR CMAKE_SYSTEM_NAME STREQUAL "Darwin")) + set(DUSK_HAS_FUNCHOOK ON) +endif () +set(DUSK_HAS_FUNCHOOK ${DUSK_HAS_FUNCHOOK} CACHE INTERNAL "Enable funchook" FORCE) + +set(_target_architectures ${CMAKE_OSX_ARCHITECTURES}) +if (NOT _target_architectures) + set(_target_architectures ${CMAKE_SYSTEM_PROCESSOR}) +endif () +list(LENGTH _target_architectures _target_arch_count) +set(DUSK_HAS_PREPATCH OFF) +if (DUSK_ENABLE_CODE_MODS AND APPLE AND _target_arch_count EQUAL 1) + list(GET _target_architectures 0 _target_arch) + if (_target_arch STREQUAL "arm64") + set(DUSK_HAS_PREPATCH ON) + endif () +endif () +set(DUSK_HAS_PREPATCH ${DUSK_HAS_PREPATCH} CACHE INTERNAL "Enable symgen prepatch support" FORCE) + +if (DUSK_ENABLE_CODE_MODS AND CMAKE_SYSTEM_NAME MATCHES "^(iOS|tvOS)$") + list(LENGTH CMAKE_OSX_ARCHITECTURES _mobile_arch_count) + if (NOT _mobile_arch_count EQUAL 1 OR NOT CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") + message(FATAL_ERROR + "iOS/tvOS code mods require a single arm64 architecture; got " + "CMAKE_OSX_ARCHITECTURES='${CMAKE_OSX_ARCHITECTURES}'") + endif () +endif () + # Edit & Continue if (MSVC) if ("${CMAKE_MSVC_DEBUG_INFORMATION_FORMAT}" STREQUAL "" AND CMAKE_BUILD_TYPE STREQUAL "Debug" @@ -175,8 +204,8 @@ if (CMAKE_SYSTEM_NAME STREQUAL Linux) set(CMAKE_BUILD_RPATH "$ORIGIN") elseif (APPLE) add_compile_options(-Wno-declaration-after-statement -Wno-non-pod-varargs) - set(CMAKE_INSTALL_RPATH "$ORIGIN") - set(CMAKE_BUILD_RPATH "$ORIGIN") + set(CMAKE_INSTALL_RPATH "@loader_path") + set(CMAKE_BUILD_RPATH "@loader_path") elseif (MSVC) add_compile_options( $<$:/bigobj> @@ -219,7 +248,7 @@ FetchContent_Declare(miniz ) set(_fetch_content_deps cxxopts json miniz) -if (DUSK_ENABLE_CODE_MODS) +if (DUSK_HAS_FUNCHOOK) 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 @@ -272,10 +301,10 @@ if (DUSK_ENABLE_SENTRY_NATIVE) ) if (NOT sentry_native_POPULATED) FetchContent_Populate(sentry_native) - set(_dusk_skip_install_rules ${CMAKE_SKIP_INSTALL_RULES}) + set(_skip_install_rules ${CMAKE_SKIP_INSTALL_RULES}) set(CMAKE_SKIP_INSTALL_RULES ON) add_subdirectory(${sentry_native_SOURCE_DIR} ${sentry_native_BINARY_DIR} EXCLUDE_FROM_ALL) - set(CMAKE_SKIP_INSTALL_RULES ${_dusk_skip_install_rules}) + set(CMAKE_SKIP_INSTALL_RULES ${_skip_install_rules}) endif () endif () @@ -308,7 +337,7 @@ set(GAME_COMPILE_DEFS DUSK_BUILDING_GAME=1) 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) +if (DUSK_HAS_FUNCHOOK) list(APPEND GAME_LIBS funchook-static) endif () @@ -381,6 +410,9 @@ endif () if (DUSK_ENABLE_CODE_MODS) list(APPEND GAME_COMPILE_DEFS DUSK_CODE_MODS=1) endif () +list(APPEND GAME_COMPILE_DEFS + DUSK_HAS_FUNCHOOK=$ + DUSK_HAS_PREPATCH=$) if (DUSK_PACKAGE_INSTALL) include(GNUInstallDirs) @@ -473,18 +505,28 @@ if (MSVC) else () target_link_options(dusklight PRIVATE /FUNCTIONPADMIN /OPT:NOICF) endif () -elseif (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") +elseif (CMAKE_CXX_COMPILER_ID MATCHES "^(AppleClang|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() + foreach(_sdk_lib aurora_card aurora_core aurora_dvd aurora_gd aurora_gx aurora_mtx + aurora_os aurora_pad aurora_si aurora_vi) + if (TARGET ${_sdk_lib}) + get_target_property(_sdk_lib_imported ${_sdk_lib} IMPORTED) + if (NOT _sdk_lib_imported) + target_compile_options(${_sdk_lib} PRIVATE ${DUSK_PATCHABLE_ENTRY_FLAG}) + endif () + endif () + endforeach () endif () if (WIN32) @@ -612,13 +654,13 @@ if (APPLE) list(APPEND _apple_bundle_properties XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${DUSK_ENTITLEMENTS}) endif () - if (NOT IOS AND NOT TVOS) + if (CMAKE_SYSTEM_NAME STREQUAL "Darwin") list(APPEND _apple_bundle_properties XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME "YES") endif () set_target_properties(dusklight PROPERTIES ${_apple_bundle_properties}) - if (NOT IOS AND NOT TVOS AND NOT "${CMAKE_GENERATOR}" STREQUAL "Xcode") + if (CMAKE_SYSTEM_NAME STREQUAL "Darwin" AND NOT "${CMAKE_GENERATOR}" STREQUAL "Xcode") set(_sign_nested_commands) if (TARGET crashpad_handler) list(APPEND _sign_nested_commands @@ -635,7 +677,7 @@ if (APPLE) endif () endif () -if (APPLE AND NOT IOS AND NOT TVOS) +if (CMAKE_SYSTEM_NAME STREQUAL "Darwin") find_library(APPKIT_FRAMEWORK AppKit REQUIRED) target_sources(dusklight PRIVATE src/dusk/file_select_macos.mm) set_source_files_properties(src/dusk/file_select_macos.mm PROPERTIES COMPILE_FLAGS -fobjc-arc) diff --git a/CMakePresets.json b/CMakePresets.json index e6485ddac1..4faccc4448 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -314,11 +314,19 @@ "type": "BOOL", "value": false }, + "ENABLE_VISIBILITY": { + "type": "BOOL", + "value": true + }, "Rust_CARGO_TARGET": "aarch64-apple-ios", "BUILD_SHARED_LIBS": { "type": "BOOL", "value": false }, + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + }, "CMAKE_DISABLE_FIND_PACKAGE_PkgConfig": { "type": "BOOL", "value": true @@ -354,12 +362,20 @@ "type": "BOOL", "value": false }, + "ENABLE_VISIBILITY": { + "type": "BOOL", + "value": true + }, "Rust_CARGO_TARGET": "aarch64-apple-tvos", "Rust_TOOLCHAIN": "nightly", "BUILD_SHARED_LIBS": { "type": "BOOL", "value": false }, + "DUSK_ENABLE_CODE_MODS": { + "type": "BOOL", + "value": true + }, "CMAKE_DISABLE_FIND_PACKAGE_PkgConfig": { "type": "BOOL", "value": true diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index 10a87e8525..0dfe5c48b4 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -417,6 +417,21 @@ function(install_bundled_mods) DESTINATION "${_bundle_dir}/Frameworks" PATTERN "${_lib_name}" EXCLUDE) endforeach () + if (DUSK_HAS_PREPATCH) + set(_prepatch_executable "${_bundle_dir}/Dusklight") + set(_prepatch_mod_args "") + foreach (_id IN LISTS _ids) + string(APPEND _prepatch_mod_args + " \"${_bundle_dir}/Frameworks/${_id}.so\"") + endforeach () + install(CODE " + execute_process( + COMMAND \"${SYMGEN_EXE}\" prepatch + --binary \"${_prepatch_executable}\" + --report \"${CMAKE_BINARY_DIR}/prepatch-report-$.json\" + ${_prepatch_mod_args} + COMMAND_ERROR_IS_FATAL ANY)") + endif () else () foreach (_i RANGE ${_last}) list(GET _ids ${_i} _id) @@ -432,6 +447,24 @@ function(install_bundled_mods) endif () endforeach ()") endforeach () + if (DUSK_HAS_PREPATCH) + set(_prepatch_executable "${_bundle_dir}/Contents/MacOS/Dusklight") + set(_prepatch_mod_args "") + foreach (_i RANGE ${_last}) + list(GET _ids ${_i} _id) + list(GET _lib_platforms ${_i} _lib_platform) + list(GET _lib_names ${_i} _lib_name) + string(APPEND _prepatch_mod_args + " \"${_bundle_dir}/Contents/Resources/mods/${_id}/lib/${_lib_platform}/${_lib_name}\"") + endforeach () + install(CODE " + execute_process( + COMMAND \"${SYMGEN_EXE}\" prepatch + --binary \"${_prepatch_executable}\" + --report \"${CMAKE_BINARY_DIR}/prepatch-report-$.json\" + ${_prepatch_mod_args} + COMMAND_ERROR_IS_FATAL ANY)") + endif () if (TARGET crashpad_handler) install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - \"${_bundle_dir}/Contents/MacOS/$\" COMMAND_ERROR_IS_FATAL ANY)") endif () diff --git a/cmake/SymbolManifest.cmake b/cmake/SymbolManifest.cmake index 657afa2a78..40584ede19 100644 --- a/cmake/SymbolManifest.cmake +++ b/cmake/SymbolManifest.cmake @@ -2,7 +2,7 @@ include_guard(GLOBAL) get_filename_component(_SYMBOL_MANIFEST_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) -set(_SYMGEN_VERSION "1.2.3") +set(_SYMGEN_VERSION "1.3.1") 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) @@ -113,14 +113,18 @@ function(setup_symbol_manifest target) endif () if (APPLE) - # Room for the load command `symgen manifest --embed` inserts post-link. - target_link_options(${target} PRIVATE "LINKER:-headerpad,0x200") + # Room for the symbol manifest and several prepatch arenas. + target_link_options(${target} PRIVATE "LINKER:-headerpad,0x1000") + # ld64 may update an existing output, which breaks our symdb insertion. Remove it first. + add_custom_command(TARGET ${target} PRE_LINK + COMMAND "${CMAKE_COMMAND}" -E rm -f "$" + VERBATIM) endif () # The manifest is embedded into the image as a new section, located at runtime through # the descriptor manifest.cpp reserves. On Apple platforms this command must stay - # attached before the ad-hoc codesign POST_BUILD command: the patch invalidates any - # existing signature. + # attached before the ad-hoc codesign POST_BUILD command: the patch removes any existing + # signature. add_custom_command(TARGET ${target} POST_BUILD COMMAND "${SYMGEN_EXE}" manifest ${_input} --embed "$" COMMENT "Embedding symbol manifest" diff --git a/files.cmake b/files.cmake index c8c92c9b75..fa96e313c6 100644 --- a/files.cmake +++ b/files.cmake @@ -1479,6 +1479,8 @@ set(DUSK_FILES src/dusk/mods/loader/loader.hpp src/dusk/mods/loader/native_module.cpp src/dusk/mods/loader/native_module.hpp + src/dusk/mods/loader/prepatch.cpp + src/dusk/mods/loader/prepatch.hpp src/dusk/mods/log_buffer.cpp src/dusk/mods/log_buffer.hpp src/dusk/mods/manifest.cpp diff --git a/sdk/include/mods/api.h b/sdk/include/mods/api.h index c63d08cfe2..3a72135388 100644 --- a/sdk/include/mods/api.h +++ b/sdk/include/mods/api.h @@ -148,6 +148,7 @@ typedef enum ModMetaKind { MOD_META_HOOK_FN = 4, MOD_META_HOOK_MEM = 5, MOD_META_HOOK_NAME = 6, + MOD_META_HOOK_MEM_EXT = 7, } ModMetaKind; typedef struct ModMetaRecord { @@ -203,15 +204,37 @@ static_assert(sizeof(ModMetaHookFn) == 24); * NUL-terminated strings follow `resolved`: the class vtable symbol (empty if the class name is not * representable), then the stringified target for tooling display. */ +#define MOD_META_HOOK_MEM_CAPACITY 16u typedef struct MOD_META_ALIGN ModMetaHookMem { ModMetaRecord rec; uint32_t reserved; - unsigned char pmf[16]; + unsigned char pmf[MOD_META_HOOK_MEM_CAPACITY]; void* resolved; /* runtime only */ } ModMetaHookMem; static_assert(sizeof(ModMetaHookMem) == 32); +/* + * Extended member-function hook record. The Microsoft x64 ABI uses a 24-byte member pointer for + * classes whose inheritance model was fixed while the class was incomplete. Keep this a distinct + * record kind so loaders can continue to consume MOD_META_HOOK_MEM's original 16-byte layout. + * + * MSVC cannot constant-initialize that member pointer, so materialize points to a compiler- + * generated helper that writes its native representation to a caller-provided buffer. The record + * itself, including its trailing names and the helper relocation, remains constant-initialized and + * available to static tooling. pmf_size is the number of bytes the helper writes. + */ +#define MOD_META_HOOK_MEM_EXT_CAPACITY 24u +typedef void (*ModMetaHookMemMaterializeFn)(unsigned char* out_pmf); +typedef struct MOD_META_ALIGN ModMetaHookMemExt { + ModMetaRecord rec; + uint32_t pmf_size; + ModMetaHookMemMaterializeFn materialize; + void* resolved; /* runtime only */ +} ModMetaHookMemExt; + +static_assert(sizeof(ModMetaHookMemExt) == 24); + /* * Hook on a function by symbol name, for targets that cannot be named in C++ (file-local statics, * private members). One NUL-terminated string follows `resolved`; it may be either the platform diff --git a/sdk/include/mods/meta.hpp b/sdk/include/mods/meta.hpp index 023783cea1..3c6223e6fb 100644 --- a/sdk/include/mods/meta.hpp +++ b/sdk/include/mods/meta.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -185,12 +186,21 @@ struct HookMemRecord { uint32_t reserved; union { F fn; - unsigned char raw[16]; + unsigned char raw[MOD_META_HOOK_MEM_CAPACITY]; } pmf; void* resolved; char names[N]; }; +template +struct HookMemExtRecord { + ModMetaRecord rec; + uint32_t pmfSize; + ModMetaHookMemMaterializeFn materialize; + void* resolved; + char names[N]; +}; + template struct HookNameRecord { ModMetaRecord rec; @@ -232,19 +242,43 @@ consteval auto make_hook_mem_names() { } /* - * MSVC constant-evaluates a pointer-to-member only when every other operand in the - * initializer is a literal: no consteval calls, constexpr-object copies, or default - * member initializers. + * MSVC constant-evaluates a compact pointer-to-member only when every other operand in the + * initializer is a literal: no consteval calls, constexpr-object copies, or default member + * initializers. It cannot constant-initialize the 24-byte general representation at all, so the + * extended record points at a compiler-generated materializer while keeping the metadata itself + * constant-initialized for static tooling. */ +template +struct HookMemHolderImpl; + template -struct HookMemHolder { +struct HookMemHolderImpl { using F = decltype(Target); - static_assert(sizeof(F) <= 16, "unsupported pointer-to-member representation"); MOD_META_RECORD static constinit inline HookMemRecord record = { {sizeof(HookMemRecord), MOD_META_HOOK_MEM, 0}, 0, {Target}, nullptr, {Cs...}}; }; +template +struct HookMemHolderImpl { + using F = decltype(Target); + static void materialize(unsigned char* outPmf) { + const F target = Target; + std::memcpy(outPmf, &target, sizeof(target)); + } + MOD_META_RECORD static constinit inline HookMemExtRecord record = { + {sizeof(HookMemExtRecord), MOD_META_HOOK_MEM_EXT, 0}, sizeof(F), materialize, + nullptr, {Cs...}}; +}; + +template +struct HookMemHolder + : HookMemHolderImpl MOD_META_HOOK_MEM_CAPACITY), Cs...> { + using F = decltype(Target); + static_assert(sizeof(F) <= MOD_META_HOOK_MEM_EXT_CAPACITY, + "unsupported pointer-to-member representation"); +}; + template struct HookFnHolder { using F = decltype(Target); diff --git a/src/dusk/mod_loader.hpp b/src/dusk/mod_loader.hpp index 34e17274bc..fad9ba1501 100644 --- a/src/dusk/mod_loader.hpp +++ b/src/dusk/mod_loader.hpp @@ -73,6 +73,7 @@ struct ModMetaParsed { std::vector exports; std::vector hookFns; std::vector hookMems; + std::vector hookMemExts; std::vector hookNames; }; @@ -85,6 +86,15 @@ inline const char* hook_mem_display_name(const ModMetaHookMem& rec) { return vtable + std::char_traits::length(vtable) + 1; } +inline const char* hook_mem_vtable_symbol(const ModMetaHookMemExt& rec) { + return reinterpret_cast(&rec) + sizeof(ModMetaHookMemExt); +} + +inline const char* hook_mem_display_name(const ModMetaHookMemExt& rec) { + const char* vtable = hook_mem_vtable_symbol(rec); + return vtable + std::char_traits::length(vtable) + 1; +} + inline const char* hook_name_symbol(const ModMetaHookName& rec) { return reinterpret_cast(&rec) + sizeof(ModMetaHookName); } diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 353a2ab596..cdcc16c5e2 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -26,6 +26,9 @@ #include "miniz.h" #include "native_module.hpp" #include "nlohmann/json.hpp" +#if DUSK_HAS_PREPATCH +#include "prepatch.hpp" +#endif using namespace std::string_literals; using namespace std::string_view_literals; @@ -411,6 +414,28 @@ static bool parse_meta(NativeMod& native, LoadedMod& mod) { parsed.hookMems.push_back(record); break; } + case MOD_META_HOOK_MEM_EXT: { + if (size <= sizeof(ModMetaHookMemExt)) { + return invalid("truncated extended hook record"); + } + auto* record = reinterpret_cast(const_cast(cursor)); + if (record->pmf_size <= MOD_META_HOOK_MEM_CAPACITY || + record->pmf_size > MOD_META_HOOK_MEM_EXT_CAPACITY || record->materialize == nullptr) + { + return invalid("bad extended hook member-pointer size"); + } + const char* strings = reinterpret_cast(cursor) + sizeof(ModMetaHookMemExt); + const size_t capacity = size - sizeof(ModMetaHookMemExt); + if (!terminated_within(strings, capacity)) { + return invalid("unterminated extended hook vtable symbol"); + } + const size_t vtableLen = std::char_traits::length(strings); + if (!terminated_within(strings + vtableLen + 1, capacity - vtableLen - 1)) { + return invalid("unterminated extended hook display name"); + } + parsed.hookMemExts.push_back(record); + break; + } case MOD_META_HOOK_NAME: { if (size <= sizeof(ModMetaHookName)) { return invalid("truncated hook record"); @@ -906,6 +931,9 @@ void ModLoader::init() { m_initialized = true; manifest::initialize(); +#if DUSK_HAS_PREPATCH + prepatch::initialize(); +#endif if (m_searchDirs.empty()) { Log.warn("no mod search directories configured; mod loading skipped"); diff --git a/src/dusk/mods/loader/prepatch.cpp b/src/dusk/mods/loader/prepatch.cpp new file mode 100644 index 0000000000..a9da0f9301 --- /dev/null +++ b/src/dusk/mods/loader/prepatch.cpp @@ -0,0 +1,260 @@ +#include "prepatch.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aurora/lib/logging.hpp" + +#if DUSK_HAS_PREPATCH +#include +#include +#endif + +namespace dusk::mods::prepatch { +namespace { + +aurora::Module Log("dusk::mods::prepatch"); + +constexpr std::string_view kSiteMagic = "PS01"; +constexpr size_t kSiteHeaderSize = 12; +constexpr size_t kGatewaySize = 28; +constexpr size_t kOriginalStubOffset = 20; + +constexpr uint32_t kBranchMask = 0xfc000000u; +constexpr uint32_t kBranch = 0x14000000u; +constexpr uint32_t kLdarX16X16 = 0xc8dffe10u; +constexpr uint32_t kCbzX16Stub = 0xb4000050u; +constexpr uint32_t kBrX16 = 0xd61f0200u; +constexpr uint32_t kBtiC = 0xd503245fu; + +struct SiteHeader { + char magic[4]; + int32_t targetDelta; + int32_t slotDelta; +}; +static_assert(sizeof(SiteHeader) == kSiteHeaderSize); + +struct Range { + uintptr_t begin = 0; + uintptr_t end = 0; + + bool contains(uintptr_t address, size_t size) const { + return address >= begin && address <= end && size <= end - address; + } +}; + +struct State { + std::vector executableRanges; + std::vector writableRanges; + std::string failureReason = "not initialized"; + bool loaded = false; + bool initialized = false; +}; +State s_state; + +void fail(std::string reason) { + s_state.failureReason = std::move(reason); + Log.error("prepatch backend unavailable: {}", s_state.failureReason); +} + +void unavailable(std::string reason) { + s_state.failureReason = std::move(reason); + Log.info("prepatch backend unavailable: {}", s_state.failureReason); +} + +bool add_delta(uintptr_t address, int64_t delta, uintptr_t& out) { + if (delta >= 0) { + const auto offset = static_cast(delta); + if (address > std::numeric_limits::max() - offset) { + return false; + } + out = address + offset; + } else { + const auto offset = static_cast(-(delta + 1)) + 1; + if (address < offset) { + return false; + } + out = address - offset; + } + return true; +} + +bool branch_target(uintptr_t address, uint32_t instruction, uintptr_t& out) { + if ((instruction & kBranchMask) != kBranch) { + return false; + } + int64_t words = instruction & 0x03ffffffu; + if ((words & 0x02000000) != 0) { + words -= int64_t{1} << 26; + } + return add_delta(address, words * 4, out); +} + +const Range* containing(const std::vector& ranges, uintptr_t address, size_t size) { + for (const auto& range : ranges) { + if (range.contains(address, size)) { + return ⦥ + } + } + return nullptr; +} + +#if DUSK_HAS_PREPATCH +bool slide_address(intptr_t slide, uint64_t vmaddr, uintptr_t& out) { + if (vmaddr > std::numeric_limits::max()) { + return false; + } + return add_delta(static_cast(vmaddr), slide, out); +} +#endif + +} // namespace + +void initialize() { + if (s_state.initialized) { + return; + } + s_state.initialized = true; + +#if DUSK_HAS_PREPATCH + const auto* imageHeader = reinterpret_cast(_dyld_get_image_header(0)); + if (imageHeader == nullptr || imageHeader->magic != MH_MAGIC_64 || + imageHeader->cputype != CPU_TYPE_ARM64 || + (imageHeader->cpusubtype & ~CPU_SUBTYPE_MASK) == CPU_SUBTYPE_ARM64E || + (imageHeader->cpusubtype & CPU_SUBTYPE_ARM64_PTR_AUTH_MASK) != 0) + { + fail("main image is not a supported 64-bit arm64 Mach-O image"); + return; + } + + const intptr_t slide = _dyld_get_image_vmaddr_slide(0); + const auto* commands = reinterpret_cast(imageHeader + 1); + size_t commandOffset = 0; + for (uint32_t i = 0; i < imageHeader->ncmds; ++i) { + if (commandOffset > imageHeader->sizeofcmds || + imageHeader->sizeofcmds - commandOffset < sizeof(load_command)) + { + fail("main image has malformed load commands"); + return; + } + const auto* command = reinterpret_cast(commands + commandOffset); + if (command->cmdsize < sizeof(load_command) || + command->cmdsize > imageHeader->sizeofcmds - commandOffset) + { + fail("main image has malformed load commands"); + return; + } + if (command->cmd == LC_SEGMENT_64) { + if (command->cmdsize < sizeof(segment_command_64)) { + fail("main image has a truncated segment command"); + return; + } + const auto* segment = reinterpret_cast(command); + const uint64_t vmEnd = segment->vmaddr + segment->vmsize; + if (vmEnd < segment->vmaddr) { + fail("main image segment range overflows"); + return; + } + uintptr_t begin = 0; + uintptr_t end = 0; + if (!slide_address(slide, segment->vmaddr, begin) || + !slide_address(slide, vmEnd, end) || end < begin) + { + fail("main image runtime segment range overflows"); + return; + } + constexpr vm_prot_t kRx = VM_PROT_READ | VM_PROT_EXECUTE; + constexpr vm_prot_t kRw = VM_PROT_READ | VM_PROT_WRITE; + if (segment->initprot == kRx && segment->maxprot == kRx) { + s_state.executableRanges.push_back({begin, end}); + } else if (segment->initprot == kRw && segment->maxprot == kRw) { + s_state.writableRanges.push_back({begin, end}); + } + } + commandOffset += command->cmdsize; + } + if (commandOffset != imageHeader->sizeofcmds || s_state.executableRanges.empty() || + s_state.writableRanges.empty()) + { + fail("main image has no usable executable or writable segments"); + return; + } + + s_state.failureReason.clear(); + s_state.loaded = true; +#else + unavailable("prepatch support is not available in this build"); +#endif +} + +bool available() { + return s_state.loaded; +} + +const char* unavailable_reason() { + return s_state.failureReason.c_str(); +} + +std::optional lookup(void* runtimeTarget) { + if (!s_state.loaded || runtimeTarget == nullptr) { + return std::nullopt; + } + const auto target = reinterpret_cast(runtimeTarget); + if (containing(s_state.executableRanges, target, sizeof(uint32_t)) == nullptr) { + return std::nullopt; + } + + uint32_t entry = 0; + std::memcpy(&entry, reinterpret_cast(target), sizeof(entry)); + uintptr_t gateway = 0; + if (!branch_target(target, entry, gateway) || gateway < kSiteHeaderSize) { + return std::nullopt; + } + const uintptr_t headerAddress = gateway - kSiteHeaderSize; + if (containing(s_state.executableRanges, headerAddress, kSiteHeaderSize + kGatewaySize) == + nullptr) + { + return std::nullopt; + } + + SiteHeader header{}; + std::memcpy(&header, reinterpret_cast(headerAddress), sizeof(header)); + if (std::memcmp(header.magic, kSiteMagic.data(), kSiteMagic.size()) != 0) { + return std::nullopt; + } + uintptr_t recordedTarget = 0; + uintptr_t slot = 0; + if (!add_delta(gateway, header.targetDelta, recordedTarget) || recordedTarget != target || + !add_delta(gateway, header.slotDelta, slot) || (slot & (alignof(void*) - 1)) != 0 || + containing(s_state.writableRanges, slot, sizeof(void*)) == nullptr) + { + return std::nullopt; + } + + uint32_t instructions[7]{}; + std::memcpy(instructions, reinterpret_cast(gateway), sizeof(instructions)); + uintptr_t original = 0; + if (instructions[2] != kLdarX16X16 || instructions[3] != kCbzX16Stub || + instructions[4] != kBrX16 || instructions[5] != kBtiC || + target > std::numeric_limits::max() - 4 || + !branch_target(gateway + 24, instructions[6], original) || original != target + 4) + { + return std::nullopt; + } + + return Site{ + reinterpret_cast(slot), reinterpret_cast(gateway + kOriginalStubOffset)}; +} + +void publish(const Site& site, void* trampoline) { + std::atomic_ref slot{*site.slot}; + slot.store(trampoline, std::memory_order_release); +} + +} // namespace dusk::mods::prepatch diff --git a/src/dusk/mods/loader/prepatch.hpp b/src/dusk/mods/loader/prepatch.hpp new file mode 100644 index 0000000000..3b742d003b --- /dev/null +++ b/src/dusk/mods/loader/prepatch.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace dusk::mods::prepatch { + +struct Site { + void** slot = nullptr; + void* original = nullptr; +}; + +void initialize(); +bool available(); +const char* unavailable_reason(); +// Returns the target function's prepatched hook site, or nullopt if it is not prepatched. +std::optional lookup(void* runtimeTarget); +// Publishes a trampoline (or nullptr to deactivate). +void publish(const Site& site, void* trampoline); + +} // namespace dusk::mods::prepatch diff --git a/src/dusk/mods/svc/hook.cpp b/src/dusk/mods/svc/hook.cpp index 34d7d54aa7..ec967ae7e6 100644 --- a/src/dusk/mods/svc/hook.cpp +++ b/src/dusk/mods/svc/hook.cpp @@ -1,6 +1,9 @@ #include "registry.hpp" #include "dusk/mods/loader/loader.hpp" +#if DUSK_HAS_PREPATCH +#include "dusk/mods/loader/prepatch.hpp" +#endif #include "dusk/mods/manifest.hpp" #include "mods/svc/hook.h" @@ -9,11 +12,14 @@ #include "dusk/mods/log_buffer.hpp" #include +#include #include #include #include #include +#if DUSK_HAS_FUNCHOOK #include +#endif #include #include #include @@ -48,8 +54,7 @@ struct HookSlot { // 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. +// unloads, the backend is handed off to a surviving candidate. struct HookCandidate { ModContext* context = nullptr; void* trampoline = nullptr; @@ -57,8 +62,28 @@ struct HookCandidate { uint64_t order = 0; }; -struct InstalledHook { +enum class BackendKind { + None, +#if DUSK_HAS_FUNCHOOK + Funchook, +#endif +#if DUSK_HAS_PREPATCH + Prepatch, +#endif +}; + +struct InstalledBackend { + BackendKind kind = BackendKind::None; +#if DUSK_HAS_FUNCHOOK funchook_t* handle = nullptr; +#endif +#if DUSK_HAS_PREPATCH + prepatch::Site prepatchSite{}; +#endif +}; + +struct InstalledHook { + InstalledBackend backend{}; void* original = nullptr; ModContext* active = nullptr; std::vector candidates; @@ -205,7 +230,8 @@ void* resolve_target(void* addr) { return addr; } -funchook_t* install_trampoline(void* fnAddr, void* trampoline, void** outOriginal) { +#if DUSK_HAS_FUNCHOOK +funchook_t* install_funchook(void* fnAddr, void* trampoline, void** outOriginal) { funchook_t* fh = funchook_create(); if (fh == nullptr) { DuskLog.warn("HookSystem: funchook_create failed for {:p}", fnAddr); @@ -214,18 +240,101 @@ funchook_t* install_trampoline(void* fnAddr, void* trampoline, void** outOrigina void* fn = fnAddr; const int prep = funchook_prepare(fh, &fn, trampoline); + if (prep == 0) { + *outOriginal = fn; + } 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); + *outOriginal = nullptr; return nullptr; } - - *outOriginal = fn; return fh; } +#endif + +bool install_backend( + void* fnAddr, void* trampoline, InstalledBackend& outBackend, void** originalStore) { +#if DUSK_HAS_PREPATCH + if (const auto site = prepatch::lookup(fnAddr)) { + *originalStore = site->original; + prepatch::publish(*site, trampoline); + outBackend.kind = BackendKind::Prepatch; + outBackend.prepatchSite = *site; + return true; + } +#endif + +#if DUSK_HAS_FUNCHOOK + funchook_t* handle = install_funchook(fnAddr, trampoline, originalStore); + if (handle == nullptr) { + return false; + } + outBackend.kind = BackendKind::Funchook; + outBackend.handle = handle; + return true; +#else +#if DUSK_HAS_PREPATCH + DuskLog.warn("HookSystem: prepatch backend cannot install target {:p}: {}", fnAddr, + prepatch::available() ? "target has no valid gateway" : prepatch::unavailable_reason()); +#else + DuskLog.warn("HookSystem: no hook backend can install target {:p}", fnAddr); +#endif + return false; +#endif +} + +void deactivate_backend(void* target, InstalledBackend& backend) { +#if DUSK_HAS_PREPATCH + if (backend.kind == BackendKind::Prepatch) { + prepatch::publish(backend.prepatchSite, nullptr); + backend = {}; + return; + } +#endif +#if DUSK_HAS_FUNCHOOK + if (backend.kind == BackendKind::Funchook) { + const int uninst = funchook_uninstall(backend.handle, 0); + const int destr = funchook_destroy(backend.handle); + if (uninst != 0 || destr != 0) { + DuskLog.warn("HookSystem: funchook uninstall/destroy for {:p} returned {}/{}", target, + uninst, destr); + } + } +#else + (void)target; +#endif + backend = {}; +} + +bool handoff_backend( + void* target, InstalledHook& entry, const HookCandidate& candidate, void** outOriginal) { +#if DUSK_HAS_PREPATCH + if (entry.backend.kind == BackendKind::Prepatch) { + *candidate.origStore = entry.original; + prepatch::publish(entry.backend.prepatchSite, candidate.trampoline); + *outOriginal = entry.original; + return true; + } +#endif +#if DUSK_HAS_FUNCHOOK + InstalledBackend backend; + if (!install_backend(target, candidate.trampoline, backend, candidate.origStore)) { + return false; + } + entry.backend = backend; + *outOriginal = *candidate.origStore; + return true; +#else + (void)target; + (void)candidate; + (void)outOriginal; + return false; +#endif +} ModResult hook_install(ModContext* context, void* fnAddr, void* trampolineFn, void** outOriginal) { if (fnAddr == nullptr || trampolineFn == nullptr || outOriginal == nullptr) { @@ -272,18 +381,16 @@ ModResult hook_install(ModContext* context, void* fnAddr, void* trampolineFn, vo name != nullptr ? name : "?", fnAddr, mod_id_from_context(context)); } - void* original = nullptr; - funchook_t* fh = install_trampoline(fnAddr, trampolineFn, &original); - if (fh == nullptr) { + InstalledBackend backend; + if (!install_backend(fnAddr, trampolineFn, backend, outOriginal)) { return MOD_ERROR; } auto& entry = s_installed[key]; - entry.handle = fh; - entry.original = original; + entry.backend = backend; + entry.original = *outOriginal; entry.active = context; entry.candidates.push_back({context, trampolineFn, outOriginal, s_nextOrder++}); - *outOriginal = original; return MOD_OK; } @@ -543,20 +650,67 @@ bool resolve_symbol_checked(const char* symbol, bool requireCode, void** out, st return false; } -/* Decode a HOOK_MEM record's pointer-to-member representation into the target code address, - * mirroring what calling through the mfp would invoke. Virtual members hook the class's own - * overrider, read from its vtable (resolved from the symbol manifest). */ -void* resolve_member_record( - const ModMetaHookMem& record, const char* vtableSymbol, std::string& why) { - uintptr_t words[2]; - std::memcpy(words, record.pmf, sizeof(words)); +/* + * Decodes a member hook record's pointer-to-member representation into the target code address. + * Virtual members prefer their named overrider, then fall back to reading the primary vtable slot. + */ +void* resolve_member_record(const unsigned char* pmf, size_t pmfSize, const char* vtableSymbol, + const char* displayName, std::string& why) { + if (pmfSize < sizeof(uintptr_t)) { + why = "truncated pointer-to-member representation"; + return nullptr; + } + uintptr_t words[2]{}; + std::memcpy(words, pmf, std::min(sizeof(words), pmfSize)); #if defined(_WIN32) const void* fn = reinterpret_cast(words[0]); + if (fn == nullptr) { + why = "null pointer-to-member target"; + return nullptr; + } const size_t slot = vcall_slot_offset(fn); if (slot == static_cast(-1)) { // not a vcall thunk: direct address return const_cast(fn); } + + // A display name resolves the actual overrider rather than an ABI vcall thunk. Besides being + // more direct, this covers secondary vtables: the MSVC representation has `this` adjustment + // but does not have the base-path suffix used by the decorated vtable symbol. + std::string displayWhy; + void* displayTarget = nullptr; + if (displayName[0] != '\0' && + resolve_symbol_checked(displayName, true, &displayTarget, displayWhy)) + { + return displayTarget; + } + + int32_t thisAdjustment = 0; + if (pmfSize >= sizeof(uintptr_t) + sizeof(thisAdjustment)) { + std::memcpy(&thisAdjustment, pmf + sizeof(uintptr_t), sizeof(thisAdjustment)); + } + if (thisAdjustment != 0) { + why = fmt::format( + "virtual member requires a {}-byte this adjustment and its overrider did not " + "resolve by name ({})", + thisAdjustment, displayWhy); + return nullptr; + } + int32_t firstVirtualField = 0; + if (pmfSize > MOD_META_HOOK_MEM_CAPACITY && pmfSize >= sizeof(uintptr_t) + 2 * sizeof(int32_t)) + { + std::memcpy(&firstVirtualField, pmf + sizeof(uintptr_t) + sizeof(int32_t), sizeof(int32_t)); + } + int32_t secondVirtualField = 0; + if (pmfSize > MOD_META_HOOK_MEM_CAPACITY && pmfSize >= sizeof(uintptr_t) + 3 * sizeof(int32_t)) + { + std::memcpy( + &secondVirtualField, pmf + sizeof(uintptr_t) + 2 * sizeof(int32_t), sizeof(int32_t)); + } + if (firstVirtualField != 0 || secondVirtualField != 0) { + why = fmt::format("virtual-base member did not resolve by name ({})", displayWhy); + return nullptr; + } if (vtableSymbol[0] == '\0') { why = "class name is not representable as a vtable symbol"; return nullptr; @@ -583,10 +737,18 @@ void* resolve_member_record( if (!isVirtual) { // non-virtual: the address itself return reinterpret_cast(words[0]); } + + std::string displayWhy; + void* displayTarget = nullptr; + if (displayName[0] != '\0' && + resolve_symbol_checked(displayName, true, &displayTarget, displayWhy)) + { + return displayTarget; + } if (thisAdjust != 0) { - // this-adjusting mfp (member of a secondary base): the slot offset is relative to a - // vtable we can't locate. Hook the overrider by name instead. - why = "virtual member of a secondary base; hook the overrider by name"; + // The slot is relative to a secondary vtable whose base path is not encoded in the mfp. + why = fmt::format( + "virtual member of a secondary base did not resolve by name ({})", displayWhy); return nullptr; } if (vtableSymbol[0] == '\0') { @@ -638,35 +800,32 @@ void hook_remove_mod(LoadedMod& mod) { } 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()) { + deactivate_backend(target, entry.backend); 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. + // A prepatch may be atomically updated directly. + // Funchook must first restore the original instructions before reinstalling. +#if DUSK_HAS_PREPATCH + const bool prepatched = entry.backend.kind == BackendKind::Prepatch; +#else + constexpr bool prepatched = false; +#endif + if (!prepatched) { + deactivate_backend(target, entry.backend); + } + entry.active = nullptr; for (auto& cand : entry.candidates) { void* original = nullptr; - funchook_t* fh = install_trampoline(target, cand.trampoline, &original); - if (fh == nullptr) { + if (!handoff_backend(target, entry, cand, &original)) { 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); + DuskLog.info("HookSystem: replaced trampoline for {:p}: {} -> {} (tramp={:p})", target, + mod_id_from_context(context), mod_id_from_context(cand.context), cand.trampoline); break; } @@ -677,6 +836,7 @@ void hook_remove_mod(LoadedMod& mod) { for (auto& cand : entry.candidates) { *cand.origStore = target; } + deactivate_backend(target, entry.backend); it = s_installed.erase(it); continue; } @@ -772,14 +932,24 @@ void hook_resolve_mod_records(LoadedMod& mod) { unresolved("", "null link-time target", &record->resolved); } } - for (auto* record : mod.native->parsed.hookMems) { + const auto resolveMember = [&](auto* record, const unsigned char* pmf, size_t pmfSize) { + const char* displayName = hook_mem_display_name(*record); std::string why; - void* target = resolve_member_record(*record, hook_mem_vtable_symbol(*record), why); + void* target = + resolve_member_record(pmf, pmfSize, hook_mem_vtable_symbol(*record), displayName, why); if (target != nullptr) { resolved(target, &record->resolved); } else { - unresolved(hook_mem_display_name(*record), why, &record->resolved); + unresolved(displayName, why, &record->resolved); } + }; + for (auto* record : mod.native->parsed.hookMems) { + resolveMember(record, record->pmf, sizeof(record->pmf)); + } + for (auto* record : mod.native->parsed.hookMemExts) { + alignas(std::max_align_t) unsigned char pmf[MOD_META_HOOK_MEM_EXT_CAPACITY]{}; + record->materialize(pmf); + resolveMember(record, pmf, record->pmf_size); } for (auto* record : mod.native->parsed.hookNames) { const char* name = hook_name_symbol(*record);