Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
android:name="com.bepinex.android.BootstrapActivity"
android:exported="false"
android:process=":game"
android:taskAffinity="${applicationId}.game"
android:launchMode="standard"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />

Expand Down Expand Up @@ -87,6 +88,7 @@
Instrumentation.newActivity restores the game's Activity class. -->
<activity
android:name="com.bepinex.android.StubActivity"
android:taskAffinity="${applicationId}.game"
android:theme="@style/UnityThemeSelector"
android:screenOrientation="landscape"
android:process=":game"
Expand Down
Binary file modified app/src/main/assets/BepInEx.Android.zip
Binary file not shown.
9 changes: 8 additions & 1 deletion app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.22.1)
project("bepinex_android" CXX)
project("bepinex_android" CXX ASM)

# FusionCore main branch architecture:
# libmain.so — registers NativeLoader load/unload, resolves fusion dynamically
Expand All @@ -10,3 +10,10 @@ project("bepinex_android" CXX)

add_subdirectory(main)
add_subdirectory(fusion)

option(FUSION_BUILD_TESTS "Build the Android ARM64 return-buffer regression executable" 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)
endif()
1 change: 1 addition & 0 deletions app/src/main/cpp/fusion/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ add_library(fusion SHARED
src/exports.cpp
src/logger.cpp
src/hooking/allocator.cpp
src/hooking/return_buffer.S
src/hooking/il2cpp.cpp
src/hooking/safehook.cpp
src/hooking/libunity.cpp
Expand Down
85 changes: 65 additions & 20 deletions app/src/main/cpp/fusion/src/exports.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
#include <dlfcn.h>
#include <cstdint>
#include <cstdbool>
#include <cstring>
#include <mutex>
#include <sys/mman.h>
#include <unistd.h>
#include "dobby.h"

namespace {
std::mutex hookMutex;
thread_local void *returnBuffer = nullptr;
}

extern "C" {

Expand All @@ -22,32 +32,67 @@ void write_log_level(int level, const char *text)

// Hook management (used by FusionInterop.hook/unhook)

void *hook(void *target, void *detour, bool specialReturnBuffer)
{
(void)specialReturnBuffer;
// Use Dobby to install the hook
void *dobby = dlopen("libdobby.so", RTLD_NOLOAD);
if (!dobby) return nullptr;

typedef int (*DobbyHook_t)(void *, void *, void **);
auto DH = (DobbyHook_t)dlsym(dobby, "DobbyHook");
if (!DH) return nullptr;
void ReturnBufferBridge();

void *original = nullptr;
int rc = DH(target, detour, &original);
if (rc != 0) return nullptr;
void SetReturnBuffer(void *value) { returnBuffer = value; }
void *GetReturnBuffer() { return returnBuffer; }

return original; // Return the trampoline (original function)
// One RX page per bridge: never remove execute permission from a page containing
// another live hook. Successful bridges stay mapped until process exit, since an
// in-flight invocation can still be using one when its hook is removed.
static void *create_return_buffer_bridge(void *detour, size_t pageSize)
{
void *page = mmap(nullptr, pageSize, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED) return nullptr;
const uint32_t instructions[] = {
0x58000090, // ldr x16, [pc, #16] : managed destination
0x580000B1, // ldr x17, [pc, #20] : common bridge
0xD61F0220, // br x17
0xD503201F // nop (literal alignment)
};
memcpy(page, instructions, sizeof(instructions));
const uintptr_t destinations[] = {
reinterpret_cast<uintptr_t>(detour),
reinterpret_cast<uintptr_t>(&ReturnBufferBridge)
};
memcpy(static_cast<char *>(page) + 16, destinations, sizeof(destinations));
__builtin___clear_cache(static_cast<char *>(page), static_cast<char *>(page) + 32);
if (mprotect(page, pageSize, PROT_READ | PROT_EXEC) != 0) {
munmap(page, pageSize);
return nullptr;
}
return page;
}

void unhook(void *target)
void *hook(void *target, void *detour, bool specialReturnBuffer)
{
void *dobby = dlopen("libdobby.so", RTLD_NOLOAD);
if (!dobby) return;
std::lock_guard<std::mutex> guard(hookMutex);
if (!target || !detour) return nullptr;
const long pageSize = sysconf(_SC_PAGESIZE);
if (pageSize <= 0) return nullptr;
void *bridge = specialReturnBuffer
? create_return_buffer_bridge(detour, static_cast<size_t>(pageSize)) : nullptr;
if (specialReturnBuffer && !bridge) {
__android_log_write(ANDROID_LOG_ERROR, "Fusion", "Cannot allocate ARM64 return-buffer bridge");
return nullptr;
}
void *original = nullptr;
int rc = DobbyHook(target, bridge ? bridge : detour, &original);
if (rc != 0) {
if (bridge) munmap(bridge, static_cast<size_t>(pageSize));
__android_log_print(ANDROID_LOG_ERROR, "Fusion", "DobbyHook failed at %p: %d", target, rc);
return nullptr;
}
return original;
}

typedef int (*DobbyDestroy_t)(void *);
auto DD = (DobbyDestroy_t)dlsym(dobby, "DobbyDestroy");
if (DD) DD(target);
bool unhook_checked(void *target)
{
std::lock_guard<std::mutex> guard(hookMutex);
return target && DobbyDestroy(target) == 0;
}

void unhook(void *target) { (void)unhook_checked(target); }

} /* extern "C" */
35 changes: 35 additions & 0 deletions app/src/main/cpp/fusion/src/hooking/return_buffer.S
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Preserve the native call frame while recording ARM64's indirect result (X8).
// Per-hook stubs pass the managed destination in X16. Stack arguments stay at
// their original offsets because this bridge restores SP before tail-calling.
.text
.p2align 2
.global ReturnBufferBridge
.type ReturnBufferBridge, %function
ReturnBufferBridge:
sub sp, sp, #224
stp x0, x1, [sp, #0]
stp x2, x3, [sp, #16]
stp x4, x5, [sp, #32]
stp x6, x7, [sp, #48]
stp x8, x16, [sp, #64]
str x30, [sp, #80]
stp q0, q1, [sp, #96]
stp q2, q3, [sp, #128]
stp q4, q5, [sp, #160]
stp q6, q7, [sp, #192]
mov x0, x8
bl SetReturnBuffer
ldp q0, q1, [sp, #96]
ldp q2, q3, [sp, #128]
ldp q4, q5, [sp, #160]
ldp q6, q7, [sp, #192]
ldp x0, x1, [sp, #0]
ldp x2, x3, [sp, #16]
ldp x4, x5, [sp, #32]
ldp x6, x7, [sp, #48]
ldp x8, x16, [sp, #64]
ldr x30, [sp, #80]
add sp, sp, #224
br x16
.size ReturnBufferBridge, .-ReturnBufferBridge
.section .note.GNU-stack,"",%progbits
94 changes: 54 additions & 40 deletions app/src/main/cpp/main/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
#include <fstream>
#include <cstring>
#include <android/log.h>
#include <android/dlext.h>
#include <atomic>

#define LOG_TAG "LibMain"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
Expand All @@ -16,6 +18,7 @@ using JNI_OnLoad_t = jint (*)(JavaVM *vm, void *reserved);
using JNI_Unload_t = void (*)(JavaVM *vm, void *reserved);
using FusionStageFromConfigPath_t = bool (*)(const char *configPath);
using FusionBootstrapFromLibMain_t = bool (*)(JNIEnv *env);
extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *, void *);

static FILE *logFile = nullptr;
static std::string log_path;
Expand All @@ -25,6 +28,8 @@ static std::string override_il2cpp_path;
static void *unityLibHandle = nullptr;
static void *il2cppLibHandle = nullptr;
static void *originalMainHandle = nullptr;
enum class LoadState { Fresh, Loading, Loaded, Stopped };
static std::atomic<LoadState> loadState{LoadState::Fresh};

static void nlog(const char *msg) {
__android_log_write(ANDROID_LOG_ERROR, "LibMainN", msg);
Expand Down Expand Up @@ -208,58 +213,46 @@ static std::string read_config_value(const std::string &path, const char *key)
return value;
}

static void load_original_game_main(JNIEnv *env, const std::string &configPath)
static bool load_original_game_main(JNIEnv *env, const std::string &configPath)
{
if (originalMainHandle) return;
if (originalMainHandle) return true;
const std::string gameLibDir = read_config_value(configPath, "gameLibraryDirectory");
if (gameLibDir.empty()) return;
if (gameLibDir.empty()) return false;

const std::string sourcePath = gameLibDir + "/libmain.so";
const std::string copyPath = configPath + ".original_main.so";

/* Copy with renamed SONAME so Android linker creates a separate link-map entry. */
std::ifstream source(sourcePath, std::ios::binary);
std::ofstream copy(copyPath, std::ios::binary | std::ios::trunc);
if (!source || !copy) {
LOGE("load_original_main: cannot copy %s", sourcePath.c_str());
return;
}
std::vector<char> image((std::istreambuf_iterator<char>(source)),
std::istreambuf_iterator<char>());
const char oldSoname[] = "libmain.so";
const char newSoname[] = "liborig.so";
bool renamed = false;
for (size_t i = 0; i + sizeof(oldSoname) <= image.size(); ++i) {
if (memcmp(image.data() + i, oldSoname, sizeof(oldSoname)) == 0) {
memcpy(image.data() + i, newSoname, sizeof(newSoname));
renamed = true;
break;
}
}
if (!renamed) {
LOGE("load_original_main: SONAME libmain.so not found");
return;
// The original and replacement share a SONAME. Ask the Android linker for a
// distinct local mapping instead of editing arbitrary bytes in the ELF image.
if (sourcePath == build_sibling_library_path("libmain.so")) {
LOGE("Original libmain path points to the launcher itself");
return false;
}
copy.write(image.data(), static_cast<std::streamsize>(image.size()));
copy.close();

android_dlextinfo ext{};
ext.flags = ANDROID_DLEXT_FORCE_LOAD;
dlerror();
void *handle = dlopen(copyPath.c_str(), RTLD_NOW | RTLD_GLOBAL);
void *handle = android_dlopen_ext(sourcePath.c_str(), RTLD_NOW | RTLD_LOCAL, &ext);
if (!handle) {
LOGE("original libmain load failed (%s): %s", copyPath.c_str(), dlerror());
return;
LOGE("original libmain load failed (%s): %s", sourcePath.c_str(), dlerror());
return false;
}

// Keep this mapping alive even after partial JNI registration: Java may hold
// pointers into it. The launcher will discard this process on the next launch.
originalMainHandle = handle;
auto originalOnLoad = reinterpret_cast<JNI_OnLoad_t>(dlsym(handle, "JNI_OnLoad"));
if (originalOnLoad == &JNI_OnLoad) {
LOGE("Original libmain resolved to the replacement; refusing recursive JNI initialization");
return false;
}
if (originalOnLoad) {
JavaVM *vm = nullptr;
if (env->GetJavaVM(&vm) != JNI_OK || originalOnLoad(vm, nullptr) < JNI_VERSION_1_6) {
if (env->GetJavaVM(&vm) != JNI_OK || originalOnLoad(vm, nullptr) < JNI_VERSION_1_6 ||
env->ExceptionCheck()) {
LOGE("original libmain JNI_OnLoad failed");
return;
return false;
}
}
originalMainHandle = handle;
LOGI("original game libmain JNI registered");
return true;
}

// Library load/unload helpers
Expand Down Expand Up @@ -363,6 +356,11 @@ JNIEXPORT jboolean JNICALL
load(JNIEnv *env, jclass clazz, jstring path)
{
nlog("load() called");
auto expected = LoadState::Fresh;
if (!loadState.compare_exchange_strong(expected, LoadState::Loading)) {
LOGE("NativeLoader.load requires a fresh game process");
return JNI_FALSE;
}
(void)clazz;
(void)path;

Expand Down Expand Up @@ -403,7 +401,7 @@ load(JNIEnv *env, jclass clazz, jstring path)

nlog("load: loading libil2cpp");
if (!internal_load(env, override_il2cpp_path.c_str(), &il2cppLibHandle)) {
internal_unload(env, &unityLibHandle);
// Keep all mappings alive until process exit, including partial loads.
nlog("load: FAILED to load libil2cpp.so");
return JNI_FALSE;
}
Expand All @@ -417,16 +415,19 @@ load(JNIEnv *env, jclass clazz, jstring path)
}

nlog("load: COMPLETE");
loadState.store(LoadState::Loaded);
return JNI_TRUE;
}

JNIEXPORT jboolean JNICALL
unload(JNIEnv *env, jclass activityObject)
{
if (unityLibHandle) internal_unload(env, &unityLibHandle);
if (il2cppLibHandle) internal_unload(env, &il2cppLibHandle);
// CoreCLR finalizers and native detours may still call Unity/IL2CPP. Unmapping
// either library here creates dangling code pointers. A new launch gets a new
// :game process; the OS releases all mappings together at process exit.
loadState.store(LoadState::Stopped);
if (logFile) { fclose(logFile); logFile = nullptr; }
LOGI("unload complete");
LOGI("NativeLoader stopped; native mappings retained until process exit");
return JNI_TRUE;
}

Expand All @@ -446,6 +447,19 @@ JNI_OnLoad(JavaVM *vm, void *reserved)
return JNI_ERR;
}

// Run the game's additional JNI registrations first. Its JNI_OnLoad may also
// register NativeLoader.load/unload; re-register our replacements below so
// injection is preserved and the original loader never loads a second Unity.
const std::string configPath = resolve_staged_config_path(globalEnv);
if (configPath.empty() || globalEnv->ExceptionCheck() ||
!load_original_game_main(globalEnv, configPath)) {
if (globalEnv->ExceptionCheck()) {
globalEnv->ExceptionDescribe();
globalEnv->ExceptionClear();
}
return JNI_ERR;
}

jclass clazz = globalEnv->FindClass("com/unity3d/player/NativeLoader");
if (!clazz) {
nlog("FindClass NativeLoader FAILED");
Expand Down
Loading
Loading