diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d985673..f36dbc18 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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" /> @@ -87,6 +88,7 @@ Instrumentation.newActivity restores the game's Activity class. --> #include #include +#include +#include +#include +#include +#include "dobby.h" + +namespace { +std::mutex hookMutex; +thread_local void *returnBuffer = nullptr; +} extern "C" { @@ -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(detour), + reinterpret_cast(&ReturnBufferBridge) + }; + memcpy(static_cast(page) + 16, destinations, sizeof(destinations)); + __builtin___clear_cache(static_cast(page), static_cast(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 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(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(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 guard(hookMutex); + return target && DobbyDestroy(target) == 0; } +void unhook(void *target) { (void)unhook_checked(target); } + } /* extern "C" */ diff --git a/app/src/main/cpp/fusion/src/hooking/return_buffer.S b/app/src/main/cpp/fusion/src/hooking/return_buffer.S new file mode 100644 index 00000000..5cd8ad50 --- /dev/null +++ b/app/src/main/cpp/fusion/src/hooking/return_buffer.S @@ -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 diff --git a/app/src/main/cpp/main/main.cpp b/app/src/main/cpp/main/main.cpp index a893de90..f81dec15 100644 --- a/app/src/main/cpp/main/main.cpp +++ b/app/src/main/cpp/main/main.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #define LOG_TAG "LibMain" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) @@ -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; @@ -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::Fresh}; static void nlog(const char *msg) { __android_log_write(ANDROID_LOG_ERROR, "LibMainN", msg); @@ -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 image((std::istreambuf_iterator(source)), - std::istreambuf_iterator()); - 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(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(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 @@ -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; @@ -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; } @@ -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; } @@ -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"); diff --git a/app/src/main/cpp/tests/return_buffer_test.cpp b/app/src/main/cpp/tests/return_buffer_test.cpp new file mode 100644 index 00000000..8d16fa38 --- /dev/null +++ b/app/src/main/cpp/tests/return_buffer_test.cpp @@ -0,0 +1,89 @@ +// Android arm64 executable: exercise the real Dobby -> stub -> bridge path. +#include +#include +#include +#include + +extern "C" void *hook(void *, void *, bool); +extern "C" bool unhook_checked(void *); +extern "C" void *GetReturnBuffer(); + +struct Result { uint64_t a, b, c; }; // indirect result in X8, not an HFA + +__attribute__((noinline, optnone)) static Result inner(uint64_t n) +{ + volatile uint64_t value = n; + return {value, value + 1, value + 2}; +} + +static void *inner_callback(uint64_t n) +{ + auto *out = static_cast(GetReturnBuffer()); + assert(out); + *out = {n + 10, n + 20, n + 30}; + return out; +} + +// Integer and FP register arguments, plus both kinds of stack arguments. +using Outer = Result (*)(uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, + uint64_t, uint64_t, uint64_t, uint64_t, uint64_t, + double, double, double, double, double, double, + double, double, double); +__attribute__((noinline, optnone)) static Result outer( + uint64_t a, uint64_t b, uint64_t c, uint64_t d, uint64_t e, + uint64_t f, uint64_t g, uint64_t h, uint64_t i, uint64_t j, + double p, double q, double r, double s, double t, double u, + double v, double w, double x) +{ + volatile uint64_t sum = a+b+c+d+e+f+g+h+i+j; + return {sum, static_cast(p+q+r+s+t+u+v+w+x), 0}; +} + +static void *outer_callback( + uint64_t a, uint64_t b, uint64_t c, uint64_t d, uint64_t e, + uint64_t f, uint64_t g, uint64_t h, uint64_t i, uint64_t j, + double p, double q, double r, double s, double t, double u, + double v, double w, double x) +{ + // This is the same entry snapshot performed by generated managed callbacks. + auto *out = static_cast(GetReturnBuffer()); + assert(out); + const auto nested = inner(a); + assert(nested.a == a + 10 && nested.b == a + 20 && nested.c == a + 30); + assert(a == 1 && b == 2 && c == 3 && d == 4 && e == 5); + assert(f == 6 && g == 7 && h == 8 && i == 9 && j == 10); + assert(p == 1.5 && q == 2.5 && r == 3.5 && s == 4.5 && t == 5.5); + assert(u == 6.5 && v == 7.5 && w == 8.5 && x == 9.5); + *out = {55, 49, nested.c}; + return out; +} + +static Result invoke(Outer fn) +{ + return fn(1,2,3,4,5,6,7,8,9,10,1.5,2.5,3.5,4.5,5.5,6.5,7.5,8.5,9.5); +} + +int main() +{ + assert(!hook(nullptr, reinterpret_cast(inner_callback), true)); + assert(hook(reinterpret_cast(inner), reinterpret_cast(inner_callback), true)); + auto original = reinterpret_cast(hook(reinterpret_cast(outer), + reinterpret_cast(outer_callback), true)); + assert(original); + const auto before = invoke(original); + assert(before.a == 55 && before.b == 49 && before.c == 0); + auto exercise = [] { + for (int n = 0; n < 1000; ++n) { + const auto result = invoke(outer); + assert(result.a == 55 && result.b == 49 && result.c == 31); + } + }; + std::thread first(exercise), second(exercise); + first.join(); second.join(); + assert(unhook_checked(reinterpret_cast(outer))); + assert(!unhook_checked(reinterpret_cast(outer))); + assert(unhook_checked(reinterpret_cast(inner))); + const auto after = invoke(outer); + assert(after.a == 55 && after.b == 49 && after.c == 0); + puts("PASS: X8 result, integer/FP/stack args, nested calls, TLS isolation, original call, unhook"); +} diff --git a/app/src/main/java/com/bepinex/android/BootstrapActivity.kt b/app/src/main/java/com/bepinex/android/BootstrapActivity.kt index 07ac6cb3..874c288a 100644 --- a/app/src/main/java/com/bepinex/android/BootstrapActivity.kt +++ b/app/src/main/java/com/bepinex/android/BootstrapActivity.kt @@ -54,6 +54,7 @@ class BootstrapActivity : Activity() { private val fusionInitialized = AtomicBoolean(false) /** Per-process: guard against re-installing base hooks on 2nd launch */ private val baseHooksInstalled = AtomicBoolean(false) + private val bootstrapStarted = AtomicBoolean(false) } private var preparedConfig: FusionConfig? = null @@ -98,6 +99,12 @@ class BootstrapActivity : Activity() { return } + // Also reject duplicate intents/activity recreation before copying assets or + // touching a runtime already mapped by the first bootstrap in this process. + if (!bootstrapStarted.compareAndSet(false, true)) { + failAndFinish("Game runtime already started. Launch again from the launcher.") + return + } BepInExLog.i("=== Bootstrap: $targetPackage ===") GameLogcatCapture.start(targetPackage) @@ -308,6 +315,7 @@ class BootstrapActivity : Activity() { BepInExLog.i("Fusion config staged: ${stagedFile.absolutePath}") } catch (t: Throwable) { BepInExLog.e("Failed to initialize Fusion", t) + throw IllegalStateException("Failed to initialize Fusion", t) } } @@ -479,6 +487,10 @@ class BootstrapActivity : Activity() { } com.bepinex.android.settings.AppSettings.setActiveModpack(this, targetPackage, activeModpack) + // Imported modpacks can carry a core/ directory. Keep the managed bridge + // paired with this APK even after restoring such a pack. + fileExtractor.extractBepInExIfNeeded(targetPackage) + // Register game native libraries (match FusionCore: no exclusions) File(gameLibDir).listFiles()?.forEach { file -> val name = file.name diff --git a/app/src/main/java/com/bepinex/android/FileExtractor.kt b/app/src/main/java/com/bepinex/android/FileExtractor.kt index f2f90e73..58b6ae5a 100644 --- a/app/src/main/java/com/bepinex/android/FileExtractor.kt +++ b/app/src/main/java/com/bepinex/android/FileExtractor.kt @@ -4,6 +4,8 @@ import android.content.Context import java.io.File import java.io.FileOutputStream import java.util.zip.ZipInputStream +import java.security.MessageDigest +import java.util.UUID /** * Extracts BepInEx and .NET runtime from APK assets to per-game directories. @@ -12,26 +14,57 @@ class FileExtractor(private val context: Context) { /** * Extract BepInEx.Android.zip for a specific game. - * Skips if BepInEx.Core.dll marker already exists. + * Match the managed runtime to the APK's native bridge, preserving user files. */ fun extractBepInExIfNeeded(packageName: String, onProgress: (String) -> Unit = {}) { val bepInExDir = BepInExPaths.getBepInExDir(packageName) - val marker = File(bepInExDir, "core/BepInEx.Core.dll") - - if (marker.exists()) { + val core = File(bepInExDir, "core") + val marker = File(core, ".launcher-asset-sha256") + val digest = MessageDigest.getInstance("SHA-256") + context.assets.open("BepInEx.Android.zip").use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + val assetHash = digest.digest().joinToString("") { "%02x".format(it) } + val required = listOf("BepInEx.Core.dll", "BepInEx.Unity.IL2CPP.dll", + "Il2CppInterop.Runtime.dll", "Il2CppInterop.HarmonySupport.dll") + if (marker.isFile && marker.readText() == assetHash && + required.all { File(core, it).isFile }) { BepInExLog.i("BepInEx already extracted for $packageName") return } - // Incomplete extraction — delete and retry - if (bepInExDir.exists()) { - BepInExLog.i("BepInEx dir exists but incomplete — re-extracting for $packageName") - bepInExDir.deleteRecursively() - } - onProgress("Extracting BepInEx for $packageName...") - BepInExLog.i("Extracting BepInEx.Android.zip → ${bepInExDir.absolutePath}") - extractZip("BepInEx.Android.zip", bepInExDir) + bepInExDir.mkdirs() + val staging = File(bepInExDir, ".framework-${UUID.randomUUID()}") + val backup = File(bepInExDir, ".core-backup-${UUID.randomUUID()}") + try { + extractZip("BepInEx.Android.zip", staging) + val newCore = File(staging, "core") + check(required.all { File(newCore, it).isFile }) { "Incomplete BepInEx package" } + File(newCore, marker.name).writeText(assetHash) + val hadCore = core.exists() + check(!hadCore || core.renameTo(backup)) { "Cannot back up BepInEx core" } + if (!newCore.renameTo(core)) { + check(!hadCore || backup.renameTo(core)) { + "Core update failed; backup retained at ${backup.absolutePath}" + } + error("Cannot install BepInEx core") + } + val config = File(bepInExDir, "config/BepInEx.cfg") + if (!config.exists()) { + config.parentFile?.mkdirs() + File(staging, "config/BepInEx.cfg").copyTo(config) + } + File(bepInExDir, "plugins").mkdirs() + backup.deleteRecursively() + } finally { + staging.deleteRecursively() + } BepInExLog.i("BepInEx extracted: ${bepInExDir.absolutePath}") } diff --git a/app/src/main/java/com/bepinex/android/GameProcessLauncher.kt b/app/src/main/java/com/bepinex/android/GameProcessLauncher.kt new file mode 100644 index 00000000..da2fe4cf --- /dev/null +++ b/app/src/main/java/com/bepinex/android/GameProcessLauncher.kt @@ -0,0 +1,59 @@ +package com.bepinex.android + +import android.app.Activity +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.os.Process +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean + +/** Unity, CoreCLR and native detours have process lifetime. Never reuse :game. */ +object GameProcessLauncher { + private val launching = AtomicBoolean(false) + val isRestarting: Boolean get() = launching.get() + + suspend fun launch( + activity: Activity, + packageName: String, + modpackName: String?, + prepareRuntime: suspend () -> Unit = {} + ): Boolean = withContext(Dispatchers.Main.immediate) { + // MainActivity and shortcut launches share this gate in the launcher process. + if (!launching.compareAndSet(false, true)) return@withContext false + try { + withContext(Dispatchers.IO) { + stopPreviousGame(activity.applicationContext) + prepareRuntime() + } + if (activity.isFinishing || activity.isDestroyed) return@withContext false + activity.startActivity(Intent(activity, BootstrapActivity::class.java).apply { + // Discard Android's saved activities for the previous native runtime. + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + putExtra(BootstrapActivity.EXTRA_TARGET_PACKAGE, packageName) + modpackName?.let { putExtra(BootstrapActivity.EXTRA_ACTIVE_MODPACK, it) } + }) + true + } finally { + launching.set(false) + } + } + + private suspend fun stopPreviousGame(context: Context) { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val gameProcessName = "${context.packageName}:game" + fun gameProcesses() = checkNotNull(manager.runningAppProcesses) { + "Cannot inspect the previous game process" + }.filter { it.uid == Process.myUid() && it.processName == gameProcessName } + + // Kill only our own named child, never the launcher or the installed game. + gameProcesses().forEach { Process.killProcess(it.pid) } + repeat(100) { + if (gameProcesses().isEmpty()) return + delay(50) + } + error("Previous game process did not exit; refusing to reuse its runtime") + } +} diff --git a/app/src/main/java/com/bepinex/android/MainActivity.kt b/app/src/main/java/com/bepinex/android/MainActivity.kt index c82a8819..04f6f027 100644 --- a/app/src/main/java/com/bepinex/android/MainActivity.kt +++ b/app/src/main/java/com/bepinex/android/MainActivity.kt @@ -560,16 +560,17 @@ class MainActivity : ComponentActivity() { BepInExLog.i("=== Launching ${game.label} (modpack: ${modpackName ?: "vanilla"}) via BootstrapActivity ===") - try { - val intent = Intent(this, BootstrapActivity::class.java).apply { - putExtra(BootstrapActivity.EXTRA_TARGET_PACKAGE, game.packageName) - modpackName?.let { putExtra(BootstrapActivity.EXTRA_ACTIVE_MODPACK, it) } + scope.launch { + try { + crashMonitorJob?.cancel() + gameProcessAlive = false + if (GameProcessLauncher.launch(this@MainActivity, game.packageName, modpackName)) { + startCrashMonitor(game.packageName) + } + } catch (e: Exception) { + BepInExLog.e("Launch failed", e) + Toast.makeText(this@MainActivity, getString(R.string.launch_failed), Toast.LENGTH_LONG).show() } - startActivity(intent) - startCrashMonitor(game.packageName) - } catch (e: Exception) { - BepInExLog.e("Launch failed", e) - Toast.makeText(this, getString(R.string.launch_failed), Toast.LENGTH_LONG).show() } } @@ -600,6 +601,7 @@ class MainActivity : ComponentActivity() { } private suspend fun reportGameCrash(packageName: String) { + if (GameProcessLauncher.isRestarting) return if (!gameProcessAlive) return gameProcessAlive = false crashMonitorJob?.cancel() diff --git a/app/src/main/java/com/bepinex/android/ShortcutLauncherActivity.kt b/app/src/main/java/com/bepinex/android/ShortcutLauncherActivity.kt index c20a029f..8cad0f50 100644 --- a/app/src/main/java/com/bepinex/android/ShortcutLauncherActivity.kt +++ b/app/src/main/java/com/bepinex/android/ShortcutLauncherActivity.kt @@ -6,12 +6,18 @@ import android.content.Intent import android.os.Bundle import com.bepinex.android.modpack.ModpackManager import com.bepinex.android.settings.AppSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch /** * Transparent activity that handles desktop shortcut intents. * Selects the modpack, applies it, and launches the game. */ class ShortcutLauncherActivity : Activity() { + private val launchScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) companion object { const val EXTRA_SHORTCUT_PACKAGE = "shortcut_package" @@ -44,23 +50,24 @@ class ShortcutLauncherActivity : Activity() { val modpackManager = ModpackManager() val previous = AppSettings.getActiveModpack(this, packageName) - Thread { + launchScope.launch { try { - kotlinx.coroutines.runBlocking { - modpackManager.switchRuntime(packageName, previous, modpackName) + GameProcessLauncher.launch(this@ShortcutLauncherActivity, packageName, modpackName) { + check(modpackManager.switchRuntime(packageName, previous, modpackName)) { + "Failed to switch modpack" + } + AppSettings.setActiveModpack(this@ShortcutLauncherActivity, packageName, modpackName) } - AppSettings.setActiveModpack(this, packageName, modpackName) } catch (error: Exception) { BepInExLog.e("Shortcut modpack switch failed", error) - } - runOnUiThread { - val launchIntent = Intent(this, BootstrapActivity::class.java).apply { - putExtra(BootstrapActivity.EXTRA_TARGET_PACKAGE, packageName) - putExtra(BootstrapActivity.EXTRA_ACTIVE_MODPACK, modpackName) - } - startActivity(launchIntent) + } finally { finish() } - }.start() + } + } + + override fun onDestroy() { + launchScope.cancel() + super.onDestroy() } } diff --git a/ci-version.txt b/ci-version.txt index b9e5dd76..d41de606 100644 --- a/ci-version.txt +++ b/ci-version.txt @@ -1 +1 @@ -540 +539 diff --git a/docs/native-crash-fixes.md b/docs/native-crash-fixes.md new file mode 100644 index 00000000..5eb64f61 --- /dev/null +++ b/docs/native-crash-fixes.md @@ -0,0 +1,52 @@ +# ARM64 return buffer and game process lifetime + +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 +snapshots it at managed callback entry, before a nested hook can overwrite it. +This implements the existing indirect-result path for Il2CppSystem.ValueType +wrappers; it is not a redesign of all aggregate/HFA marshalling. + +Bridge allocation failures fail the managed hook operation instead of installing +an incompatible callback. Each bridge owns an RX page. Successful bridges and +the Unity/IL2CPP libraries remain mapped until process exit, so unhook/unload +cannot invalidate code still being executed by another thread. + +Every launcher/shortcut start terminates the launcher's previous `:game` process +and waits for it to disappear before starting a new game task. Only the current +UID's exact `:game` process name is eligible. Duplicate bootstraps and native +reloads in a surviving process are rejected. Launching again restarts the game; +it does not resume a previous game session. + +The game's original `libmain.so` is loaded locally with Android's force-load +linker flag. Its JNI_OnLoad runs before re-registering the launcher's +NativeLoader.load/unload implementations. The original loader's load function +is never called, which avoids loading a second Unity instance. + +The managed framework asset must be updated together with the native library. +The extractor hashes the packaged ZIP and replaces core via a staged directory, +preserving plugins and config. It checks again after modpack restoration. + +## Validation + +Managed hook lifetime/failure checks, from BepInEx.Android: + +```powershell +dotnet run --project tests/NativeDetour/NativeDetour.Tests.csproj -c Release +``` + +Configure the existing Android CMake build with `-DFUSION_BUILD_TESTS=ON` to +build `return_buffer_test`. This opt-in executable exercises real Dobby hooks: +indirect results, integer/FP/stack arguments, nested callbacks, two native +threads, original trampolines and unhook. It is not shipped in normal APKs. +Run it on an ARM64 Android device with libfusion.so, libmain.so and libdobby.so +in the executable's directory and LD_LIBRARY_PATH pointing there. + +Device acceptance checks still required: + +- Cold launch, exit, launch again and confirm a different :game PID. +- Relaunch while :game survives in the background; launcher PID must survive. +- Desktop shortcut launch and switching modpacks after a previous session. +- Verify original libmain JNI registration succeeds and Fusion still starts. +- Upgrade from an older installed APK; core updates while plugins/config remain. +- Run the native return_buffer_test executable.