From ff630aaf9692e1c57964342e74302d85be70c44a Mon Sep 17 00:00:00 2001 From: LibraHp_0928 <1941163264@qq.com> Date: Wed, 23 Sep 2026 04:09:22 +0800 Subject: [PATCH] Stabilize ARM64 hooks with prebuilt Dobby --- app/src/main/cpp/CMakeLists.txt | 12 +- app/src/main/cpp/dobby.h | 150 ++++++++++-- app/src/main/cpp/fusion/include/fusion.h | 4 +- app/src/main/cpp/fusion/src/exports.cpp | 10 +- app/src/main/cpp/fusion/src/fusion.cpp | 9 +- .../main/cpp/fusion/src/hooking/il2cpp.cpp | 17 +- .../main/cpp/fusion/src/hooking/libunity.cpp | 5 +- .../main/cpp/fusion/src/hooking/safehook.cpp | 215 +----------------- app/src/main/cpp/tests/chained_functions.S | 58 +++++ app/src/main/cpp/tests/chained_hook_test.cpp | 83 +++++++ app/src/main/cpp/tests/crowded_hook_test.cpp | 154 +++++++++++++ app/src/main/cpp/tests/short_functions.S | 29 +++ app/src/main/cpp/tests/short_hook_test.cpp | 101 ++++++++ app/src/main/jniLibs/arm64-v8a/libdobby.so | 4 +- ci-version.txt | 2 +- docs/native-crash-fixes.md | 98 ++++++++ 16 files changed, 708 insertions(+), 243 deletions(-) create mode 100644 app/src/main/cpp/tests/chained_functions.S create mode 100644 app/src/main/cpp/tests/chained_hook_test.cpp create mode 100644 app/src/main/cpp/tests/crowded_hook_test.cpp create mode 100644 app/src/main/cpp/tests/short_functions.S create mode 100644 app/src/main/cpp/tests/short_hook_test.cpp diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index d09bf4ac..e653d171 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -11,9 +11,19 @@ project("bepinex_android" CXX ASM) add_subdirectory(main) add_subdirectory(fusion) -option(FUSION_BUILD_TESTS "Build the Android ARM64 return-buffer regression executable" OFF) +option(FUSION_BUILD_TESTS "Build the Android ARM64 native hook regression executables" OFF) if(FUSION_BUILD_TESTS) add_executable(return_buffer_test tests/return_buffer_test.cpp) target_compile_options(return_buffer_test PRIVATE -UNDEBUG) target_link_libraries(return_buffer_test PRIVATE fusion) + add_executable(short_hook_test tests/short_hook_test.cpp tests/short_functions.S) + target_compile_options(short_hook_test PRIVATE -UNDEBUG) + target_link_libraries(short_hook_test PRIVATE fusion) + add_executable(chained_hook_test tests/chained_hook_test.cpp tests/chained_functions.S) + target_compile_options(chained_hook_test PRIVATE -UNDEBUG) + target_link_options(chained_hook_test PRIVATE -Wl,--no-relax) + target_link_libraries(chained_hook_test PRIVATE fusion) + add_executable(crowded_hook_test tests/crowded_hook_test.cpp) + target_compile_options(crowded_hook_test PRIVATE -UNDEBUG) + target_link_libraries(crowded_hook_test PRIVATE fusion) endif() diff --git a/app/src/main/cpp/dobby.h b/app/src/main/cpp/dobby.h index afc0163d..745b7986 100644 --- a/app/src/main/cpp/dobby.h +++ b/app/src/main/cpp/dobby.h @@ -1,27 +1,147 @@ -/* - * Dobby — ARM64 inline hook framework - * https://github.com/jmpews/Dobby - * MIT License - */ -#ifndef DOBBY_H -#define DOBBY_H +#ifndef dobby_h +#define dobby_h #ifdef __cplusplus extern "C" { #endif +#include #include -// Hook a function. Returns 0 on success. -int DobbyHook(void* address, void* replace_call, void** origin_call); +typedef uintptr_t addr_t; +typedef uint32_t addr32_t; +typedef uint64_t addr64_t; -// Remove a hook. Returns 0 on success. -int DobbyDestroy(void* address); +typedef void *dobby_dummy_func_t; +typedef void *asm_func_t; -// Platform-specific: get the page size -int DobbyGetGlobalPageSize(); +#if defined(__arm__) +typedef struct { + uint32_t dummy_0; + uint32_t dummy_1; -// Enable/disable near branch trampoline (ARM64) + uint32_t dummy_2; + uint32_t sp; + + union { + uint32_t r[13]; + struct { + uint32_t r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12; + } regs; + } general; + + uint32_t lr; +} DobbyRegisterContext; +#elif defined(__arm64__) || defined(__aarch64__) +#define ARM64_TMP_REG_NDX_0 17 + +typedef union _FPReg { + __int128_t q; + struct { + double d1; + double d2; + } d; + struct { + float f1; + float f2; + float f3; + float f4; + } f; +} FPReg; + +// register context +typedef struct { + uint64_t dmmpy_0; // dummy placeholder + uint64_t sp; + + uint64_t dmmpy_1; // dummy placeholder + union { + uint64_t x[29]; + struct { + uint64_t x0, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11, x12, x13, x14, x15, x16, x17, x18, x19, x20, x21, x22, + x23, x24, x25, x26, x27, x28; + } regs; + } general; + + uint64_t fp; + uint64_t lr; + + union { + FPReg q[32]; + struct { + FPReg q0, q1, q2, q3, q4, q5, q6, q7; + // [!!! READ ME !!!] + // for Arm64, can't access q8 - q31, unless you enable full floating-point register pack + FPReg q8, q9, q10, q11, q12, q13, q14, q15, q16, q17, q18, q19, q20, q21, q22, q23, q24, q25, q26, q27, q28, q29, + q30, q31; + } regs; + } floating; +} DobbyRegisterContext; +#elif defined(_M_IX86) || defined(__i386__) +typedef struct _RegisterContext { + uint32_t dummy_0; + uint32_t esp; + + uint32_t dummy_1; + uint32_t flags; + + union { + struct { + uint32_t eax, ebx, ecx, edx, ebp, esp, edi, esi; + } regs; + } general; + +} DobbyRegisterContext; +#elif defined(_M_X64) || defined(__x86_64__) +typedef struct { + uint64_t dummy_0; + uint64_t rsp; + + union { + struct { + uint64_t rax, rbx, rcx, rdx, rbp, rsp, rdi, rsi, r8, r9, r10, r11, r12, r13, r14, r15; + } regs; + } general; + + uint64_t dummy_1; + uint64_t flags; +} DobbyRegisterContext; +#endif + +#define install_hook_name(name, fn_ret_t, fn_args_t...) \ + static fn_ret_t fake_##name(fn_args_t); \ + static fn_ret_t (*orig_##name)(fn_args_t); \ + /* __attribute__((constructor)) */ static void install_hook_##name(void *sym_addr) { \ + DobbyHook(sym_addr, (dobby_dummy_func_t)fake_##name, (dobby_dummy_func_t *)&orig_##name); \ + return; \ + } \ + fn_ret_t fake_##name(fn_args_t) + +// memory code patch +int DobbyCodePatch(void *address, uint8_t *buffer, uint32_t buffer_size); + +// function inline hook +int DobbyHook(void *address, dobby_dummy_func_t replace_func, dobby_dummy_func_t *origin_func); + +// dynamic binary instruction instrument +// for Arm64, can't access q8 - q31, unless enable full floating-point register pack +typedef void (*dobby_instrument_callback_t)(void *address, DobbyRegisterContext *ctx); +int DobbyInstrument(void *address, dobby_instrument_callback_t pre_handler); + +// destroy and restore code patch +int DobbyDestroy(void *address); + +const char *DobbyGetVersion(); + +// symbol resolver +void *DobbySymbolResolver(const char *image_name, const char *symbol_name); + +// import table replace +int DobbyImportTableReplace(char *image_name, char *symbol_name, dobby_dummy_func_t fake_func, + dobby_dummy_func_t *orig_func); + +// for arm, Arm64, try use b xxx instead of ldr absolute indirect branch +// for x86, x64, always use absolute indirect jump void dobby_enable_near_branch_trampoline(); void dobby_disable_near_branch_trampoline(); @@ -29,4 +149,4 @@ void dobby_disable_near_branch_trampoline(); } #endif -#endif // DOBBY_H +#endif diff --git a/app/src/main/cpp/fusion/include/fusion.h b/app/src/main/cpp/fusion/include/fusion.h index fb8d617c..9a84e5a3 100644 --- a/app/src/main/cpp/fusion/include/fusion.h +++ b/app/src/main/cpp/fusion/include/fusion.h @@ -63,11 +63,13 @@ const char *il2cpp_method_get_name(void *method); int il2cpp_init(char *domain_name); /* Hook management */ -void il2cpp_install_init_hook(void *hookCallback); +bool il2cpp_install_init_hook(void *hookCallback); void il2cpp_destroy_init_hook(); /* SafeHook / Dobby (safehook.cpp) */ bool safehook_initialize(void *lib_handle, uintptr_t lib_base, void *(*allocator)(void *, void *, size_t)); +int safehook_install(void *target, void *replacement, void **original); +bool safehook_remove(void *target); /* libunity hooks (libunity.cpp) */ bool try_hook_libunity(const char *libUnityPath, const char *fallbackLibUnityPath); diff --git a/app/src/main/cpp/fusion/src/exports.cpp b/app/src/main/cpp/fusion/src/exports.cpp index 87a26b7c..0d464e0f 100644 --- a/app/src/main/cpp/fusion/src/exports.cpp +++ b/app/src/main/cpp/fusion/src/exports.cpp @@ -5,13 +5,11 @@ #include #include #include -#include #include #include -#include "dobby.h" +#include "fusion.h" namespace { -std::mutex hookMutex; thread_local void *returnBuffer = nullptr; } @@ -67,7 +65,6 @@ static void *create_return_buffer_bridge(void *detour, size_t pageSize) void *hook(void *target, void *detour, bool specialReturnBuffer) { - std::lock_guard guard(hookMutex); if (!target || !detour) return nullptr; const long pageSize = sysconf(_SC_PAGESIZE); if (pageSize <= 0) return nullptr; @@ -78,7 +75,7 @@ void *hook(void *target, void *detour, bool specialReturnBuffer) return nullptr; } void *original = nullptr; - int rc = DobbyHook(target, bridge ? bridge : detour, &original); + int rc = safehook_install(target, bridge ? bridge : detour, &original); if (rc != 0) { if (bridge) munmap(bridge, static_cast(pageSize)); __android_log_print(ANDROID_LOG_ERROR, "Fusion", "DobbyHook failed at %p: %d", target, rc); @@ -89,8 +86,7 @@ void *hook(void *target, void *detour, bool specialReturnBuffer) bool unhook_checked(void *target) { - std::lock_guard guard(hookMutex); - return target && DobbyDestroy(target) == 0; + return safehook_remove(target); } void unhook(void *target) { (void)unhook_checked(target); } diff --git a/app/src/main/cpp/fusion/src/fusion.cpp b/app/src/main/cpp/fusion/src/fusion.cpp index 49b0d7f0..24ebc6d4 100644 --- a/app/src/main/cpp/fusion/src/fusion.cpp +++ b/app/src/main/cpp/fusion/src/fusion.cpp @@ -35,7 +35,7 @@ extern "C" { /* Hooking */ bool il2cpp_initialize(const char *il2cppPath); -void il2cpp_install_init_hook(void *hookCallback); +bool il2cpp_install_init_hook(void *hookCallback); void il2cpp_destroy_init_hook(); int il2cpp_init(char *domain_name); void *il2cpp_get_handle(); @@ -255,7 +255,7 @@ bool fusion_bootstrap_from_libmain(JNIEnv *env) void *il2cppHandle = il2cpp_get_handle(); uintptr_t il2cppBase = il2cpp_get_library_base(); - /* Use code cave allocator for Dobby trampolines — bypasses Android W^X. */ + /* SafeHook uses the standalone Dobby build with a mandatory four-byte entry branch. */ if (!safehook_initialize(il2cppHandle, il2cppBase, allocate_injected)) { LOGE("safehook_initialize failed"); return false; @@ -263,7 +263,10 @@ bool fusion_bootstrap_from_libmain(JNIEnv *env) LOGI("SafeHook initialized"); /* 4. Install il2cpp_init hook (one-shot, will fire when Unity calls il2cpp_init) */ - il2cpp_install_init_hook(reinterpret_cast(il2cpp_init_hook)); + if (!il2cpp_install_init_hook(reinterpret_cast(il2cpp_init_hook))) { + LOGE("Cannot install il2cpp_init hook"); + return false; + } LOGI("Fusion bootstrap complete 鈥?waiting for il2cpp_init..."); return true; diff --git a/app/src/main/cpp/fusion/src/hooking/il2cpp.cpp b/app/src/main/cpp/fusion/src/hooking/il2cpp.cpp index b3efa730..6577b789 100644 --- a/app/src/main/cpp/fusion/src/hooking/il2cpp.cpp +++ b/app/src/main/cpp/fusion/src/hooking/il2cpp.cpp @@ -6,7 +6,6 @@ #include #include #include -#include "dobby.h" #define TAG "FusionIL2CPP" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) @@ -112,31 +111,32 @@ int il2cpp_init(char *domain_name) } /* Install one-shot DobbyHook on il2cpp_init. Callback should call destroy_init_hook before chaining. */ -void il2cpp_install_init_hook(void *hookCallback) +bool il2cpp_install_init_hook(void *hookCallback) { if (!g_p_il2cpp_init) { LOGE("il2cpp_init address not resolved 鈥?call il2cpp_initialize first!"); - return; + return false; } if (!hookCallback) { LOGE("Hook function is null!"); - return; + return false; } g_init_hook_fn = reinterpret_cast(hookCallback); - int result = DobbyHook( + int result = safehook_install( g_p_il2cpp_init, hookCallback, reinterpret_cast(&g_orig_il2cpp_init)); if (result != 0) { LOGE("DobbyHook failed: %d", result); - return; + return false; } LOGI("DobbyHook installed on il2cpp_init"); + return true; } /* Destroy the one-shot hook — il2cpp_init calls go directly to original after this. */ @@ -147,7 +147,10 @@ void il2cpp_destroy_init_hook() return; } - DobbyDestroy(g_p_il2cpp_init); + if (!safehook_remove(g_p_il2cpp_init)) { + LOGE("Failed to remove il2cpp_init hook"); + return; + } g_init_hook_fn = nullptr; LOGI("DobbyHook destroyed (one-shot complete)"); } diff --git a/app/src/main/cpp/fusion/src/hooking/libunity.cpp b/app/src/main/cpp/fusion/src/hooking/libunity.cpp index 6aaf699d..72d90769 100644 --- a/app/src/main/cpp/fusion/src/hooking/libunity.cpp +++ b/app/src/main/cpp/fusion/src/hooking/libunity.cpp @@ -2,7 +2,6 @@ #include "fusion.h" #include "utilities/elf.h" -#include "dobby.h" #include #include #include @@ -116,7 +115,7 @@ bool try_hook_libunity(const char *libUnityPath, const char *fallbackLibUnityPat LOGI("scripting_method_invoke @ %p (base=%p, rva=0x%zx)", target, (void*)base, rva); - int ret = DobbyHook( + int ret = safehook_install( target, reinterpret_cast(scripting_method_invoke_hook), reinterpret_cast(&g_original_scripting_method_invoke)); @@ -133,7 +132,7 @@ bool try_hook_libunity(const char *libUnityPath, const char *fallbackLibUnityPat LOGI("path_check @ %p (base=%p, rva=0x%zx)", path_check_target, (void*)base, path_check_rva); - int ret2 = DobbyHook( + int ret2 = safehook_install( path_check_target, reinterpret_cast(path_check_hook), reinterpret_cast(&g_original_path_check)); diff --git a/app/src/main/cpp/fusion/src/hooking/safehook.cpp b/app/src/main/cpp/fusion/src/hooking/safehook.cpp index a45cdc8b..dd464a1a 100644 --- a/app/src/main/cpp/fusion/src/hooking/safehook.cpp +++ b/app/src/main/cpp/fusion/src/hooking/safehook.cpp @@ -1,214 +1,23 @@ -/* Dobby SafeHook wrapper with code cave trampoline allocation. */ - +/* All ARM64 hook entries are patched by the standalone four-byte-only Dobby. */ #include "fusion.h" #include "dobby.h" -#include -#include -#include -#include -#include #include -#define TAG "SafeHook" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) - -#if defined(__aarch64__) -static constexpr size_t kTrampolineSize = 16; // 4 instructions -#elif defined(__arm__) -static constexpr size_t kTrampolineSize = 8; -#endif - -static std::mutex g_hook_mutex; -static const size_t kPageSize = sysconf(_SC_PAGESIZE); - -static void *g_library_handle = nullptr; -static uintptr_t g_library_base = 0; - -/* Allocator function 鈥?allocates from injected code cave */ -using allocate_func = void *(*)(void *target, void *base, size_t size); -static allocate_func g_allocator = nullptr; - -// Helpers - -static inline uintptr_t align_down(uintptr_t addr, size_t page_size) { - return addr & ~(static_cast(page_size) - 1); -} - -static bool protect_trampoline(void *tramp, size_t size, int protection) { - auto start = reinterpret_cast(tramp); - auto start_page = align_down(start, kPageSize); - auto end = start + size - 1; - auto last_page = align_down(end, kPageSize); - - if (mprotect(reinterpret_cast(start_page), kPageSize, protection) != 0) { - LOGE("mprotect failed: %s", strerror(errno)); - return false; - } - if (start_page != last_page) { - if (mprotect(reinterpret_cast(last_page), kPageSize, protection) != 0) { - LOGE("mprotect page2 failed: %s", strerror(errno)); - return false; - } - } - return true; -} - -// Absolute jump emitter (ARM64) - -static bool emit_absolute_jump(void *code_addr, void *branch_addr) { - if (!code_addr || !branch_addr) return false; - - // ARM64: - // 0x00: LDR X16, [PC, #8] - // 0x04: BR X16 - // 0x08: .quad branch_address - auto *code = reinterpret_cast(code_addr); - code[0] = 0x58000050; // LDR X16, #8 - code[1] = 0xD61F0200; // BR X16 - auto *literal = reinterpret_cast(code + 2); - literal[0] = reinterpret_cast(branch_addr); - - auto start = reinterpret_cast(code_addr); - __builtin___clear_cache(reinterpret_cast(start), - reinterpret_cast(start + 24)); - return true; -} - -// Public API - extern "C" { - -bool safehook_initialize(void *lib_handle, uintptr_t lib_base, allocate_func allocator_func) -{ - if (!lib_handle) { - LOGE("safehook_initialize: lib_handle is null!"); - return false; - } - - g_library_handle = lib_handle; - g_library_base = lib_base; - g_allocator = allocator_func; - - LOGI("SafeHook initialized: base=0x%lx, allocator=%p", - (unsigned long)lib_base, (void *)allocator_func); - return true; +bool safehook_initialize(void *lib_handle, uintptr_t, void *(*)(void *, void *, size_t)) { + return lib_handle != nullptr; } -/* Check if function is too short for inline Dobby hook. */ -static bool is_small_function(void *address, int max_instr = 3) { - auto *code = reinterpret_cast(address); - for (int i = 0; i < max_instr; i++) { - uint32_t instr = code[i]; - // Check for RET - if ((instr & 0xFFFFFC1F) == 0xD65F0000) return true; - // Check for B, BL, BR, BLR - if ((instr & 0xFC000000) == 0x14000000) return true; // B - if ((instr & 0xFC000000) == 0x94000000) return true; // BL - if ((instr & 0xFFFFFC1F) == 0xD61F0000) return true; // BR - if ((instr & 0xFFFFFC1F) == 0xD63F0000) return true; // BLR - } - return false; +int safehook_install(void *target, void *replacement, void **original) { + // Dobby serializes registry access and rejects failed near jumps before writing code. + int result = DobbyHook(target, replacement, original); + if (result != 0) + __android_log_print(ANDROID_LOG_ERROR, "SafeHook", + "Cannot install 4-byte ARM64 hook at %p", target); + return result; } -static void *dobby_hook_internal(void *target, void *hook, bool use_near_branch) -{ - if (!target || !hook) return nullptr; - - void *original = nullptr; - - if (use_near_branch) - dobby_enable_near_branch_trampoline(); - else - dobby_disable_near_branch_trampoline(); - - int ret = DobbyHook(target, hook, &original); - - dobby_disable_near_branch_trampoline(); - - if (ret != 0) { - LOGE("DobbyHook failed at %p: %d", target, ret); - return nullptr; - } - return original; -} - -void *safehook_create_hook(void *target_function, void *hook_function, bool use_bridge) -{ - std::lock_guard guard(g_hook_mutex); - (void)use_bridge; // Bridge hook not implemented in this simplified version - - if (!target_function || !hook_function) { - LOGE("safehook_create_hook: null parameter"); - return nullptr; - } - - // Offset within the library - uintptr_t rva = reinterpret_cast(target_function) - g_library_base; - uintptr_t distance = reinterpret_cast(hook_function) - - reinterpret_cast(target_function); - - bool within_limits = (distance > 0 && distance < 0x7FFFFFFF) || - (distance < 0 && distance > -0x7FFFFFFF); - - void *actual_hook = hook_function; - - // If function is NOT in the library (rva < 0), use standard Dobby - if (rva < 0) { - return dobby_hook_internal(target_function, actual_hook, within_limits); - } - - if (within_limits) { - return dobby_hook_internal(target_function, actual_hook, true); - } - - if (!is_small_function(target_function)) { - LOGI("Target at rva 0x%lx is long enough for Dobby", (unsigned long)rva); - return dobby_hook_internal(target_function, actual_hook, false); - } - - // Function is too small 鈥?need a trampoline - LOGI("Target at rva 0x%lx is small, allocating trampoline", (unsigned long)rva); - - if (!g_allocator) { - LOGI("No allocator, using Dobby fallback"); - return dobby_hook_internal(target_function, actual_hook, true); - } - - void *trampoline = g_allocator(target_function, - reinterpret_cast(g_library_base), - kTrampolineSize); - if (!trampoline) { - LOGE("Failed to allocate trampoline, using Dobby fallback"); - return dobby_hook_internal(target_function, actual_hook, true); - } - - if (!protect_trampoline(trampoline, kTrampolineSize, PROT_READ | PROT_WRITE)) { - return dobby_hook_internal(target_function, actual_hook, true); - } - - if (!emit_absolute_jump(trampoline, actual_hook)) { - return dobby_hook_internal(target_function, actual_hook, true); - } - - auto start = reinterpret_cast(trampoline); - __builtin___clear_cache(reinterpret_cast(start), - reinterpret_cast(start + kTrampolineSize)); - - if (!protect_trampoline(trampoline, kTrampolineSize, PROT_READ | PROT_EXEC)) { - return dobby_hook_internal(target_function, actual_hook, true); - } - - // Hook target 鈫?trampoline 鈫?hook - return dobby_hook_internal(target_function, trampoline, true); +bool safehook_remove(void *target) { + return target && DobbyDestroy(target) == 0; } - -void safehook_destroy_hook(void *target) -{ - std::lock_guard guard(g_hook_mutex); - if (DobbyDestroy(target) != 0) { - LOGE("Failed to destroy hook at %p", target); - } } - -} /* extern "C" */ diff --git a/app/src/main/cpp/tests/chained_functions.S b/app/src/main/cpp/tests/chained_functions.S new file mode 100644 index 00000000..15102dea --- /dev/null +++ b/app/src/main/cpp/tests/chained_functions.S @@ -0,0 +1,58 @@ +.text +.p2align 2 +.global live_x17 +.type live_x17, %function +live_x17: + mov w17, #169 + mov w0, w17 + ret +.size live_x17, .-live_x17 + +.global live_x16 +.type live_x16, %function +live_x16: + mov w16, #170 + mov w0, w16 + ret +.size live_x16, .-live_x16 + +// Model the hook already installed by libNebula on SetPlant/TakeDamage. +.hidden prior_hook +.global chain_adrp_x17 +.type chain_adrp_x17, %function +chain_adrp_x17: + adrp x17, prior_hook + add x17, x17, :lo12:prior_hook + br x17 +.size chain_adrp_x17, .-chain_adrp_x17 + +.global chain_adrp_x16 +.type chain_adrp_x16, %function +chain_adrp_x16: + adrp x16, prior_hook + add x16, x16, :lo12:prior_hook + br x16 +.size chain_adrp_x16, .-chain_adrp_x16 + +.global chain_ldr_x17 +.type chain_ldr_x17, %function +chain_ldr_x17: + ldr x17, .Ldestination17 + br x17 +.size chain_ldr_x17, .-chain_ldr_x17 + +.global chain_ldr_x16 +.type chain_ldr_x16, %function +chain_ldr_x16: + ldr x16, .Ldestination16 + br x16 +.size chain_ldr_x16, .-chain_ldr_x16 + +// PIE relocations belong in RELRO data, not in executable text. +.section .data.rel.ro,"aw",%progbits +.p2align 3 +.Ldestination17: + .xword prior_hook +.Ldestination16: + .xword prior_hook +.section .note.GNU-stack,"",%progbits diff --git a/app/src/main/cpp/tests/chained_hook_test.cpp b/app/src/main/cpp/tests/chained_hook_test.cpp new file mode 100644 index 00000000..7c5ecd96 --- /dev/null +++ b/app/src/main/cpp/tests/chained_hook_test.cpp @@ -0,0 +1,83 @@ +#include +#include +#include +#include +#include + +extern "C" void *hook(void *, void *, bool); +extern "C" bool unhook_checked(void *); +extern "C" void DobbyTestFailNearAllocation(bool); +extern "C" int live_x17(); +extern "C" int live_x16(); +static int replacement() { return 900; } + +// Eight integer-register arguments, two stack arguments and FP registers. +#define PARAMETERS int64_t a, int64_t b, int64_t c, int64_t d, int64_t e, \ + int64_t f, int64_t g, int64_t h, int64_t i, int64_t j, double p, double q +using Chain = int64_t (*)(PARAMETERS); +extern "C" int64_t chain_adrp_x17(PARAMETERS); +extern "C" int64_t chain_adrp_x16(PARAMETERS); +extern "C" int64_t chain_ldr_x17(PARAMETERS); +extern "C" int64_t chain_ldr_x16(PARAMETERS); +extern "C" int64_t prior_hook(PARAMETERS) { + return a+2*b+3*c+4*d+5*e+6*f+7*g+8*h+9*i+10*j+(int64_t)(10*p+100*q); +} +static Chain original_chain = nullptr; +static int64_t outer_hook(PARAMETERS) { + return original_chain(a,b,c,d,e,f,g,h,i,j,p,q)+1000; +} +#undef PARAMETERS +static int64_t invoke(Chain function) { + return function(1,2,3,4,5,6,7,8,9,10,1.5,2.5); +} + +int main() { + // Linker relaxation must not replace the ADRP/ADD sequence with NOP/ADR. + assert((*(uint32_t *)(void *)chain_adrp_x17 & 0x9F00001Fu) == 0x90000011u); + assert((*(uint32_t *)(void *)chain_adrp_x16 & 0x9F00001Fu) == 0x90000010u); + // Check before any successful hook can cache this method's original. + // The entry destination is nearby, so failure occurs while allocating the + // original trampoline, not the forward relay. No entry bytes may change. + const uintptr_t from = (uintptr_t)live_x17, to = (uintptr_t)replacement; + assert((from > to ? from-to : to-from) < 0x08000000); + uint8_t before[12]; + memcpy(before, (void *)live_x17, sizeof(before)); + DobbyTestFailNearAllocation(true); + assert(hook((void *)live_x17, (void *)replacement, false) == nullptr); + DobbyTestFailNearAllocation(false); + assert(memcmp(before, (void *)live_x17, sizeof(before)) == 0); + assert(live_x17() == 169); + assert(!unhook_checked((void *)live_x17)); + + // A register swap in the return jump is not sufficient: either IP register + // may hold the value produced by the displaced instruction. + for (auto function : {live_x17, live_x16}) { + const int expected = function(); + auto original = (int (*)())hook((void *)function, (void *)replacement, false); + assert(original); + assert(original() == expected); + assert(function() == 900); + assert(unhook_checked((void *)function)); + assert(function() == expected); + } + + for (Chain function : {chain_adrp_x17, chain_adrp_x16, chain_ldr_x17, chain_ldr_x16}) { + uint8_t before[16]; + memcpy(before, (void *)function, sizeof(before)); + assert(invoke(function) == 650); + for (int iteration = 0; iteration < 3; ++iteration) { + original_chain = (Chain)hook((void *)function, (void *)outer_hook, false); + assert(original_chain); + assert(invoke(original_chain) == 650); + assert(invoke(function) == 1650); + assert(memcmp(before+4, (uint8_t *)function+4, sizeof(before)-4) == 0); + assert(unhook_checked((void *)function)); + assert(memcmp(before, (void *)function, sizeof(before)) == 0); + assert(invoke(function) == 650); // The first hook remains installed. + // An in-flight caller may still hold the original trampoline. + assert(invoke(original_chain) == 650); + } + } + + puts("PASS: X16/X17 values, layered ADRP and LDR hooks, integer/FP/stack args, repeated hook/unhook, allocation failure"); +} diff --git a/app/src/main/cpp/tests/crowded_hook_test.cpp b/app/src/main/cpp/tests/crowded_hook_test.cpp new file mode 100644 index 00000000..48528c96 --- /dev/null +++ b/app/src/main/cpp/tests/crowded_hook_test.cpp @@ -0,0 +1,154 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" void *hook(void *, void *, bool); +extern "C" bool unhook_checked(void *); + +using Getter = int (*)(); +static int first_replacement() { return 900; } +static int second_replacement() { return 901; } + +static uint32_t return_value_instruction(unsigned value) { + assert(value < 65536); + return 0x52800000u | (value << 5); // mov w0, #value +} + +static void check_entry(const uint8_t *target, const uint8_t *before) { + assert((*(const uint32_t *)target & 0xFC000000u) == 0x14000000u); + // Includes the current method's RET and the complete next 8-byte method. + assert(memcmp(target + 4, before + 4, 12) == 0); +} + +int main() { + setbuf(stdout, nullptr); + constexpr size_t target_count = 512, cycle_count = 8192, free_pages = 64; + constexpr size_t unique_replacements = 2048; + constexpr size_t branch_range = size_t{1} << 27; + const long native_page_size = sysconf(_SC_PAGESIZE); + assert(native_page_size > 0); + const size_t page_size = (size_t)native_page_size; + const size_t code_bytes = (target_count + 1) * 8; + const size_t code_pages = (code_bytes + page_size - 1) / page_size * page_size; + + // Every target's full B-immediate window is reserved, except for 64 pages. + // The former page-per-relay/page-per-original allocator fails after 32 hooks. + const size_t reservation_size = 2 * branch_range + code_pages + + free_pages * page_size + page_size; + auto *reservation = (uint8_t *)mmap(nullptr, reservation_size, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + assert(reservation != MAP_FAILED); + auto *code = reservation + branch_range; + auto *hook_space = code + code_pages; + assert(mprotect(code, code_pages, PROT_READ | PROT_WRITE) == 0); + for (size_t i = 0; i <= target_count; ++i) { + const uint32_t body[] = {return_value_instruction(7 + (unsigned)i), 0xD65F03C0u}; + memcpy(code + 8 * i, body, sizeof(body)); + } + __builtin___clear_cache((char *)code, (char *)code + code_bytes); + assert(mprotect(code, code_pages, PROT_READ | PROT_EXEC) == 0); + + // Allocate outside the reserved window before making its small hole. These + // distinct destinations model Harmony creating a new delegate per rebuild. + const size_t replacement_bytes = unique_replacements * 8; + const size_t replacement_pages = (replacement_bytes + page_size - 1) / page_size * page_size; + auto *replacements = (uint8_t *)mmap(nullptr, replacement_pages, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + assert(replacements != MAP_FAILED); + for (size_t i = 0; i < unique_replacements; ++i) { + const uint32_t body[] = {return_value_instruction(1000 + (unsigned)i), 0xD65F03C0u}; + memcpy(replacements + 8 * i, body, sizeof(body)); + } + __builtin___clear_cache((char *)replacements, (char *)replacements + replacement_bytes); + assert(mprotect(replacements, replacement_pages, PROT_READ | PROT_EXEC) == 0); + assert(munmap(hook_space, free_pages * page_size) == 0); + const std::vector pristine(code, code + code_bytes); + + // Replacements cannot be reached directly; each installation needs a relay. + for (auto replacement : {first_replacement, second_replacement, + (Getter)replacements, (Getter)(replacements + replacement_bytes - 8)}) { + uintptr_t source = (uintptr_t)code, destination = (uintptr_t)replacement; + assert((source > destination ? source - destination : destination - source) + >= branch_range); + } + std::vector originals; + originals.reserve(target_count + cycle_count + unique_replacements + 1); + auto install = [&](uint8_t *target, Getter replacement, int expected) { + uint8_t before[16]; + memcpy(before, target, sizeof(before)); + auto original = (Getter)hook(target, (void *)replacement, false); + if (!original) fprintf(stderr, "Hook failed after %zu successful installations\n", originals.size()); + assert(original); + // Original code must remain in the only free part of the branch window. + assert((uintptr_t)original >= (uintptr_t)hook_space); + assert((uintptr_t)original < (uintptr_t)hook_space + free_pages * page_size); + originals.push_back(original); + assert(original() == expected); + assert(((Getter)target)() == replacement()); + check_entry(target, before); + return original; + }; + + Getter first_original = install(code, first_replacement, 7); + std::atomic keep_running{true}; + std::atomic calls{0}; + // Publishing more code in a shared pool must never remove execute permission + // from a page that contains a trampoline currently used by another thread. + std::thread in_flight([&] { + while (keep_running.load(std::memory_order_relaxed)) { + assert(first_original() == 7); + calls.fetch_add(1, std::memory_order_relaxed); + } + }); + for (size_t i = 1; i < target_count; ++i) + install(code + 8 * i, first_replacement, 7 + (int)i); + for (size_t i = 0; i < target_count; ++i) { + assert(((Getter)(code + 8 * i))() == 900); + assert(originals[i]() == 7 + (int)i); + assert(unhook_checked(code + 8 * i)); + } + assert(memcmp(code, pristine.data(), code_bytes) == 0); + puts("PASS: 512 simultaneous hooks in a 64-page branch window; only 4 bytes patched"); + + for (size_t i = 0; i < cycle_count; ++i) { + auto replacement = i % 2 ? second_replacement : first_replacement; + install(code, replacement, 7); + assert(unhook_checked(code)); + assert(((Getter)code)() == 7); + if (i % 128 == 0) + for (size_t j = 0; j < target_count; ++j) assert(originals[j]() == 7 + (int)j); + } + for (size_t i = target_count; i < originals.size(); ++i) assert(originals[i]() == 7); + assert(memcmp(code, pristine.data(), code_bytes) == 0); + puts("PASS: 8192 hook/unhook cycles; all historical originals and concurrent calls remain valid"); + + for (size_t i = 0; i < unique_replacements; ++i) { + install(code, (Getter)(replacements + 8 * i), 7); + assert(unhook_checked(code)); + } + keep_running.store(false, std::memory_order_relaxed); + in_flight.join(); + assert(calls.load(std::memory_order_relaxed) > 0); + for (size_t i = target_count; i < originals.size(); ++i) assert(originals[i]() == 7); + assert(memcmp(code, pristine.data(), code_bytes) == 0); + puts("PASS: 2048 distinct far destinations also fit; relays use compact allocations"); + + // A new first instruction at the same target must create a new original, + // without overwriting any previously published trampoline. + assert(mprotect(code, page_size, PROT_READ | PROT_WRITE) == 0); + *(uint32_t *)code = return_value_instruction(777); + __builtin___clear_cache((char *)code, (char *)code + 4); + assert(mprotect(code, page_size, PROT_READ | PROT_EXEC) == 0); + Getter changed_original = install(code, second_replacement, 777); + assert(first_original() == 7); + assert(unhook_checked(code)); + assert(((Getter)code)() == 777 && changed_original() == 777 && first_original() == 7); + puts("PASS: changed first instruction gets a new original; earlier original stays immutable"); + // Published trampolines intentionally live until process exit. +} diff --git a/app/src/main/cpp/tests/short_functions.S b/app/src/main/cpp/tests/short_functions.S new file mode 100644 index 00000000..3c14f840 --- /dev/null +++ b/app/src/main/cpp/tests/short_functions.S @@ -0,0 +1,29 @@ +.text +.p2align 2 +.global tiny_getter +.type tiny_getter, %function +tiny_getter: + mov w0, #169 + ret +.size tiny_getter, .-tiny_getter + +// Match LunarCabbage: an eight-byte getter, a four-byte method, then SuperSkill. +.global tiny_attribute +.type tiny_attribute, %function +tiny_attribute: + ret +.size tiny_attribute, .-tiny_attribute + +.global tiny_super +.type tiny_super, %function +tiny_super: + mov w0, #77 + ret +.size tiny_super, .-tiny_super + +.global tiny_tail +.type tiny_tail, %function +tiny_tail: + b tiny_super +.size tiny_tail, .-tiny_tail +.section .note.GNU-stack,"",%progbits diff --git a/app/src/main/cpp/tests/short_hook_test.cpp b/app/src/main/cpp/tests/short_hook_test.cpp new file mode 100644 index 00000000..aed9f86d --- /dev/null +++ b/app/src/main/cpp/tests/short_hook_test.cpp @@ -0,0 +1,101 @@ +#include +#include +#include +#include +#include +#include +#include + +extern "C" int tiny_getter(); +extern "C" int tiny_attribute(int); +extern "C" int tiny_super(); +extern "C" int tiny_tail(); +extern "C" void *hook(void *, void *, bool); +extern "C" bool unhook_checked(void *); +extern "C" void DobbyTestFailNearAllocation(bool); +using Getter = int (*)(); +static int replacement() { return 900; } +static int attribute_replacement(int n) { return n + 1; } + +static void check_neighbours(const uint8_t *before, void *target, size_t size) { + assert(memcmp(before + 4, (uint8_t *)target + 4, size - 4) == 0); + assert((*(uint32_t *)target & 0xFC000000u) == 0x14000000u); +} + +int main() { + auto target = reinterpret_cast(tiny_getter); + uint8_t before[24]; + memcpy(before, target, sizeof(before)); + assert((uintptr_t)tiny_attribute - (uintptr_t)tiny_getter == 8); + assert((uintptr_t)tiny_super - (uintptr_t)tiny_getter == 12); + assert(tiny_getter() == 169 && tiny_attribute(33) == 33 && tiny_super() == 77); + + auto original = reinterpret_cast(hook(target, (void *)replacement, false)); + assert(original && original() == 169 && tiny_getter() == 900); + check_neighbours(before, target, sizeof(before)); + assert(tiny_attribute(33) == 33 && tiny_super() == 77); + assert(hook(target, (void *)replacement, false) == nullptr); + check_neighbours(before, target, sizeof(before)); + + auto attribute_original = reinterpret_cast( + hook((void *)tiny_attribute, (void *)attribute_replacement, false)); + assert(attribute_original && attribute_original(33) == 33 && tiny_attribute(33) == 34); + auto super_original = reinterpret_cast(hook((void *)tiny_super, (void *)replacement, false)); + assert(super_original && super_original() == 77 && tiny_super() == 900); + assert(unhook_checked(target)); + assert(tiny_getter() == 169 && tiny_attribute(33) == 34 && tiny_super() == 900); + assert(unhook_checked((void *)tiny_attribute)); + assert(unhook_checked((void *)tiny_super)); + assert(memcmp(before, target, sizeof(before)) == 0); + auto tail_original = reinterpret_cast(hook((void *)tiny_tail, (void *)replacement, false)); + assert(tail_original && tail_original() == 77 && tiny_tail() == 900); + assert(unhook_checked((void *)tiny_tail)); + + // A far destination forces relay allocation. Failure must leave every byte untouched. + auto far = (void *)((uintptr_t)target + 0x10000000); + DobbyTestFailNearAllocation(true); + assert(hook(target, far, false) == nullptr); + DobbyTestFailNearAllocation(false); + assert(memcmp(before, target, sizeof(before)) == 0); + assert(tiny_getter() == 169 && tiny_super() == 77); + assert(!unhook_checked(target)); + assert(hook((uint8_t *)target + 1, (void *)replacement, false) == nullptr); + + // Exercise actual far code, with a forward relay and an original trampoline. + const size_t page_size = (size_t)sysconf(_SC_PAGESIZE); + void *page = MAP_FAILED; + for (uintptr_t offset = 0x10000000; offset <= 0x70000000; offset += 0x10000000) { + auto hint = ((uintptr_t)target + offset) / page_size * page_size; + page = mmap((void *)hint, page_size, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (page == MAP_FAILED) continue; + const auto address = (uintptr_t)page; + const auto distance = address > (uintptr_t)target ? address - (uintptr_t)target + : (uintptr_t)target - address; + if (distance >= 0x08000000) break; + munmap(page, page_size); + page = MAP_FAILED; + } + assert(page != MAP_FAILED); + const uint32_t far_code[] = {0x52804D20, 0xD65F03C0}; // mov w0, #617; ret + memcpy(page, far_code, sizeof(far_code)); + __builtin___clear_cache((char *)page, (char *)page + sizeof(far_code)); + assert(mprotect(page, page_size, PROT_READ | PROT_EXEC) == 0); + original = reinterpret_cast(hook(target, page, false)); + assert(original && original() == 169 && tiny_getter() == 617); + check_neighbours(before, target, sizeof(before)); + assert(unhook_checked(target)); + assert(memcmp(before, target, sizeof(before)) == 0); + + // Registration and removal share one backend lock even for different API callers. + auto cycle = [](void *function) { + for (int i = 0; i < 50; ++i) { + assert(hook(function, (void *)replacement, false)); + assert(unhook_checked(function)); + } + }; + std::thread first(cycle, (void *)tiny_getter), second(cycle, (void *)tiny_super); + first.join(); second.join(); + assert(memcmp(before, target, sizeof(before)) == 0); + puts("PASS: 8-byte getter, 4-byte method, adjacent hooks, original calls, far relay, allocation failure, concurrent hooks, unhook"); +} diff --git a/app/src/main/jniLibs/arm64-v8a/libdobby.so b/app/src/main/jniLibs/arm64-v8a/libdobby.so index 5e81dbca..5e9b7bab 100644 --- a/app/src/main/jniLibs/arm64-v8a/libdobby.so +++ b/app/src/main/jniLibs/arm64-v8a/libdobby.so @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aa188e0a34398ca7ca07e129a1b02bb16bdcd3a23ef7243163a46f12999ae550 -size 244832 +oid sha256:96830dc891ea67b0551789ef126a0e6951a40f01f61194b812a13ca20ba83fa8 +size 283856 diff --git a/ci-version.txt b/ci-version.txt index b9e5dd76..8be84ab6 100644 --- a/ci-version.txt +++ b/ci-version.txt @@ -1 +1 @@ -540 +544 \ No newline at end of file diff --git a/docs/native-crash-fixes.md b/docs/native-crash-fixes.md index 5eb64f61..4ebd3ce7 100644 --- a/docs/native-crash-fixes.md +++ b/docs/native-crash-fixes.md @@ -1,5 +1,103 @@ # ARM64 return buffer and game process lifetime +## Short IL2CPP methods + +ARM64 methods can be four or eight bytes long, with another method immediately +following them. The guarded Dobby source is maintained in `E:/Work/PVZRH/dobby`. +Its `build-android-arm64.ps1` script syncs the production library to +`app/src/main/jniLibs/arm64-v8a/libdobby.so` and the header to `app/src/main/cpp/dobby.h`. +The launcher links and packages these local files without the external source tree. +Every ARM64 hook entry uses one four-byte `B`. Far callbacks use a nearby RX +relay, including callbacks that first enter the X8 return-buffer bridge. +Near-allocation failure rejects the hook before changing the target; it cannot +fall back to a longer overwrite. All native hook entry points use this backend. + +The regression fixture reproduces the game's contiguous layout: an eight-byte +getter, a four-byte method, and the next method. It verifies unchanged adjacent +bytes, independent neighbouring hooks, original calls, removal, a far callback, +concurrent installation/removal, and forced near-allocation failure. + +First run the standalone Dobby script with `-Testing` (it does not sync test +libraries into the APK). Configure a separate native build with +`-DFUSION_BUILD_TESTS=ON` and `-DDOBBY_TEST_LIBRARY=`. +Run `short_hook_test` alongside +`return_buffer_test`, using the libfusion.so, libmain.so and libdobby.so from that +same build in LD_LIBRARY_PATH. Both executables passed on the connected ARM64 +Android device on 2026-09-22. The test-only allocation switch is not built into +normal APKs. + +After upgrading, start a new game process. Already overwritten method bytes in +an old running process cannot be repaired by merely changing the hook policy. + +## Layered native hooks and X17 + +The four-byte entry patch alone is insufficient when another backend already +hooks the same method. In the modded game, Nebula installs an +`ADRP X17; ADD X17; BR X17` entry on `CreatePlant.SetPlant` and +`Plant.TakeDamage`. Relocating only ADRP and returning through +`LDR X17; BR X17` destroys its result, causing the remaining ADD/BR to jump into +the middle of the original method. This was confirmed in live process memory +and an old/new Dobby comparison on the ARM64 device. + +Original trampolines use nearby executable storage and return with a +direct `B target+4`. The return jump borrows no register. Allocation checks +reachability in both directions, and an allocation or finalization failure +rejects installation before changing the method entry. Published original +code remains mapped after unhook to support callers still using a trampoline. + +`chained_hook_test` covers live X16/X17 results, preinstalled ADRP/ADD/BR and +LDR/BR hooks, integer/FP/stack arguments, repeated hook/unhook while retaining +the first hook, and original-trampoline allocation failure. Linker relaxation +is disabled for this fixture to preserve the actual ADRP instruction sequence. +Run it with `short_hook_test` and `return_buffer_test` from the same +`FUSION_BUILD_TESTS=ON` build. All three passed on the connected ARM64 device +on 2026-09-22. These native fixtures do not replace a full mod gameplay test. + +APK 545 was built and installed on the connected device. Its packaged Dobby +also passed the X17 reproducer that failed with APK 544. The user then retested +the same Nebula-modded game and mod combination, planting BloodMoon and waiting +for its ultimate, and reported that it no longer crashed (2026-09-22). + +## Capacity while loading many mods + +The initial four-byte implementation allocated an entire nearby page for each +relay and original trampoline. Harmony rebuilds a method's wrapper whenever +another mod patches it, so even repeated hook/unhook of the same methods kept +consuming new pages. In a fresh 114-mod run on 2026-09-23, 26 plugins failed at +12 different targets. A live memory snapshot showed no unmapped complete pages +within any failed target's +/-128 MiB branch window. The outer Harmony +`IL Compile Error` was wrapping this native allocation failure. + +Nearby arenas now allocate immutable code in 16-byte-aligned slots. Relay code +uses 16 bytes, and originals reserve the actual finalized code size, including +literal data. Publishing into an existing arena uses `DobbyCodePatch`, which +keeps execute permission during the RWX write and restores RX afterward. No +live code is overwritten or temporarily made non-executable. + +Original trampolines are cached by source address and displaced four-byte +instruction. Relays to the same destination are reused when reachable from +the new source. Changed instructions produce separate originals, preserving +previously returned function pointers. Published blocks remain valid until +process exit; unpublished failed reservations are returned to their arena. +The injected ELF padding is not used by this allocator. + +`crowded_hook_test` reserves the complete branch window except for 64 pages. +It verifies 512 simultaneous hooks, 8192 repeated hook/unhook cycles, 2048 +distinct far destinations (like newly generated Harmony delegates), unchanged +neighboring instructions, concurrent calls through old originals, and a +changed first instruction at the same address. These tests and the existing +short-hook, chained-hook and return-buffer tests passed on the connected +ARM64 device on 2026-09-23. The old allocator fails the constrained-window +reproducer after 32 hook/unhook cycles. + +APK 546 (`1.1.0-ci.546`) was built successfully with this change. Its packaged +Dobby library excludes the test-only allocation-failure switch. Installation +and the full 114-mod acceptance run are pending device reconnection; the +native regression results above do not establish full-game acceptance. + +This addresses native hook capacity. The separately diagnosed unhandled +exception in `Class_FromIl2CppType_Hook` is outside this change. + The Android `specialReturnBuffer` path now enters `ReturnBufferBridge` in `libfusion.so`. It preserves X0–X8, Q0–Q7, LR and the original stack argument layout while recording X8. Il2CppInterop reads the same library's TLS slot and